@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
|
@@ -360,4 +360,36 @@ describe('DevFlowDatabase - Skill Executions', () => {
|
|
|
360
360
|
}),
|
|
361
361
|
);
|
|
362
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
|
+
});
|
|
363
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;
|
|
@@ -217,6 +218,7 @@ export declare class DevFlowDatabase {
|
|
|
217
218
|
}): boolean;
|
|
218
219
|
markToolCallBlocked(eventId: string, reason: string): boolean;
|
|
219
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[];
|
|
220
222
|
getExecutionObligationCompliance(executionId: string): {
|
|
221
223
|
rate: number;
|
|
222
224
|
applicable: number;
|
|
@@ -300,6 +302,21 @@ export declare class DevFlowDatabase {
|
|
|
300
302
|
items: GovernanceAuditRecord[];
|
|
301
303
|
total: number;
|
|
302
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;
|
|
303
320
|
getHookReceipt(projectRoot: string): HookReceiptRecord | null;
|
|
304
321
|
updateHookReceipt(projectRoot: string, updater: (current: HookReceiptRecord | null) => Omit<HookReceiptRecord, 'projectRoot' | 'updatedAt'>): HookReceiptRecord;
|
|
305
322
|
deleteHookReceipt(projectRoot: string): boolean;
|