Repository navigation
feat: local-only tutorial executor for chant run (no workflow runtime required) #73
Description
chant's Op composite currently requires Temporal for execution — chant run <op> spawns a Temporal worker and submits a workflow. For many useful Op shapes (CI runs, drift checks, short idempotent operational tasks, dev loops), that's heavyweight: standing up Temporal just to get retries + sequencing is overkill, and it forces audit-thread / observation Ops to depend on a durable workflow runtime they don't actually need.
A local executor closes the gap as a first-class peer to Temporal mode, not a tutorial shim. Both modes run real Ops; they differ only in what they offer on top of phase sequencing.
| Local mode | Temporal mode | |
|---|---|---|
| Phase sequencing + retry | ✓ | ✓ |
onFailure compensation |
✓ | ✓ |
| Activity profiles (timeout/retry) | ✓ | ✓ |
Structured stdout / exit code / --json |
✓ | ✓ |
| Gates | ✗ (errors) | ✓ |
| Schedules | ✗ (errors) | ✓ |
| Durable resume after crash | ✗ | ✓ |
chant run history/status/log |
✗ | ✓ |
| Search-attribute persistence | ✗ (outcomes go to stdout) | ✓ |
The graduation story: dev loops, CI, drift/observation Ops → local; long-running deploys, gated rollouts, scheduled jobs → Temporal.
Resolved decisions
These settle the original open questions:
-
Activity location — confirmed. All activities live in
lexicons/temporal/src/op/activities/(shell,build,kubectlApply,helmInstall,waitForStack,gitlabPipeline,stateSnapshot,stateDiff,chantTeardown). Op types inpackages/core/src/op/types.tsare already Temporal-free. Caveat:kubectl/helm/wait/gitlabcallContext.current().heartbeat()from@temporalio/activity, which throws outside a worker. Local mode needs a no-op heartbeat shim (see tasks). -
Mode-selection default — local unless
--temporal. One rule, no config inspection.chant run <op>runs local;--temporalopts into a cluster. The existingdefaultProfile: "local"stays a connection profile only and is never consulted for mode selection (avoids thelocal-the-mode vslocal-the-profile collision). -
Output —
--jsonfrom day one. Executor emits a structured record per step; human output is the default renderer over the same records,--jsonprints the array. -
CI regression guard — yes. chant's own CI runs an example Op under local mode on every push.
Note: chant has no users yet, so there is no backward-compat constraint. Existing Ops that relied on the implicit Temporal default now run local and must pass
--temporalif they contain a gate/schedule. This is intentional.
Tasks
A. Executor core — packages/core/src/op/local-executor.ts
- A1. Step record model + types. Define
StepRecord(phase,fn,args,status: "ok"|"fail"|"skipped",durationMs,outcome?,error?) andOpRunResult(op,records: StepRecord[],totalMs,ok). Outcome: types exported fromlocal-executor.ts;--jsonand the human renderer both consumeOpRunResult. - A2. Sequential phase walker. Walk
config.phasesin order; within a phase run steps sequentially. Outcome: a 3-phase Op with one activity each runs all three in declared order; unit test asserts call order. - A3. Parallel phases. When
phase.parallel === true, run steps viaPromise.all. Outcome: unit test with two sleeping activities in a parallel phase completes in ~max(t1,t2), not t1+t2. - A4. Retry + timeout via profiles. Wrap each call in
Promise.race([call, timeout])usingTEMPORAL_ACTIVITY_PROFILES[step.profile ?? "fastIdempotent"]; retry up tomaximumAttemptshonoring the profile's backoff. Outcome: unit tests — (a) an activity that fails twice then succeeds passes after 3 attempts; (b) an activity exceedingstartToCloseTimeoutis killed and retried; (c) exhausted retries reject. - A5.
outcomeAttributecapture. Store result in an in-memoryMap<string, unknown>, resolving thefromdot-path; attach to the step'sStepRecord.outcome. Outcome: unit test — astateDiffstep withoutcomeAttribute: {name:"Drift", from:"drifted"}yieldsoutcome: {name:"Drift", value:false}in the record. - A6.
onFailurecompensation. try/catch the phase loop; on terminal failure runonFailurephases in reverse order, then re-throw so the CLI exits non-zero. Outcome: unit test — failing main phase triggersonFailurephases in reverse; run resultok === falseand the call rejects. - A7. Gate/schedule rejection. Encountering a
gatestep (or schedule) throwsLocalGateUnsupportedErrorwith a one-line actionable message pointing at--temporal. Outcome: unit test asserts the error type and message; no activities after the gate run.
B. Activity registry loader — packages/core/src/op/activity-registry.ts
- B1. Dynamic import + lookup.
loadActivities()dynamically imports@intentius/chant-lexicon-temporaland returns aMap<string, Fn>keyed by export name;step.fnis resolved against it. Outcome: local executor resolvesshell/build/etc. by name; unknownfnthrows a clear "no activity named X" error. - B2. Missing-peer-dep error. If the import fails, throw a friendly error: "no activities registered — install
@intentius/chant-lexicon-temporal." Outcome: unit test mocks a failed import and asserts the message; exit code non-zero, no stack-trace spew. - B3. No-op heartbeat shim. Make
Context.current().heartbeat()inkubectl/helm/wait/gitlabsafe outside a worker — either a try/catch wrapper or aheartbeat()helper in the lexicon that no-ops whenContext.current()throws. Outcome: callingkubectlApplyfrom the local executor (no Temporal worker) does not throw on the heartbeat line; unit/integration test covers it.
C. CLI wiring — packages/core/src/cli/handlers/run.ts
- C1. Extract
runOpTemporal. Move today'srunOpbody (worker spawn + workflow submit) intorunOpTemporalunchanged. Outcome:--temporalpath behaves exactly as currentchant rundoes today; existing run tests pass againstrunOpTemporal. - C2. Add
runOpLocal. New function that discovers the Op, loads activities (B), runs the executor (A), renders output (D), returns exit code. Outcome:chant run <gate-free-op>works with no Temporal server running. - C3. Mode dispatcher in
runOp.--temporal→runOpTemporal; otherwise →runOpLocal. No config inspection. Outcome: dispatcher unit tests — no flag → local;--temporal→ temporal; precedence is exactly those two. - C4.
--local/--temporalflag parsing. Register both flags in the run command arg spec (--localis the explicit form of the default). Outcome:chant run x --localandchant run x --temporalboth parse;--helplists them. - C5. Gate/schedule pre-flight in local mode. Before executing, if the Op contains a gate/schedule and
--temporalwas not passed, fail fast with theLocalGateUnsupportedErrormessage (don't start phases). Outcome:chant run <gated-op>(no--temporal) errors immediately with a message naming the offending step and suggesting--temporal. - C6. Temporal-only subcommands guard.
chant run list/status/log/signal/cancel/--reportprint "not available in local mode — pass --temporal or configure a profile" when no Temporal context. Outcome: each subcommand without--temporalprints the message and exits non-zero; with--temporalthey behave as today.
D. Output — packages/core/src/op/local-output.ts
- D1. Human renderer. Phase banners, per-step
✓/✗ fn(args)+ duration,[outcome] name=valuelines, finalOp "<name>" completed in <t>summary. Outcome: snapshot test matches the format in the issue example; failures render✗and the error. - D2.
--jsonrenderer.--jsonprintsOpRunResultas JSON to stdout (and nothing else on stdout); human logs go to stderr. Outcome:chant run x --json | jq .okworks; test asserts stdout is valid JSON and parseable toOpRunResult.
E. Tests
- E1. Executor unit tests (
local-executor.test.ts) — sequential, parallel, retry, timeout, onFailure, outcomeAttribute, gate-rejection (covers A2–A7). Outcome: all seven scenarios green. - E2. Registry tests (
activity-registry.test.ts) — successful load + lookup, unknown fn, missing peer dep, heartbeat-shim safety (covers B1–B3). Outcome: all four green. - E3. Dispatcher tests (
run.test.ts) — flag precedence (none→local,--temporal→temporal), gate-in-local fast-fail, temporal-only subcommand guards (covers C3/C5/C6). Outcome: all green. - E4. Output tests (
local-output.test.ts) — human snapshot +--jsonshape (covers D1/D2). Outcome: both green.
F. Example + CI
- F1. Quick-start example. Verify one existing gate-free example runs end-to-end under local mode; if none fits, add
examples/local-op-quickstart/with ahello.op.ts(oneshellactivity). Outcome:cd examples/<name> && chant build && chant run hellosucceeds locally in under a minute with no Temporal. - F2. CI regression guard. Add a step to
.github/workflows/chant.yml:chant build+chant run <example> --local, assert exit 0. Outcome: CI fails if local-mode execution regresses; visible as a distinct step in the workflow run.
G. Docs
- G1. Local-vs-Temporal guide. New
guide/local-vs-temporal.mdx(or section inguide/ops.mdx) with the capability table and the graduation story, placed at the decision point. Outcome: docs build passes; page reachable from the Ops nav. - G2. Quick Start update.
chant init→hello.op.ts→chant build→chant run hellowith no Temporal. Outcome: Quick Start no longer mentions starting a Temporal server for the first run. - G3. CLI flag docs. Document
--local/--temporal/--jsonand the "local unless--temporal" default incli/run.mdx. Outcome: flags and default rule documented;docs-checkpasses.
Done when
- A user with chant installed but no Temporal can
chant init→ writehello.op.ts→chant build→chant run helloand see it work in under a minute (F1, G2). - Drift / observation Ops run under local mode in CI without a Temporal server (F2).
--temporalruns the existing Temporal path unchanged (C1).- Local mode emits both human and
--jsonoutput from the same record model (D1, D2). - Gate/schedule Ops fail fast in local mode with an actionable message (A7, C5).
- Test suite covers sequential, parallel, retry, timeout,
onFailure,outcomeAttribute, gate-in-local rejection, dispatcher precedence, registry errors, heartbeat-shim safety (E1–E4). - Docs make the local-vs-Temporal trade-off explicit at the decision point (G1).
Out of scope (intentionally)
- Gates — require cross-process persistence. Local mode errors with "use
--temporal." - Schedules — require an external scheduler. Same error.
- Durable state across process restart — if the process dies mid-run, you start over.
- Search attributes /
chant run history— local mode has no persistence to query. - Worker pools / cross-process parallelism.
Why this and not a full internal runtime
A full internal runtime (SQLite-backed, durable, with gates and schedules) would break chant's stated boundary (philosophy.mdx: "chant doesn't deploy") and imports permanent maintenance of state-machine edge cases Temporal has spent years solving. The local executor is deliberately scoped as the in-process peer to Temporal — anyone needing durability uses the Temporal lexicon (or eventually Inngest / Restate / etc. as additional lexicons).
Activity
lex00 commentedon May 30, 2026
Why this is the on-ramp, not a tutorial shim
Tracing a new user from the website to first
chant run, this issue is the missing last step of the funnel we already ship — and today that step is a wall.Funnel today:
index.mdx) — the "Deployment Ops & Drift" card sells it: "chant runstarts a durable workflow with gate steps, parallel phases, and automatic rollback." That's the hook.chant init→ write resources →chant build, and stops atchant build. It never reacheschant run.chant runspawns a Temporal worker. The card's promise now requires standing up Temporal. New user bounces.So the site sells
chant run; the Quick Start can't deliver it without Temporal. Local mode (default when notemporal.defaultProfileis set) closes that gap —chant run hellojust works in <60s on a laptop with nothing installed. The user never thinks "local executor"; they think "chant runworks." That's why the "tutorial executor" name undersells it.Ladder this creates (rung one is this issue):
chant run hello, no Temporal/Docker/cloud. First touch.Without #73, rung one requires Temporal and most users never reach rung two.
Proposed Quick Start Step 5 (for the docs task)
Add after the current Step 4 (
chant build), once local mode lands:This makes the "Deployment Ops & Drift" landing card reachable in the same five-minute sitting instead of writing a check the on-ramp can't cash. Folds into the existing Docs task ("Quick Start update").
lex00 commentedon May 30, 2026
Correction — verified Op API
My Step 5 snippet above guessed the Op shape. Checked it against the two real
*.op.tsexamples (examples/k8s-gke-microservice/ops/gke-bootstrap.op.ts,examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts). Corrected:Real API differs from my first sketch:
Op({...})(capital, default export); fieldsname/overview/taskQueue(+ optionalsearchAttributes);phase(name, steps, { parallel })helper; activities as named helpers (shell,build,kubectlApply,waitForStack,stateSnapshot) rather than{ fn, args }.Sharpens the on-ramp wording: the Op imports
Op/phase/shellfrom@intentius/chant-lexicon-temporal, so the hello world still installs the temporal lexicon package — it just never starts a server. Consistent with this issue's optional-peer-dep design (open question 1), but the Quick Start should say "no Temporal server," not "no Temporal." Install line staysnpm install --save-dev @intentius/chant-lexicon-temporal; the win ischant runworking with zero running infrastructure.lex00 commentedon May 30, 2026
Resolved the four open questions (see updated body):
lexicons/temporal/src/op/activities/; op types are already Temporal-free. Caveat:kubectl/helm/wait/gitlabcallContext.current().heartbeat(), which throws outside a worker — added a no-op heartbeat-shim task (B3).--temporal. One rule, no config inspection;defaultProfile: "local"stays a connection profile only.--jsonfrom day one over a shared step-record model.--localas a regression guard (F2).chant has no users, so backward-compat is not a constraint — dropped that section. Tasks regrouped A–G, each with a clear outcome.