@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 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.12](https://github.com/shilongfeicool/dev-flow/compare/v0.16.11...v0.16.12) (2026-07-26)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **database:** support Node 24 sqlite result types ([40efed3](https://github.com/shilongfeicool/dev-flow/commit/40efed3d8c629990f5e29324b3aba673c16a091b))
12
+ * **memory:** make embedding model resilient ([e24fd08](https://github.com/shilongfeicool/dev-flow/commit/e24fd086b09d0327e5a69c118f02501caffcf280))
13
+ * **runtime:** close final reliability gaps ([86edd2b](https://github.com/shilongfeicool/dev-flow/commit/86edd2b49acf3559c9e66643ca913fc3a9728731))
14
+ * **runtime:** harden session reconciliation retries ([fc38e56](https://github.com/shilongfeicool/dev-flow/commit/fc38e561450540adecf7883666453f22fa4a9271))
15
+ * **runtime:** preserve reconciliation retry state ([2cec7fe](https://github.com/shilongfeicool/dev-flow/commit/2cec7fe5156a7ab48fbf19c7a427cbca71e7de09))
16
+ * **runtime:** reconcile session lifecycle telemetry ([87b2f7c](https://github.com/shilongfeicool/dev-flow/commit/87b2f7c0c04a572ab396d0da1cc13eada7779691))
17
+
18
+
19
+
20
+
21
+
6
22
  ## [0.16.10](https://github.com/shilongfeicool/dev-flow/compare/v0.16.9...v0.16.10) (2026-07-24)
7
23
 
8
24
 
@@ -68,4 +68,31 @@ describe('DevFlowDatabase memory turn receipts', () => {
68
68
  stopPromptedAt: 350,
69
69
  });
70
70
  });
71
+
72
+ it('lists every session turn in stable ascending order for reconciliation', () => {
73
+ for (let index = 0; index < 125; index += 1) {
74
+ database.beginMemoryTurn({
75
+ turnId: `turn:${String(index).padStart(3, '0')}`,
76
+ projectRoot: '/project',
77
+ sessionId: 'long-session',
78
+ promptHash: `hash-${index}`,
79
+ eventId: `event-${index}`,
80
+ createdAt: 1_000 + index,
81
+ });
82
+ }
83
+ database.beginMemoryTurn({
84
+ turnId: 'turn:other',
85
+ projectRoot: '/project',
86
+ sessionId: 'other-session',
87
+ promptHash: 'other',
88
+ eventId: 'event-other',
89
+ createdAt: 1,
90
+ });
91
+
92
+ const turns = database.listSessionMemoryTurnsForReconciliation('/project', 'long-session');
93
+ expect(turns).toHaveLength(125);
94
+ expect(turns[0]?.turnId).toBe('turn:000');
95
+ expect(turns.at(-1)?.turnId).toBe('turn:124');
96
+ expect(turns.every((turn, index) => turn.createdAt === 1_000 + index)).toBe(true);
97
+ });
71
98
  });
@@ -242,7 +242,14 @@ describe('DevFlowDatabase - Skill Executions', () => {
242
242
  attempts: 1, createdAt: 170,
243
243
  });
244
244
 
245
- db.reconcileSkillExecution('exec-facts', 'completed', 200);
245
+ db.reconcileSkillExecution('exec-facts', 'completed', 200, {
246
+ missedTools: [],
247
+ actualFailureCount: 0,
248
+ blockedCount: 99,
249
+ fallbackCount: 0,
250
+ fallbackReasons: ['stale_supplied_reason'],
251
+ evidenceContractOutcomes: [{ status: 'resolved' }],
252
+ });
246
253
 
247
254
  expect(db.getSkillExecution('exec-facts')).toMatchObject({
248
255
  missedMcpTools: ['react:context', 'react:domain'],
@@ -251,14 +258,68 @@ describe('DevFlowDatabase - Skill Executions', () => {
251
258
  fallbackCount: 2,
252
259
  fallbackReasons: ['direct_tool_during_context', 'daemon_timeout'],
253
260
  metadata: expect.objectContaining({
261
+ missedTools: ['react:context', 'react:domain'],
254
262
  actualFailureCount: 1,
263
+ blockedCount: 0,
255
264
  fallbackCount: 2,
265
+ fallbackReasons: ['direct_tool_during_context', 'daemon_timeout'],
256
266
  memoryReceiptIds: ['memory-receipt:host-commit'],
257
267
  distillReceiptIds: ['distill-receipt:batch-1'],
268
+ evidenceContractOutcomes: [{ status: 'resolved' }],
258
269
  }),
259
270
  });
260
271
  });
261
272
 
273
+ it('unions evidence metadata across retries without allowing stale counters', () => {
274
+ db.ensureSession({ id: 'session-evidence-retry', projectRoot: '/project', startedAt: 100 });
275
+ db.insertSkillExecution({
276
+ executionId: 'exec-evidence-retry',
277
+ sessionId: 'session-evidence-retry',
278
+ skillName: 'devflow:context',
279
+ startedAt: 100,
280
+ status: 'running',
281
+ });
282
+ db.insertToolCallEvent({
283
+ eventId: 'event-context', executionId: 'exec-evidence-retry',
284
+ sessionId: 'session-evidence-retry', timestamp: 120,
285
+ toolName: 'get_project_context', toolType: 'mcp', isMcpTool: true,
286
+ mcpToolName: 'get_project_context', mcpEnforced: true, mcpFallback: false,
287
+ input: {}, output: { files: ['src/App.tsx'] }, duration: 10, blocked: false,
288
+ });
289
+ const degradation = {
290
+ obligationId: 'obligation-1', contractId: 'contract-1', reason: 'bounded_fail_open',
291
+ degradedAt: 150, attempt: 2,
292
+ };
293
+ const outcome = {
294
+ obligationId: 'obligation-1', contractId: 'contract-1', status: 'degraded',
295
+ completedAt: 150, attempt: 2, reason: 'bounded_fail_open',
296
+ };
297
+
298
+ db.reconcileSkillExecution('exec-evidence-retry', 'completed', 200, {
299
+ evidenceDegradations: [degradation],
300
+ evidenceContractOutcomes: [outcome],
301
+ pendingEvidenceContractIds: ['contract-pending'],
302
+ });
303
+ db.reconcileSkillExecution('exec-evidence-retry', 'completed', 220, {
304
+ evidenceDegradations: [],
305
+ evidenceContractOutcomes: [
306
+ { attempt: 2, completedAt: 150, reason: 'bounded_fail_open', status: 'degraded',
307
+ contractId: 'contract-1', obligationId: 'obligation-1' },
308
+ ],
309
+ pendingEvidenceContractIds: [],
310
+ missedTools: ['stale'],
311
+ actualFailureCount: 99,
312
+ });
313
+
314
+ expect(db.getSkillExecution('exec-evidence-retry')?.metadata).toMatchObject({
315
+ evidenceDegradations: [degradation],
316
+ evidenceContractOutcomes: [outcome],
317
+ pendingEvidenceContractIds: ['contract-pending'],
318
+ missedTools: [],
319
+ actualFailureCount: 0,
320
+ });
321
+ });
322
+
262
323
  it('aggregates an empty findings collection as an empty result', () => {
263
324
  db.insertSkillExecution({
264
325
  executionId: 'exec_empty_findings',
@@ -360,4 +421,36 @@ describe('DevFlowDatabase - Skill Executions', () => {
360
421
  }),
361
422
  );
362
423
  });
424
+
425
+ it('reconciles every running execution owned by a closing session', () => {
426
+ for (const executionId of ['exec_implicit_a', 'exec_implicit_b']) {
427
+ db.insertSkillExecution({
428
+ executionId,
429
+ sessionId: 'session-closing',
430
+ skillName: 'devflow:implicit',
431
+ startedAt: 1_000,
432
+ status: 'running',
433
+ });
434
+ }
435
+ db.insertSkillExecution({
436
+ executionId: 'exec_other_session',
437
+ sessionId: 'session-other',
438
+ skillName: 'devflow:implicit',
439
+ startedAt: 1_000,
440
+ status: 'running',
441
+ });
442
+
443
+ const reconciled = db.reconcileRunningSkillExecutionsForSession(
444
+ 'session-closing',
445
+ 5_000,
446
+ { closureReason: 'session_finalize' },
447
+ );
448
+
449
+ expect(reconciled.map(execution => execution.executionId)).toEqual([
450
+ 'exec_implicit_a',
451
+ 'exec_implicit_b',
452
+ ]);
453
+ expect(reconciled.every(execution => execution.status === 'completed')).toBe(true);
454
+ expect(db.getSkillExecution('exec_other_session')).toMatchObject({ status: 'running' });
455
+ });
363
456
  });
@@ -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
+ });
@@ -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;
@@ -81,6 +82,7 @@ export interface MemoryDistillCheckpointRecord {
81
82
  trigger: 'pre_compact' | 'session_end';
82
83
  pendingEvents: number;
83
84
  releasedLeases: number;
85
+ details?: Record<string, unknown>;
84
86
  createdAt: number;
85
87
  }
86
88
  export type MemoryTurnStatus = 'pending' | 'committed' | 'skipped';
@@ -167,6 +169,7 @@ export declare class DevFlowDatabase {
167
169
  availableMcpTools?: string[];
168
170
  }): void;
169
171
  getSkillExecution(executionId: string): any | null;
172
+ getLatestSkillExecutionForSession(sessionId: string): any | null;
170
173
  updateSkillExecution(executionId: string, updates: {
171
174
  status?: string;
172
175
  finishedAt?: number;
@@ -183,6 +186,7 @@ export declare class DevFlowDatabase {
183
186
  subagentCount?: number;
184
187
  metadata?: Record<string, unknown>;
185
188
  }): void;
189
+ mergeSkillExecutionMetadata(executionId: string, incoming: Record<string, unknown>): Record<string, unknown> | null;
186
190
  listSkillExecutions(limit: number, skillName?: string): any[];
187
191
  insertToolCallEvent(params: {
188
192
  eventId: string;
@@ -217,6 +221,7 @@ export declare class DevFlowDatabase {
217
221
  }): boolean;
218
222
  markToolCallBlocked(eventId: string, reason: string): boolean;
219
223
  reconcileSkillExecution(executionId: string, status: 'completed' | 'failed', finishedAt?: number, metadata?: Record<string, unknown>): void;
224
+ reconcileRunningSkillExecutionsForSession(sessionId: string, finishedAt?: number, metadata?: Record<string, unknown>): any[];
220
225
  getExecutionObligationCompliance(executionId: string): {
221
226
  rate: number;
222
227
  applicable: number;
@@ -300,6 +305,22 @@ export declare class DevFlowDatabase {
300
305
  items: GovernanceAuditRecord[];
301
306
  total: number;
302
307
  };
308
+ enqueueWork(input: EnqueueWorkInput): WorkItemRecord;
309
+ getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null;
310
+ requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord;
311
+ getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null;
312
+ completeSessionClosure(projectRoot: string, sessionId: string, excludingWorkItemId?: string, closedAt?: number): SessionClosureRecord;
313
+ listSessionClosures(projectRoot: string, limit?: number): SessionClosureRecord[];
314
+ leaseWork(input: LeaseWorkInput): WorkItemRecord[];
315
+ completeWork(id: string, owner: string, now?: number): boolean;
316
+ retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean;
317
+ deferWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean;
318
+ deadLetterWork(id: string, owner: string, error: WorkError): boolean;
319
+ recoverExpiredWork(projectRoot: string, now?: number): number;
320
+ getWorkQueueHealth(projectRoot: string, now?: number): WorkQueueHealth;
321
+ private mapWorkItem;
322
+ private mapSessionClosure;
323
+ private refreshClosedSessionClosure;
303
324
  getHookReceipt(projectRoot: string): HookReceiptRecord | null;
304
325
  updateHookReceipt(projectRoot: string, updater: (current: HookReceiptRecord | null) => Omit<HookReceiptRecord, 'projectRoot' | 'updatedAt'>): HookReceiptRecord;
305
326
  deleteHookReceipt(projectRoot: string): boolean;
@@ -326,6 +347,7 @@ export declare class DevFlowDatabase {
326
347
  }): MemoryTurnRecord;
327
348
  markMemoryTurnStopPrompted(turnId: string, promptedAt?: number): boolean;
328
349
  listMemoryTurns(projectRoot: string, sessionId?: string, limit?: number): MemoryTurnRecord[];
350
+ listSessionMemoryTurnsForReconciliation(projectRoot: string, sessionId: string): MemoryTurnRecord[];
329
351
  insertHookFallback(record: HookFallbackRecord): boolean;
330
352
  listHookFallbacks(filter?: {
331
353
  projectRoot?: string;