akm docs

OpenWorkflow as a replacement/underpinning for akm's workflow engine

Evaluated: openworkflow v0.9.2 (npm, published 2026-07-21) — https://github.com/openworkflowdev/openworkflow Assessed 2026-08-01 against akm @ /home/user/akm (akm-cli 0.9.0-rc.13, MPL-2.0, Bun).

Method: repo cloned to scratch, source read directly; docs read from apps/docs/docs/*.mdx; library installed from npm and executed under Bun 1.3.11 to verify embedding, crash-resume, long-step lease behavior, and a schema-collision hazard. No claim below rests on training knowledge.


Verdict summary

# Rubric item Score Evidence
1 Server requirement PASS Embedded worker class, SQLite file, zero deps
2 Durability & crash-resume PASS Verified by SIGKILL probe; memoized replay
3 Orchestration primitives PARTIAL No concurrency cap, no error classification, no step timeout
4 Long-running steps PARTIAL Heartbeat holds 40s step; no in-flight cancellation
5 Runtime & platform PARTIAL Bun first-class; Windows untested in CI
6 Maturity PARTIAL 1.3k stars, 9mo old, bus factor 1, pre-1.0
7 Integration PARTIAL ~8-12% of exec+ir deletable; adapter code added
8 Risks PARTIAL Solo maintainer, 0.x churn, well-funded competitor

Bottom line: technically excellent and a genuinely close conceptual match, but it replaces only the small durable-plumbing fraction of akm's engine while imposing a second database file, a 1000-step ceiling, and loss of in-flight unit cancellation — not worth adopting as a replacement.


1. Server requirement — PASS

Fully embeddable in a short-lived CLI process. No daemon, broker, or DB server.

Verified empirically. A single Bun script that connects SQLite, defines a workflow, starts a worker, runs a workflow to completion, stops the worker, and exits cleanly: /tmp/.../scratchpad/probe/run.ts → final log line process exiting cleanly, exit code 0.

The only caveat: the documented path is npx @openworkflow/cli worker start as a long-lived process (apps/docs/docs/workers.mdx). The embedded one-shot pattern works but is not a documented, first-class use case, so it is not covered by the project's own regression focus.

2. Durability & crash-resume — PASS

This is the library's strongest dimension and it maps almost exactly onto what akm already built.

Verified empirically — this is the decisive test. Probe workflow: step-a, then a 4-way dynamic fan-out (unit:u1..u4), then a 40-second child-process step. Run 1 SIGKILLed itself mid-long-step. Run 2 was a fresh process against the same SQLite file:

# run 1 (killed)
EXEC step-a / EXEC unit:u1 / EXEC unit:u2 / EXEC unit:u3 / EXEC unit:u4
EXEC long-agent-spawn START ... CRASHING process mid-long-step (SIGKILL self)

# run 2 (fresh process)
REPLAY: workflow fn entered            <- top-of-function replay
EXEC long-agent-spawn START            <- ONLY the incomplete step re-ran
EXEC long-agent-spawn END
RESULT {"a":{"a":1},"out":["done-u1","done-u2","done-u3","done-u4"],"long":"agent-output",...}

step-a and all four fan-out units produced no EXEC lines on resume — they were served from the memoization cache, and their values were correctly threaded into the final result. This is precisely akm's "durable-row resume: re-invoking a partially-executed run re-dispatches only work that never completed" (src/workflows/exec/run-workflow.ts header).

Measured friction: the lease is a hardcoded DEFAULT_LEASE_DURATION_MS = 30 * 1000 (worker/worker.ts:12) and is not exposed in WorkerOptions (worker/worker.ts:33-37). In the probe, the crash was at 23:09:16, the new process started at 23:09:27, and it could not claim until 23:09:45 — an 18-second dead wait, up to 30s worst case. akm's own lease is 90s but akm controls it; here a user typing akm workflow run <id> after a crash would watch the CLI sit idle for up to half a minute with no way to tune it short of patching the library.

3. Orchestration primitives — PARTIAL

The model is "durable functions", not a declarative graph engine: control flow is plain TypeScript, and only side effects are wrapped in step.run.

Primitive Status Evidence
Step graph N/A by design Ordinary TS control flow; no graph IR. core/workflow-function.ts
Fan-out Yes Promise.all over step.run; auto-disambiguates duplicate names to name:1, name:2 (apps/docs/docs/dynamic-steps.mdx)
Concurrency cap NO Not implemented. Roadmap "Coming Soon: Priority and concurrency controls" (apps/docs/docs/roadmap.mdx); issue #20 "Workflow concurrency limit" open since 2025-11-13. Docs tell you to hand-roll batching (apps/docs/docs/parallel-steps.mdx, "Limit Parallelism for External APIs")
Reduction Manual Plain JS over the Promise.all result array
Conditional routing Manual if/else in the workflow body; no route/branch construct
Bounded retry Yes Per-step retryPolicy (initialInterval, backoffCoefficient, maximumInterval, maximumAttempts), default 10 attempts (apps/docs/docs/retries.mdx)
Error classification NO RetryPolicy = BackoffPolicy & {maximumAttempts} (core/workflow-definition.ts:134). No non-retryable error types; a repo-wide grep for nonRetryable/NonRetryable finds only an unrelated internal comment
Per-step timeout NO StepFunctionConfig is { name, retryPolicy? } only (core/workflow-function.ts:16-27). timeout exists only on the wait primitives runWorkflow and waitForSignal
Cancellation Partial Run-level only, cooperative and non-preemptive (see §4)
Signals Yes sendSignal / waitForSignal with payloads and timeouts
Child workflows Yes step.runWorkflow(spec, input, {timeout})
Sleep Yes step.sleep(name, duration) parks the run and frees the worker slot

Three gaps land directly on features akm already has and relies on:

  1. No concurrency cap. akm's src/workflows/exec/scheduler.ts takes the minimum of the map request, the frozen workflow cap, the frozen LLM-engine cap, and a CPU-derived host cap — deliberately re-applying host safety at dispatch "when a frozen run resumes on a smaller machine." OpenWorkflow has no equivalent, so akm's scheduler would be kept, not deleted.
  2. No error classification. akm's isRetryEligibleFailure (src/workflows/exec/step-work.ts:831) retries only when retry.on.includes(failureReason). Under OpenWorkflow you would re-implement this by catching inside the step and re-throwing or returning a discriminated result.
  3. No per-step timeout. akm resolves one at freeze time — effectiveTimeout (src/workflows/ir/freeze.ts) layers the unit's timeout:, the document's defaults.timeout, engines.<name>.timeoutMs, then the engine-kind default (DEFAULT_LLM_TIMEOUT_MS = 600_000; DEFAULT_AGENT_TIMEOUT_MS = null, i.e. agent units are unbounded unless declared) — and freezes the result into IrInvocation.timeoutMs, which dispatch applies verbatim. There is no engine-side backstop on top of it, deliberately: timeout: none freezes to null and must stay unbounded. Workaround is a Promise.race inside every step — easy, but it is akm's code again, not the library's.

Hard capacity ceiling. WORKFLOW_STEP_LIMIT = 1000 step attempts per run, enforced, and retries count against it (worker/step-history.ts:8; apps/docs/docs/retries.mdx). akm permits WORKFLOW_MAX_MAP_EXPANSION = 10_000 (src/workflows/resource-limits.ts:15). A 10x reduction in maximum fan-out. Escaping it means one child workflow per unit, i.e. a full workflow_runs row per agent invocation.

4. Long-running steps — PARTIAL

Heartbeats: PASS, and verified. The heartbeat is a setInterval at half the lease (15s) that extends the lease independently of the step promise (worker/worker.ts:270-285). In the probe, a 40-second step (an actual Bun.spawn child process) ran under a 30-second lease and completed normally — the lease was held throughout, nothing was stolen, no duplicate execution. Minutes-long agent-CLI spawns and LLM calls are safe.

Stalled recovery: PASS. A dead worker's run becomes claimable once available_at lapses (verified in §2).

Sleep/wait: PASS. step.sleep durably parks the run and releases the worker slot rather than blocking it (ARCHITECTURE.md), and waitForSignal supports waits up to a 1-year default (worker/step-history.ts:83-87, defaultWaitTimeoutAt).

Cancellation of in-flight work: FAIL — the sharpest functional regression.

akm's scheduler deliberately does the opposite: "Cooperative cancellation via AbortSignal (workers stop claiming items; the same signal is passed into each dispatch so in-flight units can be preempted too)" (src/workflows/exec/scheduler.ts:16-18). Under OpenWorkflow, cancelling an akm run would leave a 20-minute agent CLI running to completion with its output discarded. There is no clean fix inside the model — you would keep akm's abort plumbing outside step.run, which fights the framework rather than using it.

Related: handle.result() defaults to a 5-minute timeout (client/client.ts:298). Trivially overridden per call (result({ timeoutMs }), as the probe does with 180s), but the default is wrong for akm's workloads and would bite on first use.

5. Runtime & platform — PARTIAL

6. Maturity — PARTIAL

Metric Value Source
Stars ~1,286-1,300 GitHub repo page; dependents page
Forks 53-61 same
Age 9 months — first commit 2025-10-27 git log on the clone
Commits 1,135 git rev-list --count HEAD
Last commit 2026-07-29 (3 days before assessment) git log -1
Releases 25 npm versions, 0.0.1 (2025-10-26) → 0.9.2 (2026-07-21) registry.npmjs.org
Open issues 10, all enhancements/questions — zero open bug reports GitHub issues
License Apache-2.0 LICENSE.md
Tests 19 test files; worker/execution.test.ts is 128 KB, worker/worker.test.ts 64 KB, plus a chaos.test.ts clone
Coverage gates statements/functions/lines 90%, branches 80%, and 100% on core/** vitest.config.ts:17-28
Dependents 9 repositories / 11 packages GitHub dependents page

Strengths. The engineering discipline is unusually high for a 9-month-old project: enforced coverage thresholds including 100% on core, a chaos test, knip dead-code checks, jscpd duplication checks, cspell, ESLint with boundaries/functional/sonarjs, commitlint, Postgres service containers in CI, and a separate Bun CI lane. Zero runtime dependencies. Clean, fully parameterized SQL (sqlite/backend.ts), no eval, no new Function, no child_process anywhere in the shipped library. The architecture doc is genuinely good.

Weaknesses.

On the "Security Disclosure: Database Credentials Exposed" issue (#482, closed 2026-04-23): this is a false positive and should not count against the project. I verified the claim directly against the clone. Every postgres:// string in the repo is postgresql://postgres:postgres@localhost:5432/postgres — the standard local-dev default — in examples/*, openworkflow/client.ts, and the Postgres backend's default constant (postgres/postgres.ts:4), plus placeholder text like user:password@localhost in apps/cli/commands.ts:1059 and the docs. There is no real credential and no rotation commit in the surrounding history. It reads as automated bug-bounty spam.

7. Integration sketch

How akm's concepts would map

akm OpenWorkflow Fit
Frozen plan (plan_json + plan_hash) Workflow run input (JSON) Good — one registered generic workflow interpreting the plan from input
workflow_run_units rows step_attempts rows Partial — see below
Engine lease (90s, holder id, renewed between steps) Worker lease + heartbeat (30s, fixed) Direct replacement, but not tunable
Durable-row resume Step memoization Direct replacement — verified working
Unit retry with retry.on classification retryPolicy Partial — loses classification
Unit concurrency caps — No equivalent; keep akm's scheduler
Judge-gate loop (gate.max_loops, feedback threading) — No equivalent; entirely akm's
Driver protocol (brief/report) waitForSignal / sendSignal Good fit conceptually — report becomes sendSignal
Route/branch (evaluateRoute, cascadeSkippedRouter) plain if/else Rewrite, not reuse
Markdown → IR compile/freeze — No equivalent; entirely akm's

The brief/report protocol is the pleasant surprise: an external harness reporting unit results maps naturally onto sendSignal, with the workflow parked on waitForSignal in between. That part of akm's design is genuinely idiomatic OpenWorkflow.

Blocking hazard: table-name collision (verified)

OpenWorkflow's SQLite backend creates a table literally named workflow_runs (sqlite/sqlite.ts:77) — and akm already has a workflow_runs table with a completely different schema (src/core/state/migrations.ts:881), plus workflow_run_steps (:910) and workflow_run_units (:929).

Because the migration uses CREATE TABLE IF NOT EXISTS, pointing OpenWorkflow at akm's state DB fails silently at migration time and then explodes at query time. Reproduced:

COLLISION ERROR: SQLite backend failed to open database. ...: no such column: parent_step_attempt_id

There is no table-prefix option: BackendSqliteOptions is { namespaceId?, runMigrations? } (sqlite/backend.ts:55-58), and namespaceId is a row-level discriminator, not a naming scheme. The Postgres backend supports a custom schema (postgres/backend.ts:64); the SQLite backend does not. The private constructor also means you cannot inject akm's existing Bun Database handle.

So adoption forces a second SQLite file. That is the real cost: akm currently writes step completion, unit rows, and usage_events under one transaction in one database. Split across two files, run-state advancement and akm's domain journal are no longer atomic — a crash between the two writes leaves them inconsistent, and akm's gate-spine invariant ("every step advances through completeWorkflowStep... so the summary-validation gate and run-state derivation stay authoritative", run-workflow.ts header) would now straddle two durability domains.

The escape hatch is to implement the Backend interface over akm's own tables — it is public via openworkflow/internal (internal.ts re-exports core/backend.js). But that is ~20 methods of subtle claim/lease/transaction semantics; the reference SQLite implementation is ~35 KB. You would be writing more durability code than you deleted.

Separately, step_attempts cannot absorb workflow_run_units: akm's unit rows carry tokens, model, runner, engine, phase, worktree_path, session_id, last_checkin_at, input_hash, parent_unit_id (migrations.ts:929-953). OpenWorkflow's step attempts model none of these, so akm keeps its unit table regardless — meaning the two systems would journal the same work twice.

What gets replaced vs orphaned

src/workflows/exec = 7,070 LOC, src/workflows/ir = 1,635 LOC; total 8,705.

Replaced (deletable):

≈ 670 LOC ≈ 8% of exec+ir. Even on a generous reading — akm abandoning its own unit journal and accepting step_attempts as the sole record, which would forfeit token/model/worktree/session tracking — it does not exceed ~12%.

Orphaned / kept (the other ~88-92%):

And new code is added: a second-DB adapter (or a ~20-method custom Backend), a plan-interpreter workflow function, per-step Promise.race timeouts, error-classification wrappers, and a dual-journal reconciliation path. Net LOC is plausibly negative — i.e. adopting it likely means more code in akm, not less.

8. Risks


Recommendation

Do not adopt as a replacement, and do not adopt as an underpinning.

OpenWorkflow is a well-built library — the crash-resume semantics are correct and verified, Bun support is genuine and CI-tested, the engineering hygiene is better than most projects ten times its age, and it has no dependencies. If akm were starting from zero and needed durable execution, it would be a reasonable candidate.

But akm is not starting from zero, and the overlap is in the wrong place. OpenWorkflow supplies the generic durable-execution core — lease, memoized replay, retry/backoff, status machine — which is roughly 8-12% of exec+ir. Everything that makes akm's engine large is domain logic OpenWorkflow has no opinion about: the markdown→IR compiler, the frozen plan, judge gates with bounded feedback loops, unit dispatch to agent CLIs, artifact promotion and schema validation, worktrees, secret redaction, and the brief/report driver protocol. Meanwhile adoption subtracts three capabilities akm currently has (in-flight unit cancellation, concurrency caps, retry error classification), imposes a 10x lower fan-out ceiling, and forces run state into a second database file that breaks the single-transaction invariant akm's gate spine is built on.

Worth stealing instead of adopting: the available_at-as-both-queue-and-lease trick that makes one column serve as job queue, sleep timer, and crash detector (ARCHITECTURE.md); the automatic name:1/name:2 disambiguation for dynamic fan-out with the documented "use a stable ID for mutable collections" caveat (apps/docs/docs/dynamic-steps.mdx) — akm's unitIdFor already does something similar but the failure mode is worth reviewing; and the 100%-coverage gate scoped to the core/** directory only (vitest.config.ts:22-27), which is a nice pattern for holding a durability kernel to a higher bar than the surrounding code.

Reconsider if the project reaches 1.0 with a stable API, gains a second sustained maintainer, ships intra-workflow concurrency controls (#20) and configurable leases, and adds either a SQLite table prefix or a supported way to inject an existing database handle.