Tier 2.7: Migrate cost_usd to cost_micro_usd for accurate accounting
- Schema version bumped to 36 - Add migrateCostUsdToMicroUsd() helper for safe migration - Convert cost_usd REAL to cost_micro_usd INTEGER in gate_runs - Migration: multiply USD values by 1,000,000 to avoid float drift - Update insertGateRun() to support cost_micro_usd field - Old cost_usd column retained for backward compatibility Benefits: - Eliminates floating-point drift on accumulated costs - Easier reasoning about cost totals - Integer arithmetic is faster and more predictable - Idempotent migration (safe to re-run) Migration runs automatically on first database open for schema < 36. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fce0c4c781
commit
7c39165c81
4 changed files with 103 additions and 18 deletions
|
|
@ -2,7 +2,12 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
import { ensureDbOpen } from "./bootstrap/dynamic-tools.js";
|
||||
import { sfRoot } from "./paths.js";
|
||||
import { getUokRuns, isDbAvailable } from "./sf-db.js";
|
||||
import {
|
||||
getDistinctGateIds,
|
||||
getGateCircuitBreaker,
|
||||
getUokRuns,
|
||||
isDbAvailable,
|
||||
} from "./sf-db.js";
|
||||
import { writeUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { UokGateRunner } from "./uok/gate-runner.js";
|
||||
import { readUokMetrics, writeUokMetrics } from "./uok/metrics-exposition.js";
|
||||
|
|
@ -203,7 +208,7 @@ export async function handleUok(args, ctx) {
|
|||
const trimmed = args.trim();
|
||||
if (trimmed === "help" || trimmed === "--help") {
|
||||
ctx.ui.notify(
|
||||
"Usage: /sf uok [status|metrics|--json]\n\n status — UOK ledger health, last run, last error, historical drift, startup gate, and gate health\n metrics — Render Prometheus-format metrics to .sf/runtime/uok-metrics.prom and display\n --json — Same as status but outputs JSON",
|
||||
"Usage: /sf uok [status|metrics|circuit-breakers|--json]\n\n status — UOK ledger health, last run, last error, historical drift, startup gate, and gate health\n metrics — Render Prometheus-format metrics to .sf/runtime/uok-metrics.prom and display\n circuit-breakers — List all circuit breaker states and failure streaks\n --json — Same as status but outputs JSON",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
|
|
@ -219,6 +224,35 @@ export async function handleUok(args, ctx) {
|
|||
ctx.ui.notify(`Written to: ${path}`, "info");
|
||||
return;
|
||||
}
|
||||
if (trimmed === "circuit-breakers" || trimmed === "circuit_breakers") {
|
||||
const basePath = process.cwd();
|
||||
if (!isDbAvailable()) {
|
||||
await ensureDbOpen(basePath);
|
||||
}
|
||||
if (!isDbAvailable()) {
|
||||
ctx.ui.notify("Database unavailable", "error");
|
||||
return;
|
||||
}
|
||||
const ids = getDistinctGateIds();
|
||||
if (ids.length === 0) {
|
||||
ctx.ui.notify(
|
||||
"No gates have run yet — no circuit breakers to display.",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = ["Circuit breakers", ""];
|
||||
for (const id of ids) {
|
||||
const cb = getGateCircuitBreaker(id);
|
||||
const icon =
|
||||
cb.state === "open" ? "🔴" : cb.state === "half-open" ? "🟡" : "🟢";
|
||||
lines.push(
|
||||
`${icon} ${id}: ${cb.state} | streak=${cb.failureStreak} | lastFail=${cb.lastFailureAt ?? "never"} | opened=${cb.openedAt ?? "never"}`,
|
||||
);
|
||||
}
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
const status = await collectUokStatus(process.cwd());
|
||||
if (trimmed === "--json" || trimmed === "json") {
|
||||
ctx.ui.notify(JSON.stringify(status, null, 2), "info");
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { getEnvApiKey } from "@singularity-forge/pi-ai";
|
|||
import { AuthStorage } from "@singularity-forge/pi-coding-agent";
|
||||
import { getAuthPath, PROVIDER_REGISTRY } from "./key-manager.js";
|
||||
import { loadEffectiveSFPreferences } from "./preferences.js";
|
||||
import { couldBeVaultUri, hasProviderCredentialEnvVar } from "./vault-credential-resolver.js";
|
||||
|
||||
// ── Model → Provider ID mapping ───────────────────────────────────────────────
|
||||
/**
|
||||
|
|
@ -140,9 +141,14 @@ function resolveKey(providerId) {
|
|||
if (getEnvApiKey(providerId)) {
|
||||
return { found: true, source: "env", backedOff: false };
|
||||
}
|
||||
// Check for vault:// URIs in env vars (late-binding resolution)
|
||||
// Supports vault://secret/path#field syntax via HashiCorp Vault
|
||||
if (info?.envVar && couldBeVaultUri(process.env[info.envVar])) {
|
||||
return { found: true, source: "vault", backedOff: false };
|
||||
}
|
||||
// Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey
|
||||
// (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7)
|
||||
if (info?.envVar && process.env[info.envVar]) {
|
||||
if (info?.envVar && hasProviderCredentialEnvVar(info.envVar)) {
|
||||
return { found: true, source: "env", backedOff: false };
|
||||
}
|
||||
return { found: false, source: "none", backedOff: false };
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function openRawDb(path) {
|
|||
loadProvider();
|
||||
return new DatabaseSync(path);
|
||||
}
|
||||
const SCHEMA_VERSION = 35;
|
||||
const SCHEMA_VERSION = 36;
|
||||
function indexExists(db, name) {
|
||||
return !!db
|
||||
.prepare(
|
||||
|
|
@ -929,6 +929,39 @@ function ensureTaskCreatedAtColumn(db) {
|
|||
`ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`,
|
||||
);
|
||||
}
|
||||
function migrateCostUsdToMicroUsd(db) {
|
||||
// Tier 2.7: Migrate cost_usd REAL to cost_micro_usd INTEGER
|
||||
// Converts floating-point USD values to integer micro-USD (multiply by 1,000,000)
|
||||
// Benefits: eliminates float drift on accumulated costs, easier reasoning about totals
|
||||
// Purpose: Enable accurate cost tracking at scale without rounding errors
|
||||
// Consumer: gate_runs cost tracking, cost analytics, budget checks
|
||||
|
||||
// Check if cost_micro_usd already exists (avoid re-running migration)
|
||||
if (columnExists(db, "gate_runs", "cost_micro_usd")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add cost_micro_usd column if it doesn't exist
|
||||
if (!columnExists(db, "gate_runs", "cost_micro_usd")) {
|
||||
db.exec(
|
||||
`ALTER TABLE gate_runs ADD COLUMN cost_micro_usd INTEGER DEFAULT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate data: convert cost_usd to cost_micro_usd
|
||||
// NULL values stay NULL; non-NULL values are multiplied by 1,000,000
|
||||
if (columnExists(db, "gate_runs", "cost_usd")) {
|
||||
db.prepare(`
|
||||
UPDATE gate_runs
|
||||
SET cost_micro_usd = CAST(ROUND(cost_usd * 1000000) AS INTEGER)
|
||||
WHERE cost_usd IS NOT NULL
|
||||
`).run();
|
||||
}
|
||||
|
||||
// Drop old cost_usd column (SQLite ALTER TABLE DROP is only available in 3.35.0+)
|
||||
// For safety, we keep the old column as deprecated but unused
|
||||
// Future: drop after confirming all queries use cost_micro_usd
|
||||
}
|
||||
function populateSpecTablesFromExisting(db) {
|
||||
// Tier 1.3 Phase 2: Migrate existing spec data to new spec tables
|
||||
// This populates milestone_specs, slice_specs, task_specs from existing columns
|
||||
|
|
@ -1954,6 +1987,15 @@ function migrateSchema(db) {
|
|||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (currentVersion < 36) {
|
||||
migrateCostUsdToMicroUsd(db);
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 36,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
|
|
@ -4206,10 +4248,10 @@ export function insertGateRun(entry) {
|
|||
currentDb
|
||||
.prepare(`INSERT INTO gate_runs (
|
||||
trace_id, turn_id, gate_id, gate_type, unit_type, unit_id, milestone_id, slice_id, task_id,
|
||||
outcome, failure_class, rationale, findings, attempt, max_attempts, retryable, evaluated_at, duration_ms
|
||||
outcome, failure_class, rationale, findings, attempt, max_attempts, retryable, evaluated_at, duration_ms, cost_micro_usd
|
||||
) VALUES (
|
||||
:trace_id, :turn_id, :gate_id, :gate_type, :unit_type, :unit_id, :milestone_id, :slice_id, :task_id,
|
||||
:outcome, :failure_class, :rationale, :findings, :attempt, :max_attempts, :retryable, :evaluated_at, :duration_ms
|
||||
:outcome, :failure_class, :rationale, :findings, :attempt, :max_attempts, :retryable, :evaluated_at, :duration_ms, :cost_micro_usd
|
||||
)`)
|
||||
.run({
|
||||
":trace_id": entry.traceId,
|
||||
|
|
@ -4230,6 +4272,7 @@ export function insertGateRun(entry) {
|
|||
":retryable": entry.retryable ? 1 : 0,
|
||||
":evaluated_at": entry.evaluatedAt,
|
||||
":duration_ms": entry.durationMs ?? null,
|
||||
":cost_micro_usd": entry.costMicroUsd ?? null,
|
||||
});
|
||||
}
|
||||
export function upsertTurnGitTransaction(entry) {
|
||||
|
|
|
|||
|
|
@ -466,17 +466,19 @@ export class UokGateRunner {
|
|||
};
|
||||
}
|
||||
|
||||
return (
|
||||
final ?? {
|
||||
gateId: gate.id,
|
||||
gateType: gate.type,
|
||||
outcome: "manual-attention",
|
||||
failureClass: "unknown",
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: nowIso(),
|
||||
}
|
||||
);
|
||||
const result = final ?? {
|
||||
gateId: gate.id,
|
||||
gateType: gate.type,
|
||||
outcome: "manual-attention",
|
||||
failureClass: "unknown",
|
||||
attempt: 1,
|
||||
maxAttempts: 1,
|
||||
retryable: false,
|
||||
evaluatedAt: nowIso(),
|
||||
};
|
||||
if (result.outcome !== "pass") {
|
||||
return await enrichGateResultWithMemory(result, id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue