@devflow-tools/database 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/database.ts CHANGED
@@ -1,11 +1,112 @@
1
1
  import { NodeSqliteDatabase } from './node-sqlite';
2
2
  import { join, dirname } from 'path';
3
3
  import { existsSync, mkdirSync } from 'fs';
4
+ import { homedir } from 'os';
5
+
6
+ export interface BenchmarkReportRecord {
7
+ runId: string;
8
+ suiteId: string;
9
+ suiteVersion: string;
10
+ projectRoot: string;
11
+ commit: string | null;
12
+ createdAt: number;
13
+ status: 'completed';
14
+ tasks: unknown[];
15
+ }
16
+
17
+ export interface BenchmarkReportMetaRecord {
18
+ runId: string;
19
+ suiteId: string;
20
+ suiteVersion: string;
21
+ projectRoot: string;
22
+ commit: string | null;
23
+ taskCount: number;
24
+ createdAt: number;
25
+ status: 'completed';
26
+ }
27
+
28
+ export interface FeedbackRecord {
29
+ id: string;
30
+ projectRoot: string;
31
+ query: string;
32
+ rating: 'hit' | 'partial' | 'miss';
33
+ taskType?: string;
34
+ contextMode?: 'general' | 'task';
35
+ runId?: string;
36
+ tokenCount?: number;
37
+ createdAt: number;
38
+ }
39
+
40
+ export interface TelemetryFailureRecord {
41
+ id: string;
42
+ operation: string;
43
+ payload: unknown;
44
+ error: string;
45
+ createdAt: number;
46
+ resolvedAt?: number;
47
+ }
48
+
49
+ export interface GovernanceRuleRecord {
50
+ id: string;
51
+ name: string;
52
+ gate: 1 | 2 | 3 | 4;
53
+ level: 'constitutional' | 'project' | 'session';
54
+ condition: Record<string, unknown>;
55
+ action: 'deny' | 'warn' | 'allow';
56
+ message: string;
57
+ enabled: boolean;
58
+ createdAt?: number;
59
+ updatedAt?: number;
60
+ }
61
+
62
+ export interface GovernanceAuditRecord {
63
+ seq: number;
64
+ ts: number;
65
+ tool: string;
66
+ argsHash: string;
67
+ decision: 'allow' | 'deny' | 'warn';
68
+ ruleId: string | null;
69
+ sessionId: string;
70
+ prevHash: string;
71
+ signature: string;
72
+ }
73
+
74
+ export interface HookReceiptRecord {
75
+ projectRoot: string;
76
+ lastMcpCall?: number;
77
+ bypassCount?: number;
78
+ updatedAt: number;
79
+ }
80
+
81
+ export interface MemoryDistillCheckpointRecord {
82
+ id: string;
83
+ projectRoot: string;
84
+ sessionId?: string;
85
+ trigger: 'pre_compact' | 'session_end';
86
+ pendingEvents: number;
87
+ releasedLeases: number;
88
+ createdAt: number;
89
+ }
90
+
91
+ export function getGlobalDevFlowDbPath(home = homedir()): string {
92
+ const stateDir = process.env.DEVFLOW_STATE_DIR ?? join(home, '.devflow', 'global');
93
+ return join(stateDir, 'devflow.db');
94
+ }
95
+
96
+ export function openGlobalDevFlowDatabase(
97
+ home = homedir(),
98
+ options?: { busyTimeoutMs?: number },
99
+ ): DevFlowDatabase {
100
+ return new DevFlowDatabase(home, {
101
+ dbPath: getGlobalDevFlowDbPath(home),
102
+ busyTimeoutMs: options?.busyTimeoutMs,
103
+ });
104
+ }
4
105
 
