Skip to content

Add durable workflow orchestration foundation#78

Open
Waishnav wants to merge 3 commits into
mainfrom
feat/workflow-durable-foundation
Open

Add durable workflow orchestration foundation#78
Waishnav wants to merge 3 commits into
mainfrom
feat/workflow-durable-foundation

Conversation

@Waishnav

@Waishnav Waishnav commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • add versioned SQLite workflow runs, nodes, edges, events, and idempotent submission
  • enforce durable state transitions, claim fencing, snapshot-consistent reads, and cancellation invariants
  • add focused migration, store, and orchestration tests

Stack

This is PR 1 of 5 for dynamic workflow orchestration.

Validation

  • npm test
  • npm run build

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added durable workflow execution with submission, status tracking, cancellation, node claiming, transitions, and event history.
    • Added workflow definitions, policies, validation, idempotency, retries, and wait support.
    • Added persistent storage for workflow runs, nodes, dependencies, and events.
  • Bug Fixes

    • Improved workflow consistency during concurrent operations and invalid state transitions.
  • Tests

    • Added comprehensive migration, persistence, concurrency, event pagination, cancellation, and shutdown coverage.

Waishnav and others added 3 commits July 16, 2026 20:28
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Durable workflow execution

Layer / File(s) Summary
Workflow contracts and storage schema
src/workflows/types.ts, src/db/schema.ts, src/db/migrations.ts, src/workflows/migrations.test.ts, src/oauth-store.test.ts
Defines workflow records and status types, adds four SQLite tables and migration version 4, and tests fresh, compatible, and rollback migration paths.
Workflow persistence and state transitions
src/workflows/store.ts
Adds durable submission, idempotency, DAG validation, claims, transitions, cancellation, event sequencing, pagination, and typed record hydration.
Workflow orchestration and waiting
src/workflows/orchestrator.ts
Adds workflow submission, retrieval, cancellation, event access, bounded polling, notifications, and shutdown handling.
Workflow behavior and test execution
src/workflows/workflows.test.ts, package.json
Adds concurrency, transition, event, cancellation, DAG, wait, and worker-process tests, and wires workflow tests into the main test command.

Estimated code review effort: 5 (Critical) | ~120 minutes

Poem

