@devflow-tools/database 0.16.10 → 0.16.12

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,6 +7,7 @@ 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
  ]);
@@ -293,6 +294,7 @@ class DevFlowDatabase {
293
294
  trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
294
295
  pending_events INTEGER NOT NULL DEFAULT 0,
295
296
  released_leases INTEGER NOT NULL DEFAULT 0,
297
+ details TEXT NOT NULL DEFAULT '{}',
296
298
  created_at INTEGER NOT NULL
297
299
  );
298
300
 
@@ -335,6 +337,52 @@ class DevFlowDatabase {
335
337
  );
336
338
  CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
337
339
  ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
340
+
341
+ CREATE TABLE IF NOT EXISTS devflow_work_items (
342
+ id TEXT PRIMARY KEY,
343
+ idempotency_key TEXT NOT NULL UNIQUE,
344
+ kind TEXT NOT NULL,
345
+ project_root TEXT NOT NULL,
346
+ session_id TEXT,
347
+ turn_id TEXT,
348
+ payload TEXT NOT NULL,
349
+ state TEXT NOT NULL DEFAULT 'pending'
350
+ CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
351
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
352
+ max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
353
+ lease_owner TEXT,
354
+ lease_expires_at INTEGER,
355
+ next_attempt_at INTEGER NOT NULL,
356
+ error_category TEXT,
357
+ error_message TEXT,
358
+ created_at INTEGER NOT NULL,
359
+ updated_at INTEGER NOT NULL,
360
+ completed_at INTEGER
361
+ );
362
+
363
+ CREATE INDEX IF NOT EXISTS idx_work_items_ready
364
+ ON devflow_work_items(project_root, state, next_attempt_at, created_at);
365
+ CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
366
+ ON devflow_work_items(project_root, state, lease_expires_at);
367
+ CREATE INDEX IF NOT EXISTS idx_work_items_completed
368
+ ON devflow_work_items(project_root, completed_at DESC);
369
+
370
+ CREATE TABLE IF NOT EXISTS devflow_session_closures (
371
+ session_id TEXT NOT NULL,
372
+ project_root TEXT NOT NULL,
373
+ state TEXT NOT NULL DEFAULT 'active'
374
+ CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
375
+ receipt_id TEXT NOT NULL UNIQUE,
376
+ work_item_id TEXT,
377
+ pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
378
+ requested_at INTEGER NOT NULL,
379
+ updated_at INTEGER NOT NULL,
380
+ closed_at INTEGER,
381
+ PRIMARY KEY (project_root, session_id)
382
+ );
383
+
384
+ CREATE INDEX IF NOT EXISTS idx_session_closures_state
385
+ ON devflow_session_closures(project_root, state, updated_at DESC);
338
386
  `);
339
387
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
340
388
  try {
@@ -377,6 +425,10 @@ class DevFlowDatabase {
377
425
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT');
378
426
  }
379
427
  catch { }
428
+ try {
429
+ this.db.exec("ALTER TABLE devflow_memory_distill_checkpoints ADD COLUMN details TEXT NOT NULL DEFAULT '{}'");
430
+ }
431
+ catch { }
380
432
  try {
381
433
  this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0');
382
434
  }
@@ -840,6 +892,15 @@ class DevFlowDatabase {
840
892
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
841
893
  };
842
894
  }
895
+ getLatestSkillExecutionForSession(sessionId) {
896
+ const row = this.db.prepare(`
897
+ SELECT execution_id FROM skill_executions
898
+ WHERE session_id = ?
899
+ ORDER BY started_at DESC, execution_id DESC
900
+ LIMIT 1
901
+ `).get(sessionId);
902
+ return row?.execution_id ? this.getSkillExecution(row.execution_id) : null;
903
+ }
843
904
  updateSkillExecution(executionId, updates) {
844
905
  const sets = [];
845
906
  const values = [];
@@ -904,6 +965,17 @@ class DevFlowDatabase {
904
965
  this.db.prepare(`UPDATE skill_executions SET ${sets.join(', ')} WHERE execution_id = ?`).run(...values);
905
966
  }
906
967
  }
968
+ mergeSkillExecutionMetadata(executionId, incoming) {
969
+ const execution = this.getSkillExecution(executionId);
970
+ if (!execution)
971
+ return null;
972
+ const existing = execution.metadata && typeof execution.metadata === 'object'
973
+ ? execution.metadata
974
+ : {};
975
+ const merged = mergeExecutionMetadata(existing, incoming);
976
+ this.updateSkillExecution(executionId, { metadata: merged });
977
+ return merged;
978
+ }
907
979
  listSkillExecutions(limit, skillName) {
908
980
  let query = 'SELECT * FROM skill_executions';
909
981
  const params = [];
@@ -1048,23 +1120,38 @@ class DevFlowDatabase {
1048
1120
  const blocked = events.filter(event => event.blocked);
1049
1121
  const directFallbacks = events.filter(event => event.mcpFallback);
1050
1122
  const hookFallbacks = execution?.sessionId
1051
- ? this.listHookFallbacks({
1052
- sessionId: execution.sessionId,
1053
- from: execution.startedAt,
1054
- to: finishedAt,
1055
- })
1123
+ ? this.db.prepare(`
1124
+ SELECT * FROM devflow_hook_fallbacks
1125
+ WHERE session_id = ? AND created_at >= ? AND created_at <= ?
1126
+ ORDER BY created_at ASC
1127
+ `).all(execution.sessionId, execution.startedAt, finishedAt).map(row => ({
1128
+ id: row.id,
1129
+ projectRoot: row.project_root,
1130
+ sessionId: row.session_id ?? undefined,
1131
+ toolUseId: row.tool_use_id ?? undefined,
1132
+ requestType: row.request_type,
1133
+ tool: row.tool,
1134
+ reason: row.reason,
1135
+ durationMs: row.duration_ms,
1136
+ attempts: row.attempts,
1137
+ createdAt: row.created_at,
1138
+ }))
1056
1139
  : [];
1057
1140
  const fallbackReasons = [...new Set([
1058
1141
  ...directFallbacks.map(() => 'direct_tool_during_context'),
1059
1142
  ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1060
1143
  ])];
1061
1144
  const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1062
- const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1063
- ? metadata.memoryReceiptIds.filter((id) => typeof id === 'string')
1064
- : [];
1065
- const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1066
- ? metadata.distillReceiptIds.filter((id) => typeof id === 'string')
1067
- : [];
1145
+ const existingMetadata = execution?.metadata && typeof execution.metadata === 'object'
1146
+ ? execution.metadata
1147
+ : {};
1148
+ const mergedMetadata = mergeExecutionMetadata(existingMetadata, metadata ?? {});
1149
+ const suppliedMemoryReceiptIds = [existingMetadata.memoryReceiptIds, metadata?.memoryReceiptIds]
1150
+ .flatMap(value => Array.isArray(value) ? value : [])
1151
+ .filter((id) => typeof id === 'string');
1152
+ const suppliedDistillReceiptIds = [existingMetadata.distillReceiptIds, metadata?.distillReceiptIds]
1153
+ .flatMap(value => Array.isArray(value) ? value : [])
1154
+ .filter((id) => typeof id === 'string');
1068
1155
  const memoryReceiptIds = [...new Set([
1069
1156
  ...suppliedMemoryReceiptIds,
1070
1157
  ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
@@ -1090,7 +1177,7 @@ class DevFlowDatabase {
1090
1177
  fallbackCount: directFallbacks.length + hookFallbacks.length,
1091
1178
  fallbackReasons,
1092
1179
  metadata: {
1093
- ...(metadata ?? {}),
1180
+ ...mergedMetadata,
1094
1181
  applicableObligations: obligation.applicable,
1095
1182
  satisfiedObligations: obligation.satisfied,
1096
1183
  missedTools: obligation.missedTools,
@@ -1103,6 +1190,22 @@ class DevFlowDatabase {
1103
1190
  },
1104
1191
  });
1105
1192
  }
1193
+ reconcileRunningSkillExecutionsForSession(sessionId, finishedAt = Date.now(), metadata) {
1194
+ const rows = this.db.prepare(`
1195
+ SELECT execution_id
1196
+ FROM skill_executions
1197
+ WHERE session_id = ? AND status = 'running'
1198
+ ORDER BY started_at ASC
1199
+ `).all(sessionId);
1200
+ const reconciled = [];
1201
+ for (const row of rows) {
1202
+ this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
1203
+ const execution = this.getSkillExecution(row.execution_id);
1204
+ if (execution)
1205
+ reconciled.push(execution);
1206
+ }
1207
+ return reconciled;
1208
+ }
1106
1209
  getExecutionObligationCompliance(executionId) {
1107
1210
  const execution = this.getSkillExecution(executionId);
1108
1211
  const events = this.listToolCallEvents(executionId);
@@ -1526,6 +1629,351 @@ class DevFlowDatabase {
1526
1629
  total: Number(count?.total ?? 0),
1527
1630
  };
1528
1631
  }
1632
+ // ---- Durable Work Queue ----
1633
+ enqueueWork(input) {
1634
+ if (!input.idempotencyKey.trim())
1635
+ throw new Error('Work idempotency key is required');
1636
+ if (!input.projectRoot.trim())
1637
+ throw new Error('Work project root is required');
1638
+ const maxAttempts = input.maxAttempts ?? 5;
1639
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
1640
+ throw new Error('Work maxAttempts must be a positive safe integer');
1641
+ }
1642
+ const createdAt = Date.now();
1643
+ const nextAttemptAt = input.nextAttemptAt ?? createdAt;
1644
+ if (!Number.isSafeInteger(nextAttemptAt)) {
1645
+ throw new Error('Work nextAttemptAt must be a safe integer');
1646
+ }
1647
+ const row = this.db.prepare(`
1648
+ INSERT INTO devflow_work_items (
1649
+ id, idempotency_key, kind, project_root, session_id, turn_id, payload,
1650
+ state, attempts, max_attempts, lease_owner, lease_expires_at,
1651
+ next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
1652
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
1653
+ ON CONFLICT(idempotency_key) DO UPDATE SET
1654
+ state = CASE
1655
+ WHEN devflow_work_items.state = 'dead_letter'
1656
+ AND excluded.max_attempts > devflow_work_items.attempts
1657
+ THEN 'failed'
1658
+ ELSE devflow_work_items.state
1659
+ END,
1660
+ max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
1661
+ next_attempt_at = CASE
1662
+ WHEN devflow_work_items.state = 'dead_letter'
1663
+ AND excluded.max_attempts > devflow_work_items.attempts
1664
+ THEN excluded.next_attempt_at
1665
+ ELSE devflow_work_items.next_attempt_at
1666
+ END,
1667
+ error_category = CASE
1668
+ WHEN devflow_work_items.state = 'dead_letter'
1669
+ AND excluded.max_attempts > devflow_work_items.attempts
1670
+ THEN NULL
1671
+ ELSE devflow_work_items.error_category
1672
+ END,
1673
+ error_message = CASE
1674
+ WHEN devflow_work_items.state = 'dead_letter'
1675
+ AND excluded.max_attempts > devflow_work_items.attempts
1676
+ THEN NULL
1677
+ ELSE devflow_work_items.error_message
1678
+ END,
1679
+ updated_at = CASE
1680
+ WHEN devflow_work_items.state = 'dead_letter'
1681
+ AND excluded.max_attempts > devflow_work_items.attempts
1682
+ THEN excluded.updated_at
1683
+ ELSE devflow_work_items.updated_at
1684
+ END
1685
+ RETURNING *
1686
+ `).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);
1687
+ return this.mapWorkItem(row);
1688
+ }
1689
+ getWorkByIdempotencyKey(idempotencyKey) {
1690
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE idempotency_key = ?').get(idempotencyKey);
1691
+ return row ? this.mapWorkItem(row) : null;
1692
+ }
1693
+ requestSessionClosure(input) {
1694
+ if (!input.sessionId.trim())
1695
+ throw new Error('Session closure requires a session ID');
1696
+ if (!input.projectRoot.trim())
1697
+ throw new Error('Session closure requires a project root');
1698
+ if (!input.receiptId.trim())
1699
+ throw new Error('Session closure requires a receipt ID');
1700
+ return this.db.transaction(() => {
1701
+ const now = Date.now();
1702
+ this.db.prepare(`
1703
+ INSERT INTO devflow_session_closures (
1704
+ session_id, project_root, state, receipt_id, pending_work_count,
1705
+ requested_at, updated_at
1706
+ ) VALUES (?, ?, 'active', ?, 0, ?, ?)
1707
+ ON CONFLICT(project_root, session_id) DO NOTHING
1708
+ `).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
1709
+ const work = this.enqueueWork({
1710
+ idempotencyKey: input.receiptId,
1711
+ kind: 'session.finalize',
1712
+ projectRoot: input.projectRoot,
1713
+ sessionId: input.sessionId,
1714
+ payload: input.payload,
1715
+ maxAttempts: input.maxAttempts,
1716
+ });
1717
+ const pending = this.db.prepare(`
1718
+ SELECT COUNT(*) AS count
1719
+ FROM devflow_work_items
1720
+ WHERE project_root = ? AND session_id = ?
1721
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1722
+ `).get(input.projectRoot, input.sessionId);
1723
+ const pendingWorkCount = Number(pending?.count ?? 0);
1724
+ this.db.prepare(`
1725
+ UPDATE devflow_session_closures
1726
+ SET state = CASE
1727
+ WHEN state IN ('closed', 'closed_with_pending_work')
1728
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
1729
+ WHEN ? = 'completed'
1730
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
1731
+ WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
1732
+ ELSE 'closing'
1733
+ END,
1734
+ work_item_id = COALESCE(work_item_id, ?),
1735
+ pending_work_count = ?,
1736
+ closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
1737
+ updated_at = ?
1738
+ WHERE project_root = ? AND session_id = ?
1739
+ `).run(pendingWorkCount, work.state, pendingWorkCount, work.state, work.id, pendingWorkCount, work.state, now, now, input.projectRoot, input.sessionId);
1740
+ return this.getSessionClosure(input.projectRoot, input.sessionId);
1741
+ });
1742
+ }
1743
+ getSessionClosure(projectRoot, sessionId) {
1744
+ const row = this.db.prepare(`
1745
+ SELECT * FROM devflow_session_closures
1746
+ WHERE project_root = ? AND session_id = ?
1747
+ `).get(projectRoot, sessionId);
1748
+ return row ? this.mapSessionClosure(row) : null;
1749
+ }
1750
+ completeSessionClosure(projectRoot, sessionId, excludingWorkItemId, closedAt = Date.now()) {
1751
+ const pending = this.db.prepare(`
1752
+ SELECT COUNT(*) AS count
1753
+ FROM devflow_work_items
1754
+ WHERE project_root = ? AND session_id = ?
1755
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1756
+ AND (? IS NULL OR id <> ?)
1757
+ `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null);
1758
+ const pendingWorkCount = Number(pending?.count ?? 0);
1759
+ const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
1760
+ this.db.prepare(`
1761
+ UPDATE devflow_session_closures
1762
+ SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
1763
+ WHERE project_root = ? AND session_id = ? AND state = 'closing'
1764
+ `).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
1765
+ const closure = this.getSessionClosure(projectRoot, sessionId);
1766
+ if (!closure)
1767
+ throw new Error(`Session closure ${sessionId} does not exist`);
1768
+ return closure;
1769
+ }
1770
+ listSessionClosures(projectRoot, limit = 100) {
1771
+ const rows = this.db.prepare(`
1772
+ SELECT * FROM devflow_session_closures
1773
+ WHERE project_root = ?
1774
+ ORDER BY updated_at DESC
1775
+ LIMIT ?
1776
+ `).all(projectRoot, Math.max(1, Math.min(limit, 1000)));
1777
+ return rows.map(row => this.mapSessionClosure(row));
1778
+ }
1779
+ leaseWork(input) {
1780
+ if (!input.owner.trim())
1781
+ throw new Error('Work lease owner is required');
1782
+ if (!Number.isFinite(input.limit) || input.limit <= 0)
1783
+ return [];
1784
+ if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
1785
+ throw new Error('Work leaseMs must be a positive safe integer');
1786
+ }
1787
+ if (input.kinds?.length === 0)
1788
+ return [];
1789
+ const now = input.now ?? Date.now();
1790
+ const leaseExpiresAt = now + input.leaseMs;
1791
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
1792
+ throw new Error('Work lease timestamps must be safe integers');
1793
+ }
1794
+ const limit = Math.min(Math.floor(input.limit), 1000);
1795
+ const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
1796
+ const kindClause = kinds
1797
+ ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
1798
+ : '';
1799
+ this.db.exec('BEGIN IMMEDIATE');
1800
+ try {
1801
+ const candidates = this.db.prepare(`
1802
+ SELECT id, state
1803
+ FROM devflow_work_items
1804
+ WHERE project_root = ?
1805
+ AND state IN ('pending', 'failed')
1806
+ AND lease_owner IS NULL
1807
+ AND lease_expires_at IS NULL
1808
+ AND next_attempt_at <= ?
1809
+ AND attempts < max_attempts
1810
+ ${kindClause}
1811
+ ORDER BY next_attempt_at ASC, created_at ASC, id ASC
1812
+ LIMIT ?
1813
+ `).all(input.projectRoot, now, ...(kinds ?? []), limit);
1814
+ const leased = [];
1815
+ for (const candidate of candidates) {
1816
+ const result = this.db.prepare(`
1817
+ UPDATE devflow_work_items
1818
+ SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
1819
+ lease_expires_at = ?, updated_at = ?
1820
+ WHERE id = ? AND project_root = ? AND state = ?
1821
+ AND lease_owner IS NULL AND lease_expires_at IS NULL
1822
+ AND next_attempt_at <= ? AND attempts < max_attempts
1823
+ `).run(input.owner, leaseExpiresAt, now, candidate.id, input.projectRoot, candidate.state, now);
1824
+ if (result.changes === 1) {
1825
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
1826
+ .get(candidate.id);
1827
+ leased.push(this.mapWorkItem(row));
1828
+ }
1829
+ }
1830
+ this.db.exec('COMMIT');
1831
+ return leased;
1832
+ }
1833
+ catch (error) {
1834
+ try {
1835
+ this.db.exec('ROLLBACK');
1836
+ }
1837
+ catch { }
1838
+ throw error;
1839
+ }
1840
+ }
1841
+ completeWork(id, owner, now = Date.now()) {
1842
+ const work = this.db.prepare(`
1843
+ SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
1844
+ `).get(id);
1845
+ const completed = this.db.prepare(`
1846
+ UPDATE devflow_work_items
1847
+ SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
1848
+ updated_at = ?, completed_at = ?
1849
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1850
+ `).run(now, now, id, owner).changes === 1;
1851
+ if (completed && work?.project_root && work.session_id) {
1852
+ this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
1853
+ }
1854
+ return completed;
1855
+ }
1856
+ retryWork(id, owner, error, nextAttemptAt) {
1857
+ const updatedAt = Date.now();
1858
+ return this.db.prepare(`
1859
+ UPDATE devflow_work_items
1860
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
1861
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1862
+ error_category = ?, error_message = ?, updated_at = ?
1863
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1864
+ `).run(nextAttemptAt, error.category, error.message, updatedAt, id, owner).changes === 1;
1865
+ }
1866
+ deferWork(id, owner, error, nextAttemptAt) {
1867
+ const updatedAt = Date.now();
1868
+ return this.db.prepare(`
1869
+ UPDATE devflow_work_items
1870
+ SET state = 'pending', attempts = MAX(0, attempts - 1),
1871
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1872
+ error_category = ?, error_message = ?, updated_at = ?
1873
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1874
+ `).run(nextAttemptAt, error.category, error.message, updatedAt, id, owner).changes === 1;
1875
+ }
1876
+ deadLetterWork(id, owner, error) {
1877
+ return this.db.prepare(`
1878
+ UPDATE devflow_work_items
1879
+ SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
1880
+ error_category = ?, error_message = ?, updated_at = ?
1881
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
1882
+ `).run(error.category, error.message, Date.now(), id, owner).changes === 1;
1883
+ }
1884
+ recoverExpiredWork(projectRoot, now = Date.now()) {
1885
+ return this.db.prepare(`
1886
+ UPDATE devflow_work_items
1887
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
1888
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
1889
+ error_category = 'lease_expired',
1890
+ error_message = 'Work lease expired before completion',
1891
+ updated_at = ?
1892
+ WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
1893
+ `).run(now, now, projectRoot, now).changes;
1894
+ }
1895
+ getWorkQueueHealth(projectRoot, now = Date.now()) {
1896
+ const row = this.db.prepare(`
1897
+ SELECT
1898
+ COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
1899
+ COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
1900
+ COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
1901
+ COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
1902
+ COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
1903
+ COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
1904
+ MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
1905
+ MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
1906
+ FROM devflow_work_items
1907
+ WHERE project_root = ?
1908
+ `).get(now, projectRoot);
1909
+ const oldestPendingAt = row.oldest_pending_at == null
1910
+ ? undefined
1911
+ : Number(row.oldest_pending_at);
1912
+ return {
1913
+ projectRoot,
1914
+ queueDepth: Number(row.queue_depth),
1915
+ pending: Number(row.pending),
1916
+ leased: Number(row.leased),
1917
+ failed: Number(row.failed),
1918
+ expiredLeases: Number(row.expired_leases),
1919
+ deadLetters: Number(row.dead_letters),
1920
+ oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
1921
+ lastSuccessfulDrainAt: row.last_successful_drain_at == null
1922
+ ? undefined
1923
+ : Number(row.last_successful_drain_at),
1924
+ };
1925
+ }
1926
+ mapWorkItem(row) {
1927
+ return {
1928
+ id: row.id,
1929
+ idempotencyKey: row.idempotency_key,
1930
+ kind: row.kind,
1931
+ projectRoot: row.project_root,
1932
+ sessionId: row.session_id ?? undefined,
1933
+ turnId: row.turn_id ?? undefined,
1934
+ payload: parseJson(row.payload),
1935
+ state: row.state,
1936
+ attempts: Number(row.attempts),
1937
+ maxAttempts: Number(row.max_attempts),
1938
+ leaseOwner: row.lease_owner ?? undefined,
1939
+ leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
1940
+ nextAttemptAt: Number(row.next_attempt_at),
1941
+ errorCategory: row.error_category ?? undefined,
1942
+ errorMessage: row.error_message ?? undefined,
1943
+ createdAt: Number(row.created_at),
1944
+ updatedAt: Number(row.updated_at),
1945
+ completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
1946
+ };
1947
+ }
1948
+ mapSessionClosure(row) {
1949
+ return {
1950
+ sessionId: row.session_id,
1951
+ projectRoot: row.project_root,
1952
+ state: row.state,
1953
+ receiptId: row.receipt_id,
1954
+ workItemId: row.work_item_id ?? undefined,
1955
+ pendingWorkCount: Number(row.pending_work_count),
1956
+ requestedAt: Number(row.requested_at),
1957
+ updatedAt: Number(row.updated_at),
1958
+ closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
1959
+ };
1960
+ }
1961
+ refreshClosedSessionClosure(projectRoot, sessionId, now) {
1962
+ const pending = this.db.prepare(`
1963
+ SELECT COUNT(*) AS count
1964
+ FROM devflow_work_items
1965
+ WHERE project_root = ? AND session_id = ?
1966
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
1967
+ `).get(projectRoot, sessionId);
1968
+ const pendingWorkCount = Number(pending?.count ?? 0);
1969
+ this.db.prepare(`
1970
+ UPDATE devflow_session_closures
1971
+ SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
1972
+ pending_work_count = ?, updated_at = ?
1973
+ WHERE project_root = ? AND session_id = ?
1974
+ AND state IN ('closed', 'closed_with_pending_work')
1975
+ `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
1976
+ }
1529
1977
  // ---- Hook Lifecycle ----
1530
1978
  getHookReceipt(projectRoot) {
1531
1979
  const row = this.db.prepare(`
@@ -1676,6 +2124,14 @@ class DevFlowDatabase {
1676
2124
  .all(projectRoot, limit));
1677
2125
  return rows.map(row => this.mapMemoryTurn(row));
1678
2126
  }
