@devflow-tools/database 0.16.10 → 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/__tests__/database.skill-executions.test.ts +32 -0
- package/__tests__/database.work-queue.test.ts +240 -0
- package/dist/database.d.ts +17 -0
- package/dist/database.js +398 -0
- package/dist/index.d.ts +1 -0
- package/dist/work-queue.d.ts +74 -0
- package/dist/work-queue.js +2 -0
- package/package.json +2 -2
- package/src/database.ts +474 -0
- package/src/index.ts +12 -0
- 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
|
]);
|
|
@@ -335,6 +336,52 @@ class DevFlowDatabase {
|
|
|
335
336
|
);
|
|
336
337
|
CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
|
|
337
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);
|
|
338
385
|
`);
|
|
339
386
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
340
387
|
try {
|
|
@@ -1103,6 +1150,22 @@ class DevFlowDatabase {
|
|
|
1103
1150
|
},
|
|
1104
1151
|
});
|
|
1105
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
|
+
}
|
|
1106
1169
|
getExecutionObligationCompliance(executionId) {
|
|
1107
1170
|
const execution = this.getSkillExecution(executionId);
|
|
1108
1171
|
const events = this.listToolCallEvents(executionId);
|
|
@@ -1526,6 +1589,341 @@ class DevFlowDatabase {
|
|
|
1526
1589
|
total: Number(count?.total ?? 0),
|
|
1527
1590
|
};
|
|
1528
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
|
+
}
|
|
1529
1927
|
// ---- Hook Lifecycle ----
|
|
1530
1928
|
getHookReceipt(projectRoot) {
|
|
1531
1929
|
const row = this.db.prepare(`
|
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';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type WorkKind = 'memory.explicit_commit' | 'memory.turn_capture' | 'memory.turn_distill' | 'memory.vector_backfill' | 'session.bootstrap' | 'session.finalize' | 'telemetry.reconcile';
|
|
2
|
+
export type WorkState = 'pending' | 'leased' | 'completed' | 'failed' | 'dead_letter';
|
|
3
|
+
export interface WorkError {
|
|
4
|
+
category: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface WorkItemRecord {
|
|
8
|
+
id: string;
|
|
9
|
+
idempotencyKey: string;
|
|
10
|
+
kind: WorkKind;
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
turnId?: string;
|
|
14
|
+
payload: unknown;
|
|
15
|
+
state: WorkState;
|
|
16
|
+
attempts: number;
|
|
17
|
+
maxAttempts: number;
|
|
18
|
+
leaseOwner?: string;
|
|
19
|
+
leaseExpiresAt?: number;
|
|
20
|
+
nextAttemptAt: number;
|
|
21
|
+
errorCategory?: string;
|
|
22
|
+
errorMessage?: string;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
completedAt?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface EnqueueWorkInput {
|
|
28
|
+
idempotencyKey: string;
|
|
29
|
+
kind: WorkKind;
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
turnId?: string;
|
|
33
|
+
payload: unknown;
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
nextAttemptAt?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface LeaseWorkInput {
|
|
38
|
+
projectRoot: string;
|
|
39
|
+
owner: string;
|
|
40
|
+
kinds?: WorkKind[];
|
|
41
|
+
limit: number;
|
|
42
|
+
leaseMs: number;
|
|
43
|
+
now?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface WorkQueueHealth {
|
|
46
|
+
projectRoot: string;
|
|
47
|
+
queueDepth: number;
|
|
48
|
+
pending: number;
|
|
49
|
+
leased: number;
|
|
50
|
+
failed: number;
|
|
51
|
+
expiredLeases: number;
|
|
52
|
+
deadLetters: number;
|
|
53
|
+
oldestPendingAgeMs: number;
|
|
54
|
+
lastSuccessfulDrainAt?: number;
|
|
55
|
+
}
|
|
56
|
+
export type SessionClosureState = 'active' | 'closing' | 'closed' | 'closed_with_pending_work';
|
|
57
|
+
export interface SessionClosureRecord {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
projectRoot: string;
|
|
60
|
+
state: SessionClosureState;
|
|
61
|
+
receiptId: string;
|
|
62
|
+
workItemId?: string;
|
|
63
|
+
pendingWorkCount: number;
|
|
64
|
+
requestedAt: number;
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
closedAt?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface RequestSessionClosureInput {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
projectRoot: string;
|
|
71
|
+
receiptId: string;
|
|
72
|
+
payload: unknown;
|
|
73
|
+
maxAttempts?: number;
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devflow-tools/database",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.11",
|
|
4
4
|
"description": "DevFlow SQLite database package",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -13,5 +13,5 @@
|
|
|
13
13
|
"typescript": "^5.5.0",
|
|
14
14
|
"vitest": "^2.0.0"
|
|
15
15
|
},
|
|
16
|
-
"gitHead": "
|
|
16
|
+
"gitHead": "d580af1e"
|
|
17
17
|
}
|