“A durable trail!” said the rabbit bright,
“Runs and nodes now hop just right.
Events line up, claims take flight,
Polling waits through day and night.
With tests in tow, the burrow’s tight!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the PR’s main change: adding the durable workflow orchestration foundation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/workflow-durable-foundation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/db/migrations.ts`:
- Around line 184-264: Make migration 4 fail rather than silently accepting
incompatible existing workflow objects: in the workflow schema creation block of
src/db/migrations.ts (lines 184-264), replace the IF NOT EXISTS table/index
creation or validate every existing object’s columns, keys, and foreign keys
before recording the migration. In src/workflows/migrations.test.ts (lines
172-208), add coverage for a legacy workflow_events table containing
workflow_run_id and sequence but missing required event/payload columns, and
assert the migration fails and rolls back.

In `@src/workflows/store.ts`:
- Around line 264-273: The claim lease checks in src/workflows/store.ts lines
264-273 and 339-344 must revoke expired-token authority. In the renewal logic
around getNodeRow, require claim_expires_at > now alongside the running status
and token match; in the running-node transition logic, require the same
unexpired-lease condition in both validation and the update predicate, so
expired claims are rejected and reclamation requires a fresh token.

In `@src/workflows/workflows.test.ts`:
- Around line 538-567: Update the worker cleanup in the workflow test’s
running/try/finally flow to terminate every spawned child process before
removing coordinationDir. Retain references to the spawned processes alongside
readyPath and result, and in finally request termination and await worker
completion as needed before rmSync deletes the barrier directory, including
timeout and setup-failure paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 393765c3-8b13-4bb6-9a8c-3ec409627766

📥 Commits

Reviewing files that changed from the base of the PR and between 80423b5 and fa9cef5.

📒 Files selected for processing (9)
  • package.json
  • src/db/migrations.ts
  • src/db/schema.ts
  • src/oauth-store.test.ts
  • src/workflows/migrations.test.ts
  • src/workflows/orchestrator.ts
  • src/workflows/store.ts
  • src/workflows/types.ts
  • src/workflows/workflows.test.ts

Comment thread src/db/migrations.ts
Comment on lines +184 to +264
create table if not exists workflow_runs (
id text primary key,
definition_version integer not null,
status text not null,
definition_json text not null,
input_json text not null,
policy_json text not null,
idempotency_key text unique,
request_hash text not null,
result_json text,
error_json text,
cancellation_requested_at text,
event_sequence integer not null default 0,
created_at text not null,
updated_at text not null,
started_at text,
completed_at text
);

create index if not exists workflow_runs_status_idx
on workflow_runs(status, created_at);

create table if not exists workflow_nodes (
id text primary key,
workflow_run_id text not null,
node_key text not null,
node_type text not null,
status text not null,
definition_json text not null,
attempt integer not null default 0,
claim_token text,
claimed_at text,
claim_expires_at text,
result_json text,
error_json text,
created_at text not null,
updated_at text not null,
completed_at text,
foreign key (workflow_run_id) references workflow_runs(id) on delete cascade
);

create unique index if not exists workflow_nodes_run_key_idx
on workflow_nodes(workflow_run_id, node_key);

create unique index if not exists workflow_nodes_run_id_idx
on workflow_nodes(workflow_run_id, id);

create index if not exists workflow_nodes_status_idx
on workflow_nodes(workflow_run_id, status, created_at);

create table if not exists workflow_edges (
workflow_run_id text not null,
from_node_id text not null,
to_node_id text not null,
primary key (workflow_run_id, from_node_id, to_node_id),
foreign key (workflow_run_id) references workflow_runs(id) on delete cascade,
foreign key (workflow_run_id, from_node_id)
references workflow_nodes(workflow_run_id, id) on delete cascade,
foreign key (workflow_run_id, to_node_id)
references workflow_nodes(workflow_run_id, id) on delete cascade
);

create index if not exists workflow_edges_to_node_idx
on workflow_edges(workflow_run_id, to_node_id);

create table if not exists workflow_events (
workflow_run_id text not null,
sequence integer not null,
event_type text not null,
node_id text,
payload_json text not null,
created_at text not null,
primary key (workflow_run_id, sequence),
foreign key (workflow_run_id) references workflow_runs(id) on delete cascade,
foreign key (workflow_run_id, node_id)
references workflow_nodes(workflow_run_id, id)
);

create index if not exists workflow_events_cursor_idx
on workflow_events(workflow_run_id, sequence);
`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail migration 4 when workflow objects already exist.

IF NOT EXISTS can silently accept an incompatible legacy table that happens to contain the indexed columns, record version 4, and leave runtime queries failing on missing columns or constraints.

  • src/db/migrations.ts#L184-L264: use plain CREATE TABLE/CREATE INDEX, or explicitly verify every existing object's columns, keys, and foreign keys before recording the migration.
  • src/workflows/migrations.test.ts#L172-L208: cover a legacy workflow_events table containing workflow_run_id and sequence but missing required payload/event columns; migration must still fail and roll back.
📍 Affects 2 files
  • src/db/migrations.ts#L184-L264 (this comment)
  • src/workflows/migrations.test.ts#L172-L208
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/migrations.ts` around lines 184 - 264, Make migration 4 fail rather
than silently accepting incompatible existing workflow objects: in the workflow
schema creation block of src/db/migrations.ts (lines 184-264), replace the IF
NOT EXISTS table/index creation or validate every existing object’s columns,
keys, and foreign keys before recording the migration. In
src/workflows/migrations.test.ts (lines 172-208), add coverage for a legacy
workflow_events table containing workflow_run_id and sequence but missing
required event/payload columns, and assert the migration fails and rolls back.

Comment thread src/workflows/store.ts
Comment on lines +264 to +273
if (row.status === "running" && row.claim_token === claim.claimToken) {
this.database.sqlite
.prepare(
`update workflow_nodes
set claim_expires_at = ?, updated_at = ?
where id = ? and status = 'running' and claim_token = ?`,
)
.run(expiresAt, now, row.id, claim.claimToken);
return this.getNodeRow(claim.workflowId, claim.nodeKey);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Revoke node authority when its claim lease expires.

A matching token can currently renew or complete a node after claim_expires_at. A delayed worker can therefore beat reclamation despite no longer holding a valid lease.

  • src/workflows/store.ts#L264-L273: renew only while claim_expires_at > now; reject an expired matching token and require a fresh token for reclamation.
  • src/workflows/store.ts#L339-L344: require both token equality and an unexpired lease for running-node transitions, preferably in the update predicate as well.
📍 Affects 1 file
  • src/workflows/store.ts#L264-L273 (this comment)
  • src/workflows/store.ts#L339-L344
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/workflows/store.ts` around lines 264 - 273, The claim lease checks in
src/workflows/store.ts lines 264-273 and 339-344 must revoke expired-token
authority. In the renewal logic around getNodeRow, require claim_expires_at >
now alongside the running status and token match; in the running-node transition
logic, require the same unexpired-lease condition in both validation and the
update predicate, so expired claims are rejected and reclamation requires a
fresh token.

