diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index cf5230750..e2fb69026 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -731,7 +731,8 @@ function initSchema(db, fileBacked) { max_attempts INTEGER NOT NULL DEFAULT 1, retryable INTEGER NOT NULL DEFAULT 0, evaluated_at TEXT NOT NULL DEFAULT '', - duration_ms INTEGER DEFAULT NULL + duration_ms INTEGER DEFAULT NULL, + cost_micro_usd INTEGER DEFAULT NULL ) `); db.exec(` @@ -936,11 +937,6 @@ function migrateCostUsdToMicroUsd(db) { // 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( @@ -955,6 +951,7 @@ function migrateCostUsdToMicroUsd(db) { UPDATE gate_runs SET cost_micro_usd = CAST(ROUND(cost_usd * 1000000) AS INTEGER) WHERE cost_usd IS NOT NULL + AND cost_micro_usd IS NULL `).run(); } @@ -1551,12 +1548,13 @@ function migrateSchema(db) { rationale TEXT NOT NULL DEFAULT '', findings TEXT NOT NULL DEFAULT '', attempt INTEGER NOT NULL DEFAULT 1, - max_attempts INTEGER NOT NULL DEFAULT 1, - retryable INTEGER NOT NULL DEFAULT 0, - evaluated_at TEXT NOT NULL DEFAULT '', - duration_ms INTEGER DEFAULT NULL - ) - `); + max_attempts INTEGER NOT NULL DEFAULT 1, + retryable INTEGER NOT NULL DEFAULT 0, + evaluated_at TEXT NOT NULL DEFAULT '', + duration_ms INTEGER DEFAULT NULL, + cost_micro_usd INTEGER DEFAULT NULL + ) + `); db.exec(` CREATE TABLE IF NOT EXISTS turn_git_transactions ( trace_id TEXT NOT NULL, diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 562b8b847..51cc83b50 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -10,7 +10,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, test } from "vitest"; -import { closeDatabase, getDatabase, openDatabase } from "../sf-db.js"; +import { + closeDatabase, + getDatabase, + insertGateRun, + openDatabase, +} from "../sf-db.js"; const tmpDirs = []; @@ -131,6 +136,58 @@ function makeLegacyV27Db() { return dbPath; } +function makeLegacyV35GateRunsDb() { + const dir = mkdtempSync(join(tmpdir(), "sf-legacy-v35-gates-")); + tmpDirs.push(dir); + const sfDir = join(dir, ".sf"); + mkdirSync(sfDir, { recursive: true }); + const dbPath = join(sfDir, "sf.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ); + INSERT INTO schema_version (version, applied_at) + VALUES (35, '2026-05-07T00:00:00.000Z'); + + CREATE TABLE gate_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + gate_type TEXT NOT NULL DEFAULT '', + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + outcome TEXT NOT NULL DEFAULT 'pass', + failure_class TEXT NOT NULL DEFAULT 'none', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 1, + retryable INTEGER NOT NULL DEFAULT 0, + evaluated_at TEXT NOT NULL DEFAULT '', + duration_ms INTEGER DEFAULT NULL, + cost_usd REAL DEFAULT NULL + ); + 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, + cost_usd + ) VALUES ( + 'trace-1', 'turn-1', 'cost-gate', 'policy', 'execute-task', + 'M001/S01/T01', 'M001', 'S01', 'T01', 'pass', 'none', 'ok', + '', 1, 1, 0, '2026-05-07T00:00:00.000Z', 12, 0.123456 + ); + `); + db.close(); + return dbPath; +} + test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", () => { const dbPath = makeLegacyV27Db(); @@ -142,7 +199,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", const version = db .prepare("SELECT MAX(version) AS version FROM schema_version") .get(); - assert.equal(version.version, 35); + assert.equal(version.version, 36); const taskSpec = db .prepare( "SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'", @@ -155,3 +212,44 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", verify: "go test ./portal", }); }); + +test("openDatabase_when_fresh_db_supports_gate_run_micro_usd", () => { + assert.equal(openDatabase(":memory:"), true); + + insertGateRun({ + traceId: "trace-1", + turnId: "turn-1", + gateId: "cost-gate", + gateType: "policy", + outcome: "pass", + failureClass: "none", + rationale: "ok", + attempt: 1, + maxAttempts: 1, + retryable: false, + evaluatedAt: "2026-05-07T00:00:00.000Z", + durationMs: 12, + costMicroUsd: 123_456, + }); + + const row = getDatabase() + .prepare("SELECT cost_micro_usd FROM gate_runs WHERE gate_id = 'cost-gate'") + .get(); + assert.equal(row.cost_micro_usd, 123_456); +}); + +test("openDatabase_migrates_v35_gate_cost_usd_to_micro_usd", () => { + const dbPath = makeLegacyV35GateRunsDb(); + + assert.equal(openDatabase(dbPath), true); + const db = getDatabase(); + const columns = db.prepare("PRAGMA table_info(gate_runs)").all(); + assert.ok(columns.some((row) => row.name === "cost_micro_usd")); + const row = db + .prepare( + "SELECT cost_usd, cost_micro_usd FROM gate_runs WHERE gate_id = 'cost-gate'", + ) + .get(); + assert.equal(row.cost_usd, 0.123456); + assert.equal(row.cost_micro_usd, 123_456); +});