@devflow-tools/database 0.16.9 → 0.16.11

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/dist/database.js CHANGED
@@ -7,16 +7,27 @@ const node_sqlite_1 = require("./node-sqlite");
7
7
  const path_1 = require("path");
8
8
  const fs_1 = require("fs");
9
9
  const os_1 = require("os");
10
+ const crypto_1 = require("crypto");
10
11
  const CONTEXT_REQUIRED_SKILLS = new Set([
11
12
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
12
13
  ]);
13
14
  function toolNameMatches(event, expected) {
14
- if (event.blocked || event.error)
15
+ if (!isEffectiveToolEvent(event))
15
16
  return false;
16
17
  const name = String(event.mcpToolName ?? event.toolName ?? '')
17
18
  .replace(/^mcp__[^_]+__/, '');
18
19
  return name === expected;
19
20
  }
21
+ function isEffectiveToolEvent(event) {
22
+ if (event.blocked || event.error || event.output == null)
23
+ return false;
24
+ if (typeof event.output === 'object') {
25
+ const output = event.output;
26
+ if (output.error || output.isError === true || output.success === false || output.degraded === true)
27
+ return false;
28
+ }
29
+ return countTelemetryResults(event.output) > 0;
30
+ }
20
31
  function obligationsForSkill(skillName, configured) {
21
32
  const configuredTools = Array.isArray(configured)
22
33
  ? configured.filter((tool) => typeof tool === 'string' && tool.length > 0)
@@ -145,6 +156,10 @@ class DevFlowDatabase {
145
156
  total_duration INTEGER DEFAULT 0,
146
157
  mcp_compliance_rate REAL DEFAULT 0,
147
158
  missed_mcp_tools TEXT,
159
+ failed_tool_calls INTEGER DEFAULT 0,
160
+ blocked_tool_calls INTEGER DEFAULT 0,
161
+ fallback_count INTEGER DEFAULT 0,
162
+ fallback_reasons TEXT,
148
163
  created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)
149
164
  );
150
165
 
@@ -284,6 +299,89 @@ class DevFlowDatabase {
284
299
 
285
300
  CREATE INDEX IF NOT EXISTS idx_distill_checkpoints_project
286
301
  ON devflow_memory_distill_checkpoints(project_root, created_at DESC);
302
+
303
+ CREATE TABLE IF NOT EXISTS devflow_memory_turns (
304
+ turn_id TEXT PRIMARY KEY,
305
+ project_root TEXT NOT NULL,
306
+ session_id TEXT NOT NULL,
307
+ prompt_hash TEXT NOT NULL,
308
+ event_id TEXT NOT NULL,
309
+ status TEXT NOT NULL DEFAULT 'pending'
310
+ CHECK(status IN ('pending', 'committed', 'skipped')),
311
+ receipt_id TEXT,
312
+ memory_ids TEXT NOT NULL DEFAULT '[]',
313
+ source TEXT,
314
+ reason TEXT,
315
+ stop_prompted_at INTEGER,
316
+ created_at INTEGER NOT NULL,
317
+ decided_at INTEGER
318
+ );
319
+
320
+ CREATE INDEX IF NOT EXISTS idx_memory_turns_pending
321
+ ON devflow_memory_turns(project_root, session_id, status, created_at DESC);
322
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_turns_receipt
323
+ ON devflow_memory_turns(receipt_id) WHERE receipt_id IS NOT NULL;
324
+
325
+ CREATE TABLE IF NOT EXISTS devflow_hook_fallbacks (
326
+ id TEXT PRIMARY KEY,
327
+ project_root TEXT NOT NULL,
328
+ session_id TEXT,
329
+ tool_use_id TEXT,
330
+ request_type TEXT NOT NULL,
331
+ tool TEXT NOT NULL,
332
+ reason TEXT NOT NULL CHECK(reason IN ('timeout', 'unreachable', 'protocol', 'unknown')),
333
+ duration_ms INTEGER NOT NULL,
334
+ attempts INTEGER NOT NULL,
335
+ created_at INTEGER NOT NULL
336
+ );
337
+ CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
338
+ ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
339
+
340
+ CREATE TABLE IF NOT EXISTS devflow_work_items (
341
+ id TEXT PRIMARY KEY,
342
+ idempotency_key TEXT NOT NULL UNIQUE,
343
+ kind TEXT NOT NULL,
344
+ project_root TEXT NOT NULL,
345
+ session_id TEXT,
346
+ turn_id TEXT,
347
+ payload TEXT NOT NULL,
348
+ state TEXT NOT NULL DEFAULT 'pending'
349
+ CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
350
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
351
+ max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
352
+ lease_owner TEXT,
353
+ lease_expires_at INTEGER,
354
+ next_attempt_at INTEGER NOT NULL,
355
+ error_category TEXT,
356
+ error_message TEXT,
357
+ created_at INTEGER NOT NULL,
358
+ updated_at INTEGER NOT NULL,
359
+ completed_at INTEGER
360
+ );
361
+
362
+ CREATE INDEX IF NOT EXISTS idx_work_items_ready
363
+ ON devflow_work_items(project_root, state, next_attempt_at, created_at);
364
+ CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
365
+ ON devflow_work_items(project_root, state, lease_expires_at);
366
+ CREATE INDEX IF NOT EXISTS idx_work_items_completed
367
+ ON devflow_work_items(project_root, completed_at DESC);
368
+
369
+ CREATE TABLE IF NOT EXISTS devflow_session_closures (
370
+ session_id TEXT NOT NULL,
371
+ project_root TEXT NOT NULL,
372
+ state TEXT NOT NULL DEFAULT 'active'
373
+ CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
374
+ receipt_id TEXT NOT NULL UNIQUE,
375
+ work_item_id TEXT,
376
+ pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
377
+ requested_at INTEGER NOT NULL,
378
+ updated_at INTEGER NOT NULL,
379
+ closed_at INTEGER,
380
+ PRIMARY KEY (project_root, session_id)
381
+ );
382
+
383
+ CREATE INDEX IF NOT EXISTS idx_session_closures_state
384
+ ON devflow_session_closures(project_root, state, updated_at DESC);
287
385
  `);
288
386
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
289
387
  try {
@@ -326,6 +424,22 @@ class DevFlowDatabase {
326
424
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT');
327
425
  }
328
426
  catch { }
427
+ try {
428
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0');
429
+ }
430
+ catch { }
431
+ try {
432
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0');
433
+ }
434
+ catch { }
435
+ try {
436
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0');
437
+ }
438
+ catch { }
439
+ try {
440
+ this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_reasons TEXT');
441
+ }
442
+ catch { }
329
443
  try {
330
444
  this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_events_session_tool_use
331
445
  ON tool_call_events(session_id, tool_use_id) WHERE tool_use_id IS NOT NULL`);