Comment on lines +538 to +567
const running = workers.map((worker, index) => {
const readyPath = join(coordinationDir, `ready-${index}`);
const spec: WorkerSpec = { ...worker, stateDir, readyPath, barrierPath };
const child = spawn(process.execPath, ["--import", "tsx", fileURLToPath(import.meta.url), JSON.stringify(spec)], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
const result = new Promise<Record<string, unknown>>((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0) reject(new Error(`Workflow worker exited ${code}: ${stderr}`));
else resolve(JSON.parse(stdout) as Record<string, unknown>);
});
});
return { readyPath, result };
});
try {
const deadline = performance.now() + 10_000;
while (running.some(({ readyPath }) => !existsSync(readyPath))) {
if (performance.now() >= deadline) throw new Error("Timed out waiting for workflow workers");
await delay(5);
}
writeFileSync(barrierPath, "start");
return await Promise.all(running.map(({ result }) => result));
} finally {
rmSync(coordinationDir, { recursive: true, force: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Terminate worker subprocesses before removing their barrier directory.

If readiness times out or setup throws, workers continue polling the deleted barrierPath forever. Their open process and pipe handles can hang the test runner.

Proposed cleanup
-    return { readyPath, result };
+    return { readyPath, child, result };
...
   } finally {
+    for (const { child } of running) {
+      if (child.exitCode === null && child.signalCode === null) {
+        child.kill();
+      }
+    }
+    await Promise.allSettled(running.map(({ result }) => result));
     rmSync(coordinationDir, { recursive: true, force: true });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const running = workers.map((worker, index) => {
const readyPath = join(coordinationDir, `ready-${index}`);
const spec: WorkerSpec = { ...worker, stateDir, readyPath, barrierPath };
const child = spawn(process.execPath, ["--import", "tsx", fileURLToPath(import.meta.url), JSON.stringify(spec)], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
const result = new Promise<Record<string, unknown>>((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0) reject(new Error(`Workflow worker exited ${code}: ${stderr}`));
else resolve(JSON.parse(stdout) as Record<string, unknown>);
});
});
return { readyPath, result };
});
try {
const deadline = performance.now() + 10_000;
while (running.some(({ readyPath }) => !existsSync(readyPath))) {
if (performance.now() >= deadline) throw new Error("Timed out waiting for workflow workers");
await delay(5);
}
writeFileSync(barrierPath, "start");
return await Promise.all(running.map(({ result }) => result));
} finally {
rmSync(coordinationDir, { recursive: true, force: true });
}
const running = workers.map((worker, index) => {
const readyPath = join(coordinationDir, `ready-${index}`);
const spec: WorkerSpec = { ...worker, stateDir, readyPath, barrierPath };
const child = spawn(process.execPath, ["--import", "tsx", fileURLToPath(import.meta.url), JSON.stringify(spec)], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
const result = new Promise<Record<string, unknown>>((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0) reject(new Error(`Workflow worker exited ${code}: ${stderr}`));
else resolve(JSON.parse(stdout) as Record<string, unknown>);
});
});
return { readyPath, child, result };
});
try {
const deadline = performance.now() + 10_000;
while (running.some(({ readyPath }) => !existsSync(readyPath))) {
if (performance.now() >= deadline) throw new Error("Timed out waiting for workflow workers");
await delay(5);
}
writeFileSync(barrierPath, "start");
return await Promise.all(running.map(({ result }) => result));
} finally {
for (const { child } of running) {
if (child.exitCode === null && child.signalCode === null) {
child.kill();
}
}
await Promise.allSettled(running.map(({ result }) => result));
rmSync(coordinationDir, { recursive: true, force: true });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/workflows/workflows.test.ts` around lines 538 - 567, Update the worker
cleanup in the workflow test’s running/try/finally flow to terminate every
spawned child process before removing coordinationDir. Retain references to the
spawned processes alongside readyPath and result, and in finally request
termination and await worker completion as needed before rmSync deletes the
barrier directory, including timeout and setup-failure paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant