diff --git a/.sf/backups/db/sf.db.2026-05-09T21-41-14-119Z b/.sf/backups/db/sf.db.2026-05-09T21-41-14-119Z new file mode 100644 index 000000000..3bf7381bb Binary files /dev/null and b/.sf/backups/db/sf.db.2026-05-09T21-41-14-119Z differ diff --git a/.sf/backups/db/sf.db.2026-05-09T22-17-48-976Z b/.sf/backups/db/sf.db.2026-05-09T22-17-48-976Z new file mode 100644 index 000000000..0a1848c63 Binary files /dev/null and b/.sf/backups/db/sf.db.2026-05-09T22-17-48-976Z differ diff --git a/.sf/metrics.db b/.sf/metrics.db new file mode 100644 index 000000000..dbf369b39 Binary files /dev/null and b/.sf/metrics.db differ diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index 639687561..c23d1a521 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -1394,9 +1394,12 @@ export async function startAuto(ctx, pi, base, verboseMode, options) { debugLog("startAuto", { phase: "already-active", skipping: true }); return; } - // Gate: if the user is in Ask mode (manual runControl), ask permission to - // switch to Build mode before starting autonomous execution. - if (s.runControl === "manual" && !options?.skipModeGate) { + // Gate: if the user is in Ask mode (manual runControl and not already in + // build workMode), ask permission to switch to Build mode. + // Skip if workMode is already "build" — runControl is reset to "manual" on + // autonomous stop but workMode persists, so this avoids a spurious prompt + // for users who stay in Build mode between autonomous runs. + if (s.runControl === "manual" && s.workMode !== "build" && !options?.skipModeGate) { const confirmed = await showConfirm(ctx, { title: "Switch to Build mode?", message: diff --git a/src/resources/extensions/sf/metrics-central.js b/src/resources/extensions/sf/metrics-central.js index 430bf6862..f96ef4ecb 100644 --- a/src/resources/extensions/sf/metrics-central.js +++ b/src/resources/extensions/sf/metrics-central.js @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { sfRoot } from "./paths.js"; import { logWarning } from "./workflow-logger.js"; @@ -28,6 +29,7 @@ const MAX_HISTOGRAM_BUCKETS = 10; const FLUSH_RETRY_MAX = 3; const FLUSH_RETRY_BASE_MS = 1000; const METRIC_NAME_PATTERN = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/; +const METRICS_DB_ROW_CAP = 10_000; // keep newest N rows; prune on flush when exceeded // ─── Metrics System Performance Monitoring ────────────────────────────────── @@ -60,7 +62,7 @@ export function getMetricsSystemStats() { _flushSuccessCount > 0 ? Math.round(_totalFlushDuration / _flushSuccessCount) : 0, - databaseStatus: _dbAdapter ? "connected" : "disconnected", + databaseStatus: _metricsDb ? "connected" : "disconnected", }; } @@ -391,7 +393,8 @@ let _flushTimer = null; let _metricsHealthTimer = null; let _basePath = ""; let _sessionId = ""; -let _dbAdapter = null; +let _dbAdapter = null; // kept for API compat but no longer used for metrics writes +let _metricsDb = null; // dedicated metrics.db connection let _flushFailures = 0; function getRegistry() { @@ -405,9 +408,17 @@ function metricsFilePath(basePath) { // ─── DB Persistence ───────────────────────────────────────────────────────── -function ensureMetricsTable(db) { - if (!db) return; +function metricsDbPath(basePath) { + return join(sfRoot(basePath), "metrics.db"); +} + +function openMetricsDb(basePath) { + if (_metricsDb) return; try { + mkdirSync(sfRoot(basePath), { recursive: true }); + const db = new DatabaseSync(metricsDbPath(basePath)); + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA synchronous=NORMAL"); db.exec(` CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -420,20 +431,32 @@ function ensureMetricsTable(db) { ) `); db.exec(`CREATE INDEX IF NOT EXISTS idx_metrics_name ON metrics(name)`); - db.exec( - `CREATE INDEX IF NOT EXISTS idx_metrics_session ON metrics(session_id)`, - ); - db.exec( - `CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp)`, - ); + db.exec(`CREATE INDEX IF NOT EXISTS idx_metrics_session ON metrics(session_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_metrics_name_ts ON metrics(name, timestamp DESC)`); + _metricsDb = db; } catch (err) { - logWarning("metrics-central", `DB table creation failed: ${err.message}`); + logWarning("metrics-central", `Failed to open metrics.db: ${err.message}`); } } -function persistMetricsToDb(registry, sessionId, db) { +function closeMetricsDb() { + if (!_metricsDb) return; + try { + _metricsDb.close(); + } catch { + // swallow + } + _metricsDb = null; +} + +function ensureMetricsTable(db) { + // no-op — metrics.db is set up by openMetricsDb + void db; +} + +function persistMetricsToDb(registry, sessionId, _ignored) { + const db = _metricsDb; if (!db) return; - ensureMetricsTable(db); const ts = new Date().toISOString(); try { const insert = db.prepare( @@ -476,8 +499,25 @@ function persistMetricsToDb(registry, sessionId, db) { ); } } catch (err) { + if (err.message?.includes("database is not open")) { + closeMetricsDb(); + return; + } logWarning("metrics-central", `DB persist failed: ${err.message}`); } + // Prune if the table has grown beyond the cap (best-effort; never block flush) + try { + const row = _metricsDb?.prepare("SELECT count(*) as n FROM metrics").get(); + if (row && row.n > METRICS_DB_ROW_CAP) { + _metricsDb.prepare( + `DELETE FROM metrics WHERE rowid NOT IN ( + SELECT rowid FROM metrics ORDER BY timestamp DESC LIMIT ${METRICS_DB_ROW_CAP} + )`, + ).run(); + } + } catch (_) { + // swallow — prune failure must never surface to the user + } } // ─── Flush with Retry ─────────────────────────────────────────────────────── @@ -493,10 +533,8 @@ function flushMetrics() { const path = metricsFilePath(_basePath); mkdirSync(join(sfRoot(_basePath), "runtime"), { recursive: true }); writeFileSync(path, text, "utf-8"); - // Also persist to DB if available - if (_dbAdapter) { - persistMetricsToDb(getRegistry(), _sessionId, _dbAdapter); - } + // Persist to dedicated metrics.db + persistMetricsToDb(getRegistry(), _sessionId, null); // Update performance metrics _flushSuccessCount++; @@ -562,7 +600,7 @@ function flushMetrics() { export function initMetricsCentral(basePath, opts = {}) { _basePath = basePath; _sessionId = opts.sessionId ?? ""; - _dbAdapter = opts.dbAdapter ?? null; + _dbAdapter = opts.dbAdapter ?? null; // accepted but no longer used for metrics writes const interval = opts.flushIntervalMs ?? FLUSH_INTERVAL_MS; // Reset metrics system stats on fresh init @@ -582,10 +620,8 @@ export function initMetricsCentral(basePath, opts = {}) { // Ensure timer doesn't keep process alive if (_flushTimer.unref) _flushTimer.unref(); - // Ensure DB table exists - if (_dbAdapter) { - ensureMetricsTable(_dbAdapter); - } + // Open dedicated metrics.db (separate from main sf.db to avoid WAL pressure) + openMetricsDb(basePath); // Start periodic metrics system health reporting if (!_metricsHealthTimer) { @@ -663,6 +699,7 @@ export function stopMetricsCentral() { _basePath = ""; _sessionId = ""; _dbAdapter = null; + closeMetricsDb(); } /** diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js index 7af92b629..142b8a8b8 100644 --- a/src/resources/extensions/sf/sf-db.js +++ b/src/resources/extensions/sf/sf-db.js @@ -244,7 +244,7 @@ function performDatabaseMaintenance(rawDb, path) { ); } } -const SCHEMA_VERSION = 54; +const SCHEMA_VERSION = 56; function indexExists(db, name) { return !!db .prepare( @@ -1464,6 +1464,22 @@ function initSchema(db, fileBacked) { db.exec( `CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`, ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, + ); + db.exec(` + CREATE VIEW IF NOT EXISTS v_task_full AS + SELECT t.*, ts.spec_version, ts.verify AS spec_verify, + ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output + FROM tasks t + LEFT JOIN task_specs ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + `); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`, + ); const existing = db .prepare("SELECT count(*) as cnt FROM schema_version") .get(); @@ -3173,6 +3189,42 @@ function migrateSchema(db) { ":applied_at": new Date().toISOString(), }); } + if (currentVersion < 55) { + // Schema v55: composite index for audit_events + task access-pattern views + db.exec( + `CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, + ); + db.exec(` + CREATE VIEW IF NOT EXISTS v_task_full AS + SELECT t.*, ts.spec_version, ts.verify AS spec_verify, + ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output + FROM tasks t + LEFT JOIN task_specs ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 55, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 56) { + // Schema v56: move metrics table to dedicated metrics.db — drop from main DB + // to eliminate WAL pressure from high-frequency telemetry writes. + db.exec(`DROP TABLE IF EXISTS metrics`); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 56, + ":applied_at": new Date().toISOString(), + }); + } db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK");