lua-cli 3.32.2 → 3.32.4

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.
@@ -61,7 +61,7 @@ Config: `name`, `description?`, `inputSchema`, `outputSchema?`, `budget?` (`maxC
61
61
  | `.specialistStep(id, { role, prompt, … })` | Ephemeral role on the owning agent (D25) |
62
62
  | `.toolStep(id, { toolId, input? })` | One tool call — `toolId` must name a `LuaTool` the compiler can see as a tool primitive (declared in its own module and exported, or registered on the agent); a tool object defined inline in the workflow file is not detected and fails `WORKFLOW_TOOL_REF_UNRESOLVED` at compile |
63
63
  | `.map(descriptors, { id })` | Data reshaping — `id` is **required** once the workflow has ≥ 2 maps (`map-id-required`) |
64
- | `.parallel([...arms], { merge? })` | Concurrent arms; a `[map, step]` pair is a legal arm (lowered to an implicit subrun) |
64
+ | `.parallel([...arms], { merge? })` | Concurrent arms; a `[map, step]` pair is a legal arm — the step runs with the map as its own `input` (the arm node carries `input`, like a `toolStep` with one). Not a `foreach` / loop body: those receive their item / the previous output (`mapping-placement` at `.commit()`) — map the items before the container instead |
65
65
  | `.switch([[predicate, armRef]...], otherwise?)` / `.branch(...)` | Conditional (exclusive / inclusive) |
66
66
  | `.foreach(step, { concurrency?, maxItems?, chunk?, rateLimit? })` / `.foreach({ items })` | Fan-out over an upstream array |
67
67
  | `.dowhile(ref, predicate, { maxIterations, intervalSeconds? })` / `.dountil(...)` | Loop |
@@ -2,20 +2,38 @@
2
2
 
3
3
  _Source of truth: workflows-spec 06 §6.4. This page is the developer summary; the spec is normative._
4
4
 
5
- `.approval(id, cfg)` parks the run until a person decides. The node completes with the decision as data — branchable, never an exception. A human decision completes it with
5
+ `.approval(id, cfg)` parks the run until a person decides. The node completes with the decision as data — branchable, never an exception. The output is **one** shape everywhere — the engine, `lua workflows run --approve/--deny` offline and the shipped examples — `WorkflowApprovalOutput` (`@lua/shared-types`, LUA-751):
6
6
 
7
7
  ```typescript
8
8
  {
9
- approved: boolean;
9
+ approved: boolean; // false on a denial AND on a timeout under onTimeout:'deny'
10
+ decision: 'approved' | 'denied' | 'timed_out';
11
+ text: string; // the approver's note when one was left, else the decision word — `${stepResults.<id>.text}`
10
12
  note?: string; // the approver's note, when one was left
11
13
  editedPayload?: unknown; // only when the approver edited (see below)
12
- editRevision: number; // 0 when the payload was never edited
13
- decidedBy: { id?: string; kind: string }; // who decided
14
+ editRevision?: number; // 0 when the payload was never edited; absent on a timeout
15
+ decidedBy?: { id?: string; kind?: string }; // who decided (the resumer's actor); absent on a timeout — the surface it came through is on the approval link / event, never here
14
16
  evidence?: string[]; // decision artefact ids frozen with the decision
17
+ timedOut?: boolean; escalations?: number; // the timeout leg (below)
18
+ items?: Array<{ index; itemKey?; decision; decidedBy?; payload }>; // per-item approvals
15
19
  }
16
20
  ```
17
21
 
18
- A timeout that ends the chain in `'deny'` completes it with the sweep's shape instead — `{ approved: false, timedOut: true, escalations: <hops> }` (no `decidedBy`, `editRevision` or `evidence`); `'continue'` is treated as `'deny'` for approvals; `'cancel-run'` cancels the run. The original payload is not echoed back — read it from the step's own input (`stepResults`), not from the output.
22
+ A timeout that ends the chain in `'deny'` completes it with the sweep's envelope — `{ approved: false, decision: 'timed_out', text: 'timed_out', timedOut: true, escalations: <hops> }` (no `decidedBy`, `editRevision` or `evidence`); `'continue'` is treated as `'deny'` for approvals; `'cancel-run'` cancels the run. **The original payload is not echoed back** — read it from the step's own input (`getInitData()`, `getStepResult('<id>')`, `${stepResults.<id>}`), not from the output; `editedPayload` replaces it only when the approver edited. `onDeny` defaults to `'continue'` (a denial is data — the next step runs with `{ approved: false, … }`); `onDeny: 'fail'` fails the step `approval_denied` on denials and timeouts alike.
23
+
24
+ The step after an approval declares the members it reads and lets the rest ride (`.passthrough()` — the engine validates a step's input with `additionalProperties: false` otherwise):
25
+
26
+ ```typescript
27
+ inputSchema: z.object({ approved: z.boolean(), editedPayload: refundRequest.optional() }).passthrough(),
28
+ async execute({ inputData, getStepResult }) {
29
+ if (!inputData.approved) return { refundId: null };
30
+ const r = inputData.editedPayload ?? getStepResult<RefundRequest>('refundRequest'); // the edited details, else what the approver saw
31
+
32
+ ```
33
+
34
+ Map the payload the approver should see ahead of the approval (`.map({ ticketId: fromInit('ticketId'), amount: fromInit('amount') }, { id: 'refundRequest' })`) when it is not the previous step's whole output: `editedPayloadSchema` describes THAT payload, and an edit is judged on the pointers that changed against it.
35
+
36
+ Offline, `lua workflows run --approve <id>` completes the node with `decidedBy: { id: 'local', kind: 'user' }` (exactly the resumer's `{ id, kind }` — no `via`: the engine never writes a surface into a step output) and `editRevision: 0`; `--approve <id>=@edited.json` carries the FULL edited payload as `editedPayload` with `editRevision: 1`; `--deny <id>[=<reason>]` makes the reason the `note` (and the `text`). `packages/shared-types/src/__fixtures__/workflow-approval-output.fixture.json` is the one fixture the engine, the driver and the examples are pinned to.
19
37
 
20
38
  ## Approver specs
21
39
 
@@ -32,10 +50,12 @@ A timeout that ends the chain in `'deny'` completes it with the sweep's shape in
32
50
 
33
51
  `editable: true` + `editablePaths: ['amount', 'drafts[*].body']` (the `a.b[0].c` / `[*]` grammar) lets the approver patch the payload. The sequence is **fetch → patch → approve-with-fingerprint**: the approve call echoes the fingerprint of the revision the approver saw, so a concurrent edit forces a re-read. Out-of-grammar paths are refused; `editedPayloadSchema` validates the result.
34
52
 
53
+ From the CLI: `lua workflows approval-payload <runId> --approval <wfa_…>` prints the current payload with its `payloadFingerprint`, `editRevision` and the editable paths (a large payload answers the arrays to page — `--path drafts [--limit n] [--cursor c]` reads one page); then `lua workflows approve <runId> --approval <wfa_…> --edit @edited.json --fingerprint <payloadFingerprint>`. A stale fingerprint is `409 PAYLOAD_MISMATCH` — refetch and retry.
54
+
35
55
  ## Where approvals surface
36
56
 
37
57
  The desktop inbox and run detail carry the full card (per-item rows, edits, escalation state). **Text channels (WhatsApp/SMS/email) approve or deny the whole batch only** — no per-item decisions, no edits.
38
58
 
39
59
  Per-item approvals (`itemsPath` / `itemApprover` / `itemTimeout`) fan one node out to one decision per item — see [Per-item approvals](./per-item-approvals.md).
40
60
 
41
- Offline: `--approve <id>[=@payload]` / `--deny <id>[=@reason]` pre-answer the prompt; an edited payload is checked with the same `matchesEditablePath` + `editedPayloadSchema` the server uses.
61
+ Offline: `--approve <id>[=@payload]` / `--deny <id>[=@reason]` pre-answer the prompt; an edited payload is the FULL payload, checked the way the server checks an inline edit — only the pointers that CHANGED against the payload the approval showed must sit inside `editablePaths` (`changedPointers` + `matchesEditablePath` from `@lua/workflow-graph`), then `editedPayloadSchema`. `editable: true` with no `editablePaths` is whole-root rights — any member may change — as on the server. Because the change set is judged against the payload the approval actually showed, an edit outside `editablePaths` fails exit 2 `edit-path-not-allowed` **when the approval's turn comes**, i.e. after the steps before it have run, not before the run starts (`editedPayloadSchema` and `approval-not-editable` are still checked up-front).
@@ -41,6 +41,6 @@ lua workflows goals create -i outreach \
41
41
 
42
42
  ## Errors and exit codes
43
43
 
44
- The usual `lua workflows` codes: 0 ok · 1 API refusal · 2 usage · 3 not found. The goal routes' refusals are printed with their hint: `GOAL_NOT_ACTIVE {status}` (only an active goal pauses, only a paused one resumes, a done/closed goal stays closed), `GOAL_CAP {cap}` (close or finish a goal first), `VALIDATION_FAILED {issues}` (each issue with its path — `goal-judge-schema-missing-done`, `goal-cadence-invalid`, `goal-objective-too-long`, `ephemeral-role-required`, …), `GOAL_MAX_RUNS_INVALID`, `WORKFLOW_NOT_ON_AGENT`.
44
+ The usual `lua workflows` codes: 0 ok · 1 API refusal (a 4xx other than 404) · 2 usage · 3 not found · 11 unavailable (5xx / connection refused / timeout); an escaped 401 / 403 is 9 / 10 — `lua --help` prints the full table. The goal routes' refusals are printed with their hint: `GOAL_NOT_ACTIVE {status}` (only an active goal pauses, only a paused one resumes, a done/closed goal stays closed), `GOAL_CAP {cap}` (close or finish a goal first), `VALIDATION_FAILED {issues}` (each issue with its path — `goal-judge-schema-missing-done`, `goal-cadence-invalid`, `goal-objective-too-long`, `ephemeral-role-required`, …), `GOAL_MAX_RUNS_INVALID`, `WORKFLOW_NOT_ON_AGENT`.
45
45
 
46
- `lua workflows schedules delete` on a goal's cadence Job is refused (`goal_schedule`) — use `goals pause` / `goals close`; see [schedules.md](./schedules.md).
46
+ `lua workflows schedules delete` on a goal's cadence Job is refused (`goal_schedule`) while the goal is active or paused — use `goals pause` / `goals close` (closing retires the Job). Once the goal is done or closed (`goals close` then answers `GOAL_NOT_ACTIVE`), a cadence Job that still lingers is removed with `schedules delete <jobId>`; see [schedules.md](./schedules.md).
@@ -19,7 +19,7 @@ A parked step waits for exactly one of:
19
19
 
20
20
  `onError:'continue'` never parks: the step is `failed` on the ledger, the run goes on, and every successor sees the step's **continued-failure value** `{ __lua_workflow:'continued_failure', failed:true, error:{code,message}, text:'' }` under `stepResults.<id>` (the default input, `${stepResults.<id>.text}` → `''`, `getStepResult(id)`, a `conditional` on `stepResults.<id>.failed`, and the parallel / foreach join entry). A `retry-step` on such a row while the run is still running re-arms it, but a successor that already consumed the value is not re-run.
21
21
 
22
- `fail` (or cancelling the run) applies the step's failure as-is. From the CLI: `lua workflows status <runId>` shows the park, `lua workflows resume` / the desktop inbox carry the verbs.
22
+ `fail` (or cancelling the run) applies the step's failure as-is. From the CLI: `lua workflows status <runId>` shows the park and names the verbs; `lua workflows retry-step <runId> --step <id>` re-runs, `lua workflows resolve-step <runId> --step <id> --outcome skip|complete|fail [--output <json|@file>] [--note …]` decides (R37 — `complete` needs `--output`, validated against the step's `outputSchema`; a second decision on the same park is a 200 no-op that names who decided). The desktop inbox carries the same three, plus a repair run.
23
23
 
24
24
  ## Repair runs
25
25
 
@@ -15,10 +15,16 @@ Two options were on the table:
15
15
 
16
16
  Why A: the wrapper drags the whole runtime plane (`@lua/sandbox-runtime` → `@lua/shared-sandbox` polyfills, artefact/dataset contexts) into the CLI bundle otherwise; every project that can run a workflow-script replay today is a monorepo/dev checkout that already has the runtime in `node_modules`; and resolving from the **project** rather than the CLI guarantees the wrapper version matches the runner the project deploys.
17
17
 
18
+ ## Update — LUA-751: the wrapper text is bundled, the project's runtime still wins
19
+
20
+ Option A left every plain `lua init` project unable to run a script offline (`lua workflows run adversarial-verify` → exit 3 `REPLAY_RUNTIME_UNAVAILABLE`: the runtime is neither a template dependency nor on npm). The fix keeps A's ordering and adds the missing fallback without option B's fork: `@lua/sandbox-runtime` now ships a **dependency-free subpath entry**, `@lua/sandbox-runtime/workflow-script-wrapper` (the replay-round source, `buildWorkflowScriptWrapper`, the protocol window), which lua-cli inlines through its usual `noExternal: [/^@lua\//]` — the same file the runner builds, versioned with the CLI release, none of the platform-API clients of the root index. The context is the `workflow-script` surface of `@lua/shared-sandbox` (`buildWorkflowScriptGlobals` + the runtime handle), i.e. what `createSandboxContext({ siteType: 'workflow-script' })` builds.
21
+
22
+ `loadLocalScriptRunner(projectDir, { runtime })`: `'auto'` (default) resolves the project's `@lua/sandbox-runtime` first and falls back to the bundled wrapper; `'project'` keeps the strict behaviour (`REPLAY_RUNTIME_UNAVAILABLE`, never a silent "stable" against a different wrapper); `'bundled'` never looks. The report carries `runtime: 'project' | 'bundled'` and the printed report names it. The `journalProtocolVersion` gate applies to the bundled wrapper exactly as on the runner (`runtime_incompatible` outside its window).
23
+
18
24
  ## What it means for a project
19
25
 
20
- - Inside the monorepo (or any project that lists `@lua/sandbox-runtime` in its devDependencies): `replay --local` and `lua test workflow <script>` work as-is.
21
- - Elsewhere: `REPLAY_RUNTIME_UNAVAILABLE: replay --local needs @lua/sandbox-runtime resolvable from the project (…)` add the devDependency, or run the verb from a monorepo checkout with `--project <dir>`.
26
+ - Inside the monorepo (or any project that lists `@lua/sandbox-runtime` in its devDependencies): `replay --local` and `lua test workflow <script>` run the project's runtime — parity with the runner it deploys.
27
+ - Elsewhere: both verbs run the wrapper bundled with the CLI; the replay report says `wrapper: bundled with this lua-cli`. A divergence found on the bundled wrapper is worth re-running from a monorepo checkout with `--project <dir>` before it is triaged as a runner NDE.
22
28
  - The graph-form `replay --local` (R4/R5 → `replayLedger`) has no runtime dependency and always works. It re-evaluates every derived row over the same ancestor context the engine used — including a `failed` ancestor under `onError:'continue'`, which binds its continued-failure value (`{ __lua_workflow:'continued_failure', failed:true, error, text:'' }`) exactly as the engine did, so a mapping over one replays stable rather than `binding_unresolved`.
23
29
 
24
30
  ## Exit matrix (script form)
@@ -26,7 +32,7 @@ Why A: the wrapper drags the whole runtime plane (`@lua/sandbox-runtime` → `@l
26
32
  | Exit | Meaning |
27
33
  | ---- | ------------------------------------------------------------------------------------------------------------ |
28
34
  | 0 | stable — every async journal entry re-issued with the same `callHash` |
29
- | 3 | runtime unavailable (`REPLAY_RUNTIME_UNAVAILABLE`) or the run/bundle could not be fetched |
35
+ | 3 | the run/bundle could not be fetched (`REPLAY_RUNTIME_UNAVAILABLE` only under `{ runtime: 'project' }`) |
30
36
  | 4 | `LEDGER_DIVERGENCE` / `JOURNAL_DIVERGENCE` — at least one `hash_mismatch` / `journal_longer_than_script` row |
31
37
 
32
38
  ## Related
@@ -2,25 +2,34 @@
2
2
 
3
3
  _Source of truth: workflows-spec 07 §7.1. This page is the developer summary; the spec is normative._
4
4
 
5
- `schedule: { type:'cron', expression: '0 9 * * 1', timezone: 'Europe/London' }` on `createWorkflow` fires runs on the cron grid; `scheduleInput` is the run input. `concurrencyPolicy:'forbid'` skips a fire while a run is in flight (the skip is recorded, never queued). Manage with `lua workflows activate` / `deactivate`; `lua workflows backfill` starts missed occurrences by hand (deduplicated on `backfill:<workflowId>:<occurrenceIso>`).
5
+ `schedule: { type:'cron', expression: '0 9 * * 1', timezone: 'Europe/London' }` on `createWorkflow` fires runs on the cron grid; `scheduleInput` is the run input. `concurrencyPolicy:'forbid'` skips a fire while a run is in flight (the skip is recorded, never queued). Manage with `lua workflows activate` / `deactivate`, or create and drive a schedule from the CLI without a `schedule:` block — `lua workflows schedules create|pause|resume|patch|delete` below; a re-enable with `--backfill-now` starts the missed occurrences (deduplicated on `backfill:<workflowId>:<occurrenceIso>`).
6
6
 
7
7
  ## Re-enabling a schedule
8
8
 
9
- A paused or auto-disabled schedule never replays missed fires by itself; opt in with `schedule.backfillOnEnable: { maxOccurrences }` (or `lua workflows activate --backfill` for one re-enable) and the most recent misses start as one batch, deduplicated against `lua workflows backfill` by the shared `backfill:<workflowId>:<occurrenceIso>` key, capped, and summarised in your inbox.
9
+ A paused or auto-disabled schedule never replays missed fires by itself; opt in with `schedule.backfillOnEnable: { maxOccurrences }` (or `lua workflows schedules resume <jobId> --backfill-now` for one re-enable, `--backfill-on-enable <n>` to persist the opt-in) and the most recent misses start as one batch, deduplicated by the shared `backfill:<workflowId>:<occurrenceIso>` key, capped, and summarised in your inbox.
10
10
 
11
11
  Offline, schedules do not fire (`backfillOnEnable` is not emulated) — start runs with `lua test workflow` / `lua workflows start`.
12
12
 
13
13
  ## From the CLI
14
14
 
15
- Schedules are `Job{kind:'workflow'}` rows on the agent; the CLI reads them through `GET /workflows/:agentId/schedules` and removes one through R28.
15
+ Schedules are `Job{kind:'workflow'}` rows on the agent; the CLI reads them through `GET /workflows/:agentId/schedules`, creates one through R27, pauses / resumes it through R56 and removes it through R28.
16
16
 
17
17
  ```bash
18
18
  lua workflows schedules list -i outreach # this workflow's schedule Jobs (omit -i for every workflow of the agent)
19
19
  lua workflows schedules list --json # { success, data: { items: [{ jobId, workflowId, trigger, paused, nextRunAt, lastFiredAt?, consecutiveFailures, autoDisabled?, goalId? }] } }
20
+ lua workflows schedules create outreach --cadence '0 9 * * 1' --timezone Europe/London --input '{"segment":"trial"}'
21
+ # R27 — the goals cadence grammar: --cadence (cron | JSON | @file, ≤ 5) [--timezone] or --every 30m;
22
+ # [--tag] [--notify emailApp|email|app|off] [-v <version>] [--budget-credits <n>] [--backfill-on-enable <n>].
23
+ # Create-or-REPLACE: a second create swaps the workflow's schedule.
24
+ lua workflows schedules pause <jobId> # R56 { paused:true }
25
+ lua workflows schedules resume <jobId> [--backfill-now] [--backfill-on-enable <n|none>] # R56 { paused:false, … } — the one-shot backfill rides a re-enable only
26
+ lua workflows schedules patch <jobId> --paused true|false [--backfill-on-enable <n|none>] [--backfill-now]
20
27
  lua workflows schedules delete <jobId> [--yes] # R28 — asks first unless --yes
21
28
  lua workflows view outreach # the "Schedules:" line + table; --json carries the same rows under `schedules`
22
29
  ```
23
30
 
31
+ Every verb prints one `{ success, data | error }` envelope under `--json`; exit codes are the usual ones (`2` usage before any call, `3` an unknown workflow / `SCHEDULE_NOT_FOUND`, `1` a refusal — `SCHEDULE_CAP`, a `VALIDATION_FAILED` whose issues are printed, `goal_schedule`).
32
+
24
33
  Each row shows the trigger (`cron 0 9 * * 1 (Europe/London)` · `every 3600s` · `once <iso>`), the next fire, the last fire, its status (`active` · `paused` · `auto-disabled` after the PRO-726 strike limit) and the strike count.
25
34
 
26
- **Goal-owned schedules.** A goal's cadence is a schedule Job too; it is tagged with the goal's id (`goalId` in `--json`, the `Goal` column in the table). The CLI refuses to delete it — `schedules delete` on such a row answers `goal_schedule` (exit 1) without calling the API, the same refusal the chat tool gives — because unscheduling a goal's job is recorded as a failure of the goal, never as stopping it. Stop the goal instead: `lua workflows goals pause <goalId>` (it can come back) or `lua workflows goals close <goalId>` (final). The server refuses it too: R28 answers `409 GOAL_SCHEDULE {goalId}` for every caller (SDK, desktop, curl), and the CLI renders that with the same message. See [goals.md](./goals.md).
35
+ **Goal-owned schedules.** A goal's cadence is a schedule Job too; it is tagged with the goal's id (`goalId` in `--json`, the `Goal` column in the table). `schedules pause|resume|patch` on such a row refuses (`goal_schedule`, exit 1, nothing called) whatever the goal's state — a goal's Job follows its goal (`goals pause` / `goals resume`), never the other way round. While the goal is **active or paused** the CLI refuses to delete it — `schedules delete` on such a row answers `goal_schedule` (exit 1) without calling the API, the same refusal the chat tool gives — because unscheduling a live goal's job is recorded as a failure of the goal, never as stopping it. Stop the goal instead: `lua workflows goals pause <goalId>` (it can come back) or `lua workflows goals close <goalId>` (final; closing retires the cadence Job with it). The server refuses it too while the goal is live: R28 answers `409 GOAL_SCHEDULE {goalId}` for every caller (SDK, desktop, curl), and the CLI renders that with the same message. Once the goal has **ended** (`done` / `closed`, or its row is gone) a cadence Job that still lingers — one left behind before LUA-760, or one whose retirement did not land — is R28's to remove: `schedules delete <jobId>` goes through and retires it (the engine's sweep does the same on its own within one interval). A goal the CLI cannot read refuses (fail closed). See [goals.md](./goals.md).
@@ -11,8 +11,8 @@ export const meta = {
11
11
  phases: [{ title: 'Draft' }, { title: 'Judge' }, { title: 'Synthesize' }],
12
12
  };
13
13
  const drafts = await parallel([
14
- agent('Draft, technical angle', { phase: 'Draft' }),
15
- agent('Draft, market angle', { phase: 'Draft' }),
14
+ () => agent('Draft, technical angle', { phase: 'Draft' }), // thunks — `parallel` starts each arm itself (04 §4.3.2)
15
+ () => agent('Draft, market angle', { phase: 'Draft' }),
16
16
  ]);
17
17
  const scores = await foreach(drafts, (d) => agent(`Score this draft: ${d}`, { phase: 'Judge' }));
18
18
  const picked = await step('pick-winner', () => ({ winner: drafts[scores.indexOf(Math.max(...scores))] }));
@@ -39,7 +39,7 @@ Run in this order — the same order lua-api re-validates on push (one implement
39
39
 
40
40
  ## `lua test workflow <name>` on a script
41
41
 
42
- Runs the file through the runner's own replay wrapper (parity context) in an offline tick loop: every tick re-runs the script against the journal so far, the wrapper parks with its async intents, the driver settles each with a **fake completion** (no API call) and re-ticks until `done`/`bailed`/`failed`. Steering:
42
+ Runs the file through the runner's own replay wrapper (parity context) in an offline tick loop: every tick re-runs the script against the journal so far, the wrapper parks with its async intents, the driver settles each with a **fake completion** (no API call) and re-ticks until `done`/`bailed`/`failed`. The wrapper is **bundled with the CLI** (LUA-751 — the dependency-free `@lua/sandbox-runtime/workflow-script-wrapper` entry over the `workflow-script` context of `@lua/shared-sandbox`, the same surface the runner builds), so a plain `lua init` project runs scripts offline; a project that lists `@lua/sandbox-runtime` in its devDependencies (a monorepo checkout) runs that copy instead. A fake `approval()` resolves the same `WorkflowApprovalOutput` the graph form's `--approve` completes with. Steering:
43
43
 
44
44
  | Flag | Effect |
45
45
  | ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
@@ -49,7 +49,7 @@ Runs the file through the runner's own replay wrapper (parity context) in an off
49
49
  | `--ledger-out <file>` | the local journal (`{ form:'script', journal[] }`) |
50
50
  | `--json` | the result envelope |
51
51
 
52
- Exit 0 `done`/`bailed`; 4 `failed` (`SCRIPT_THREW`, `SCRIPT_DEADLOCK` — parked with zero intents, `SCRIPT_TICK_LIMIT`); 3 when `@lua/sandbox-runtime` is not resolvable from the project (`REPLAY_RUNTIME_UNAVAILABLE`, see `replay-local.md`); 2 on flag grammar.
52
+ Exit 0 `done`/`bailed`; 4 `failed` (`SCRIPT_THREW`, `SCRIPT_DEADLOCK` — parked with zero intents, `SCRIPT_TICK_LIMIT`); 2 on flag grammar. (Exit 3 `REPLAY_RUNTIME_UNAVAILABLE` is reserved for `replay --local --runtime project` on a project without `@lua/sandbox-runtime`, see `replay-local.md`.)
53
53
 
54
54
  ## `step(label, fn, { timeoutSeconds? })`
55
55
 
@@ -8,26 +8,26 @@ _Source of truth: workflows-spec 03 §3.9 (WF-204/WF-225/WF-332). This page is t
8
8
 
9
9
  ## Steering flags — fully scriptable, no stdin
10
10
 
11
- | Flag | Effect |
12
- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `--input @file\|<json>` | Run input (validated against `inputSchema`) |
14
- | `--step-output <id>=@file\|<json>` (rep.) | Completes the row **without** running it — validated against the step's `outputSchema`. This is how a predicate over an agent output gets BOTH truth values under `--agents fake` |
15
- | `--approve <id>[=@payload]` · `--deny <id>[=@reason]` | Pre-answer approvals (edited payloads use the server's `matchesEditablePath` + `editedPayloadSchema`) |
16
- | `--signal <name>=@file\|<json>` (rep.) | Pre-supply `waitForSignal` payloads in order of arrival |
17
- | `--fixtures <dir>` / `--record <dir>` | Replay / record agent+tool outputs (`<stepId>.<attempt>.json`); a missing fixture is exit **5** `FIXTURE_MISSING`, never a silent fake; `--step-output` wins over a fixture |
18
- | `--from-run <runId>` | Seed every `completed` step from a real run (same math as repair runs); graph drift asks for `--force` |
19
- | `--park <id>` (rep.) | Simulate a platform-fault park of a `sideEffects:'external'` step — then `retry / skip / complete / fail`, the production verbs (see [When a step parks](./recovery.md)) |
20
- | `--fast-retries` | Collapse retry backoff waits to 0 |
21
- | `--real-time` | Actually wait on `sleep`/backoff instead of fast-forwarding the virtual clock |
22
- | `--now <iso>` | Pin the virtual clock start (resolves `sleepUntil` dateFrom, business-hours deadlines) |
23
- | `--artefacts-dir <dir>` | Back `ctx.artefacts.*` on disk (`<dir>/<artefactId>` + `<artefactId>.meta.json`, ids `local-<n>`) |
24
- | `--env KEY=value` (rep.) | The local `env.template()` overlay — a missing key is exit 2 `env-template-missing`; values never reach `--ledger-out` |
25
- | `--step-wall <s>` | The cap on every step wall, default 600 — a node's own `timeoutSeconds` (or its step definition's) applies below it; a step past its wall fails `TIMEOUT` and its retry policy applies; the attempt's `ctx.signal` aborts at the wall — the driver cannot kill in-process code, so a step that ignores the signal keeps running in the background until it settles (LUA-689) |
26
- | `--max-foreach-items <n>` | Override the local foreach cap |
27
- | `--agents fake\|live` | Fake stubs (default) or the dev API |
28
- | `--ledger-out <file>` | Write the in-memory ledger (steps, outputs, attempts, effects, events) — the input of `replay --local` |
29
- | `--json` | Machine-readable result envelope |
30
- | `--workspace <dir>` | The checkout Job-tier **code** steps run in: gives them `ctx.workspace` and `ctx.exec` / `ctx.$` against that directory — the pod's allowlist (`git`, `gh`, `npm`, …, argv only, no shell) on your own PATH and git credentials. Without it a Job-tier step's `$` rejects `ExecError{code:'EXEC_UNAVAILABLE'}` naming the flag |
11
+ | Flag | Effect |
12
+ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `--input @file\|<json>` | Run input (validated against `inputSchema`) |
14
+ | `--step-output <id>=@file\|<json>` (rep.) | Completes the row **without** running it — validated against the step's `outputSchema`. This is how a predicate over an agent output gets BOTH truth values under `--agents fake` |
15
+ | `--approve <id>[=@payload]` · `--deny <id>[=@reason]` | Pre-answer approvals — the node completes with the engine's own envelope, `WorkflowApprovalOutput` (`{ approved, decision, text, note?, editedPayload?, editRevision?, decidedBy? }`, see [Approvals](./approvals.md)); an edited payload is the FULL payload, judged like the server's inline edit (`changedPointers` against the payload the approval showed + `matchesEditablePath`, then `editedPayloadSchema`); `onDeny` defaults to `'continue'` as on the engine (LUA-751). `editable: true` with no `editablePaths` is whole-root rights (any member may change), as on the server. Because the change set is judged against the payload the approval actually showed, a change outside `editablePaths` fails exit 2 `edit-path-not-allowed` **when the approval's turn comes — after the steps before it have run** — not before the run starts; `editedPayloadSchema` is still checked up-front |
16
+ | `--signal <name>=@file\|<json>` (rep.) | Pre-supply `waitForSignal` payloads in order of arrival |
17
+ | `--fixtures <dir>` / `--record <dir>` | Replay / record agent+tool outputs (`<stepId>.<attempt>.json`); a missing fixture is exit **5** `FIXTURE_MISSING`, never a silent fake; `--step-output` wins over a fixture. An agent fixture's `output` is either the `{ text, object? }` reply envelope `--record` writes (the `object` wins, else the text is parsed under the `outputSchema`) or the structured value itself, taken as-is (LUA-751) — a value that is exactly `{ text }` reads as a text reply; record it as `{ object: { text } }` |
18
+ | `--from-run <runId>` | Seed every `completed` step from a real run (same math as repair runs); graph drift asks for `--force` |
19
+ | `--park <id>` (rep.) | Simulate a platform-fault park of a `sideEffects:'external'` step — then `retry / skip / complete / fail`, the production verbs (see [When a step parks](./recovery.md)) |
20
+ | `--fast-retries` | Collapse retry backoff waits to 0 |
21
+ | `--real-time` | Actually wait on `sleep`/backoff instead of fast-forwarding the virtual clock |
22
+ | `--now <iso>` | Pin the virtual clock start (resolves `sleepUntil` dateFrom, business-hours deadlines) |
23
+ | `--artefacts-dir <dir>` | Back `ctx.artefacts.*` on disk (`<dir>/<artefactId>` + `<artefactId>.meta.json`, ids `local-<n>`) |
24
+ | `--env KEY=value` (rep.) | The local `env.template()` overlay — a missing key is exit 2 `env-template-missing`; values never reach `--ledger-out` |
25
+ | `--step-wall <s>` | The cap on every step wall, default 600 — a node's own `timeoutSeconds` (or its step definition's) applies below it; a step past its wall fails `TIMEOUT` and its retry policy applies; the attempt's `ctx.signal` aborts at the wall — the driver cannot kill in-process code, so a step that ignores the signal keeps running in the background until it settles (LUA-689) |
26
+ | `--max-foreach-items <n>` | Override the local foreach cap |
27
+ | `--agents fake\|live` | Fake stubs (default) or the dev API |
28
+ | `--ledger-out <file>` | Write the in-memory ledger (steps, outputs, attempts, effects, events) — the input of `replay --local` |
29
+ | `--json` | Machine-readable result envelope |
30
+ | `--workspace <dir>` | The checkout Job-tier **code** steps run in: gives them `ctx.workspace` and `ctx.exec` / `ctx.$` against that directory — the pod's allowlist (`git`, `gh`, `npm`, …, argv only, no shell) on your own PATH and git credentials. Without it a Job-tier step's `$` rejects `ExecError{code:'EXEC_UNAVAILABLE'}` naming the flag |
31
31
 
32
32
  ## Semantics worth knowing
33
33
 
@@ -37,6 +37,10 @@ _Source of truth: workflows-spec 03 §3.9 (WF-204/WF-225/WF-332). This page is t
37
37
  - **`suspend`** re-runs `execute` from the top with `resumeData` set — the documented Mastra semantics, exercised offline.
38
38
  - **`ctx.once`** settles against the in-memory effects map: a re-drive over the same ledger sends nothing twice. (On the platform it is served to worker-tier steps; the Job pod does not serve `ctx.once` yet — LUA-706 — so a Job-tier step dedupes on `occurrenceId` itself.)
39
39
  - **`ctx.getInitData()`** is `null` on a run without `--input` and **`ctx.runtime`** is `{ trigger:'sdk', principalKind:'user' }` — the same shapes the pod and the worker serve, so a step that branches on them behaves offline as it does on the platform.
40
+ - **Agent steps complete with the OBJECT, never a `{text, object}` wrapper** (LUA-751 — the engine's structured-output rule): under `--agents fake` a node with an `outputSchema` completes with a schema-shaped stub object (`{ summary: '', confidence: 0 }`), one without completes with `{ text: '[fake:<id>] …' }`; under `--agents live` / `--fixtures` the reply's `object` wins, else the single JSON value in its text is validated against the schema (`OUTPUT_SCHEMA_INVALID` names the miss). The next step's `inputData` is that value — a plain agent → code step declares `{ text: string }`.
41
+ - **A `foreach`'s output is readable under its body id** — `fromStep('draftEmail')`, `${stepResults.draftEmail}`, `getStepResult('draftEmail')` after `.foreach(draftEmail)` are the ordered item outputs, and a container's `.join` is readable under the container id (`foreach@2`, `conditional@1`) — as the engine publishes them (LUA-639).
42
+ - **`env.template('KEY')`** at a workflow file's module scope resolves to its placeholder when `lua workflows run` loads the artifact; the `--env KEY=value` overlay substitutes it on the graph.
43
+ - **Script-form workflows** run on the replay wrapper bundled with the CLI (`@lua/sandbox-runtime/workflow-script-wrapper`) over the `workflow-script` parity context; a project that lists `@lua/sandbox-runtime` in its devDependencies runs that copy instead — see [Script form](./script-form.md).
40
44
  - **Not emulated offline**: the org pacing policy (no org context — `pacing_deferred` never appears locally), the runner mid-run kill (Ctrl-C is the local analogue), `backfillOnEnable`, and the `startWorkflowRunBatch` tool.
41
45
 
42
46
  ## Example
@@ -48,4 +52,12 @@ lua test workflow outreach --input @leads.json \
48
52
  --ledger-out out.json
49
53
  ```
50
54
 
51
- runs §3.2 (b) end to end with no prompt and no model call; `sendEmails` still runs (`ctx.once` against the effects map, so a second invocation with the same ledger sends nothing).
55
+ runs §3.2 (b) end to end with no prompt and no model call; `sendEmails` still runs (`ctx.once` against the effects map, so a second invocation with the same ledger sends nothing) — its input is the approval's `WorkflowApprovalOutput`, the drafts come from `editedPayload` (the edit above) or `getStepResult('drafts')`.
56
+
57
+ The three approval / typed-agent examples `lua init --with-examples` ships are pinned to run this way — `tests/__tests__/compile.workflow.examples-offline.test.ts`:
58
+
59
+ ```bash
60
+ lua workflows run refund-approval --input '{"ticketId":"t1","amount":5,"requesterId":"u1"}' --approve approveRefund # exit 0 (a stubbed Stripe passthrough in the test; `--step-output postRefund=…` on a machine without one)
61
+ lua workflows run outreach --input '{"leads":[{"email":"a@b.co","name":"A"}]}' --approve reviewDrafts --step-output 'draftEmail={"to":"a@b.co","body":"hi"}' --step-output 'sendEmails={"sent":1}'
62
+ lua workflows run research-brief --input '{"topic":"x"}' --step-output 'fetchSources={"urls":["https://a.example/1"]}' # the fake agents take the lowConfidence arm
63
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lua-cli",
3
- "version": "3.32.2",
3
+ "version": "3.32.4",
4
4
  "description": "Build, test, and deploy AI agents with custom tools, webhooks, and scheduled jobs. Features LuaAgent unified configuration, streaming chat, and batch deployment.",
5
5
  "readmeFilename": "README.md",
6
6
  "main": "dist/api-exports.js",
@@ -113,10 +113,11 @@
113
113
  "stripe": "^19.2.0",
114
114
  "ts-node": "^10.9.2",
115
115
  "tsup": "^8.5.1",
116
+ "@lua/sandbox-runtime": "0.0.1",
116
117
  "@lua/shared-sandbox": "0.0.1",
117
- "@lua/shared-source-sync": "0.0.1",
118
118
  "@lua/shared-types": "0.0.1",
119
- "@lua/workflow-graph": "0.0.1"
119
+ "@lua/workflow-graph": "0.0.1",
120
+ "@lua/shared-source-sync": "0.0.1"
120
121
  },
121
122
  "scripts": {
122
123
  "clean": "rm -rf dist temp",
@@ -5,17 +5,17 @@ Worked examples from workflows-spec 03 §3.2 (the spec is normative — do not r
5
5
  it (a `.then(createStep)` placement used as a string-ref target; a `template(…)` approval title). The note says what
6
6
  changed and why; `tests/__tests__/compile.workflow.examples.test.ts` compiles every file here, so a drift cannot return.
7
7
 
8
- | File | Shows |
9
- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
10
- | `research-brief.ts` | (a) sequential + parallel research, typed predicates (`stepOf`/`gt`/`lit`), switch + otherwise |
11
- | `outreach.ts` | (b) `foreach` + editable approval + an exactly-once send (`ctx.once`), cron schedule, `concurrencyPolicy` |
12
- | `provision-tenant.ts` | (c) nested workflow + `sleep` + `waitForSignal` |
13
- | `reviewed-brief.ts` | (d) `specialistStep` — an ephemeral reviewer role on the owning agent (D25) |
14
- | `refund-approval.ts` | (e) maker-checker + four-eyes + business-hours escalation chain + a recoverable external step (`onError:'park'`) |
15
- | `ticket-to-pr.ts` + `pr-review-round.ts` + `linear-ready.trigger.ts` + `github-review.webhook.ts` | (f) the SWE headline: Job tier, worktree arms + merge, review loop over a child workflow, trigger `startWorkflow`, webhook → `Workflows.signal` |
16
- | `support-triage.ts` | (g) knowledge grounding, mandatory `toolScope` on external content, dataset rows, `ctx.artefacts` |
17
- | `adversarial-verify.workflow.script.js` | (i) script form — `step()` inline effect + a library `role:{ref}` |
18
- | `vendor-invoices.ts` | (j) per-item approvals (`itemsPath`), `env.template()` overlays, `outputVisibility` |
8
+ | File | Shows |
9
+ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
10
+ | `research-brief.ts` | (a) sequential + parallel research, typed predicates (`stepOf`/`gt`/`lit`), switch + otherwise |
11
+ | `outreach.ts` | (b) `foreach` + editable approval + an exactly-once send (`ctx.once`), cron schedule, `concurrencyPolicy` |
12
+ | `provision-tenant.ts` | (c) nested workflow + `sleep` + `waitForSignal` |
13
+ | `reviewed-brief.ts` | (d) `specialistStep` — an ephemeral reviewer role on the owning agent (D25) |
14
+ | `refund-approval.ts` | (e) maker-checker + four-eyes + business-hours escalation chain + a recoverable external step (`onError:'park'`) |
15
+ | `ticket-to-pr.ts` + `pr-review-round.ts` + `linear-ready.trigger.ts` + `github-review.webhook.ts` | (f) the SWE headline: Job tier, worktree arms + merge, review loop over a child workflow, trigger `startWorkflow`, webhook → `Workflows.signal` |
16
+ | `support-triage.ts` | (g) knowledge grounding, mandatory `toolScope` on external content, dataset rows, `ctx.artefacts` |
17
+ | `adversarial-verify.workflow.script.js` | (i) script form — `parallel()` over thunks, a `step()` inline reduction that returns (never mutates) — runs offline on the bundled wrapper (LUA-751) |
18
+ | `vendor-invoices.ts` | (j) per-item approvals (`itemsPath`), `env.template()` overlays, `outputVisibility` |
19
19
 
20
20
  Recipes:
21
21
 
@@ -25,3 +25,9 @@ Recipes:
25
25
  - Every doc rule worth repeating: _execute re-runs from the top after resume; execution is at-least-once — dedupe
26
26
  on `occurrenceId`_ (`${lineageId}:${stepId}`); an effect unique across independent runs needs your own business
27
27
  key (`refund:${ticketId}`).
28
+ - The step after an `.approval()` receives the approval node's output — `WorkflowApprovalOutput` (`@lua/shared-types`):
29
+ `{ approved, decision, text, note?, editedPayload?, editRevision?, decidedBy?, timedOut?, escalations? }`, the same shape
30
+ on the engine and under `lua workflows run --approve/--deny` (LUA-751; `tests/__tests__/compile.workflow.examples-offline.test.ts`
31
+ runs `refund-approval`, `outreach` and `research-brief` offline over the shared fixture). The original payload is not
32
+ echoed back — `refund-approval` reads it with `getStepResult('refundRequest')` (the map ahead of its approval: the
33
+ payload the approver sees and edits is exactly what `editedPayloadSchema` describes), `outreach` with `getStepResult('drafts')`.
@@ -1,6 +1,10 @@
1
1
  // Example (i) — adversarial verify: spawn finders until two consecutive rounds add nothing
2
2
  // new, then have an independent verifier confirm each finding. Script form (04 §4.3);
3
3
  // push with `lua push workflow`, run offline with `lua test workflow adversarial-verify`.
4
+ // LUA-751: `parallel()` takes THUNKS (04 §4.3.2 — `parallel(angles.map((angle) => () => agent(…)))`); an array of
5
+ // promises resolved to `null`s. And a `step()` closure is journaled once and never re-runs on replay, so it must not
6
+ // mutate module state: it RETURNS the fresh findings and the merge into `found` happens outside it — otherwise the
7
+ // replay tick saw an empty `found`, issued nothing to verify and diverged (`JOURNAL_DIVERGENCE(entry_before_issue)`).
4
8
  export const meta = {
5
9
  name: 'adversarial-verify',
6
10
  description: 'Find candidate issues from several angles, verify each independently, report the confirmed set',
@@ -13,29 +17,29 @@ const found = new Map();
13
17
  let quietRounds = 0;
14
18
  for (let round = 1; round <= 5 && quietRounds < 2; round++) {
15
19
  const batch = await parallel(
16
- args.angles.map((angle) =>
17
- agent(`Round ${round}: list concrete issues in ${args.subject} from the ${angle} angle. One per line.`, {
18
- phase: 'Find',
19
- label: `find-${angle}-${round}`,
20
- })
20
+ args.angles.map(
21
+ (angle) => () =>
22
+ agent(`Round ${round}: list concrete issues in ${args.subject} from the ${angle} angle. One per line.`, {
23
+ phase: 'Find',
24
+ label: `find-${angle}-${round}`,
25
+ })
21
26
  )
22
27
  );
23
- // step(): a pure reduction journaled once — its closure may use anything, it never re-runs on replay.
24
- const added = await step(`dedupe-${round}`, () => {
25
- let fresh = 0;
28
+ // step(): a pure reduction journaled once — its closure may use anything, it never re-runs on replay, so it
29
+ // returns what it found and touches no state of its own; the merge below is plain script code that replays.
30
+ const fresh = await step(`dedupe-${round}`, () => {
31
+ const out = [];
26
32
  for (const text of batch) {
27
- for (const line of String(text).split('\n')) {
33
+ for (const line of String(text ?? '').split('\n')) {
28
34
  const key = line.trim().toLowerCase();
29
- if (key && !found.has(key)) {
30
- found.set(key, line.trim());
31
- fresh++;
32
- }
35
+ if (key && !found.has(key) && !out.some((f) => f.key === key)) out.push({ key, line: line.trim() });
33
36
  }
34
37
  }
35
- return fresh;
38
+ return out;
36
39
  });
37
- quietRounds = added === 0 ? quietRounds + 1 : 0;
38
- log(`round ${round}: ${added} new finding(s)`);
40
+ for (const f of fresh) found.set(f.key, f.line);
41
+ quietRounds = fresh.length === 0 ? quietRounds + 1 : 0;
42
+ log(`round ${round}: ${fresh.length} new finding(s)`);
39
43
  }
40
44
 
41
45
  const verdicts = await foreach(
@@ -1,5 +1,8 @@
1
1
  // foreach + approval + an exactly-once send (`ctx.once`).
2
- // Verbatim from workflows-spec 03 §3.2 (b) (WF-215 / WF-223 — the spec is normative).
2
+ // From workflows-spec 03 §3.2 (b) (WF-215 / WF-223 — the spec is normative). LUA-751: `sendEmails` reads the approval node's
3
+ // REAL output — `WorkflowApprovalOutput` (@lua/shared-types), the ONE shape the engine emits and `lua workflows run --approve`
4
+ // emits offline: the approver's edited `{ drafts }` rides under `editedPayload`; otherwise the drafts are read back from the
5
+ // `drafts` map the approval was shown (`getStepResult`) — the output never echoes the original payload.
3
6
  import { z } from 'zod';
4
7
  import { createStep, createWorkflow, fromInit, fromStep, template, AI, Channels } from 'lua-cli';
5
8
 
@@ -8,7 +11,7 @@ const draft = z.object({ to: z.string(), body: z.string() });
8
11
 
9
12
  const draftEmail = createStep({
10
13
  id: 'draftEmail',
11
- inputSchema: lead, // foreach passes the RAW item — `{ email, name }`, not `{ lead: {…} }` (§3.2.1 check 4)
14
+ inputSchema: lead, // foreach passes the RAW item — `{ email, name }`, not `{ lead: {…} }` (§3.2.1 check 4)
12
15
  outputSchema: draft,
13
16
  async execute({ inputData }) {
14
17
  // string overload → Promise<string> (`api-exports.ts:768`); the object overload returns `AiGenerateOutput{ text, … }` (`:786`, shared-types `ai-generate.types.ts:78-86`)
@@ -18,16 +21,22 @@ const draftEmail = createStep({
18
21
  });
19
22
  const sendEmails = createStep({
20
23
  id: 'sendEmails',
21
- inputSchema: z.object({ drafts: z.array(draft) }),
24
+ // The approval node's output (`WorkflowApprovalOutput`): what this step reads, `.passthrough()` for the rest of the envelope
25
+ // (`decision`, `text`, `note`, `editRevision`, `decidedBy`, `timedOut`, `escalations`).
26
+ inputSchema: z
27
+ .object({ approved: z.boolean(), editedPayload: z.object({ drafts: z.array(draft) }).optional() })
28
+ .passthrough(),
22
29
  outputSchema: z.object({ sent: z.number() }),
23
- sideEffects: 'external', // never auto-retried on platform-fault reclaim
24
- onError: 'park', // an EFFECT_IN_DOUBT parks the step for R37 instead of failing the run
25
- async execute({ inputData, once }) {
30
+ sideEffects: 'external', // never auto-retried on platform-fault reclaim
31
+ onError: 'park', // an EFFECT_IN_DOUBT parks the step for R37 instead of failing the run
32
+ async execute({ inputData, getStepResult, once }) {
33
+ if (!inputData.approved) return { sent: 0 }; // denied — data, not an exception (§06 §6.4.11); a timeout cancels the run before this step (`onTimeout:'cancel-run'`)
34
+ const { drafts } = inputData.editedPayload ?? getStepResult<{ drafts: z.infer<typeof draft>[] }>('drafts'); // the approver's edits, else the batch the approval showed
26
35
  let sent = 0;
27
- for (const d of inputData.drafts) {
36
+ for (const d of drafts) {
28
37
  // exactly-once per {occurrenceId, key}: a retry, resume, repair run or migrated run that reaches this line again gets the stored
29
38
  // result back and never re-sends (I27 claim/settle). No hand-rolled Data check-then-act — that pattern was non-atomic.
30
- const r = await once(d.to, () => Channels.email.send({ to: { email: d.to }, subject: 'Hello', body: d.body })); // `EmailSendInput.to` is `{ userId?, email? }`
39
+ const r = await once(d.to, () => Channels.email.send({ to: { email: d.to }, subject: 'Hello', body: d.body })); // `EmailSendInput.to` is `{ userId?, email? }`
31
40
  // The whole `Channels` facade is callable in-step — incl. `Channels.whatsapp.send({ threadId: ctx.runtime.replyTo!.threadId, text })` on a customer-channel run (07 §7.0-L / §7.4.5): a step MAY answer the customer
32
41
  // itself (e.g. the refund outcome right after the Stripe step, before a QA step that must not delay it); `once()` covers it exactly like email, and the platform's terminal reply then lands as a
33
42
  // duplicate-safe fallback (same threadId; the customer sees two lines only if the step's text differs). Outside WhatsApp's session the in-step send needs a template too — `Channels.whatsapp.sendTemplate` (closing pass 2026-08-27).
@@ -45,11 +54,18 @@ export const outreach = createWorkflow({
45
54
  scheduleInput: { leads: [] },
46
55
  concurrencyPolicy: 'forbid',
47
56
  })
48
- .map({ leads: fromInit('leads') }, { id: 'leads' }) // entry 1 — three maps in this workflow ⇒ every id explicit (`map-id-required`)
49
- .map({ '': fromStep('leads', 'leads') }, { id: 'items' }) // entry 2 — '' key = "output IS this value" (Lua extension, §3.4): foreach needs a raw array upstream
50
- .foreach(draftEmail, { concurrency: 8, maxItems: 500 }) // entry 3 — items are `lead`s, output is `draft[]`
51
- .map({ drafts: fromStep('draftEmail') }, { id: 'drafts' }) // entry 4 — `{ drafts: draft[] }`, the approval's editable payload
52
- .approval('reviewDrafts', { title: 'Approve outreach batch', details: template('${stepResults.drafts.drafts.length} drafts ready'),
53
- approver: 'org-admins', timeoutHours: 48, onTimeout: 'cancel-run', editable: true, editablePaths: ['drafts', 'drafts[*].body'] }) // entry 5
54
- .then(sendEmails) // entry 6 — reads `stepResults.drafts` (the approval passes its edited payload through)
57
+ .map({ leads: fromInit('leads') }, { id: 'leads' }) // entry 1 — three maps in this workflow ⇒ every id explicit (`map-id-required`)
58
+ .map({ '': fromStep('leads', 'leads') }, { id: 'items' }) // entry 2 — '' key = "output IS this value" (Lua extension, §3.4): foreach needs a raw array upstream
59
+ .foreach(draftEmail, { concurrency: 8, maxItems: 500 }) // entry 3 — items are `lead`s, output is `draft[]`
60
+ .map({ drafts: fromStep('draftEmail') }, { id: 'drafts' }) // entry 4 — `{ drafts: draft[] }`, the approval's editable payload
61
+ .approval('reviewDrafts', {
62
+ title: 'Approve outreach batch',
63
+ details: template('${stepResults.drafts.drafts.length} drafts ready'),
64
+ approver: 'org-admins',
65
+ timeoutHours: 48,
66
+ onTimeout: 'cancel-run',
67
+ editable: true,
68
+ editablePaths: ['drafts', 'drafts[*].body'],
69
+ }) // entry 5
70
+ .then(sendEmails) // entry 6 — its input IS the approval's output; the drafts come from `editedPayload` or `getStepResult('drafts')`
55
71
  .commit();