@devflow-tools/database 0.16.22 → 0.16.24

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.
@@ -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.22",
3
+ "version": "0.16.24",
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": "964af33767bf232606e63d973b3a29d7d53c8119"
16
+ "gitHead": "b21cfa5fadfa43922e724b49156645504b88be34"
17
17
  }
package/src/database.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NodeSqliteDatabase } from './node-sqlite';
2
- import { join, dirname } from 'path';
3
- import { existsSync, mkdirSync } from 'fs';
2
+ import { join, dirname, resolve } from 'path';
3
+ import { existsSync, mkdirSync, realpathSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import type {
@@ -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,338 @@ export class DevFlowDatabase {
4166
4268
  return false;
4167
4269
  }
4168
4270
 
4271
+ // ---- Isolated Workflow Workers ----
4272
+
4273
+ createWorkflowWorker(input: CreateWorkflowWorkerInput): WorkflowWorkerRecord {
4274
+ const projectRoot = canonicalWorkflowProjectRoot(input.projectRoot);
4275
+ for (const [name, value] of [
4276
+ ['run ID', input.runId],
4277
+ ['step ID', input.stepId],
4278
+ ['project root', input.projectRoot],
4279
+ ['branch', input.branch],
4280
+ ['worktree path', input.worktreePath],
4281
+ ['host', input.host],
4282
+ ['context receipt', input.contextReceipt],
4283
+ ['base revision', input.baseRevision],
4284
+ ] as Array<[string, string]>) {
4285
+ if (!value.trim()) throw new Error(`Workflow worker requires a ${name}`);
4286
+ }
4287
+ const workerId = input.workerId ?? randomUUID();
4288
+ const now = Date.now();
4289
+ return this.withImmediateTransaction(() => {
4290
+ this.db.prepare(`
4291
+ INSERT INTO devflow_workflow_workers (
4292
+ worker_id, run_id, step_id, project_root, branch, worktree_path, host,
4293
+ state, context_receipt, retrieval_session_id, session_id, execution_id,
4294
+ base_revision, allowed_files, handoff_json, created_at, updated_at
4295
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?, ?, ?, ?, ?, ?, ?, ?)
4296
+ ON CONFLICT DO NOTHING
4297
+ `).run(
4298
+ workerId,
4299
+ input.runId,
4300
+ input.stepId,
4301
+ projectRoot,
4302
+ input.branch,
4303
+ input.worktreePath,
4304
+ input.host,
4305
+ input.contextReceipt,
4306
+ input.retrievalSessionId ?? null,
4307
+ input.sessionId ?? null,
4308
+ input.executionId ?? null,
4309
+ input.baseRevision,
4310
+ JSON.stringify([...new Set(input.allowedFiles ?? [])].sort()),
4311
+ JSON.stringify(input.handoff ?? {}),
4312
+ now,
4313
+ now,
4314
+ );
4315
+ const rows = this.db.prepare(`
4316
+ SELECT * FROM devflow_workflow_workers
4317
+ WHERE worker_id = ? OR (run_id = ? AND step_id = ?)
4318
+ `).all(workerId, input.runId, input.stepId) as any[];
4319
+ if (rows.length !== 1) throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
4320
+ const record = mapWorkflowWorkerRow(rows[0]);
4321
+ if (record.workerId !== workerId
4322
+ || record.projectRoot !== projectRoot
4323
+ || record.worktreePath !== input.worktreePath
4324
+ || record.contextReceipt !== input.contextReceipt) {
4325
+ throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
4326
+ }
4327
+ if (record.createdAt === now) this.appendWorkflowWorkerEvent(record, undefined, 'planned', undefined, {}, now);
4328
+ return record;
4329
+ });
4330
+ }
4331
+
4332
+ getWorkflowWorker(projectRoot: string, workerId: string): WorkflowWorkerRecord | null {
4333
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4334
+ const row = this.db.prepare(`
4335
+ SELECT * FROM devflow_workflow_workers WHERE project_root = ? AND worker_id = ?
4336
+ `).get(projectRoot, workerId) as any;
4337
+ return row ? mapWorkflowWorkerRow(row) : null;
4338
+ }
4339
+
4340
+ listWorkflowWorkers(projectRoot: string, options: {
4341
+ states?: WorkflowWorkerRecord['state'][];
4342
+ runId?: string;
4343
+ limit?: number;
4344
+ } = {}): WorkflowWorkerRecord[] {
4345
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4346
+ const conditions = ['project_root = ?'];
4347
+ const values: unknown[] = [projectRoot];
4348
+ if (options.runId) {
4349
+ conditions.push('run_id = ?');
4350
+ values.push(options.runId);
4351
+ }
4352
+ if (options.states?.length) {
4353
+ conditions.push(`state IN (${options.states.map(() => '?').join(',')})`);
4354
+ values.push(...options.states);
4355
+ }
4356
+ values.push(Math.max(1, Math.min(options.limit ?? 200, 2_000)));
4357
+ const rows = this.db.prepare(`
4358
+ SELECT * FROM devflow_workflow_workers
4359
+ WHERE ${conditions.join(' AND ')}
4360
+ ORDER BY updated_at DESC, worker_id ASC LIMIT ?
4361
+ `).all(...values) as any[];
4362
+ return rows.map(mapWorkflowWorkerRow);
4363
+ }
4364
+
4365
+ listWorkflowWorkerEvents(projectRoot: string, workerId: string): WorkflowWorkerEventRecord[] {
4366
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4367
+ return (this.db.prepare(`
4368
+ SELECT * FROM devflow_worker_events
4369
+ WHERE project_root = ? AND worker_id = ?
4370
+ ORDER BY created_at ASC, event_id ASC
4371
+ `).all(projectRoot, workerId) as any[]).map(mapWorkflowWorkerEventRow);
4372
+ }
4373
+
4374
+ leaseWorkflowWorker(
4375
+ projectRoot: string,
4376
+ workerId: string,
4377
+ owner: string,
4378
+ leaseMs: number,
4379
+ now = Date.now(),
4380
+ ): WorkflowWorkerRecord {
4381
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4382
+ if (!owner.trim()) throw new Error('Workflow worker lease owner is required');
4383
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) throw new Error('Workflow worker lease must be positive');
4384
+ return this.transitionWorkflowWorker({
4385
+ projectRoot,
4386
+ workerId,
4387
+ target: 'leased',
4388
+ leaseOwner: owner,
4389
+ leaseExpiresAt: now + leaseMs,
4390
+ heartbeatAt: now,
4391
+ now,
4392
+ });
4393
+ }
4394
+
4395
+ heartbeatWorkflowWorker(
4396
+ projectRoot: string,
4397
+ workerId: string,
4398
+ owner: string,
4399
+ leaseMs: number,
4400
+ now = Date.now(),
4401
+ ): WorkflowWorkerRecord {
4402
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4403
+ const result = this.db.prepare(`
4404
+ UPDATE devflow_workflow_workers
4405
+ SET heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
4406
+ WHERE project_root = ? AND worker_id = ? AND lease_owner = ?
4407
+ AND state IN ('leased','starting','running','reported','verifying','merge_ready')
4408
+ `).run(now, now + leaseMs, now, projectRoot, workerId, owner);
4409
+ if (result.changes !== 1) throw new Error(`WORKFLOW_WORKER_HEARTBEAT_REJECTED:${workerId}`);
4410
+ return this.getWorkflowWorker(projectRoot, workerId)!;
4411
+ }
4412
+
4413
+ transitionWorkflowWorker(input: TransitionWorkflowWorkerInput): WorkflowWorkerRecord {
4414
+ const projectRoot = canonicalWorkflowProjectRoot(input.projectRoot);
4415
+ return this.withImmediateTransaction(() => {
4416
+ const existing = this.getWorkflowWorker(projectRoot, input.workerId);
4417
+ if (!existing) throw new Error(`WORKFLOW_WORKER_NOT_FOUND:${input.workerId}`);
4418
+ if (TERMINAL_WORKFLOW_WORKER_STATES.has(existing.state)) {
4419
+ if (existing.state === input.target) return existing;
4420
+ throw new Error(`WORKFLOW_WORKER_TERMINAL_CONFLICT:${input.workerId}`);
4421
+ }
4422
+ if (!LEGAL_WORKFLOW_WORKER_TRANSITIONS[existing.state].includes(input.target)) {
4423
+ throw new Error(`WORKFLOW_WORKER_INVALID_TRANSITION:${existing.state}->${input.target}`);
4424
+ }
4425
+ if (existing.leaseOwner && input.leaseOwner && existing.leaseOwner !== input.leaseOwner) {
4426
+ throw new Error(`WORKFLOW_WORKER_LEASE_CONFLICT:${input.workerId}`);
4427
+ }
4428
+ const now = input.now ?? Date.now();
4429
+ const terminal = TERMINAL_WORKFLOW_WORKER_STATES.has(input.target);
4430
+ const clearLease = terminal || input.target === 'hold';
4431
+ const reason = input.reason?.trim().slice(0, 1_000);
4432
+ const result = this.db.prepare(`
4433
+ UPDATE devflow_workflow_workers SET
4434
+ state = ?,
4435
+ lease_owner = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_owner) END,
4436
+ lease_expires_at = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_expires_at) END,
4437
+ heartbeat_at = COALESCE(?, heartbeat_at),
4438
+ final_revision = COALESCE(?, final_revision),
4439
+ handoff_json = CASE WHEN ? IS NULL THEN handoff_json ELSE ? END,
4440
+ hold_reason = CASE WHEN ? = 'hold' THEN ? ELSE hold_reason END,
4441
+ failure_reason = CASE WHEN ? = 'failed' THEN ? ELSE failure_reason END,
4442
+ updated_at = ?, finished_at = ?
4443
+ WHERE project_root = ? AND worker_id = ? AND state = ?
4444
+ `).run(
4445
+ input.target,
4446
+ clearLease ? 1 : 0,
4447
+ input.leaseOwner ?? null,
4448
+ clearLease ? 1 : 0,
4449
+ input.leaseExpiresAt ?? null,
4450
+ input.heartbeatAt ?? null,
4451
+ input.finalRevision ?? null,
4452
+ input.handoff === undefined ? null : 1,
4453
+ JSON.stringify(input.handoff ?? {}),
4454
+ input.target,
4455
+ reason ?? null,
4456
+ input.target,
4457
+ reason ?? null,
4458
+ now,
4459
+ terminal ? now : null,
4460
+ projectRoot,
4461
+ input.workerId,
4462
+ existing.state,
4463
+ );
4464
+ if (result.changes !== 1) throw new Error(`WORKFLOW_WORKER_UPDATE_CONFLICT:${input.workerId}`);
4465
+ const updated = this.getWorkflowWorker(projectRoot, input.workerId)!;
4466
+ this.appendWorkflowWorkerEvent(updated, existing.state, input.target, reason, input.handoff ?? {}, now);
4467
+ return updated;
4468
+ });
4469
+ }
4470
+
4471
+ recoverExpiredWorkflowWorkers(projectRoot: string, now = Date.now()): WorkflowWorkerRecord[] {
4472
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4473
+ const expired = this.listWorkflowWorkers(projectRoot, {
4474
+ states: ['leased', 'starting', 'running', 'reported', 'verifying', 'merge_ready'],
4475
+ limit: 2_000,
4476
+ }).filter(worker => worker.leaseExpiresAt !== undefined && worker.leaseExpiresAt <= now);
4477
+ return expired.map(worker => this.transitionWorkflowWorker({
4478
+ projectRoot,
4479
+ workerId: worker.workerId,
4480
+ target: 'hold',
4481
+ leaseOwner: worker.leaseOwner,
4482
+ reason: 'lease_expired_dirty_worktree_requires_inspection',
4483
+ now,
4484
+ }));
4485
+ }
4486
+
4487
+ enqueueWorkflowMerge(input: EnqueueWorkflowMergeInput): WorkflowMergeRecord {
4488
+ const projectRoot = canonicalWorkflowProjectRoot(input.projectRoot);
4489
+ const now = Date.now();
4490
+ const mergeId = input.mergeId ?? randomUUID();
4491
+ this.db.prepare(`
4492
+ INSERT INTO devflow_merge_queue (
4493
+ merge_id, worker_id, project_root, state, base_revision, parent_revision,
4494
+ candidate_revision, grader_receipts, simulation_hash, created_at, updated_at
4495
+ ) VALUES (?, ?, ?, 'waiting', ?, ?, ?, ?, ?, ?, ?)
4496
+ ON CONFLICT(worker_id) DO NOTHING
4497
+ `).run(
4498
+ mergeId,
4499
+ input.workerId,
4500
+ projectRoot,
4501
+ input.baseRevision,
4502
+ input.parentRevision,
4503
+ input.candidateRevision,
4504
+ JSON.stringify([...new Set(input.graderReceipts ?? [])].sort()),
4505
+ input.simulationHash ?? null,
4506
+ now,
4507
+ now,
4508
+ );
4509
+ const record = this.getWorkflowMergeByWorker(projectRoot, input.workerId);
4510
+ if (!record
4511
+ || record.baseRevision !== input.baseRevision
4512
+ || record.candidateRevision !== input.candidateRevision) {
4513
+ throw new Error(`WORKFLOW_MERGE_IDENTITY_CONFLICT:${input.workerId}`);
4514
+ }
4515
+ return record;
4516
+ }
4517
+
4518
+ getWorkflowMerge(projectRoot: string, mergeId: string): WorkflowMergeRecord | null {
4519
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4520
+ const row = this.db.prepare(`
4521
+ SELECT * FROM devflow_merge_queue WHERE project_root = ? AND merge_id = ?
4522
+ `).get(projectRoot, mergeId) as any;
4523
+ return row ? mapWorkflowMergeRow(row) : null;
4524
+ }
4525
+
4526
+ getWorkflowMergeByWorker(projectRoot: string, workerId: string): WorkflowMergeRecord | null {
4527
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4528
+ const row = this.db.prepare(`
4529
+ SELECT * FROM devflow_merge_queue WHERE project_root = ? AND worker_id = ?
4530
+ `).get(projectRoot, workerId) as any;
4531
+ return row ? mapWorkflowMergeRow(row) : null;
4532
+ }
4533
+
4534
+ listWorkflowMerges(projectRoot: string, limit = 200): WorkflowMergeRecord[] {
4535
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4536
+ return (this.db.prepare(`
4537
+ SELECT * FROM devflow_merge_queue WHERE project_root = ?
4538
+ ORDER BY created_at ASC, merge_id ASC LIMIT ?
4539
+ `).all(projectRoot, Math.max(1, Math.min(limit, 2_000))) as any[]).map(mapWorkflowMergeRow);
4540
+ }
4541
+
4542
+ transitionWorkflowMerge(
4543
+ projectRoot: string,
4544
+ mergeId: string,
4545
+ target: WorkflowMergeState,
4546
+ update: { parentRevision?: string; simulationHash?: string; mergeCommit?: string; reason?: string } = {},
4547
+ now = Date.now(),
4548
+ ): WorkflowMergeRecord {
4549
+ projectRoot = canonicalWorkflowProjectRoot(projectRoot);
4550
+ const existing = this.getWorkflowMerge(projectRoot, mergeId);
4551
+ if (!existing) throw new Error(`WORKFLOW_MERGE_NOT_FOUND:${mergeId}`);
4552
+ const legal: Record<WorkflowMergeState, WorkflowMergeState[]> = {
4553
+ waiting: ['ready', 'conflict', 'rejected'],
4554
+ ready: ['conflict', 'merged', 'rejected'],
4555
+ conflict: ['waiting', 'rejected'],
4556
+ merged: [],
4557
+ rejected: [],
4558
+ };
4559
+ if (!legal[existing.state].includes(target)) {
4560
+ if (existing.state === target) return existing;
4561
+ throw new Error(`WORKFLOW_MERGE_INVALID_TRANSITION:${existing.state}->${target}`);
4562
+ }
4563
+ const terminal = target === 'merged' || target === 'rejected';
4564
+ const result = this.db.prepare(`
4565
+ UPDATE devflow_merge_queue SET state = ?, parent_revision = COALESCE(?, parent_revision),
4566
+ simulation_hash = COALESCE(?, simulation_hash), merge_commit = COALESCE(?, merge_commit),
4567
+ reason = COALESCE(?, reason), updated_at = ?, finished_at = ?
4568
+ WHERE project_root = ? AND merge_id = ? AND state = ?
4569
+ `).run(
4570
+ target,
4571
+ update.parentRevision ?? null,
4572
+ update.simulationHash ?? null,
4573
+ update.mergeCommit ?? null,
4574
+ update.reason?.trim().slice(0, 1_000) ?? null,
4575
+ now,
4576
+ terminal ? now : null,
4577
+ projectRoot,
4578
+ mergeId,
4579
+ existing.state,
4580
+ );
4581
+ if (result.changes !== 1) throw new Error(`WORKFLOW_MERGE_UPDATE_CONFLICT:${mergeId}`);
4582
+ return this.getWorkflowMerge(projectRoot, mergeId)!;
4583
+ }
4584
+
4585
+ private appendWorkflowWorkerEvent(
4586
+ record: WorkflowWorkerRecord,
4587
+ fromState: WorkflowWorkerRecord['state'] | undefined,
4588
+ toState: WorkflowWorkerRecord['state'],
4589
+ reason: string | undefined,
4590
+ payload: Record<string, unknown>,
4591
+ createdAt: number,
4592
+ ): void {
4593
+ this.db.prepare(`
4594
+ INSERT INTO devflow_worker_events (
4595
+ event_id, worker_id, project_root, from_state, to_state, reason, payload_json, created_at
4596
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4597
+ `).run(
4598
+ randomUUID(), record.workerId, record.projectRoot, fromState ?? null, toState,
4599
+ reason ?? null, JSON.stringify(payload), createdAt,
4600
+ );
4601
+ }
4602
+
4169
4603
  // Convenience methods for raw SQL queries (used by AutoChecker)
4170
4604
  all(sql: string, ...params: unknown[]): unknown[] {
4171
4605
  return this.db.prepare(sql).all(...params);
@@ -4293,3 +4727,8 @@ function mergeExecutionMetadata(
4293
4727
  }
4294
4728
  return merged;
4295
4729
  }
4730
+
4731
+ function canonicalWorkflowProjectRoot(projectRoot: string): string {
4732
+ const resolved = resolve(projectRoot);
4733
+ try { return realpathSync(resolved); } catch { return resolved; }
4734
+ }
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';