5
106
  export class DevFlowDatabase {
6
107
  private db: NodeSqliteDatabase;
7
108
 
8
- constructor(projectRoot: string, opts?: { dbPath?: string }) {
109
+ constructor(projectRoot: string, opts?: { dbPath?: string; busyTimeoutMs?: number }) {
9
110
  let dbPath: string;
10
111
  if (opts?.dbPath) {
11
112
  const dir = dirname(opts.dbPath);
@@ -16,10 +117,10 @@ export class DevFlowDatabase {
16
117
  if (!existsSync(devflowDir)) mkdirSync(devflowDir, { recursive: true });
17
118
  dbPath = join(devflowDir, 'devflow.db');
18
119
  }
19
- this.db = new NodeSqliteDatabase(dbPath);
120
+ this.db = new NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs);
20
121
 
21
122
  this.db.exec('PRAGMA journal_mode = WAL');
22
- this.db.exec('PRAGMA busy_timeout = 5000');
123
+ this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts?.busyTimeoutMs ?? 5000))}`);
23
124
  this.db.exec('PRAGMA foreign_keys = OFF');
24
125
 
25
126
  this.initializeSchema();
@@ -123,7 +224,8 @@ export class DevFlowDatabase {
123
224
  subagent_id TEXT,
124
225
  error TEXT,
125
226
  blocked INTEGER DEFAULT 0 CHECK (blocked IN (0, 1)),
126
- block_reason TEXT
227
+ block_reason TEXT,
228
+ failure_category TEXT
127
229
  );
128
230
 
129
231
  CREATE INDEX IF NOT EXISTS idx_executions_skill ON skill_executions(skill_name);
@@ -151,6 +253,80 @@ export class DevFlowDatabase {
151
253
 
152
254
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root);
153
255
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
256
+
257
+ CREATE TABLE IF NOT EXISTS feedback (
258
+ id TEXT PRIMARY KEY,
259
+ project_root TEXT NOT NULL,
260
+ query TEXT NOT NULL,
261
+ rating TEXT NOT NULL CHECK (rating IN ('hit', 'partial', 'miss')),
262
+ task_type TEXT,
263
+ context_mode TEXT,
264
+ run_id TEXT,
265
+ token_count INTEGER DEFAULT 0,
266
+ created_at INTEGER NOT NULL
267
+ );
268
+
269
+ CREATE INDEX IF NOT EXISTS idx_feedback_project ON feedback(project_root);
270
+ CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at DESC);
271
+
272
+ CREATE TABLE IF NOT EXISTS telemetry_failures (
273
+ id TEXT PRIMARY KEY,
274
+ operation TEXT NOT NULL,
275
+ payload TEXT,
276
+ error TEXT NOT NULL,
277
+ created_at INTEGER NOT NULL,
278
+ resolved_at INTEGER
279
+ );
280
+
281
+ CREATE INDEX IF NOT EXISTS idx_telemetry_failures_created ON telemetry_failures(created_at DESC);
282
+ CREATE INDEX IF NOT EXISTS idx_telemetry_failures_unresolved ON telemetry_failures(resolved_at) WHERE resolved_at IS NULL;
283
+
284
+ CREATE TABLE IF NOT EXISTS devflow_rules (
285
+ id TEXT PRIMARY KEY,
286
+ name TEXT NOT NULL,
287
+ level TEXT NOT NULL DEFAULT 'project',
288
+ gate INTEGER NOT NULL DEFAULT 1,
289
+ condition_json TEXT NOT NULL,
290
+ action TEXT NOT NULL DEFAULT 'warn',
291
+ message TEXT NOT NULL,
292
+ enabled INTEGER NOT NULL DEFAULT 1,
293
+ created_at INTEGER NOT NULL,
294
+ updated_at INTEGER NOT NULL
295
+ );
296
+
297
+ CREATE TABLE IF NOT EXISTS devflow_audit_chain (
298
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
299
+ ts INTEGER NOT NULL,
300
+ tool TEXT NOT NULL,
301
+ args_hash TEXT NOT NULL,
302
+ decision TEXT NOT NULL,
303
+ rule_id TEXT,
304
+ session_id TEXT NOT NULL,
305
+ prev_hash TEXT NOT NULL,
306
+ signature TEXT NOT NULL
307
+ );
308
+
309
+ CREATE INDEX IF NOT EXISTS idx_devflow_audit_ts ON devflow_audit_chain(ts DESC);
310
+
311
+ CREATE TABLE IF NOT EXISTS devflow_hook_receipts (
312
+ project_root TEXT PRIMARY KEY,
313
+ last_mcp_call INTEGER,
314
+ bypass_count INTEGER NOT NULL DEFAULT 0,
315
+ updated_at INTEGER NOT NULL
316
+ );
317
+
318
+ CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
319
+ id TEXT PRIMARY KEY,
320
+ project_root TEXT NOT NULL,
321
+ session_id TEXT,
322
+ trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
323
+ pending_events INTEGER NOT NULL DEFAULT 0,
324
+ released_leases INTEGER NOT NULL DEFAULT 0,
325
+ created_at INTEGER NOT NULL
326
+ );
327
+
328
+ CREATE INDEX IF NOT EXISTS idx_distill_checkpoints_project
329
+ ON devflow_memory_distill_checkpoints(project_root, created_at DESC);
154
330
  `);
155
331
 
