@devflow-tools/database 0.16.19 → 0.16.20

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,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.16.20](https://github.com/shilongfeicool/dev-flow/compare/v0.16.19...v0.16.20) (2026-07-28)
7
+
8
+
9
+ ### Features
10
+
11
+ * **retrieval:** close outcome-aware retrieval quality ([e4048a1](https://github.com/shilongfeicool/dev-flow/commit/e4048a1d921a85bd078de031b334f24cb8f4f393))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [0.16.19](https://github.com/shilongfeicool/dev-flow/compare/v0.16.18...v0.16.19) (2026-07-28)
7
18
 
8
19
 
@@ -0,0 +1,79 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { DevFlowDatabase } from '../src/database';
6
+
7
+ describe('DevFlowDatabase retrieval sessions', () => {
8
+ let root: string;
9
+ let database: DevFlowDatabase;
10
+
11
+ beforeEach(() => {
12
+ root = mkdtempSync(join(tmpdir(), 'devflow-retrieval-session-'));
13
+ database = new DevFlowDatabase(root);
14
+ });
15
+
16
+ afterEach(() => {
17
+ database.close();
18
+ rmSync(root, { recursive: true, force: true });
19
+ });
20
+
21
+ const create = () => database.createRetrievalSession({
22
+ id: 'retrieval-1', requestId: 'request-1', projectRoot: '/project-a',
23
+ sessionId: 'session-1', executionId: 'execution-1', query: 'review player',
24
+ intent: 'performance', tokenBudget: 1000, baselineReceipt: 'baseline-1',
25
+ expiresAt: Date.now() + 60_000,
26
+ });
27
+
28
+ it('persists idempotent cycles and rejects changed evidence', () => {
29
+ expect(create()).toMatchObject({ id: 'retrieval-1', cycle: 0, remainingTokenBudget: 1000 });
30
+ expect(create()).toMatchObject({ id: 'retrieval-1' });
31
+ const input = {
32
+ retrievalSessionId: 'retrieval-1', projectRoot: '/project-a', sessionId: 'session-1',
33
+ baselineReceipt: 'baseline-1', cycle: 1,
34
+ gaps: [{ kind: 'caller' as const, reason: 'missing caller', evidence: [], resolver: 'codegraph' as const, priority: 80 }],
35
+ selectedIds: ['code:src/player.tsx'], rejectedIds: [], tokenCost: 200,
36
+ remainingTokenBudget: 800, quality: { status: 'healthy' }, receipt: 'cycle-1', evidenceHash: 'hash-1',
37
+ };
38
+ expect(database.appendRetrievalCycle(input)).toMatchObject({ cycle: 1, tokenCost: 200 });
39
+ expect(database.appendRetrievalCycle(input)).toMatchObject({ evidenceHash: 'hash-1' });
40
+ expect(() => database.appendRetrievalCycle({ ...input, evidenceHash: 'changed' }))
41
+ .toThrow('RETRIEVAL_CYCLE_CONFLICT:1');
42
+ expect(database.getRetrievalSession('/project-a', 'session-1', 'retrieval-1'))
43
+ .toMatchObject({ cycle: 1, remainingTokenBudget: 800 });
44
+ });
45
+
46
+ it('enforces ownership, sequence, terminal state, and expiration', () => {
47
+ create();
48
+ expect(database.getRetrievalSession('/project-b', 'session-1', 'retrieval-1')).toBeNull();
49
+ expect(() => database.appendRetrievalCycle({
50
+ retrievalSessionId: 'retrieval-1', projectRoot: '/project-a', sessionId: 'session-1',
51
+ baselineReceipt: 'baseline-1', cycle: 2, gaps: [], selectedIds: [], rejectedIds: [],
52
+ tokenCost: 1, remainingTokenBudget: 999, quality: {}, receipt: 'cycle-2', evidenceHash: 'hash-2',
53
+ })).toThrow('RETRIEVAL_CYCLE_SEQUENCE:0->2');
54
+ database.finalizeRetrievalSession({
55
+ id: 'retrieval-1', projectRoot: '/project-a', sessionId: 'session-1',
56
+ state: 'exhausted', finalReceipt: 'final-1',
57
+ });
58
+ expect(() => database.finalizeRetrievalSession({
59
+ id: 'retrieval-1', projectRoot: '/project-a', sessionId: 'session-1',
60
+ state: 'satisfied', finalReceipt: 'different',
61
+ })).toThrow('RETRIEVAL_SESSION_TERMINAL:exhausted');
62
+
63
+ database.createRetrievalSession({
64
+ id: 'retrieval-expire', requestId: 'request-expire', projectRoot: '/project-a',
65
+ sessionId: 'session-1', query: 'x', intent: 'debug', tokenBudget: 1,
66
+ baselineReceipt: 'baseline-expire', expiresAt: Date.now() + 10,
67
+ });
68
+ expect(database.expireRetrievalSessions(Date.now() + 20)).toBe(1);
69
+ expect(database.getRetrievalSession('/project-a', 'session-1', 'retrieval-expire')?.state).toBe('expired');
70
+ });
71
+
72
+ it('keeps retrieval rows stable across reopen', () => {
73
+ create();
74
+ database.close();
75
+ database = new DevFlowDatabase(root);
76
+ expect(database.getRetrievalSession('/project-a', 'session-1', 'retrieval-1'))
77
+ .toMatchObject({ requestId: 'request-1', baselineReceipt: 'baseline-1' });
78
+ });
79
+ });
@@ -1,6 +1,7 @@
1
1
  import type { EnqueueWorkInput, LeaseWorkInput, RequestSessionClosureInput, SessionClosureRecord, WorkError, WorkItemRecord, WorkQueueHealth } from './work-queue';
2
2
  import { type SessionObligationRecord, type SessionObligationState } from './obligation-ledger';
3
3
  import { type FailHostActionInput, type HostActionRecord, type ReportHostActionInput, type RequestHostActionInput, type StartHostActionInput, type VerifyHostActionInput } from './host-actions';
4
+ import { type AppendRetrievalCycleInput, type CreateRetrievalSessionInput, type RetrievalCycleRecord, type RetrievalSessionRecord, type RetrievalSessionState } from './retrieval-sessions';
4
5
  export interface BenchmarkReportRecord {
5
6
  runId: string;
6
7
  suiteId: string;
@@ -344,6 +345,21 @@ export declare class DevFlowDatabase {
344
345
  getHookReceipt(projectRoot: string): HookReceiptRecord | null;
345
346
  updateHookReceipt(projectRoot: string, updater: (current: HookReceiptRecord | null) => Omit<HookReceiptRecord, 'projectRoot' | 'updatedAt'>): HookReceiptRecord;
346
347
  deleteHookReceipt(projectRoot: string): boolean;
348
+ createRetrievalSession(input: CreateRetrievalSessionInput): RetrievalSessionRecord;
349
+ getRetrievalSession(projectRoot: string, sessionId: string, id: string): RetrievalSessionRecord | null;
350
+ getRetrievalSessionByRequest(requestId: string): RetrievalSessionRecord | null;
351
+ listRetrievalCycles(retrievalSessionId: string): RetrievalCycleRecord[];
352
+ appendRetrievalCycle(input: AppendRetrievalCycleInput): RetrievalCycleRecord;
353
+ finalizeRetrievalSession(input: {
354
+ id: string;
355
+ projectRoot: string;
356
+ sessionId: string;
357
+ state: Extract<RetrievalSessionState, 'satisfied' | 'exhausted'>;
358
+ finalReceipt: string;
359
+ }): RetrievalSessionRecord;
360
+ expireRetrievalSessions(now?: number, limit?: number): number;
361
+ private validateRetrievalSessionInput;
362
+ private assertRetrievalIdentity;
347
363
  upsertContextReceipt(receipt: ContextReceiptRecord): void;
348
364
  getContextReceipt(projectRoot: string, sessionId: string, executionId: string): ContextReceiptRecord | null;
349
365
  getActiveContextReceipt(projectRoot: string, sessionId: string, executionId?: string, now?: number): ContextReceiptRecord | null;
@@ -408,6 +424,7 @@ export declare class DevFlowDatabase {
408
424
  failHostAction(input: FailHostActionInput): HostActionRecord;
409
425
  getHostAction(projectRoot: string, actionId: string): HostActionRecord | null;
410
426
  listHostActionsForRun(projectRoot: string, runId: string): HostActionRecord[];
427
+ listHostActionsForSession(projectRoot: string, sessionId: string, executionId?: string): HostActionRecord[];
411
428
  private transitionHostAction;
412
429
  private appendHostActionEvent;
413
430
  private validateHostActionIdentity;
package/dist/database.js CHANGED
@@ -10,6 +10,7 @@ const os_1 = require("os");
10
10
  const crypto_1 = require("crypto");
11
11
  const obligation_ledger_1 = require("./obligation-ledger");
12
12
  const host_actions_1 = require("./host-actions");
13
+ const retrieval_sessions_1 = require("./retrieval-sessions");
13
14
  const CONTEXT_REQUIRED_SKILLS = new Set([
14
15
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
15
16
  ]);
@@ -309,6 +310,50 @@ class DevFlowDatabase {
309
310
  CREATE INDEX IF NOT EXISTS idx_context_selection_identity
310
311
  ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
311
312
 
313
+ CREATE TABLE IF NOT EXISTS devflow_retrieval_sessions (
314
+ id TEXT PRIMARY KEY,
315
+ request_id TEXT NOT NULL UNIQUE,
316
+ project_root TEXT NOT NULL,
317
+ session_id TEXT NOT NULL,
318
+ execution_id TEXT,
319
+ query TEXT NOT NULL,
320
+ intent TEXT NOT NULL,
321
+ state TEXT NOT NULL DEFAULT 'open'
322
+ CHECK(state IN ('open', 'satisfied', 'exhausted', 'expired')),
323
+ cycle INTEGER NOT NULL DEFAULT 0 CHECK(cycle BETWEEN 0 AND 3),
324
+ max_cycles INTEGER NOT NULL DEFAULT 3 CHECK(max_cycles = 3),
325
+ initial_token_budget INTEGER NOT NULL CHECK(initial_token_budget >= 0),
326
+ remaining_token_budget INTEGER NOT NULL CHECK(remaining_token_budget >= 0),
327
+ baseline_receipt TEXT NOT NULL,
328
+ final_receipt TEXT,
329
+ created_at INTEGER NOT NULL,
330
+ updated_at INTEGER NOT NULL,
331
+ expires_at INTEGER NOT NULL
332
+ );
333
+
334
+ CREATE INDEX IF NOT EXISTS idx_retrieval_sessions_owner
335
+ ON devflow_retrieval_sessions(project_root, session_id, state, updated_at DESC);
336
+ CREATE INDEX IF NOT EXISTS idx_retrieval_sessions_expiry
337
+ ON devflow_retrieval_sessions(state, expires_at);
338
+
339
+ CREATE TABLE IF NOT EXISTS devflow_retrieval_cycles (
340
+ retrieval_session_id TEXT NOT NULL,
341
+ cycle INTEGER NOT NULL CHECK(cycle BETWEEN 1 AND 3),
342
+ gaps_json TEXT NOT NULL DEFAULT '[]',
343
+ selected_ids TEXT NOT NULL DEFAULT '[]',
344
+ rejected_ids TEXT NOT NULL DEFAULT '[]',
345
+ token_cost INTEGER NOT NULL CHECK(token_cost >= 0),
346
+ remaining_token_budget INTEGER NOT NULL CHECK(remaining_token_budget >= 0),
347
+ quality_json TEXT NOT NULL DEFAULT '{}',
348
+ receipt TEXT NOT NULL,
349
+ evidence_hash TEXT NOT NULL,
350
+ created_at INTEGER NOT NULL,
351
+ PRIMARY KEY (retrieval_session_id, cycle)
352
+ );
353
+
354
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_retrieval_cycles_receipt
355
+ ON devflow_retrieval_cycles(receipt);
356
+
312
357
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
313
358
  id TEXT PRIMARY KEY,
314
359
  project_root TEXT NOT NULL,
@@ -2180,6 +2225,183 @@ class DevFlowDatabase {
2180
2225
  return this.db.prepare('DELETE FROM devflow_hook_receipts WHERE project_root = ?')
2181
2226
  .run(projectRoot).changes > 0;
2182
2227
  }
2228
+ createRetrievalSession(input) {
2229
+ this.validateRetrievalSessionInput(input);
2230
+ const now = Date.now();
2231
+ const id = input.id?.trim() || (0, crypto_1.randomUUID)();
2232
+ this.db.exec('BEGIN IMMEDIATE');
2233
+ try {
2234
+ const existing = this.getRetrievalSessionByRequest(input.requestId);
2235
+ if (existing) {
2236
+ this.assertRetrievalIdentity(existing, input);
2237
+ this.db.exec('COMMIT');
2238
+ return existing;
2239
+ }
2240
+ this.db.prepare(`
2241
+ INSERT INTO devflow_retrieval_sessions
2242
+ (id, request_id, project_root, session_id, execution_id, query, intent, state,
2243
+ cycle, max_cycles, initial_token_budget, remaining_token_budget,
2244
+ baseline_receipt, created_at, updated_at, expires_at)
2245
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', 0, ?, ?, ?, ?, ?, ?, ?)
2246
+ `).run(id, input.requestId, input.projectRoot, input.sessionId, input.executionId ?? null, input.query, input.intent, retrieval_sessions_1.RETRIEVAL_MAX_CYCLES, input.tokenBudget, input.tokenBudget, input.baselineReceipt, now, now, input.expiresAt);
2247
+ const created = this.getRetrievalSession(input.projectRoot, input.sessionId, id);
2248
+ this.db.exec('COMMIT');
2249
+ return created;
2250
+ }
2251
+ catch (error) {
2252
+ try {
2253
+ this.db.exec('ROLLBACK');
2254
+ }
2255
+ catch { }
2256
+ throw error;
2257
+ }
2258
+ }
2259
+ getRetrievalSession(projectRoot, sessionId, id) {
2260
+ const row = this.db.prepare(`
2261
+ SELECT * FROM devflow_retrieval_sessions
2262
+ WHERE id = ? AND project_root = ? AND session_id = ?
2263
+ `).get(id, projectRoot, sessionId);
2264
+ return row ? (0, retrieval_sessions_1.mapRetrievalSessionRow)(row) : null;
2265
+ }
2266
+ getRetrievalSessionByRequest(requestId) {
2267
+ const row = this.db.prepare(`
2268
+ SELECT * FROM devflow_retrieval_sessions WHERE request_id = ?
2269
+ `).get(requestId);
2270
+ return row ? (0, retrieval_sessions_1.mapRetrievalSessionRow)(row) : null;
2271
+ }
2272
+ listRetrievalCycles(retrievalSessionId) {
2273
+ return this.db.prepare(`
2274
+ SELECT * FROM devflow_retrieval_cycles
2275
+ WHERE retrieval_session_id = ? ORDER BY cycle ASC
2276
+ `).all(retrievalSessionId).map(retrieval_sessions_1.mapRetrievalCycleRow);
2277
+ }
2278
+ appendRetrievalCycle(input) {
2279
+ if (!Number.isInteger(input.cycle) || input.cycle < 1 || input.cycle > retrieval_sessions_1.RETRIEVAL_MAX_CYCLES) {
2280
+ throw new Error(`RETRIEVAL_INVALID_CYCLE:${input.cycle}`);
2281
+ }
2282
+ if (!Number.isSafeInteger(input.tokenCost) || input.tokenCost < 0
2283
+ || !Number.isSafeInteger(input.remainingTokenBudget) || input.remainingTokenBudget < 0) {
2284
+ throw new Error('RETRIEVAL_INVALID_BUDGET');
2285
+ }
2286
+ const gapsJson = (0, retrieval_sessions_1.serializeRetrievalJson)(input.gaps);
2287
+ const selectedJson = (0, retrieval_sessions_1.serializeRetrievalJson)([...new Set(input.selectedIds)]);
2288
+ const rejectedJson = (0, retrieval_sessions_1.serializeRetrievalJson)([...new Set(input.rejectedIds)]);
2289
+ const qualityJson = (0, retrieval_sessions_1.serializeRetrievalJson)(input.quality);
2290
+ const now = Date.now();
2291
+ this.db.exec('BEGIN IMMEDIATE');
2292
+ try {
2293
+ const session = this.getRetrievalSession(input.projectRoot, input.sessionId, input.retrievalSessionId);
2294
+ if (!session)
2295
+ throw new Error(`RETRIEVAL_SESSION_NOT_FOUND:${input.retrievalSessionId}`);
2296
+ if (session.baselineReceipt !== input.baselineReceipt)
2297
+ throw new Error('RETRIEVAL_BASELINE_RECEIPT_MISMATCH');
2298
+ const existing = this.db.prepare(`
2299
+ SELECT * FROM devflow_retrieval_cycles WHERE retrieval_session_id = ? AND cycle = ?
2300
+ `).get(input.retrievalSessionId, input.cycle);
2301
+ if (existing) {
2302
+ const cycle = (0, retrieval_sessions_1.mapRetrievalCycleRow)(existing);
2303
+ if (cycle.evidenceHash !== input.evidenceHash || cycle.receipt !== input.receipt
2304
+ || (0, retrieval_sessions_1.serializeRetrievalJson)(cycle.gaps) !== gapsJson
2305
+ || (0, retrieval_sessions_1.serializeRetrievalJson)(cycle.selectedIds) !== selectedJson
2306
+ || (0, retrieval_sessions_1.serializeRetrievalJson)(cycle.rejectedIds) !== rejectedJson
2307
+ || (0, retrieval_sessions_1.serializeRetrievalJson)(cycle.quality) !== qualityJson) {
2308
+ throw new Error(`RETRIEVAL_CYCLE_CONFLICT:${input.cycle}`);
2309
+ }
2310
+ this.db.exec('COMMIT');
2311
+ return cycle;
2312
+ }
2313
+ if (session.state !== 'open')
2314
+ throw new Error(`RETRIEVAL_SESSION_TERMINAL:${session.state}`);
2315
+ if (session.expiresAt <= now)
2316
+ throw new Error('RETRIEVAL_SESSION_EXPIRED');
2317
+ if (input.cycle !== session.cycle + 1)
2318
+ throw new Error(`RETRIEVAL_CYCLE_SEQUENCE:${session.cycle}->${input.cycle}`);
2319
+ if (input.tokenCost > session.remainingTokenBudget
2320
+ || input.remainingTokenBudget !== session.remainingTokenBudget - input.tokenCost) {
2321
+ throw new Error('RETRIEVAL_BUDGET_MISMATCH');
2322
+ }
2323
+ this.db.prepare(`
2324
+ INSERT INTO devflow_retrieval_cycles
2325
+ (retrieval_session_id, cycle, gaps_json, selected_ids, rejected_ids, token_cost,
2326
+ remaining_token_budget, quality_json, receipt, evidence_hash, created_at)
2327
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2328
+ `).run(input.retrievalSessionId, input.cycle, gapsJson, selectedJson, rejectedJson, input.tokenCost, input.remainingTokenBudget, qualityJson, input.receipt, input.evidenceHash, now);
2329
+ this.db.prepare(`
2330
+ UPDATE devflow_retrieval_sessions
2331
+ SET cycle = ?, remaining_token_budget = ?, updated_at = ?
2332
+ WHERE id = ? AND project_root = ? AND session_id = ?
2333
+ `).run(input.cycle, input.remainingTokenBudget, now, input.retrievalSessionId, input.projectRoot, input.sessionId);
2334
+ const created = (0, retrieval_sessions_1.mapRetrievalCycleRow)(this.db.prepare(`
2335
+ SELECT * FROM devflow_retrieval_cycles WHERE retrieval_session_id = ? AND cycle = ?
2336
+ `).get(input.retrievalSessionId, input.cycle));
2337
+ this.db.exec('COMMIT');
2338
+ return created;
2339
+ }
2340
+ catch (error) {
2341
+ try {
2342
+ this.db.exec('ROLLBACK');
2343
+ }
2344
+ catch { }
2345
+ throw error;
2346
+ }
2347
+ }
2348
+ finalizeRetrievalSession(input) {
2349
+ const now = Date.now();
2350
+ const current = this.getRetrievalSession(input.projectRoot, input.sessionId, input.id);
2351
+ if (!current)
2352
+ throw new Error(`RETRIEVAL_SESSION_NOT_FOUND:${input.id}`);
2353
+ if (current.state !== 'open') {
2354
+ if (current.state === input.state && current.finalReceipt === input.finalReceipt)
2355
+ return current;
2356
+ throw new Error(`RETRIEVAL_SESSION_TERMINAL:${current.state}`);
2357
+ }
2358
+ this.db.prepare(`
2359
+ UPDATE devflow_retrieval_sessions SET state = ?, final_receipt = ?, updated_at = ?
2360
+ WHERE id = ? AND project_root = ? AND session_id = ? AND state = 'open'
2361
+ `).run(input.state, input.finalReceipt, now, input.id, input.projectRoot, input.sessionId);
2362
+ return this.getRetrievalSession(input.projectRoot, input.sessionId, input.id);
2363
+ }
2364
+ expireRetrievalSessions(now = Date.now(), limit = 100) {
2365
+ const rows = this.db.prepare(`
2366
+ SELECT id FROM devflow_retrieval_sessions
2367
+ WHERE state = 'open' AND expires_at <= ? ORDER BY expires_at ASC LIMIT ?
2368
+ `).all(now, Math.max(1, Math.min(1000, Math.floor(limit))));
2369
+ if (rows.length === 0)
2370
+ return 0;
2371
+ const placeholders = rows.map(() => '?').join(',');
2372
+ return this.db.prepare(`
2373
+ UPDATE devflow_retrieval_sessions SET state = 'expired', updated_at = ?
2374
+ WHERE state = 'open' AND id IN (${placeholders})
2375
+ `).run(now, ...rows.map(row => row.id)).changes;
2376
+ }
2377
+ validateRetrievalSessionInput(input) {
2378
+ for (const [field, value] of Object.entries({
2379
+ requestId: input.requestId,
2380
+ projectRoot: input.projectRoot,
2381
+ sessionId: input.sessionId,
2382
+ query: input.query,
2383
+ intent: input.intent,
2384
+ baselineReceipt: input.baselineReceipt,
2385
+ })) {
2386
+ if (typeof value !== 'string' || value.trim().length === 0)
2387
+ throw new Error(`RETRIEVAL_INVALID_${field}`);
2388
+ }
2389
+ if (!Number.isSafeInteger(input.tokenBudget) || input.tokenBudget < 0)
2390
+ throw new Error('RETRIEVAL_INVALID_BUDGET');
2391
+ if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= Date.now())
2392
+ throw new Error('RETRIEVAL_INVALID_EXPIRY');
2393
+ }
2394
+ assertRetrievalIdentity(existing, input) {
2395
+ const matches = existing.projectRoot === input.projectRoot
2396
+ && existing.sessionId === input.sessionId
2397
+ && existing.executionId === input.executionId
2398
+ && existing.query === input.query
2399
+ && existing.intent === input.intent
2400
+ && existing.initialTokenBudget === input.tokenBudget
2401
+ && existing.baselineReceipt === input.baselineReceipt;
2402
+ if (!matches)
2403
+ throw new Error(`RETRIEVAL_REQUEST_CONFLICT:${input.requestId}`);
2404
+ }
2183
2405
  upsertContextReceipt(receipt) {
2184
2406
  this.db.prepare(`
2185
2407
  INSERT INTO devflow_context_receipts
@@ -2665,6 +2887,15 @@ class DevFlowDatabase {
2665
2887
  `).all(projectRoot, runId);
2666
2888
  return rows.map(host_actions_1.mapHostActionRow);
2667
2889
  }
2890
+ listHostActionsForSession(projectRoot, sessionId, executionId) {
2891
+ const rows = this.db.prepare(`
2892
+ SELECT * FROM devflow_host_actions
2893
+ WHERE project_root = ? AND session_id = ?
2894
+ AND (? IS NULL OR execution_id = ?)
2895
+ ORDER BY created_at ASC, action_id ASC
2896
+ `).all(projectRoot, sessionId, executionId ?? null, executionId ?? null);
2897
+ return rows.map(host_actions_1.mapHostActionRow);
2898
+ }
2668
2899
  transitionHostAction(identity, targetState, report, evidenceHash, requireNonEmptyReport = false) {
2669
2900
  if (!identity.actionId.trim())
2670
2901
  throw new Error('Host action requires an action ID');
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { DevFlowDatabase, getGlobalDevFlowDbPath, openGlobalDevFlowDatabase, } f
2
2
  export type { BenchmarkReportMetaRecord, BenchmarkReportRecord, FeedbackRecord, GovernanceAuditRecord, GovernanceRuleRecord, HookFallbackRecord, HookReceiptRecord, ContextReceiptRecord, ContextSelectionEventRecord, 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 { hostActionReportsEqual, isHostActionState, mapHostActionRow, serializeHostActionReport, } from './host-actions';
5
+ export { RETRIEVAL_MAX_CYCLES, isRetrievalSessionState } from './retrieval-sessions';
6
+ export type { AppendRetrievalCycleInput, CreateRetrievalSessionInput, RetrievalCycleRecord, RetrievalGapKind, RetrievalGapRecord, RetrievalSessionRecord, RetrievalSessionState, } from './retrieval-sessions';
5
7
  export type { FailHostActionInput, HostActionRecord, HostActionState, ReportHostActionInput, RequestHostActionInput, StartHostActionInput, VerifyHostActionInput, } from './host-actions';
6
8
  export { mapSessionObligationRow, normalizeTurnId, } from './obligation-ledger';
7
9
  export type { SessionObligationKind, SessionObligationRecord, SessionObligationState, } from './obligation-ledger';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeTurnId = exports.mapSessionObligationRow = exports.serializeHostActionReport = exports.mapHostActionRow = exports.isHostActionState = exports.hostActionReportsEqual = exports.openGlobalDevFlowDatabase = exports.getGlobalDevFlowDbPath = exports.DevFlowDatabase = void 0;
3
+ 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;
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; } });
@@ -10,6 +10,9 @@ Object.defineProperty(exports, "hostActionReportsEqual", { enumerable: true, get
10
10
  Object.defineProperty(exports, "isHostActionState", { enumerable: true, get: function () { return host_actions_1.isHostActionState; } });
11
11
  Object.defineProperty(exports, "mapHostActionRow", { enumerable: true, get: function () { return host_actions_1.mapHostActionRow; } });
12
12
  Object.defineProperty(exports, "serializeHostActionReport", { enumerable: true, get: function () { return host_actions_1.serializeHostActionReport; } });
13
+ var retrieval_sessions_1 = require("./retrieval-sessions");
14
+ Object.defineProperty(exports, "RETRIEVAL_MAX_CYCLES", { enumerable: true, get: function () { return retrieval_sessions_1.RETRIEVAL_MAX_CYCLES; } });
15
+ Object.defineProperty(exports, "isRetrievalSessionState", { enumerable: true, get: function () { return retrieval_sessions_1.isRetrievalSessionState; } });
13
16
  var obligation_ledger_1 = require("./obligation-ledger");
14
17
  Object.defineProperty(exports, "mapSessionObligationRow", { enumerable: true, get: function () { return obligation_ledger_1.mapSessionObligationRow; } });
15
18
  Object.defineProperty(exports, "normalizeTurnId", { enumerable: true, get: function () { return obligation_ledger_1.normalizeTurnId; } });
@@ -0,0 +1,74 @@
1
+ export declare const RETRIEVAL_MAX_CYCLES: 3;
2
+ export type RetrievalSessionState = 'open' | 'satisfied' | 'exhausted' | 'expired';
3
+ export type RetrievalGapKind = 'target' | 'caller' | 'route' | 'resource' | 'test' | 'configuration' | 'symbol_ambiguity' | 'memory_applicability' | 'knowledge_version' | 'runtime_evidence' | 'unavailable_channel';
4
+ export interface RetrievalGapRecord {
5
+ kind: RetrievalGapKind;
6
+ target?: string;
7
+ reason: string;
8
+ evidence: string[];
9
+ resolver: 'codegraph' | 'memory' | 'knowledge' | 'analyzer' | 'none';
10
+ priority: number;
11
+ }
12
+ export interface RetrievalCycleRecord {
13
+ retrievalSessionId: string;
14
+ cycle: number;
15
+ gaps: RetrievalGapRecord[];
16
+ selectedIds: string[];
17
+ rejectedIds: string[];
18
+ tokenCost: number;
19
+ remainingTokenBudget: number;
20
+ quality: Record<string, unknown>;
21
+ receipt: string;
22
+ evidenceHash: string;
23
+ createdAt: number;
24
+ }
25
+ export interface RetrievalSessionRecord {
26
+ id: string;
27
+ requestId: string;
28
+ projectRoot: string;
29
+ sessionId: string;
30
+ executionId?: string;
31
+ query: string;
32
+ intent: string;
33
+ state: RetrievalSessionState;
34
+ cycle: number;
35
+ maxCycles: typeof RETRIEVAL_MAX_CYCLES;
36
+ initialTokenBudget: number;
37
+ remainingTokenBudget: number;
38
+ baselineReceipt: string;
39
+ finalReceipt?: string;
40
+ createdAt: number;
41
+ updatedAt: number;
42
+ expiresAt: number;
43
+ }
44
+ export interface CreateRetrievalSessionInput {
45
+ id?: string;
46
+ requestId: string;
47
+ projectRoot: string;
48
+ sessionId: string;
49
+ executionId?: string;
50
+ query: string;
51
+ intent: string;
52
+ tokenBudget: number;
53
+ baselineReceipt: string;
54
+ expiresAt: number;
55
+ }
56
+ export interface AppendRetrievalCycleInput {
57
+ retrievalSessionId: string;
58
+ projectRoot: string;
59
+ sessionId: string;
60
+ baselineReceipt: string;
61
+ cycle: number;
62
+ gaps: RetrievalGapRecord[];
63
+ selectedIds: string[];
64
+ rejectedIds: string[];
65
+ tokenCost: number;
66
+ remainingTokenBudget: number;
67
+ quality: Record<string, unknown>;
68
+ receipt: string;
69
+ evidenceHash: string;
70
+ }
71
+ export declare function mapRetrievalSessionRow(row: Record<string, unknown>): RetrievalSessionRecord;
72
+ export declare function mapRetrievalCycleRow(row: Record<string, unknown>): RetrievalCycleRecord;
73
+ export declare function serializeRetrievalJson(value: unknown): string;
74
+ export declare function isRetrievalSessionState(value: unknown): value is RetrievalSessionState;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RETRIEVAL_MAX_CYCLES = void 0;
4
+ exports.mapRetrievalSessionRow = mapRetrievalSessionRow;
5
+ exports.mapRetrievalCycleRow = mapRetrievalCycleRow;
6
+ exports.serializeRetrievalJson = serializeRetrievalJson;
7
+ exports.isRetrievalSessionState = isRetrievalSessionState;
8
+ exports.RETRIEVAL_MAX_CYCLES = 3;
9
+ function mapRetrievalSessionRow(row) {
10
+ const state = requiredString(row.state, 'state');
11
+ if (!isRetrievalSessionState(state)) {
12
+ throw new Error(`Invalid persisted retrieval session state: ${state}`);
13
+ }
14
+ const maxCycles = finiteInteger(row.max_cycles, 'max_cycles');
15
+ if (maxCycles !== exports.RETRIEVAL_MAX_CYCLES) {
16
+ throw new Error(`Invalid persisted retrieval max_cycles: ${maxCycles}`);
17
+ }
18
+ return {
19
+ id: requiredString(row.id, 'id'),
20
+ requestId: requiredString(row.request_id, 'request_id'),
21
+ projectRoot: requiredString(row.project_root, 'project_root'),
22
+ sessionId: requiredString(row.session_id, 'session_id'),
23
+ executionId: optionalString(row.execution_id),
24
+ query: requiredString(row.query, 'query'),
25
+ intent: requiredString(row.intent, 'intent'),
26
+ state,
27
+ cycle: finiteInteger(row.cycle, 'cycle'),
28
+ maxCycles: exports.RETRIEVAL_MAX_CYCLES,
29
+ initialTokenBudget: finiteInteger(row.initial_token_budget, 'initial_token_budget'),
30
+ remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
31
+ baselineReceipt: requiredString(row.baseline_receipt, 'baseline_receipt'),
32
+ finalReceipt: optionalString(row.final_receipt),
33
+ createdAt: finiteInteger(row.created_at, 'created_at'),
34
+ updatedAt: finiteInteger(row.updated_at, 'updated_at'),
35
+ expiresAt: finiteInteger(row.expires_at, 'expires_at'),
36
+ };
37
+ }
38
+ function mapRetrievalCycleRow(row) {
39
+ return {
40
+ retrievalSessionId: requiredString(row.retrieval_session_id, 'retrieval_session_id'),
41
+ cycle: finiteInteger(row.cycle, 'cycle'),
42
+ gaps: parseJsonArray(row.gaps_json, 'gaps_json'),
43
+ selectedIds: parseStringArray(row.selected_ids, 'selected_ids'),
44
+ rejectedIds: parseStringArray(row.rejected_ids, 'rejected_ids'),
45
+ tokenCost: finiteInteger(row.token_cost, 'token_cost'),
46
+ remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
47
+ quality: parseJsonObject(row.quality_json, 'quality_json'),
48
+ receipt: requiredString(row.receipt, 'receipt'),
49
+ evidenceHash: requiredString(row.evidence_hash, 'evidence_hash'),
50
+ createdAt: finiteInteger(row.created_at, 'created_at'),
51
+ };
52
+ }
53
+ function serializeRetrievalJson(value) {
54
+ return JSON.stringify(canonicalize(value));
55
+ }
56
+ function isRetrievalSessionState(value) {
57
+ return value === 'open' || value === 'satisfied' || value === 'exhausted' || value === 'expired';
58
+ }
59
+ function canonicalize(value) {
60
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
61
+ return value;
62
+ if (typeof value === 'number') {
63
+ if (!Number.isFinite(value) || Object.is(value, -0))
64
+ throw new Error('Retrieval evidence must be lossless JSON');
65
+ return value;
66
+ }
67
+ if (Array.isArray(value))
68
+ return value.map(canonicalize);
69
+ if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) {
70
+ throw new Error('Retrieval evidence must be plain JSON');
71
+ }
72
+ const record = value;
73
+ return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonicalize(record[key])]));
74
+ }
75
+ function parseJson(value, field) {
76
+ if (typeof value !== 'string')
77
+ throw new Error(`Invalid persisted ${field}: expected JSON text`);
78
+ try {
79
+ return JSON.parse(value);
80
+ }
81
+ catch {
82
+ throw new Error(`Invalid persisted ${field}: malformed JSON`);
83
+ }
84
+ }
85
+ function parseJsonArray(value, field) {
86
+ const parsed = parseJson(value, field);
87
+ if (!Array.isArray(parsed))
88
+ throw new Error(`Invalid persisted ${field}: expected array`);
89
+ return parsed;
90
+ }
91
+ function parseStringArray(value, field) {
92
+ const parsed = parseJsonArray(value, field);
93
+ if (parsed.some(item => typeof item !== 'string'))
94
+ throw new Error(`Invalid persisted ${field}: expected strings`);
95
+ return parsed;
96
+ }
97
+ function parseJsonObject(value, field) {
98
+ const parsed = parseJson(value, field);
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ throw new Error(`Invalid persisted ${field}: expected object`);
101
+ }
102
+ return parsed;
103
+ }
104
+ function requiredString(value, field) {
105
+ if (typeof value !== 'string' || value.length === 0)
106
+ throw new Error(`Invalid persisted ${field}`);
107
+ return value;
108
+ }
109
+ function optionalString(value) {
110
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
111
+ }
112
+ function finiteInteger(value, field) {
113
+ const result = Number(value);
114
+ if (!Number.isSafeInteger(result) || result < 0)
115
+ throw new Error(`Invalid persisted ${field}`);
116
+ return result;
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-tools/database",
3
- "version": "0.16.19",
3
+ "version": "0.16.20",
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
@@ -31,6 +31,17 @@ import {
31
31
  type StartHostActionInput,
32
32
  type VerifyHostActionInput,
33
33
  } from './host-actions';
34
+ import {
35
+ RETRIEVAL_MAX_CYCLES,
36
+ mapRetrievalCycleRow,
37
+ mapRetrievalSessionRow,
38
+ serializeRetrievalJson,
39
+ type AppendRetrievalCycleInput,
40
+ type CreateRetrievalSessionInput,
41
+ type RetrievalCycleRecord,
42
+ type RetrievalSessionRecord,
43
+ type RetrievalSessionState,
44
+ } from './retrieval-sessions';
34
45
 
35
46
  export interface BenchmarkReportRecord {
36
47
  runId: string;
@@ -477,6 +488,50 @@ export class DevFlowDatabase {
477
488
  CREATE INDEX IF NOT EXISTS idx_context_selection_identity
478
489
  ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
479
490
 
491
+ CREATE TABLE IF NOT EXISTS devflow_retrieval_sessions (
492
+ id TEXT PRIMARY KEY,
493
+ request_id TEXT NOT NULL UNIQUE,
494
+ project_root TEXT NOT NULL,
495
+ session_id TEXT NOT NULL,
496
+ execution_id TEXT,
497
+ query TEXT NOT NULL,
498
+ intent TEXT NOT NULL,
499
+ state TEXT NOT NULL DEFAULT 'open'
500
+ CHECK(state IN ('open', 'satisfied', 'exhausted', 'expired')),
501
+ cycle INTEGER NOT NULL DEFAULT 0 CHECK(cycle BETWEEN 0 AND 3),
502
+ max_cycles INTEGER NOT NULL DEFAULT 3 CHECK(max_cycles = 3),
503
+ initial_token_budget INTEGER NOT NULL CHECK(initial_token_budget >= 0),
504
+ remaining_token_budget INTEGER NOT NULL CHECK(remaining_token_budget >= 0),
505
+ baseline_receipt TEXT NOT NULL,
506
+ final_receipt TEXT,
507
+ created_at INTEGER NOT NULL,
508
+ updated_at INTEGER NOT NULL,
509
+ expires_at INTEGER NOT NULL
510
+ );
511
+
512
+ CREATE INDEX IF NOT EXISTS idx_retrieval_sessions_owner
513
+ ON devflow_retrieval_sessions(project_root, session_id, state, updated_at DESC);
514
+ CREATE INDEX IF NOT EXISTS idx_retrieval_sessions_expiry
515
+ ON devflow_retrieval_sessions(state, expires_at);
516
+
517
+ CREATE TABLE IF NOT EXISTS devflow_retrieval_cycles (
518
+ retrieval_session_id TEXT NOT NULL,
519
+ cycle INTEGER NOT NULL CHECK(cycle BETWEEN 1 AND 3),
520
+ gaps_json TEXT NOT NULL DEFAULT '[]',
521
+ selected_ids TEXT NOT NULL DEFAULT '[]',
522
+ rejected_ids TEXT NOT NULL DEFAULT '[]',
523
+ token_cost INTEGER NOT NULL CHECK(token_cost >= 0),
524
+ remaining_token_budget INTEGER NOT NULL CHECK(remaining_token_budget >= 0),
525
+ quality_json TEXT NOT NULL DEFAULT '{}',
526
+ receipt TEXT NOT NULL,
527
+ evidence_hash TEXT NOT NULL,
528
+ created_at INTEGER NOT NULL,
529
+ PRIMARY KEY (retrieval_session_id, cycle)
530
+ );
531
+
532
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_retrieval_cycles_receipt
533
+ ON devflow_retrieval_cycles(receipt);
534
+
480
535
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
481
536
  id TEXT PRIMARY KEY,
482
537
  project_root TEXT NOT NULL,
@@ -2677,6 +2732,207 @@ export class DevFlowDatabase {
2677
2732
  .run(projectRoot).changes > 0;
2678
2733
  }
2679
2734
 
2735
+ createRetrievalSession(input: CreateRetrievalSessionInput): RetrievalSessionRecord {
2736
+ this.validateRetrievalSessionInput(input);
2737
+ const now = Date.now();
2738
+ const id = input.id?.trim() || randomUUID();
2739
+ this.db.exec('BEGIN IMMEDIATE');
2740
+ try {
2741
+ const existing = this.getRetrievalSessionByRequest(input.requestId);
2742
+ if (existing) {
2743
+ this.assertRetrievalIdentity(existing, input);
2744
+ this.db.exec('COMMIT');
2745
+ return existing;
2746
+ }
2747
+ this.db.prepare(`
2748
+ INSERT INTO devflow_retrieval_sessions
2749
+ (id, request_id, project_root, session_id, execution_id, query, intent, state,
2750
+ cycle, max_cycles, initial_token_budget, remaining_token_budget,
2751
+ baseline_receipt, created_at, updated_at, expires_at)
2752
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', 0, ?, ?, ?, ?, ?, ?, ?)
2753
+ `).run(
2754
+ id,
2755
+ input.requestId,
2756
+ input.projectRoot,
2757
+ input.sessionId,
2758
+ input.executionId ?? null,
2759
+ input.query,
2760
+ input.intent,
2761
+ RETRIEVAL_MAX_CYCLES,
2762
+ input.tokenBudget,
2763
+ input.tokenBudget,
2764
+ input.baselineReceipt,
2765
+ now,
2766
+ now,
2767
+ input.expiresAt,
2768
+ );
2769
+ const created = this.getRetrievalSession(input.projectRoot, input.sessionId, id)!;
2770
+ this.db.exec('COMMIT');
2771
+ return created;
2772
+ } catch (error) {
2773
+ try { this.db.exec('ROLLBACK'); } catch {}
2774
+ throw error;
2775
+ }
2776
+ }
2777
+
2778
+ getRetrievalSession(projectRoot: string, sessionId: string, id: string): RetrievalSessionRecord | null {
2779
+ const row = this.db.prepare(`
2780
+ SELECT * FROM devflow_retrieval_sessions
2781
+ WHERE id = ? AND project_root = ? AND session_id = ?
2782
+ `).get(id, projectRoot, sessionId) as Record<string, unknown> | undefined;
2783
+ return row ? mapRetrievalSessionRow(row) : null;
2784
+ }
2785
+
2786
+ getRetrievalSessionByRequest(requestId: string): RetrievalSessionRecord | null {
2787
+ const row = this.db.prepare(`
2788
+ SELECT * FROM devflow_retrieval_sessions WHERE request_id = ?
2789
+ `).get(requestId) as Record<string, unknown> | undefined;
2790
+ return row ? mapRetrievalSessionRow(row) : null;
2791
+ }
2792
+
2793
+ listRetrievalCycles(retrievalSessionId: string): RetrievalCycleRecord[] {
2794
+ return (this.db.prepare(`
2795
+ SELECT * FROM devflow_retrieval_cycles
2796
+ WHERE retrieval_session_id = ? ORDER BY cycle ASC
2797
+ `).all(retrievalSessionId) as Array<Record<string, unknown>>).map(mapRetrievalCycleRow);
2798
+ }
2799
+
2800
+ appendRetrievalCycle(input: AppendRetrievalCycleInput): RetrievalCycleRecord {
2801
+ if (!Number.isInteger(input.cycle) || input.cycle < 1 || input.cycle > RETRIEVAL_MAX_CYCLES) {
2802
+ throw new Error(`RETRIEVAL_INVALID_CYCLE:${input.cycle}`);
2803
+ }
2804
+ if (!Number.isSafeInteger(input.tokenCost) || input.tokenCost < 0
2805
+ || !Number.isSafeInteger(input.remainingTokenBudget) || input.remainingTokenBudget < 0) {
2806
+ throw new Error('RETRIEVAL_INVALID_BUDGET');
2807
+ }
2808
+ const gapsJson = serializeRetrievalJson(input.gaps);
2809
+ const selectedJson = serializeRetrievalJson([...new Set(input.selectedIds)]);
2810
+ const rejectedJson = serializeRetrievalJson([...new Set(input.rejectedIds)]);
2811
+ const qualityJson = serializeRetrievalJson(input.quality);
2812
+ const now = Date.now();
2813
+ this.db.exec('BEGIN IMMEDIATE');
2814
+ try {
2815
+ const session = this.getRetrievalSession(input.projectRoot, input.sessionId, input.retrievalSessionId);
2816
+ if (!session) throw new Error(`RETRIEVAL_SESSION_NOT_FOUND:${input.retrievalSessionId}`);
2817
+ if (session.baselineReceipt !== input.baselineReceipt) throw new Error('RETRIEVAL_BASELINE_RECEIPT_MISMATCH');
2818
+
2819
+ const existing = this.db.prepare(`
2820
+ SELECT * FROM devflow_retrieval_cycles WHERE retrieval_session_id = ? AND cycle = ?
2821
+ `).get(input.retrievalSessionId, input.cycle) as Record<string, unknown> | undefined;
2822
+ if (existing) {
2823
+ const cycle = mapRetrievalCycleRow(existing);
2824
+ if (cycle.evidenceHash !== input.evidenceHash || cycle.receipt !== input.receipt
2825
+ || serializeRetrievalJson(cycle.gaps) !== gapsJson
2826
+ || serializeRetrievalJson(cycle.selectedIds) !== selectedJson
2827
+ || serializeRetrievalJson(cycle.rejectedIds) !== rejectedJson
2828
+ || serializeRetrievalJson(cycle.quality) !== qualityJson) {
2829
+ throw new Error(`RETRIEVAL_CYCLE_CONFLICT:${input.cycle}`);
2830
+ }
2831
+ this.db.exec('COMMIT');
2832
+ return cycle;
2833
+ }
2834
+ if (session.state !== 'open') throw new Error(`RETRIEVAL_SESSION_TERMINAL:${session.state}`);
2835
+ if (session.expiresAt <= now) throw new Error('RETRIEVAL_SESSION_EXPIRED');
2836
+ if (input.cycle !== session.cycle + 1) throw new Error(`RETRIEVAL_CYCLE_SEQUENCE:${session.cycle}->${input.cycle}`);
2837
+ if (input.tokenCost > session.remainingTokenBudget
2838
+ || input.remainingTokenBudget !== session.remainingTokenBudget - input.tokenCost) {
2839
+ throw new Error('RETRIEVAL_BUDGET_MISMATCH');
2840
+ }
2841
+
2842
+ this.db.prepare(`
2843
+ INSERT INTO devflow_retrieval_cycles
2844
+ (retrieval_session_id, cycle, gaps_json, selected_ids, rejected_ids, token_cost,
2845
+ remaining_token_budget, quality_json, receipt, evidence_hash, created_at)
2846
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2847
+ `).run(
2848
+ input.retrievalSessionId,
2849
+ input.cycle,
2850
+ gapsJson,
2851
+ selectedJson,
2852
+ rejectedJson,
2853
+ input.tokenCost,
2854
+ input.remainingTokenBudget,
2855
+ qualityJson,
2856
+ input.receipt,
2857
+ input.evidenceHash,
2858
+ now,
2859
+ );
2860
+ this.db.prepare(`
2861
+ UPDATE devflow_retrieval_sessions
2862
+ SET cycle = ?, remaining_token_budget = ?, updated_at = ?
2863
+ WHERE id = ? AND project_root = ? AND session_id = ?
2864
+ `).run(input.cycle, input.remainingTokenBudget, now, input.retrievalSessionId, input.projectRoot, input.sessionId);
2865
+ const created = mapRetrievalCycleRow(this.db.prepare(`
2866
+ SELECT * FROM devflow_retrieval_cycles WHERE retrieval_session_id = ? AND cycle = ?
2867
+ `).get(input.retrievalSessionId, input.cycle) as Record<string, unknown>);
2868
+ this.db.exec('COMMIT');
2869
+ return created;
2870
+ } catch (error) {
2871
+ try { this.db.exec('ROLLBACK'); } catch {}
2872
+ throw error;
2873
+ }
2874
+ }
2875
+
2876
+ finalizeRetrievalSession(input: {
2877
+ id: string;
2878
+ projectRoot: string;
2879
+ sessionId: string;
2880
+ state: Extract<RetrievalSessionState, 'satisfied' | 'exhausted'>;
2881
+ finalReceipt: string;
2882
+ }): RetrievalSessionRecord {
2883
+ const now = Date.now();
2884
+ const current = this.getRetrievalSession(input.projectRoot, input.sessionId, input.id);
2885
+ if (!current) throw new Error(`RETRIEVAL_SESSION_NOT_FOUND:${input.id}`);
2886
+ if (current.state !== 'open') {
2887
+ if (current.state === input.state && current.finalReceipt === input.finalReceipt) return current;
2888
+ throw new Error(`RETRIEVAL_SESSION_TERMINAL:${current.state}`);
2889
+ }
2890
+ this.db.prepare(`
2891
+ UPDATE devflow_retrieval_sessions SET state = ?, final_receipt = ?, updated_at = ?
2892
+ WHERE id = ? AND project_root = ? AND session_id = ? AND state = 'open'
2893
+ `).run(input.state, input.finalReceipt, now, input.id, input.projectRoot, input.sessionId);
2894
+ return this.getRetrievalSession(input.projectRoot, input.sessionId, input.id)!;
2895
+ }
2896
+
2897
+ expireRetrievalSessions(now = Date.now(), limit = 100): number {
2898
+ const rows = this.db.prepare(`
2899
+ SELECT id FROM devflow_retrieval_sessions
2900
+ WHERE state = 'open' AND expires_at <= ? ORDER BY expires_at ASC LIMIT ?
2901
+ `).all(now, Math.max(1, Math.min(1000, Math.floor(limit)))) as Array<{ id: string }>;
2902
+ if (rows.length === 0) return 0;
2903
+ const placeholders = rows.map(() => '?').join(',');
2904
+ return this.db.prepare(`
2905
+ UPDATE devflow_retrieval_sessions SET state = 'expired', updated_at = ?
2906
+ WHERE state = 'open' AND id IN (${placeholders})
2907
+ `).run(now, ...rows.map(row => row.id)).changes;
2908
+ }
2909
+
2910
+ private validateRetrievalSessionInput(input: CreateRetrievalSessionInput): void {
2911
+ for (const [field, value] of Object.entries({
2912
+ requestId: input.requestId,
2913
+ projectRoot: input.projectRoot,
2914
+ sessionId: input.sessionId,
2915
+ query: input.query,
2916
+ intent: input.intent,
2917
+ baselineReceipt: input.baselineReceipt,
2918
+ })) {
2919
+ if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`RETRIEVAL_INVALID_${field}`);
2920
+ }
2921
+ if (!Number.isSafeInteger(input.tokenBudget) || input.tokenBudget < 0) throw new Error('RETRIEVAL_INVALID_BUDGET');
2922
+ if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= Date.now()) throw new Error('RETRIEVAL_INVALID_EXPIRY');
2923
+ }
2924
+
2925
+ private assertRetrievalIdentity(existing: RetrievalSessionRecord, input: CreateRetrievalSessionInput): void {
2926
+ const matches = existing.projectRoot === input.projectRoot
2927
+ && existing.sessionId === input.sessionId
2928
+ && existing.executionId === input.executionId
2929
+ && existing.query === input.query
2930
+ && existing.intent === input.intent
2931
+ && existing.initialTokenBudget === input.tokenBudget
2932
+ && existing.baselineReceipt === input.baselineReceipt;
2933
+ if (!matches) throw new Error(`RETRIEVAL_REQUEST_CONFLICT:${input.requestId}`);
2934
+ }
2935
+
2680
2936
  upsertContextReceipt(receipt: ContextReceiptRecord): void {
2681
2937
  this.db.prepare(`
2682
2938
  INSERT INTO devflow_context_receipts
@@ -3335,6 +3591,16 @@ export class DevFlowDatabase {
3335
3591
  return rows.map(mapHostActionRow);
3336
3592
  }
3337
3593
 
3594
+ listHostActionsForSession(projectRoot: string, sessionId: string, executionId?: string): HostActionRecord[] {
3595
+ const rows = this.db.prepare(`
3596
+ SELECT * FROM devflow_host_actions
3597
+ WHERE project_root = ? AND session_id = ?
3598
+ AND (? IS NULL OR execution_id = ?)
3599
+ ORDER BY created_at ASC, action_id ASC
3600
+ `).all(projectRoot, sessionId, executionId ?? null, executionId ?? null) as Array<Record<string, unknown>>;
3601
+ return rows.map(mapHostActionRow);
3602
+ }
3603
+
3338
3604
  private transitionHostAction(
3339
3605
  identity: StartHostActionInput,
3340
3606
  targetState: Exclude<HostActionState, 'waiting'>,
package/src/index.ts CHANGED
@@ -36,6 +36,16 @@ export {
36
36
  mapHostActionRow,
37
37
  serializeHostActionReport,
38
38
  } from './host-actions';
39
+ export { RETRIEVAL_MAX_CYCLES, isRetrievalSessionState } from './retrieval-sessions';
40
+ export type {
41
+ AppendRetrievalCycleInput,
42
+ CreateRetrievalSessionInput,
43
+ RetrievalCycleRecord,
44
+ RetrievalGapKind,
45
+ RetrievalGapRecord,
46
+ RetrievalSessionRecord,
47
+ RetrievalSessionState,
48
+ } from './retrieval-sessions';
39
49
  export type {
40
50
  FailHostActionInput,
41
51
  HostActionRecord,
@@ -0,0 +1,196 @@
1
+ export const RETRIEVAL_MAX_CYCLES = 3 as const;
2
+
3
+ export type RetrievalSessionState = 'open' | 'satisfied' | 'exhausted' | 'expired';
4
+
5
+ export type RetrievalGapKind =
6
+ | 'target'
7
+ | 'caller'
8
+ | 'route'
9
+ | 'resource'
10
+ | 'test'
11
+ | 'configuration'
12
+ | 'symbol_ambiguity'
13
+ | 'memory_applicability'
14
+ | 'knowledge_version'
15
+ | 'runtime_evidence'
16
+ | 'unavailable_channel';
17
+
18
+ export interface RetrievalGapRecord {
19
+ kind: RetrievalGapKind;
20
+ target?: string;
21
+ reason: string;
22
+ evidence: string[];
23
+ resolver: 'codegraph' | 'memory' | 'knowledge' | 'analyzer' | 'none';
24
+ priority: number;
25
+ }
26
+
27
+ export interface RetrievalCycleRecord {
28
+ retrievalSessionId: string;
29
+ cycle: number;
30
+ gaps: RetrievalGapRecord[];
31
+ selectedIds: string[];
32
+ rejectedIds: string[];
33
+ tokenCost: number;
34
+ remainingTokenBudget: number;
35
+ quality: Record<string, unknown>;
36
+ receipt: string;
37
+ evidenceHash: string;
38
+ createdAt: number;
39
+ }
40
+
41
+ export interface RetrievalSessionRecord {
42
+ id: string;
43
+ requestId: string;
44
+ projectRoot: string;
45
+ sessionId: string;
46
+ executionId?: string;
47
+ query: string;
48
+ intent: string;
49
+ state: RetrievalSessionState;
50
+ cycle: number;
51
+ maxCycles: typeof RETRIEVAL_MAX_CYCLES;
52
+ initialTokenBudget: number;
53
+ remainingTokenBudget: number;
54
+ baselineReceipt: string;
55
+ finalReceipt?: string;
56
+ createdAt: number;
57
+ updatedAt: number;
58
+ expiresAt: number;
59
+ }
60
+
61
+ export interface CreateRetrievalSessionInput {
62
+ id?: string;
63
+ requestId: string;
64
+ projectRoot: string;
65
+ sessionId: string;
66
+ executionId?: string;
67
+ query: string;
68
+ intent: string;
69
+ tokenBudget: number;
70
+ baselineReceipt: string;
71
+ expiresAt: number;
72
+ }
73
+
74
+ export interface AppendRetrievalCycleInput {
75
+ retrievalSessionId: string;
76
+ projectRoot: string;
77
+ sessionId: string;
78
+ baselineReceipt: string;
79
+ cycle: number;
80
+ gaps: RetrievalGapRecord[];
81
+ selectedIds: string[];
82
+ rejectedIds: string[];
83
+ tokenCost: number;
84
+ remainingTokenBudget: number;
85
+ quality: Record<string, unknown>;
86
+ receipt: string;
87
+ evidenceHash: string;
88
+ }
89
+
90
+ export function mapRetrievalSessionRow(row: Record<string, unknown>): RetrievalSessionRecord {
91
+ const state = requiredString(row.state, 'state');
92
+ if (!isRetrievalSessionState(state)) {
93
+ throw new Error(`Invalid persisted retrieval session state: ${state}`);
94
+ }
95
+ const maxCycles = finiteInteger(row.max_cycles, 'max_cycles');
96
+ if (maxCycles !== RETRIEVAL_MAX_CYCLES) {
97
+ throw new Error(`Invalid persisted retrieval max_cycles: ${maxCycles}`);
98
+ }
99
+ return {
100
+ id: requiredString(row.id, 'id'),
101
+ requestId: requiredString(row.request_id, 'request_id'),
102
+ projectRoot: requiredString(row.project_root, 'project_root'),
103
+ sessionId: requiredString(row.session_id, 'session_id'),
104
+ executionId: optionalString(row.execution_id),
105
+ query: requiredString(row.query, 'query'),
106
+ intent: requiredString(row.intent, 'intent'),
107
+ state,
108
+ cycle: finiteInteger(row.cycle, 'cycle'),
109
+ maxCycles: RETRIEVAL_MAX_CYCLES,
110
+ initialTokenBudget: finiteInteger(row.initial_token_budget, 'initial_token_budget'),
111
+ remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
112
+ baselineReceipt: requiredString(row.baseline_receipt, 'baseline_receipt'),
113
+ finalReceipt: optionalString(row.final_receipt),
114
+ createdAt: finiteInteger(row.created_at, 'created_at'),
115
+ updatedAt: finiteInteger(row.updated_at, 'updated_at'),
116
+ expiresAt: finiteInteger(row.expires_at, 'expires_at'),
117
+ };
118
+ }
119
+
120
+ export function mapRetrievalCycleRow(row: Record<string, unknown>): RetrievalCycleRecord {
121
+ return {
122
+ retrievalSessionId: requiredString(row.retrieval_session_id, 'retrieval_session_id'),
123
+ cycle: finiteInteger(row.cycle, 'cycle'),
124
+ gaps: parseJsonArray(row.gaps_json, 'gaps_json') as RetrievalGapRecord[],
125
+ selectedIds: parseStringArray(row.selected_ids, 'selected_ids'),
126
+ rejectedIds: parseStringArray(row.rejected_ids, 'rejected_ids'),
127
+ tokenCost: finiteInteger(row.token_cost, 'token_cost'),
128
+ remainingTokenBudget: finiteInteger(row.remaining_token_budget, 'remaining_token_budget'),
129
+ quality: parseJsonObject(row.quality_json, 'quality_json'),
130
+ receipt: requiredString(row.receipt, 'receipt'),
131
+ evidenceHash: requiredString(row.evidence_hash, 'evidence_hash'),
132
+ createdAt: finiteInteger(row.created_at, 'created_at'),
133
+ };
134
+ }
135
+
136
+ export function serializeRetrievalJson(value: unknown): string {
137
+ return JSON.stringify(canonicalize(value));
138
+ }
139
+
140
+ export function isRetrievalSessionState(value: unknown): value is RetrievalSessionState {
141
+ return value === 'open' || value === 'satisfied' || value === 'exhausted' || value === 'expired';
142
+ }
143
+
144
+ function canonicalize(value: unknown): unknown {
145
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
146
+ if (typeof value === 'number') {
147
+ if (!Number.isFinite(value) || Object.is(value, -0)) throw new Error('Retrieval evidence must be lossless JSON');
148
+ return value;
149
+ }
150
+ if (Array.isArray(value)) return value.map(canonicalize);
151
+ if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) {
152
+ throw new Error('Retrieval evidence must be plain JSON');
153
+ }
154
+ const record = value as Record<string, unknown>;
155
+ return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonicalize(record[key])]));
156
+ }
157
+
158
+ function parseJson(value: unknown, field: string): unknown {
159
+ if (typeof value !== 'string') throw new Error(`Invalid persisted ${field}: expected JSON text`);
160
+ try { return JSON.parse(value) as unknown; } catch { throw new Error(`Invalid persisted ${field}: malformed JSON`); }
161
+ }
162
+
163
+ function parseJsonArray(value: unknown, field: string): unknown[] {
164
+ const parsed = parseJson(value, field);
165
+ if (!Array.isArray(parsed)) throw new Error(`Invalid persisted ${field}: expected array`);
166
+ return parsed;
167
+ }
168
+
169
+ function parseStringArray(value: unknown, field: string): string[] {
170
+ const parsed = parseJsonArray(value, field);
171
+ if (parsed.some(item => typeof item !== 'string')) throw new Error(`Invalid persisted ${field}: expected strings`);
172
+ return parsed as string[];
173
+ }
174
+
175
+ function parseJsonObject(value: unknown, field: string): Record<string, unknown> {
176
+ const parsed = parseJson(value, field);
177
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
178
+ throw new Error(`Invalid persisted ${field}: expected object`);
179
+ }
180
+ return parsed as Record<string, unknown>;
181
+ }
182
+
183
+ function requiredString(value: unknown, field: string): string {
184
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`Invalid persisted ${field}`);
185
+ return value;
186
+ }
187
+
188
+ function optionalString(value: unknown): string | undefined {
189
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
190
+ }
191
+
192
+ function finiteInteger(value: unknown, field: string): number {
193
+ const result = Number(value);
194
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`Invalid persisted ${field}`);
195
+ return result;
196
+ }