@devflow-tools/database 0.16.22 → 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 +12 -0
- package/__tests__/database.workflow-workers.test.ts +60 -0
- package/dist/database.d.ts +25 -0
- package/dist/database.js +315 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- 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 +421 -0
- package/src/index.ts +17 -0
- package/src/work-queue.ts +5 -0
- package/src/workflow-workers.ts +198 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEGAL_WORKFLOW_WORKER_TRANSITIONS = exports.TERMINAL_WORKFLOW_WORKER_STATES = void 0;
|
|
4
|
+
exports.mapWorkflowWorkerRow = mapWorkflowWorkerRow;
|
|
5
|
+
exports.mapWorkflowWorkerEventRow = mapWorkflowWorkerEventRow;
|
|
6
|
+
exports.mapWorkflowMergeRow = mapWorkflowMergeRow;
|
|
7
|
+
exports.TERMINAL_WORKFLOW_WORKER_STATES = new Set([
|
|
8
|
+
'merged', 'failed', 'cancelled',
|
|
9
|
+
]);
|
|
10
|
+
exports.LEGAL_WORKFLOW_WORKER_TRANSITIONS = {
|
|
11
|
+
planned: ['leased', 'cancelled', 'hold', 'failed'],
|
|
12
|
+
leased: ['starting', 'hold', 'cancelled', 'failed'],
|
|
13
|
+
starting: ['running', 'reported', 'hold', 'cancelled', 'failed'],
|
|
14
|
+
running: ['reported', 'hold', 'cancelled', 'failed'],
|
|
15
|
+
reported: ['verifying', 'hold', 'cancelled', 'failed'],
|
|
16
|
+
verifying: ['merge_ready', 'hold', 'cancelled', 'failed'],
|
|
17
|
+
merge_ready: ['merged', 'hold', 'cancelled', 'failed'],
|
|
18
|
+
merged: [],
|
|
19
|
+
hold: ['leased', 'cancelled', 'failed'],
|
|
20
|
+
failed: [],
|
|
21
|
+
cancelled: [],
|
|
22
|
+
};
|
|
23
|
+
function mapWorkflowWorkerRow(row) {
|
|
24
|
+
return {
|
|
25
|
+
workerId: row.worker_id,
|
|
26
|
+
runId: row.run_id,
|
|
27
|
+
stepId: row.step_id,
|
|
28
|
+
projectRoot: row.project_root,
|
|
29
|
+
branch: row.branch,
|
|
30
|
+
worktreePath: row.worktree_path,
|
|
31
|
+
host: row.host,
|
|
32
|
+
state: row.state,
|
|
33
|
+
leaseOwner: row.lease_owner ?? undefined,
|
|
34
|
+
leaseExpiresAt: row.lease_expires_at ?? undefined,
|
|
35
|
+
heartbeatAt: row.heartbeat_at ?? undefined,
|
|
36
|
+
contextReceipt: row.context_receipt,
|
|
37
|
+
retrievalSessionId: row.retrieval_session_id ?? undefined,
|
|
38
|
+
sessionId: row.session_id ?? undefined,
|
|
39
|
+
executionId: row.execution_id ?? undefined,
|
|
40
|
+
baseRevision: row.base_revision,
|
|
41
|
+
finalRevision: row.final_revision ?? undefined,
|
|
42
|
+
allowedFiles: parseJson(row.allowed_files, []),
|
|
43
|
+
handoff: parseJson(row.handoff_json, {}),
|
|
44
|
+
holdReason: row.hold_reason ?? undefined,
|
|
45
|
+
failureReason: row.failure_reason ?? undefined,
|
|
46
|
+
createdAt: row.created_at,
|
|
47
|
+
updatedAt: row.updated_at,
|
|
48
|
+
finishedAt: row.finished_at ?? undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function mapWorkflowWorkerEventRow(row) {
|
|
52
|
+
return {
|
|
53
|
+
eventId: row.event_id,
|
|
54
|
+
workerId: row.worker_id,
|
|
55
|
+
projectRoot: row.project_root,
|
|
56
|
+
fromState: row.from_state ?? undefined,
|
|
57
|
+
toState: row.to_state,
|
|
58
|
+
reason: row.reason ?? undefined,
|
|
59
|
+
payload: parseJson(row.payload_json, {}),
|
|
60
|
+
createdAt: row.created_at,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function mapWorkflowMergeRow(row) {
|
|
64
|
+
return {
|
|
65
|
+
mergeId: row.merge_id,
|
|
66
|
+
workerId: row.worker_id,
|
|
67
|
+
projectRoot: row.project_root,
|
|
68
|
+
state: row.state,
|
|
69
|
+
baseRevision: row.base_revision,
|
|
70
|
+
parentRevision: row.parent_revision,
|
|
71
|
+
candidateRevision: row.candidate_revision,
|
|
72
|
+
graderReceipts: parseJson(row.grader_receipts, []),
|
|
73
|
+
simulationHash: row.simulation_hash ?? undefined,
|
|
74
|
+
mergeCommit: row.merge_commit ?? undefined,
|
|
75
|
+
reason: row.reason ?? undefined,
|
|
76
|
+
createdAt: row.created_at,
|
|
77
|
+
updatedAt: row.updated_at,
|
|
78
|
+
finishedAt: row.finished_at ?? undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function parseJson(value, fallback) {
|
|
82
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
83
|
+
return fallback;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(value);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devflow-tools/database",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.23",
|
|
4
4
|
"description": "DevFlow SQLite database package",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -13,5 +13,5 @@
|
|
|
13
13
|
"typescript": "^5.5.0",
|
|
14
14
|
"vitest": "^2.0.0"
|
|
15
15
|
},
|
|
16
|
-
"gitHead": "
|
|
16
|
+
"gitHead": "9cace27e1c380cc2bd73a57fa2709f04e197f25b"
|
|
17
17
|
}
|
package/src/database.ts
CHANGED
|
@@ -55,6 +55,20 @@ import {
|
|
|
55
55
|
type TransitionLearningCandidateInput,
|
|
56
56
|
type UpsertLearningCandidateInput,
|
|
57
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';
|
|
58
72
|
|
|
59
73
|
export interface BenchmarkReportRecord {
|
|
60
74
|
runId: string;
|
|
@@ -808,6 +822,85 @@ export class DevFlowDatabase {
|
|
|
808
822
|
BEGIN
|
|
809
823
|
SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
|
|
810
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);
|
|
811
904
|
`);
|
|
812
905
|
|
|
813
906
|
// Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
|
|
@@ -2926,6 +3019,15 @@ export class DevFlowDatabase {
|
|
|
2926
3019
|
return completed;
|
|
2927
3020
|
}
|
|
2928
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
|
+
|
|
2929
3031
|
retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean {
|
|
2930
3032
|
const updatedAt = Date.now();
|
|
2931
3033
|
return this.db.prepare(`
|
|
@@ -4166,6 +4268,325 @@ export class DevFlowDatabase {
|
|
|
4166
4268
|
return false;
|
|
4167
4269
|
}
|
|
4168
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
|
+
|
|
4169
4590
|
// Convenience methods for raw SQL queries (used by AutoChecker)
|
|
4170
4591
|
all(sql: string, ...params: unknown[]): unknown[] {
|
|
4171
4592
|
return this.db.prepare(sql).all(...params);
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,23 @@ export {
|
|
|
59
59
|
mapSessionObligationRow,
|
|
60
60
|
normalizeTurnId,
|
|
61
61
|
} from './obligation-ledger';
|
|
62
|
+
export {
|
|
63
|
+
LEGAL_WORKFLOW_WORKER_TRANSITIONS,
|
|
64
|
+
TERMINAL_WORKFLOW_WORKER_STATES,
|
|
65
|
+
mapWorkflowMergeRow,
|
|
66
|
+
mapWorkflowWorkerEventRow,
|
|
67
|
+
mapWorkflowWorkerRow,
|
|
68
|
+
} from './workflow-workers';
|
|
69
|
+
export type {
|
|
70
|
+
CreateWorkflowWorkerInput,
|
|
71
|
+
EnqueueWorkflowMergeInput,
|
|
72
|
+
TransitionWorkflowWorkerInput,
|
|
73
|
+
WorkflowMergeRecord,
|
|
74
|
+
WorkflowMergeState,
|
|
75
|
+
WorkflowWorkerEventRecord,
|
|
76
|
+
WorkflowWorkerRecord,
|
|
77
|
+
WorkflowWorkerState,
|
|
78
|
+
} from './workflow-workers';
|
|
62
79
|
export {
|
|
63
80
|
mapLearningCandidateRow,
|
|
64
81
|
mapLearningEvidenceRow,
|
package/src/work-queue.ts
CHANGED
|
@@ -6,6 +6,11 @@ export type WorkKind =
|
|
|
6
6
|
| 'memory.vector_backfill'
|
|
7
7
|
| 'context.prefetch'
|
|
8
8
|
| 'learning.analyze_session'
|
|
9
|
+
| 'workflow.worker_start'
|
|
10
|
+
| 'workflow.worker_monitor'
|
|
11
|
+
| 'workflow.worker_verify'
|
|
12
|
+
| 'workflow.worker_merge'
|
|
13
|
+
| 'workflow.worker_cleanup'
|
|
9
14
|
| 'session.bootstrap'
|
|
10
15
|
| 'session.finalize'
|
|
11
16
|
| 'telemetry.reconcile';
|