156
332
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
@@ -163,6 +339,11 @@ export class DevFlowDatabase {
163
339
  try { this.db.exec("ALTER TABLE tool_call_events ADD COLUMN kind TEXT DEFAULT 'tool_use'"); } catch {}
164
340
  // Migration: workflow run persistence
165
341
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN workflow_run_id TEXT'); } catch {}
342
+ try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN failure_category TEXT'); } catch {}
343
+ try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN gate INTEGER NOT NULL DEFAULT 1'); } catch {}
344
+ try { this.db.exec('ALTER TABLE devflow_rules ADD COLUMN updated_at INTEGER'); } catch {}
345
+ this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
346
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_devflow_rules_gate ON devflow_rules(gate, enabled)');
166
347
 
167
348
  // Migration: tool_metrics table for per-tool metrics collection
168
349
  try {
@@ -211,6 +392,28 @@ export class DevFlowDatabase {
211
392
  `);
212
393
  } catch {}
213
394
 
395
+ // Canonical benchmark reports live in the global DevFlow database.
396
+ try {
397
+ this.db.exec(`
398
+ CREATE TABLE IF NOT EXISTS benchmark_reports (
399
+ run_id TEXT PRIMARY KEY,
400
+ suite_id TEXT NOT NULL,
401
+ suite_version TEXT NOT NULL,
402
+ project_root TEXT NOT NULL,
403
+ commit_sha TEXT,
404
+ task_count INTEGER NOT NULL,
405
+ status TEXT NOT NULL CHECK(status IN ('completed')),
406
+ report_json TEXT NOT NULL,
407
+ created_at INTEGER NOT NULL
408
+ );
409
+
410
+ CREATE INDEX IF NOT EXISTS idx_benchmark_reports_created
411
+ ON benchmark_reports(created_at DESC);
412
+ CREATE INDEX IF NOT EXISTS idx_benchmark_reports_suite
413
+ ON benchmark_reports(suite_id, suite_version, created_at DESC);
414
+ `);
415
+ } catch {}
416
+
214
417
  // Migration: logging system views for agent queries
215
418
  try {
216
419
  this.db.exec(`
@@ -259,7 +462,7 @@ export class DevFlowDatabase {
259
462
 
260
463
  insertRun(run: any): void {
261
464
  this.db.prepare(`
262
- INSERT INTO runs (id, source, tool, input, status, started_at, finished_at, token_used, metadata, created_at)
465
+ INSERT OR IGNORE INTO runs (id, source, tool, input, status, started_at, finished_at, token_used, metadata, created_at)
263
466
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
264
467
  `).run(
265
468
  run.id,
@@ -535,6 +738,7 @@ export class DevFlowDatabase {
535
738
  error: row.error,
536
739
  blocked: row.blocked === 1,
537
740
  blockReason: row.block_reason,
741
+ failureCategory: row.failure_category,
538
742
  tokensUsed: row.tokens_used ?? 0,
539
743
  }));
540
744
  }
@@ -570,6 +774,7 @@ export class DevFlowDatabase {
570
774
  error: row.error,
571
775
  blocked: row.blocked === 1,
572
776
  blockReason: row.block_reason,
777
+ failureCategory: row.failure_category,
573
778
  tokensUsed: row.tokens_used ?? 0,
574
779
  });
575
780
  }
@@ -588,7 +793,7 @@ export class DevFlowDatabase {
588
793
  availableMcpTools?: string[];
589
794
  }): void {
590
795
  this.db.prepare(`
591
- INSERT INTO skill_executions
796
+ INSERT OR IGNORE INTO skill_executions
592
797
  (execution_id, session_id, skill_name, started_at, status, required_mcp_tools, available_mcp_tools)
593
798
  VALUES (?, ?, ?, ?, ?, ?, ?)
594
799
  `).run(
@@ -707,50 +912,59 @@ export class DevFlowDatabase {
707
912
  blocked: boolean;
708
913
  blockReason?: string;
709
914
  workflowRunId?: string;
710
- }): void {
711
- this.db.prepare(`
712
- INSERT INTO tool_call_events
713
- (event_id, execution_id, session_id, timestamp, tool_name, tool_type, is_mcp_tool,
714
- mcp_tool_name, mcp_enforced, mcp_fallback, kind, input, output, duration,
715
- parent_tool_call_id, subagent_id, error, blocked, block_reason, workflow_run_id)
716
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
717
- `).run(
718
- params.eventId,
719
- params.executionId || null,
720
- params.sessionId ?? null,
721
- params.timestamp,
722
- params.toolName,
723
- params.toolType,
724
- params.isMcpTool ? 1 : 0,
725
- params.mcpToolName || null,
726
- params.mcpEnforced ? 1 : 0,
727
- params.mcpFallback ? 1 : 0,
728
- params.kind ?? 'tool_use',
729
- JSON.stringify(params.input) ?? null,
730
- params.output ? JSON.stringify(params.output) : null,
731
- params.duration,
732
- params.parentToolCallId || null,
733
- params.subagentId || null,
734
- params.error || null,
735
- params.blocked ? 1 : 0,
736
- params.blockReason || null,
737
- params.workflowRunId || null,
738
- );
915
+ failureCategory?: string;
916
+ tokensUsed?: number;
917
+ }): boolean {
918
+ return this.db.transaction(() => {
919
+ const result = this.db.prepare(`
920
+ INSERT OR IGNORE INTO tool_call_events
921
+ (event_id, execution_id, session_id, timestamp, tool_name, tool_type, is_mcp_tool,
922
+ mcp_tool_name, mcp_enforced, mcp_fallback, kind, input, output, tokens_used, duration,
923
+ parent_tool_call_id, subagent_id, error, blocked, block_reason, workflow_run_id,
924
+ failure_category)
925
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
926
+ `).run(
927
+ params.eventId,
928
+ params.executionId || '',
929
+ params.sessionId ?? null,
930
+ params.timestamp,
931
+ params.toolName,
932
+ params.toolType,
933
+ params.isMcpTool ? 1 : 0,
934
+ params.mcpToolName || null,
935
+ params.mcpEnforced ? 1 : 0,
936
+ params.mcpFallback ? 1 : 0,
937
+ params.kind ?? 'tool_use',
938
+ JSON.stringify(params.input) ?? null,
939
+ params.output !== undefined ? JSON.stringify(params.output) : null,
940
+ params.tokensUsed ?? 0,
941
+ params.duration,
942
+ params.parentToolCallId || null,
943
+ params.subagentId || null,
944
+ params.error || null,
945
+ params.blocked ? 1 : 0,
946
+ params.blockReason || null,
947
+ params.workflowRunId || null,
948
+ params.failureCategory || null,
949
+ );
739
950
 
740
- // Update execution summary counters
741
- this.db.prepare(`
742
- UPDATE skill_executions
743
- SET total_tool_calls = total_tool_calls + 1,
744
- mcp_tool_calls = mcp_tool_calls + ?,
745
- direct_tool_calls = direct_tool_calls + ?,
746
- subagent_count = subagent_count + ?
747
- WHERE execution_id = ?
748
- `).run(
749
- params.isMcpTool ? 1 : 0,
750
- params.isMcpTool ? 0 : 1,
751
- params.toolType === 'subagent' ? 1 : 0,
752
- params.executionId,
753
- );
951
+ if (result.changes === 0) return false;
952
+
953
+ this.db.prepare(`
954
+ UPDATE skill_executions
955
+ SET total_tool_calls = total_tool_calls + 1,
956
+ mcp_tool_calls = mcp_tool_calls + ?,
957
+ direct_tool_calls = direct_tool_calls + ?,
958
+ subagent_count = subagent_count + ?
959
+ WHERE execution_id = ?
960
+ `).run(
961
+ params.isMcpTool ? 1 : 0,
962
+ params.isMcpTool ? 0 : 1,
963
+ params.toolType === 'subagent' ? 1 : 0,
964
+ params.executionId,
965
+ );
966
+ return true;
967
+ });
754
968
  }
