@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/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;
@@ -459,6 +470,52 @@ export class DevFlowDatabase {
459
470
  );
460
471
  CREATE INDEX IF NOT EXISTS idx_hook_fallbacks_session
461
472
  ON devflow_hook_fallbacks(project_root, session_id, created_at DESC);
473
+
474
+ CREATE TABLE IF NOT EXISTS devflow_work_items (
475
+ id TEXT PRIMARY KEY,
476
+ idempotency_key TEXT NOT NULL UNIQUE,
477
+ kind TEXT NOT NULL,
478
+ project_root TEXT NOT NULL,
479
+ session_id TEXT,
480
+ turn_id TEXT,
481
+ payload TEXT NOT NULL,
482
+ state TEXT NOT NULL DEFAULT 'pending'
483
+ CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
484
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
485
+ max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
486
+ lease_owner TEXT,
487
+ lease_expires_at INTEGER,
488
+ next_attempt_at INTEGER NOT NULL,
489
+ error_category TEXT,
490
+ error_message TEXT,
491
+ created_at INTEGER NOT NULL,
492
+ updated_at INTEGER NOT NULL,
493
+ completed_at INTEGER
494
+ );
495
+
496
+ CREATE INDEX IF NOT EXISTS idx_work_items_ready
497
+ ON devflow_work_items(project_root, state, next_attempt_at, created_at);
498
+ CREATE INDEX IF NOT EXISTS idx_work_items_expired_leases
499
+ ON devflow_work_items(project_root, state, lease_expires_at);
500
+ CREATE INDEX IF NOT EXISTS idx_work_items_completed
501
+ ON devflow_work_items(project_root, completed_at DESC);
502
+
503
+ CREATE TABLE IF NOT EXISTS devflow_session_closures (
504
+ session_id TEXT NOT NULL,
505
+ project_root TEXT NOT NULL,
506
+ state TEXT NOT NULL DEFAULT 'active'
507
+ CHECK(state IN ('active', 'closing', 'closed', 'closed_with_pending_work')),
508
+ receipt_id TEXT NOT NULL UNIQUE,
509
+ work_item_id TEXT,
510
+ pending_work_count INTEGER NOT NULL DEFAULT 0 CHECK(pending_work_count >= 0),
511
+ requested_at INTEGER NOT NULL,
512
+ updated_at INTEGER NOT NULL,
513
+ closed_at INTEGER,
514
+ PRIMARY KEY (project_root, session_id)
515
+ );
516
+
517
+ CREATE INDEX IF NOT EXISTS idx_session_closures_state
518
+ ON devflow_session_closures(project_root, state, updated_at DESC);
462
519
  `);
463
520
 
464
521
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
@@ -1342,6 +1399,26 @@ export class DevFlowDatabase {
1342
1399
  });
1343
1400
  }
1344
1401
 
1402
+ reconcileRunningSkillExecutionsForSession(
1403
+ sessionId: string,
1404
+ finishedAt = Date.now(),
1405
+ metadata?: Record<string, unknown>,
1406
+ ): any[] {
1407
+ const rows = this.db.prepare(`
1408
+ SELECT execution_id
1409
+ FROM skill_executions
1410
+ WHERE session_id = ? AND status = 'running'
1411
+ ORDER BY started_at ASC
1412
+ `).all(sessionId) as Array<{ execution_id: string }>;
1413
+ const reconciled: any[] = [];
1414
+ for (const row of rows) {
1415
+ this.reconcileSkillExecution(row.execution_id, 'completed', finishedAt, metadata);
1416
+ const execution = this.getSkillExecution(row.execution_id);
1417
+ if (execution) reconciled.push(execution);
1418
+ }
1419
+ return reconciled;
1420
+ }
1421
+
1345
1422
  getExecutionObligationCompliance(executionId: string): {
1346
1423
  rate: number;
1347
1424
  applicable: number;
@@ -1890,6 +1967,403 @@ export class DevFlowDatabase {
1890
1967
  };
1891
1968
  }
1892
1969
 
1970
+ // ---- Durable Work Queue ----
1971
+
1972
+ enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
1973
+ if (!input.idempotencyKey.trim()) throw new Error('Work idempotency key is required');
1974
+ if (!input.projectRoot.trim()) throw new Error('Work project root is required');
1975
+ const maxAttempts = input.maxAttempts ?? 5;
1976
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
1977
+ throw new Error('Work maxAttempts must be a positive safe integer');
1978
+ }
1979
+
1980
+ const createdAt = Date.now();
1981
+ const nextAttemptAt = input.nextAttemptAt ?? createdAt;
1982
+ if (!Number.isSafeInteger(nextAttemptAt)) {
1983
+ throw new Error('Work nextAttemptAt must be a safe integer');
1984
+ }
1985
+
1986
+ const row = this.db.prepare(`
1987
+ INSERT INTO devflow_work_items (
1988
+ id, idempotency_key, kind, project_root, session_id, turn_id, payload,
1989
+ state, attempts, max_attempts, lease_owner, lease_expires_at,
1990
+ next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
1991
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
1992
+ ON CONFLICT(idempotency_key) DO UPDATE SET
1993
+ state = CASE
1994
+ WHEN devflow_work_items.state = 'dead_letter'
1995
+ AND excluded.max_attempts > devflow_work_items.attempts
1996
+ THEN 'failed'
1997
+ ELSE devflow_work_items.state
1998
+ END,
1999
+ max_attempts = MAX(devflow_work_items.max_attempts, excluded.max_attempts),
2000
+ next_attempt_at = CASE
2001
+ WHEN devflow_work_items.state = 'dead_letter'
2002
+ AND excluded.max_attempts > devflow_work_items.attempts
2003
+ THEN excluded.next_attempt_at
2004
+ ELSE devflow_work_items.next_attempt_at
2005
+ END,
2006
+ error_category = CASE
2007
+ WHEN devflow_work_items.state = 'dead_letter'
2008
+ AND excluded.max_attempts > devflow_work_items.attempts
2009
+ THEN NULL
2010
+ ELSE devflow_work_items.error_category
2011
+ END,
2012
+ error_message = CASE
2013
+ WHEN devflow_work_items.state = 'dead_letter'
2014
+ AND excluded.max_attempts > devflow_work_items.attempts
2015
+ THEN NULL
2016
+ ELSE devflow_work_items.error_message
2017
+ END,
2018
+ updated_at = CASE
2019
+ WHEN devflow_work_items.state = 'dead_letter'
2020
+ AND excluded.max_attempts > devflow_work_items.attempts
2021
+ THEN excluded.updated_at
2022
+ ELSE devflow_work_items.updated_at
2023
+ END
2024
+ RETURNING *
2025
+ `).get(
2026
+ randomUUID(),
2027
+ input.idempotencyKey,
2028
+ input.kind,
2029
+ input.projectRoot,
2030
+ input.sessionId ?? null,
2031
+ input.turnId ?? null,
2032
+ JSON.stringify(input.payload ?? null),
2033
+ maxAttempts,
2034
+ nextAttemptAt,
2035
+ createdAt,
2036
+ createdAt,
2037
+ ) as any;
2038
+
2039
+ return this.mapWorkItem(row);
2040
+ }
2041
+
2042
+ getWorkByIdempotencyKey(idempotencyKey: string): WorkItemRecord | null {
2043
+ const row = this.db.prepare(
2044
+ 'SELECT * FROM devflow_work_items WHERE idempotency_key = ?',
2045
+ ).get(idempotencyKey) as any;
2046
+ return row ? this.mapWorkItem(row) : null;
2047
+ }
2048
+
2049
+ requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord {
2050
+ if (!input.sessionId.trim()) throw new Error('Session closure requires a session ID');
2051
+ if (!input.projectRoot.trim()) throw new Error('Session closure requires a project root');
2052
+ if (!input.receiptId.trim()) throw new Error('Session closure requires a receipt ID');
2053
+
2054
+ return this.db.transaction(() => {
2055
+ const now = Date.now();
2056
+ this.db.prepare(`
2057
+ INSERT INTO devflow_session_closures (
2058
+ session_id, project_root, state, receipt_id, pending_work_count,
2059
+ requested_at, updated_at
2060
+ ) VALUES (?, ?, 'active', ?, 0, ?, ?)
2061
+ ON CONFLICT(project_root, session_id) DO NOTHING
2062
+ `).run(input.sessionId, input.projectRoot, input.receiptId, now, now);
2063
+
2064
+ const work = this.enqueueWork({
2065
+ idempotencyKey: input.receiptId,
2066
+ kind: 'session.finalize',
2067
+ projectRoot: input.projectRoot,
2068
+ sessionId: input.sessionId,
2069
+ payload: input.payload,
2070
+ maxAttempts: input.maxAttempts,
2071
+ });
2072
+ const pending = this.db.prepare(`
2073
+ SELECT COUNT(*) AS count
2074
+ FROM devflow_work_items
2075
+ WHERE project_root = ? AND session_id = ?
2076
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2077
+ `).get(input.projectRoot, input.sessionId) as { count?: number };
2078
+ const pendingWorkCount = Number(pending?.count ?? 0);
2079
+ this.db.prepare(`
2080
+ UPDATE devflow_session_closures
2081
+ SET state = CASE
2082
+ WHEN state IN ('closed', 'closed_with_pending_work')
2083
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
2084
+ WHEN ? = 'completed'
2085
+ THEN CASE WHEN ? > 0 THEN 'closed_with_pending_work' ELSE 'closed' END
2086
+ WHEN ? = 'dead_letter' THEN 'closed_with_pending_work'
2087
+ ELSE 'closing'
2088
+ END,
2089
+ work_item_id = COALESCE(work_item_id, ?),
2090
+ pending_work_count = ?,
2091
+ closed_at = CASE WHEN ? = 'completed' THEN ? ELSE closed_at END,
2092
+ updated_at = ?
2093
+ WHERE project_root = ? AND session_id = ?
2094
+ `).run(
2095
+ pendingWorkCount,
2096
+ work.state,
2097
+ pendingWorkCount,
2098
+ work.state,
2099
+ work.id,
2100
+ pendingWorkCount,
2101
+ work.state,
2102
+ now,
2103
+ now,
2104
+ input.projectRoot,
2105
+ input.sessionId,
2106
+ );
2107
+ return this.getSessionClosure(input.projectRoot, input.sessionId)!;
2108
+ });
2109
+ }
2110
+
2111
+ getSessionClosure(projectRoot: string, sessionId: string): SessionClosureRecord | null {
2112
+ const row = this.db.prepare(`
2113
+ SELECT * FROM devflow_session_closures
2114
+ WHERE project_root = ? AND session_id = ?
2115
+ `).get(projectRoot, sessionId) as any;
2116
+ return row ? this.mapSessionClosure(row) : null;
2117
+ }
2118
+
2119
+ completeSessionClosure(
2120
+ projectRoot: string,
2121
+ sessionId: string,
2122
+ excludingWorkItemId?: string,
2123
+ closedAt = Date.now(),
2124
+ ): SessionClosureRecord {
2125
+ const pending = this.db.prepare(`
2126
+ SELECT COUNT(*) AS count
2127
+ FROM devflow_work_items
2128
+ WHERE project_root = ? AND session_id = ?
2129
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2130
+ AND (? IS NULL OR id <> ?)
2131
+ `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null) as { count?: number };
2132
+ const pendingWorkCount = Number(pending?.count ?? 0);
2133
+ const state = pendingWorkCount > 0 ? 'closed_with_pending_work' : 'closed';
2134
+ this.db.prepare(`
2135
+ UPDATE devflow_session_closures
2136
+ SET state = ?, pending_work_count = ?, closed_at = ?, updated_at = ?
2137
+ WHERE project_root = ? AND session_id = ? AND state = 'closing'
2138
+ `).run(state, pendingWorkCount, closedAt, closedAt, projectRoot, sessionId);
2139
+ const closure = this.getSessionClosure(projectRoot, sessionId);
2140
+ if (!closure) throw new Error(`Session closure ${sessionId} does not exist`);
2141
+ return closure;
2142
+ }
2143
+
2144
+ listSessionClosures(projectRoot: string, limit = 100): SessionClosureRecord[] {
2145
+ const rows = this.db.prepare(`
2146
+ SELECT * FROM devflow_session_closures
2147
+ WHERE project_root = ?
2148
+ ORDER BY updated_at DESC
2149
+ LIMIT ?
2150
+ `).all(projectRoot, Math.max(1, Math.min(limit, 1_000))) as any[];
2151
+ return rows.map(row => this.mapSessionClosure(row));
2152
+ }
2153
+
2154
+ leaseWork(input: LeaseWorkInput): WorkItemRecord[] {
2155
+ if (!input.owner.trim()) throw new Error('Work lease owner is required');
2156
+ if (!Number.isFinite(input.limit) || input.limit <= 0) return [];
2157
+ if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0) {
2158
+ throw new Error('Work leaseMs must be a positive safe integer');
2159
+ }
2160
+ if (input.kinds?.length === 0) return [];
2161
+
2162
+ const now = input.now ?? Date.now();
2163
+ const leaseExpiresAt = now + input.leaseMs;
2164
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
2165
+ throw new Error('Work lease timestamps must be safe integers');
2166
+ }
2167
+ const limit = Math.min(Math.floor(input.limit), 1_000);
2168
+ const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
2169
+ const kindClause = kinds
2170
+ ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
2171
+ : '';
2172
+
2173
+ this.db.exec('BEGIN IMMEDIATE');
2174
+ try {
2175
+ const candidates = this.db.prepare(`
2176
+ SELECT id, state
2177
+ FROM devflow_work_items
2178
+ WHERE project_root = ?
2179
+ AND state IN ('pending', 'failed')
2180
+ AND lease_owner IS NULL
2181
+ AND lease_expires_at IS NULL
2182
+ AND next_attempt_at <= ?
2183
+ AND attempts < max_attempts
2184
+ ${kindClause}
2185
+ ORDER BY next_attempt_at ASC, created_at ASC, id ASC
2186
+ LIMIT ?
2187
+ `).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
2188
+ id: string;
2189
+ state: WorkState;
2190
+ }>;
2191
+
2192
+ const leased: WorkItemRecord[] = [];
2193
+ for (const candidate of candidates) {
2194
+ const result = this.db.prepare(`
2195
+ UPDATE devflow_work_items
2196
+ SET state = 'leased', attempts = attempts + 1, lease_owner = ?,
2197
+ lease_expires_at = ?, updated_at = ?
2198
+ WHERE id = ? AND project_root = ? AND state = ?
2199
+ AND lease_owner IS NULL AND lease_expires_at IS NULL
2200
+ AND next_attempt_at <= ? AND attempts < max_attempts
2201
+ `).run(
2202
+ input.owner,
2203
+ leaseExpiresAt,
2204
+ now,
2205
+ candidate.id,
2206
+ input.projectRoot,
2207
+ candidate.state,
2208
+ now,
2209
+ );
2210
+ if (result.changes === 1) {
2211
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?')
2212
+ .get(candidate.id) as any;
2213
+ leased.push(this.mapWorkItem(row));
2214
+ }
2215
+ }
2216
+
2217
+ this.db.exec('COMMIT');
2218
+ return leased;
2219
+ } catch (error) {
2220
+ try { this.db.exec('ROLLBACK'); } catch {}
2221
+ throw error;
2222
+ }
2223
+ }
2224
+
2225
+ completeWork(id: string, owner: string, now = Date.now()): boolean {
2226
+ const work = this.db.prepare(`
2227
+ SELECT project_root, session_id FROM devflow_work_items WHERE id = ?
2228
+ `).get(id) as { project_root?: string; session_id?: string } | undefined;
2229
+ const completed = this.db.prepare(`
2230
+ UPDATE devflow_work_items
2231
+ SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL,
2232
+ updated_at = ?, completed_at = ?
2233
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2234
+ `).run(now, now, id, owner).changes === 1;
2235
+ if (completed && work?.project_root && work.session_id) {
2236
+ this.refreshClosedSessionClosure(work.project_root, work.session_id, now);
2237
+ }
2238
+ return completed;
2239
+ }
2240
+
2241
+ retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
2242
+ const updatedAt = Date.now();
2243
+ return this.db.prepare(`
2244
+ UPDATE devflow_work_items
2245
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
2246
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2247
+ error_category = ?, error_message = ?, updated_at = ?
2248
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2249
+ `).run(
2250
+ nextAttemptAt,
2251
+ error.category,
2252
+ error.message,
2253
+ updatedAt,
2254
+ id,
2255
+ owner,
2256
+ ).changes === 1;
2257
+ }
2258
+
2259
+ deadLetterWork(id: string, owner: string, error: WorkError): boolean {
2260
+ return this.db.prepare(`
2261
+ UPDATE devflow_work_items
2262
+ SET state = 'dead_letter', lease_owner = NULL, lease_expires_at = NULL,
2263
+ error_category = ?, error_message = ?, updated_at = ?
2264
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2265
+ `).run(error.category, error.message, Date.now(), id, owner).changes === 1;
2266
+ }
2267
+
2268
+ recoverExpiredWork(projectRoot: string, now = Date.now()): number {
2269
+ return this.db.prepare(`
2270
+ UPDATE devflow_work_items
2271
+ SET state = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'failed' END,
2272
+ lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = ?,
2273
+ error_category = 'lease_expired',
2274
+ error_message = 'Work lease expired before completion',
2275
+ updated_at = ?
2276
+ WHERE project_root = ? AND state = 'leased' AND lease_expires_at <= ?
2277
+ `).run(now, now, projectRoot, now).changes;
2278
+ }
2279
+
2280
+ getWorkQueueHealth(projectRoot: string, now = Date.now()): WorkQueueHealth {
2281
+ const row = this.db.prepare(`
2282
+ SELECT
2283
+ COALESCE(SUM(CASE WHEN state IN ('pending', 'leased', 'failed') THEN 1 ELSE 0 END), 0) AS queue_depth,
2284
+ COALESCE(SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END), 0) AS pending,
2285
+ COALESCE(SUM(CASE WHEN state = 'leased' THEN 1 ELSE 0 END), 0) AS leased,
2286
+ COALESCE(SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
2287
+ COALESCE(SUM(CASE WHEN state = 'leased' AND lease_expires_at <= ? THEN 1 ELSE 0 END), 0) AS expired_leases,
2288
+ COALESCE(SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_letters,
2289
+ MIN(CASE WHEN state IN ('pending', 'leased', 'failed') THEN created_at END) AS oldest_pending_at,
2290
+ MAX(CASE WHEN state = 'completed' THEN completed_at END) AS last_successful_drain_at
2291
+ FROM devflow_work_items
2292
+ WHERE project_root = ?
2293
+ `).get(now, projectRoot) as any;
2294
+ const oldestPendingAt = row.oldest_pending_at == null
2295
+ ? undefined
2296
+ : Number(row.oldest_pending_at);
2297
+
2298
+ return {
2299
+ projectRoot,
2300
+ queueDepth: Number(row.queue_depth),
2301
+ pending: Number(row.pending),
2302
+ leased: Number(row.leased),
2303
+ failed: Number(row.failed),
2304
+ expiredLeases: Number(row.expired_leases),
2305
+ deadLetters: Number(row.dead_letters),
2306
+ oldestPendingAgeMs: oldestPendingAt === undefined ? 0 : Math.max(0, now - oldestPendingAt),
2307
+ lastSuccessfulDrainAt: row.last_successful_drain_at == null
2308
+ ? undefined
2309
+ : Number(row.last_successful_drain_at),
2310
+ };
2311
+ }
2312
+
2313
+ private mapWorkItem(row: any): WorkItemRecord {
2314
+ return {
2315
+ id: row.id,
2316
+ idempotencyKey: row.idempotency_key,
2317
+ kind: row.kind,
2318
+ projectRoot: row.project_root,
2319
+ sessionId: row.session_id ?? undefined,
2320
+ turnId: row.turn_id ?? undefined,
2321
+ payload: parseJson(row.payload),
2322
+ state: row.state,
2323
+ attempts: Number(row.attempts),
2324
+ maxAttempts: Number(row.max_attempts),
2325
+ leaseOwner: row.lease_owner ?? undefined,
2326
+ leaseExpiresAt: row.lease_expires_at == null ? undefined : Number(row.lease_expires_at),
2327
+ nextAttemptAt: Number(row.next_attempt_at),
2328
+ errorCategory: row.error_category ?? undefined,
2329
+ errorMessage: row.error_message ?? undefined,
2330
+ createdAt: Number(row.created_at),
2331
+ updatedAt: Number(row.updated_at),
2332
+ completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
2333
+ };
2334
+ }
2335
+
2336
+ private mapSessionClosure(row: any): SessionClosureRecord {
2337
+ return {
2338
+ sessionId: row.session_id,
2339
+ projectRoot: row.project_root,
2340
+ state: row.state,
2341
+ receiptId: row.receipt_id,
2342
+ workItemId: row.work_item_id ?? undefined,
2343
+ pendingWorkCount: Number(row.pending_work_count),
2344
+ requestedAt: Number(row.requested_at),
2345
+ updatedAt: Number(row.updated_at),
2346
+ closedAt: row.closed_at == null ? undefined : Number(row.closed_at),
2347
+ };
2348
+ }
2349
+
2350
+ private refreshClosedSessionClosure(projectRoot: string, sessionId: string, now: number): void {
2351
+ const pending = this.db.prepare(`
2352
+ SELECT COUNT(*) AS count
2353
+ FROM devflow_work_items
2354
+ WHERE project_root = ? AND session_id = ?
2355
+ AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2356
+ `).get(projectRoot, sessionId) as { count?: number };
2357
+ const pendingWorkCount = Number(pending?.count ?? 0);
2358
+ this.db.prepare(`
2359
+ UPDATE devflow_session_closures
2360
+ SET state = CASE WHEN ? = 0 THEN 'closed' ELSE 'closed_with_pending_work' END,
2361
+ pending_work_count = ?, updated_at = ?
2362
+ WHERE project_root = ? AND session_id = ?
2363
+ AND state IN ('closed', 'closed_with_pending_work')
2364
+ `).run(pendingWorkCount, pendingWorkCount, now, projectRoot, sessionId);
2365
+ }
2366
+
1893
2367
  // ---- Hook Lifecycle ----
1894
2368
 
1895
2369
  getHookReceipt(projectRoot: string): HookReceiptRecord | null {
package/src/index.ts CHANGED
@@ -16,3 +16,15 @@ export type {
16
16
  MemoryTurnStatus,
17
17
  TelemetryFailureRecord,
18
18
  } from './database';
19
+ export type {
20
+ EnqueueWorkInput,
21
+ LeaseWorkInput,
22
+ RequestSessionClosureInput,
23
+ SessionClosureRecord,
24
+ SessionClosureState,
25
+ WorkError,
26
+ WorkItemRecord,
27
+ WorkKind,
28
+ WorkQueueHealth,
29
+ WorkState,
30
+ } from './work-queue';
@@ -0,0 +1,94 @@
1
+ export type WorkKind =
2
+ | 'memory.explicit_commit'
3
+ | 'memory.turn_capture'
4
+ | 'memory.turn_distill'
5
+ | 'memory.vector_backfill'
6
+ | 'session.bootstrap'
7
+ | 'session.finalize'
8
+ | 'telemetry.reconcile';
9
+
10
+ export type WorkState = 'pending' | 'leased' | 'completed' | 'failed' | 'dead_letter';
11
+
12
+ export interface WorkError {
13
+ category: string;
14
+ message: string;
15
+ }
16
+
17
+ export interface WorkItemRecord {
18
+ id: string;
19
+ idempotencyKey: string;
20
+ kind: WorkKind;
21
+ projectRoot: string;
22
+ sessionId?: string;
23
+ turnId?: string;
24
+ payload: unknown;
25
+ state: WorkState;
26
+ attempts: number;
27
+ maxAttempts: number;
28
+ leaseOwner?: string;
29
+ leaseExpiresAt?: number;
30
+ nextAttemptAt: number;
31
+ errorCategory?: string;
32
+ errorMessage?: string;
33
+ createdAt: number;
34
+ updatedAt: number;
35
+ completedAt?: number;
36
+ }
37
+
38
+ export interface EnqueueWorkInput {
39
+ idempotencyKey: string;
40
+ kind: WorkKind;
41
+ projectRoot: string;
42
+ sessionId?: string;
43
+ turnId?: string;
44
+ payload: unknown;
45
+ maxAttempts?: number;
46
+ nextAttemptAt?: number;
47
+ }
48
+
49
+ export interface LeaseWorkInput {
50
+ projectRoot: string;
51
+ owner: string;
52
+ kinds?: WorkKind[];
53
+ limit: number;
54
+ leaseMs: number;
55
+ now?: number;
56
+ }
57
+
58
+ export interface WorkQueueHealth {
59
+ projectRoot: string;
60
+ queueDepth: number;
61
+ pending: number;
62
+ leased: number;
63
+ failed: number;
64
+ expiredLeases: number;
65
+ deadLetters: number;
66
+ oldestPendingAgeMs: number;
67
+ lastSuccessfulDrainAt?: number;
68
+ }
69
+
70
+ export type SessionClosureState =
71
+ | 'active'
72
+ | 'closing'
73
+ | 'closed'
74
+ | 'closed_with_pending_work';
75
+
76
+ export interface SessionClosureRecord {
77
+ sessionId: string;
78
+ projectRoot: string;
79
+ state: SessionClosureState;
80
+ receiptId: string;
81
+ workItemId?: string;
82
+ pendingWorkCount: number;
83
+ requestedAt: number;
84
+ updatedAt: number;
85
+ closedAt?: number;
86
+ }
87
+
88
+ export interface RequestSessionClosureInput {
89
+ sessionId: string;
90
+ projectRoot: string;
91
+ receiptId: string;
92
+ payload: unknown;
93
+ maxAttempts?: number;
94
+ }