diff --git a/src/resources/extensions/sf/commands-uok.js b/src/resources/extensions/sf/commands-uok.js index a39ca5100..0540a1e3e 100644 --- a/src/resources/extensions/sf/commands-uok.js +++ b/src/resources/extensions/sf/commands-uok.js @@ -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"); diff --git a/src/resources/extensions/sf/doctor-providers.js b/src/resources/extensions/sf/doctor-providers.js index f5919d274..10b5bd774 100644 --- a/src/resources/extensions/sf/doctor-providers.js +++ b/src/resources/extensions/sf/doctor-providers.js @@ -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 }; diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 992d35e8b..cf5230750 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -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) { diff --git a/src/resources/extensions/sf/uok/gate-runner.js b/src/resources/extensions/sf/uok/gate-runner.js index a96f93bee..3b7b7db6e 100644 --- a/src/resources/extensions/sf/uok/gate-runner.js +++ b/src/resources/extensions/sf/uok/gate-runner.js @@ -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; } }