@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/CHANGELOG.md +16 -0
- package/__tests__/database.memory-turn-receipts.test.ts +71 -0
- package/__tests__/database.skill-executions.test.ts +92 -5
- package/__tests__/database.work-queue.test.ts +240 -0
- package/dist/database.d.ts +77 -0
- package/dist/database.js +699 -3
- package/dist/index.d.ts +2 -1
- package/dist/work-queue.d.ts +74 -0
- package/dist/work-queue.js +2 -0
- package/package.json +2 -2
- package/src/database.ts +828 -3
- package/src/index.ts +15 -0
- package/src/work-queue.ts +94 -0
- package/tsconfig.tsbuildinfo +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [0.16.10](https://github.com/shilongfeicool/dev-flow/compare/v0.16.9...v0.16.10) (2026-07-24)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **telemetry:** persist compliance failure facts ([fcb5a8b](https://github.com/shilongfeicool/dev-flow/commit/fcb5a8b4db37fc545927262ae841669395491b06))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **memory:** add canonical turn and explicit receipts ([eb3e178](https://github.com/shilongfeicool/dev-flow/commit/eb3e1786e17f5dbc2c4e12e4fa788af7095d335e))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
## [0.16.9](https://github.com/shilongfeicool/dev-flow/compare/v0.16.8...v0.16.9) (2026-07-23)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @devflow-tools/database
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { DevFlowDatabase } from '../src/database';
|
|
6
|
+
|
|
7
|
+
describe('DevFlowDatabase memory turn receipts', () => {
|
|
8
|
+
let directory: string;
|
|
9
|
+
let database: DevFlowDatabase;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
directory = mkdtempSync(join(tmpdir(), 'devflow-memory-turn-'));
|
|
13
|
+
database = new DevFlowDatabase(directory);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
database.close();
|
|
18
|
+
rmSync(directory, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('commits a pending turn with one canonical receipt', () => {
|
|
22
|
+
database.beginMemoryTurn({
|
|
23
|
+
turnId: 'turn:1',
|
|
24
|
+
projectRoot: '/project',
|
|
25
|
+
sessionId: 'session-1',
|
|
26
|
+
promptHash: 'hash',
|
|
27
|
+
eventId: 'event-1',
|
|
28
|
+
createdAt: 100,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const committed = database.commitMemoryTurn({
|
|
32
|
+
turnId: 'turn:1',
|
|
33
|
+
receiptId: 'memory-receipt:1',
|
|
34
|
+
memoryIds: ['memory-1'],
|
|
35
|
+
source: 'explicit_intent',
|
|
36
|
+
decidedAt: 200,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(committed).toMatchObject({
|
|
40
|
+
status: 'committed',
|
|
41
|
+
receiptId: 'memory-receipt:1',
|
|
42
|
+
memoryIds: ['memory-1'],
|
|
43
|
+
stopPromptedAt: undefined,
|
|
44
|
+
});
|
|
45
|
+
expect(database.getPendingMemoryTurn('/project', 'session-1')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks a pending Stop prompt only once and supports skip receipts', () => {
|
|
49
|
+
database.beginMemoryTurn({
|
|
50
|
+
turnId: 'turn:2',
|
|
51
|
+
projectRoot: '/project',
|
|
52
|
+
sessionId: 'session-1',
|
|
53
|
+
promptHash: 'hash-2',
|
|
54
|
+
eventId: 'event-2',
|
|
55
|
+
createdAt: 300,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(database.markMemoryTurnStopPrompted('turn:2', 350)).toBe(true);
|
|
59
|
+
expect(database.markMemoryTurnStopPrompted('turn:2', 360)).toBe(false);
|
|
60
|
+
expect(database.skipMemoryTurn({
|
|
61
|
+
turnId: 'turn:2',
|
|
62
|
+
receiptId: 'memory-receipt:2',
|
|
63
|
+
reason: 'transient request',
|
|
64
|
+
decidedAt: 400,
|
|
65
|
+
})).toMatchObject({
|
|
66
|
+
status: 'skipped',
|
|
67
|
+
reason: 'transient request',
|
|
68
|
+
stopPromptedAt: 350,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -154,19 +154,19 @@ describe('DevFlowDatabase - Skill Executions', () => {
|
|
|
154
154
|
eventId: 'evt_context_1', executionId: 'exec_001', timestamp: Date.now(),
|
|
155
155
|
toolName: 'mcp__devflow__get_project_context', toolType: 'mcp',
|
|
156
156
|
isMcpTool: true, mcpToolName: 'get_project_context', mcpEnforced: true,
|
|
157
|
-
mcpFallback: false, input: {}, duration: 20, blocked: false,
|
|
157
|
+
mcpFallback: false, input: {}, output: { files: ['src/App.tsx'] }, duration: 20, blocked: false,
|
|
158
158
|
});
|
|
159
159
|
db.insertToolCallEvent({
|
|
160
160
|
eventId: 'evt_001', executionId: 'exec_001', timestamp: Date.now(),
|
|
161
161
|
toolName: 'mcp__devflow__react_diagnose_bug', toolType: 'mcp',
|
|
162
162
|
isMcpTool: true, mcpEnforced: true, mcpFallback: false,
|
|
163
|
-
input: {}, tokensUsed: 100, duration: 500, blocked: false,
|
|
163
|
+
input: {}, output: { findings: [{ id: 'finding-1' }] }, tokensUsed: 100, duration: 500, blocked: false,
|
|
164
164
|
});
|
|
165
165
|
db.insertToolCallEvent({
|
|
166
166
|
eventId: 'evt_002', executionId: 'exec_001', timestamp: Date.now(),
|
|
167
167
|
toolName: 'mcp__devflow__react_review_hooks', toolType: 'mcp',
|
|
168
168
|
isMcpTool: true, mcpEnforced: true, mcpFallback: false,
|
|
169
|
-
input: {}, tokensUsed: 100, duration: 500, blocked: false,
|
|
169
|
+
input: {}, output: { findings: [{ id: 'finding-2' }] }, tokensUsed: 100, duration: 500, blocked: false,
|
|
170
170
|
});
|
|
171
171
|
db.insertToolCallEvent({
|
|
172
172
|
eventId: 'evt_003', executionId: 'exec_001', timestamp: Date.now(),
|
|
@@ -186,13 +186,13 @@ describe('DevFlowDatabase - Skill Executions', () => {
|
|
|
186
186
|
eventId: 'evt_context_2', executionId: 'exec_002', timestamp: Date.now(),
|
|
187
187
|
toolName: 'mcp__devflow__get_project_context', toolType: 'mcp',
|
|
188
188
|
isMcpTool: true, mcpToolName: 'get_project_context', mcpEnforced: true,
|
|
189
|
-
mcpFallback: false, input: {}, duration: 20, blocked: false,
|
|
189
|
+
mcpFallback: false, input: {}, output: { files: ['src/App.vue'] }, duration: 20, blocked: false,
|
|
190
190
|
});
|
|
191
191
|
db.insertToolCallEvent({
|
|
192
192
|
eventId: 'evt_004', executionId: 'exec_002', timestamp: Date.now(),
|
|
193
193
|
toolName: 'mcp__devflow__vue_diagnose_bug', toolType: 'mcp',
|
|
194
194
|
isMcpTool: true, mcpEnforced: true, mcpFallback: false,
|
|
195
|
-
input: {}, tokensUsed: 100, duration: 500, blocked: false,
|
|
195
|
+
input: {}, output: { findings: [{ id: 'finding-vue' }] }, tokensUsed: 100, duration: 500, blocked: false,
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
const compliance = db.getMcpCompliance();
|
|
@@ -204,6 +204,61 @@ describe('DevFlowDatabase - Skill Executions', () => {
|
|
|
204
204
|
expect(compliance.mcpCallShare).toBeCloseTo(83.33, 1);
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
+
it('persists missed tools, actual failures, and fallback reasons on reconciliation', () => {
|
|
208
|
+
db.ensureSession({ id: 'session-facts', projectRoot: '/project', startedAt: 100 });
|
|
209
|
+
db.insertSkillExecution({
|
|
210
|
+
executionId: 'exec-facts',
|
|
211
|
+
sessionId: 'session-facts',
|
|
212
|
+
skillName: 'devflow:react',
|
|
213
|
+
startedAt: 100,
|
|
214
|
+
status: 'running',
|
|
215
|
+
});
|
|
216
|
+
db.insertToolCallEvent({
|
|
217
|
+
eventId: 'event-failed', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 150,
|
|
218
|
+
toolName: 'mcp__devflow__get_project_context', toolType: 'mcp', isMcpTool: true,
|
|
219
|
+
mcpToolName: 'get_project_context', mcpEnforced: true, mcpFallback: false,
|
|
220
|
+
input: {}, error: 'route failed', duration: 20, blocked: false,
|
|
221
|
+
});
|
|
222
|
+
db.insertToolCallEvent({
|
|
223
|
+
eventId: 'event-fallback', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 160,
|
|
224
|
+
toolName: 'Read', toolType: 'direct', isMcpTool: false, mcpEnforced: false,
|
|
225
|
+
mcpFallback: true, input: {}, output: { content: 'fallback' }, duration: 10, blocked: false,
|
|
226
|
+
});
|
|
227
|
+
db.insertToolCallEvent({
|
|
228
|
+
eventId: 'event-memory-receipt', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 165,
|
|
229
|
+
toolName: 'mcp__devflow__memory_commit_turn', toolType: 'mcp', isMcpTool: true,
|
|
230
|
+
mcpToolName: 'memory_commit_turn', mcpEnforced: true, mcpFallback: false,
|
|
231
|
+
input: {}, output: { receiptId: 'memory-receipt:host-commit' }, duration: 5, blocked: false,
|
|
232
|
+
});
|
|
233
|
+
db.insertToolCallEvent({
|
|
234
|
+
eventId: 'event-distill-receipt', executionId: 'exec-facts', sessionId: 'session-facts', timestamp: 166,
|
|
235
|
+
toolName: 'mcp__devflow__memory_save_distilled', toolType: 'mcp', isMcpTool: true,
|
|
236
|
+
mcpToolName: 'memory_save_distilled', mcpEnforced: true, mcpFallback: false,
|
|
237
|
+
input: {}, output: { receipt: { receiptId: 'distill-receipt:batch-1' } }, duration: 5, blocked: false,
|
|
238
|
+
});
|
|
239
|
+
db.insertHookFallback({
|
|
240
|
+
id: 'hook-fallback-1', projectRoot: '/project', sessionId: 'session-facts',
|
|
241
|
+
requestType: 'post-tool-use', tool: 'Read', reason: 'timeout', durationMs: 1_000,
|
|
242
|
+
attempts: 1, createdAt: 170,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
db.reconcileSkillExecution('exec-facts', 'completed', 200);
|
|
246
|
+
|
|
247
|
+
expect(db.getSkillExecution('exec-facts')).toMatchObject({
|
|
248
|
+
missedMcpTools: ['react:context', 'react:domain'],
|
|
249
|
+
failedToolCalls: 1,
|
|
250
|
+
blockedToolCalls: 0,
|
|
251
|
+
fallbackCount: 2,
|
|
252
|
+
fallbackReasons: ['direct_tool_during_context', 'daemon_timeout'],
|
|
253
|
+
metadata: expect.objectContaining({
|
|
254
|
+
actualFailureCount: 1,
|
|
255
|
+
fallbackCount: 2,
|
|
256
|
+
memoryReceiptIds: ['memory-receipt:host-commit'],
|
|
257
|
+
distillReceiptIds: ['distill-receipt:batch-1'],
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
207
262
|
it('aggregates an empty findings collection as an empty result', () => {
|
|
208
263
|
db.insertSkillExecution({
|
|
209
264
|
executionId: 'exec_empty_findings',
|
|
@@ -305,4 +360,36 @@ describe('DevFlowDatabase - Skill Executions', () => {
|
|
|
305
360
|
}),
|
|
306
361
|
);
|
|
307
362
|
});
|
|
363
|
+
|
|
364
|
+
it('reconciles every running execution owned by a closing session', () => {
|
|
365
|
+
for (const executionId of ['exec_implicit_a', 'exec_implicit_b']) {
|
|
366
|
+
db.insertSkillExecution({
|
|
367
|
+
executionId,
|
|
368
|
+
sessionId: 'session-closing',
|
|
369
|
+
skillName: 'devflow:implicit',
|
|
370
|
+
startedAt: 1_000,
|
|
371
|
+
status: 'running',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
db.insertSkillExecution({
|
|
375
|
+
executionId: 'exec_other_session',
|
|
376
|
+
sessionId: 'session-other',
|
|
377
|
+
skillName: 'devflow:implicit',
|
|
378
|
+
startedAt: 1_000,
|
|
379
|
+
status: 'running',
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const reconciled = db.reconcileRunningSkillExecutionsForSession(
|
|
383
|
+
'session-closing',
|
|
384
|
+
5_000,
|
|
385
|
+
{ closureReason: 'session_finalize' },
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
expect(reconciled.map(execution => execution.executionId)).toEqual([
|
|
389
|
+
'exec_implicit_a',
|
|
390
|
+
'exec_implicit_b',
|
|
391
|
+
]);
|
|
392
|
+
expect(reconciled.every(execution => execution.status === 'completed')).toBe(true);
|
|
393
|
+
expect(db.getSkillExecution('exec_other_session')).toMatchObject({ status: 'running' });
|
|
394
|
+
});
|
|
308
395
|
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { DevFlowDatabase } from '../src/database';
|
|
6
|
+
|
|
7
|
+
describe('DevFlowDatabase durable work queue', () => {
|
|
8
|
+
let directory: string;
|
|
9
|
+
let database: DevFlowDatabase;
|
|
10
|
+
const projectRoot = '/project';
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
directory = mkdtempSync(join(tmpdir(), 'devflow-work-queue-'));
|
|
14
|
+
database = new DevFlowDatabase(directory);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
database.close();
|
|
19
|
+
rmSync(directory, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns the existing item for an idempotency conflict without resetting completed work', () => {
|
|
23
|
+
const first = database.enqueueWork({
|
|
24
|
+
idempotencyKey: 'memory:session-1:turn-1',
|
|
25
|
+
kind: 'memory.explicit_commit',
|
|
26
|
+
projectRoot,
|
|
27
|
+
sessionId: 'session-1',
|
|
28
|
+
turnId: 'turn-1',
|
|
29
|
+
payload: { text: 'remember this' },
|
|
30
|
+
nextAttemptAt: 10,
|
|
31
|
+
});
|
|
32
|
+
const [leased] = database.leaseWork({
|
|
33
|
+
projectRoot,
|
|
34
|
+
owner: 'worker-a',
|
|
35
|
+
limit: 1,
|
|
36
|
+
leaseMs: 100,
|
|
37
|
+
now: 10,
|
|
38
|
+
});
|
|
39
|
+
expect(leased?.id).toBe(first.id);
|
|
40
|
+
expect(database.completeWork(first.id, 'worker-a', 20)).toBe(true);
|
|
41
|
+
|
|
42
|
+
const duplicate = database.enqueueWork({
|
|
43
|
+
idempotencyKey: 'memory:session-1:turn-1',
|
|
44
|
+
kind: 'telemetry.reconcile',
|
|
45
|
+
projectRoot: '/different-project',
|
|
46
|
+
payload: { replacement: true },
|
|
47
|
+
maxAttempts: 1,
|
|
48
|
+
nextAttemptAt: 999,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(duplicate).toMatchObject({
|
|
52
|
+
id: first.id,
|
|
53
|
+
kind: 'memory.explicit_commit',
|
|
54
|
+
projectRoot,
|
|
55
|
+
payload: { text: 'remember this' },
|
|
56
|
+
state: 'completed',
|
|
57
|
+
attempts: 1,
|
|
58
|
+
maxAttempts: 5,
|
|
59
|
+
completedAt: 20,
|
|
60
|
+
});
|
|
61
|
+
expect(database.all('SELECT id FROM devflow_work_items')).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('leases atomically across database connections and requires the lease owner to complete', () => {
|
|
65
|
+
database.enqueueWork({
|
|
66
|
+
idempotencyKey: 'session:finalize:1',
|
|
67
|
+
kind: 'session.finalize',
|
|
68
|
+
projectRoot,
|
|
69
|
+
payload: { sessionId: 'session-1' },
|
|
70
|
+
nextAttemptAt: 100,
|
|
71
|
+
});
|
|
72
|
+
const secondConnection = new DevFlowDatabase(directory);
|
|
73
|
+
try {
|
|
74
|
+
const firstLease = database.leaseWork({
|
|
75
|
+
projectRoot,
|
|
76
|
+
owner: 'worker-a',
|
|
77
|
+
kinds: ['session.finalize'],
|
|
78
|
+
limit: 1,
|
|
79
|
+
leaseMs: 1_000,
|
|
80
|
+
now: 100,
|
|
81
|
+
});
|
|
82
|
+
const competingLease = secondConnection.leaseWork({
|
|
83
|
+
projectRoot,
|
|
84
|
+
owner: 'worker-b',
|
|
85
|
+
limit: 1,
|
|
86
|
+
leaseMs: 1_000,
|
|
87
|
+
now: 100,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(firstLease).toHaveLength(1);
|
|
91
|
+
expect(firstLease[0]).toMatchObject({
|
|
92
|
+
state: 'leased',
|
|
93
|
+
attempts: 1,
|
|
94
|
+
leaseOwner: 'worker-a',
|
|
95
|
+
leaseExpiresAt: 1_100,
|
|
96
|
+
});
|
|
97
|
+
expect(competingLease).toEqual([]);
|
|
98
|
+
expect(secondConnection.completeWork(firstLease[0]!.id, 'worker-b', 200)).toBe(false);
|
|
99
|
+
expect(database.completeWork(firstLease[0]!.id, 'worker-a', 200)).toBe(true);
|
|
100
|
+
expect(database.completeWork(firstLease[0]!.id, 'worker-a', 201)).toBe(false);
|
|
101
|
+
} finally {
|
|
102
|
+
secondConnection.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('applies owner and state predicates to retries and dead letters', () => {
|
|
107
|
+
const item = database.enqueueWork({
|
|
108
|
+
idempotencyKey: 'telemetry:reconcile:1',
|
|
109
|
+
kind: 'telemetry.reconcile',
|
|
110
|
+
projectRoot,
|
|
111
|
+
payload: {},
|
|
112
|
+
nextAttemptAt: 50,
|
|
113
|
+
});
|
|
114
|
+
database.leaseWork({
|
|
115
|
+
projectRoot,
|
|
116
|
+
owner: 'worker-a',
|
|
117
|
+
limit: 1,
|
|
118
|
+
leaseMs: 100,
|
|
119
|
+
now: 50,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(database.retryWork(
|
|
123
|
+
item.id,
|
|
124
|
+
'worker-b',
|
|
125
|
+
{ category: 'temporary', message: 'try again' },
|
|
126
|
+
75,
|
|
127
|
+
)).toBe(false);
|
|
128
|
+
expect(database.retryWork(
|
|
129
|
+
item.id,
|
|
130
|
+
'worker-a',
|
|
131
|
+
{ category: 'temporary', message: 'try again' },
|
|
132
|
+
75,
|
|
133
|
+
)).toBe(true);
|
|
134
|
+
expect(database.getWorkQueueHealth(projectRoot, 75)).toMatchObject({
|
|
135
|
+
queueDepth: 1,
|
|
136
|
+
failed: 1,
|
|
137
|
+
deadLetters: 0,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const [retried] = database.leaseWork({
|
|
141
|
+
projectRoot,
|
|
142
|
+
owner: 'worker-b',
|
|
143
|
+
limit: 1,
|
|
144
|
+
leaseMs: 100,
|
|
145
|
+
now: 75,
|
|
146
|
+
});
|
|
147
|
+
expect(retried).toMatchObject({ attempts: 2, errorCategory: 'temporary' });
|
|
148
|
+
expect(database.deadLetterWork(
|
|
149
|
+
item.id,
|
|
150
|
+
'worker-a',
|
|
151
|
+
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
152
|
+
)).toBe(false);
|
|
153
|
+
expect(database.deadLetterWork(
|
|
154
|
+
item.id,
|
|
155
|
+
'worker-b',
|
|
156
|
+
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
157
|
+
)).toBe(true);
|
|
158
|
+
expect(database.deadLetterWork(
|
|
159
|
+
item.id,
|
|
160
|
+
'worker-b',
|
|
161
|
+
{ category: 'invalid_schema', message: 'payload is invalid' },
|
|
162
|
+
)).toBe(false);
|
|
163
|
+
expect(database.getWorkQueueHealth(projectRoot, 80)).toMatchObject({
|
|
164
|
+
queueDepth: 0,
|
|
165
|
+
deadLetters: 1,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('recovers expired leases and dead-letters exhausted work', () => {
|
|
170
|
+
database.enqueueWork({
|
|
171
|
+
idempotencyKey: 'memory:distill:retryable',
|
|
172
|
+
kind: 'memory.turn_distill',
|
|
173
|
+
projectRoot,
|
|
174
|
+
payload: {},
|
|
175
|
+
maxAttempts: 2,
|
|
176
|
+
nextAttemptAt: 100,
|
|
177
|
+
});
|
|
178
|
+
database.enqueueWork({
|
|
179
|
+
idempotencyKey: 'memory:vector:exhausted',
|
|
180
|
+
kind: 'memory.vector_backfill',
|
|
181
|
+
projectRoot,
|
|
182
|
+
payload: {},
|
|
183
|
+
maxAttempts: 1,
|
|
184
|
+
nextAttemptAt: 100,
|
|
185
|
+
});
|
|
186
|
+
database.leaseWork({
|
|
187
|
+
projectRoot,
|
|
188
|
+
owner: 'worker-a',
|
|
189
|
+
limit: 2,
|
|
190
|
+
leaseMs: 10,
|
|
191
|
+
now: 100,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(database.getWorkQueueHealth(projectRoot, 110)).toMatchObject({
|
|
195
|
+
queueDepth: 2,
|
|
196
|
+
leased: 2,
|
|
197
|
+
expiredLeases: 2,
|
|
198
|
+
});
|
|
199
|
+
expect(database.recoverExpiredWork(projectRoot, 110)).toBe(2);
|
|
200
|
+
expect(database.getWorkQueueHealth(projectRoot, 110)).toMatchObject({
|
|
201
|
+
queueDepth: 1,
|
|
202
|
+
leased: 0,
|
|
203
|
+
failed: 1,
|
|
204
|
+
expiredLeases: 0,
|
|
205
|
+
deadLetters: 1,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const recovered = database.leaseWork({
|
|
209
|
+
projectRoot,
|
|
210
|
+
owner: 'worker-b',
|
|
211
|
+
limit: 2,
|
|
212
|
+
leaseMs: 20,
|
|
213
|
+
now: 110,
|
|
214
|
+
});
|
|
215
|
+
expect(recovered).toHaveLength(1);
|
|
216
|
+
expect(recovered[0]).toMatchObject({
|
|
217
|
+
idempotencyKey: 'memory:distill:retryable',
|
|
218
|
+
state: 'leased',
|
|
219
|
+
attempts: 2,
|
|
220
|
+
leaseOwner: 'worker-b',
|
|
221
|
+
errorCategory: 'lease_expired',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const revived = database.enqueueWork({
|
|
225
|
+
idempotencyKey: 'memory:vector:exhausted',
|
|
226
|
+
kind: 'memory.vector_backfill',
|
|
227
|
+
projectRoot,
|
|
228
|
+
payload: {},
|
|
229
|
+
maxAttempts: 20,
|
|
230
|
+
nextAttemptAt: 120,
|
|
231
|
+
});
|
|
232
|
+
expect(revived).toMatchObject({
|
|
233
|
+
state: 'failed',
|
|
234
|
+
attempts: 1,
|
|
235
|
+
maxAttempts: 20,
|
|
236
|
+
nextAttemptAt: 120,
|
|
237
|
+
errorCategory: undefined,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
package/dist/database.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EnqueueWorkInput, LeaseWorkInput, RequestSessionClosureInput, SessionClosureRecord, WorkError, WorkItemRecord, WorkQueueHealth } from './work-queue';
|
|
1
2
|
export interface BenchmarkReportRecord {
|
|
2
3
|
runId: string;
|
|
3
4
|
suiteId: string;
|
|
@@ -83,6 +84,34 @@ export interface MemoryDistillCheckpointRecord {
|
|
|
83
84
|
releasedLeases: number;
|
|
84
85
|
createdAt: number;
|
|
85
86
|
}
|
|
87
|
+
export type MemoryTurnStatus = 'pending' | 'committed' | 'skipped';
|
|
88
|
+
export interface MemoryTurnRecord {
|
|
89
|
+
turnId: string;
|
|
90
|
+
projectRoot: string;
|
|
91
|
+
sessionId: string;
|
|
92
|
+
promptHash: string;
|
|
93
|
+
eventId: string;
|
|
94
|
+
status: MemoryTurnStatus;
|
|
95
|
+
receiptId?: string;
|
|
96
|
+
memoryIds: string[];
|
|
97
|
+
source?: string;
|
|
98
|
+
reason?: string;
|
|
99
|
+
stopPromptedAt?: number;
|
|
100
|
+
createdAt: number;
|
|
101
|
+
decidedAt?: number;
|
|
102
|
+
}
|
|
103
|
+
export interface HookFallbackRecord {
|
|
104
|
+
id: string;
|
|
105
|
+
projectRoot: string;
|
|
106
|
+
sessionId?: string;
|
|
107
|
+
toolUseId?: string;
|
|
108
|
+
requestType: string;
|
|
109
|
+
tool: string;
|
|
110
|
+
reason: 'timeout' | 'unreachable' | 'protocol' | 'unknown';
|
|
111
|
+
durationMs: number;
|
|
112
|
+
attempts: number;
|
|
113
|
+
createdAt: number;
|
|
114
|
+
}
|
|
86
115
|
export declare function getGlobalDevFlowDbPath(home?: string): string;
|
|
87
116
|
export declare function openGlobalDevFlowDatabase(home?: string, options?: {
|
|
88
117
|
busyTimeoutMs?: number;
|
|
@@ -145,6 +174,10 @@ export declare class DevFlowDatabase {
|
|
|
145
174
|
totalDuration?: number;
|
|
146
175
|
mcpComplianceRate?: number;
|
|
147
176
|
missedMcpTools?: string[];
|
|
177
|
+
failedToolCalls?: number;
|
|
178
|
+
blockedToolCalls?: number;
|
|
179
|
+
fallbackCount?: number;
|
|
180
|
+
fallbackReasons?: string[];
|
|
148
181
|
totalToolCalls?: number;
|
|
149
182
|
mcpToolCalls?: number;
|
|
150
183
|
directToolCalls?: number;
|
|
@@ -185,6 +218,7 @@ export declare class DevFlowDatabase {
|
|
|
185
218
|
}): boolean;
|
|
186
219
|
markToolCallBlocked(eventId: string, reason: string): boolean;
|
|
187
220
|
reconcileSkillExecution(executionId: string, status: 'completed' | 'failed', finishedAt?: number, metadata?: Record<string, unknown>): void;
|
|
221
|
+
reconcileRunningSkillExecutionsForSession(sessionId: string, finishedAt?: number, metadata?: Record<string, unknown>): any[];
|
|
188
222
|
getExecutionObligationCompliance(executionId: string): {
|
|
189
223
|
rate: number;
|
|
190
224
|
applicable: number;
|
|
@@ -268,6 +302,21 @@ export declare class DevFlowDatabase {
|
|
|
268
302
|
items: GovernanceAuditRecord[];
|
|
269
303
|
total: number;
|
|
270
304
|
};
|
|
305
|
+
enqueueWork(input: EnqueueWorkInput): WorkItemRecord;
|
|
306
|
+
getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null;
|
|
307
|
+
requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord;
|
|
308
|
+
getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null;
|
|
309
|
+
completeSessionClosure(projectRoot: string, sessionId: string, excludingWorkItemId?: string, closedAt?: number): SessionClosureRecord;
|
|
310
|
+
listSessionClosures(projectRoot: string, limit?: number): SessionClosureRecord[];
|
|
311
|
+
leaseWork(input: LeaseWorkInput): WorkItemRecord[];
|
|
312
|
+
completeWork(id: string, owner: string, now?: number): boolean;
|
|
313
|
+
retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean;
|
|
314
|
+
deadLetterWork(id: string, owner: string, error: WorkError): boolean;
|
|
315
|
+
recoverExpiredWork(projectRoot: string, now?: number): number;
|
|
316
|
+
getWorkQueueHealth(projectRoot: string, now?: number): WorkQueueHealth;
|
|
317
|
+
private mapWorkItem;
|
|
318
|
+
private mapSessionClosure;
|
|
319
|
+
private refreshClosedSessionClosure;
|
|
271
320
|
getHookReceipt(projectRoot: string): HookReceiptRecord | null;
|
|
272
321
|
updateHookReceipt(projectRoot: string, updater: (current: HookReceiptRecord | null) => Omit<HookReceiptRecord, 'projectRoot' | 'updatedAt'>): HookReceiptRecord;
|
|
273
322
|
deleteHookReceipt(projectRoot: string): boolean;
|
|
@@ -275,6 +324,34 @@ export declare class DevFlowDatabase {
|
|
|
275
324
|
getContextReceipt(projectRoot: string, sessionId: string, executionId: string): ContextReceiptRecord | null;
|
|
276
325
|
deleteContextReceipt(projectRoot: string, sessionId: string, executionId?: string): number;
|
|
277
326
|
purgeExpiredContextReceipts(now?: number): number;
|
|
327
|
+
beginMemoryTurn(input: Omit<MemoryTurnRecord, 'status' | 'memoryIds'>): MemoryTurnRecord;
|
|
328
|
+
getMemoryTurn(turnId: string): MemoryTurnRecord | null;
|
|
329
|
+
getPendingMemoryTurn(projectRoot: string, sessionId: string): MemoryTurnRecord | null;
|
|
330
|
+
commitMemoryTurn(input: {
|
|
331
|
+
turnId: string;
|
|
332
|
+
receiptId: string;
|
|
333
|
+
memoryIds: string[];
|
|
334
|
+
source: string;
|
|
335
|
+
reason?: string;
|
|
336
|
+
decidedAt?: number;
|
|
337
|
+
}): MemoryTurnRecord;
|
|
338
|
+
skipMemoryTurn(input: {
|
|
339
|
+
turnId: string;
|
|
340
|
+
receiptId: string;
|
|
341
|
+
reason: string;
|
|
342
|
+
decidedAt?: number;
|
|
343
|
+
}): MemoryTurnRecord;
|
|
344
|
+
markMemoryTurnStopPrompted(turnId: string, promptedAt?: number): boolean;
|
|
345
|
+
listMemoryTurns(projectRoot: string, sessionId?: string, limit?: number): MemoryTurnRecord[];
|
|
346
|
+
insertHookFallback(record: HookFallbackRecord): boolean;
|
|
347
|
+
listHookFallbacks(filter?: {
|
|
348
|
+
projectRoot?: string;
|
|
349
|
+
sessionId?: string;
|
|
350
|
+
from?: number;
|
|
351
|
+
to?: number;
|
|
352
|
+
limit?: number;
|
|
353
|
+
}): HookFallbackRecord[];
|
|
354
|
+
private mapMemoryTurn;
|
|
278
355
|
recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void;
|
|
279
356
|
listMemoryDistillCheckpoints(projectRoot: string, limit?: number): MemoryDistillCheckpointRecord[];
|
|
280
357
|
all(sql: string, ...params: unknown[]): unknown[];
|