@devflow-tools/database 0.16.21 → 0.16.23
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 +23 -0
- package/__tests__/database.learning-candidates.test.ts +93 -0
- package/__tests__/database.workflow-workers.test.ts +60 -0
- package/dist/database.d.ts +42 -0
- package/dist/database.js +623 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -1
- package/dist/learning-candidates.d.ts +87 -0
- package/dist/learning-candidates.js +81 -0
- package/dist/work-queue.d.ts +1 -1
- package/dist/workflow-workers.d.ts +97 -0
- package/dist/workflow-workers.js +90 -0
- package/package.json +2 -2
- package/src/database.ts +848 -0
- package/src/index.ts +35 -0
- package/src/learning-candidates.ts +181 -0
- package/src/work-queue.ts +6 -0
- package/src/workflow-workers.ts +198 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/database.ts
CHANGED
|
@@ -42,6 +42,33 @@ import {
|
|
|
42
42
|
type RetrievalSessionRecord,
|
|
43
43
|
type RetrievalSessionState,
|
|
44
44
|
} from './retrieval-sessions';
|
|
45
|
+
import {
|
|
46
|
+
mapLearningCandidateRow,
|
|
47
|
+
mapLearningEvidenceRow,
|
|
48
|
+
mapLearningVersionRow,
|
|
49
|
+
stableLearningJson,
|
|
50
|
+
type AddLearningCandidateEvidenceInput,
|
|
51
|
+
type LearningCandidateEvidenceRecord,
|
|
52
|
+
type LearningCandidateRecord,
|
|
53
|
+
type LearningCandidateState,
|
|
54
|
+
type LearningCandidateVersionRecord,
|
|
55
|
+
type TransitionLearningCandidateInput,
|
|
56
|
+
type UpsertLearningCandidateInput,
|
|
57
|
+
} from './learning-candidates';
|
|
58
|
+
import {
|
|
59
|
+
LEGAL_WORKFLOW_WORKER_TRANSITIONS,
|
|
60
|
+
TERMINAL_WORKFLOW_WORKER_STATES,
|
|
61
|
+
mapWorkflowMergeRow,
|
|
62
|
+
mapWorkflowWorkerEventRow,
|
|
63
|
+
mapWorkflowWorkerRow,
|
|
64
|
+
type CreateWorkflowWorkerInput,
|
|
65
|
+
type EnqueueWorkflowMergeInput,
|
|
66
|
+
type TransitionWorkflowWorkerInput,
|
|
67
|
+
type WorkflowMergeRecord,
|
|
68
|
+
type WorkflowMergeState,
|
|
69
|
+
type WorkflowWorkerEventRecord,
|
|
70
|
+
type WorkflowWorkerRecord,
|
|
71
|
+
} from './workflow-workers';
|
|
45
72
|
|
|
46
73
|
export interface BenchmarkReportRecord {
|
|
47
74
|
runId: string;
|
|
@@ -655,6 +682,74 @@ export class DevFlowDatabase {
|
|
|
655
682
|
CREATE INDEX IF NOT EXISTS idx_session_closures_state
|
|
656
683
|
ON devflow_session_closures(project_root, state, updated_at DESC);
|
|
657
684
|
|
|
685
|
+
CREATE TABLE IF NOT EXISTS devflow_learning_candidates (
|
|
686
|
+
id TEXT PRIMARY KEY,
|
|
687
|
+
project_root TEXT NOT NULL,
|
|
688
|
+
scope TEXT NOT NULL CHECK(scope IN ('project', 'global')),
|
|
689
|
+
state TEXT NOT NULL DEFAULT 'observed'
|
|
690
|
+
CHECK(state IN ('observed', 'candidate', 'shadow', 'evaluated', 'active', 'rejected', 'retired')),
|
|
691
|
+
kind TEXT NOT NULL CHECK(kind IN ('convention', 'workflow', 'tool_preference', 'correction')),
|
|
692
|
+
trigger_json TEXT NOT NULL,
|
|
693
|
+
instruction TEXT NOT NULL,
|
|
694
|
+
confidence REAL NOT NULL CHECK(confidence BETWEEN 0 AND 1),
|
|
695
|
+
overlay_version INTEGER NOT NULL DEFAULT 1 CHECK(overlay_version > 0),
|
|
696
|
+
supporting_sessions INTEGER NOT NULL DEFAULT 0 CHECK(supporting_sessions >= 0),
|
|
697
|
+
successful_outcomes INTEGER NOT NULL DEFAULT 0 CHECK(successful_outcomes >= 0),
|
|
698
|
+
contradictions INTEGER NOT NULL DEFAULT 0 CHECK(contradictions >= 0),
|
|
699
|
+
manual_only INTEGER NOT NULL DEFAULT 0 CHECK(manual_only IN (0, 1)),
|
|
700
|
+
risk TEXT NOT NULL DEFAULT 'low' CHECK(risk IN ('low', 'medium', 'high')),
|
|
701
|
+
grader_receipt TEXT,
|
|
702
|
+
expires_at INTEGER,
|
|
703
|
+
created_at INTEGER NOT NULL,
|
|
704
|
+
updated_at INTEGER NOT NULL
|
|
705
|
+
);
|
|
706
|
+
CREATE INDEX IF NOT EXISTS idx_learning_candidates_project
|
|
707
|
+
ON devflow_learning_candidates(project_root, state, kind, updated_at DESC);
|
|
708
|
+
|
|
709
|
+
CREATE TABLE IF NOT EXISTS devflow_learning_candidate_versions (
|
|
710
|
+
candidate_id TEXT NOT NULL,
|
|
711
|
+
version INTEGER NOT NULL CHECK(version > 0),
|
|
712
|
+
trigger_json TEXT NOT NULL,
|
|
713
|
+
instruction TEXT NOT NULL,
|
|
714
|
+
created_at INTEGER NOT NULL,
|
|
715
|
+
PRIMARY KEY(candidate_id, version)
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
CREATE TABLE IF NOT EXISTS devflow_learning_candidate_evidence (
|
|
719
|
+
id TEXT PRIMARY KEY,
|
|
720
|
+
candidate_id TEXT NOT NULL,
|
|
721
|
+
project_root TEXT NOT NULL,
|
|
722
|
+
session_id TEXT NOT NULL,
|
|
723
|
+
execution_id TEXT,
|
|
724
|
+
memory_observation_id TEXT,
|
|
725
|
+
outcome_id TEXT,
|
|
726
|
+
source_type TEXT NOT NULL
|
|
727
|
+
CHECK(source_type IN ('memory', 'correction', 'workflow_outcome', 'tool_preference', 'convention', 'grader', 'manual')),
|
|
728
|
+
polarity TEXT NOT NULL CHECK(polarity IN ('supporting', 'contradicting')),
|
|
729
|
+
outcome TEXT NOT NULL DEFAULT 'unknown' CHECK(outcome IN ('positive', 'negative', 'unknown')),
|
|
730
|
+
evidence_hash TEXT NOT NULL,
|
|
731
|
+
receipt TEXT,
|
|
732
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
733
|
+
created_at INTEGER NOT NULL,
|
|
734
|
+
resolved_at INTEGER,
|
|
735
|
+
UNIQUE(candidate_id, evidence_hash)
|
|
736
|
+
);
|
|
737
|
+
CREATE INDEX IF NOT EXISTS idx_learning_evidence_candidate
|
|
738
|
+
ON devflow_learning_candidate_evidence(candidate_id, polarity, resolved_at, created_at);
|
|
739
|
+
|
|
740
|
+
CREATE TABLE IF NOT EXISTS devflow_learning_activation_history (
|
|
741
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
742
|
+
candidate_id TEXT NOT NULL,
|
|
743
|
+
from_state TEXT NOT NULL,
|
|
744
|
+
to_state TEXT NOT NULL,
|
|
745
|
+
reason TEXT NOT NULL,
|
|
746
|
+
grader_receipt TEXT,
|
|
747
|
+
manual_approval INTEGER NOT NULL DEFAULT 0 CHECK(manual_approval IN (0, 1)),
|
|
748
|
+
changed_at INTEGER NOT NULL
|
|
749
|
+
);
|
|
750
|
+
CREATE INDEX IF NOT EXISTS idx_learning_activation_candidate
|
|
751
|
+
ON devflow_learning_activation_history(candidate_id, changed_at DESC);
|
|
752
|
+
|
|
658
753
|
CREATE TABLE IF NOT EXISTS devflow_host_actions (
|
|
659
754
|
action_id TEXT PRIMARY KEY,
|
|
660
755
|
run_id TEXT NOT NULL,
|
|
@@ -727,6 +822,85 @@ export class DevFlowDatabase {
|
|
|
727
822
|
BEGIN
|
|
728
823
|
SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
|
|
729
824
|
END;
|
|
825
|
+
|
|
826
|
+
CREATE TABLE IF NOT EXISTS devflow_workflow_workers (
|
|
827
|
+
worker_id TEXT PRIMARY KEY,
|
|
828
|
+
run_id TEXT NOT NULL,
|
|
829
|
+
step_id TEXT NOT NULL,
|
|
830
|
+
project_root TEXT NOT NULL,
|
|
831
|
+
branch TEXT NOT NULL,
|
|
832
|
+
worktree_path TEXT NOT NULL,
|
|
833
|
+
host TEXT NOT NULL,
|
|
834
|
+
state TEXT NOT NULL DEFAULT 'planned'
|
|
835
|
+
CHECK(state IN ('planned','leased','starting','running','reported','verifying','merge_ready','merged','hold','failed','cancelled')),
|
|
836
|
+
lease_owner TEXT,
|
|
837
|
+
lease_expires_at INTEGER,
|
|
838
|
+
heartbeat_at INTEGER,
|
|
839
|
+
context_receipt TEXT NOT NULL,
|
|
840
|
+
retrieval_session_id TEXT,
|
|
841
|
+
session_id TEXT,
|
|
842
|
+
execution_id TEXT,
|
|
843
|
+
base_revision TEXT NOT NULL,
|
|
844
|
+
final_revision TEXT,
|
|
845
|
+
allowed_files TEXT NOT NULL DEFAULT '[]',
|
|
846
|
+
handoff_json TEXT NOT NULL DEFAULT '{}',
|
|
847
|
+
hold_reason TEXT,
|
|
848
|
+
failure_reason TEXT,
|
|
849
|
+
created_at INTEGER NOT NULL,
|
|
850
|
+
updated_at INTEGER NOT NULL,
|
|
851
|
+
finished_at INTEGER,
|
|
852
|
+
UNIQUE(run_id, step_id),
|
|
853
|
+
UNIQUE(worktree_path),
|
|
854
|
+
CHECK(
|
|
855
|
+
(state IN ('merged','failed','cancelled') AND finished_at IS NOT NULL)
|
|
856
|
+
OR (state NOT IN ('merged','failed','cancelled') AND finished_at IS NULL)
|
|
857
|
+
)
|
|
858
|
+
);
|
|
859
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_workers_project
|
|
860
|
+
ON devflow_workflow_workers(project_root, state, updated_at DESC);
|
|
861
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_workers_lease
|
|
862
|
+
ON devflow_workflow_workers(project_root, state, lease_expires_at);
|
|
863
|
+
|
|
864
|
+
CREATE TABLE IF NOT EXISTS devflow_worker_events (
|
|
865
|
+
event_id TEXT PRIMARY KEY,
|
|
866
|
+
worker_id TEXT NOT NULL,
|
|
867
|
+
project_root TEXT NOT NULL,
|
|
868
|
+
from_state TEXT,
|
|
869
|
+
to_state TEXT NOT NULL,
|
|
870
|
+
reason TEXT,
|
|
871
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
872
|
+
created_at INTEGER NOT NULL
|
|
873
|
+
);
|
|
874
|
+
CREATE INDEX IF NOT EXISTS idx_worker_events_worker
|
|
875
|
+
ON devflow_worker_events(worker_id, created_at, event_id);
|
|
876
|
+
CREATE TRIGGER IF NOT EXISTS prevent_worker_event_update
|
|
877
|
+
BEFORE UPDATE ON devflow_worker_events BEGIN
|
|
878
|
+
SELECT RAISE(ABORT, 'devflow_worker_events is append-only');
|
|
879
|
+
END;
|
|
880
|
+
CREATE TRIGGER IF NOT EXISTS prevent_worker_event_delete
|
|
881
|
+
BEFORE DELETE ON devflow_worker_events BEGIN
|
|
882
|
+
SELECT RAISE(ABORT, 'devflow_worker_events is append-only');
|
|
883
|
+
END;
|
|
884
|
+
|
|
885
|
+
CREATE TABLE IF NOT EXISTS devflow_merge_queue (
|
|
886
|
+
merge_id TEXT PRIMARY KEY,
|
|
887
|
+
worker_id TEXT NOT NULL UNIQUE,
|
|
888
|
+
project_root TEXT NOT NULL,
|
|
889
|
+
state TEXT NOT NULL DEFAULT 'waiting'
|
|
890
|
+
CHECK(state IN ('waiting','ready','conflict','merged','rejected')),
|
|
891
|
+
base_revision TEXT NOT NULL,
|
|
892
|
+
parent_revision TEXT NOT NULL,
|
|
893
|
+
candidate_revision TEXT NOT NULL,
|
|
894
|
+
grader_receipts TEXT NOT NULL DEFAULT '[]',
|
|
895
|
+
simulation_hash TEXT,
|
|
896
|
+
merge_commit TEXT,
|
|
897
|
+
reason TEXT,
|
|
898
|
+
created_at INTEGER NOT NULL,
|
|
899
|
+
updated_at INTEGER NOT NULL,
|
|
900
|
+
finished_at INTEGER
|
|
901
|
+
);
|
|
902
|
+
CREATE INDEX IF NOT EXISTS idx_merge_queue_project
|
|
903
|
+
ON devflow_merge_queue(project_root, state, created_at, merge_id);
|
|
730
904
|
`);
|
|
731
905
|
|
|
732
906
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
@@ -2231,6 +2405,352 @@ export class DevFlowDatabase {
|
|
|
2231
2405
|
};
|
|
2232
2406
|
}
|
|
2233
2407
|
|
|
2408
|
+
// ---- Governed Learning ----
|
|
2409
|
+
|
|
2410
|
+
upsertLearningCandidate(input: UpsertLearningCandidateInput): LearningCandidateRecord {
|
|
2411
|
+
if (!input.id.trim()) throw new Error('Learning candidate requires an ID');
|
|
2412
|
+
if (!input.projectRoot.trim()) throw new Error('Learning candidate requires a project root');
|
|
2413
|
+
if (!input.instruction.trim()) throw new Error('Learning candidate requires an instruction');
|
|
2414
|
+
if (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1) {
|
|
2415
|
+
throw new Error('Learning candidate confidence must be between 0 and 1');
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
return this.db.transaction(() => {
|
|
2419
|
+
const now = Date.now();
|
|
2420
|
+
const existing = this.getLearningCandidate(input.id);
|
|
2421
|
+
const triggerJson = stableLearningJson(input.trigger);
|
|
2422
|
+
const instruction = input.instruction.trim().slice(0, 2_000);
|
|
2423
|
+
if (!existing) {
|
|
2424
|
+
this.db.prepare(`
|
|
2425
|
+
INSERT INTO devflow_learning_candidates (
|
|
2426
|
+
id, project_root, scope, state, kind, trigger_json, instruction,
|
|
2427
|
+
confidence, overlay_version, supporting_sessions, successful_outcomes,
|
|
2428
|
+
contradictions, manual_only, risk, expires_at, created_at, updated_at
|
|
2429
|
+
) VALUES (?, ?, ?, 'observed', ?, ?, ?, ?, 1, 0, 0, 0, ?, ?, ?, ?, ?)
|
|
2430
|
+
`).run(
|
|
2431
|
+
input.id,
|
|
2432
|
+
input.projectRoot,
|
|
2433
|
+
input.scope,
|
|
2434
|
+
input.kind,
|
|
2435
|
+
triggerJson,
|
|
2436
|
+
instruction,
|
|
2437
|
+
input.confidence,
|
|
2438
|
+
input.manualOnly ? 1 : 0,
|
|
2439
|
+
input.risk ?? 'low',
|
|
2440
|
+
input.expiresAt ?? null,
|
|
2441
|
+
now,
|
|
2442
|
+
now,
|
|
2443
|
+
);
|
|
2444
|
+
this.insertLearningCandidateVersion(input.id, 1, triggerJson, instruction, now);
|
|
2445
|
+
return this.getLearningCandidate(input.id)!;
|
|
2446
|
+
}
|
|
2447
|
+
if (existing.projectRoot !== input.projectRoot || existing.scope !== input.scope || existing.kind !== input.kind) {
|
|
2448
|
+
throw new Error(`Learning candidate identity mismatch for ${input.id}`);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
const contentChanged = stableLearningJson(existing.trigger) !== triggerJson
|
|
2452
|
+
|| existing.instruction !== instruction;
|
|
2453
|
+
const nextVersion = contentChanged ? existing.overlayVersion + 1 : existing.overlayVersion;
|
|
2454
|
+
const nextState = contentChanged && (existing.state === 'active' || existing.state === 'evaluated')
|
|
2455
|
+
? 'shadow'
|
|
2456
|
+
: existing.state;
|
|
2457
|
+
this.db.prepare(`
|
|
2458
|
+
UPDATE devflow_learning_candidates
|
|
2459
|
+
SET trigger_json = ?, instruction = ?, confidence = ?, overlay_version = ?,
|
|
2460
|
+
state = ?, manual_only = MAX(manual_only, ?),
|
|
2461
|
+
risk = CASE
|
|
2462
|
+
WHEN risk = 'high' OR ? = 'high' THEN 'high'
|
|
2463
|
+
WHEN risk = 'medium' OR ? = 'medium' THEN 'medium'
|
|
2464
|
+
ELSE 'low'
|
|
2465
|
+
END,
|
|
2466
|
+
expires_at = ?, updated_at = ?
|
|
2467
|
+
WHERE id = ?
|
|
2468
|
+
`).run(
|
|
2469
|
+
triggerJson,
|
|
2470
|
+
instruction,
|
|
2471
|
+
Math.max(existing.confidence, input.confidence),
|
|
2472
|
+
nextVersion,
|
|
2473
|
+
nextState,
|
|
2474
|
+
input.manualOnly ? 1 : 0,
|
|
2475
|
+
input.risk ?? 'low',
|
|
2476
|
+
input.risk ?? 'low',
|
|
2477
|
+
input.expiresAt ?? existing.expiresAt ?? null,
|
|
2478
|
+
now,
|
|
2479
|
+
input.id,
|
|
2480
|
+
);
|
|
2481
|
+
if (contentChanged) {
|
|
2482
|
+
this.insertLearningCandidateVersion(input.id, nextVersion, triggerJson, instruction, now);
|
|
2483
|
+
if (nextState !== existing.state) {
|
|
2484
|
+
this.insertLearningActivation(input.id, existing.state, nextState, 'candidate_content_changed', undefined, false, now);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
return this.getLearningCandidate(input.id)!;
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
getLearningCandidate(id: string): LearningCandidateRecord | null {
|
|
2492
|
+
const row = this.db.prepare('SELECT * FROM devflow_learning_candidates WHERE id = ?')
|
|
2493
|
+
.get(id) as Record<string, unknown> | undefined;
|
|
2494
|
+
return row ? mapLearningCandidateRow(row) : null;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
listLearningCandidates(options: {
|
|
2498
|
+
projectRoot?: string;
|
|
2499
|
+
states?: LearningCandidateState[];
|
|
2500
|
+
limit?: number;
|
|
2501
|
+
} = {}): LearningCandidateRecord[] {
|
|
2502
|
+
const predicates: string[] = [];
|
|
2503
|
+
const params: unknown[] = [];
|
|
2504
|
+
if (options.projectRoot) {
|
|
2505
|
+
predicates.push('(project_root = ? OR scope = \'global\')');
|
|
2506
|
+
params.push(options.projectRoot);
|
|
2507
|
+
}
|
|
2508
|
+
const states = [...new Set(options.states ?? [])];
|
|
2509
|
+
if (states.length > 0) {
|
|
2510
|
+
predicates.push(`state IN (${states.map(() => '?').join(',')})`);
|
|
2511
|
+
params.push(...states);
|
|
2512
|
+
}
|
|
2513
|
+
const rows = this.db.prepare(`
|
|
2514
|
+
SELECT * FROM devflow_learning_candidates
|
|
2515
|
+
${predicates.length > 0 ? `WHERE ${predicates.join(' AND ')}` : ''}
|
|
2516
|
+
ORDER BY updated_at DESC, id ASC
|
|
2517
|
+
LIMIT ?
|
|
2518
|
+
`).all(...params, Math.max(1, Math.min(options.limit ?? 200, 2_000))) as Array<Record<string, unknown>>;
|
|
2519
|
+
return rows.map(mapLearningCandidateRow);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
addLearningCandidateEvidence(input: AddLearningCandidateEvidenceInput): LearningCandidateRecord {
|
|
2523
|
+
if (!input.evidenceHash.trim()) throw new Error('Learning evidence requires a hash');
|
|
2524
|
+
if (!input.sessionId.trim()) throw new Error('Learning evidence requires a session ID');
|
|
2525
|
+
return this.db.transaction(() => {
|
|
2526
|
+
const candidate = this.getLearningCandidate(input.candidateId);
|
|
2527
|
+
if (!candidate) throw new Error(`Learning candidate ${input.candidateId} does not exist`);
|
|
2528
|
+
if (candidate.projectRoot !== input.projectRoot && candidate.scope !== 'global') {
|
|
2529
|
+
throw new Error(`Learning evidence project mismatch for ${input.candidateId}`);
|
|
2530
|
+
}
|
|
2531
|
+
const createdAt = input.createdAt ?? Date.now();
|
|
2532
|
+
const inserted = this.db.prepare(`
|
|
2533
|
+
INSERT OR IGNORE INTO devflow_learning_candidate_evidence (
|
|
2534
|
+
id, candidate_id, project_root, session_id, execution_id,
|
|
2535
|
+
memory_observation_id, outcome_id, source_type, polarity, outcome,
|
|
2536
|
+
evidence_hash, receipt, payload_json, created_at
|
|
2537
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2538
|
+
`).run(
|
|
2539
|
+
input.id,
|
|
2540
|
+
input.candidateId,
|
|
2541
|
+
input.projectRoot,
|
|
2542
|
+
input.sessionId,
|
|
2543
|
+
input.executionId ?? null,
|
|
2544
|
+
input.memoryObservationId ?? null,
|
|
2545
|
+
input.outcomeId ?? null,
|
|
2546
|
+
input.sourceType,
|
|
2547
|
+
input.polarity,
|
|
2548
|
+
input.outcome ?? 'unknown',
|
|
2549
|
+
input.evidenceHash,
|
|
2550
|
+
input.receipt ?? null,
|
|
2551
|
+
stableLearningJson(input.payload ?? {}),
|
|
2552
|
+
createdAt,
|
|
2553
|
+
).changes === 1;
|
|
2554
|
+
if (!inserted) return candidate;
|
|
2555
|
+
|
|
2556
|
+
const previousState = candidate.state;
|
|
2557
|
+
const aggregates = this.getLearningEvidenceAggregates(input.candidateId);
|
|
2558
|
+
let state = previousState;
|
|
2559
|
+
if (state === 'observed' && aggregates.supportingSessions > 0) state = 'candidate';
|
|
2560
|
+
if (aggregates.contradictions > 0 && (state === 'active' || state === 'evaluated')) state = 'shadow';
|
|
2561
|
+
this.db.prepare(`
|
|
2562
|
+
UPDATE devflow_learning_candidates
|
|
2563
|
+
SET state = ?, supporting_sessions = ?, successful_outcomes = ?,
|
|
2564
|
+
contradictions = ?, updated_at = ?
|
|
2565
|
+
WHERE id = ?
|
|
2566
|
+
`).run(
|
|
2567
|
+
state,
|
|
2568
|
+
aggregates.supportingSessions,
|
|
2569
|
+
aggregates.successfulOutcomes,
|
|
2570
|
+
aggregates.contradictions,
|
|
2571
|
+
createdAt,
|
|
2572
|
+
input.candidateId,
|
|
2573
|
+
);
|
|
2574
|
+
if (state !== previousState) {
|
|
2575
|
+
this.insertLearningActivation(
|
|
2576
|
+
input.candidateId,
|
|
2577
|
+
previousState,
|
|
2578
|
+
state,
|
|
2579
|
+
aggregates.contradictions > 0 ? 'unresolved_contradiction' : 'first_supporting_evidence',
|
|
2580
|
+
undefined,
|
|
2581
|
+
false,
|
|
2582
|
+
createdAt,
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
return this.getLearningCandidate(input.candidateId)!;
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
listLearningCandidateEvidence(candidateId: string): LearningCandidateEvidenceRecord[] {
|
|
2590
|
+
const rows = this.db.prepare(`
|
|
2591
|
+
SELECT * FROM devflow_learning_candidate_evidence
|
|
2592
|
+
WHERE candidate_id = ? ORDER BY created_at ASC, id ASC
|
|
2593
|
+
`).all(candidateId) as Array<Record<string, unknown>>;
|
|
2594
|
+
return rows.map(mapLearningEvidenceRow);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
resolveLearningContradiction(candidateId: string, evidenceId: string, resolvedAt = Date.now()): LearningCandidateRecord {
|
|
2598
|
+
return this.db.transaction(() => {
|
|
2599
|
+
this.db.prepare(`
|
|
2600
|
+
UPDATE devflow_learning_candidate_evidence
|
|
2601
|
+
SET resolved_at = ?
|
|
2602
|
+
WHERE id = ? AND candidate_id = ? AND polarity = 'contradicting' AND resolved_at IS NULL
|
|
2603
|
+
`).run(resolvedAt, evidenceId, candidateId);
|
|
2604
|
+
const aggregates = this.getLearningEvidenceAggregates(candidateId);
|
|
2605
|
+
this.db.prepare(`
|
|
2606
|
+
UPDATE devflow_learning_candidates
|
|
2607
|
+
SET supporting_sessions = ?, successful_outcomes = ?, contradictions = ?, updated_at = ?
|
|
2608
|
+
WHERE id = ?
|
|
2609
|
+
`).run(
|
|
2610
|
+
aggregates.supportingSessions,
|
|
2611
|
+
aggregates.successfulOutcomes,
|
|
2612
|
+
aggregates.contradictions,
|
|
2613
|
+
resolvedAt,
|
|
2614
|
+
candidateId,
|
|
2615
|
+
);
|
|
2616
|
+
const candidate = this.getLearningCandidate(candidateId);
|
|
2617
|
+
if (!candidate) throw new Error(`Learning candidate ${candidateId} does not exist`);
|
|
2618
|
+
return candidate;
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
transitionLearningCandidate(
|
|
2623
|
+
candidateId: string,
|
|
2624
|
+
input: TransitionLearningCandidateInput,
|
|
2625
|
+
): LearningCandidateRecord {
|
|
2626
|
+
return this.db.transaction(() => {
|
|
2627
|
+
const candidate = this.getLearningCandidate(candidateId);
|
|
2628
|
+
if (!candidate) throw new Error(`Learning candidate ${candidateId} does not exist`);
|
|
2629
|
+
if (candidate.state === input.target) return candidate;
|
|
2630
|
+
this.assertLearningTransition(candidate, input);
|
|
2631
|
+
const changedAt = input.changedAt ?? Date.now();
|
|
2632
|
+
const graderReceipt = input.graderReceipt ?? candidate.graderReceipt;
|
|
2633
|
+
this.db.prepare(`
|
|
2634
|
+
UPDATE devflow_learning_candidates
|
|
2635
|
+
SET state = ?, grader_receipt = COALESCE(?, grader_receipt), updated_at = ?
|
|
2636
|
+
WHERE id = ?
|
|
2637
|
+
`).run(input.target, graderReceipt ?? null, changedAt, candidateId);
|
|
2638
|
+
this.insertLearningActivation(
|
|
2639
|
+
candidateId,
|
|
2640
|
+
candidate.state,
|
|
2641
|
+
input.target,
|
|
2642
|
+
input.reason,
|
|
2643
|
+
graderReceipt,
|
|
2644
|
+
input.manualApproval === true,
|
|
2645
|
+
changedAt,
|
|
2646
|
+
);
|
|
2647
|
+
return this.getLearningCandidate(candidateId)!;
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
listLearningCandidateVersions(candidateId: string): LearningCandidateVersionRecord[] {
|
|
2652
|
+
const rows = this.db.prepare(`
|
|
2653
|
+
SELECT * FROM devflow_learning_candidate_versions
|
|
2654
|
+
WHERE candidate_id = ? ORDER BY version ASC
|
|
2655
|
+
`).all(candidateId) as Array<Record<string, unknown>>;
|
|
2656
|
+
return rows.map(mapLearningVersionRow);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
private assertLearningTransition(
|
|
2660
|
+
candidate: LearningCandidateRecord,
|
|
2661
|
+
input: TransitionLearningCandidateInput,
|
|
2662
|
+
): void {
|
|
2663
|
+
const terminal = input.target === 'rejected' || input.target === 'retired';
|
|
2664
|
+
const rollback = input.target === 'shadow'
|
|
2665
|
+
&& (candidate.state === 'active' || candidate.state === 'evaluated');
|
|
2666
|
+
const legal: Record<LearningCandidateState, LearningCandidateState[]> = {
|
|
2667
|
+
observed: ['candidate', 'rejected', 'retired'],
|
|
2668
|
+
candidate: ['shadow', 'rejected', 'retired'],
|
|
2669
|
+
shadow: ['evaluated', 'rejected', 'retired'],
|
|
2670
|
+
evaluated: ['active', 'shadow', 'rejected', 'retired'],
|
|
2671
|
+
active: ['shadow', 'retired'],
|
|
2672
|
+
rejected: ['retired'],
|
|
2673
|
+
retired: [],
|
|
2674
|
+
};
|
|
2675
|
+
if (!terminal && !rollback && !legal[candidate.state].includes(input.target)) {
|
|
2676
|
+
throw new Error(`Illegal learning transition ${candidate.state} -> ${input.target}`);
|
|
2677
|
+
}
|
|
2678
|
+
if (input.target === 'shadow') {
|
|
2679
|
+
if (candidate.supportingSessions < 3 || candidate.successfulOutcomes < 2) {
|
|
2680
|
+
throw new Error('Learning candidate requires three sessions and two verified outcomes before shadow');
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
if (input.target === 'evaluated' && candidate.contradictions > 0) {
|
|
2684
|
+
throw new Error('Learning candidate has unresolved contradictions');
|
|
2685
|
+
}
|
|
2686
|
+
if (input.target === 'active') {
|
|
2687
|
+
const receipt = input.graderReceipt ?? candidate.graderReceipt;
|
|
2688
|
+
if (!receipt) throw new Error('Learning candidate activation requires a grader receipt');
|
|
2689
|
+
if (candidate.contradictions > 0) throw new Error('Learning candidate has unresolved contradictions');
|
|
2690
|
+
const manualRequired = candidate.scope === 'global' || candidate.manualOnly || candidate.risk === 'high';
|
|
2691
|
+
if (manualRequired && input.manualApproval !== true) {
|
|
2692
|
+
throw new Error('Learning candidate requires manual approval');
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
private getLearningEvidenceAggregates(candidateId: string): {
|
|
2698
|
+
supportingSessions: number;
|
|
2699
|
+
successfulOutcomes: number;
|
|
2700
|
+
contradictions: number;
|
|
2701
|
+
} {
|
|
2702
|
+
const row = this.db.prepare(`
|
|
2703
|
+
SELECT
|
|
2704
|
+
COUNT(DISTINCT CASE WHEN polarity = 'supporting' THEN session_id END) AS supporting_sessions,
|
|
2705
|
+
COUNT(DISTINCT CASE WHEN polarity = 'supporting' AND outcome = 'positive' THEN session_id END) AS successful_outcomes,
|
|
2706
|
+
SUM(CASE WHEN polarity = 'contradicting' AND resolved_at IS NULL THEN 1 ELSE 0 END) AS contradictions
|
|
2707
|
+
FROM devflow_learning_candidate_evidence WHERE candidate_id = ?
|
|
2708
|
+
`).get(candidateId) as Record<string, unknown> | undefined;
|
|
2709
|
+
return {
|
|
2710
|
+
supportingSessions: Number(row?.supporting_sessions ?? 0),
|
|
2711
|
+
successfulOutcomes: Number(row?.successful_outcomes ?? 0),
|
|
2712
|
+
contradictions: Number(row?.contradictions ?? 0),
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
private insertLearningCandidateVersion(
|
|
2717
|
+
candidateId: string,
|
|
2718
|
+
version: number,
|
|
2719
|
+
triggerJson: string,
|
|
2720
|
+
instruction: string,
|
|
2721
|
+
createdAt: number,
|
|
2722
|
+
): void {
|
|
2723
|
+
this.db.prepare(`
|
|
2724
|
+
INSERT OR IGNORE INTO devflow_learning_candidate_versions
|
|
2725
|
+
(candidate_id, version, trigger_json, instruction, created_at)
|
|
2726
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2727
|
+
`).run(candidateId, version, triggerJson, instruction, createdAt);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
private insertLearningActivation(
|
|
2731
|
+
candidateId: string,
|
|
2732
|
+
fromState: LearningCandidateState,
|
|
2733
|
+
toState: LearningCandidateState,
|
|
2734
|
+
reason: string,
|
|
2735
|
+
graderReceipt: string | undefined,
|
|
2736
|
+
manualApproval: boolean,
|
|
2737
|
+
changedAt: number,
|
|
2738
|
+
): void {
|
|
2739
|
+
this.db.prepare(`
|
|
2740
|
+
INSERT INTO devflow_learning_activation_history
|
|
2741
|
+
(candidate_id, from_state, to_state, reason, grader_receipt, manual_approval, changed_at)
|
|
2742
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2743
|
+
`).run(
|
|
2744
|
+
candidateId,
|
|
2745
|
+
fromState,
|
|
2746
|
+
toState,
|
|
2747
|
+
reason.trim().slice(0, 500),
|
|
2748
|
+
graderReceipt ?? null,
|
|
2749
|
+
manualApproval ? 1 : 0,
|
|
2750
|
+
changedAt,
|
|
2751
|
+
);
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2234
2754
|
// ---- Durable Work Queue ----
|
|
2235
2755
|
|
|
2236
2756
|
enqueueWork(input: EnqueueWorkInput): WorkItemRecord {
|
|
@@ -2499,6 +3019,15 @@ export class DevFlowDatabase {
|
|
|
2499
3019
|
return completed;
|
|
2500
3020
|
}
|
|
2501
3021
|
|
|
3022
|
+
heartbeatWork(id: string, owner: string, leaseMs: number, now = Date.now()): boolean {
|
|
3023
|
+
if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) return false;
|
|
3024
|
+
return this.db.prepare(`
|
|
3025
|
+
UPDATE devflow_work_items
|
|
3026
|
+
SET lease_expires_at = ?, updated_at = ?
|
|
3027
|
+
WHERE id = ? AND state = 'leased' AND lease_owner = ?
|
|
3028
|
+
`).run(now + leaseMs, now, id, owner).changes === 1;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
2502
3031
|
retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
|
|
2503
3032
|
const updatedAt = Date.now();
|
|
2504
3033
|
return this.db.prepare(`
|
|
@@ -3739,6 +4268,325 @@ export class DevFlowDatabase {
|
|
|
3739
4268
|
return false;
|
|
3740
4269
|
}
|
|
3741
4270
|
|
|
4271
|
+
// ---- Isolated Workflow Workers ----
|
|
4272
|
+
|
|
4273
|
+
createWorkflowWorker(input: CreateWorkflowWorkerInput): WorkflowWorkerRecord {
|
|
4274
|
+
for (const [name, value] of [
|
|
4275
|
+
['run ID', input.runId],
|
|
4276
|
+
['step ID', input.stepId],
|
|
4277
|
+
['project root', input.projectRoot],
|
|
4278
|
+
['branch', input.branch],
|
|
4279
|
+
['worktree path', input.worktreePath],
|
|
4280
|
+
['host', input.host],
|
|
4281
|
+
['context receipt', input.contextReceipt],
|
|
4282
|
+
['base revision', input.baseRevision],
|
|
4283
|
+
] as Array<[string, string]>) {
|
|
4284
|
+
if (!value.trim()) throw new Error(`Workflow worker requires a ${name}`);
|
|
4285
|
+
}
|
|
4286
|
+
const workerId = input.workerId ?? randomUUID();
|
|
4287
|
+
const now = Date.now();
|
|
4288
|
+
return this.withImmediateTransaction(() => {
|
|
4289
|
+
this.db.prepare(`
|
|
4290
|
+
INSERT INTO devflow_workflow_workers (
|
|
4291
|
+
worker_id, run_id, step_id, project_root, branch, worktree_path, host,
|
|
4292
|
+
state, context_receipt, retrieval_session_id, session_id, execution_id,
|
|
4293
|
+
base_revision, allowed_files, handoff_json, created_at, updated_at
|
|
4294
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4295
|
+
ON CONFLICT DO NOTHING
|
|
4296
|
+
`).run(
|
|
4297
|
+
workerId,
|
|
4298
|
+
input.runId,
|
|
4299
|
+
input.stepId,
|
|
4300
|
+
input.projectRoot,
|
|
4301
|
+
input.branch,
|
|
4302
|
+
input.worktreePath,
|
|
4303
|
+
input.host,
|
|
4304
|
+
input.contextReceipt,
|
|
4305
|
+
input.retrievalSessionId ?? null,
|
|
4306
|
+
input.sessionId ?? null,
|
|
4307
|
+
input.executionId ?? null,
|
|
4308
|
+
input.baseRevision,
|
|
4309
|
+
JSON.stringify([...new Set(input.allowedFiles ?? [])].sort()),
|
|
4310
|
+
JSON.stringify(input.handoff ?? {}),
|
|
4311
|
+
now,
|
|
4312
|
+
now,
|
|
4313
|
+
);
|
|
4314
|
+
const rows = this.db.prepare(`
|
|
4315
|
+
SELECT * FROM devflow_workflow_workers
|
|
4316
|
+
WHERE worker_id = ? OR (run_id = ? AND step_id = ?)
|
|
4317
|
+
`).all(workerId, input.runId, input.stepId) as any[];
|
|
4318
|
+
if (rows.length !== 1) throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
|
|
4319
|
+
const record = mapWorkflowWorkerRow(rows[0]);
|
|
4320
|
+
if (record.workerId !== workerId
|
|
4321
|
+
|| record.projectRoot !== input.projectRoot
|
|
4322
|
+
|| record.worktreePath !== input.worktreePath
|
|
4323
|
+
|| record.contextReceipt !== input.contextReceipt) {
|
|
4324
|
+
throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
|
|
4325
|
+
}
|
|
4326
|
+
if (record.createdAt === now) this.appendWorkflowWorkerEvent(record, undefined, 'planned', undefined, {}, now);
|
|
4327
|
+
return record;
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
getWorkflowWorker(projectRoot: string, workerId: string): WorkflowWorkerRecord | null {
|
|
4332
|
+
const row = this.db.prepare(`
|
|
4333
|
+
SELECT * FROM devflow_workflow_workers WHERE project_root = ? AND worker_id = ?
|
|
4334
|
+
`).get(projectRoot, workerId) as any;
|
|
4335
|
+
return row ? mapWorkflowWorkerRow(row) : null;
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
listWorkflowWorkers(projectRoot: string, options: {
|
|
4339
|
+
states?: WorkflowWorkerRecord['state'][];
|
|
4340
|
+
runId?: string;
|
|
4341
|
+
limit?: number;
|
|
4342
|
+
} = {}): WorkflowWorkerRecord[] {
|
|
4343
|
+
const conditions = ['project_root = ?'];
|
|
4344
|
+
const values: unknown[] = [projectRoot];
|
|
4345
|
+
if (options.runId) {
|
|
4346
|
+
conditions.push('run_id = ?');
|
|
4347
|
+
values.push(options.runId);
|
|
4348
|
+
}
|
|
4349
|
+
if (options.states?.length) {
|
|
4350
|
+
conditions.push(`state IN (${options.states.map(() => '?').join(',')})`);
|
|
4351
|
+
values.push(...options.states);
|
|
4352
|
+
}
|
|
4353
|
+
values.push(Math.max(1, Math.min(options.limit ?? 200, 2_000)));
|
|
4354
|
+
const rows = this.db.prepare(`
|
|
4355
|
+
SELECT * FROM devflow_workflow_workers
|
|
4356
|
+
WHERE ${conditions.join(' AND ')}
|
|
4357
|
+
ORDER BY updated_at DESC, worker_id ASC LIMIT ?
|
|
4358
|
+
`).all(...values) as any[];
|
|
4359
|
+
return rows.map(mapWorkflowWorkerRow);
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
listWorkflowWorkerEvents(projectRoot: string, workerId: string): WorkflowWorkerEventRecord[] {
|
|
4363
|
+
return (this.db.prepare(`
|
|
4364
|
+
SELECT * FROM devflow_worker_events
|
|
4365
|
+
WHERE project_root = ? AND worker_id = ?
|
|
4366
|
+
ORDER BY created_at ASC, event_id ASC
|
|
4367
|
+
`).all(projectRoot, workerId) as any[]).map(mapWorkflowWorkerEventRow);
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
leaseWorkflowWorker(
|
|
4371
|
+
projectRoot: string,
|
|
4372
|
+
workerId: string,
|
|
4373
|
+
owner: string,
|
|
4374
|
+
leaseMs: number,
|
|
4375
|
+
now = Date.now(),
|
|
4376
|
+
): WorkflowWorkerRecord {
|
|
4377
|
+
if (!owner.trim()) throw new Error('Workflow worker lease owner is required');
|
|
4378
|
+
if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) throw new Error('Workflow worker lease must be positive');
|
|
4379
|
+
return this.transitionWorkflowWorker({
|
|
4380
|
+
projectRoot,
|
|
4381
|
+
workerId,
|
|
4382
|
+
target: 'leased',
|
|
4383
|
+
leaseOwner: owner,
|
|
4384
|
+
leaseExpiresAt: now + leaseMs,
|
|
4385
|
+
heartbeatAt: now,
|
|
4386
|
+
now,
|
|
4387
|
+
});
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
heartbeatWorkflowWorker(
|
|
4391
|
+
projectRoot: string,
|
|
4392
|
+
workerId: string,
|
|
4393
|
+
owner: string,
|
|
4394
|
+
leaseMs: number,
|
|
4395
|
+
now = Date.now(),
|
|
4396
|
+
): WorkflowWorkerRecord {
|
|
4397
|
+
const result = this.db.prepare(`
|
|
4398
|
+
UPDATE devflow_workflow_workers
|
|
4399
|
+
SET heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
|
|
4400
|
+
WHERE project_root = ? AND worker_id = ? AND lease_owner = ?
|
|
4401
|
+
AND state IN ('leased','starting','running','reported','verifying','merge_ready')
|
|
4402
|
+
`).run(now, now + leaseMs, now, projectRoot, workerId, owner);
|
|
4403
|
+
if (result.changes !== 1) throw new Error(`WORKFLOW_WORKER_HEARTBEAT_REJECTED:${workerId}`);
|
|
4404
|
+
return this.getWorkflowWorker(projectRoot, workerId)!;
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
transitionWorkflowWorker(input: TransitionWorkflowWorkerInput): WorkflowWorkerRecord {
|
|
4408
|
+
return this.withImmediateTransaction(() => {
|
|
4409
|
+
const existing = this.getWorkflowWorker(input.projectRoot, input.workerId);
|
|
4410
|
+
if (!existing) throw new Error(`WORKFLOW_WORKER_NOT_FOUND:${input.workerId}`);
|
|
4411
|
+
if (TERMINAL_WORKFLOW_WORKER_STATES.has(existing.state)) {
|
|
4412
|
+
if (existing.state === input.target) return existing;
|
|
4413
|
+
throw new Error(`WORKFLOW_WORKER_TERMINAL_CONFLICT:${input.workerId}`);
|
|
4414
|
+
}
|
|
4415
|
+
if (!LEGAL_WORKFLOW_WORKER_TRANSITIONS[existing.state].includes(input.target)) {
|
|
4416
|
+
throw new Error(`WORKFLOW_WORKER_INVALID_TRANSITION:${existing.state}->${input.target}`);
|
|
4417
|
+
}
|
|
4418
|
+
if (existing.leaseOwner && input.leaseOwner && existing.leaseOwner !== input.leaseOwner) {
|
|
4419
|
+
throw new Error(`WORKFLOW_WORKER_LEASE_CONFLICT:${input.workerId}`);
|
|
4420
|
+
}
|
|
4421
|
+
const now = input.now ?? Date.now();
|
|
4422
|
+
const terminal = TERMINAL_WORKFLOW_WORKER_STATES.has(input.target);
|
|
4423
|
+
const clearLease = terminal || input.target === 'hold';
|
|
4424
|
+
const reason = input.reason?.trim().slice(0, 1_000);
|
|
4425
|
+
const result = this.db.prepare(`
|
|
4426
|
+
UPDATE devflow_workflow_workers SET
|
|
4427
|
+
state = ?,
|
|
4428
|
+
lease_owner = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_owner) END,
|
|
4429
|
+
lease_expires_at = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_expires_at) END,
|
|
4430
|
+
heartbeat_at = COALESCE(?, heartbeat_at),
|
|
4431
|
+
final_revision = COALESCE(?, final_revision),
|
|
4432
|
+
handoff_json = CASE WHEN ? IS NULL THEN handoff_json ELSE ? END,
|
|
4433
|
+
hold_reason = CASE WHEN ? = 'hold' THEN ? ELSE hold_reason END,
|
|
4434
|
+
failure_reason = CASE WHEN ? = 'failed' THEN ? ELSE failure_reason END,
|
|
4435
|
+
updated_at = ?, finished_at = ?
|
|
4436
|
+
WHERE project_root = ? AND worker_id = ? AND state = ?
|
|
4437
|
+
`).run(
|
|
4438
|
+
input.target,
|
|
4439
|
+
clearLease ? 1 : 0,
|
|
4440
|
+
input.leaseOwner ?? null,
|
|
4441
|
+
clearLease ? 1 : 0,
|
|
4442
|
+
input.leaseExpiresAt ?? null,
|
|
4443
|
+
input.heartbeatAt ?? null,
|
|
4444
|
+
input.finalRevision ?? null,
|
|
4445
|
+
input.handoff === undefined ? null : 1,
|
|
4446
|
+
JSON.stringify(input.handoff ?? {}),
|
|
4447
|
+
input.target,
|
|
4448
|
+
reason ?? null,
|
|
4449
|
+
input.target,
|
|
4450
|
+
reason ?? null,
|
|
4451
|
+
now,
|
|
4452
|
+
terminal ? now : null,
|
|
4453
|
+
input.projectRoot,
|
|
4454
|
+
input.workerId,
|
|
4455
|
+
existing.state,
|
|
4456
|
+
);
|
|
4457
|
+
if (result.changes !== 1) throw new Error(`WORKFLOW_WORKER_UPDATE_CONFLICT:${input.workerId}`);
|
|
4458
|
+
const updated = this.getWorkflowWorker(input.projectRoot, input.workerId)!;
|
|
4459
|
+
this.appendWorkflowWorkerEvent(updated, existing.state, input.target, reason, input.handoff ?? {}, now);
|
|
4460
|
+
return updated;
|
|
4461
|
+
});
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
recoverExpiredWorkflowWorkers(projectRoot: string, now = Date.now()): WorkflowWorkerRecord[] {
|
|
4465
|
+
const expired = this.listWorkflowWorkers(projectRoot, {
|
|
4466
|
+
states: ['leased', 'starting', 'running', 'reported', 'verifying', 'merge_ready'],
|
|
4467
|
+
limit: 2_000,
|
|
4468
|
+
}).filter(worker => worker.leaseExpiresAt !== undefined && worker.leaseExpiresAt <= now);
|
|
4469
|
+
return expired.map(worker => this.transitionWorkflowWorker({
|
|
4470
|
+
projectRoot,
|
|
4471
|
+
workerId: worker.workerId,
|
|
4472
|
+
target: 'hold',
|
|
4473
|
+
leaseOwner: worker.leaseOwner,
|
|
4474
|
+
reason: 'lease_expired_dirty_worktree_requires_inspection',
|
|
4475
|
+
now,
|
|
4476
|
+
}));
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
enqueueWorkflowMerge(input: EnqueueWorkflowMergeInput): WorkflowMergeRecord {
|
|
4480
|
+
const now = Date.now();
|
|
4481
|
+
const mergeId = input.mergeId ?? randomUUID();
|
|
4482
|
+
this.db.prepare(`
|
|
4483
|
+
INSERT INTO devflow_merge_queue (
|
|
4484
|
+
merge_id, worker_id, project_root, state, base_revision, parent_revision,
|
|
4485
|
+
candidate_revision, grader_receipts, simulation_hash, created_at, updated_at
|
|
4486
|
+
) VALUES (?, ?, ?, 'waiting', ?, ?, ?, ?, ?, ?, ?)
|
|
4487
|
+
ON CONFLICT(worker_id) DO NOTHING
|
|
4488
|
+
`).run(
|
|
4489
|
+
mergeId,
|
|
4490
|
+
input.workerId,
|
|
4491
|
+
input.projectRoot,
|
|
4492
|
+
input.baseRevision,
|
|
4493
|
+
input.parentRevision,
|
|
4494
|
+
input.candidateRevision,
|
|
4495
|
+
JSON.stringify([...new Set(input.graderReceipts ?? [])].sort()),
|
|
4496
|
+
input.simulationHash ?? null,
|
|
4497
|
+
now,
|
|
4498
|
+
now,
|
|
4499
|
+
);
|
|
4500
|
+
const record = this.getWorkflowMergeByWorker(input.projectRoot, input.workerId);
|
|
4501
|
+
if (!record
|
|
4502
|
+
|| record.baseRevision !== input.baseRevision
|
|
4503
|
+
|| record.candidateRevision !== input.candidateRevision) {
|
|
4504
|
+
throw new Error(`WORKFLOW_MERGE_IDENTITY_CONFLICT:${input.workerId}`);
|
|
4505
|
+
}
|
|
4506
|
+
return record;
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
getWorkflowMerge(projectRoot: string, mergeId: string): WorkflowMergeRecord | null {
|
|
4510
|
+
const row = this.db.prepare(`
|
|
4511
|
+
SELECT * FROM devflow_merge_queue WHERE project_root = ? AND merge_id = ?
|
|
4512
|
+
`).get(projectRoot, mergeId) as any;
|
|
4513
|
+
return row ? mapWorkflowMergeRow(row) : null;
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
getWorkflowMergeByWorker(projectRoot: string, workerId: string): WorkflowMergeRecord | null {
|
|
4517
|
+
const row = this.db.prepare(`
|
|
4518
|
+
SELECT * FROM devflow_merge_queue WHERE project_root = ? AND worker_id = ?
|
|
4519
|
+
`).get(projectRoot, workerId) as any;
|
|
4520
|
+
return row ? mapWorkflowMergeRow(row) : null;
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
listWorkflowMerges(projectRoot: string, limit = 200): WorkflowMergeRecord[] {
|
|
4524
|
+
return (this.db.prepare(`
|
|
4525
|
+
SELECT * FROM devflow_merge_queue WHERE project_root = ?
|
|
4526
|
+
ORDER BY created_at ASC, merge_id ASC LIMIT ?
|
|
4527
|
+
`).all(projectRoot, Math.max(1, Math.min(limit, 2_000))) as any[]).map(mapWorkflowMergeRow);
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
transitionWorkflowMerge(
|
|
4531
|
+
projectRoot: string,
|
|
4532
|
+
mergeId: string,
|
|
4533
|
+
target: WorkflowMergeState,
|
|
4534
|
+
update: { parentRevision?: string; simulationHash?: string; mergeCommit?: string; reason?: string } = {},
|
|
4535
|
+
now = Date.now(),
|
|
4536
|
+
): WorkflowMergeRecord {
|
|
4537
|
+
const existing = this.getWorkflowMerge(projectRoot, mergeId);
|
|
4538
|
+
if (!existing) throw new Error(`WORKFLOW_MERGE_NOT_FOUND:${mergeId}`);
|
|
4539
|
+
const legal: Record<WorkflowMergeState, WorkflowMergeState[]> = {
|
|
4540
|
+
waiting: ['ready', 'conflict', 'rejected'],
|
|
4541
|
+
ready: ['conflict', 'merged', 'rejected'],
|
|
4542
|
+
conflict: ['waiting', 'rejected'],
|
|
4543
|
+
merged: [],
|
|
4544
|
+
rejected: [],
|
|
4545
|
+
};
|
|
4546
|
+
if (!legal[existing.state].includes(target)) {
|
|
4547
|
+
if (existing.state === target) return existing;
|
|
4548
|
+
throw new Error(`WORKFLOW_MERGE_INVALID_TRANSITION:${existing.state}->${target}`);
|
|
4549
|
+
}
|
|
4550
|
+
const terminal = target === 'merged' || target === 'rejected';
|
|
4551
|
+
const result = this.db.prepare(`
|
|
4552
|
+
UPDATE devflow_merge_queue SET state = ?, parent_revision = COALESCE(?, parent_revision),
|
|
4553
|
+
simulation_hash = COALESCE(?, simulation_hash), merge_commit = COALESCE(?, merge_commit),
|
|
4554
|
+
reason = COALESCE(?, reason), updated_at = ?, finished_at = ?
|
|
4555
|
+
WHERE project_root = ? AND merge_id = ? AND state = ?
|
|
4556
|
+
`).run(
|
|
4557
|
+
target,
|
|
4558
|
+
update.parentRevision ?? null,
|
|
4559
|
+
update.simulationHash ?? null,
|
|
4560
|
+
update.mergeCommit ?? null,
|
|
4561
|
+
update.reason?.trim().slice(0, 1_000) ?? null,
|
|
4562
|
+
now,
|
|
4563
|
+
terminal ? now : null,
|
|
4564
|
+
projectRoot,
|
|
4565
|
+
mergeId,
|
|
4566
|
+
existing.state,
|
|
4567
|
+
);
|
|
4568
|
+
if (result.changes !== 1) throw new Error(`WORKFLOW_MERGE_UPDATE_CONFLICT:${mergeId}`);
|
|
4569
|
+
return this.getWorkflowMerge(projectRoot, mergeId)!;
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
private appendWorkflowWorkerEvent(
|
|
4573
|
+
record: WorkflowWorkerRecord,
|
|
4574
|
+
fromState: WorkflowWorkerRecord['state'] | undefined,
|
|
4575
|
+
toState: WorkflowWorkerRecord['state'],
|
|
4576
|
+
reason: string | undefined,
|
|
4577
|
+
payload: Record<string, unknown>,
|
|
4578
|
+
createdAt: number,
|
|
4579
|
+
): void {
|
|
4580
|
+
this.db.prepare(`
|
|
4581
|
+
INSERT INTO devflow_worker_events (
|
|
4582
|
+
event_id, worker_id, project_root, from_state, to_state, reason, payload_json, created_at
|
|
4583
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4584
|
+
`).run(
|
|
4585
|
+
randomUUID(), record.workerId, record.projectRoot, fromState ?? null, toState,
|
|
4586
|
+
reason ?? null, JSON.stringify(payload), createdAt,
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
|
|
3742
4590
|
// Convenience methods for raw SQL queries (used by AutoChecker)
|
|
3743
4591
|
all(sql: string, ...params: unknown[]): unknown[] {
|
|
3744
4592
|
return this.db.prepare(sql).all(...params);
|