@devflow-tools/database 0.17.1 → 0.17.2

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.17.2](https://github.com/shilongfeicool/dev-flow/compare/v0.17.1...v0.17.2) (2026-08-01)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **reliability:** close semantic lifecycle gaps ([08bae87](https://github.com/shilongfeicool/dev-flow/commit/08bae8742c5907dffdd476679e70c86cc373a7e8))
12
+
13
+
14
+ ### Features
15
+
16
+ * **reliability:** strengthen semantic retrieval closure ([b979ab3](https://github.com/shilongfeicool/dev-flow/commit/b979ab3ec790e3d7119db512902db90a9567667d))
17
+
18
+
19
+
20
+
21
+
6
22
  ## [0.17.1](https://github.com/shilongfeicool/dev-flow/compare/v0.16.28...v0.17.1) (2026-07-31)
7
23
 
8
24
 
@@ -174,12 +174,17 @@ export interface HookFallbackRecord {
174
174
  export declare function getGlobalDevFlowDbPath(home?: string): string;
175
175
  export declare function openGlobalDevFlowDatabase(home?: string, options?: {
176
176
  busyTimeoutMs?: number;
177
+ readonly?: boolean;
178
+ }): DevFlowDatabase;
179
+ export declare function openGlobalDevFlowReadOnlyDatabase(home?: string, options?: {
180
+ busyTimeoutMs?: number;
177
181
  }): DevFlowDatabase;
178
182
  export declare class DevFlowDatabase {
179
183
  private db;
180
184
  constructor(projectRoot: string, opts?: {
181
185
  dbPath?: string;
182
186
  busyTimeoutMs?: number;
187
+ readonly?: boolean;
183
188
  });
184
189
  private initializeSchema;
185
190
  insertRun(run: any): void;
@@ -213,8 +218,10 @@ export declare class DevFlowDatabase {
213
218
  listToolCallEventsBySession(sessionId: string): any[];
214
219
  getToolCallEventByToolUseId(sessionId: string, toolUseId: string): {
215
220
  eventId: string;
221
+ executionId: string;
216
222
  timestamp: number;
217
223
  duration: number;
224
+ input: Record<string, unknown>;
218
225
  } | null;
219
226
  listToolCallEventsBySessions(sessionIds: string[]): Record<string, any[]>;
220
227
  insertSkillExecution(params: {
@@ -499,6 +506,11 @@ export declare class DevFlowDatabase {
499
506
  reason?: string;
500
507
  decidedAt?: number;
501
508
  }): MemoryTurnRecord;
509
+ updateCommittedMemoryTurnProjection(input: {
510
+ turnId: string;
511
+ memoryIds: string[];
512
+ reason?: string;
513
+ }): MemoryTurnRecord;
502
514
  skipMemoryTurn(input: {
503
515
  turnId: string;
504
516
  receiptId: string;
package/dist/database.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DevFlowDatabase = void 0;
4
4
  exports.getGlobalDevFlowDbPath = getGlobalDevFlowDbPath;
5
5
  exports.openGlobalDevFlowDatabase = openGlobalDevFlowDatabase;
6
+ exports.openGlobalDevFlowReadOnlyDatabase = openGlobalDevFlowReadOnlyDatabase;
6
7
  const node_sqlite_1 = require("./node-sqlite");
7
8
  const path_1 = require("path");
8
9
  const fs_1 = require("fs");
@@ -62,28 +63,59 @@ function openGlobalDevFlowDatabase(home = (0, os_1.homedir)(), options) {
62
63
  return new DevFlowDatabase(home, {
63
64
  dbPath: getGlobalDevFlowDbPath(home),
64
65
  busyTimeoutMs: options?.busyTimeoutMs,
66
+ readonly: options?.readonly,
65
67
  });
66
68
  }
69
+ function openGlobalDevFlowReadOnlyDatabase(home = (0, os_1.homedir)(), options) {
70
+ return openGlobalDevFlowDatabase(home, { ...options, readonly: true });
71
+ }
72
+ // Schema setup is process-owned. Hook daemons open short-lived connections for
73
+ // bounded transactions, but replaying the full idempotent DDL on every open is
74
+ // still expensive and serializes concurrent host requests. An inode identity
75
+ // keeps the cache safe when a test, repair, or user replaces the database file.
76
+ const initializedDatabaseFiles = new Map();
77
+ function getDatabaseIdentity(path) {
78
+ try {
79
+ const stats = (0, fs_1.statSync)(path);
80
+ return { dev: stats.dev, ino: stats.ino };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
67
86
  class DevFlowDatabase {
68
87
  constructor(projectRoot, opts) {
69
88
  let dbPath;
70
89
  if (opts?.dbPath) {
71
90
  const dir = (0, path_1.dirname)(opts.dbPath);
72
- if (!(0, fs_1.existsSync)(dir))
91
+ if (!(0, fs_1.existsSync)(dir) && !opts.readonly)
73
92
  (0, fs_1.mkdirSync)(dir, { recursive: true });
74
93
  dbPath = opts.dbPath;
75
94
  }
76
95
  else {
77
96
  const devflowDir = (0, path_1.join)(projectRoot, '.devflow');
78
- if (!(0, fs_1.existsSync)(devflowDir))
97
+ if (!(0, fs_1.existsSync)(devflowDir) && !opts?.readonly)
79
98
  (0, fs_1.mkdirSync)(devflowDir, { recursive: true });
80
99
  dbPath = (0, path_1.join)(devflowDir, 'devflow.db');
81
100
  }
82
- this.db = new node_sqlite_1.NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs);
83
- this.db.exec('PRAGMA journal_mode = WAL');
101
+ if (opts?.readonly && !(0, fs_1.existsSync)(dbPath))
102
+ throw new Error(`DEVFLOW_DATABASE_NOT_FOUND:${dbPath}`);
103
+ this.db = new node_sqlite_1.NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs, opts?.readonly === true);
84
104
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts?.busyTimeoutMs ?? 5000))}`);
105
+ if (opts?.readonly)
106
+ return;
85
107
  this.db.exec('PRAGMA foreign_keys = OFF');
86
- this.initializeSchema();
108
+ const databaseIdentity = getDatabaseIdentity(dbPath);
109
+ const initializedIdentity = initializedDatabaseFiles.get(dbPath);
110
+ const schemaInitialized = databaseIdentity !== null
111
+ && initializedIdentity?.dev === databaseIdentity.dev
112
+ && initializedIdentity.ino === databaseIdentity.ino;
113
+ if (!schemaInitialized) {
114
+ this.initializeSchema();
115
+ const currentIdentity = getDatabaseIdentity(dbPath);
116
+ if (currentIdentity)
117
+ initializedDatabaseFiles.set(dbPath, currentIdentity);
118
+ }
87
119
  }
88
120
  initializeSchema() {
89
121
  this.db.exec(`
@@ -1294,14 +1326,20 @@ class DevFlowDatabase {
1294
1326
  }
1295
1327
  getToolCallEventByToolUseId(sessionId, toolUseId) {
1296
1328
  const row = this.db.prepare(`
1297
- SELECT event_id, timestamp, duration
1329
+ SELECT event_id, execution_id, timestamp, duration, input
1298
1330
  FROM tool_call_events
1299
1331
  WHERE session_id = ? AND tool_use_id = ?
1300
1332
  ORDER BY timestamp DESC
1301
1333
  LIMIT 1
1302
1334
  `).get(sessionId, toolUseId);
1303
1335
  return row
1304
- ? { eventId: row.event_id, timestamp: row.timestamp, duration: row.duration ?? 0 }
1336
+ ? {
1337
+ eventId: row.event_id,
1338
+ executionId: row.execution_id,
1339
+ timestamp: row.timestamp,
1340
+ duration: row.duration ?? 0,
1341
+ input: parseJsonObject(row.input) ?? {},
1342
+ }
1305
1343
  : null;
1306
1344
  }
1307
1345
  listToolCallEventsBySessions(sessionIds) {
@@ -2457,7 +2495,8 @@ class DevFlowDatabase {
2457
2495
  SELECT COUNT(*) AS count
2458
2496
  FROM devflow_work_items
2459
2497
  WHERE project_root = ? AND session_id = ?
2460
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2498
+ AND state IN ('pending', 'leased', 'failed')
2499
+ AND attempts < max_attempts
2461
2500
  `).get(input.projectRoot, input.sessionId);
2462
2501
  const pendingWorkCount = Number(pending?.count ?? 0);
2463
2502
  this.db.prepare(`
@@ -2518,6 +2557,8 @@ class DevFlowDatabase {
2518
2557
  }
2519
2558
  if (input.kinds?.length === 0)
2520
2559
  return [];
2560
+ if (input.workItemIds?.length === 0)
2561
+ return [];
2521
2562
  const now = input.now ?? Date.now();
2522
2563
  const leaseExpiresAt = now + input.leaseMs;
2523
2564
  if (!Number.isSafeInteger(now) || !Number.isSafeInteger(leaseExpiresAt)) {
@@ -2525,9 +2566,13 @@ class DevFlowDatabase {
2525
2566
  }
2526
2567
  const limit = Math.min(Math.floor(input.limit), 1000);
2527
2568
  const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
2569
+ const workItemIds = input.workItemIds ? [...new Set(input.workItemIds)] : undefined;
2528
2570
  const kindClause = kinds
2529
2571
  ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
2530
2572
  : '';
2573
+ const workItemClause = workItemIds
2574
+ ? `AND id IN (${workItemIds.map(() => '?').join(', ')})`
2575
+ : '';
2531
2576
  this.db.exec('BEGIN IMMEDIATE');
2532
2577
  try {
2533
2578
  const candidates = this.db.prepare(`
@@ -2540,9 +2585,10 @@ class DevFlowDatabase {
2540
2585
  AND next_attempt_at <= ?
2541
2586
  AND attempts < max_attempts
2542
2587
  ${kindClause}
2588
+ ${workItemClause}
2543
2589
  ORDER BY next_attempt_at ASC, created_at ASC, id ASC
2544
2590
  LIMIT ?
2545
- `).all(input.projectRoot, now, ...(kinds ?? []), limit);
2591
+ `).all(input.projectRoot, now, ...(kinds ?? []), ...(workItemIds ?? []), limit);
2546
2592
  const leased = [];
2547
2593
  for (const candidate of candidates) {
2548
2594
  const result = this.db.prepare(`
@@ -2714,28 +2760,11 @@ class DevFlowDatabase {
2714
2760
  SELECT COUNT(*) AS count
2715
2761
  FROM devflow_work_items
2716
2762
  WHERE project_root = ? AND session_id = ?
2717
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
2763
+ AND state IN ('pending', 'leased', 'failed')
2764
+ AND attempts < max_attempts
2718
2765
  AND (? IS NULL OR id <> ?)
2719
2766
  `).get(projectRoot, sessionId, excludingWorkItemId ?? null, excludingWorkItemId ?? null);
2720
- const obligations = this.db.prepare(`
2721
- SELECT COUNT(*) AS count
2722
- FROM devflow_session_obligations
2723
- WHERE project_root = ? AND session_id = ?
2724
- AND state IN ('open', 'degraded')
2725
- `).get(projectRoot, sessionId);
2726
- const legacyTurns = this.db.prepare(`
2727
- SELECT COUNT(*) AS count
2728
- FROM devflow_memory_turns t
2729
- WHERE t.project_root = ? AND t.session_id = ? AND t.status = 'pending'
2730
- AND NOT EXISTS (
2731
- SELECT 1 FROM devflow_session_obligations o
2732
- WHERE o.project_root = t.project_root AND o.session_id = t.session_id
2733
- AND o.obligation_id = 'memory:' || t.turn_id
2734
- )
2735
- `).get(projectRoot, sessionId);
2736
- return Number(work?.count ?? 0)
2737
- + Number(obligations?.count ?? 0)
2738
- + Number(legacyTurns?.count ?? 0);
2767
+ return Number(work?.count ?? 0);
2739
2768
  }
2740
2769
  // ---- Hook Lifecycle ----
2741
2770
  getHookReceipt(projectRoot) {
@@ -3459,6 +3488,15 @@ class DevFlowDatabase {
3459
3488
  if (!existing)
3460
3489
  throw new Error(`Session obligation ${input.obligationId} does not exist`);
3461
3490
  if (existing.state !== 'open') {
3491
+ if (existing.state === 'degraded' && input.state === 'satisfied' && input.receiptId) {
3492
+ const resolvedAt = input.resolvedAt ?? Date.now();
3493
+ this.db.prepare(`
3494
+ UPDATE devflow_session_obligations
3495
+ SET state = 'satisfied', receipt_id = ?, reason = ?, resolved_at = ?, updated_at = ?
3496
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ? AND state = 'degraded'
3497
+ `).run(input.receiptId, input.reason ?? 'superseded_by_corrected_evidence', resolvedAt, resolvedAt, input.projectRoot, input.sessionId, input.obligationId);
3498
+ return this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId);
3499
+ }
3462
3500
  const sameResolution = existing.state === input.state
3463
3501
  && (input.receiptId === undefined || existing.receiptId === input.receiptId);
3464
3502
  if (sameResolution)
@@ -3517,6 +3555,19 @@ class DevFlowDatabase {
3517
3555
  });
3518
3556
  return turn;
3519
3557
  }
3558
+ updateCommittedMemoryTurnProjection(input) {
3559
+ const turnId = (0, obligation_ledger_1.normalizeTurnId)(input.turnId);
3560
+ this.db.prepare(`
3561
+ UPDATE devflow_memory_turns
3562
+ SET memory_ids = ?, reason = COALESCE(?, reason)
3563
+ WHERE turn_id = ? AND status = 'committed' AND source = 'explicit_intent'
3564
+ `).run(JSON.stringify([...new Set(input.memoryIds)]), input.reason ?? null, turnId);
3565
+ const turn = this.getMemoryTurn(turnId);
3566
+ if (!turn || turn.status !== 'committed') {
3567
+ throw new Error(`Committed explicit memory turn ${turnId} does not exist`);
3568
+ }
3569
+ return turn;
3570
+ }
3520
3571
  skipMemoryTurn(input) {
3521
3572
  const turnId = (0, obligation_ledger_1.normalizeTurnId)(input.turnId);
3522
3573
  this.db.prepare(`
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { DevFlowDatabase, getGlobalDevFlowDbPath, openGlobalDevFlowDatabase, } from './database';
1
+ export { DevFlowDatabase, getGlobalDevFlowDbPath, openGlobalDevFlowDatabase, openGlobalDevFlowReadOnlyDatabase, } from './database';
2
2
  export type { BenchmarkReportMetaRecord, BenchmarkReportRecord, FeedbackRecord, GovernanceAuditRecord, GovernanceRuleRecord, HookFallbackRecord, HookReceiptRecord, ContextReceiptRecord, ContextSelectionEventRecord, PolicyVerificationBaselineRecord, MemoryDistillCheckpointRecord, MemoryTurnRecord, MemoryTurnStatus, TelemetryFailureRecord, } from './database';
3
3
  export type { EnqueueWorkInput, LeaseWorkInput, RequestSessionClosureInput, SessionClosureRecord, SessionClosureState, WorkError, WorkItemRecord, WorkKind, WorkQueueHealth, WorkState, } from './work-queue';
4
4
  export type { RetrievalLedgerEventRecord, RetrievalLedgerSourceType, RetrievalLedgerStage, } from './retrieval-ledger';
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.stableLearningJson = exports.mapLearningVersionRow = exports.mapLearningEvidenceRow = exports.mapLearningCandidateRow = exports.mapWorkflowWorkerRow = exports.mapWorkflowWorkerEventRow = exports.mapWorkflowMergeRow = exports.TERMINAL_WORKFLOW_WORKER_STATES = exports.LEGAL_WORKFLOW_WORKER_TRANSITIONS = exports.stableSemanticJson = exports.mapTranscriptCheckpointRow = exports.mapToolNameResolutionRow = exports.mapTerminalTransitionRow = exports.mapTaskIntentArtifactRow = exports.mapChannelQueryPlanRow = exports.assertTaskIdentity = exports.normalizeTurnId = exports.mapSessionObligationRow = exports.isRetrievalSessionState = exports.RETRIEVAL_MAX_CYCLES = exports.serializeHostActionReport = exports.mapHostActionRow = exports.isHostActionState = exports.hostActionReportsEqual = exports.openGlobalDevFlowDatabase = exports.getGlobalDevFlowDbPath = exports.DevFlowDatabase = void 0;
3
+ exports.stableLearningJson = exports.mapLearningVersionRow = exports.mapLearningEvidenceRow = exports.mapLearningCandidateRow = exports.mapWorkflowWorkerRow = exports.mapWorkflowWorkerEventRow = exports.mapWorkflowMergeRow = exports.TERMINAL_WORKFLOW_WORKER_STATES = exports.LEGAL_WORKFLOW_WORKER_TRANSITIONS = exports.stableSemanticJson = exports.mapTranscriptCheckpointRow = exports.mapToolNameResolutionRow = exports.mapTerminalTransitionRow = exports.mapTaskIntentArtifactRow = exports.mapChannelQueryPlanRow = exports.assertTaskIdentity = exports.normalizeTurnId = exports.mapSessionObligationRow = exports.isRetrievalSessionState = exports.RETRIEVAL_MAX_CYCLES = exports.serializeHostActionReport = exports.mapHostActionRow = exports.isHostActionState = exports.hostActionReportsEqual = exports.openGlobalDevFlowReadOnlyDatabase = exports.openGlobalDevFlowDatabase = exports.getGlobalDevFlowDbPath = exports.DevFlowDatabase = void 0;
4
4
  var database_1 = require("./database");
5
5
  Object.defineProperty(exports, "DevFlowDatabase", { enumerable: true, get: function () { return database_1.DevFlowDatabase; } });
6
6
  Object.defineProperty(exports, "getGlobalDevFlowDbPath", { enumerable: true, get: function () { return database_1.getGlobalDevFlowDbPath; } });
7
7
  Object.defineProperty(exports, "openGlobalDevFlowDatabase", { enumerable: true, get: function () { return database_1.openGlobalDevFlowDatabase; } });
8
+ Object.defineProperty(exports, "openGlobalDevFlowReadOnlyDatabase", { enumerable: true, get: function () { return database_1.openGlobalDevFlowReadOnlyDatabase; } });
8
9
  var host_actions_1 = require("./host-actions");
9
10
  Object.defineProperty(exports, "hostActionReportsEqual", { enumerable: true, get: function () { return host_actions_1.hostActionReportsEqual; } });
10
11
  Object.defineProperty(exports, "isHostActionState", { enumerable: true, get: function () { return host_actions_1.isHostActionState; } });
@@ -1,7 +1,7 @@
1
1
  import type { Database, Statement } from './types';
2
2
  export declare class NodeSqliteDatabase implements Database {
3
3
  private db;
4
- constructor(path: string, busyTimeoutMs?: number);
4
+ constructor(path: string, busyTimeoutMs?: number, readOnly?: boolean);
5
5
  exec(sql: string): void;
6
6
  prepare(sql: string): Statement;
7
7
  transaction<T>(fn: () => T): T;
@@ -3,11 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NodeSqliteDatabase = void 0;
4
4
  const node_sqlite_1 = require("node:sqlite");
5
5
  class NodeSqliteDatabase {
6
- constructor(path, busyTimeoutMs = 10000) {
7
- this.db = new node_sqlite_1.DatabaseSync(path);
6
+ constructor(path, busyTimeoutMs = 10000, readOnly = false) {
7
+ this.db = readOnly
8
+ ? new node_sqlite_1.DatabaseSync(path, { readOnly: true })
9
+ : new node_sqlite_1.DatabaseSync(path);
8
10
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`);
9
- this.db.exec('PRAGMA journal_mode = WAL');
10
- this.db.exec('PRAGMA synchronous = NORMAL');
11
+ if (!readOnly) {
12
+ this.db.exec('PRAGMA journal_mode = WAL');
13
+ this.db.exec('PRAGMA synchronous = NORMAL');
14
+ }
11
15
  }
12
16
  exec(sql) {
13
17
  this.db.exec(sql);
@@ -38,6 +38,7 @@ export interface LeaseWorkInput {
38
38
  projectRoot: string;
39
39
  owner: string;
40
40
  kinds?: WorkKind[];
41
+ workItemIds?: string[];
41
42
  limit: number;
42
43
  leaseMs: number;
43
44
  now?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-tools/database",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "DevFlow SQLite database package",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/database.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NodeSqliteDatabase } from './node-sqlite';
2
2
  import { join, dirname, resolve } from 'path';
3
- import { existsSync, mkdirSync, realpathSync } from 'fs';
3
+ import { existsSync, mkdirSync, realpathSync, statSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import type {
@@ -305,35 +305,72 @@ export function getGlobalDevFlowDbPath(home = homedir()): string {
305
305
 
306
306
  export function openGlobalDevFlowDatabase(
307
307
  home = homedir(),
308
- options?: { busyTimeoutMs?: number },
308
+ options?: { busyTimeoutMs?: number; readonly?: boolean },
309
309
  ): DevFlowDatabase {
310
310
  return new DevFlowDatabase(home, {
311
311
  dbPath: getGlobalDevFlowDbPath(home),
312
312
  busyTimeoutMs: options?.busyTimeoutMs,
313
+ readonly: options?.readonly,
313
314
  });
314
315
  }
315
316
 
317
+ export function openGlobalDevFlowReadOnlyDatabase(
318
+ home = homedir(),
319
+ options?: { busyTimeoutMs?: number },
320
+ ): DevFlowDatabase {
321
+ return openGlobalDevFlowDatabase(home, { ...options, readonly: true });
322
+ }
323
+
324
+ interface InitializedDatabaseIdentity {
325
+ dev: number;
326
+ ino: number;
327
+ }
328
+
329
+ // Schema setup is process-owned. Hook daemons open short-lived connections for
330
+ // bounded transactions, but replaying the full idempotent DDL on every open is
331
+ // still expensive and serializes concurrent host requests. An inode identity
332
+ // keeps the cache safe when a test, repair, or user replaces the database file.
333
+ const initializedDatabaseFiles = new Map<string, InitializedDatabaseIdentity>();
334
+
335
+ function getDatabaseIdentity(path: string): InitializedDatabaseIdentity | null {
336
+ try {
337
+ const stats = statSync(path);
338
+ return { dev: stats.dev, ino: stats.ino };
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
316
344
  export class DevFlowDatabase {
317
345
  private db: NodeSqliteDatabase;
318
346
 
319
- constructor(projectRoot: string, opts?: { dbPath?: string; busyTimeoutMs?: number }) {
347
+ constructor(projectRoot: string, opts?: { dbPath?: string; busyTimeoutMs?: number; readonly?: boolean }) {
320
348
  let dbPath: string;
321
349
  if (opts?.dbPath) {
322
350
  const dir = dirname(opts.dbPath);
323
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
351
+ if (!existsSync(dir) && !opts.readonly) mkdirSync(dir, { recursive: true });
324
352
  dbPath = opts.dbPath;
325
353
  } else {
326
354
  const devflowDir = join(projectRoot, '.devflow');
327
- if (!existsSync(devflowDir)) mkdirSync(devflowDir, { recursive: true });
355
+ if (!existsSync(devflowDir) && !opts?.readonly) mkdirSync(devflowDir, { recursive: true });
328
356
  dbPath = join(devflowDir, 'devflow.db');
329
357
  }
330
- this.db = new NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs);
358
+ if (opts?.readonly && !existsSync(dbPath)) throw new Error(`DEVFLOW_DATABASE_NOT_FOUND:${dbPath}`);
359
+ this.db = new NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs, opts?.readonly === true);
331
360
 
332
- this.db.exec('PRAGMA journal_mode = WAL');
333
361
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts?.busyTimeoutMs ?? 5000))}`);
362
+ if (opts?.readonly) return;
334
363
  this.db.exec('PRAGMA foreign_keys = OFF');
335
-
336
- this.initializeSchema();
364
+ const databaseIdentity = getDatabaseIdentity(dbPath);
365
+ const initializedIdentity = initializedDatabaseFiles.get(dbPath);
366
+ const schemaInitialized = databaseIdentity !== null
367
+ && initializedIdentity?.dev === databaseIdentity.dev
368
+ && initializedIdentity.ino === databaseIdentity.ino;
369
+ if (!schemaInitialized) {
370
+ this.initializeSchema();
371
+ const currentIdentity = getDatabaseIdentity(dbPath);
372
+ if (currentIdentity) initializedDatabaseFiles.set(dbPath, currentIdentity);
373
+ }
337
374
  }
338
375
 
339
376
  private initializeSchema() {
@@ -1550,16 +1587,30 @@ export class DevFlowDatabase {
1550
1587
  }));
1551
1588
  }
1552
1589
 
1553
- getToolCallEventByToolUseId(sessionId: string, toolUseId: string): { eventId: string; timestamp: number; duration: number } | null {
1590
+ getToolCallEventByToolUseId(sessionId: string, toolUseId: string): {
1591
+ eventId: string;
1592
+ executionId: string;
1593
+ timestamp: number;
1594
+ duration: number;
1595
+ input: Record<string, unknown>;
1596
+ } | null {
1554
1597
  const row = this.db.prepare(`
1555
- SELECT event_id, timestamp, duration
1598
+ SELECT event_id, execution_id, timestamp, duration, input
1556
1599
  FROM tool_call_events
1557
1600
  WHERE session_id = ? AND tool_use_id = ?
1558
1601
  ORDER BY timestamp DESC
1559
1602
  LIMIT 1
1560
- `).get(sessionId, toolUseId) as { event_id: string; timestamp: number; duration: number } | undefined;
1603
+ `).get(sessionId, toolUseId) as {
1604
+ event_id: string; execution_id: string; timestamp: number; duration: number; input: string | null;
1605
+ } | undefined;
1561
1606
  return row
1562
- ? { eventId: row.event_id, timestamp: row.timestamp, duration: row.duration ?? 0 }
1607
+ ? {
1608
+ eventId: row.event_id,
1609
+ executionId: row.execution_id,
1610
+ timestamp: row.timestamp,
1611
+ duration: row.duration ?? 0,
1612
+ input: parseJsonObject(row.input) ?? {},
1613
+ }
1563
1614
  : null;
1564
1615
  }
1565
1616
 
@@ -3060,7 +3111,8 @@ export class DevFlowDatabase {
3060
3111
  SELECT COUNT(*) AS count
3061
3112
  FROM devflow_work_items
3062
3113
  WHERE project_root = ? AND session_id = ?
3063
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
3114
+ AND state IN ('pending', 'leased', 'failed')
3115
+ AND attempts < max_attempts
3064
3116
  `).get(input.projectRoot, input.sessionId) as { count?: number };
3065
3117
  const pendingWorkCount = Number(pending?.count ?? 0);
3066
3118
  this.db.prepare(`
@@ -3142,6 +3194,7 @@ export class DevFlowDatabase {
3142
3194
  throw new Error('Work leaseMs must be a positive safe integer');
3143
3195
  }
3144
3196
  if (input.kinds?.length === 0) return [];
3197
+ if (input.workItemIds?.length === 0) return [];
3145
3198
 
3146
3199
  const now = input.now ?? Date.now();
3147
3200
  const leaseExpiresAt = now + input.leaseMs;
@@ -3150,9 +3203,13 @@ export class DevFlowDatabase {
3150
3203
  }
3151
3204
  const limit = Math.min(Math.floor(input.limit), 1_000);
3152
3205
  const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
3206
+ const workItemIds = input.workItemIds ? [...new Set(input.workItemIds)] : undefined;
3153
3207
  const kindClause = kinds
3154
3208
  ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
3155
3209
  : '';
3210
+ const workItemClause = workItemIds
3211
+ ? `AND id IN (${workItemIds.map(() => '?').join(', ')})`
3212
+ : '';
3156
3213
 
3157
3214
  this.db.exec('BEGIN IMMEDIATE');
3158
3215
  try {
@@ -3166,9 +3223,10 @@ export class DevFlowDatabase {
3166
3223
  AND next_attempt_at <= ?
3167
3224
  AND attempts < max_attempts
3168
3225
  ${kindClause}
3226
+ ${workItemClause}
3169
3227
  ORDER BY next_attempt_at ASC, created_at ASC, id ASC
3170
3228
  LIMIT ?
3171
- `).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
3229
+ `).all(input.projectRoot, now, ...(kinds ?? []), ...(workItemIds ?? []), limit) as Array<{
3172
3230
  id: string;
3173
3231
  state: WorkState;
3174
3232
  }>;
@@ -3378,7 +3436,8 @@ export class DevFlowDatabase {
3378
3436
  SELECT COUNT(*) AS count
3379
3437
  FROM devflow_work_items
3380
3438
  WHERE project_root = ? AND session_id = ?
3381
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
3439
+ AND state IN ('pending', 'leased', 'failed')
3440
+ AND attempts < max_attempts
3382
3441
  AND (? IS NULL OR id <> ?)
3383
3442
  `).get(
3384
3443
  projectRoot,
@@ -3386,25 +3445,7 @@ export class DevFlowDatabase {
3386
3445
  excludingWorkItemId ?? null,
3387
3446
  excludingWorkItemId ?? null,
3388
3447
  ) as { count?: number };
3389
- const obligations = this.db.prepare(`
3390
- SELECT COUNT(*) AS count
3391
- FROM devflow_session_obligations
3392
- WHERE project_root = ? AND session_id = ?
3393
- AND state IN ('open', 'degraded')
3394
- `).get(projectRoot, sessionId) as { count?: number };
3395
- const legacyTurns = this.db.prepare(`
3396
- SELECT COUNT(*) AS count
3397
- FROM devflow_memory_turns t
3398
- WHERE t.project_root = ? AND t.session_id = ? AND t.status = 'pending'
3399
- AND NOT EXISTS (
3400
- SELECT 1 FROM devflow_session_obligations o
3401
- WHERE o.project_root = t.project_root AND o.session_id = t.session_id
3402
- AND o.obligation_id = 'memory:' || t.turn_id
3403
- )
3404
- `).get(projectRoot, sessionId) as { count?: number };
3405
- return Number(work?.count ?? 0)
3406
- + Number(obligations?.count ?? 0)
3407
- + Number(legacyTurns?.count ?? 0);
3448
+ return Number(work?.count ?? 0);
3408
3449
  }
3409
3450
 
3410
3451
  // ---- Hook Lifecycle ----
@@ -4330,6 +4371,23 @@ export class DevFlowDatabase {
4330
4371
  );
4331
4372
  if (!existing) throw new Error(`Session obligation ${input.obligationId} does not exist`);
4332
4373
  if (existing.state !== 'open') {
4374
+ if (existing.state === 'degraded' && input.state === 'satisfied' && input.receiptId) {
4375
+ const resolvedAt = input.resolvedAt ?? Date.now();
4376
+ this.db.prepare(`
4377
+ UPDATE devflow_session_obligations
4378
+ SET state = 'satisfied', receipt_id = ?, reason = ?, resolved_at = ?, updated_at = ?
4379
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ? AND state = 'degraded'
4380
+ `).run(
4381
+ input.receiptId,
4382
+ input.reason ?? 'superseded_by_corrected_evidence',
4383
+ resolvedAt,
4384
+ resolvedAt,
4385
+ input.projectRoot,
4386
+ input.sessionId,
4387
+ input.obligationId,
4388
+ );
4389
+ return this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId)!;
4390
+ }
4333
4391
  const sameResolution = existing.state === input.state
4334
4392
  && (input.receiptId === undefined || existing.receiptId === input.receiptId);
4335
4393
  if (sameResolution) return existing;
@@ -4417,6 +4475,28 @@ export class DevFlowDatabase {
4417
4475
  return turn;
4418
4476
  }
4419
4477
 
4478
+ updateCommittedMemoryTurnProjection(input: {
4479
+ turnId: string;
4480
+ memoryIds: string[];
4481
+ reason?: string;
4482
+ }): MemoryTurnRecord {
4483
+ const turnId = normalizeTurnId(input.turnId);
4484
+ this.db.prepare(`
4485
+ UPDATE devflow_memory_turns
4486
+ SET memory_ids = ?, reason = COALESCE(?, reason)
4487
+ WHERE turn_id = ? AND status = 'committed' AND source = 'explicit_intent'
4488
+ `).run(
4489
+ JSON.stringify([...new Set(input.memoryIds)]),
4490
+ input.reason ?? null,
4491
+ turnId,
4492
+ );
4493
+ const turn = this.getMemoryTurn(turnId);
4494
+ if (!turn || turn.status !== 'committed') {
4495
+ throw new Error(`Committed explicit memory turn ${turnId} does not exist`);
4496
+ }
4497
+ return turn;
4498
+ }
4499
+
4420
4500
  skipMemoryTurn(input: {
4421
4501
  turnId: string;
4422
4502
  receiptId: string;
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export {
2
2
  DevFlowDatabase,
3
3
  getGlobalDevFlowDbPath,
4
4
  openGlobalDevFlowDatabase,
5
+ openGlobalDevFlowReadOnlyDatabase,
5
6
  } from './database';
6
7
  export type {
7
8
  BenchmarkReportMetaRecord,
@@ -4,11 +4,15 @@ import type { Database, Statement, RunResult } from './types';
4
4
  export class NodeSqliteDatabase implements Database {
5
5
  private db: DatabaseSync;
6
6
 
7
- constructor(path: string, busyTimeoutMs = 10000) {
8
- this.db = new DatabaseSync(path);
7
+ constructor(path: string, busyTimeoutMs = 10000, readOnly = false) {
8
+ this.db = readOnly
9
+ ? new DatabaseSync(path, { readOnly: true })
10
+ : new DatabaseSync(path);
9
11
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`);
10
- this.db.exec('PRAGMA journal_mode = WAL');
11
- this.db.exec('PRAGMA synchronous = NORMAL');
12
+ if (!readOnly) {
13
+ this.db.exec('PRAGMA journal_mode = WAL');
14
+ this.db.exec('PRAGMA synchronous = NORMAL');
15
+ }
12
16
  }
13
17
 
14
18
  exec(sql: string): void {
package/src/work-queue.ts CHANGED
@@ -59,6 +59,7 @@ export interface LeaseWorkInput {
59
59
  projectRoot: string;
60
60
  owner: string;
61
61
  kinds?: WorkKind[];
62
+ workItemIds?: string[];
62
63
  limit: number;
63
64
  leaseMs: number;
64
65
  now?: number;