@@ -765,6 +879,10 @@ class DevFlowDatabase {
765
879
  totalDuration: row.total_duration,
766
880
  mcpComplianceRate: row.mcp_compliance_rate,
767
881
  missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : null,
882
+ failedToolCalls: row.failed_tool_calls ?? 0,
883
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
884
+ fallbackCount: row.fallback_count ?? 0,
885
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
768
886
  createdAt: row.created_at,
769
887
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
770
888
  };
@@ -792,6 +910,22 @@ class DevFlowDatabase {
792
910
  sets.push('missed_mcp_tools = ?');
793
911
  values.push(JSON.stringify(updates.missedMcpTools));
794
912
  }
913
+ if (updates.failedToolCalls !== undefined) {
914
+ sets.push('failed_tool_calls = ?');
915
+ values.push(updates.failedToolCalls);
916
+ }
917
+ if (updates.blockedToolCalls !== undefined) {
918
+ sets.push('blocked_tool_calls = ?');
919
+ values.push(updates.blockedToolCalls);
920
+ }
921
+ if (updates.fallbackCount !== undefined) {
922
+ sets.push('fallback_count = ?');
923
+ values.push(updates.fallbackCount);
924
+ }
925
+ if (updates.fallbackReasons !== undefined) {
926
+ sets.push('fallback_reasons = ?');
927
+ values.push(JSON.stringify(updates.fallbackReasons));
928
+ }
795
929
  if (updates.totalToolCalls !== undefined) {
796
930
  sets.push('total_tool_calls = ?');
797
931
  values.push(updates.totalToolCalls);
@@ -840,6 +974,11 @@ class DevFlowDatabase {
840
974
  subagentCount: row.subagent_count,
841
975
  totalDuration: row.total_duration,
842
976
  mcpComplianceRate: row.mcp_compliance_rate,
977
+ missedMcpTools: row.missed_mcp_tools ? JSON.parse(row.missed_mcp_tools) : [],
978
+ failedToolCalls: row.failed_tool_calls ?? 0,
979
+ blockedToolCalls: row.blocked_tool_calls ?? 0,
980
+ fallbackCount: row.fallback_count ?? 0,
981
+ fallbackReasons: row.fallback_reasons ? JSON.parse(row.fallback_reasons) : [],
843
982
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
844
983
  }));
845
984
  }
