Skip to content

feat: local-only tutorial executor for chant run (no workflow runtime required) #73

Closed

Description

@lex00

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:

  1. Activity location — confirmed. All activities live in lexicons/temporal/src/op/activities/ (shell, build, kubectlApply, helmInstall, waitForStack, gitlabPipeline, stateSnapshot, stateDiff, chantTeardown). Op types in packages/core/src/op/types.ts are already Temporal-free. Caveat: kubectl/helm/wait/gitlab call Context.current().heartbeat() from @temporalio/activity, which throws outside a worker. Local mode needs a no-op heartbeat shim (see tasks).

  2. Mode-selection default — local unless --temporal. One rule, no config inspection. chant run <op> runs local; --temporal opts into a cluster. The existing defaultProfile: "local" stays a connection profile only and is never consulted for mode selection (avoids the local-the-mode vs local-the-profile collision).

  3. Output — --json from day one. Executor emits a structured record per step; human output is the default renderer over the same records, --json prints the array.

  4. 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 --temporal if 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?) and OpRunResult (op, records: StepRecord[], totalMs, ok). Outcome: types exported from local-executor.ts; --json and the human renderer both consume OpRunResult.
  • A2. Sequential phase walker. Walk config.phases in 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 via Promise.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]) using TEMPORAL_ACTIVITY_PROFILES[step.profile ?? "fastIdempotent"]; retry up to maximumAttempts honoring the profile's backoff. Outcome: unit tests — (a) an activity that fails twice then succeeds passes after 3 attempts; (b) an activity exceeding startToCloseTimeout is killed and retried; (c) exhausted retries reject.
  • A5. outcomeAttribute capture. Store result in an in-memory Map<string, unknown>, resolving the from dot-path; attach to the step's StepRecord.outcome. Outcome: unit test — a stateDiff step with outcomeAttribute: {name:"Drift", from:"drifted"} yields outcome: {name:"Drift", value:false} in the record.
  • A6. onFailure compensation. try/catch the phase loop; on terminal failure run onFailure phases in reverse order, then re-throw so the CLI exits non-zero. Outcome: unit test — failing main phase triggers onFailure phases in reverse; run result ok === false and the call rejects.
  • A7. Gate/schedule rejection. Encountering a gate step (or schedule) throws LocalGateUnsupportedError with 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-temporal and returns a Map<string, Fn> keyed by export name; step.fn is resolved against it. Outcome: local executor resolves shell/build/etc. by name; unknown fn throws 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() in kubectl/helm/wait/gitlab safe outside a worker — either a try/catch wrapper or a heartbeat() helper in the lexicon that no-ops when Context.current() throws. Outcome: calling kubectlApply from 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's runOp body (worker spawn + workflow submit) into runOpTemporal unchanged. Outcome: --temporal path behaves exactly as current chant run does today; existing run tests pass against runOpTemporal.
  • 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/--temporal flag parsing. Register both flags in the run command arg spec (--local is the explicit form of the default). Outcome: chant run x --local and chant run x --temporal both parse; --help lists them.
  • C5. Gate/schedule pre-flight in local mode. Before executing, if the Op contains a gate/schedule and --temporal was not passed, fail fast with the LocalGateUnsupportedError message (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/--report print "not available in local mode — pass --temporal or configure a profile" when no Temporal context. Outcome: each subcommand without --temporal prints the message and exits non-zero; with --temporal they behave as today.

D. Output — packages/core/src/op/local-output.ts

  • D1. Human renderer. Phase banners, per-step ✓/✗ fn(args) + duration, [outcome] name=value lines, final Op "<name>" completed in <t> summary. Outcome: snapshot test matches the format in the issue example; failures render ✗ and the error.
  • D2. --json renderer. --json prints OpRunResult as JSON to stdout (and nothing else on stdout); human logs go to stderr. Outcome: chant run x --json | jq .ok works; test asserts stdout is valid JSON and parseable to OpRunResult.

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 + --json shape (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 a hello.op.ts (one shell activity). Outcome: cd examples/<name> && chant build && chant run hello succeeds 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 in guide/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 hello with no Temporal. Outcome: Quick Start no longer mentions starting a Temporal server for the first run.
  • G3. CLI flag docs. Document --local/--temporal/--json and the "local unless --temporal" default in cli/run.mdx. Outcome: flags and default rule documented; docs-check passes.

Done when

  • A user with chant installed but no Temporal can chant init → write hello.op.ts → chant build → chant run hello and see it work in under a minute (F1, G2).
  • Drift / observation Ops run under local mode in CI without a Temporal server (F2).
  • --temporal runs the existing Temporal path unchanged (C1).
  • Local mode emits both human and --json output 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

  1. lex00 commented on May 30, 2026

    @lex00
    ContributorAuthor

    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:

    1. Landing (index.mdx) — the "Deployment Ops & Drift" card sells it: "chant run starts a durable workflow with gate steps, parallel phases, and automatic rollback." That's the hook.
    2. Get Started → Quick Start.
    3. Quick Start walks install → chant init → write resources → chant build, and stops at chant build. It never reaches chant run.
    4. Following the Ops link, the user hits the wall: chant run spawns 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 no temporal.defaultProfile is set) closes that gap — chant run hello just works in <60s on a laptop with nothing installed. The user never thinks "local executor"; they think "chant run works." That's why the "tutorial executor" name undersells it.

    Ladder this creates (rung one is this issue):

    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:

    Step 5: Run an Op

    Resources synthesize to artifacts. Ops execute — phased, with retries and rollback. Write a minimal one:

    // hello.op.ts
    export const hello = op({
      phases: [
        { name: "Greet", steps: [{ fn: "shell", args: { cmd: "echo hello from chant" } }] },
      ],
    });
    chant run hello
    [phase] Greet
      ✓ shell(cmd="echo hello from chant")   42ms
    Op "hello" completed in 0.1s
    

    No Temporal required — chant runs Ops locally by default. For durable resume, gates, and schedules, configure a Temporal profile and pass --temporal. See Local vs Temporal.

    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").

  2. lex00 commented on May 30, 2026

    @lex00
    ContributorAuthor

    Correction — verified Op API

    My Step 5 snippet above guessed the Op shape. Checked it against the two real *.op.ts examples (examples/k8s-gke-microservice/ops/gke-bootstrap.op.ts, examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts). Corrected:

    // hello.op.ts
    import { Op, phase, shell } from "@intentius/chant-lexicon-temporal";
    
    export default Op({
      name: "hello",
      overview: "Minimal local Op — no Temporal server required",
      taskQueue: "hello",
      phases: [
        phase("Greet", [
          shell("echo hello from chant"),
        ]),
      ],
    });
    chant run hello
    [phase] Greet
      ✓ shell(cmd="echo hello from chant")   42ms
    Op "hello" completed in 0.1s
    

    Real API differs from my first sketch: Op({...}) (capital, default export); fields name / overview / taskQueue (+ optional searchAttributes); 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/shell from @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 stays npm install --save-dev @intentius/chant-lexicon-temporal; the win is chant run working with zero running infrastructure.

  3. lex00 commented on May 30, 2026

    @lex00
    ContributorAuthor

    Resolved the four open questions (see updated body):

    1. Activities confirmed in lexicons/temporal/src/op/activities/; op types are already Temporal-free. Caveat: kubectl/helm/wait/gitlab call Context.current().heartbeat(), which throws outside a worker — added a no-op heartbeat-shim task (B3).
    2. Mode default = local unless --temporal. One rule, no config inspection; defaultProfile: "local" stays a connection profile only.
    3. --json from day one over a shared step-record model.
    4. CI runs an example Op in --local as 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions