@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/src/database.ts CHANGED
@@ -19,6 +19,29 @@ import {
19
19
  type SessionObligationRecord,
20
20
  type SessionObligationState,
21
21
  } from './obligation-ledger';
22
+ import {
23
+ hostActionReportsEqual,
24
+ mapHostActionRow,
25
+ serializeHostActionReport,
26
+ type FailHostActionInput,
27
+ type HostActionRecord,
28
+ type HostActionState,
29
+ type ReportHostActionInput,
30
+ type RequestHostActionInput,
31
+ type StartHostActionInput,
32
+ type VerifyHostActionInput,
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';
22
45
 
23
46
  export interface BenchmarkReportRecord {
24
47
  runId: string;
@@ -465,6 +488,50 @@ export class DevFlowDatabase {
465
488
  CREATE INDEX IF NOT EXISTS idx_context_selection_identity
466
489
  ON devflow_context_selection_events(project_root, session_id, execution_id, request_id);
467
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
+
468
535
  CREATE TABLE IF NOT EXISTS devflow_memory_distill_checkpoints (
469
536
  id TEXT PRIMARY KEY,
470
537
  project_root TEXT NOT NULL,
@@ -587,6 +654,79 @@ export class DevFlowDatabase {
587
654
 
588
655
  CREATE INDEX IF NOT EXISTS idx_session_closures_state
589
656
  ON devflow_session_closures(project_root, state, updated_at DESC);
657
+
658
+ CREATE TABLE IF NOT EXISTS devflow_host_actions (
659
+ action_id TEXT PRIMARY KEY,
660
+ run_id TEXT NOT NULL,
661
+ engine_run_id TEXT NOT NULL,
662
+ step_id TEXT NOT NULL,
663
+ project_root TEXT NOT NULL,
664
+ session_id TEXT,
665
+ execution_id TEXT,
666
+ context_receipt TEXT,
667
+ state TEXT NOT NULL DEFAULT 'waiting'
668
+ CHECK(state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
669
+ report_json TEXT NOT NULL DEFAULT '{}',
670
+ evidence_hash TEXT,
671
+ created_at INTEGER NOT NULL,
672
+ updated_at INTEGER NOT NULL,
673
+ finished_at INTEGER,
674
+ UNIQUE(run_id, step_id),
675
+ CHECK(
676
+ (state IN ('verified', 'failed', 'cancelled', 'degraded') AND finished_at IS NOT NULL)
677
+ OR (state IN ('waiting', 'running', 'reported') AND finished_at IS NULL)
678
+ )
679
+ );
680
+
681
+ CREATE INDEX IF NOT EXISTS idx_host_actions_project
682
+ ON devflow_host_actions(project_root, created_at DESC);
683
+ CREATE INDEX IF NOT EXISTS idx_host_actions_session
684
+ ON devflow_host_actions(project_root, session_id, created_at DESC);
685
+ CREATE INDEX IF NOT EXISTS idx_host_actions_run
686
+ ON devflow_host_actions(project_root, run_id, created_at, action_id);
687
+ CREATE INDEX IF NOT EXISTS idx_host_actions_action
688
+ ON devflow_host_actions(action_id);
689
+
690
+ CREATE TABLE IF NOT EXISTS devflow_host_action_events (
691
+ event_id TEXT PRIMARY KEY,
692
+ action_id TEXT NOT NULL,
693
+ project_root TEXT NOT NULL,
694
+ run_id TEXT NOT NULL,
695
+ step_id TEXT NOT NULL,
696
+ from_state TEXT
697
+ CHECK(from_state IS NULL OR from_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
698
+ to_state TEXT NOT NULL
699
+ CHECK(to_state IN ('waiting', 'running', 'reported', 'verified', 'failed', 'cancelled', 'degraded')),
700
+ report_json TEXT NOT NULL DEFAULT '{}',
701
+ evidence_hash TEXT,
702
+ created_at INTEGER NOT NULL
703
+ );
704
+
705
+ CREATE INDEX IF NOT EXISTS idx_host_action_events_action
706
+ ON devflow_host_action_events(action_id, created_at, event_id);
707
+ CREATE INDEX IF NOT EXISTS idx_host_action_events_project_run
708
+ ON devflow_host_action_events(project_root, run_id, created_at, event_id);
709
+
710
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_replace
711
+ BEFORE INSERT ON devflow_host_action_events
712
+ WHEN EXISTS (
713
+ SELECT 1 FROM devflow_host_action_events WHERE event_id = NEW.event_id
714
+ )
715
+ BEGIN
716
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
717
+ END;
718
+
719
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_update
720
+ BEFORE UPDATE ON devflow_host_action_events
721
+ BEGIN
722
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
723
+ END;
724
+
725
+ CREATE TRIGGER IF NOT EXISTS prevent_host_action_event_delete
726
+ BEFORE DELETE ON devflow_host_action_events
727
+ BEGIN
728
+ SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
729
+ END;
590
730
  `);
591
731
 
592
732
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
@@ -2592,6 +2732,207 @@ export class DevFlowDatabase {
2592
2732
  .run(projectRoot).changes > 0;
2593
2733
  }
2594
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
+
2595
2936
  upsertContextReceipt(receipt: ContextReceiptRecord): void {
2596
2937
  this.db.prepare(`
2597
2938
  INSERT INTO devflow_context_receipts
@@ -3150,6 +3491,254 @@ export class DevFlowDatabase {
3150
3491
  }));
3151
3492
  }
3152
3493
 
3494
+ // ---- Durable Host Actions ----
3495
+
3496
+ requestHostAction(input: RequestHostActionInput): HostActionRecord {
3497
+ this.validateHostActionIdentity(input);
3498
+ const actionId = input.actionId ?? randomUUID();
3499
+ if (!actionId.trim()) throw new Error('Host action requires an action ID');
3500
+
3501
+ return this.withImmediateTransaction(() => {
3502
+ const now = Date.now();
3503
+ const result = this.db.prepare(`
3504
+ INSERT INTO devflow_host_actions (
3505
+ action_id, run_id, engine_run_id, step_id, project_root, session_id,
3506
+ execution_id, context_receipt, state, report_json, evidence_hash,
3507
+ created_at, updated_at, finished_at
3508
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', '{}', NULL, ?, ?, NULL)
3509
+ ON CONFLICT DO NOTHING
3510
+ `).run(
3511
+ actionId,
3512
+ input.runId,
3513
+ input.engineRunId,
3514
+ input.stepId,
3515
+ input.projectRoot,
3516
+ input.sessionId ?? null,
3517
+ input.executionId ?? null,
3518
+ input.contextReceipt ?? null,
3519
+ now,
3520
+ now,
3521
+ );
3522
+
3523
+ const rows = this.db.prepare(`
3524
+ SELECT * FROM devflow_host_actions
3525
+ WHERE action_id = ? OR (run_id = ? AND step_id = ?)
3526
+ `).all(actionId, input.runId, input.stepId) as Array<Record<string, unknown>>;
3527
+
3528
+ if (rows.length !== 1) {
3529
+ throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
3530
+ }
3531
+ const record = mapHostActionRow(rows[0]!);
3532
+ const sameIdentity = (input.actionId === undefined || record.actionId === input.actionId)
3533
+ && record.runId === input.runId
3534
+ && record.engineRunId === input.engineRunId
3535
+ && record.stepId === input.stepId
3536
+ && record.projectRoot === input.projectRoot
3537
+ && record.sessionId === input.sessionId
3538
+ && record.executionId === input.executionId
3539
+ && record.contextReceipt === input.contextReceipt;
3540
+ if (!sameIdentity) {
3541
+ throw new Error(`HOST_ACTION_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
3542
+ }
3543
+
3544
+ if (result.changes > 0) this.appendHostActionEvent(record, null, 'waiting', now);
3545
+ return record;
3546
+ });
3547
+ }
3548
+
3549
+ startHostAction(input: StartHostActionInput): HostActionRecord {
3550
+ return this.transitionHostAction(input, 'running', {});
3551
+ }
3552
+
3553
+ reportHostAction(input: ReportHostActionInput): HostActionRecord {
3554
+ return this.transitionHostAction(input, 'reported', input.report, input.evidenceHash);
3555
+ }
3556
+
3557
+ verifyHostAction(input: VerifyHostActionInput): HostActionRecord {
3558
+ if (typeof input.evidenceHash !== 'string' || !input.evidenceHash.trim()) {
3559
+ throw new Error('Host action verification requires a non-empty evidence hash');
3560
+ }
3561
+ return this.transitionHostAction(input, 'verified', input.report, input.evidenceHash, true);
3562
+ }
3563
+
3564
+ failHostAction(input: FailHostActionInput): HostActionRecord {
3565
+ const outcome = input.outcome ?? 'failed';
3566
+ if (outcome !== 'failed' && outcome !== 'cancelled' && outcome !== 'degraded') {
3567
+ throw new Error(`Invalid host action failure outcome: ${String(outcome)}`);
3568
+ }
3569
+ return this.transitionHostAction(
3570
+ input,
3571
+ outcome,
3572
+ input.report,
3573
+ input.evidenceHash,
3574
+ );
3575
+ }
3576
+
3577
+ getHostAction(projectRoot: string, actionId: string): HostActionRecord | null {
3578
+ const row = this.db.prepare(`
3579
+ SELECT * FROM devflow_host_actions
3580
+ WHERE project_root = ? AND action_id = ?
3581
+ `).get(projectRoot, actionId) as Record<string, unknown> | undefined;
3582
+ return row ? mapHostActionRow(row) : null;
3583
+ }
3584
+
3585
+ listHostActionsForRun(projectRoot: string, runId: string): HostActionRecord[] {
3586
+ const rows = this.db.prepare(`
3587
+ SELECT * FROM devflow_host_actions
3588
+ WHERE project_root = ? AND run_id = ?
3589
+ ORDER BY created_at ASC, action_id ASC
3590
+ `).all(projectRoot, runId) as Array<Record<string, unknown>>;
3591
+ return rows.map(mapHostActionRow);
3592
+ }
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
+
3604
+ private transitionHostAction(
3605
+ identity: StartHostActionInput,
3606
+ targetState: Exclude<HostActionState, 'waiting'>,
3607
+ report: Record<string, unknown>,
3608
+ evidenceHash?: string,
3609
+ requireNonEmptyReport = false,
3610
+ ): HostActionRecord {
3611
+ if (!identity.actionId.trim()) throw new Error('Host action requires an action ID');
3612
+ if (!identity.projectRoot.trim()) throw new Error('Host action requires a project root');
3613
+ const reportJson = serializeHostActionReport(report);
3614
+ if (requireNonEmptyReport && reportJson === '{}') {
3615
+ throw new Error('Host action verification requires a non-empty report');
3616
+ }
3617
+
3618
+ return this.withImmediateTransaction(() => {
3619
+ const existing = this.getHostAction(identity.projectRoot, identity.actionId);
3620
+ if (!existing) throw new Error(`HOST_ACTION_NOT_FOUND:${identity.actionId}`);
3621
+
3622
+ if (this.isTerminalHostActionState(existing.state)) {
3623
+ const identical = existing.state === targetState
3624
+ && existing.evidenceHash === evidenceHash
3625
+ && hostActionReportsEqual(existing.report, report);
3626
+ if (identical) return existing;
3627
+ throw new Error(`HOST_ACTION_TERMINAL_CONFLICT:${identity.actionId}`);
3628
+ }
3629
+
3630
+ if (!this.isLegalHostActionTransition(existing.state, targetState)) {
3631
+ throw new Error(`HOST_ACTION_INVALID_TRANSITION:${existing.state}->${targetState}`);
3632
+ }
3633
+
3634
+ const now = Date.now();
3635
+ const finishedAt = this.isTerminalHostActionState(targetState) ? now : null;
3636
+ const result = this.db.prepare(`
3637
+ UPDATE devflow_host_actions
3638
+ SET state = ?, report_json = ?, evidence_hash = ?, updated_at = ?, finished_at = ?
3639
+ WHERE project_root = ? AND action_id = ? AND state = ?
3640
+ `).run(
3641
+ targetState,
3642
+ reportJson,
3643
+ evidenceHash ?? null,
3644
+ now,
3645
+ finishedAt,
3646
+ identity.projectRoot,
3647
+ identity.actionId,
3648
+ existing.state,
3649
+ );
3650
+ if (result.changes !== 1) {
3651
+ throw new Error(`HOST_ACTION_UPDATE_CONFLICT:${identity.actionId}`);
3652
+ }
3653
+
3654
+ const updated = this.getHostAction(identity.projectRoot, identity.actionId)!;
3655
+ this.appendHostActionEvent(updated, existing.state, targetState, now);
3656
+ return updated;
3657
+ });
3658
+ }
3659
+
3660
+ private appendHostActionEvent(
3661
+ record: HostActionRecord,
3662
+ fromState: HostActionState | null,
3663
+ toState: HostActionState,
3664
+ createdAt: number,
3665
+ ): void {
3666
+ this.db.prepare(`
3667
+ INSERT INTO devflow_host_action_events (
3668
+ event_id, action_id, project_root, run_id, step_id, from_state,
3669
+ to_state, report_json, evidence_hash, created_at
3670
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3671
+ `).run(
3672
+ randomUUID(),
3673
+ record.actionId,
3674
+ record.projectRoot,
3675
+ record.runId,
3676
+ record.stepId,
3677
+ fromState,
3678
+ toState,
3679
+ serializeHostActionReport(record.report),
3680
+ record.evidenceHash ?? null,
3681
+ createdAt,
3682
+ );
3683
+ }
3684
+
3685
+ private validateHostActionIdentity(input: RequestHostActionInput): void {
3686
+ const fields: Array<[string, string]> = [
3687
+ ['run ID', input.runId],
3688
+ ['engine run ID', input.engineRunId],
3689
+ ['step ID', input.stepId],
3690
+ ['project root', input.projectRoot],
3691
+ ];
3692
+ for (const [name, value] of fields) {
3693
+ if (!value.trim()) throw new Error(`Host action requires a ${name}`);
3694
+ }
3695
+ }
3696
+
3697
+ private withImmediateTransaction<T>(operation: () => T): T {
3698
+ this.db.exec('BEGIN IMMEDIATE');
3699
+ try {
3700
+ const result = operation();
3701
+ this.db.exec('COMMIT');
3702
+ return result;
3703
+ } catch (error) {
3704
+ try { this.db.exec('ROLLBACK'); } catch {}
3705
+ throw error;
3706
+ }
3707
+ }
3708
+
3709
+ private isTerminalHostActionState(state: HostActionState): boolean {
3710
+ return state === 'verified'
3711
+ || state === 'failed'
3712
+ || state === 'cancelled'
3713
+ || state === 'degraded';
3714
+ }
3715
+
3716
+ private isLegalHostActionTransition(
3717
+ fromState: HostActionState,
3718
+ toState: HostActionState,
3719
+ ): boolean {
3720
+ if (fromState === 'waiting') {
3721
+ return toState === 'running'
3722
+ || toState === 'reported'
3723
+ || toState === 'failed'
3724
+ || toState === 'cancelled'
3725
+ || toState === 'degraded';
3726
+ }
3727
+ if (fromState === 'running') {
3728
+ return toState === 'reported'
3729
+ || toState === 'failed'
3730
+ || toState === 'cancelled'
3731
+ || toState === 'degraded';
3732
+ }
3733
+ if (fromState === 'reported') {
3734
+ return toState === 'verified'
3735
+ || toState === 'failed'
3736
+ || toState === 'cancelled'
3737
+ || toState === 'degraded';
3738
+ }
3739
+ return false;
3740
+ }
3741
+
3153
3742
  // Convenience methods for raw SQL queries (used by AutoChecker)
3154
3743
  all(sql: string, ...params: unknown[]): unknown[] {
3155
3744
  return this.db.prepare(sql).all(...params);