2127
+ listSessionMemoryTurnsForReconciliation(projectRoot, sessionId) {
2128
+ const rows = this.db.prepare(`
2129
+ SELECT * FROM devflow_memory_turns
2130
+ WHERE project_root = ? AND session_id = ?
2131
+ ORDER BY created_at ASC, turn_id ASC
2132
+ `).all(projectRoot, sessionId);
2133
+ return rows.map(row => this.mapMemoryTurn(row));
2134
+ }
1679
2135
  insertHookFallback(record) {
1680
2136
  return this.db.prepare(`
1681
2137
  INSERT OR IGNORE INTO devflow_hook_fallbacks
@@ -1749,13 +2205,18 @@ class DevFlowDatabase {
1749
2205
  recordMemoryDistillCheckpoint(checkpoint) {
1750
2206
  this.db.prepare(`
1751
2207
  INSERT INTO devflow_memory_distill_checkpoints
1752
- (id, project_root, session_id, trigger, pending_events, released_leases, created_at)
1753
- VALUES (?, ?, ?, ?, ?, ?, ?)
1754
- `).run(checkpoint.id, checkpoint.projectRoot, checkpoint.sessionId ?? null, checkpoint.trigger, checkpoint.pendingEvents, checkpoint.releasedLeases, checkpoint.createdAt);
2208
+ (id, project_root, session_id, trigger, pending_events, released_leases, details, created_at)
2209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2210
+ ON CONFLICT(id) DO UPDATE SET
2211
+ pending_events = excluded.pending_events,
2212
+ released_leases = MAX(devflow_memory_distill_checkpoints.released_leases, excluded.released_leases),
2213
+ details = excluded.details,
2214
+ created_at = MIN(devflow_memory_distill_checkpoints.created_at, excluded.created_at)
2215
+ `).run(checkpoint.id, checkpoint.projectRoot, checkpoint.sessionId ?? null, checkpoint.trigger, checkpoint.pendingEvents, checkpoint.releasedLeases, JSON.stringify(checkpoint.details ?? {}), checkpoint.createdAt);
1755
2216
  }