@@ -948,9 +1087,39 @@ class DevFlowDatabase {
948
1087
  FROM tool_call_events WHERE execution_id = ?
949
1088
  `).get(executionId);
950
1089
  const execution = this.getSkillExecution(executionId);
1090
+ const events = this.listToolCallEvents(executionId);
951
1091
  const total = Number(row?.total ?? 0);
952
1092
  const mcp = Number(row?.mcp ?? 0);
953
1093
  const obligation = this.getExecutionObligationCompliance(executionId);
1094
+ const failures = events.filter(event => Boolean(event.error) && !event.blocked);
1095
+ const blocked = events.filter(event => event.blocked);
1096
+ const directFallbacks = events.filter(event => event.mcpFallback);
1097
+ const hookFallbacks = execution?.sessionId
1098
+ ? this.listHookFallbacks({
1099
+ sessionId: execution.sessionId,
1100
+ from: execution.startedAt,
1101
+ to: finishedAt,
1102
+ })
1103
+ : [];
1104
+ const fallbackReasons = [...new Set([
1105
+ ...directFallbacks.map(() => 'direct_tool_during_context'),
1106
+ ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1107
+ ])];
1108
+ const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1109
+ const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1110
+ ? metadata.memoryReceiptIds.filter((id) => typeof id === 'string')
1111
+ : [];
1112
+ const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1113
+ ? metadata.distillReceiptIds.filter((id) => typeof id === 'string')
1114
+ : [];
1115
+ const memoryReceiptIds = [...new Set([
1116
+ ...suppliedMemoryReceiptIds,
1117
+ ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
1118
+ ])];
1119
+ const distillReceiptIds = [...new Set([
1120
+ ...suppliedDistillReceiptIds,
1121
+ ...eventReceiptIds.filter(id => id.startsWith('distill-receipt:')),
1122
+ ])];
954
1123
  this.updateSkillExecution(executionId, {
955
1124
  status,
956
1125
  finishedAt,
@@ -962,9 +1131,41 @@ class DevFlowDatabase {
962
1131
  ? Math.max(0, finishedAt - execution.startedAt)
963
1132
  : row?.first_at != null && row?.last_at != null ? row.last_at - row.first_at : 0,
964
1133
  mcpComplianceRate: obligation.rate,
965
- metadata,
1134
+ missedMcpTools: obligation.missedTools,
1135
+ failedToolCalls: failures.length,
1136
+ blockedToolCalls: blocked.length,
1137
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1138
+ fallbackReasons,
1139
+ metadata: {
1140
+ ...(metadata ?? {}),
1141
+ applicableObligations: obligation.applicable,
1142
+ satisfiedObligations: obligation.satisfied,
1143
+ missedTools: obligation.missedTools,
1144
+ actualFailureCount: failures.length,
1145
+ blockedCount: blocked.length,
1146
+ fallbackCount: directFallbacks.length + hookFallbacks.length,
1147
+ fallbackReasons,
1148
+ memoryReceiptIds,
1149
+ distillReceiptIds,
1150
+ },
966
1151
  });
967
1152
  }
1153
+ reconcileRunningSkillExecutionsForSession(sessionId, finishedAt = Date.now(), metadata) {
1154
+ const rows = this.db.prepare(`
1155
+ SELECT execution_id
1156
+ FROM skill_executions
1157
+ WHERE session_id = ? AND status = 'running'
1158
+ ORDER BY started_at ASC
1159
+ `).all(sessionId);
1160
+ const reconciled = [];
1161
+ for (const row of rows) {
1162
+ this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
1163
+ const execution = this.getSkillExecution(row.execution_id);
1164
+ if (execution)
1165
+ reconciled.push(execution);
1166
+ }
1167
+ return reconciled;
1168
+ }
968
1169
  getExecutionObligationCompliance(executionId) {
969
1170
  const execution = this.getSkillExecution(executionId);
970
1171
  const events = this.listToolCallEvents(executionId);
@@ -979,7 +1180,7 @@ class DevFlowDatabase {
979
1180
  if (obligation.endsWith(':domain')) {
980
1181
  const family = obligation.slice(0, -':domain'.length);
981
1182
  return events.some((event) => {
982
- if (event.blocked || event.error || !event.isMcpTool)
1183
+ if (!event.isMcpTool || !isEffectiveToolEvent(event))
983
1184
  return false;
984
1185
  const name = String(event.mcpToolName ?? event.toolName ?? '')
985
1186
  .replace(/^mcp__[^_]+__/, '');
@@ -1388,6 +1589,341 @@ class DevFlowDatabase {
1388
1589
  total: Number(count?.total ?? 0),
1389
1590
  };
1390
1591
  }
1592
+ // ---- Durable Work Queue ----
1593
+ enqueueWork(input) {
1594
+ if (!input.idempotencyKey.trim())
1595
+ throw new Error('Work idempotency key is required');
1596
+ if (!input.projectRoot.trim())
1597
+ throw new Error('Work project root is required');
1598
+ const maxAttempts = input.maxAttempts ?? 5;
1599
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
1600
+ throw new Error('Work maxAttempts must be a positive safe integer');
1601
+ }
1602
+ const createdAt = Date.now();
1603
+ const nextAttemptAt = input.nextAttemptAt ?? createdAt;
1604
+ if (!Number.isSafeInteger(nextAttemptAt)) {
1605
+ throw new Error('Work nextAttemptAt must be a safe integer');
1606
+ }
1607
+ const row = this.db.prepare(`
1608
+ INSERT INTO devflow_work_items (
1609
+ id, idempotency_key, kind, project_root, session_id, turn_id, payload,
1610
+ state, attempts, max_attempts, lease_owner, lease_expires_at,
1611
+ next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
1612
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
1613
+ ON CONFLICT(idempotency_key) DO UPDATE SET
1614
+ state = CASE
1615
+ WHEN devflow_work_items.state = 'dead_letter'
1616
+ AND excluded.max_attempts > devflow_work_items.attempts
1617
+ THEN 'failed'
1618
+ ELSE devflow_work_items.state
1619
+ END,
1620
+ max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
1621
+ next_attempt_at = CASE
1622
+ WHEN devflow_work_items.state = 'dead_letter'
1623
+ AND excluded.max_attempts > devflow_work_items.attempts
1624
+ THEN excluded.next_attempt_at
1625
+ ELSE devflow_work_items.next_attempt_at
1626
+ END,
1627
+ error_category = CASE
1628
+ WHEN devflow_work_items.state = 'dead_letter'
1629
+ AND excluded.max_attempts > devflow_work_items.attempts
1630
+ THEN NULL
1631
+ ELSE devflow_work_items.error_category
1632
+ END,
1633
+ error_message = CASE
1634
+ WHEN devflow_work_items.state = 'dead_letter'
1635
+ AND excluded.max_attempts > devflow_work_items.attempts
1636
+ THEN NULL
1637
+ ELSE devflow_work_items.error_message
1638
+ END,
1639
+ updated_at = CASE
1640
+ WHEN devflow_work_items.state = 'dead_letter'
1641
+ AND excluded.max_attempts > devflow_work_items.attempts
1642
+ THEN excluded.updated_at
1643
+ ELSE devflow_work_items.updated_at
1644
+ END
1645
+ RETURNING *
1646
+ `).get((0, crypto_1.randomUUID)(), input.idempotencyKey, input.kind, input.projectRoot, input.sessionId ?? null, input.turnId ?? null, JSON.stringify(input.payload ?? null), maxAttempts, nextAttemptAt, createdAt, createdAt);
1647
+ return this.mapWorkItem(row);
1648
+ }
1649
+ getWorkByIdempotencyKey(idempotencyKey) {
1650
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE idempotency_key = ?').get(idempotencyKey);
1651
+ return row ? this.mapWorkItem(row) : null;
1652
+ }
1653
+ requestSessionClosure(input) {
1654
+ if (!input.sessionId.trim())
1655
+ throw new Error('Session closure requires a session ID');
1656
+ if (!input.projectRoot.trim())
1657
+ throw new Error('Session closure requires a project root');
1658
+ if (!input.receiptId.trim())
1659
+ throw new Error('Session closure requires a receipt ID');
1660
+ return this.db.transaction(() => {
1661
+ const now = Date.now();
1662
+ this.db.prepare(`
1663
+ INSERT INTO devflow_session_closures (
1664
+ session_id, project_root, state, receipt_id, pending_work_count,
1665
+ requested_at, updated_at
1666
+ ) VALUES (?, ?, 'active', ?, 0, ?, ?)
1667
+ ON CONFLICT(project_root, session_id) DO NOTHING
1668
+ `).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
1669
+ const work = this.enqueueWork({
1670
+ idempotencyKey: input.receiptId,
1671
+ kind: 'session.finalize',
1672
+ projectRoot: input.projectRoot,
1673
+ sessionId: input.sessionId,
1674
+ payload: input.payload,
1675
+ maxAttempts: input.maxAttempts,
1676
+ });
1677
+ const pending = this.db.prepare(`
1678
+ SELECT COUNT(*) AS count
1679
+ FROM devflow_work_items
1680
+ WHERE project_root = ? AND session_id = ?
1681
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1682
+ `).get(input.projectRoot, input.sessionId);
1683
+ const pendingWorkCount = Number(pending?.count ?? 0);
1684
+ this.db.prepare(`
1685
+ UPDATE devflow_session_closures
1686
+ SET state = CASE
1687
+ WHEN state IN ('closed', 'closed_with_pending_work')
1688
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
1689
+ WHEN ? = 'completed'
1690
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
1691
+ WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
1692
+ ELSE 'closing'
1693
+ END,
1694
+ work_item_id = COALESCE(work_item_id, ?),
1695
+ pending_work_count = ?,
1696
+ closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
1697
+ updated_at = ?
1698
+ WHERE project_root = ? AND session_id = ?
1699
+ `).run(pendingWorkCount, work.state, pendingWorkCount, work.state, work.id, pendingWorkCount, work.state, now, now, input.projectRoot, input.sessionId);
1700
+ return this.getSessionClosure(input.projectRoot, input.sessionId);
1701
+ });
1702
+ }
1703
+ getSessionClosure(projectRoot, sessionId) {
1704
+ const row = this.db.prepare(`
1705
+ SELECT * FROM devflow_session_closures
1706
+ WHERE project_root = ? AND session_id = ?
1707
+ `).get(projectRoot, sessionId);
1708
+ return row ? this.mapSessionClosure(row) : null;
1709
+ }
1710
+ completeSessionClosure(projectRoot, sessionId, excludingWorkItemId, closedAt = Date.now()) {
1711
+ const pending = this.db.prepare(`
1712
+ SELECT COUNT(*) AS count
1713
+ FROM devflow_work_items
1714
+ WHERE project_root = ? AND session_id = ?
1715
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1716
+ AND (? IS NULL OR id <> ?)
1717
+ `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null);
1718
+ const pendingWorkCount = Number(pending?.count ?? 0);
1719
+ const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
1720
+ this.db.prepare(`
1721
+ UPDATE devflow_session_closures
1722
+ SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
1723
+ WHERE project_root = ? AND session_id = ? AND state = 'closing'
1724
+ `).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
1725
+ const closure = this.getSessionClosure(projectRoot, sessionId);
1726
+ if (!closure)
1727
+ throw new Error(`Session closure ${sessionId} does not exist`);
1728
+ return closure;
1729
+ }
1730
+ listSessionClosures(projectRoot, limit = 100) {
1731
+ const rows = this.db.prepare(`
1732
+ SELECT * FROM devflow_session_closures
1733
+ WHERE project_root = ?
1734
+ ORDER BY updated_at DESC
1735
+ LIMIT ?
1736
+ `).all(projectRoot, Math.max(1, Math.min(limit, 1000)));
1737
+ return rows.map(row => this.mapSessionClosure(row));
1738
+ }
1739
+ leaseWork(input) {
1740
+ if (!input.owner.trim())
1741
+ throw new Error('Work lease owner is required');
1742
+ if (!Number.isFinite(input.limit) || input.limit <= 0)
1743
+ return [];
1744
+ if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
1745
+ throw new Error('Work leaseMs must be a positive safe integer');
1746
+ }
1747
+ if (input.kinds?.length === 0)
1748
+ return [];
1749
+ const now = input.now ?? Date.now();
1750
+ const leaseExpiresAt = now + input.leaseMs;
1751
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
1752
+ throw new Error('Work lease timestamps must be safe integers');
1753
+ }
1754
+ const limit = Math.min(Math.floor(input.limit), 1000);
1755
+ const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
1756
+ const kindClause = kinds
1757
+ ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
1758
+ : '';
1759
+ this.db.exec('BEGIN IMMEDIATE');
1760
+ try {
1761
+ const candidates = this.db.prepare(`
1762
+ SELECT id, state
1763
+ FROM devflow_work_items
1764
+ WHERE project_root = ?
1765
+ AND state IN ('pending', 'failed')
1766
+ AND lease_owner IS NULL
1767
+ AND lease_expires_at IS NULL
1768
+ AND next_attempt_at <= ?
1769
+ AND attempts < max_attempts
1770
+ ${kindClause}
1771
+ ORDER BY next_attempt_at ASC, created_at ASC, id ASC
1772
+ LIMIT ?
1773
+ `).all(input.projectRoot, now, ...(kinds ?? []), limit);
1774
+ const leased = [];
1775
+ for (const candidate of candidates) {
1776
+ const result = this.db.prepare(`
1777
+ UPDATE devflow_work_items
1778
+ SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
1779
+ lease_expires_at = ?, updated_at = ?
1780
+ WHERE id = ? AND project_root = ? AND state = ?
1781
+ AND lease_owner IS NULL AND lease_expires_at IS NULL
1782
+ AND next_attempt_at <= ? AND attempts < max_attempts
1783
+ `).run(input.owner, leaseExpiresAt, now, candidate.id, input.projectRoot, candidate.state, now);
1784
+ if (result.changes === 1) {
1785
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
1786
+ .get(candidate.id);
1787
+ leased.push(this.mapWorkItem(row));
1788
+ }
1789
+ }
1790
+ this.db.exec('COMMIT');
1791
+ return leased;
1792
+ }
1793
+ catch (error) {
1794
+ try {
1795
+ this.db.exec('ROLLBACK');
1796
+ }
1797
+ catch { }
1798
+ throw error;
1799
+ }
1800
+ }
1801
+ completeWork(id, owner, now = Date.now()) {
1802
+ const work = this.db.prepare(`
1803
+ SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
1804
+ `).get(id);
1805
+ const completed = this.db.prepare(`
1806
+ UPDATE devflow_work_items
1807
+ SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
1808
+ updated_at = ?, completed_at = ?
1809
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1810
+ `).run(now, now, id, owner).changes === 1;
1811
+ if (completed && work?.project_root && work.session_id) {
1812
+ this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
1813
+ }
1814
+ return completed;
1815
+ }
1816
+ retryWork(id, owner, error, nextAttemptAt) {
1817
+ const updatedAt = Date.now();
1818
+ return this.db.prepare(`
1819
+ UPDATE devflow_work_items
1820
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
1821
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1822
+ error_category = ?, error_message = ?, updated_at = ?
1823
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1824
+ `).run(nextAttemptAt, error.category, error.message, updatedAt, id, owner).changes === 1;
1825
+ }
1826
+ deadLetterWork(id, owner, error) {
1827
+ return this.db.prepare(`
1828
+ UPDATE devflow_work_items
1829
+ SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
1830
+ error_category = ?, error_message = ?, updated_at = ?
1831
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1832
+ `).run(error.category, error.message, Date.now(), id, owner).changes === 1;
1833
+ }
1834
+ recoverExpiredWork(projectRoot, now = Date.now()) {
1835
+ return this.db.prepare(`
1836
+ UPDATE devflow_work_items
1837
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
1838
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1839
+ error_category = 'lease_expired',
1840
+ error_message = 'Work lease expired before completion',
1841
+ updated_at = ?
1842
+ WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
1843
+ `).run(now, now, projectRoot, now).changes;
1844
+ }
1845
+ getWorkQueueHealth(projectRoot, now = Date.now()) {
1846
+ const row = this.db.prepare(`
1847
+ SELECT
1848
+ COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
1849
+ COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
1850
+ COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
1851
+ COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
1852
+ COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
1853
+ COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
1854
+ MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
1855
+ MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
1856
+ FROM devflow_work_items
1857
+ WHERE project_root = ?
1858
+ `).get(now, projectRoot);
1859
+ const oldestPendingAt = row.oldest_pending_at == null
1860
+ ? undefined
1861
+ : Number(row.oldest_pending_at);
1862
+ return {
1863
+ projectRoot,
1864
+ queueDepth: Number(row.queue_depth),
1865
+ pending: Number(row.pending),
1866
+ leased: Number(row.leased),
1867
+ failed: Number(row.failed),
1868
+ expiredLeases: Number(row.expired_leases),
1869
+ deadLetters: Number(row.dead_letters),
1870
+ oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
1871
+ lastSuccessfulDrainAt: row.last_successful_drain_at == null
1872
+ ? undefined
1873
+ : Number(row.last_successful_drain_at),
1874
+ };
1875
+ }
1876
+ mapWorkItem(row) {
1877
+ return {
1878
+ id: row.id,
1879
+ idempotencyKey: row.idempotency_key,
1880
+ kind: row.kind,
1881
+ projectRoot: row.project_root,
1882
+ sessionId: row.session_id ?? undefined,
1883
+ turnId: row.turn_id ?? undefined,
1884
+ payload: parseJson(row.payload),
1885
+ state: row.state,
1886
+ attempts: Number(row.attempts),
1887
+ maxAttempts: Number(row.max_attempts),
1888
+ leaseOwner: row.lease_owner ?? undefined,
1889
+ leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
1890
+ nextAttemptAt: Number(row.next_attempt_at),
1891
+ errorCategory: row.error_category ?? undefined,
1892
+ errorMessage: row.error_message ?? undefined,
1893
+ createdAt: Number(row.created_at),
1894
+ updatedAt: Number(row.updated_at),
1895
+ completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
1896
+ };
1897
+ }
1898
+ mapSessionClosure(row) {
1899
+ return {
1900
+ sessionId: row.session_id,
1901
+ projectRoot: row.project_root,
1902
+ state: row.state,
1903
+ receiptId: row.receipt_id,
1904
+ workItemId: row.work_item_id ?? undefined,
1905
+ pendingWorkCount: Number(row.pending_work_count),
1906
+ requestedAt: Number(row.requested_at),
1907
+ updatedAt: Number(row.updated_at),
1908
+ closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
1909
+ };
1910
+ }
1911
+ refreshClosedSessionClosure(projectRoot, sessionId, now) {
1912
+ const pending = this.db.prepare(`
1913
+ SELECT COUNT(*) AS count
1914
+ FROM devflow_work_items
1915
+ WHERE project_root = ? AND session_id = ?
1916
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1917
+ `).get(projectRoot, sessionId);
1918
+ const pendingWorkCount = Number(pending?.count ?? 0);
1919
+ this.db.prepare(`
1920
+ UPDATE devflow_session_closures
1921
+ SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
1922
+ pending_work_count = ?, updated_at = ?
1923
+ WHERE project_root = ? AND session_id = ?
1924
+ AND state IN ('closed', 'closed_with_pending_work')
1925
+ `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
1926
+ }
1391
1927
  // ---- Hook Lifecycle ----
1392
1928
  getHookReceipt(projectRoot) {
1393
1929
  const row = this.db.prepare(`
@@ -1477,6 +2013,137 @@ class DevFlowDatabase {
1477
2013
  return this.db.prepare('DELETE FROM devflow_context_receipts WHERE expires_at <= ?')
1478
2014
  .run(now).changes;
1479
2015
  }
2016
+ beginMemoryTurn(input) {
2017
+ this.db.prepare(`
2018
+ INSERT OR IGNORE INTO devflow_memory_turns
2019
+ (turn_id, project_root, session_id, prompt_hash, event_id, status, memory_ids, created_at)
2020
+ VALUES (?, ?, ?, ?, ?, 'pending', '[]', ?)
2021
+ `).run(input.turnId, input.projectRoot, input.sessionId, input.promptHash, input.eventId, input.createdAt);
2022
+ return this.getMemoryTurn(input.turnId);
2023
+ }
2024
+ getMemoryTurn(turnId) {
2025
+ const row = this.db.prepare('SELECT * FROM devflow_memory_turns WHERE turn_id = ?').get(turnId);
2026
+ return row ? this.mapMemoryTurn(row) : null;
2027
+ }
2028
+ getPendingMemoryTurn(projectRoot, sessionId) {
2029
+ const row = this.db.prepare(`
2030
+ SELECT * FROM devflow_memory_turns
2031
+ WHERE project_root = ? AND session_id = ? AND status = 'pending'
2032
+ ORDER BY created_at DESC LIMIT 1
2033
+ `).get(projectRoot, sessionId);
2034
+ return row ? this.mapMemoryTurn(row) : null;
2035
+ }
2036
+ commitMemoryTurn(input) {
2037
+ this.db.prepare(`
2038
+ UPDATE devflow_memory_turns
2039
+ SET status = 'committed', receipt_id = ?, memory_ids = ?, source = ?, reason = ?, decided_at = ?
2040
+ WHERE turn_id = ? AND status = 'pending'
2041
+ `).run(input.receiptId, JSON.stringify([...new Set(input.memoryIds)]), input.source, input.reason ?? null, input.decidedAt ?? Date.now(), input.turnId);
2042
+ const turn = this.getMemoryTurn(input.turnId);
2043
+ if (!turn || turn.status !== 'committed') {
2044
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2045
+ }
2046
+ return turn;
2047
+ }
2048
+ skipMemoryTurn(input) {
2049
+ this.db.prepare(`
2050
+ UPDATE devflow_memory_turns
2051
+ SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
2052
+ reason = ?, decided_at = ?
2053
+ WHERE turn_id = ? AND status = 'pending'
2054
+ `).run(input.receiptId, input.reason, input.decidedAt ?? Date.now(), input.turnId);
2055
+ const turn = this.getMemoryTurn(input.turnId);
2056
+ if (!turn || turn.status !== 'skipped') {
2057
+ throw new Error(`Memory turn ${input.turnId} is not pending or does not exist`);
2058
+ }
2059
+ return turn;
2060
+ }
2061
+ markMemoryTurnStopPrompted(turnId, promptedAt = Date.now()) {
2062
+ return this.db.prepare(`
2063
+ UPDATE devflow_memory_turns SET stop_prompted_at = ?
2064
+ WHERE turn_id = ? AND status = 'pending' AND stop_prompted_at IS NULL
2065
+ `).run(promptedAt, turnId).changes === 1;
2066
+ }
2067
+ listMemoryTurns(projectRoot, sessionId, limit = 50) {
2068
+ const rows = (sessionId
2069
+ ? this.db.prepare(`SELECT * FROM devflow_memory_turns
2070
+ WHERE project_root = ? AND session_id = ? ORDER BY created_at DESC LIMIT ?`)
2071
+ .all(projectRoot, sessionId, limit)
2072
+ : this.db.prepare(`SELECT * FROM devflow_memory_turns
2073
+ WHERE project_root = ? ORDER BY created_at DESC LIMIT ?`)
2074
+ .all(projectRoot, limit));
2075
+ return rows.map(row => this.mapMemoryTurn(row));
2076
+ }
2077
+ insertHookFallback(record) {
2078
+ return this.db.prepare(`
2079
+ INSERT OR IGNORE INTO devflow_hook_fallbacks
2080
+ (id, project_root, session_id, tool_use_id, request_type, tool, reason,
2081
+ duration_ms, attempts, created_at)
2082
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2083
+ `).run(record.id, record.projectRoot, record.sessionId ?? null, record.toolUseId ?? null, record.requestType, record.tool, record.reason, record.durationMs, record.attempts, record.createdAt).changes === 1;
2084
+ }
2085
+ listHookFallbacks(filter = {}) {
2086
+ const conditions = [];
2087
+ const values = [];
2088
+ if (filter.projectRoot) {
2089
+ conditions.push('project_root = ?');
2090
+ values.push(filter.projectRoot);
2091
+ }
2092
+ if (filter.sessionId) {
2093
+ conditions.push('session_id = ?');
2094
+ values.push(filter.sessionId);
2095
+ }
2096
+ if (filter.from !== undefined) {
2097
+ conditions.push('created_at >= ?');
2098
+ values.push(filter.from);
2099
+ }
2100
+ if (filter.to !== undefined) {
2101
+ conditions.push('created_at <= ?');
2102
+ values.push(filter.to);
2103
+ }
2104
+ values.push(Math.max(1, Math.min(filter.limit ?? 500, 5000)));
2105
+ const rows = this.db.prepare(`
2106
+ SELECT * FROM devflow_hook_fallbacks
2107
+ ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
2108
+ ORDER BY created_at DESC LIMIT ?
2109
+ `).all(...values);
2110
+ return rows.map(row => ({
2111
+ id: row.id,
2112
+ projectRoot: row.project_root,
2113
+ sessionId: row.session_id ?? undefined,
2114
+ toolUseId: row.tool_use_id ?? undefined,
2115
+ requestType: row.request_type,
2116
+ tool: row.tool,
2117
+ reason: row.reason,
2118
+ durationMs: row.duration_ms,
2119
+ attempts: row.attempts,
2120
+ createdAt: row.created_at,
2121
+ }));
2122
+ }
2123
+ mapMemoryTurn(row) {
2124
+ let memoryIds = [];
2125
+ try {
2126
+ const parsed = JSON.parse(row.memory_ids ?? '[]');
2127
+ if (Array.isArray(parsed))
2128
+ memoryIds = parsed.filter((id) => typeof id === 'string');
2129
+ }
2130
+ catch { }
2131
+ return {
2132
+ turnId: row.turn_id,
2133
+ projectRoot: row.project_root,
2134
+ sessionId: row.session_id,
2135
+ promptHash: row.prompt_hash,
2136
+ eventId: row.event_id,
2137
+ status: row.status,
2138
+ receiptId: row.receipt_id ?? undefined,
2139
+ memoryIds,
2140
+ source: row.source ?? undefined,
2141
+ reason: row.reason ?? undefined,
2142
+ stopPromptedAt: row.stop_prompted_at ?? undefined,
2143
+ createdAt: row.created_at,
2144
+ decidedAt: row.decided_at ?? undefined,
2145
+ };
2146
+ }
1480
2147
  recordMemoryDistillCheckpoint(checkpoint) {
1481
2148
  this.db.prepare(`
1482
2149
  INSERT INTO devflow_memory_distill_checkpoints
@@ -1570,3 +2237,32 @@ function countTelemetryResults(value, depth = 0) {
1570
2237
  return 0;
1571
2238
  return Object.keys(record).length > 0 ? 1 : 0;
1572
2239
  }
2240
+ function collectCanonicalReceiptIds(values) {
2241
+ const receiptIds = new Set();
2242
+ const visit = (value, depth) => {
2243
+ if (depth > 5 || value == null)
2244
+ return;
2245
+ if (typeof value === 'string') {
2246
+ if (value.startsWith('memory-receipt:') || value.startsWith('distill-receipt:')) {
2247
+ receiptIds.add(value);
2248
+ return;
2249
+ }
2250
+ if ((value.startsWith('{') || value.startsWith('[')) && value.length < 1000000) {
2251
+ try {
2252
+ visit(JSON.parse(value), depth + 1);
2253
+ }
2254
+ catch { }
2255
+ }
2256
+ return;
2257
+ }
2258
+ if (Array.isArray(value)) {
2259
+ value.forEach(item => visit(item, depth + 1));
2260
+ return;
2261
+ }
2262
+ if (typeof value === 'object') {
2263
+ Object.values(value).forEach(item => visit(item, depth + 1));
2264
+ }
2265
+ };
2266
+ values.forEach(value => visit(value, 0));
2267
+ return [...receiptIds];
2268
+ }