@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/src/database.ts CHANGED
@@ -2,6 +2,17 @@ import { NodeSqliteDatabase } from './node-sqlite';
2
2
  import { join, dirname } from 'path';
3
3
  import { existsSync, mkdirSync } from 'fs';
4
4
  import { homedir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ import type {
7
+ EnqueueWorkInput,
8
+ LeaseWorkInput,
9
+ RequestSessionClosureInput,
10
+ SessionClosureRecord,
11
+ WorkError,
12
+ WorkItemRecord,
13
+ WorkQueueHealth,
14
+ WorkState,
15
+ } from './work-queue';
5
16
 
6
17
  export interface BenchmarkReportRecord {
7
18
  runId: string;
@@ -130,6 +141,7 @@ export interface MemoryDistillCheckpointRecord {
130
141
  trigger: 'pre_compact' | 'session_end';
131
142
  pendingEvents: number;
132
143
  releasedLeases: number;
144
+ details?: Record<string, unknown>;
133
145
  createdAt: number;
134
146
  }
135
147
 
@@ -417,6 +429,7 @@ export class DevFlowDatabase {
417
429
  trigger TEXT NOT NULL CHECK(trigger IN ('pre_compact', 'session_end')),
418
430
  pending_events INTEGER NOT NULL DEFAULT 0,
419
431
  released_leases INTEGER NOT NULL DEFAULT 0,
432
+ details TEXT NOT NULL DEFAULT '{}',
420
433
  created_at INTEGER NOT NULL
421
434
  );
422
435
 
@@ -459,6 +472,52 @@ export class DevFlowDatabase {
459
472
  );
460
473
  CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
461
474
  ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
475
+
476
+ CREATE TABLE IF NOT EXISTS devflow_work_items (
477
+ id TEXT PRIMARY KEY,
478
+ idempotency_key TEXT NOT NULL UNIQUE,
479
+ kind TEXT NOT NULL,
480
+ project_root TEXT NOT NULL,
481
+ session_id TEXT,
482
+ turn_id TEXT,
483
+ payload TEXT NOT NULL,
484
+ state TEXT NOT NULL DEFAULT 'pending'
485
+ CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
486
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
487
+ max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
488
+ lease_owner TEXT,
489
+ lease_expires_at INTEGER,
490
+ next_attempt_at INTEGER NOT NULL,
491
+ error_category TEXT,
492
+ error_message TEXT,
493
+ created_at INTEGER NOT NULL,
494
+ updated_at INTEGER NOT NULL,
495
+ completed_at INTEGER
496
+ );
497
+
498
+ CREATE INDEX IF NOT EXISTS idx_work_items_ready
499
+ ON devflow_work_items(project_root, state, next_attempt_at, created_at);
500
+ CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
501
+ ON devflow_work_items(project_root, state, lease_expires_at);
502
+ CREATE INDEX IF NOT EXISTS idx_work_items_completed
503
+ ON devflow_work_items(project_root, completed_at DESC);
504
+
505
+ CREATE TABLE IF NOT EXISTS devflow_session_closures (
506
+ session_id TEXT NOT NULL,
507
+ project_root TEXT NOT NULL,
508
+ state TEXT NOT NULL DEFAULT 'active'
509
+ CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
510
+ receipt_id TEXT NOT NULL UNIQUE,
511
+ work_item_id TEXT,
512
+ pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
513
+ requested_at INTEGER NOT NULL,
514
+ updated_at INTEGER NOT NULL,
515
+ closed_at INTEGER,
516
+ PRIMARY KEY (project_root, session_id)
517
+ );
518
+
519
+ CREATE INDEX IF NOT EXISTS idx_session_closures_state
520
+ ON devflow_session_closures(project_root, state, updated_at DESC);
462
521
  `);
463
522
 
464
523
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
@@ -475,6 +534,7 @@ export class DevFlowDatabase {
475
534
  try { this.db.exec('ALTER TABLE tool_call_events ADD COLUMN tool_use_id TEXT'); } catch {}
476
535
  try { this.db.exec('ALTER TABLE sessions ADD COLUMN metadata TEXT'); } catch {}
477
536
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN metadata TEXT'); } catch {}
537
+ try { this.db.exec("ALTER TABLE devflow_memory_distill_checkpoints ADD COLUMN details TEXT NOT NULL DEFAULT '{}'"); } catch {}
478
538
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN failed_tool_calls INTEGER DEFAULT 0'); } catch {}
479
539
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN blocked_tool_calls INTEGER DEFAULT 0'); } catch {}
480
540
  try { this.db.exec('ALTER TABLE skill_executions ADD COLUMN fallback_count INTEGER DEFAULT 0'); } catch {}
@@ -1019,6 +1079,16 @@ export class DevFlowDatabase {
1019
1079
  };
1020
1080
  }
1021
1081
 
1082
+ getLatestSkillExecutionForSession(sessionId: string): any | null {
1083
+ const row = this.db.prepare(`
1084
+ SELECT execution_id FROM skill_executions
1085
+ WHERE session_id = ?
1086
+ ORDER BY started_at DESC, execution_id DESC
1087
+ LIMIT 1
1088
+ `).get(sessionId) as { execution_id?: string } | undefined;
1089
+ return row?.execution_id ? this.getSkillExecution(row.execution_id) : null;
1090
+ }
1091
+
1022
1092
  updateSkillExecution(executionId: string, updates: {
1023
1093
  status?: string;
1024
1094
  finishedAt?: number;
@@ -1059,6 +1129,20 @@ export class DevFlowDatabase {
1059
1129
  }
1060
1130
  }
1061
1131
 
1132
+ mergeSkillExecutionMetadata(
1133
+ executionId: string,
1134
+ incoming: Record<string, unknown>,
1135
+ ): Record<string, unknown> | null {
1136
+ const execution = this.getSkillExecution(executionId);
1137
+ if (!execution) return null;
1138
+ const existing = execution.metadata && typeof execution.metadata === 'object'
1139
+ ? execution.metadata as Record<string, unknown>
1140
+ : {};
1141
+ const merged = mergeExecutionMetadata(existing, incoming);
1142
+ this.updateSkillExecution(executionId, { metadata: merged });
1143
+ return merged;
1144
+ }
1145
+
1062
1146
  listSkillExecutions(limit: number, skillName?: string): any[] {
1063
1147
  let query = 'SELECT * FROM skill_executions';
1064
1148
  const params: any[] = [];
@@ -1286,23 +1370,38 @@ export class DevFlowDatabase {
1286
1370
  const blocked = events.filter(event => event.blocked);
1287
1371
  const directFallbacks = events.filter(event => event.mcpFallback);
1288
1372
  const hookFallbacks = execution?.sessionId
1289
- ? this.listHookFallbacks({
1290
- sessionId: execution.sessionId,
1291
- from: execution.startedAt,
1292
- to: finishedAt,
1293
- })
1373
+ ? (this.db.prepare(`
1374
+ SELECT * FROM devflow_hook_fallbacks
1375
+ WHERE session_id = ? AND created_at >= ? AND created_at <= ?
1376
+ ORDER BY created_at ASC
1377
+ `).all(execution.sessionId, execution.startedAt, finishedAt) as any[]).map(row => ({
1378
+ id: row.id,
1379
+ projectRoot: row.project_root,
1380
+ sessionId: row.session_id ?? undefined,
1381
+ toolUseId: row.tool_use_id ?? undefined,
1382
+ requestType: row.request_type,
1383
+ tool: row.tool,
1384
+ reason: row.reason,
1385
+ durationMs: row.duration_ms,
1386
+ attempts: row.attempts,
1387
+ createdAt: row.created_at,
1388
+ } satisfies HookFallbackRecord))
1294
1389
  : [];
1295
1390
  const fallbackReasons = [...new Set([
1296
1391
  ...directFallbacks.map(() => 'direct_tool_during_context'),
1297
1392
  ...hookFallbacks.map(fallback => `daemon_${fallback.reason}`),
1298
1393
  ])];
1299
1394
  const eventReceiptIds = collectCanonicalReceiptIds(events.map(event => event.output));
1300
- const suppliedMemoryReceiptIds = Array.isArray(metadata?.memoryReceiptIds)
1301
- ? metadata.memoryReceiptIds.filter((id): id is string => typeof id === 'string')
1302
- : [];
1303
- const suppliedDistillReceiptIds = Array.isArray(metadata?.distillReceiptIds)
1304
- ? metadata.distillReceiptIds.filter((id): id is string => typeof id === 'string')
1305
- : [];
1395
+ const existingMetadata = execution?.metadata && typeof execution.metadata === 'object'
1396
+ ? execution.metadata as Record<string, unknown>
1397
+ : {};
1398
+ const mergedMetadata = mergeExecutionMetadata(existingMetadata, metadata ?? {});
1399
+ const suppliedMemoryReceiptIds = [existingMetadata.memoryReceiptIds, metadata?.memoryReceiptIds]
1400
+ .flatMap(value => Array.isArray(value) ? value : [])
1401
+ .filter((id): id is string => typeof id === 'string');
1402
+ const suppliedDistillReceiptIds = [existingMetadata.distillReceiptIds, metadata?.distillReceiptIds]
1403
+ .flatMap(value => Array.isArray(value) ? value : [])
1404
+ .filter((id): id is string => typeof id === 'string');
1306
1405
  const memoryReceiptIds = [...new Set([
1307
1406
  ...suppliedMemoryReceiptIds,
1308
1407
  ...eventReceiptIds.filter(id => id.startsWith('memory-receipt:')),
@@ -1328,7 +1427,7 @@ export class DevFlowDatabase {
1328
1427
  fallbackCount: directFallbacks.length + hookFallbacks.length,
1329
1428
  fallbackReasons,
1330
1429
  metadata: {
1331
- ...(metadata ?? {}),
1430
+ ...mergedMetadata,
1332
1431
  applicableObligations: obligation.applicable,
1333
1432
  satisfiedObligations: obligation.satisfied,
1334
1433
  missedTools: obligation.missedTools,
@@ -1342,6 +1441,26 @@ export class DevFlowDatabase {
1342
1441
  });
1343
1442
  }
1344
1443
 
1444
+ reconcileRunningSkillExecutionsForSession(
1445
+ sessionId: string,
1446
+ finishedAt = Date.now(),
1447
+ metadata?: Record<string, unknown>,
1448
+ ): any[] {
1449
+ const rows = this.db.prepare(`
1450
+ SELECT execution_id
1451
+ FROM skill_executions
1452
+ WHERE session_id = ? AND status = 'running'
1453
+ ORDER BY started_at ASC
1454
+ `).all(sessionId) as Array<{ execution_id: string }>;
1455
+ const reconciled: any[] = [];
1456
+ for (const row of rows) {
1457
+ this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
1458
+ const execution = this.getSkillExecution(row.execution_id);
1459
+ if (execution) reconciled.push(execution);
1460
+ }
1461
+ return reconciled;
1462
+ }
1463
+
1345
1464
  getExecutionObligationCompliance(executionId: string): {
1346
1465
  rate: number;
1347
1466
  applicable: number;
@@ -1890,6 +2009,421 @@ export class DevFlowDatabase {
1890
2009
  };
1891
2010
  }
1892
2011
 
2012
+ // ---- Durable Work Queue ----
2013
+
2014
+ enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
2015
+ if (!input.idempotencyKey.trim()) throw new Error('Work idempotency key is required');
2016
+ if (!input.projectRoot.trim()) throw new Error('Work project root is required');
2017
+ const maxAttempts = input.maxAttempts ?? 5;
2018
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
2019
+ throw new Error('Work maxAttempts must be a positive safe integer');
2020
+ }
2021
+
2022
+ const createdAt = Date.now();
2023
+ const nextAttemptAt = input.nextAttemptAt ?? createdAt;
2024
+ if (!Number.isSafeInteger(nextAttemptAt)) {
2025
+ throw new Error('Work nextAttemptAt must be a safe integer');
2026
+ }
2027
+
2028
+ const row = this.db.prepare(`
2029
+ INSERT INTO devflow_work_items (
2030
+ id, idempotency_key, kind, project_root, session_id, turn_id, payload,
2031
+ state, attempts, max_attempts, lease_owner, lease_expires_at,
2032
+ next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
2033
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
2034
+ ON CONFLICT(idempotency_key) DO UPDATE SET
2035
+ state = CASE
2036
+ WHEN devflow_work_items.state = 'dead_letter'
2037
+ AND excluded.max_attempts > devflow_work_items.attempts
2038
+ THEN 'failed'
2039
+ ELSE devflow_work_items.state
2040
+ END,
2041
+ max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
2042
+ next_attempt_at = CASE
2043
+ WHEN devflow_work_items.state = 'dead_letter'
2044
+ AND excluded.max_attempts > devflow_work_items.attempts
2045
+ THEN excluded.next_attempt_at
2046
+ ELSE devflow_work_items.next_attempt_at
2047
+ END,
2048
+ error_category = CASE
2049
+ WHEN devflow_work_items.state = 'dead_letter'
2050
+ AND excluded.max_attempts > devflow_work_items.attempts
2051
+ THEN NULL
2052
+ ELSE devflow_work_items.error_category
2053
+ END,
2054
+ error_message = CASE
2055
+ WHEN devflow_work_items.state = 'dead_letter'
2056
+ AND excluded.max_attempts > devflow_work_items.attempts
2057
+ THEN NULL
2058
+ ELSE devflow_work_items.error_message
2059
+ END,
2060
+ updated_at = CASE
2061
+ WHEN devflow_work_items.state = 'dead_letter'
2062
+ AND excluded.max_attempts > devflow_work_items.attempts
2063
+ THEN excluded.updated_at
2064
+ ELSE devflow_work_items.updated_at
2065
+ END
2066
+ RETURNING *
2067
+ `).get(
2068
+ randomUUID(),
2069
+ input.idempotencyKey,
2070
+ input.kind,
2071
+ input.projectRoot,
2072
+ input.sessionId ?? null,
2073
+ input.turnId ?? null,
2074
+ JSON.stringify(input.payload ?? null),
2075
+ maxAttempts,
2076
+ nextAttemptAt,
2077
+ createdAt,
2078
+ createdAt,
2079
+ ) as any;
2080
+
2081
+ return this.mapWorkItem(row);
2082
+ }
2083
+
2084
+ getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null {
2085
+ const row = this.db.prepare(
2086
+ 'SELECT * FROM devflow_work_items WHERE idempotency_key = ?',
2087
+ ).get(idempotencyKey) as any;
2088
+ return row ? this.mapWorkItem(row) : null;
2089
+ }
2090
+
2091
+ requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord {
2092
+ if (!input.sessionId.trim()) throw new Error('Session closure requires a session ID');
2093
+ if (!input.projectRoot.trim()) throw new Error('Session closure requires a project root');
2094
+ if (!input.receiptId.trim()) throw new Error('Session closure requires a receipt ID');
2095
+
2096
+ return this.db.transaction(() => {
2097
+ const now = Date.now();
2098
+ this.db.prepare(`
2099
+ INSERT INTO devflow_session_closures (
2100
+ session_id, project_root, state, receipt_id, pending_work_count,
2101
+ requested_at, updated_at
2102
+ ) VALUES (?, ?, 'active', ?, 0, ?, ?)
2103
+ ON CONFLICT(project_root, session_id) DO NOTHING
2104
+ `).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
2105
+
2106
+ const work = this.enqueueWork({
2107
+ idempotencyKey: input.receiptId,
2108
+ kind: 'session.finalize',
2109
+ projectRoot: input.projectRoot,
2110
+ sessionId: input.sessionId,
2111
+ payload: input.payload,
2112
+ maxAttempts: input.maxAttempts,
2113
+ });
2114
+ const pending = this.db.prepare(`
2115
+ SELECT COUNT(*) AS count
2116
+ FROM devflow_work_items
2117
+ WHERE project_root = ? AND session_id = ?
2118
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2119
+ `).get(input.projectRoot, input.sessionId) as { count?: number };
2120
+ const pendingWorkCount = Number(pending?.count ?? 0);
2121
+ this.db.prepare(`
2122
+ UPDATE devflow_session_closures
2123
+ SET state = CASE
2124
+ WHEN state IN ('closed', 'closed_with_pending_work')
2125
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
2126
+ WHEN ? = 'completed'
2127
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
2128
+ WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
2129
+ ELSE 'closing'
2130
+ END,
2131
+ work_item_id = COALESCE(work_item_id, ?),
2132
+ pending_work_count = ?,
2133
+ closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
2134
+ updated_at = ?
2135
+ WHERE project_root = ? AND session_id = ?
2136
+ `).run(
2137
+ pendingWorkCount,
2138
+ work.state,
2139
+ pendingWorkCount,
2140
+ work.state,
2141
+ work.id,
2142
+ pendingWorkCount,
2143
+ work.state,
2144
+ now,
2145
+ now,
2146
+ input.projectRoot,
2147
+ input.sessionId,
2148
+ );
2149
+ return this.getSessionClosure(input.projectRoot, input.sessionId)!;
2150
+ });
2151
+ }
2152
+
2153
+ getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null {
2154
+ const row = this.db.prepare(`
2155
+ SELECT * FROM devflow_session_closures
2156
+ WHERE project_root = ? AND session_id = ?
2157
+ `).get(projectRoot, sessionId) as any;
2158
+ return row ? this.mapSessionClosure(row) : null;
2159
+ }
2160
+
2161
+ completeSessionClosure(
2162
+ projectRoot: string,
2163
+ sessionId: string,
2164
+ excludingWorkItemId?: string,
2165
+ closedAt = Date.now(),
2166
+ ): SessionClosureRecord {
2167
+ const pending = this.db.prepare(`
2168
+ SELECT COUNT(*) AS count
2169
+ FROM devflow_work_items
2170
+ WHERE project_root = ? AND session_id = ?
2171
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2172
+ AND (? IS NULL OR id <> ?)
2173
+ `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
2174
+ const pendingWorkCount = Number(pending?.count ?? 0);
2175
+ const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
2176
+ this.db.prepare(`
2177
+ UPDATE devflow_session_closures
2178
+ SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
2179
+ WHERE project_root = ? AND session_id = ? AND state = 'closing'
2180
+ `).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
2181
+ const closure = this.getSessionClosure(projectRoot, sessionId);
2182
+ if (!closure) throw new Error(`Session closure ${sessionId} does not exist`);
2183
+ return closure;
2184
+ }
2185
+
2186
+ listSessionClosures(projectRoot: string, limit = 100): SessionClosureRecord[] {
2187
+ const rows = this.db.prepare(`
2188
+ SELECT * FROM devflow_session_closures
2189
+ WHERE project_root = ?
2190
+ ORDER BY updated_at DESC
2191
+ LIMIT ?
2192
+ `).all(projectRoot, Math.max(1, Math.min(limit, 1_000))) as any[];
2193
+ return rows.map(row => this.mapSessionClosure(row));
2194
+ }
2195
+
2196
+ leaseWork(input: LeaseWorkInput): WorkItemRecord[] {
2197
+ if (!input.owner.trim()) throw new Error('Work lease owner is required');
2198
+ if (!Number.isFinite(input.limit) || input.limit <= 0) return [];
2199
+ if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
2200
+ throw new Error('Work leaseMs must be a positive safe integer');
2201
+ }
2202
+ if (input.kinds?.length === 0) return [];
2203
+
2204
+ const now = input.now ?? Date.now();
2205
+ const leaseExpiresAt = now + input.leaseMs;
2206
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
2207
+ throw new Error('Work lease timestamps must be safe integers');
2208
+ }
2209
+ const limit = Math.min(Math.floor(input.limit), 1_000);
2210
+ const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
2211
+ const kindClause = kinds
2212
+ ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
2213
+ : '';
2214
+
2215
+ this.db.exec('BEGIN IMMEDIATE');
2216
+ try {
2217
+ const candidates = this.db.prepare(`
2218
+ SELECT id, state
2219
+ FROM devflow_work_items
2220
+ WHERE project_root = ?
2221
+ AND state IN ('pending', 'failed')
2222
+ AND lease_owner IS NULL
2223
+ AND lease_expires_at IS NULL
2224
+ AND next_attempt_at <= ?
2225
+ AND attempts < max_attempts
2226
+ ${kindClause}
2227
+ ORDER BY next_attempt_at ASC, created_at ASC, id ASC
2228
+ LIMIT ?
2229
+ `).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
2230
+ id: string;
2231
+ state: WorkState;
2232
+ }>;
2233
+
2234
+ const leased: WorkItemRecord[] = [];
2235
+ for (const candidate of candidates) {
2236
+ const result = this.db.prepare(`
2237
+ UPDATE devflow_work_items
2238
+ SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
2239
+ lease_expires_at = ?, updated_at = ?
2240
+ WHERE id = ? AND project_root = ? AND state = ?
2241
+ AND lease_owner IS NULL AND lease_expires_at IS NULL
2242
+ AND next_attempt_at <= ? AND attempts < max_attempts
2243
+ `).run(
2244
+ input.owner,
2245
+ leaseExpiresAt,
2246
+ now,
2247
+ candidate.id,
2248
+ input.projectRoot,
2249
+ candidate.state,
2250
+ now,
2251
+ );
2252
+ if (result.changes === 1) {
2253
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
2254
+ .get(candidate.id) as any;
2255
+ leased.push(this.mapWorkItem(row));
2256
+ }
2257
+ }
2258
+
2259
+ this.db.exec('COMMIT');
2260
+ return leased;
2261
+ } catch (error) {
2262
+ try { this.db.exec('ROLLBACK'); } catch {}
2263
+ throw error;
2264
+ }
2265
+ }
2266
+
2267
+ completeWork(id: string, owner: string, now = Date.now()): boolean {
2268
+ const work = this.db.prepare(`
2269
+ SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
2270
+ `).get(id) as { project_root?: string; session_id?: string } | undefined;
2271
+ const completed = this.db.prepare(`
2272
+ UPDATE devflow_work_items
2273
+ SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
2274
+ updated_at = ?, completed_at = ?
2275
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2276
+ `).run(now, now, id, owner).changes === 1;
2277
+ if (completed && work?.project_root && work.session_id) {
2278
+ this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
2279
+ }
2280
+ return completed;
2281
+ }
2282
+
2283
+ retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
2284
+ const updatedAt = Date.now();
2285
+ return this.db.prepare(`
2286
+ UPDATE devflow_work_items
2287
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
2288
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2289
+ error_category = ?, error_message = ?, updated_at = ?
2290
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2291
+ `).run(
2292
+ nextAttemptAt,
2293
+ error.category,
2294
+ error.message,
2295
+ updatedAt,
2296
+ id,
2297
+ owner,
2298
+ ).changes === 1;
2299
+ }
2300
+
2301
+ deferWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
2302
+ const updatedAt = Date.now();
2303
+ return this.db.prepare(`
2304
+ UPDATE devflow_work_items
2305
+ SET state = 'pending', attempts = MAX(0, attempts - 1),
2306
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2307
+ error_category = ?, error_message = ?, updated_at = ?
2308
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2309
+ `).run(
2310
+ nextAttemptAt,
2311
+ error.category,
2312
+ error.message,
2313
+ updatedAt,
2314
+ id,
2315
+ owner,
2316
+ ).changes === 1;
2317
+ }
2318
+
2319
+ deadLetterWork(id: string, owner: string, error: WorkError): boolean {
2320
+ return this.db.prepare(`
2321
+ UPDATE devflow_work_items
2322
+ SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
2323
+ error_category = ?, error_message = ?, updated_at = ?
2324
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2325
+ `).run(error.category, error.message, Date.now(), id, owner).changes === 1;
2326
+ }
2327
+
2328
+ recoverExpiredWork(projectRoot: string, now = Date.now()): number {
2329
+ return this.db.prepare(`
2330
+ UPDATE devflow_work_items
2331
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
2332
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2333
+ error_category = 'lease_expired',
2334
+ error_message = 'Work lease expired before completion',
2335
+ updated_at = ?
2336
+ WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
2337
+ `).run(now, now, projectRoot, now).changes;
2338
+ }
2339
+
2340
+ getWorkQueueHealth(projectRoot: string, now = Date.now()): WorkQueueHealth {
2341
+ const row = this.db.prepare(`
2342
+ SELECT
2343
+ COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
2344
+ COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
2345
+ COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
2346
+ COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
2347
+ COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
2348
+ COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
2349
+ MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
2350
+ MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
2351
+ FROM devflow_work_items
2352
+ WHERE project_root = ?
2353
+ `).get(now, projectRoot) as any;
2354
+ const oldestPendingAt = row.oldest_pending_at == null
2355
+ ? undefined
2356
+ : Number(row.oldest_pending_at);
2357
+
2358
+ return {
2359
+ projectRoot,
2360
+ queueDepth: Number(row.queue_depth),
2361
+ pending: Number(row.pending),
2362
+ leased: Number(row.leased),
2363
+ failed: Number(row.failed),
2364
+ expiredLeases: Number(row.expired_leases),
2365
+ deadLetters: Number(row.dead_letters),
2366
+ oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
2367
+ lastSuccessfulDrainAt: row.last_successful_drain_at == null
2368
+ ? undefined
2369
+ : Number(row.last_successful_drain_at),
2370
+ };
2371
+ }
2372
+
2373
+ private mapWorkItem(row: any): WorkItemRecord {
2374
+ return {
2375
+ id: row.id,
2376
+ idempotencyKey: row.idempotency_key,
2377
+ kind: row.kind,
2378
+ projectRoot: row.project_root,
2379
+ sessionId: row.session_id ?? undefined,
2380
+ turnId: row.turn_id ?? undefined,
2381
+ payload: parseJson(row.payload),
2382
+ state: row.state,
2383
+ attempts: Number(row.attempts),
2384
+ maxAttempts: Number(row.max_attempts),
2385
+ leaseOwner: row.lease_owner ?? undefined,
2386
+ leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
2387
+ nextAttemptAt: Number(row.next_attempt_at),
2388
+ errorCategory: row.error_category ?? undefined,
2389
+ errorMessage: row.error_message ?? undefined,
2390
+ createdAt: Number(row.created_at),
2391
+ updatedAt: Number(row.updated_at),
2392
+ completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
2393
+ };
2394
+ }
2395
+
2396
+ private mapSessionClosure(row: any): SessionClosureRecord {
2397
+ return {
2398
+ sessionId: row.session_id,
2399
+ projectRoot: row.project_root,
2400
+ state: row.state,
2401
+ receiptId: row.receipt_id,
2402
+ workItemId: row.work_item_id ?? undefined,
2403
+ pendingWorkCount: Number(row.pending_work_count),
2404
+ requestedAt: Number(row.requested_at),
2405
+ updatedAt: Number(row.updated_at),
2406
+ closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
2407
+ };
2408
+ }
2409
+
2410
+ private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
2411
+ const pending = this.db.prepare(`
2412
+ SELECT COUNT(*) AS count
2413
+ FROM devflow_work_items
2414
+ WHERE project_root = ? AND session_id = ?
2415
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2416
+ `).get(projectRoot, sessionId) as { count?: number };
2417
+ const pendingWorkCount = Number(pending?.count ?? 0);
2418
+ this.db.prepare(`
2419
+ UPDATE devflow_session_closures
2420
+ SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
2421
+ pending_work_count = ?, updated_at = ?
2422
+ WHERE project_root = ? AND session_id = ?
2423
+ AND state IN ('closed', 'closed_with_pending_work')
2424
+ `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
2425
+ }
2426
+
1893
2427
  // ---- Hook Lifecycle ----
1894
2428
 
1895
2429
  getHookReceipt(projectRoot: string): HookReceiptRecord | null {
@@ -2102,6 +2636,18 @@ export class DevFlowDatabase {
2102
2636
  return rows.map(row => this.mapMemoryTurn(row));
2103
2637
  }
2104
2638
 
2639
+ listSessionMemoryTurnsForReconciliation(
2640
+ projectRoot: string,
2641
+ sessionId: string,
2642
+ ): MemoryTurnRecord[] {
2643
+ const rows = this.db.prepare(`
2644
+ SELECT * FROM devflow_memory_turns
2645
+ WHERE project_root = ? AND session_id = ?
2646
+ ORDER BY created_at ASC, turn_id ASC
2647
+ `).all(projectRoot, sessionId) as any[];
2648
+ return rows.map(row => this.mapMemoryTurn(row));
2649
+ }
2650
+
2105
2651
  insertHookFallback(record: HookFallbackRecord): boolean {
2106
2652
  return this.db.prepare(`
2107
2653
  INSERT OR IGNORE INTO devflow_hook_fallbacks
@@ -2181,8 +2727,13 @@ export class DevFlowDatabase {
2181
2727
  recordMemoryDistillCheckpoint(checkpoint: MemoryDistillCheckpointRecord): void {
2182
2728
  this.db.prepare(`
2183
2729
  INSERT INTO devflow_memory_distill_checkpoints
2184
- (id, project_root, session_id, trigger, pending_events, released_leases, created_at)
2185
- VALUES (?, ?, ?, ?, ?, ?, ?)
2730
+ (id, project_root, session_id, trigger, pending_events, released_leases, details, created_at)
2731
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2732
+ ON CONFLICT(id) DO UPDATE SET
2733
+ pending_events = excluded.pending_events,
2734
+ released_leases = MAX(devflow_memory_distill_checkpoints.released_leases, excluded.released_leases),
2735
+ details = excluded.details,
2736
+ created_at = MIN(devflow_memory_distill_checkpoints.created_at, excluded.created_at)
2186
2737
  `).run(
2187
2738
  checkpoint.id,
2188
2739
  checkpoint.projectRoot,
@@ -2190,13 +2741,14 @@ export class DevFlowDatabase {
2190
2741
  checkpoint.trigger,
2191
2742
  checkpoint.pendingEvents,
2192
2743
  checkpoint.releasedLeases,
2744
+ JSON.stringify(checkpoint.details ?? {}),
2193
2745
  checkpoint.createdAt,
2194
2746
  );
2195
2747
  }
2196
2748
 
2197
2749
  listMemoryDistillCheckpoints(projectRoot: string, limit = 50): MemoryDistillCheckpointRecord[] {
2198
2750
  const rows = this.db.prepare(`
2199
- SELECT id, project_root, session_id, trigger, pending_events, released_leases, created_at
2751
+ SELECT id, project_root, session_id, trigger, pending_events, released_leases, details, created_at
2200
2752
  FROM devflow_memory_distill_checkpoints
2201
2753
  WHERE project_root = ?
2202
2754
  ORDER BY created_at DESC
@@ -2209,6 +2761,7 @@ export class DevFlowDatabase {
2209
2761
  trigger: row.trigger,
2210
2762
  pendingEvents: Number(row.pending_events),
2211
2763
  releasedLeases: Number(row.released_leases),
2764
+ details: row.details ? JSON.parse(row.details) : {},
2212
2765
  createdAt: Number(row.created_at),
2213
2766
  }));
2214
2767
  }
@@ -2296,3 +2849,40 @@ function collectCanonicalReceiptIds(values: unknown[]): string[] {
2296
2849
  values.forEach(value => visit(value, 0));
2297
2850
  return [...receiptIds];
2298
2851
  }
2852
+
2853
+ function stableMetadataKey(value: unknown): string {
2854
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? String(value);
2855
+ if (Array.isArray(value)) return `[${value.map(stableMetadataKey).join(',')}]`;
2856
+ const record = value as Record<string, unknown>;
2857
+ return `{${Object.keys(record).sort()
2858
+ .map(key => `${JSON.stringify(key)}:${stableMetadataKey(record[key])}`)
2859
+ .join(',')}}`;
2860
+ }
2861
+
2862
+ function unionMetadataArrays(existing: unknown, incoming: unknown): unknown[] | undefined {
2863
+ const values = [
2864
+ ...(Array.isArray(existing) ? existing : []),
2865
+ ...(Array.isArray(incoming) ? incoming : []),
2866
+ ];
2867
+ if (values.length === 0 && !Array.isArray(existing) && !Array.isArray(incoming)) return undefined;
2868
+ const unique = new Map<string, unknown>();
2869
+ for (const value of values) unique.set(stableMetadataKey(value), value);
2870
+ return [...unique.values()];
2871
+ }
2872
+
2873
+ function mergeExecutionMetadata(
2874
+ existing: Record<string, unknown>,
2875
+ incoming: Record<string, unknown>,
2876
+ ): Record<string, unknown> {
2877
+ const merged = { ...existing, ...incoming };
2878
+ for (const key of [
2879
+ 'evidenceDegradations',
2880
+ 'evidenceContractOutcomes',
2881
+ 'pendingEvidenceContractIds',
2882
+ 'sessionFinalizationDegradations',
2883
+ ]) {
2884
+ const union = unionMetadataArrays(existing[key], incoming[key]);
2885
+ if (union !== undefined) merged[key] = union;
2886
+ }
2887
+ return merged;
2888
+ }