1756
2217
  listMemoryDistillCheckpoints(projectRoot, limit = 50) {
1757
2218
  const rows = this.db.prepare(`
1758
- SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
2219
+ SELECT id, project_root, session_id, trigger, pending_events, released_leases, details, created_at
1759
2220
  FROM devflow_memory_distill_checkpoints
1760
2221
  WHERE project_root = ?
1761
2222
  ORDER BY created_at DESC
@@ -1768,6 +2229,7 @@ class DevFlowDatabase {
1768
2229
  trigger: row.trigger,
1769
2230
  pendingEvents: Number(row.pending_events),
1770
2231
  releasedLeases: Number(row.released_leases),
2232
+ details: row.details ? JSON.parse(row.details) : {},
1771
2233
  createdAt: Number(row.created_at),
1772
2234
  }));
1773
2235
  }
@@ -1868,3 +2330,39 @@ function collectCanonicalReceiptIds(values) {
1868
2330
  values.forEach(value => visit(value, 0));
1869
2331
  return [...receiptIds];
1870
2332
  }
2333
+ function stableMetadataKey(value) {
2334
+ if (value === null || typeof value !== 'object')
2335
+ return JSON.stringify(value) ?? String(value);
2336
+ if (Array.isArray(value))
2337
+ return `[${value.map(stableMetadataKey).join(',')}]`;
2338
+ const record = value;
2339
+ return `{${Object.keys(record).sort()
2340
+ .map(key => `${JSON.stringify(key)}:${stableMetadataKey(record[key])}`)
2341
+ .join(',')}}`;
2342
+ }
2343
+ function unionMetadataArrays(existing, incoming) {
2344
+ const values = [
2345
+ ...(Array.isArray(existing) ? existing : []),
2346
+ ...(Array.isArray(incoming) ? incoming : []),
2347
+ ];
2348
+ if (values.length === 0 && !Array.isArray(existing) && !Array.isArray(incoming))
2349
+ return undefined;
2350
+ const unique = new Map();
2351
+ for (const value of values)
2352
+ unique.set(stableMetadataKey(value), value);
2353
+ return [...unique.values()];
2354
+ }
2355
+ function mergeExecutionMetadata(existing, incoming) {
2356
+ const merged = { ...existing, ...incoming };
2357
+ for (const key of [
2358
+ 'evidenceDegradations',
2359
+ 'evidenceContractOutcomes',
2360
+ 'pendingEvidenceContractIds',
2361
+ 'sessionFinalizationDegradations',
2362
+ ]) {
2363
+ const union = unionMetadataArrays(existing[key], incoming[key]);
2364
+ if (union !== undefined)
2365
+ merged[key] = union;
2366
+ }
2367
+ return merged;
2368
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { DevFlowDatabase, getGlobalDevFlowDbPath, openGlobalDevFlowDatabase, } from './database';
2
2
  export type { BenchmarkReportMetaRecord, BenchmarkReportRecord, FeedbackRecord, GovernanceAuditRecord, GovernanceRuleRecord, HookFallbackRecord, HookReceiptRecord, MemoryDistillCheckpointRecord, MemoryTurnRecord, MemoryTurnStatus, TelemetryFailureRecord, } from './database';
3
+ export type { EnqueueWorkInput, LeaseWorkInput, RequestSessionClosureInput, SessionClosureRecord, SessionClosureState, WorkError, WorkItemRecord, WorkKind, WorkQueueHealth, WorkState, } from './work-queue';
@@ -1,11 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NodeSqliteDatabase = void 0;
4
- // node:sqlite is a Node.js 22+ experimental built-in. TypeScript does not
5
- // resolve `node:` prefixed modules unless @types/node includes them (which
6
- // requires @types/node >= 22.5). We suppress the TS error here and rely on
7
- // the ambient types defined below until the project upgrades @types/node.
8
- // @ts-expect-error - node:sqlite types not available in @types/node@20
9
4
  const node_sqlite_1 = require("node:sqlite");
10
5
  class NodeSqliteDatabase {
11
6
  constructor(path, busyTimeoutMs = 10000) {
@@ -23,7 +18,7 @@ class NodeSqliteDatabase {
23
18
  run: (...params) => {
24
19
  const result = stmt.run(...normalizeParams(params));
25
20
  return {
26
- changes: result.changes,
21
+ changes: Number(result.changes),
27
22
  lastInsertRowid: result.lastInsertRowid,
28
23
  };
29
24
  },