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:
Mikael Hugo 2026-05-07 05:04:35 +02:00
parent fce0c4c781
commit 7c39165c81
4 changed files with 103 additions and 18 deletions

View file

@ -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");

View file

@ -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 };

View file

@ -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) {

View file

@ -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;
}
}