755
969
 
756
970
  listToolCallEvents(executionId: string): any[] {
@@ -778,10 +992,11 @@ export class DevFlowDatabase {
778
992
  error: row.error,
779
993
  blocked: row.blocked === 1,
780
994
  blockReason: row.block_reason,
995
+ failureCategory: row.failure_category,
781
996
  }));
782
997
  }
783
998
 
784
- updateToolCallEvent(eventId: string, updates: { output?: string; error?: string; duration?: number }): void {
999
+ updateToolCallEvent(eventId: string, updates: { output?: string; error?: string; duration?: number }): boolean {
785
1000
  const sets: string[] = [];
786
1001
  const values: any[] = [];
787
1002
 
@@ -800,10 +1015,12 @@ export class DevFlowDatabase {
800
1015
 
801
1016
  if (sets.length > 0) {
802
1017
  values.push(eventId);
803
- this.db.prepare(
1018
+ const result = this.db.prepare(
804
1019
  `UPDATE tool_call_events SET ${sets.join(', ')} WHERE event_id = ?`
805
1020
  ).run(...values);
1021
+ return result.changes > 0;
806
1022
  }
1023
+ return false;
807
1024
  }
808
1025
 
809
1026
  // ---- MCP Compliance ----
@@ -884,7 +1101,7 @@ export class DevFlowDatabase {
884
1101
  createdAt: number;
885
1102
  }): void {
886
1103
  this.db.prepare(`
887
- INSERT OR REPLACE INTO tool_metrics (id, tool_call_event_id, session_id, tool_name, query, status, result_count, result_size_bytes, latency_ms, engine, accuracy_query_id, metadata, created_at)
1104
+ INSERT OR IGNORE INTO tool_metrics (id, tool_call_event_id, session_id, tool_name, query, status, result_count, result_size_bytes, latency_ms, engine, accuracy_query_id, metadata, created_at)
888
1105
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
889
1106
  `).run(
890
1107
  metric.id, metric.toolCallEventId, metric.sessionId, metric.toolName,
@@ -928,6 +1145,153 @@ export class DevFlowDatabase {
928
1145
  }));
929
1146
  }
930
1147
 
1148
+ aggregatePendingToolMetrics(limit = 1000): number {
1149
+ const rows = this.db.prepare(`
1150
+ SELECT e.*
1151
+ FROM tool_call_events e
1152
+ LEFT JOIN tool_metrics m ON m.tool_call_event_id = e.event_id
1153
+ WHERE m.id IS NULL AND e.output IS NOT NULL
1154
+ ORDER BY e.timestamp ASC
1155
+ LIMIT ?
1156
+ `).all(limit) as any[];
1157
+
1158
+ let inserted = 0;
1159
+ this.db.transaction(() => {
1160
+ for (const row of rows) {
1161
+ const input = parseJson(row.input);
1162
+ const output = parseJson(row.output);
1163
+ const toolName = row.mcp_tool_name || row.tool_name;
1164
+ const resultCount = countTelemetryResults(output);
1165
+ const resultSizeBytes = Buffer.byteLength(row.output ?? '', 'utf8');
1166
+ const status = row.error || row.blocked
1167
+ ? 'error'
1168
+ : resultCount === 0 ? 'empty' : 'success';
1169
+ const result = this.db.prepare(`
1170
+ INSERT OR IGNORE INTO tool_metrics
1171
+ (id, tool_call_event_id, session_id, tool_name, query, status, result_count,
1172
+ result_size_bytes, latency_ms, engine, metadata, created_at)
1173
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1174
+ `).run(
1175
+ `metric:${row.event_id}`,
1176
+ row.event_id,
1177
+ row.session_id || '',
1178
+ toolName,
1179
+ deriveQuery(input),
1180
+ status,
1181
+ resultCount,
1182
+ resultSizeBytes,
1183
+ row.duration ?? 0,
1184
+ deriveEngine(toolName),
1185
+ JSON.stringify({ executionId: row.execution_id, kind: row.kind }),
1186
+ row.timestamp,
1187
+ );
1188
+ inserted += result.changes;
1189
+ }
1190
+ });
1191
+ return inserted;
1192
+ }
1193
+
1194
+ // ---- Context Feedback ----
1195
+
1196
+ insertFeedback(feedback: FeedbackRecord): void {
1197
+ this.db.prepare(`
1198
+ INSERT OR IGNORE INTO feedback
1199
+ (id, project_root, query, rating, task_type, context_mode, run_id, token_count, created_at)
1200
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1201
+ `).run(
1202
+ feedback.id,
1203
+ feedback.projectRoot,
1204
+ feedback.query,
1205
+ feedback.rating,
1206
+ feedback.taskType ?? null,
1207
+ feedback.contextMode ?? null,
1208
+ feedback.runId ?? null,
1209
+ feedback.tokenCount ?? 0,
1210
+ feedback.createdAt,
1211
+ );
1212
+ }
1213
+
1214
+ listFeedback(projectRoot?: string, limit = 100): FeedbackRecord[] {
1215
+ const rows = projectRoot
1216
+ ? this.db.prepare('SELECT * FROM feedback WHERE project_root = ? ORDER BY created_at DESC LIMIT ?').all(projectRoot, limit)
1217
+ : this.db.prepare('SELECT * FROM feedback ORDER BY created_at DESC LIMIT ?').all(limit);
1218
+ return (rows as any[]).map(row => ({
1219
+ id: row.id,
1220
+ projectRoot: row.project_root,
1221
+ query: row.query,
1222
+ rating: row.rating,
1223
+ taskType: row.task_type ?? undefined,
1224
+ contextMode: row.context_mode ?? undefined,
1225
+ runId: row.run_id ?? undefined,
1226
+ tokenCount: row.token_count ?? 0,
1227
+ createdAt: row.created_at,
1228
+ }));
1229
+ }
1230
+
1231
+ getFeedbackSummary(projectRoot?: string): { total: number; hits: number; partials: number; misses: number; hitRate: number } {
1232
+ const row = (projectRoot
1233
+ ? this.db.prepare(`SELECT COUNT(*) total, SUM(rating = 'hit') hits, SUM(rating = 'partial') partials, SUM(rating = 'miss') misses FROM feedback WHERE project_root = ?`).get(projectRoot)
1234
+ : this.db.prepare(`SELECT COUNT(*) total, SUM(rating = 'hit') hits, SUM(rating = 'partial') partials, SUM(rating = 'miss') misses FROM feedback`).get()) as any;
1235
+ const total = Number(row?.total ?? 0);
1236
+ const hits = Number(row?.hits ?? 0);
1237
+ const partials = Number(row?.partials ?? 0);
1238
+ const misses = Number(row?.misses ?? 0);
1239
+ return { total, hits, partials, misses, hitRate: total > 0 ? hits / total : 0 };
1240
+ }
1241
+
1242
+ // ---- Telemetry Failures ----
1243
+
1244
+ insertTelemetryFailure(failure: TelemetryFailureRecord): void {
1245
+ this.db.prepare(`
1246
+ INSERT OR IGNORE INTO telemetry_failures
1247
+ (id, operation, payload, error, created_at, resolved_at)
1248
+ VALUES (?, ?, ?, ?, ?, ?)
1249
+ `).run(
1250
+ failure.id,
1251
+ failure.operation,
1252
+ JSON.stringify(failure.payload) ?? null,
1253
+ failure.error,
1254
+ failure.createdAt,
1255
+ failure.resolvedAt ?? null,
1256
+ );
1257
+ }
1258
+
1259
+ resolveTelemetryFailure(id: string, resolvedAt = Date.now()): void {
1260
+ this.db.prepare('UPDATE telemetry_failures SET resolved_at = ? WHERE id = ?').run(resolvedAt, id);
1261
+ }
1262
+
1263
+ listTelemetryFailures(options?: { unresolvedOnly?: boolean; limit?: number }): TelemetryFailureRecord[] {
1264
+ const where = options?.unresolvedOnly ? 'WHERE resolved_at IS NULL' : '';
1265
+ const rows = this.db.prepare(`SELECT * FROM telemetry_failures ${where} ORDER BY created_at DESC LIMIT ?`)
1266
+ .all(options?.limit ?? 100) as any[];
1267
+ return rows.map(row => ({
1268
+ id: row.id,
1269
+ operation: row.operation,
1270
+ payload: parseJson(row.payload),
1271
+ error: row.error,
1272
+ createdAt: row.created_at,
1273
+ resolvedAt: row.resolved_at ?? undefined,
1274
+ }));
1275
+ }
1276
+
1277
+ countTelemetryFailures(unresolvedOnly = false): number {
1278
+ const row = this.db.prepare(`SELECT COUNT(*) count FROM telemetry_failures ${unresolvedOnly ? 'WHERE resolved_at IS NULL' : ''}`).get() as any;
1279
+ return Number(row?.count ?? 0);
1280
+ }
1281
+
1282
+ trimTelemetryFailures(maxRows = 500): number {
1283
+ const limit = Math.max(1, Math.floor(maxRows));
1284
+ const result = this.db.prepare(`
1285
+ DELETE FROM telemetry_failures
1286
+ WHERE id IN (
1287
+ SELECT id FROM telemetry_failures
1288
+ ORDER BY created_at DESC, id DESC
1289
+ LIMIT -1 OFFSET ?
1290
+ )
1291
+ `).run(limit);
1292
+ return Number(result.changes);
1293
+ }
1294
+
931
1295
  // ---- Accuracy Queries ----
932
1296
 
933
1297
  insertAccuracyQuery(q: {
@@ -992,6 +1356,266 @@ export class DevFlowDatabase {
992
1356
  return statsByEngine;
993
1357
  }
994
1358
 
1359
+ // ---- Benchmark Reports ----
1360
+
1361
+ insertBenchmarkReport<T extends BenchmarkReportRecord>(report: T): void {
1362
+ this.db.prepare(`
1363
+ INSERT INTO benchmark_reports
1364
+ (run_id, suite_id, suite_version, project_root, commit_sha, task_count, status, report_json, created_at)
1365
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1366
+ ON CONFLICT(run_id) DO UPDATE SET
1367
+ suite_id = excluded.suite_id,
1368
+ suite_version = excluded.suite_version,
1369
+ project_root = excluded.project_root,
1370
+ commit_sha = excluded.commit_sha,
1371
+ task_count = excluded.task_count,
1372
+ status = excluded.status,
1373
+ report_json = excluded.report_json,
1374
+ created_at = excluded.created_at
1375
+ `).run(
1376
+ report.runId,
1377
+ report.suiteId,
1378
+ report.suiteVersion,
1379
+ report.projectRoot,
1380
+ report.commit,
1381
+ report.tasks.length,
1382
+ report.status,
1383
+ JSON.stringify(report),
1384
+ report.createdAt,
1385
+ );
1386
+ }
1387
+
1388
+ getBenchmarkReport<T extends BenchmarkReportRecord = BenchmarkReportRecord>(runId: string): T | null {
1389
+ const row = this.db.prepare(
1390
+ 'SELECT report_json FROM benchmark_reports WHERE run_id = ?',
1391
+ ).get(runId) as { report_json?: string } | undefined;
1392
+ return row?.report_json
1393
+ ? JSON.parse(row.report_json) as T
1394
+ : null;
1395
+ }
1396
+
1397
+ getLatestBenchmarkReport<T extends BenchmarkReportRecord = BenchmarkReportRecord>(suiteId?: string): T | null {
1398
+ const row = (suiteId
1399
+ ? this.db.prepare(
1400
+ 'SELECT report_json FROM benchmark_reports WHERE suite_id = ? ORDER BY created_at DESC LIMIT 1',
1401
+ ).get(suiteId)
1402
+ : this.db.prepare(
1403
+ 'SELECT report_json FROM benchmark_reports ORDER BY created_at DESC LIMIT 1',
1404
+ ).get()) as { report_json?: string } | undefined;
1405
+ return row?.report_json
1406
+ ? JSON.parse(row.report_json) as T
1407
+ : null;
1408
+ }
1409
+
1410
+ listBenchmarkReports(limit = 50): BenchmarkReportMetaRecord[] {
1411
+ const rows = this.db.prepare(`
1412
+ SELECT run_id, suite_id, suite_version, project_root, commit_sha,
1413
+ task_count, status, created_at
1414
+ FROM benchmark_reports
1415
+ ORDER BY created_at DESC
1416
+ LIMIT ?
1417
+ `).all(Math.max(1, Math.min(limit, 500))) as Array<{
1418
+ run_id: string;
1419
+ suite_id: string;
1420
+ suite_version: string;
1421
+ project_root: string;
1422
+ commit_sha: string | null;
1423
+ task_count: number;
1424
+ status: 'completed';
1425
+ created_at: number;
1426
+ }>;
1427
+ return rows.map((row) => ({
1428
+ runId: row.run_id,
1429
+ suiteId: row.suite_id,
1430
+ suiteVersion: row.suite_version,
1431
+ projectRoot: row.project_root,
1432
+ commit: row.commit_sha,
1433
+ taskCount: row.task_count,
1434
+ createdAt: row.created_at,
1435
+ status: row.status,
1436
+ }));
1437
+ }
1438
+
1439
+ // ---- Enforcer Governance ----
1440
+
1441
+ listGovernanceRules(includeDisabled = true): GovernanceRuleRecord[] {
1442
+ const where = includeDisabled ? '' : 'WHERE enabled = 1';
1443
+ const rows = this.db.prepare(`
1444
+ SELECT id, name, gate, level, condition_json, action, message,
1445
+ enabled, created_at, updated_at
1446
+ FROM devflow_rules ${where}
1447
+ ORDER BY gate, id
1448
+ `).all() as any[];
1449
+ return rows.map(row => ({
1450
+ id: row.id,
1451
+ name: row.name,
1452
+ gate: row.gate,
1453
+ level: row.level,
1454
+ condition: parseJson(row.condition_json) as Record<string, unknown>,
1455
+ action: row.action,
1456
+ message: row.message,
1457
+ enabled: row.enabled === 1,
1458
+ createdAt: row.created_at,
1459
+ updatedAt: row.updated_at,
1460
+ }));
1461
+ }
1462
+
1463
+ getGovernanceRule(id: string): GovernanceRuleRecord | null {
1464
+ return this.listGovernanceRules(true).find(rule => rule.id === id) ?? null;
1465
+ }
1466
+
1467
+ upsertGovernanceRule(rule: GovernanceRuleRecord): GovernanceRuleRecord {
1468
+ const now = Date.now();
1469
+ this.db.prepare(`
1470
+ INSERT INTO devflow_rules
1471
+ (id, name, gate, level, condition_json, action, message, enabled, created_at, updated_at)
1472
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1473
+ ON CONFLICT(id) DO UPDATE SET
1474
+ name = excluded.name,
1475
+ gate = excluded.gate,
1476
+ level = excluded.level,
1477
+ condition_json = excluded.condition_json,
1478
+ action = excluded.action,
1479
+ message = excluded.message,
1480
+ enabled = excluded.enabled,
1481
+ updated_at = excluded.updated_at
1482
+ `).run(
1483
+ rule.id,
1484
+ rule.name,
1485
+ rule.gate,
1486
+ rule.level,
1487
+ JSON.stringify(rule.condition),
1488
+ rule.action,
1489
+ rule.message,
1490
+ rule.enabled ? 1 : 0,
1491
+ rule.createdAt ?? now,
1492
+ now,
1493
+ );
1494
+ return this.getGovernanceRule(rule.id)!;
1495
+ }
1496
+
1497
+ deleteGovernanceRule(id: string): boolean {
1498
+ return this.db.prepare('DELETE FROM devflow_rules WHERE id = ?').run(id).changes > 0;
1499
+ }
1500
+
1501
+ listGovernanceAudit(limit = 100, offset = 0): { items: GovernanceAuditRecord[]; total: number } {
1502
+ const boundedLimit = Math.max(1, Math.min(limit, 500));
1503
+ const boundedOffset = Math.max(0, offset);
1504
+ const rows = this.db.prepare(`
1505
+ SELECT seq, ts, tool, args_hash, decision, rule_id, session_id, prev_hash, signature
1506
+ FROM devflow_audit_chain
1507
+ ORDER BY seq DESC
1508
+ LIMIT ? OFFSET ?
1509
+ `).all(boundedLimit, boundedOffset) as any[];
1510
+ const count = this.db.prepare('SELECT COUNT(*) AS total FROM devflow_audit_chain').get() as { total?: number } | undefined;
1511
+ return {
1512
+ items: rows.map(row => ({
1513
+ seq: row.seq,
1514
+ ts: row.ts,
1515
+ tool: row.tool,
1516
+ argsHash: row.args_hash,
1517
+ decision: row.decision,
1518
+ ruleId: row.rule_id,
1519
+ sessionId: row.session_id,
1520
+ prevHash: row.prev_hash,
1521
+ signature: row.signature,
1522
+ })),
1523
+ total: Number(count?.total ?? 0),
1524
+ };
1525
+ }
1526
+
1527
+ // ---- Hook Lifecycle ----
1528
+
1529
+ getHookReceipt(projectRoot: string): HookReceiptRecord | null {
1530
+ const row = this.db.prepare(`
1531
+ SELECT project_root, last_mcp_call, bypass_count, updated_at
1532
+ FROM devflow_hook_receipts WHERE project_root = ?
1533
+ `).get(projectRoot) as any;
1534
+ if (!row) return null;
1535
+ return {
1536
+ projectRoot: row.project_root,
1537
+ lastMcpCall: row.last_mcp_call ?? undefined,
1538
+ bypassCount: Number(row.bypass_count ?? 0),
1539
+ updatedAt: Number(row.updated_at),
1540
+ };
1541
+ }
1542
+
1543
+ updateHookReceipt(
1544
+ projectRoot: string,
1545
+ updater: (current: HookReceiptRecord | null) => Omit<HookReceiptRecord, 'projectRoot' | 'updatedAt'>,
1546
+ ): HookReceiptRecord {
1547
+ this.db.exec('BEGIN IMMEDIATE');
1548
+ try {
1549
+ const current = this.getHookReceipt(projectRoot);
1550
+ const next = updater(current);
1551
+ const updatedAt = Date.now();
1552
+ this.db.prepare(`
1553
+ INSERT INTO devflow_hook_receipts
1554
+ (project_root, last_mcp_call, bypass_count, updated_at)
1555
+ VALUES (?, ?, ?, ?)
1556
+ ON CONFLICT(project_root) DO UPDATE SET
1557
+ last_mcp_call = excluded.last_mcp_call,
1558
+ bypass_count = excluded.bypass_count,
1559
+ updated_at = excluded.updated_at
1560
+ `).run(
1561
+ projectRoot,
1562
+ next.lastMcpCall ?? null,
1563
+ next.bypassCount ?? 0,
1564
+ updatedAt,
1565
+ );
1566
+ this.db.exec('COMMIT');
1567
+ return {
1568
+ projectRoot,
1569
+ lastMcpCall: next.lastMcpCall,
1570
+ bypassCount: next.bypassCount ?? 0,
1571
+ updatedAt,
1572
+ };
1573
+ } catch (error) {
1574
+ try { this.db.exec('ROLLBACK'); } catch {}
1575
+ throw error;
1576
+ }
1577
+ }
1578
+
1579
+ deleteHookReceipt(projectRoot: string): boolean {
1580
+ return this.db.prepare('DELETE FROM devflow_hook_receipts WHERE project_root = ?')
1581
+ .run(projectRoot).changes > 0;
1582
+ }
1583
+
1584
+ recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
1585
+ this.db.prepare(`
1586
+ INSERT INTO devflow_memory_distill_checkpoints
1587
+ (id, project_root, session_id, trigger, pending_events, released_leases, created_at)
1588
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1589
+ `).run(
1590
+ checkpoint.id,
1591
+ checkpoint.projectRoot,
1592
+ checkpoint.sessionId ?? null,
1593
+ checkpoint.trigger,
1594
+ checkpoint.pendingEvents,
1595
+ checkpoint.releasedLeases,
1596
+ checkpoint.createdAt,
1597
+ );
1598
+ }
1599
+
1600
+ listMemoryDistillCheckpoints(projectRoot: string, limit = 50): MemoryDistillCheckpointRecord[] {
1601
+ const rows = this.db.prepare(`
1602
+ SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
1603
+ FROM devflow_memory_distill_checkpoints
1604
+ WHERE project_root = ?
1605
+ ORDER BY created_at DESC
1606
+ LIMIT ?
1607
+ `).all(projectRoot, Math.max(1, Math.min(limit, 500))) as any[];
1608
+ return rows.map(row => ({
1609
+ id: row.id,
1610
+ projectRoot: row.project_root,
1611
+ sessionId: row.session_id ?? undefined,
1612
+ trigger: row.trigger,
1613
+ pendingEvents: Number(row.pending_events),
1614
+ releasedLeases: Number(row.released_leases),
1615
+ createdAt: Number(row.created_at),
1616
+ }));
1617
+ }
1618
+
995
1619
  // Convenience methods for raw SQL queries (used by AutoChecker)
996
1620
  all(sql: string, ...params: unknown[]): unknown[] {
997
1621
  return this.db.prepare(sql).all(...params);
@@ -1005,3 +1629,42 @@ export class DevFlowDatabase {
1005
1629
  this.db.close();
1006
1630
  }
1007
1631
  }
1632
+
1633
+ function parseJson(value: unknown): unknown {
1634
+ if (typeof value !== 'string') return value;
1635
+ try { return JSON.parse(value); } catch { return value; }
1636
+ }
1637
+
1638
+ function deriveQuery(input: unknown): string | null {
1639
+ if (!input || typeof input !== 'object') return null;
1640
+ const record = input as Record<string, unknown>;
1641
+ for (const key of ['query', 'task', 'prompt', 'name', 'command']) {
1642
+ if (typeof record[key] === 'string' && record[key]) return record[key] as string;
1643
+ }
1644
+ return null;
1645
+ }
1646
+
1647
+ function deriveEngine(toolName: string): string | null {
1648
+ if (/project_context|symbol|dependency_graph|codegraph/.test(toolName)) return 'codegraph';
1649
+ if (/knowledge|docs/.test(toolName)) return 'knowledge';
1650
+ if (/memory/.test(toolName)) return 'memory';
1651
+ return null;
1652
+ }
1653
+
1654
+ function countTelemetryResults(value: unknown, depth = 0): number {
1655
+ if (value == null || depth > 2) return 0;
1656
+ if (Array.isArray(value)) return value.length;
1657
+ if (typeof value !== 'object') return value === '' ? 0 : 1;
1658
+
1659
+ const record = value as Record<string, unknown>;
1660
+ const unwrapped = record.data ?? record.structuredContent;
1661
+ if (unwrapped !== undefined && unwrapped !== value) return countTelemetryResults(unwrapped, depth + 1);
1662
+
1663
+ let count = 0;
1664
+ for (const key of ['files', 'results', 'result', 'memories', 'nodes', 'chunks', 'findings', 'symbols', 'keySymbols']) {
1665
+ if (Array.isArray(record[key])) count += record[key].length;
1666
+ }
1667
+ if (count > 0) return count;
1668
+ if (record.error) return 0;
1669
+ return Object.keys(record).length > 0 ? 1 : 0;
1670
+ }