@devflow-tools/database 0.17.1 → 0.17.3

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
@@ -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 {
@@ -12,6 +12,7 @@ import type {
12
12
  WorkItemRecord,
13
13
  WorkQueueHealth,
14
14
  WorkState,
15
+ ProjectMutationLeaseRecord,
15
16
  } from './work-queue';
16
17
  import {
17
18
  mapSessionObligationRow,
@@ -84,6 +85,13 @@ import {
84
85
  type ToolNameResolutionRecord,
85
86
  type TranscriptCheckpointRecord,
86
87
  } from './task-semantic-control';
88
+ import {
89
+ mapTaskRuntimeEventRow,
90
+ mapTaskRuntimeSnapshotRow,
91
+ stableTaskRuntimeJson,
92
+ type TaskRuntimeEventRecord,
93
+ type TaskRuntimeSnapshotRecord,
94
+ } from './task-runtime';
87
95
 
88
96
  export interface BenchmarkReportRecord {
89
97
  runId: string;
@@ -305,35 +313,72 @@ export function getGlobalDevFlowDbPath(home = homedir()): string {
305
313
 
306
314
  export function openGlobalDevFlowDatabase(
307
315
  home = homedir(),
308
- options?: { busyTimeoutMs?: number },
316
+ options?: { busyTimeoutMs?: number; readonly?: boolean },
309
317
  ): DevFlowDatabase {
310
318
  return new DevFlowDatabase(home, {
311
319
  dbPath: getGlobalDevFlowDbPath(home),
312
320
  busyTimeoutMs: options?.busyTimeoutMs,
321
+ readonly: options?.readonly,
313
322
  });
314
323
  }
315
324
 
325
+ export function openGlobalDevFlowReadOnlyDatabase(
326
+ home = homedir(),
327
+ options?: { busyTimeoutMs?: number },
328
+ ): DevFlowDatabase {
329
+ return openGlobalDevFlowDatabase(home, { ...options, readonly: true });
330
+ }
331
+
332
+ interface InitializedDatabaseIdentity {
333
+ dev: number;
334
+ ino: number;
335
+ }
336
+
337
+ // Schema setup is process-owned. Hook daemons open short-lived connections for
338
+ // bounded transactions, but replaying the full idempotent DDL on every open is
339
+ // still expensive and serializes concurrent host requests. An inode identity
340
+ // keeps the cache safe when a test, repair, or user replaces the database file.
341
+ const initializedDatabaseFiles = new Map<string, InitializedDatabaseIdentity>();
342
+
343
+ function getDatabaseIdentity(path: string): InitializedDatabaseIdentity | null {
344
+ try {
345
+ const stats = statSync(path);
346
+ return { dev: stats.dev, ino: stats.ino };
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
316
352
  export class DevFlowDatabase {
317
353
  private db: NodeSqliteDatabase;
318
354
 
319
- constructor(projectRoot: string, opts?: { dbPath?: string; busyTimeoutMs?: number }) {
355
+ constructor(projectRoot: string, opts?: { dbPath?: string; busyTimeoutMs?: number; readonly?: boolean }) {
320
356
  let dbPath: string;
321
357
  if (opts?.dbPath) {
322
358
  const dir = dirname(opts.dbPath);
323
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
359
+ if (!existsSync(dir) && !opts.readonly) mkdirSync(dir, { recursive: true });
324
360
  dbPath = opts.dbPath;
325
361
  } else {
326
362
  const devflowDir = join(projectRoot, '.devflow');
327
- if (!existsSync(devflowDir)) mkdirSync(devflowDir, { recursive: true });
363
+ if (!existsSync(devflowDir) && !opts?.readonly) mkdirSync(devflowDir, { recursive: true });
328
364
  dbPath = join(devflowDir, 'devflow.db');
329
365
  }
330
- this.db = new NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs);
366
+ if (opts?.readonly && !existsSync(dbPath)) throw new Error(`DEVFLOW_DATABASE_NOT_FOUND:${dbPath}`);
367
+ this.db = new NodeSqliteDatabase(dbPath, opts?.busyTimeoutMs, opts?.readonly === true);
331
368
 
332
- this.db.exec('PRAGMA journal_mode = WAL');
333
369
  this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(opts?.busyTimeoutMs ?? 5000))}`);
370
+ if (opts?.readonly) return;
334
371
  this.db.exec('PRAGMA foreign_keys = OFF');
335
-
336
- this.initializeSchema();
372
+ const databaseIdentity = getDatabaseIdentity(dbPath);
373
+ const initializedIdentity = initializedDatabaseFiles.get(dbPath);
374
+ const schemaInitialized = databaseIdentity !== null
375
+ && initializedIdentity?.dev === databaseIdentity.dev
376
+ && initializedIdentity.ino === databaseIdentity.ino;
377
+ if (!schemaInitialized) {
378
+ this.initializeSchema();
379
+ const currentIdentity = getDatabaseIdentity(dbPath);
380
+ if (currentIdentity) initializedDatabaseFiles.set(dbPath, currentIdentity);
381
+ }
337
382
  }
338
383
 
339
384
  private initializeSchema() {
@@ -575,6 +620,9 @@ export class DevFlowDatabase {
575
620
  source_id TEXT NOT NULL, stage TEXT NOT NULL, rank INTEGER, raw_score REAL,
576
621
  normalized_score REAL, final_score REAL, applicability TEXT NOT NULL DEFAULT '[]',
577
622
  reason TEXT, tool_evidence TEXT NOT NULL DEFAULT '[]', verification_receipt TEXT,
623
+ schema_version TEXT NOT NULL DEFAULT 'retrieval-ledger-event.v1', task_spec_hash TEXT,
624
+ actor TEXT, source_version TEXT, source_content_hash TEXT,
625
+ evidence_ids TEXT NOT NULL DEFAULT '[]', reason_code TEXT,
578
626
  payload TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL
579
627
  );
580
628
  CREATE INDEX IF NOT EXISTS idx_retrieval_ledger_identity ON devflow_retrieval_ledger(
@@ -679,6 +727,40 @@ export class DevFlowDatabase {
679
727
  CREATE INDEX IF NOT EXISTS idx_terminal_transition_turn
680
728
  ON devflow_terminal_transitions(project_root, session_id, turn_id, sequence);
681
729
 
730
+ CREATE TABLE IF NOT EXISTS devflow_task_runtime_events (
731
+ event_id TEXT PRIMARY KEY,
732
+ schema_version TEXT NOT NULL,
733
+ producer TEXT NOT NULL,
734
+ producer_version TEXT NOT NULL,
735
+ project_root TEXT NOT NULL,
736
+ project_id TEXT NOT NULL,
737
+ host_id TEXT NOT NULL,
738
+ session_id TEXT NOT NULL,
739
+ turn_id TEXT NOT NULL,
740
+ request_id TEXT NOT NULL,
741
+ execution_id TEXT,
742
+ task_spec_hash TEXT NOT NULL,
743
+ sequence INTEGER NOT NULL CHECK(sequence > 0),
744
+ kind TEXT NOT NULL,
745
+ payload_json TEXT NOT NULL DEFAULT '{}',
746
+ created_at INTEGER NOT NULL,
747
+ UNIQUE(project_root, session_id, turn_id, sequence)
748
+ );
749
+ CREATE INDEX IF NOT EXISTS idx_task_runtime_event_identity
750
+ ON devflow_task_runtime_events(project_root, session_id, turn_id, sequence);
751
+
752
+ CREATE TABLE IF NOT EXISTS devflow_task_runtime_snapshots (
753
+ project_root TEXT NOT NULL,
754
+ session_id TEXT NOT NULL,
755
+ turn_id TEXT NOT NULL,
756
+ last_event_sequence INTEGER NOT NULL,
757
+ schema_version TEXT NOT NULL,
758
+ snapshot_json TEXT NOT NULL,
759
+ snapshot_hash TEXT NOT NULL,
760
+ updated_at INTEGER NOT NULL,
761
+ PRIMARY KEY(project_root, session_id, turn_id)
762
+ );
763
+
682
764
  CREATE TABLE IF NOT EXISTS devflow_transcript_checkpoints (
683
765
  id TEXT PRIMARY KEY,
684
766
  project_root TEXT NOT NULL,
@@ -832,9 +914,11 @@ export class DevFlowDatabase {
832
914
  project_root TEXT NOT NULL,
833
915
  session_id TEXT,
834
916
  turn_id TEXT,
917
+ source_hash TEXT,
918
+ task_spec_hash TEXT,
835
919
  payload TEXT NOT NULL,
836
920
  state TEXT NOT NULL DEFAULT 'pending'
837
- CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter')),
921
+ CHECK(state IN ('pending', 'leased', 'completed', 'failed', 'dead_letter', 'cancelled')),
838
922
  attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
839
923
  max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts > 0),
840
924
  lease_owner TEXT,
@@ -844,7 +928,11 @@ export class DevFlowDatabase {
844
928
  error_message TEXT,
845
929
  created_at INTEGER NOT NULL,
846
930
  updated_at INTEGER NOT NULL,
847
- completed_at INTEGER
931
+ completed_at INTEGER,
932
+ cancelled_at INTEGER,
933
+ cancellation_reason TEXT,
934
+ replay_of_id TEXT,
935
+ replay_count INTEGER NOT NULL DEFAULT 0
848
936
  );
849
937
 
850
938
  CREATE INDEX IF NOT EXISTS idx_work_items_ready
@@ -854,6 +942,16 @@ export class DevFlowDatabase {
854
942
  CREATE INDEX IF NOT EXISTS idx_work_items_completed
855
943
  ON devflow_work_items(project_root, completed_at DESC);
856
944
 
945
+ CREATE TABLE IF NOT EXISTS devflow_project_mutation_leases (
946
+ project_root TEXT NOT NULL,
947
+ mutation_kind TEXT NOT NULL,
948
+ owner TEXT NOT NULL,
949
+ lease_expires_at INTEGER NOT NULL,
950
+ source_hash TEXT,
951
+ updated_at INTEGER NOT NULL,
952
+ PRIMARY KEY(project_root, mutation_kind)
953
+ );
954
+
857
955
  CREATE TABLE IF NOT EXISTS devflow_session_closures (
858
956
  session_id TEXT NOT NULL,
859
957
  project_root TEXT NOT NULL,
@@ -1125,6 +1223,19 @@ export class DevFlowDatabase {
1125
1223
  try { this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN action_satisfied_at INTEGER'); } catch {}
1126
1224
  try { this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN action_degradation_json TEXT'); } catch {}
1127
1225
  try { this.db.exec('ALTER TABLE devflow_context_receipts ADD COLUMN request_id TEXT'); } catch {}
1226
+ try { this.db.exec("ALTER TABLE devflow_retrieval_ledger ADD COLUMN schema_version TEXT NOT NULL DEFAULT 'retrieval-ledger-event.v1'"); } catch {}
1227
+ try { this.db.exec('ALTER TABLE devflow_retrieval_ledger ADD COLUMN task_spec_hash TEXT'); } catch {}
1228
+ try { this.db.exec('ALTER TABLE devflow_retrieval_ledger ADD COLUMN actor TEXT'); } catch {}
1229
+ try { this.db.exec('ALTER TABLE devflow_retrieval_ledger ADD COLUMN source_version TEXT'); } catch {}
1230
+ try { this.db.exec('ALTER TABLE devflow_retrieval_ledger ADD COLUMN source_content_hash TEXT'); } catch {}
1231
+ try { this.db.exec("ALTER TABLE devflow_retrieval_ledger ADD COLUMN evidence_ids TEXT NOT NULL DEFAULT '[]'"); } catch {}
1232
+ try { this.db.exec('ALTER TABLE devflow_retrieval_ledger ADD COLUMN reason_code TEXT'); } catch {}
1233
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN source_hash TEXT'); } catch {}
1234
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN task_spec_hash TEXT'); } catch {}
1235
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN cancelled_at INTEGER'); } catch {}
1236
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN cancellation_reason TEXT'); } catch {}
1237
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN replay_of_id TEXT'); } catch {}
1238
+ try { this.db.exec('ALTER TABLE devflow_work_items ADD COLUMN replay_count INTEGER NOT NULL DEFAULT 0'); } catch {}
1128
1239
  this.db.exec('UPDATE devflow_rules SET updated_at = created_at WHERE updated_at IS NULL');
1129
1240
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_devflow_rules_gate ON devflow_rules(gate, enabled)');
1130
1241
 
@@ -1550,16 +1661,30 @@ export class DevFlowDatabase {
1550
1661
  }));
1551
1662
  }
1552
1663
 
1553
- getToolCallEventByToolUseId(sessionId: string, toolUseId: string): { eventId: string; timestamp: number; duration: number } | null {
1664
+ getToolCallEventByToolUseId(sessionId: string, toolUseId: string): {
1665
+ eventId: string;
1666
+ executionId: string;
1667
+ timestamp: number;
1668
+ duration: number;
1669
+ input: Record<string, unknown>;
1670
+ } | null {
1554
1671
  const row = this.db.prepare(`
1555
- SELECT event_id, timestamp, duration
1672
+ SELECT event_id, execution_id, timestamp, duration, input
1556
1673
  FROM tool_call_events
1557
1674
  WHERE session_id = ? AND tool_use_id = ?
1558
1675
  ORDER BY timestamp DESC
1559
1676
  LIMIT 1
1560
- `).get(sessionId, toolUseId) as { event_id: string; timestamp: number; duration: number } | undefined;
1677
+ `).get(sessionId, toolUseId) as {
1678
+ event_id: string; execution_id: string; timestamp: number; duration: number; input: string | null;
1679
+ } | undefined;
1561
1680
  return row
1562
- ? { eventId: row.event_id, timestamp: row.timestamp, duration: row.duration ?? 0 }
1681
+ ? {
1682
+ eventId: row.event_id,
1683
+ executionId: row.execution_id,
1684
+ timestamp: row.timestamp,
1685
+ duration: row.duration ?? 0,
1686
+ input: parseJsonObject(row.input) ?? {},
1687
+ }
1563
1688
  : null;
1564
1689
  }
1565
1690
 
@@ -2959,7 +3084,8 @@ export class DevFlowDatabase {
2959
3084
  enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
2960
3085
  if (!input.idempotencyKey.trim()) throw new Error('Work idempotency key is required');
2961
3086
  if (!input.projectRoot.trim()) throw new Error('Work project root is required');
2962
- const maxAttempts = input.maxAttempts ?? 5;
3087
+ const maxAttempts = input.maxAttempts
3088
+ ?? (input.kind === 'knowledge.ingest' || input.kind === 'knowledge.index_refresh' ? 3 : 5);
2963
3089
  if (!Number.isSafeInteger(maxAttempts) || maxAttempts <= 0) {
2964
3090
  throw new Error('Work maxAttempts must be a positive safe integer');
2965
3091
  }
@@ -2972,10 +3098,10 @@ export class DevFlowDatabase {
2972
3098
 
2973
3099
  const row = this.db.prepare(`
2974
3100
  INSERT INTO devflow_work_items (
2975
- id, idempotency_key, kind, project_root, session_id, turn_id, payload,
3101
+ id, idempotency_key, kind, project_root, session_id, turn_id, source_hash, task_spec_hash, payload,
2976
3102
  state, attempts, max_attempts, lease_owner, lease_expires_at,
2977
3103
  next_attempt_at, error_category, error_message, created_at, updated_at, completed_at
2978
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
3104
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, NULL, NULL, ?, ?, NULL)
2979
3105
  ON CONFLICT(idempotency_key) DO UPDATE SET
2980
3106
  state = CASE
2981
3107
  WHEN devflow_work_items.state = 'dead_letter'
@@ -3016,6 +3142,8 @@ export class DevFlowDatabase {
3016
3142
  input.projectRoot,
3017
3143
  input.sessionId ?? null,
3018
3144
  input.turnId ?? null,
3145
+ input.sourceHash ?? null,
3146
+ input.taskSpecHash ?? null,
3019
3147
  JSON.stringify(input.payload ?? null),
3020
3148
  maxAttempts,
3021
3149
  nextAttemptAt,
@@ -3033,6 +3161,53 @@ export class DevFlowDatabase {
3033
3161
  return row ? this.mapWorkItem(row) : null;
3034
3162
  }
3035
3163
 
3164
+ getWorkById(id: string): WorkItemRecord | null {
3165
+ const row = this.db.prepare('SELECT * FROM devflow_work_items WHERE id = ?').get(id) as any;
3166
+ return row ? this.mapWorkItem(row) : null;
3167
+ }
3168
+
3169
+ acquireProjectMutationLease(input: { projectRoot: string; mutationKind: string; owner: string; leaseMs: number; sourceHash?: string; now?: number }): boolean {
3170
+ const now = input.now ?? Date.now();
3171
+ return this.db.prepare(`
3172
+ INSERT INTO devflow_project_mutation_leases
3173
+ (project_root, mutation_kind, owner, lease_expires_at, source_hash, updated_at)
3174
+ VALUES (?, ?, ?, ?, ?, ?)
3175
+ ON CONFLICT(project_root, mutation_kind) DO UPDATE SET
3176
+ owner = excluded.owner, lease_expires_at = excluded.lease_expires_at,
3177
+ source_hash = excluded.source_hash, updated_at = excluded.updated_at
3178
+ WHERE devflow_project_mutation_leases.lease_expires_at <= ? OR devflow_project_mutation_leases.owner = excluded.owner
3179
+ `).run(input.projectRoot, input.mutationKind, input.owner, now + input.leaseMs, input.sourceHash ?? null, now, now).changes > 0;
3180
+ }
3181
+
3182
+ releaseProjectMutationLease(projectRoot: string, mutationKind: string, owner: string): boolean {
3183
+ return this.db.prepare('DELETE FROM devflow_project_mutation_leases WHERE project_root = ? AND mutation_kind = ? AND owner = ?')
3184
+ .run(projectRoot, mutationKind, owner).changes > 0;
3185
+ }
3186
+
3187
+ cancelStaleWork(input: { id: string; owner: string; sourceHash?: string; taskSpecHash?: string; reason?: string; now?: number }): boolean {
3188
+ const current = this.getWorkById(input.id);
3189
+ const stale = current && current.state === 'leased' && current.leaseOwner === input.owner
3190
+ && ((input.sourceHash !== undefined && current.sourceHash !== input.sourceHash)
3191
+ || (input.taskSpecHash !== undefined && current.taskSpecHash !== input.taskSpecHash));
3192
+ if (!stale) return false;
3193
+ const now = input.now ?? Date.now();
3194
+ try {
3195
+ return this.db.prepare(`UPDATE devflow_work_items SET state = 'cancelled', lease_owner = NULL, lease_expires_at = NULL, cancelled_at = ?, cancellation_reason = ?, updated_at = ? WHERE id = ? AND state = 'leased' AND lease_owner = ?`)
3196
+ .run(now, input.reason ?? 'stale_source_or_task_spec', now, input.id, input.owner).changes === 1;
3197
+ } catch {
3198
+ return this.db.prepare(`UPDATE devflow_work_items SET state = 'completed', lease_owner = NULL, lease_expires_at = NULL, cancelled_at = ?, cancellation_reason = ?, updated_at = ? WHERE id = ? AND state = 'leased' AND lease_owner = ?`)
3199
+ .run(now, input.reason ?? 'stale_source_or_task_spec', now, input.id, input.owner).changes === 1;
3200
+ }
3201
+ }
3202
+
3203
+ replayDeadLetterWork(id: string, input: { idempotencyKey: string; sourceHash?: string; taskSpecHash?: string; now?: number }): WorkItemRecord | null {
3204
+ const original = this.getWorkById(id);
3205
+ if (!original || original.state !== 'dead_letter') return null;
3206
+ const replay = this.enqueueWork({ idempotencyKey: input.idempotencyKey, kind: original.kind, projectRoot: original.projectRoot, sessionId: original.sessionId, turnId: original.turnId, payload: original.payload, sourceHash: input.sourceHash ?? original.sourceHash, taskSpecHash: input.taskSpecHash ?? original.taskSpecHash, maxAttempts: original.maxAttempts, nextAttemptAt: input.now ?? Date.now() });
3207
+ this.db.prepare('UPDATE devflow_work_items SET replay_of_id = ?, replay_count = replay_count + 1 WHERE id = ?').run(original.id, replay.id);
3208
+ return this.getWorkById(replay.id);
3209
+ }
3210
+
3036
3211
  requestSessionClosure(input: RequestSessionClosureInput): SessionClosureRecord {
3037
3212
  if (!input.sessionId.trim()) throw new Error('Session closure requires a session ID');
3038
3213
  if (!input.projectRoot.trim()) throw new Error('Session closure requires a project root');
@@ -3060,7 +3235,8 @@ export class DevFlowDatabase {
3060
3235
  SELECT COUNT(*) AS count
3061
3236
  FROM devflow_work_items
3062
3237
  WHERE project_root = ? AND session_id = ?
3063
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
3238
+ AND state IN ('pending', 'leased', 'failed')
3239
+ AND attempts < max_attempts
3064
3240
  `).get(input.projectRoot, input.sessionId) as { count?: number };
3065
3241
  const pendingWorkCount = Number(pending?.count ?? 0);
3066
3242
  this.db.prepare(`
@@ -3142,6 +3318,7 @@ export class DevFlowDatabase {
3142
3318
  throw new Error('Work leaseMs must be a positive safe integer');
3143
3319
  }
3144
3320
  if (input.kinds?.length === 0) return [];
3321
+ if (input.workItemIds?.length === 0) return [];
3145
3322
 
3146
3323
  const now = input.now ?? Date.now();
3147
3324
  const leaseExpiresAt = now + input.leaseMs;
@@ -3150,9 +3327,13 @@ export class DevFlowDatabase {
3150
3327
  }
3151
3328
  const limit = Math.min(Math.floor(input.limit), 1_000);
3152
3329
  const kinds = input.kinds ? [...new Set(input.kinds)] : undefined;
3330
+ const workItemIds = input.workItemIds ? [...new Set(input.workItemIds)] : undefined;
3153
3331
  const kindClause = kinds
3154
3332
  ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
3155
3333
  : '';
3334
+ const workItemClause = workItemIds
3335
+ ? `AND id IN (${workItemIds.map(() => '?').join(', ')})`
3336
+ : '';
3156
3337
 
3157
3338
  this.db.exec('BEGIN IMMEDIATE');
3158
3339
  try {
@@ -3166,9 +3347,10 @@ export class DevFlowDatabase {
3166
3347
  AND next_attempt_at <= ?
3167
3348
  AND attempts < max_attempts
3168
3349
  ${kindClause}
3350
+ ${workItemClause}
3169
3351
  ORDER BY next_attempt_at ASC, created_at ASC, id ASC
3170
3352
  LIMIT ?
3171
- `).all(input.projectRoot, now, ...(kinds ?? []), limit) as Array<{
3353
+ `).all(input.projectRoot, now, ...(kinds ?? []), ...(workItemIds ?? []), limit) as Array<{
3172
3354
  id: string;
3173
3355
  state: WorkState;
3174
3356
  }>;
@@ -3329,8 +3511,10 @@ export class DevFlowDatabase {
3329
3511
  projectRoot: row.project_root,
3330
3512
  sessionId: row.session_id ?? undefined,
3331
3513
  turnId: row.turn_id ?? undefined,
3514
+ sourceHash: row.source_hash ?? undefined,
3515
+ taskSpecHash: row.task_spec_hash ?? undefined,
3332
3516
  payload: parseJson(row.payload),
3333
- state: row.state,
3517
+ state: row.cancelled_at != null ? 'cancelled' : row.state,
3334
3518
  attempts: Number(row.attempts),
3335
3519
  maxAttempts: Number(row.max_attempts),
3336
3520
  leaseOwner: row.lease_owner ?? undefined,
@@ -3341,6 +3525,10 @@ export class DevFlowDatabase {
3341
3525
  createdAt: Number(row.created_at),
3342
3526
  updatedAt: Number(row.updated_at),
3343
3527
  completedAt: row.completed_at == null ? undefined : Number(row.completed_at),
3528
+ cancelledAt: row.cancelled_at == null ? undefined : Number(row.cancelled_at),
3529
+ cancellationReason: row.cancellation_reason ?? undefined,
3530
+ replayOfId: row.replay_of_id ?? undefined,
3531
+ replayCount: Number(row.replay_count ?? 0),
3344
3532
  };
3345
3533
  }
3346
3534
 
@@ -3378,7 +3566,8 @@ export class DevFlowDatabase {
3378
3566
  SELECT COUNT(*) AS count
3379
3567
  FROM devflow_work_items
3380
3568
  WHERE project_root = ? AND session_id = ?
3381
- AND state IN ('pending', 'leased', 'failed', 'dead_letter')
3569
+ AND state IN ('pending', 'leased', 'failed')
3570
+ AND attempts < max_attempts
3382
3571
  AND (? IS NULL OR id <> ?)
3383
3572
  `).get(
3384
3573
  projectRoot,
@@ -3386,25 +3575,7 @@ export class DevFlowDatabase {
3386
3575
  excludingWorkItemId ?? null,
3387
3576
  excludingWorkItemId ?? null,
3388
3577
  ) 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);
3578
+ return Number(work?.count ?? 0);
3408
3579
  }
3409
3580
 
3410
3581
  // ---- Hook Lifecycle ----
@@ -3769,6 +3940,38 @@ export class DevFlowDatabase {
3769
3940
  } : null;
3770
3941
  }
3771
3942
 
3943
+ getContextReceiptForRequest(
3944
+ projectRoot: string,
3945
+ sessionId: string,
3946
+ requestId: string,
3947
+ ): ContextReceiptRecord | null {
3948
+ const row = this.db.prepare(`
3949
+ SELECT project_root, session_id, execution_id, context_hash, issued_at, expires_at,
3950
+ selected_files, memory_ids, canonical_next_action, canonical_action_json,
3951
+ action_attempts, action_satisfied_at, action_degradation_json, request_id
3952
+ FROM devflow_context_receipts
3953
+ WHERE project_root = ? AND session_id = ? AND request_id = ?
3954
+ ORDER BY issued_at DESC
3955
+ LIMIT 1
3956
+ `).get(projectRoot, sessionId, requestId) as any;
3957
+ return row ? {
3958
+ projectRoot: row.project_root,
3959
+ sessionId: row.session_id,
3960
+ executionId: row.execution_id,
3961
+ contextHash: row.context_hash,
3962
+ issuedAt: row.issued_at,
3963
+ expiresAt: row.expires_at,
3964
+ selectedFiles: parseJsonStringArray(row.selected_files),
3965
+ memoryIds: parseJsonStringArray(row.memory_ids),
3966
+ canonicalNextAction: row.canonical_next_action ?? undefined,
3967
+ canonicalAction: parseJsonObject(row.canonical_action_json),
3968
+ actionAttempts: parseJsonArray(row.action_attempts),
3969
+ actionSatisfiedAt: row.action_satisfied_at ?? undefined,
3970
+ actionDegradation: parseJsonObject(row.action_degradation_json),
3971
+ requestId: row.request_id ?? undefined,
3972
+ } : null;
3973
+ }
3974
+
3772
3975
  recordContextSelectionEvent(event: ContextSelectionEventRecord): boolean {
3773
3976
  return this.db.prepare(`
3774
3977
  INSERT OR IGNORE INTO devflow_context_selection_events
@@ -3801,7 +4004,8 @@ export class DevFlowDatabase {
3801
4004
  id, project_root, session_id, execution_id, turn_id, request_id, context_receipt,
3802
4005
  source_type, source_id, stage, rank, raw_score, normalized_score, final_score,
3803
4006
  applicability, reason, tool_evidence, verification_receipt, payload, created_at
3804
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4007
+ , schema_version, task_spec_hash, actor, source_version, source_content_hash, evidence_ids, reason_code
4008
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3805
4009
  `).run(
3806
4010
  event.id, event.projectRoot, event.sessionId, event.executionId ?? null, event.turnId ?? null,
3807
4011
  event.requestId, event.contextReceipt, event.sourceType, event.sourceId, event.stage,
@@ -3809,6 +4013,9 @@ export class DevFlowDatabase {
3809
4013
  event.finalScore ?? null, JSON.stringify(event.applicability), event.reason ?? null,
3810
4014
  JSON.stringify(event.toolEvidence), event.verificationReceipt ?? null,
3811
4015
  JSON.stringify(event.payload), event.createdAt,
4016
+ event.schemaVersion ?? 'retrieval-ledger-event.v1', event.taskSpecHash ?? null,
4017
+ event.actor ?? null, event.sourceVersion ?? null, event.sourceContentHash ?? null,
4018
+ JSON.stringify(event.evidenceIds ?? []), event.reasonCode ?? null,
3812
4019
  ).changes > 0;
3813
4020
  }
3814
4021
 
@@ -4044,6 +4251,123 @@ export class DevFlowDatabase {
4044
4251
  return this.getTerminalTransition(record.receiptId)!;
4045
4252
  }
4046
4253
 
4254
+ appendTaskRuntimeEvent(event: TaskRuntimeEventRecord): TaskRuntimeEventRecord {
4255
+ const existing = this.getTaskRuntimeEvent(event.eventId);
4256
+ if (existing) {
4257
+ if (stableTaskRuntimeJson(existing) !== stableTaskRuntimeJson(event)) {
4258
+ throw new Error(`TASK_RUNTIME_EVENT_CONFLICT:${event.eventId}`);
4259
+ }
4260
+ return existing;
4261
+ }
4262
+ const atSequence = this.db.prepare(`
4263
+ SELECT event_id FROM devflow_task_runtime_events
4264
+ WHERE project_root = ? AND session_id = ? AND turn_id = ? AND sequence = ?
4265
+ `).get(event.identity.projectRoot, event.identity.sessionId, event.identity.turnId, event.sequence) as
4266
+ { event_id?: string } | undefined;
4267
+ if (atSequence) throw new Error(`TASK_RUNTIME_SEQUENCE_CONFLICT:${event.sequence}`);
4268
+ try {
4269
+ this.db.prepare(`
4270
+ INSERT INTO devflow_task_runtime_events (
4271
+ event_id, schema_version, producer, producer_version, project_root, project_id,
4272
+ host_id, session_id, turn_id, request_id, execution_id, task_spec_hash,
4273
+ sequence, kind, payload_json, created_at
4274
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4275
+ `).run(
4276
+ event.eventId, event.schemaVersion, event.producer, event.producerVersion,
4277
+ event.identity.projectRoot, event.identity.projectId, event.identity.hostId,
4278
+ event.identity.sessionId, event.identity.turnId, event.identity.requestId,
4279
+ event.identity.executionId ?? null, event.taskSpecHash, event.sequence,
4280
+ event.kind, stableTaskRuntimeJson(event.payload), event.createdAt,
4281
+ );
4282
+ } catch (error) {
4283
+ const concurrentEvent = this.getTaskRuntimeEvent(event.eventId);
4284
+ if (concurrentEvent) {
4285
+ if (stableTaskRuntimeJson(concurrentEvent) === stableTaskRuntimeJson(event)) return concurrentEvent;
4286
+ throw new Error(`TASK_RUNTIME_EVENT_CONFLICT:${event.eventId}`);
4287
+ }
4288
+ const concurrentSequence = this.db.prepare(`
4289
+ SELECT event_id FROM devflow_task_runtime_events
4290
+ WHERE project_root = ? AND session_id = ? AND turn_id = ? AND sequence = ?
4291
+ `).get(event.identity.projectRoot, event.identity.sessionId, event.identity.turnId, event.sequence);
4292
+ if (concurrentSequence) throw new Error(`TASK_RUNTIME_SEQUENCE_CONFLICT:${event.sequence}`);
4293
+ throw error;
4294
+ }
4295
+ return this.getTaskRuntimeEvent(event.eventId)!;
4296
+ }
4297
+
4298
+ getTaskRuntimeEvent(eventId: string): TaskRuntimeEventRecord | null {
4299
+ const row = this.db.prepare('SELECT * FROM devflow_task_runtime_events WHERE event_id = ?')
4300
+ .get(eventId) as Record<string, unknown> | undefined;
4301
+ return row ? mapTaskRuntimeEventRow(row) : null;
4302
+ }
4303
+
4304
+ listTaskRuntimeEvents(projectRoot: string, sessionId: string, turnId: string): TaskRuntimeEventRecord[] {
4305
+ return (this.db.prepare(`
4306
+ SELECT * FROM devflow_task_runtime_events
4307
+ WHERE project_root = ? AND session_id = ? AND turn_id = ?
4308
+ ORDER BY sequence ASC
4309
+ `).all(projectRoot, sessionId, turnId) as Array<Record<string, unknown>>)
4310
+ .map(mapTaskRuntimeEventRow);
4311
+ }
4312
+
4313
+ putTaskRuntimeSnapshot(snapshot: TaskRuntimeSnapshotRecord): TaskRuntimeSnapshotRecord {
4314
+ const current = this.getTaskRuntimeSnapshot(
4315
+ snapshot.identity.projectRoot, snapshot.identity.sessionId, snapshot.identity.turnId,
4316
+ );
4317
+ if (current && current.lastEventSequence > snapshot.lastEventSequence) {
4318
+ throw new Error('TASK_RUNTIME_SNAPSHOT_REGRESSION');
4319
+ }
4320
+ this.db.prepare(`
4321
+ INSERT INTO devflow_task_runtime_snapshots (
4322
+ project_root, session_id, turn_id, last_event_sequence, schema_version,
4323
+ snapshot_json, snapshot_hash, updated_at
4324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4325
+ ON CONFLICT(project_root, session_id, turn_id) DO UPDATE SET
4326
+ last_event_sequence = excluded.last_event_sequence,
4327
+ schema_version = excluded.schema_version,
4328
+ snapshot_json = excluded.snapshot_json,
4329
+ snapshot_hash = excluded.snapshot_hash,
4330
+ updated_at = excluded.updated_at
4331
+ WHERE excluded.last_event_sequence >= devflow_task_runtime_snapshots.last_event_sequence
4332
+ `).run(
4333
+ snapshot.identity.projectRoot, snapshot.identity.sessionId, snapshot.identity.turnId,
4334
+ snapshot.lastEventSequence, snapshot.schemaVersion, stableTaskRuntimeJson(snapshot),
4335
+ snapshot.snapshotHash, snapshot.updatedAt,
4336
+ );
4337
+ return this.getTaskRuntimeSnapshot(
4338
+ snapshot.identity.projectRoot, snapshot.identity.sessionId, snapshot.identity.turnId,
4339
+ )!;
4340
+ }
4341
+
4342
+ appendTaskRuntimeEventAndSnapshot(
4343
+ event: TaskRuntimeEventRecord,
4344
+ snapshot: TaskRuntimeSnapshotRecord,
4345
+ ): TaskRuntimeSnapshotRecord {
4346
+ return this.db.transaction(() => {
4347
+ this.appendTaskRuntimeEvent(event);
4348
+ return this.putTaskRuntimeSnapshot(snapshot);
4349
+ });
4350
+ }
4351
+
4352
+ getTaskRuntimeSnapshot(
4353
+ projectRoot: string,
4354
+ sessionId: string,
4355
+ turnId: string,
4356
+ ): TaskRuntimeSnapshotRecord | null {
4357
+ const row = this.db.prepare(`
4358
+ SELECT * FROM devflow_task_runtime_snapshots
4359
+ WHERE project_root = ? AND session_id = ? AND turn_id = ?
4360
+ `).get(projectRoot, sessionId, turnId) as Record<string, unknown> | undefined;
4361
+ return row ? mapTaskRuntimeSnapshotRow(row) : null;
4362
+ }
4363
+
4364
+ deleteTaskRuntimeSnapshot(projectRoot: string, sessionId: string, turnId: string): boolean {
4365
+ return this.db.prepare(`
4366
+ DELETE FROM devflow_task_runtime_snapshots
4367
+ WHERE project_root = ? AND session_id = ? AND turn_id = ?
4368
+ `).run(projectRoot, sessionId, turnId).changes > 0;
4369
+ }
4370
+
4047
4371
  getTerminalTransition(receiptId: string): TerminalTransitionRecord | null {
4048
4372
  const row = this.db.prepare(`
4049
4373
  SELECT * FROM devflow_terminal_transitions WHERE receipt_id = ?
@@ -4115,7 +4439,11 @@ export class DevFlowDatabase {
4115
4439
  id: row.id, projectRoot: row.project_root, sessionId: row.session_id,
4116
4440
  executionId: row.execution_id ?? undefined, turnId: row.turn_id ?? undefined,
4117
4441
  requestId: row.request_id, contextReceipt: row.context_receipt,
4118
- sourceType: row.source_type, sourceId: row.source_id, stage: row.stage,
4442
+ schemaVersion: row.schema_version ?? 'retrieval-ledger-event.v1',
4443
+ sourceType: row.source_type, sourceId: row.source_id, stage: row.stage === 'adopted' ? 'consumed' : row.stage,
4444
+ taskSpecHash: row.task_spec_hash ?? undefined, actor: row.actor ?? undefined,
4445
+ sourceVersion: row.source_version ?? undefined, sourceContentHash: row.source_content_hash ?? undefined,
4446
+ evidenceIds: parseJsonStringArray(row.evidence_ids), reasonCode: row.reason_code ?? undefined,
4119
4447
  rank: row.rank ?? undefined, rawScore: row.raw_score ?? undefined,
4120
4448
  normalizedScore: row.normalized_score ?? undefined, finalScore: row.final_score ?? undefined,
4121
4449
  applicability: parseJsonStringArray(row.applicability), reason: row.reason ?? undefined,
@@ -4330,6 +4658,23 @@ export class DevFlowDatabase {
4330
4658
  );
4331
4659
  if (!existing) throw new Error(`Session obligation ${input.obligationId} does not exist`);
4332
4660
  if (existing.state !== 'open') {
4661
+ if (existing.state === 'degraded' && input.state === 'satisfied' && input.receiptId) {
4662
+ const resolvedAt = input.resolvedAt ?? Date.now();
4663
+ this.db.prepare(`
4664
+ UPDATE devflow_session_obligations
4665
+ SET state = 'satisfied', receipt_id = ?, reason = ?, resolved_at = ?, updated_at = ?
4666
+ WHERE project_root = ? AND session_id = ? AND obligation_id = ? AND state = 'degraded'
4667
+ `).run(
4668
+ input.receiptId,
4669
+ input.reason ?? 'superseded_by_corrected_evidence',
4670
+ resolvedAt,
4671
+ resolvedAt,
4672
+ input.projectRoot,
4673
+ input.sessionId,
4674
+ input.obligationId,
4675
+ );
4676
+ return this.getSessionObligation(input.projectRoot, input.sessionId, input.obligationId)!;
4677
+ }
4333
4678
  const sameResolution = existing.state === input.state
4334
4679
  && (input.receiptId === undefined || existing.receiptId === input.receiptId);
4335
4680
  if (sameResolution) return existing;
@@ -4417,20 +4762,44 @@ export class DevFlowDatabase {
4417
4762
  return turn;
4418
4763
  }
4419
4764
 
4765
+ updateCommittedMemoryTurnProjection(input: {
4766
+ turnId: string;
4767
+ memoryIds: string[];
4768
+ reason?: string;
4769
+ }): MemoryTurnRecord {
4770
+ const turnId = normalizeTurnId(input.turnId);
4771
+ this.db.prepare(`
4772
+ UPDATE devflow_memory_turns
4773
+ SET memory_ids = ?, reason = COALESCE(?, reason)
4774
+ WHERE turn_id = ? AND status = 'committed' AND source = 'explicit_intent'
4775
+ `).run(
4776
+ JSON.stringify([...new Set(input.memoryIds)]),
4777
+ input.reason ?? null,
4778
+ turnId,
4779
+ );
4780
+ const turn = this.getMemoryTurn(turnId);
4781
+ if (!turn || turn.status !== 'committed') {
4782
+ throw new Error(`Committed explicit memory turn ${turnId} does not exist`);
4783
+ }
4784
+ return turn;
4785
+ }
4786
+
4420
4787
  skipMemoryTurn(input: {
4421
4788
  turnId: string;
4422
4789
  receiptId: string;
4423
4790
  reason: string;
4791
+ source?: string;
4424
4792
  decidedAt?: number;
4425
4793
  }): MemoryTurnRecord {
4426
4794
  const turnId = normalizeTurnId(input.turnId);
4427
4795
  this.db.prepare(`
4428
4796
  UPDATE devflow_memory_turns
4429
- SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = 'host_skip',
4797
+ SET status = 'skipped', receipt_id = ?, memory_ids = '[]', source = ?,
4430
4798
  reason = ?, decided_at = ?
4431
4799
  WHERE turn_id = ? AND status = 'pending'
4432
4800
  `).run(
4433
4801
  input.receiptId,
4802
+ input.source ?? 'host_skip',
4434
4803
  input.reason,
4435
4804
  input.decidedAt ?? Date.now(),
4436
4805
  turnId,