@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/CHANGELOG.md +16 -0
- package/__tests__/database.memory-turn-receipts.test.ts +27 -0
- package/__tests__/database.skill-executions.test.ts +94 -1
- package/__tests__/database.work-queue.test.ts +240 -0
- package/dist/database.d.ts +22 -0
- package/dist/database.js +514 -16
- package/dist/index.d.ts +1 -0
- package/dist/node-sqlite.js +1 -6
- package/dist/work-queue.d.ts +74 -0
- package/dist/work-queue.js +2 -0
- package/package.json +3 -3
- package/src/database.ts +605 -15
- package/src/index.ts +12 -0
- package/src/node-sqlite.ts +1 -6
- package/src/work-queue.ts +94 -0
- package/tsconfig.tsbuildinfo +1 -0
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.
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
1063
|
-
? metadata
|
|
1064
|
-
:
|
|
1065
|
-
const
|
|
1066
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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';
|
package/dist/node-sqlite.js
CHANGED
|
@@ -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
|
},
|