@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/CHANGELOG.md +27 -0
- package/__tests__/database.host-actions.test.ts +405 -0
- package/__tests__/database.retrieval-sessions.test.ts +79 -0
- package/dist/database.d.ts +31 -0
- package/dist/database.js +481 -0
- package/dist/host-actions.d.ts +47 -0
- package/dist/host-actions.js +152 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -1
- package/dist/retrieval-sessions.d.ts +74 -0
- package/dist/retrieval-sessions.js +117 -0
- package/package.json +1 -1
- package/src/database.ts +589 -0
- package/src/host-actions.ts +211 -0
- package/src/index.ts +25 -0
- package/src/retrieval-sessions.ts +196 -0
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);
|