@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 CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.16.23](https://github.com/shilongfeicool/dev-flow/compare/v0.16.22...v0.16.23) (2026-07-28)
7
+
8
+
9
+ ### Features
10
+
11
+ * **database:** persist workflow worker lifecycle ([6f57fc7](https://github.com/shilongfeicool/dev-flow/commit/6f57fc7d7f13915b4f9b7faaa5e37c1a04182cad))
12
+ * **workflow:** execute durable worker lifecycle ([799cc9d](https://github.com/shilongfeicool/dev-flow/commit/799cc9d341b6d4251717e20665654809ed84e29d))
13
+
14
+
15
+
16
+
17
+
6
18
  ## [0.16.22](https://github.com/shilongfeicool/dev-flow/compare/v0.16.21...v0.16.22) (2026-07-28)
7
19
 
8
20
 
@@ -0,0 +1,60 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { DevFlowDatabase } from '../src/database.js';
6
+
7
+ const roots: string[] = [];
8
+
9
+ function open(): { db: DevFlowDatabase; root: string } {
10
+ const root = mkdtempSync(join(tmpdir(), 'devflow-workers-'));
11
+ roots.push(root);
12
+ return { db: new DevFlowDatabase(root), root };
13
+ }
14
+
15
+ afterEach(() => {
16
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('workflow worker persistence', () => {
20
+ it('persists legal transitions and refuses terminal mutation', () => {
21
+ const { db, root } = open();
22
+ const worker = db.createWorkflowWorker({
23
+ runId: 'run-1', stepId: 'step-1', projectRoot: root,
24
+ branch: 'devflow/run-1/step-1', worktreePath: join(root, '.worker'),
25
+ host: 'codex', contextReceipt: 'receipt-1', baseRevision: 'a'.repeat(40),
26
+ });
27
+ db.leaseWorkflowWorker(root, worker.workerId, 'owner', 1_000, 100);
28
+ db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'starting', leaseOwner: 'owner', now: 110 });
29
+ db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'running', leaseOwner: 'owner', now: 120 });
30
+ db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'failed', leaseOwner: 'owner', reason: 'test', now: 130 });
31
+ expect(() => db.transitionWorkflowWorker({ projectRoot: root, workerId: worker.workerId, target: 'hold' }))
32
+ .toThrow(/TERMINAL_CONFLICT/);
33
+ expect(db.listWorkflowWorkerEvents(root, worker.workerId)).toHaveLength(5);
34
+ db.close();
35
+ });
36
+
37
+ it('recovers expired leases to hold without retrying the worktree', () => {
38
+ const { db, root } = open();
39
+ const worker = db.createWorkflowWorker({
40
+ runId: 'run-2', stepId: 'step-2', projectRoot: root,
41
+ branch: 'devflow/run-2/step-2', worktreePath: join(root, '.worker-2'),
42
+ host: 'claude-code', contextReceipt: 'receipt-2', baseRevision: 'b'.repeat(40),
43
+ });
44
+ db.leaseWorkflowWorker(root, worker.workerId, 'owner', 10, 100);
45
+ expect(db.recoverExpiredWorkflowWorkers(root, 111)[0]?.state).toBe('hold');
46
+ db.close();
47
+ });
48
+
49
+ it('keeps merge queue insertion idempotent', () => {
50
+ const { db, root } = open();
51
+ const input = {
52
+ workerId: 'worker-3', projectRoot: root, baseRevision: 'a', parentRevision: 'b',
53
+ candidateRevision: 'c', graderReceipts: ['grader:1'], simulationHash: 'sha256:1',
54
+ };
55
+ const first = db.enqueueWorkflowMerge(input);
56
+ expect(db.enqueueWorkflowMerge(input).mergeId).toBe(first.mergeId);
57
+ expect(db.transitionWorkflowMerge(root, first.mergeId, 'ready').state).toBe('ready');
58
+ db.close();
59
+ });
60
+ });
@@ -3,6 +3,7 @@ import { type SessionObligationRecord, type SessionObligationState } from './obl
3
3
  import { type FailHostActionInput, type HostActionRecord, type ReportHostActionInput, type RequestHostActionInput, type StartHostActionInput, type VerifyHostActionInput } from './host-actions';
4
4
  import { type AppendRetrievalCycleInput, type CreateRetrievalSessionInput, type RetrievalCycleRecord, type RetrievalSessionRecord, type RetrievalSessionState } from './retrieval-sessions';
5
5
  import { type AddLearningCandidateEvidenceInput, type LearningCandidateEvidenceRecord, type LearningCandidateRecord, type LearningCandidateState, type LearningCandidateVersionRecord, type TransitionLearningCandidateInput, type UpsertLearningCandidateInput } from './learning-candidates';
6
+ import { type CreateWorkflowWorkerInput, type EnqueueWorkflowMergeInput, type TransitionWorkflowWorkerInput, type WorkflowMergeRecord, type WorkflowMergeState, type WorkflowWorkerEventRecord, type WorkflowWorkerRecord } from './workflow-workers';
6
7
  export interface BenchmarkReportRecord {
7
8
  runId: string;
8
9
  suiteId: string;
@@ -350,6 +351,7 @@ export declare class DevFlowDatabase {
350
351
  listSessionClosures(projectRoot: string, limit?: number): SessionClosureRecord[];
351
352
  leaseWork(input: LeaseWorkInput): WorkItemRecord[];
352
353
  completeWork(id: string, owner: string, now?: number): boolean;
354
+ heartbeatWork(id: string, owner: string, leaseMs: number, now?: number): boolean;
353
355
  retryWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean;
354
356
  deferWork(id: string, owner: string, error: WorkError, nextAttemptAt: number): boolean;
355
357
  deadLetterWork(id: string, owner: string, error: WorkError): boolean;
@@ -448,6 +450,29 @@ export declare class DevFlowDatabase {
448
450
  private withImmediateTransaction;
449
451
  private isTerminalHostActionState;
450
452
  private isLegalHostActionTransition;
453
+ createWorkflowWorker(input: CreateWorkflowWorkerInput): WorkflowWorkerRecord;
454
+ getWorkflowWorker(projectRoot: string, workerId: string): WorkflowWorkerRecord | null;
455
+ listWorkflowWorkers(projectRoot: string, options?: {
456
+ states?: WorkflowWorkerRecord['state'][];
457
+ runId?: string;
458
+ limit?: number;
459
+ }): WorkflowWorkerRecord[];
460
+ listWorkflowWorkerEvents(projectRoot: string, workerId: string): WorkflowWorkerEventRecord[];
461
+ leaseWorkflowWorker(projectRoot: string, workerId: string, owner: string, leaseMs: number, now?: number): WorkflowWorkerRecord;
462
+ heartbeatWorkflowWorker(projectRoot: string, workerId: string, owner: string, leaseMs: number, now?: number): WorkflowWorkerRecord;
463
+ transitionWorkflowWorker(input: TransitionWorkflowWorkerInput): WorkflowWorkerRecord;
464
+ recoverExpiredWorkflowWorkers(projectRoot: string, now?: number): WorkflowWorkerRecord[];
465
+ enqueueWorkflowMerge(input: EnqueueWorkflowMergeInput): WorkflowMergeRecord;
466
+ getWorkflowMerge(projectRoot: string, mergeId: string): WorkflowMergeRecord | null;
467
+ getWorkflowMergeByWorker(projectRoot: string, workerId: string): WorkflowMergeRecord | null;
468
+ listWorkflowMerges(projectRoot: string, limit?: number): WorkflowMergeRecord[];
469
+ transitionWorkflowMerge(projectRoot: string, mergeId: string, target: WorkflowMergeState, update?: {
470
+ parentRevision?: string;
471
+ simulationHash?: string;
472
+ mergeCommit?: string;
473
+ reason?: string;
474
+ }, now?: number): WorkflowMergeRecord;
475
+ private appendWorkflowWorkerEvent;
451
476
  all(sql: string, ...params: unknown[]): unknown[];
452
477
  get(sql: string, ...params: unknown[]): unknown;
453
478
  close(): void;
package/dist/database.js CHANGED
@@ -12,6 +12,7 @@ const obligation_ledger_1 = require("./obligation-ledger");
12
12
  const host_actions_1 = require("./host-actions");
13
13
  const retrieval_sessions_1 = require("./retrieval-sessions");
14
14
  const learning_candidates_1 = require("./learning-candidates");
15
+ const workflow_workers_1 = require("./workflow-workers");
15
16
  const CONTEXT_REQUIRED_SKILLS = new Set([
16
17
  'react', 'vue', 'nest', 'nextjs', 'graphql', 'typescript',
17
18
  ]);
@@ -618,6 +619,85 @@ class DevFlowDatabase {
618
619
  BEGIN
619
620
  SELECT RAISE(ABORT, 'devflow_host_action_events is append-only');
620
621
  END;
622
+
623
+ CREATE TABLE IF NOT EXISTS devflow_workflow_workers (
624
+ worker_id TEXT PRIMARY KEY,
625
+ run_id TEXT NOT NULL,
626
+ step_id TEXT NOT NULL,
627
+ project_root TEXT NOT NULL,
628
+ branch TEXT NOT NULL,
629
+ worktree_path TEXT NOT NULL,
630
+ host TEXT NOT NULL,
631
+ state TEXT NOT NULL DEFAULT 'planned'
632
+ CHECK(state IN ('planned','leased','starting','running','reported','verifying','merge_ready','merged','hold','failed','cancelled')),
633
+ lease_owner TEXT,
634
+ lease_expires_at INTEGER,
635
+ heartbeat_at INTEGER,
636
+ context_receipt TEXT NOT NULL,
637
+ retrieval_session_id TEXT,
638
+ session_id TEXT,
639
+ execution_id TEXT,
640
+ base_revision TEXT NOT NULL,
641
+ final_revision TEXT,
642
+ allowed_files TEXT NOT NULL DEFAULT '[]',
643
+ handoff_json TEXT NOT NULL DEFAULT '{}',
644
+ hold_reason TEXT,
645
+ failure_reason TEXT,
646
+ created_at INTEGER NOT NULL,
647
+ updated_at INTEGER NOT NULL,
648
+ finished_at INTEGER,
649
+ UNIQUE(run_id, step_id),
650
+ UNIQUE(worktree_path),
651
+ CHECK(
652
+ (state IN ('merged','failed','cancelled') AND finished_at IS NOT NULL)
653
+ OR (state NOT IN ('merged','failed','cancelled') AND finished_at IS NULL)
654
+ )
655
+ );
656
+ CREATE INDEX IF NOT EXISTS idx_workflow_workers_project
657
+ ON devflow_workflow_workers(project_root, state, updated_at DESC);
658
+ CREATE INDEX IF NOT EXISTS idx_workflow_workers_lease
659
+ ON devflow_workflow_workers(project_root, state, lease_expires_at);
660
+
661
+ CREATE TABLE IF NOT EXISTS devflow_worker_events (
662
+ event_id TEXT PRIMARY KEY,
663
+ worker_id TEXT NOT NULL,
664
+ project_root TEXT NOT NULL,
665
+ from_state TEXT,
666
+ to_state TEXT NOT NULL,
667
+ reason TEXT,
668
+ payload_json TEXT NOT NULL DEFAULT '{}',
669
+ created_at INTEGER NOT NULL
670
+ );
671
+ CREATE INDEX IF NOT EXISTS idx_worker_events_worker
672
+ ON devflow_worker_events(worker_id, created_at, event_id);
673
+ CREATE TRIGGER IF NOT EXISTS prevent_worker_event_update
674
+ BEFORE UPDATE ON devflow_worker_events BEGIN
675
+ SELECT RAISE(ABORT, 'devflow_worker_events is append-only');
676
+ END;
677
+ CREATE TRIGGER IF NOT EXISTS prevent_worker_event_delete
678
+ BEFORE DELETE ON devflow_worker_events BEGIN
679
+ SELECT RAISE(ABORT, 'devflow_worker_events is append-only');
680
+ END;
681
+
682
+ CREATE TABLE IF NOT EXISTS devflow_merge_queue (
683
+ merge_id TEXT PRIMARY KEY,
684
+ worker_id TEXT NOT NULL UNIQUE,
685
+ project_root TEXT NOT NULL,
686
+ state TEXT NOT NULL DEFAULT 'waiting'
687
+ CHECK(state IN ('waiting','ready','conflict','merged','rejected')),
688
+ base_revision TEXT NOT NULL,
689
+ parent_revision TEXT NOT NULL,
690
+ candidate_revision TEXT NOT NULL,
691
+ grader_receipts TEXT NOT NULL DEFAULT '[]',
692
+ simulation_hash TEXT,
693
+ merge_commit TEXT,
694
+ reason TEXT,
695
+ created_at INTEGER NOT NULL,
696
+ updated_at INTEGER NOT NULL,
697
+ finished_at INTEGER
698
+ );
699
+ CREATE INDEX IF NOT EXISTS idx_merge_queue_project
700
+ ON devflow_merge_queue(project_root, state, created_at, merge_id);
621
701
  `);
622
702
  // Migration: add session_id to skill_executions (SQLite compat — ignore if exists)
623
703
  try {
@@ -2340,6 +2420,15 @@ class DevFlowDatabase {
2340
2420
  }
2341
2421
  return completed;
2342
2422
  }
2423
+ heartbeatWork(id, owner, leaseMs, now = Date.now()) {
2424
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0)
2425
+ return false;
2426
+ return this.db.prepare(`
2427
+ UPDATE devflow_work_items
2428
+ SET lease_expires_at = ?, updated_at = ?
2429
+ WHERE id = ? AND state = 'leased' AND lease_owner = ?
2430
+ `).run(now + leaseMs, now, id, owner).changes === 1;
2431
+ }
2343
2432
  retryWork(id, owner, error, nextAttemptAt) {
2344
2433
  const updatedAt = Date.now();
2345
2434
  return this.db.prepare(`
@@ -3306,6 +3395,232 @@ class DevFlowDatabase {
3306
3395
  }
3307
3396
  return false;
3308
3397
  }
3398
+ // ---- Isolated Workflow Workers ----
3399
+ createWorkflowWorker(input) {
3400
+ for (const [name, value] of [
3401
+ ['run ID', input.runId],
3402
+ ['step ID', input.stepId],
3403
+ ['project root', input.projectRoot],
3404
+ ['branch', input.branch],
3405
+ ['worktree path', input.worktreePath],
3406
+ ['host', input.host],
3407
+ ['context receipt', input.contextReceipt],
3408
+ ['base revision', input.baseRevision],
3409
+ ]) {
3410
+ if (!value.trim())
3411
+ throw new Error(`Workflow worker requires a ${name}`);
3412
+ }
3413
+ const workerId = input.workerId ?? (0, crypto_1.randomUUID)();
3414
+ const now = Date.now();
3415
+ return this.withImmediateTransaction(() => {
3416
+ this.db.prepare(`
3417
+ INSERT INTO devflow_workflow_workers (
3418
+ worker_id, run_id, step_id, project_root, branch, worktree_path, host,
3419
+ state, context_receipt, retrieval_session_id, session_id, execution_id,
3420
+ base_revision, allowed_files, handoff_json, created_at, updated_at
3421
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'planned', ?, ?, ?, ?, ?, ?, ?, ?, ?)
3422
+ ON CONFLICT DO NOTHING
3423
+ `).run(workerId, input.runId, input.stepId, input.projectRoot, input.branch, input.worktreePath, input.host, input.contextReceipt, input.retrievalSessionId ?? null, input.sessionId ?? null, input.executionId ?? null, input.baseRevision, JSON.stringify([...new Set(input.allowedFiles ?? [])].sort()), JSON.stringify(input.handoff ?? {}), now, now);
3424
+ const rows = this.db.prepare(`
3425
+ SELECT * FROM devflow_workflow_workers
3426
+ WHERE worker_id = ? OR (run_id = ? AND step_id = ?)
3427
+ `).all(workerId, input.runId, input.stepId);
3428
+ if (rows.length !== 1)
3429
+ throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
3430
+ const record = (0, workflow_workers_1.mapWorkflowWorkerRow)(rows[0]);
3431
+ if (record.workerId !== workerId
3432
+ || record.projectRoot !== input.projectRoot
3433
+ || record.worktreePath !== input.worktreePath
3434
+ || record.contextReceipt !== input.contextReceipt) {
3435
+ throw new Error(`WORKFLOW_WORKER_IDENTITY_CONFLICT:${input.runId}:${input.stepId}`);
3436
+ }
3437
+ if (record.createdAt === now)
3438
+ this.appendWorkflowWorkerEvent(record, undefined, 'planned', undefined, {}, now);
3439
+ return record;
3440
+ });
3441
+ }
3442
+ getWorkflowWorker(projectRoot, workerId) {
3443
+ const row = this.db.prepare(`
3444
+ SELECT * FROM devflow_workflow_workers WHERE project_root = ? AND worker_id = ?
3445
+ `).get(projectRoot, workerId);
3446
+ return row ? (0, workflow_workers_1.mapWorkflowWorkerRow)(row) : null;
3447
+ }
3448
+ listWorkflowWorkers(projectRoot, options = {}) {
3449
+ const conditions = ['project_root = ?'];
3450
+ const values = [projectRoot];
3451
+ if (options.runId) {
3452
+ conditions.push('run_id = ?');
3453
+ values.push(options.runId);
3454
+ }
3455
+ if (options.states?.length) {
3456
+ conditions.push(`state IN (${options.states.map(() => '?').join(',')})`);
3457
+ values.push(...options.states);
3458
+ }
3459
+ values.push(Math.max(1, Math.min(options.limit ?? 200, 2000)));
3460
+ const rows = this.db.prepare(`
3461
+ SELECT * FROM devflow_workflow_workers
3462
+ WHERE ${conditions.join(' AND ')}
3463
+ ORDER BY updated_at DESC, worker_id ASC LIMIT ?
3464
+ `).all(...values);
3465
+ return rows.map(workflow_workers_1.mapWorkflowWorkerRow);
3466
+ }
3467
+ listWorkflowWorkerEvents(projectRoot, workerId) {
3468
+ return this.db.prepare(`
3469
+ SELECT * FROM devflow_worker_events
3470
+ WHERE project_root = ? AND worker_id = ?
3471
+ ORDER BY created_at ASC, event_id ASC
3472
+ `).all(projectRoot, workerId).map(workflow_workers_1.mapWorkflowWorkerEventRow);
3473
+ }
3474
+ leaseWorkflowWorker(projectRoot, workerId, owner, leaseMs, now = Date.now()) {
3475
+ if (!owner.trim())
3476
+ throw new Error('Workflow worker lease owner is required');
3477
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0)
3478
+ throw new Error('Workflow worker lease must be positive');
3479
+ return this.transitionWorkflowWorker({
3480
+ projectRoot,
3481
+ workerId,
3482
+ target: 'leased',
3483
+ leaseOwner: owner,
3484
+ leaseExpiresAt: now + leaseMs,
3485
+ heartbeatAt: now,
3486
+ now,
3487
+ });
3488
+ }
3489
+ heartbeatWorkflowWorker(projectRoot, workerId, owner, leaseMs, now = Date.now()) {
3490
+ const result = this.db.prepare(`
3491
+ UPDATE devflow_workflow_workers
3492
+ SET heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
3493
+ WHERE project_root = ? AND worker_id = ? AND lease_owner = ?
3494
+ AND state IN ('leased','starting','running','reported','verifying','merge_ready')
3495
+ `).run(now, now + leaseMs, now, projectRoot, workerId, owner);
3496
+ if (result.changes !== 1)
3497
+ throw new Error(`WORKFLOW_WORKER_HEARTBEAT_REJECTED:${workerId}`);
3498
+ return this.getWorkflowWorker(projectRoot, workerId);
3499
+ }
3500
+ transitionWorkflowWorker(input) {
3501
+ return this.withImmediateTransaction(() => {
3502
+ const existing = this.getWorkflowWorker(input.projectRoot, input.workerId);
3503
+ if (!existing)
3504
+ throw new Error(`WORKFLOW_WORKER_NOT_FOUND:${input.workerId}`);
3505
+ if (workflow_workers_1.TERMINAL_WORKFLOW_WORKER_STATES.has(existing.state)) {
3506
+ if (existing.state === input.target)
3507
+ return existing;
3508
+ throw new Error(`WORKFLOW_WORKER_TERMINAL_CONFLICT:${input.workerId}`);
3509
+ }
3510
+ if (!workflow_workers_1.LEGAL_WORKFLOW_WORKER_TRANSITIONS[existing.state].includes(input.target)) {
3511
+ throw new Error(`WORKFLOW_WORKER_INVALID_TRANSITION:${existing.state}->${input.target}`);
3512
+ }
3513
+ if (existing.leaseOwner && input.leaseOwner && existing.leaseOwner !== input.leaseOwner) {
3514
+ throw new Error(`WORKFLOW_WORKER_LEASE_CONFLICT:${input.workerId}`);
3515
+ }
3516
+ const now = input.now ?? Date.now();
3517
+ const terminal = workflow_workers_1.TERMINAL_WORKFLOW_WORKER_STATES.has(input.target);
3518
+ const clearLease = terminal || input.target === 'hold';
3519
+ const reason = input.reason?.trim().slice(0, 1000);
3520
+ const result = this.db.prepare(`
3521
+ UPDATE devflow_workflow_workers SET
3522
+ state = ?,
3523
+ lease_owner = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_owner) END,
3524
+ lease_expires_at = CASE WHEN ? THEN NULL ELSE COALESCE(?, lease_expires_at) END,
3525
+ heartbeat_at = COALESCE(?, heartbeat_at),
3526
+ final_revision = COALESCE(?, final_revision),
3527
+ handoff_json = CASE WHEN ? IS NULL THEN handoff_json ELSE ? END,
3528
+ hold_reason = CASE WHEN ? = 'hold' THEN ? ELSE hold_reason END,
3529
+ failure_reason = CASE WHEN ? = 'failed' THEN ? ELSE failure_reason END,
3530
+ updated_at = ?, finished_at = ?
3531
+ WHERE project_root = ? AND worker_id = ? AND state = ?
3532
+ `).run(input.target, clearLease ? 1 : 0, input.leaseOwner ?? null, clearLease ? 1 : 0, input.leaseExpiresAt ?? null, input.heartbeatAt ?? null, input.finalRevision ?? null, input.handoff === undefined ? null : 1, JSON.stringify(input.handoff ?? {}), input.target, reason ?? null, input.target, reason ?? null, now, terminal ? now : null, input.projectRoot, input.workerId, existing.state);
3533
+ if (result.changes !== 1)
3534
+ throw new Error(`WORKFLOW_WORKER_UPDATE_CONFLICT:${input.workerId}`);
3535
+ const updated = this.getWorkflowWorker(input.projectRoot, input.workerId);
3536
+ this.appendWorkflowWorkerEvent(updated, existing.state, input.target, reason, input.handoff ?? {}, now);
3537
+ return updated;
3538
+ });
3539
+ }
3540
+ recoverExpiredWorkflowWorkers(projectRoot, now = Date.now()) {
3541
+ const expired = this.listWorkflowWorkers(projectRoot, {
3542
+ states: ['leased', 'starting', 'running', 'reported', 'verifying', 'merge_ready'],
3543
+ limit: 2000,
3544
+ }).filter(worker => worker.leaseExpiresAt !== undefined && worker.leaseExpiresAt <= now);
3545
+ return expired.map(worker => this.transitionWorkflowWorker({
3546
+ projectRoot,
3547
+ workerId: worker.workerId,
3548
+ target: 'hold',
3549
+ leaseOwner: worker.leaseOwner,
3550
+ reason: 'lease_expired_dirty_worktree_requires_inspection',
3551
+ now,
3552
+ }));
3553
+ }
3554
+ enqueueWorkflowMerge(input) {
3555
+ const now = Date.now();
3556
+ const mergeId = input.mergeId ?? (0, crypto_1.randomUUID)();
3557
+ this.db.prepare(`
3558
+ INSERT INTO devflow_merge_queue (
3559
+ merge_id, worker_id, project_root, state, base_revision, parent_revision,
3560
+ candidate_revision, grader_receipts, simulation_hash, created_at, updated_at
3561
+ ) VALUES (?, ?, ?, 'waiting', ?, ?, ?, ?, ?, ?, ?)
3562
+ ON CONFLICT(worker_id) DO NOTHING
3563
+ `).run(mergeId, input.workerId, input.projectRoot, input.baseRevision, input.parentRevision, input.candidateRevision, JSON.stringify([...new Set(input.graderReceipts ?? [])].sort()), input.simulationHash ?? null, now, now);
3564
+ const record = this.getWorkflowMergeByWorker(input.projectRoot, input.workerId);
3565
+ if (!record
3566
+ || record.baseRevision !== input.baseRevision
3567
+ || record.candidateRevision !== input.candidateRevision) {
3568
+ throw new Error(`WORKFLOW_MERGE_IDENTITY_CONFLICT:${input.workerId}`);
3569
+ }
3570
+ return record;
3571
+ }
3572
+ getWorkflowMerge(projectRoot, mergeId) {
3573
+ const row = this.db.prepare(`
3574
+ SELECT * FROM devflow_merge_queue WHERE project_root = ? AND merge_id = ?
3575
+ `).get(projectRoot, mergeId);
3576
+ return row ? (0, workflow_workers_1.mapWorkflowMergeRow)(row) : null;
3577
+ }
3578
+ getWorkflowMergeByWorker(projectRoot, workerId) {
3579
+ const row = this.db.prepare(`
3580
+ SELECT * FROM devflow_merge_queue WHERE project_root = ? AND worker_id = ?
3581
+ `).get(projectRoot, workerId);
3582
+ return row ? (0, workflow_workers_1.mapWorkflowMergeRow)(row) : null;
3583
+ }
3584
+ listWorkflowMerges(projectRoot, limit = 200) {
3585
+ return this.db.prepare(`
3586
+ SELECT * FROM devflow_merge_queue WHERE project_root = ?
3587
+ ORDER BY created_at ASC, merge_id ASC LIMIT ?
3588
+ `).all(projectRoot, Math.max(1, Math.min(limit, 2000))).map(workflow_workers_1.mapWorkflowMergeRow);
3589
+ }
3590
+ transitionWorkflowMerge(projectRoot, mergeId, target, update = {}, now = Date.now()) {
3591
+ const existing = this.getWorkflowMerge(projectRoot, mergeId);
3592
+ if (!existing)
3593
+ throw new Error(`WORKFLOW_MERGE_NOT_FOUND:${mergeId}`);
3594
+ const legal = {
3595
+ waiting: ['ready', 'conflict', 'rejected'],
3596
+ ready: ['conflict', 'merged', 'rejected'],
3597
+ conflict: ['waiting', 'rejected'],
3598
+ merged: [],
3599
+ rejected: [],
3600
+ };
3601
+ if (!legal[existing.state].includes(target)) {
3602
+ if (existing.state === target)
3603
+ return existing;
3604
+ throw new Error(`WORKFLOW_MERGE_INVALID_TRANSITION:${existing.state}->${target}`);
3605
+ }
3606
+ const terminal = target === 'merged' || target === 'rejected';
3607
+ const result = this.db.prepare(`
3608
+ UPDATE devflow_merge_queue SET state = ?, parent_revision = COALESCE(?, parent_revision),
3609
+ simulation_hash = COALESCE(?, simulation_hash), merge_commit = COALESCE(?, merge_commit),
3610
+ reason = COALESCE(?, reason), updated_at = ?, finished_at = ?
3611
+ WHERE project_root = ? AND merge_id = ? AND state = ?
3612
+ `).run(target, update.parentRevision ?? null, update.simulationHash ?? null, update.mergeCommit ?? null, update.reason?.trim().slice(0, 1000) ?? null, now, terminal ? now : null, projectRoot, mergeId, existing.state);
3613
+ if (result.changes !== 1)
3614
+ throw new Error(`WORKFLOW_MERGE_UPDATE_CONFLICT:${mergeId}`);
3615
+ return this.getWorkflowMerge(projectRoot, mergeId);
3616
+ }
3617
+ appendWorkflowWorkerEvent(record, fromState, toState, reason, payload, createdAt) {
3618
+ this.db.prepare(`
3619
+ INSERT INTO devflow_worker_events (
3620
+ event_id, worker_id, project_root, from_state, to_state, reason, payload_json, created_at
3621
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3622
+ `).run((0, crypto_1.randomUUID)(), record.workerId, record.projectRoot, fromState ?? null, toState, reason ?? null, JSON.stringify(payload), createdAt);
3623
+ }
3309
3624
  // Convenience methods for raw SQL queries (used by AutoChecker)
3310
3625
  all(sql, ...params) {
3311
3626
  return this.db.prepare(sql).all(...params);
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export { RETRIEVAL_MAX_CYCLES, isRetrievalSessionState } from './retrieval-sessi
6
6
  export type { AppendRetrievalCycleInput, CreateRetrievalSessionInput, RetrievalCycleRecord, RetrievalGapKind, RetrievalGapRecord, RetrievalSessionRecord, RetrievalSessionState, } from './retrieval-sessions';
7
7
  export type { FailHostActionInput, HostActionRecord, HostActionState, ReportHostActionInput, RequestHostActionInput, StartHostActionInput, VerifyHostActionInput, } from './host-actions';
8
8
  export { mapSessionObligationRow, normalizeTurnId, } from './obligation-ledger';
9
+ export { LEGAL_WORKFLOW_WORKER_TRANSITIONS, TERMINAL_WORKFLOW_WORKER_STATES, mapWorkflowMergeRow, mapWorkflowWorkerEventRow, mapWorkflowWorkerRow, } from './workflow-workers';
10
+ export type { CreateWorkflowWorkerInput, EnqueueWorkflowMergeInput, TransitionWorkflowWorkerInput, WorkflowMergeRecord, WorkflowMergeState, WorkflowWorkerEventRecord, WorkflowWorkerRecord, WorkflowWorkerState, } from './workflow-workers';
9
11
  export { mapLearningCandidateRow, mapLearningEvidenceRow, mapLearningVersionRow, stableLearningJson, } from './learning-candidates';
10
12
  export type { AddLearningCandidateEvidenceInput, LearningCandidateEvidenceRecord, LearningCandidateKind, LearningCandidateRecord, LearningCandidateState, LearningCandidateVersionRecord, LearningEvidencePolarity, LearningOutcomeState, TransitionLearningCandidateInput, UpsertLearningCandidateInput, } from './learning-candidates';
11
13
  export type { SessionObligationKind, SessionObligationRecord, SessionObligationState, } from './obligation-ledger';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.stableLearningJson = exports.mapLearningVersionRow = exports.mapLearningEvidenceRow = exports.mapLearningCandidateRow = exports.normalizeTurnId = exports.mapSessionObligationRow = exports.isRetrievalSessionState = exports.RETRIEVAL_MAX_CYCLES = exports.serializeHostActionReport = exports.mapHostActionRow = exports.isHostActionState = exports.hostActionReportsEqual = exports.openGlobalDevFlowDatabase = exports.getGlobalDevFlowDbPath = exports.DevFlowDatabase = void 0;
3
+ exports.stableLearningJson = exports.mapLearningVersionRow = exports.mapLearningEvidenceRow = exports.mapLearningCandidateRow = exports.mapWorkflowWorkerRow = exports.mapWorkflowWorkerEventRow = exports.mapWorkflowMergeRow = exports.TERMINAL_WORKFLOW_WORKER_STATES = exports.LEGAL_WORKFLOW_WORKER_TRANSITIONS = exports.normalizeTurnId = exports.mapSessionObligationRow = exports.isRetrievalSessionState = exports.RETRIEVAL_MAX_CYCLES = exports.serializeHostActionReport = exports.mapHostActionRow = exports.isHostActionState = exports.hostActionReportsEqual = exports.openGlobalDevFlowDatabase = exports.getGlobalDevFlowDbPath = exports.DevFlowDatabase = void 0;
4
4
  var database_1 = require("./database");
5
5
  Object.defineProperty(exports, "DevFlowDatabase", { enumerable: true, get: function () { return database_1.DevFlowDatabase; } });
6
6
  Object.defineProperty(exports, "getGlobalDevFlowDbPath", { enumerable: true, get: function () { return database_1.getGlobalDevFlowDbPath; } });
@@ -16,6 +16,12 @@ Object.defineProperty(exports, "isRetrievalSessionState", { enumerable: true, ge
16
16
  var obligation_ledger_1 = require("./obligation-ledger");
17
17
  Object.defineProperty(exports, "mapSessionObligationRow", { enumerable: true, get: function () { return obligation_ledger_1.mapSessionObligationRow; } });
18
18
  Object.defineProperty(exports, "normalizeTurnId", { enumerable: true, get: function () { return obligation_ledger_1.normalizeTurnId; } });
19
+ var workflow_workers_1 = require("./workflow-workers");
20
+ Object.defineProperty(exports, "LEGAL_WORKFLOW_WORKER_TRANSITIONS", { enumerable: true, get: function () { return workflow_workers_1.LEGAL_WORKFLOW_WORKER_TRANSITIONS; } });
21
+ Object.defineProperty(exports, "TERMINAL_WORKFLOW_WORKER_STATES", { enumerable: true, get: function () { return workflow_workers_1.TERMINAL_WORKFLOW_WORKER_STATES; } });
22
+ Object.defineProperty(exports, "mapWorkflowMergeRow", { enumerable: true, get: function () { return workflow_workers_1.mapWorkflowMergeRow; } });
23
+ Object.defineProperty(exports, "mapWorkflowWorkerEventRow", { enumerable: true, get: function () { return workflow_workers_1.mapWorkflowWorkerEventRow; } });
24
+ Object.defineProperty(exports, "mapWorkflowWorkerRow", { enumerable: true, get: function () { return workflow_workers_1.mapWorkflowWorkerRow; } });
19
25
  var learning_candidates_1 = require("./learning-candidates");
20
26
  Object.defineProperty(exports, "mapLearningCandidateRow", { enumerable: true, get: function () { return learning_candidates_1.mapLearningCandidateRow; } });
21
27
  Object.defineProperty(exports, "mapLearningEvidenceRow", { enumerable: true, get: function () { return learning_candidates_1.mapLearningEvidenceRow; } });
@@ -1,4 +1,4 @@
1
- export type WorkKind = 'memory.explicit_commit' | 'memory.explicit_enrichment' | 'memory.turn_capture' | 'memory.turn_distill' | 'memory.vector_backfill' | 'context.prefetch' | 'learning.analyze_session' | 'session.bootstrap' | 'session.finalize' | 'telemetry.reconcile';
1
+ export type WorkKind = 'memory.explicit_commit' | 'memory.explicit_enrichment' | 'memory.turn_capture' | 'memory.turn_distill' | 'memory.vector_backfill' | 'context.prefetch' | 'learning.analyze_session' | 'workflow.worker_start' | 'workflow.worker_monitor' | 'workflow.worker_verify' | 'workflow.worker_merge' | 'workflow.worker_cleanup' | 'session.bootstrap' | 'session.finalize' | 'telemetry.reconcile';
2
2
  export type WorkState = 'pending' | 'leased' | 'completed' | 'failed' | 'dead_letter';
3
3
  export interface WorkError {
4
4
  category: string;
@@ -0,0 +1,97 @@
1
+ export type WorkflowWorkerState = 'planned' | 'leased' | 'starting' | 'running' | 'reported' | 'verifying' | 'merge_ready' | 'merged' | 'hold' | 'failed' | 'cancelled';
2
+ export type WorkflowMergeState = 'waiting' | 'ready' | 'conflict' | 'merged' | 'rejected';
3
+ export interface WorkflowWorkerRecord {
4
+ workerId: string;
5
+ runId: string;
6
+ stepId: string;
7
+ projectRoot: string;
8
+ branch: string;
9
+ worktreePath: string;
10
+ host: string;
11
+ state: WorkflowWorkerState;
12
+ leaseOwner?: string;
13
+ leaseExpiresAt?: number;
14
+ heartbeatAt?: number;
15
+ contextReceipt: string;
16
+ retrievalSessionId?: string;
17
+ sessionId?: string;
18
+ executionId?: string;
19
+ baseRevision: string;
20
+ finalRevision?: string;
21
+ allowedFiles: string[];
22
+ handoff: Record<string, unknown>;
23
+ holdReason?: string;
24
+ failureReason?: string;
25
+ createdAt: number;
26
+ updatedAt: number;
27
+ finishedAt?: number;
28
+ }
29
+ export interface CreateWorkflowWorkerInput {
30
+ workerId?: string;
31
+ runId: string;
32
+ stepId: string;
33
+ projectRoot: string;
34
+ branch: string;
35
+ worktreePath: string;
36
+ host: string;
37
+ contextReceipt: string;
38
+ retrievalSessionId?: string;
39
+ sessionId?: string;
40
+ executionId?: string;
41
+ baseRevision: string;
42
+ allowedFiles?: string[];
43
+ handoff?: Record<string, unknown>;
44
+ }
45
+ export interface TransitionWorkflowWorkerInput {
46
+ projectRoot: string;
47
+ workerId: string;
48
+ target: WorkflowWorkerState;
49
+ leaseOwner?: string;
50
+ leaseExpiresAt?: number;
51
+ heartbeatAt?: number;
52
+ finalRevision?: string;
53
+ handoff?: Record<string, unknown>;
54
+ reason?: string;
55
+ now?: number;
56
+ }
57
+ export interface WorkflowWorkerEventRecord {
58
+ eventId: string;
59
+ workerId: string;
60
+ projectRoot: string;
61
+ fromState?: WorkflowWorkerState;
62
+ toState: WorkflowWorkerState;
63
+ reason?: string;
64
+ payload: Record<string, unknown>;
65
+ createdAt: number;
66
+ }
67
+ export interface WorkflowMergeRecord {
68
+ mergeId: string;
69
+ workerId: string;
70
+ projectRoot: string;
71
+ state: WorkflowMergeState;
72
+ baseRevision: string;
73
+ parentRevision: string;
74
+ candidateRevision: string;
75
+ graderReceipts: string[];
76
+ simulationHash?: string;
77
+ mergeCommit?: string;
78
+ reason?: string;
79
+ createdAt: number;
80
+ updatedAt: number;
81
+ finishedAt?: number;
82
+ }
83
+ export interface EnqueueWorkflowMergeInput {
84
+ mergeId?: string;
85
+ workerId: string;
86
+ projectRoot: string;
87
+ baseRevision: string;
88
+ parentRevision: string;
89
+ candidateRevision: string;
90
+ graderReceipts?: string[];
91
+ simulationHash?: string;
92
+ }
93
+ export declare const TERMINAL_WORKFLOW_WORKER_STATES: Set<WorkflowWorkerState>;
94
+ export declare const LEGAL_WORKFLOW_WORKER_TRANSITIONS: Record<WorkflowWorkerState, WorkflowWorkerState[]>;
95
+ export declare function mapWorkflowWorkerRow(row: any): WorkflowWorkerRecord;
96
+ export declare function mapWorkflowWorkerEventRow(row: any): WorkflowWorkerEventRecord;
97
+ export declare function mapWorkflowMergeRow(row: any): WorkflowMergeRecord;