@devflow-tools/database 0.16.18 → 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/dist/database.js CHANGED
@@ -9,6 +9,8 @@ const fs_1 = require("fs");
9
9
  const os_1 = require("os");
10
10
  const crypto_1 = require("crypto");
11
11
  const obligation_ledger_1 = require("./obligation-ledger");
12
+ const host_actions_1 = require("./host-actions");
13
+ const retrieval_sessions_1 = require("./retrieval-sessions");
12
14
  const CONTEXT_REQUIRED_SKILLS = new Set([
13
15
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
14
16
  ]);
@@ -308,6 +310,50 @@ class DevFlowDatabase {
308
310
  CREATE INDEX IF NOT EXISTS idx_context_selection_identity
309
311
  ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
310
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
+
311
357
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
312
358
  id TEXT PRIMARY KEY,
313
359
  project_root TEXT NOT NULL,
@@ -430,6 +476,79 @@ class DevFlowDatabase {
430
476
 
431
477
  CREATE INDEX IF NOT EXISTS idx_session_closures_state
432
478
  ON devflow_session_closures(project_root, state, updated_at DESC);
479
+
480
+ CREATE TABLE IF NOT EXISTS devflow_host_actions (
481
+ action_id TEXT PRIMARY KEY,
482
+ run_id TEXT NOT NULL,
483
+ engine_run_id TEXT NOT NULL,
484
+ step_id TEXT NOT NULL,
485
+ project_root TEXT NOT NULL,
486
+ session_id TEXT,
487
+ execution_id TEXT,
488
+ context_receipt TEXT,
489
+ state TEXT NOT NULL DEFAULT 'waiting'
490
+ CHECK(state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
491
+ report_json TEXT NOT NULL DEFAULT '{}',
492
+ evidence_hash TEXT,
493
+ created_at INTEGER NOT NULL,
494
+ updated_at INTEGER NOT NULL,
495
+ finished_at INTEGER,
496
+ UNIQUE(run_id, step_id),
497
+ CHECK(
498
+ (state IN ('verified', 'failed', 'cancelled', 'degraded') AND finished_at IS NOT NULL)
499
+ OR (state IN ('waiting', 'running', 'reported') AND finished_at IS NULL)
500
+ )
501
+ );
502
+
503
+ CREATE INDEX IF NOT EXISTS idx_host_actions_project
504
+ ON devflow_host_actions(project_root, created_at DESC);
505
+ CREATE INDEX IF NOT EXISTS idx_host_actions_session
506
+ ON devflow_host_actions(project_root, session_id, created_at DESC);
507
+ CREATE INDEX IF NOT EXISTS idx_host_actions_run
508
+ ON devflow_host_actions(project_root, run_id, created_at, action_id);
509
+ CREATE INDEX IF NOT EXISTS idx_host_actions_action
510
+ ON devflow_host_actions(action_id);
511
+
512
+ CREATE TABLE IF NOT EXISTS devflow_host_action_events (
513
+ event_id TEXT PRIMARY KEY,
514
+ action_id TEXT NOT NULL,
515
+ project_root TEXT NOT NULL,
516
+ run_id TEXT NOT NULL,
517
+ step_id TEXT NOT NULL,
518
+ from_state TEXT
519
+ CHECK(from_state IS NULL OR from_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
520
+ to_state TEXT NOT NULL
521
+ CHECK(to_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
522
+ report_json TEXT NOT NULL DEFAULT '{}',
523
+ evidence_hash TEXT,
524
+ created_at INTEGER NOT NULL
525
+ );
526
+
527
+ CREATE INDEX IF NOT EXISTS idx_host_action_events_action
528
+ ON devflow_host_action_events(action_id, created_at, event_id);
529
+ CREATE INDEX IF NOT EXISTS idx_host_action_events_project_run
530
+ ON devflow_host_action_events(project_root, run_id, created_at, event_id);
531
+
532
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_replace
533
+ BEFORE INSERT ON devflow_host_action_events
534
+ WHEN EXISTS (
535
+ SELECT 1 FROM devflow_host_action_events WHERE event_id = NEW.event_id
536
+ )
537
+ BEGIN
538
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
539
+ END;
540
+
541
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_update
542
+ BEFORE UPDATE ON devflow_host_action_events
543
+ BEGIN
544
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
545
+ END;
546
+
547
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_delete
548
+ BEFORE DELETE ON devflow_host_action_events
549
+ BEGIN
550
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
551
+ END;
433
552
  `);
434
553
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
435
554
  try {
@@ -2106,6 +2225,183 @@ class DevFlowDatabase {
2106
2225
  return this.db.prepare('DELETE FROM devflow_hook_receipts WHERE project_root = ?')
2107
2226
  .run(projectRoot).changes > 0;
2108
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
+ }
2109
2405
  upsertContextReceipt(receipt) {
2110
2406
  this.db.prepare(`
2111
2407
  INSERT INTO devflow_context_receipts
@@ -2517,6 +2813,191 @@ class DevFlowDatabase {
2517
2813
  createdAt: Number(row.created_at),
2518
2814
  }));
2519
2815
  }
2816
+ // ---- Durable Host Actions ----
2817
+ requestHostAction(input) {
2818
+ this.validateHostActionIdentity(input);
2819
+ const actionId = input.actionId ?? (0, crypto_1.randomUUID)();
2820
+ if (!actionId.trim())
2821
+ throw new Error('Host action requires an action ID');
2822
+ return this.withImmediateTransaction(() => {
2823
+ const now = Date.now();
2824
+ const result = this.db.prepare(`
2825
+ INSERT INTO devflow_host_actions (
2826
+ action_id, run_id, engine_run_id, step_id, project_root, session_id,
2827
+ execution_id, context_receipt, state, report_json, evidence_hash,
2828
+ created_at, updated_at, finished_at
2829
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', '{}', NULL, ?, ?, NULL)
2830
+ ON CONFLICT DO NOTHING
2831
+ `).run(actionId, input.runId, input.engineRunId, input.stepId, input.projectRoot, input.sessionId ?? null, input.executionId ?? null, input.contextReceipt ?? null, now, now);
2832
+ const rows = this.db.prepare(`
2833
+ SELECT * FROM devflow_host_actions
2834
+ WHERE action_id = ? OR (run_id = ? AND step_id = ?)
2835
+ `).all(actionId, input.runId, input.stepId);
2836
+ if (rows.length !== 1) {
2837
+ throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
2838
+ }
2839
+ const record = (0, host_actions_1.mapHostActionRow)(rows[0]);
2840
+ const sameIdentity = (input.actionId === undefined || record.actionId === input.actionId)
2841
+ && record.runId === input.runId
2842
+ && record.engineRunId === input.engineRunId
2843
+ && record.stepId === input.stepId
2844
+ && record.projectRoot === input.projectRoot
2845
+ && record.sessionId === input.sessionId
2846
+ && record.executionId === input.executionId
2847
+ && record.contextReceipt === input.contextReceipt;
2848
+ if (!sameIdentity) {
2849
+ throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
2850
+ }
2851
+ if (result.changes > 0)
2852
+ this.appendHostActionEvent(record, null, 'waiting', now);
2853
+ return record;
2854
+ });
2855
+ }
2856
+ startHostAction(input) {
2857
+ return this.transitionHostAction(input, 'running', {});
2858
+ }
2859
+ reportHostAction(input) {
2860
+ return this.transitionHostAction(input, 'reported', input.report, input.evidenceHash);
2861
+ }
2862
+ verifyHostAction(input) {
2863
+ if (typeof input.evidenceHash !== 'string' || !input.evidenceHash.trim()) {
2864
+ throw new Error('Host action verification requires a non-empty evidence hash');
2865
+ }
2866
+ return this.transitionHostAction(input, 'verified', input.report, input.evidenceHash, true);
2867
+ }
2868
+ failHostAction(input) {
2869
+ const outcome = input.outcome ?? 'failed';
2870
+ if (outcome !== 'failed' && outcome !== 'cancelled' && outcome !== 'degraded') {
2871
+ throw new Error(`Invalid host action failure outcome: ${String(outcome)}`);
2872
+ }
2873
+ return this.transitionHostAction(input, outcome, input.report, input.evidenceHash);
2874
+ }
2875
+ getHostAction(projectRoot, actionId) {
2876
+ const row = this.db.prepare(`
2877
+ SELECT * FROM devflow_host_actions
2878
+ WHERE project_root = ? AND action_id = ?
2879
+ `).get(projectRoot, actionId);
2880
+ return row ? (0, host_actions_1.mapHostActionRow)(row) : null;
2881
+ }
2882
+ listHostActionsForRun(projectRoot, runId) {
2883
+ const rows = this.db.prepare(`
2884
+ SELECT * FROM devflow_host_actions
2885
+ WHERE project_root = ? AND run_id = ?
2886
+ ORDER BY created_at ASC, action_id ASC
2887
+ `).all(projectRoot, runId);
2888
+ return rows.map(host_actions_1.mapHostActionRow);
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
+ }
2899
+ transitionHostAction(identity, targetState, report, evidenceHash, requireNonEmptyReport = false) {
2900
+ if (!identity.actionId.trim())
2901
+ throw new Error('Host action requires an action ID');
2902
+ if (!identity.projectRoot.trim())
2903
+ throw new Error('Host action requires a project root');
2904
+ const reportJson = (0, host_actions_1.serializeHostActionReport)(report);
2905
+ if (requireNonEmptyReport && reportJson === '{}') {
2906
+ throw new Error('Host action verification requires a non-empty report');
2907
+ }
2908
+ return this.withImmediateTransaction(() => {
2909
+ const existing = this.getHostAction(identity.projectRoot, identity.actionId);
2910
+ if (!existing)
2911
+ throw new Error(`HOST_ACTION_NOT_FOUND:${identity.actionId}`);
2912
+ if (this.isTerminalHostActionState(existing.state)) {
2913
+ const identical = existing.state === targetState
2914
+ && existing.evidenceHash === evidenceHash
2915
+ && (0, host_actions_1.hostActionReportsEqual)(existing.report, report);
2916
+ if (identical)
2917
+ return existing;
2918
+ throw new Error(`HOST_ACTION_TERMINAL_CONFLICT:${identity.actionId}`);
2919
+ }
2920
+ if (!this.isLegalHostActionTransition(existing.state, targetState)) {
2921
+ throw new Error(`HOST_ACTION_INVALID_TRANSITION:${existing.state}->${targetState}`);
2922
+ }
2923
+ const now = Date.now();
2924
+ const finishedAt = this.isTerminalHostActionState(targetState) ? now : null;
2925
+ const result = this.db.prepare(`
2926
+ UPDATE devflow_host_actions
2927
+ SET state = ?, report_json = ?, evidence_hash = ?, updated_at = ?, finished_at = ?
2928
+ WHERE project_root = ? AND action_id = ? AND state = ?
2929
+ `).run(targetState, reportJson, evidenceHash ?? null, now, finishedAt, identity.projectRoot, identity.actionId, existing.state);
2930
+ if (result.changes !== 1) {
2931
+ throw new Error(`HOST_ACTION_UPDATE_CONFLICT:${identity.actionId}`);
2932
+ }
2933
+ const updated = this.getHostAction(identity.projectRoot, identity.actionId);
2934
+ this.appendHostActionEvent(updated, existing.state, targetState, now);
2935
+ return updated;
2936
+ });
2937
+ }
2938
+ appendHostActionEvent(record, fromState, toState, createdAt) {
2939
+ this.db.prepare(`
2940
+ INSERT INTO devflow_host_action_events (
2941
+ event_id, action_id, project_root, run_id, step_id, from_state,
2942
+ to_state, report_json, evidence_hash, created_at
2943
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2944
+ `).run((0, crypto_1.randomUUID)(), record.actionId, record.projectRoot, record.runId, record.stepId, fromState, toState, (0, host_actions_1.serializeHostActionReport)(record.report), record.evidenceHash ?? null, createdAt);
2945
+ }
2946
+ validateHostActionIdentity(input) {
2947
+ const fields = [
2948
+ ['run ID', input.runId],
2949
+ ['engine run ID', input.engineRunId],
2950
+ ['step ID', input.stepId],
2951
+ ['project root', input.projectRoot],
2952
+ ];
2953
+ for (const [name, value] of fields) {
2954
+ if (!value.trim())
2955
+ throw new Error(`Host action requires a ${name}`);
2956
+ }
2957
+ }
2958
+ withImmediateTransaction(operation) {
2959
+ this.db.exec('BEGIN IMMEDIATE');
2960
+ try {
2961
+ const result = operation();
2962
+ this.db.exec('COMMIT');
2963
+ return result;
2964
+ }
2965
+ catch (error) {
2966
+ try {
2967
+ this.db.exec('ROLLBACK');
2968
+ }
2969
+ catch { }
2970
+ throw error;
2971
+ }
2972
+ }
2973
+ isTerminalHostActionState(state) {
2974
+ return state === 'verified'
2975
+ || state === 'failed'
2976
+ || state === 'cancelled'
2977
+ || state === 'degraded';
2978
+ }
2979
+ isLegalHostActionTransition(fromState, toState) {
2980
+ if (fromState === 'waiting') {
2981
+ return toState === 'running'
2982
+ || toState === 'reported'
2983
+ || toState === 'failed'
2984
+ || toState === 'cancelled'
2985
+ || toState === 'degraded';
2986
+ }
2987
+ if (fromState === 'running') {
2988
+ return toState === 'reported'
2989
+ || toState === 'failed'
2990
+ || toState === 'cancelled'
2991
+ || toState === 'degraded';
2992
+ }
2993
+ if (fromState === 'reported') {
2994
+ return toState === 'verified'
2995
+ || toState === 'failed'
2996
+ || toState === 'cancelled'
2997
+ || toState === 'degraded';
2998
+ }
2999
+ return false;
3000
+ }
2520
3001
  // Convenience methods for raw SQL queries (used by AutoChecker)
2521
3002
  all(sql, ...params) {
2522
3003
  return this.db.prepare(sql).all(...params);
@@ -0,0 +1,47 @@
1
+ export type HostActionState = 'waiting' | 'running' | 'reported' | 'verified' | 'failed' | 'cancelled' | 'degraded';
2
+ export interface HostActionRecord {
3
+ actionId: string;
4
+ runId: string;
5
+ engineRunId: string;
6
+ stepId: string;
7
+ projectRoot: string;
8
+ sessionId?: string;
9
+ executionId?: string;
10
+ contextReceipt?: string;
11
+ state: HostActionState;
12
+ report: Record<string, unknown>;
13
+ evidenceHash?: string;
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ finishedAt?: number;
17
+ }
18
+ export interface RequestHostActionInput {
19
+ actionId?: string;
20
+ runId: string;
21
+ engineRunId: string;
22
+ stepId: string;
23
+ projectRoot: string;
24
+ sessionId?: string;
25
+ executionId?: string;
26
+ contextReceipt?: string;
27
+ }
28
+ export interface StartHostActionInput {
29
+ actionId: string;
30
+ projectRoot: string;
31
+ }
32
+ export interface ReportHostActionInput {
33
+ actionId: string;
34
+ projectRoot: string;
35
+ report: Record<string, unknown>;
36
+ evidenceHash?: string;
37
+ }
38
+ export interface VerifyHostActionInput extends ReportHostActionInput {
39
+ evidenceHash: string;
40
+ }
41
+ export interface FailHostActionInput extends ReportHostActionInput {
42
+ outcome?: 'failed' | 'cancelled' | 'degraded';
43
+ }
44
+ export declare function isHostActionState(value: unknown): value is HostActionState;
45
+ export declare function mapHostActionRow(row: Record<string, unknown>): HostActionRecord;
46
+ export declare function serializeHostActionReport(report: Record<string, unknown>): string;
47
+ export declare function hostActionReportsEqual(left: Record<string, unknown>, right: Record<string, unknown>): boolean;