lua-cli 3.31.0 → 3.32.2

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.
Files changed (33) hide show
  1. package/dist/api-exports.d.ts +416 -103
  2. package/dist/api-exports.js +1992 -299
  3. package/dist/api-exports.js.map +1 -1
  4. package/dist/index.js +5262 -1914
  5. package/dist/index.js.map +1 -1
  6. package/dist/voice/test/index.d.ts +54 -54
  7. package/dist/workflow-builder.d.ts +257 -44
  8. package/dist/workflow-builder.js +1382 -265
  9. package/dist/workflow-builder.js.map +1 -1
  10. package/docs/README.md +2 -2
  11. package/docs/api/LuaWorkflow.md +44 -28
  12. package/docs/api/Workflows.md +12 -1
  13. package/docs/workflows/approvals.md +14 -1
  14. package/docs/workflows/connections-in-coding-turns.md +1 -0
  15. package/docs/workflows/correlation-keys.md +1 -0
  16. package/docs/workflows/git-credentials.md +22 -1
  17. package/docs/workflows/goals.md +46 -0
  18. package/docs/workflows/limits.md +6 -0
  19. package/docs/workflows/recovery.md +6 -2
  20. package/docs/workflows/replay-local.md +10 -10
  21. package/docs/workflows/schedules.md +15 -0
  22. package/docs/workflows/script-form.md +20 -10
  23. package/docs/workflows/testing-offline.md +25 -20
  24. package/docs/workflows/workspaces-and-long-steps.md +38 -2
  25. package/package.json +2 -2
  26. package/template/examples/workflows/CLAUDE.md +16 -13
  27. package/template/examples/workflows/pr-review-round.ts +61 -20
  28. package/template/examples/workflows/provision-tenant.ts +25 -8
  29. package/template/examples/workflows/refund-approval.ts +30 -17
  30. package/template/examples/workflows/support-triage.ts +59 -22
  31. package/template/examples/workflows/ticket-to-pr.ts +125 -46
  32. package/template/examples/workflows/vendor-invoices.ts +69 -16
  33. package/template/package.json +1 -1
@@ -6,12 +6,48 @@ _Source of truth: workflows-spec 05 §5.17 (D19-r1/r2). This page is the develop
6
6
 
7
7
  ## Workspaces
8
8
 
9
- `workspace` on `createWorkflow` declares the checkout: `{ kind:'git', repo, ref, credentialsRef, sizeGb?, verify? }` or `{ kind:'empty' }`. Steps mount it with `workspace: { mount: 'rw'|'ro', isolation?: 'worktree' }` and read `ctx.workspace = { path, mount, branch }`.
9
+ `workspace` on `createWorkflow` declares the checkout: `{ kind:'git', repo, ref, credentialsRef, sizeGb?, verify? }` or `{ kind:'empty' }`. Steps mount it with `workspace: { mount: 'rw'|'ro', isolation?: 'worktree' }` and read `ctx.workspace = { root, mount, branch, headSha, isolation, baseSha?, arm?, backend? }` — `root` is the absolute directory of the checkout (`/workspace`); `baseSha` is the commit the run's base ref resolved to at provision (`headSha` is where this step's checkout is), `arm` the worktree arm id under `isolation:'worktree'`, `backend` the volume backend — the three are present when the pod hands the run's stamps to the step and absent otherwise (an `empty` workspace, a shared mount). The pre-3.33 name `path` is served by the Job pod as a deprecated alias for one minor (it warns once per step) and then removed; new code reads `root`.
10
+
11
+ `credentialsRef` is a connection id **or a key you declare under `connections: [{ key, integrationType }]`** — declare a key; it resolves against the owner agent's own connections on any agent at run time (see [Git credentials](./git-credentials.md)), so the definition never freezes a production id.
10
12
 
11
13
  **The release-at-suspension rule:** when your run waits for a person or a signal, its volume is released: committed work is on the run branch `lua/wf-<lineageId>` on your remote; untracked files — `node_modules`, build output — are rebuilt when the next step restores the checkout.
12
14
 
13
15
  **Worktree arms:** parallel arms never share a filesystem; each gets its own clone and branch and the `merge` step integrates them — put `verify` on the workspace so an agent-resolved conflict must pass your tests. `onConflict:'agent'` lets one resolver turn fix conflicts.
14
16
 
17
+ ## Commands from a code step — `ctx.exec` / `ctx.$`
18
+
19
+ A Job-tier **code** step has no `child_process` — an import of it fails `lua compile` with `node-capability-unavailable` — and it builds no code from strings and compiles no WebAssembly (see the note at the end of this section). It runs commands through `ctx.exec` / `ctx.$`, which the Job pod executes on its behalf:
20
+
21
+ ```ts
22
+ const runTests = createStep({
23
+ id: 'runTests',
24
+ tier: 'job',
25
+ workspace: { mount: 'rw' },
26
+ inputSchema: z.any(),
27
+ outputSchema: z.object({ passed: z.boolean() }),
28
+ async execute({ $, exec, inputData, log }) {
29
+ await $!.strict`npm ci`; // `.strict` throws ExecError on a non-zero exit
30
+ const r = await $!`npm test -- --maxWorkers=2`; // { code, stdout, stderr, durationMs, truncated, timedOut }
31
+ const sha = (await exec!(['git', 'rev-parse', 'HEAD'], { cwd: 'packages/app' })).stdout.trim();
32
+ await $!.strict`git add -- ${'CHANGELOG.md'}`; // each ${…} is ONE argument; `--` keeps a data value out of git's options
33
+ log(`tests ${r.code === 0 ? 'passed' : 'failed'} at ${sha}`);
34
+ return { passed: r.code === 0 };
35
+ },
36
+ });
37
+ ```
38
+
39
+ - **Argv only, never a shell.** `$` splits its literal text on whitespace (with `'…'` / `"…"` quoting) and passes every `${value}` as exactly one argument, never re-parsed — a title with spaces or a body with newlines is one argument. There is no `cd`, `&&`, `|`, glob or `$VAR`: a second command is a second call, and the cwd is the workspace root (`{ cwd: 'packages/app' }` for a directory inside it).
40
+ - **Allowlist.** `git`, `gh`, `pnpm`, `npm`, `npx`, `node`, `yarn`, `python3`, `pytest`, `make` — bare names, resolved from the pod image's own PATH. `sh -c`, `bash`, `curl` or a path is refused before anything runs (`ExecError{code:'EXEC_REFUSED'}`, `reason` says why). **The allowlist is not the image's inventory:** today's Job image ships `git`, `gh`, `node`, `npm`, `npx` (plus `corepack`, `gitleaks`, `claude`, `curl` for the harness); `pnpm`, `yarn`, `python3`, `pytest` and `make` are on the list for the local driver and a future image but fail `EXEC_REFUSED/binary_not_found` in the pod today — run them offline, or use `npx` / `npm exec` for what npm can fetch.
41
+ - **git rules.** The same as a coding turn's git tool: no `-C`, `--git-dir`, `--work-tree`, `--exec-path` (the repository is the workspace and the binary is the host's — use `{ cwd }`); no `-c` over what git would execute or route through (`core.hooksPath`, `core.fsmonitor`, `protocol.*`, `credential.*`, `url.*`, proxies, editors, filters); no `git config` (the commit identity and every setting a step needs come from the host); no `credential*`, `push-mirror`, `daemon`. **`git push`** is refused on an `ro` mount and with `--force` / `-f` / `--force-with-lease` / `--delete` / `--mirror` / `--prune` / `--all` / `--tags`, and every push first runs the same pre-push secret scan the harness's own push runs (05 §5.17.6) — a finding refuses it (`EXEC_REFUSED/git_push_refused`). Put `--` before positional values that come from data: `$\`git add -- ${file}\``, `$\`git log -- ${path}\``.
42
+ - **Budget.** `timeoutMs` (default 10 min) is capped by the step's remaining wall; on expiry the command's whole process tree is killed and the call rejects `EXEC_TIMEOUT`. stdout and stderr are each kept to 1 MiB (`truncated`). One command runs at a time.
43
+ - **Env.** The command sees a scrubbed env — no token, no `LUA_WF_*`, no cloud credential. git runs exactly as the harness's own git does: through the credential-proxy remote, hooks and other repo-configured commands off, `GIT_TERMINAL_PROMPT=0`, the run's commit identity; `gh` and the package managers run non-interactive. A step that declares `jobTools: ['gh']` gives its `gh` (and only `gh`) the sidecar's gh proxy env, exactly what a coding turn's `gh` tool gets — the proxy admits `gh pr create` / `gh pr edit` / `gh pr comment` on the pinned repository and nothing else: **no `gh pr merge`** (a merge stays with a person), no `/graphql`. `env: { NODE_ENV: 'test' }` adds variables; `PATH`, `HOME`, `GIT_*`, `GH_*`, `LUA_*`, `NODE_OPTIONS` cannot be overridden.
44
+ - **Errors.** A non-zero exit is data (`result.code`). `exec.strict` / `$.strict` throw an `ExecError` — `name === 'ExecError'`, `code` one of `EXEC_FAILED` | `EXEC_TIMEOUT` | `EXEC_REFUSED` | `EXEC_UNAVAILABLE`, with `exitCode`, `stdout`, `stderr`. Check `name` / `code`, not `instanceof` (the pod runs a step in its own realm). `undefined` interpolated into `$` is refused, never stringified.
45
+ - **Tier.** Worker-tier steps have neither `exec` nor `$`. Shell work with a model in the loop is a Job-tier `agentStep` (its `shell` tool, below). The command line, exit code and duration are logged to the run's job-logs; the output is not — `log()` what matters.
46
+
47
+ An `ro` mount is read-only at the volume (the PVC is mounted `readOnly`): a command that writes fails `EROFS`, and `git push` is refused on it.
48
+
49
+ **No code from strings, no WebAssembly — Job tier only.** Inside a Job-tier code step, `eval`, `new Function` (the `Function` constructor reached any way, `Buffer.constructor` included) and `new WebAssembly.Module` / `WebAssembly.compile` / `WebAssembly.instantiate` throw — `EvalError` / `CompileError`, surfaced as `USER_CODE_ERROR` with the reason named — because the pod runs the step with code generation off in both realms (`--disallow-code-generation-from-strings` on the child process, `codeGeneration: { strings: false, wasm: false }` on the vm context). `WebAssembly.validate` still answers (it compiles nothing). A library that builds code from strings at import time fails the same way. The worker tier has neither restriction: a step that needs either stays `tier: 'worker'`.
50
+
15
51
  ## Coding turns
16
52
 
17
53
  A Job-tier `agentStep` is a coding turn (Claude Code or the generic harness — see [Coding harness](./coding-harness.md)) with the `WORKFLOW_JOB_TOOLS` set (`shell`, `read`, `write`, `edit`, `glob`, `grep`, `git`, …). MCP connections mount via `toolScope.connectionIds`: your connections are mounted through a local proxy; the model never sees a token, and a tool that needs approval is refused inside the turn — hand it to a worker-tier step.
@@ -24,4 +60,4 @@ GitHub review loops: the PR body carries `<!-- lua-run:<runId> -->`; the GitHub
24
60
 
25
61
  ## Offline
26
62
 
27
- The local driver emulates the tier: `--workspace <dir>`, `--job-wall <s>`, worktree arms as literal `git worktree add`, `--segment-wall` for segment rehearsal. `credentialsRef` is printed and never resolved offline.
63
+ The local driver emulates the tier: `--workspace <dir>` gives Job-tier code steps `ctx.workspace` and `ctx.exec` / `ctx.$` against that directory (the pod's allowlist, your own PATH and git credentials; without the flag a Job-tier step's `$` rejects `EXEC_UNAVAILABLE`), `--job-wall <s>` rehearses the segment wall for agent steps. `credentialsRef` is printed and never resolved offline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lua-cli",
3
- "version": "3.31.0",
3
+ "version": "3.32.2",
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",
@@ -114,8 +114,8 @@
114
114
  "ts-node": "^10.9.2",
115
115
  "tsup": "^8.5.1",
116
116
  "@lua/shared-sandbox": "0.0.1",
117
- "@lua/shared-types": "0.0.1",
118
117
  "@lua/shared-source-sync": "0.0.1",
118
+ "@lua/shared-types": "0.0.1",
119
119
  "@lua/workflow-graph": "0.0.1"
120
120
  },
121
121
  "scripts": {
@@ -1,24 +1,27 @@
1
1
  # Workflow examples
2
2
 
3
- Verbatim worked examples from workflows-spec 03 §3.2 (the spec is normative — do not restyle them).
3
+ Worked examples from workflows-spec 03 §3.2 (the spec is normative — do not restyle them). A file whose header carries a
4
+ `LUA-635:` note departs from the spec's listing on purpose: the compiler is what ships, and the listing was wrong against
5
+ it (a `.then(createStep)` placement used as a string-ref target; a `template(…)` approval title). The note says what
6
+ changed and why; `tests/__tests__/compile.workflow.examples.test.ts` compiles every file here, so a drift cannot return.
4
7
 
5
- | File | Shows |
6
- |---|---|
7
- | `research-brief.ts` | (a) sequential + parallel research, typed predicates (`stepOf`/`gt`/`lit`), switch + otherwise |
8
- | `outreach.ts` | (b) `foreach` + editable approval + an exactly-once send (`ctx.once`), cron schedule, `concurrencyPolicy` |
9
- | `provision-tenant.ts` | (c) nested workflow + `sleep` + `waitForSignal` |
10
- | `reviewed-brief.ts` | (d) `specialistStep` — an ephemeral reviewer role on the owning agent (D25) |
11
- | `refund-approval.ts` | (e) maker-checker + four-eyes + business-hours escalation chain + a recoverable external step (`onError:'park'`) |
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'`) |
12
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` |
13
- | `support-triage.ts` | (g) knowledge grounding, mandatory `toolScope` on external content, dataset rows, `ctx.artefacts` |
14
- | `adversarial-verify.workflow.script.js` | (i) script form — `step()` inline effect + a library `role:{ref}` |
15
- | `vendor-invoices.ts` | (j) per-item approvals (`itemsPath`), `env.template()` overlays, `outputVisibility` |
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` |
16
19
 
17
20
  Recipes:
18
21
 
19
22
  - Run any of these offline: `lua test workflow <name>` (steering flags in `docs/api/Workflows.md` → "Testing locally").
20
23
  - Give a predicate BOTH truth values under `--agents fake` with `--step-output <id>=<json>` — the fake stub alone
21
24
  cannot reach the `gt(confidence, 0.6)` arm of `research-brief`.
22
- - Every doc rule worth repeating: *execute re-runs from the top after resume; execution is at-least-once — dedupe
23
- on `occurrenceId`* (`${lineageId}:${stepId}`); an effect unique across independent runs needs your own business
25
+ - Every doc rule worth repeating: _execute re-runs from the top after resume; execution is at-least-once — dedupe
26
+ on `occurrenceId`_ (`${lineageId}:${stepId}`); an effect unique across independent runs needs your own business
24
27
  key (`refund:${ticketId}`).
@@ -1,38 +1,79 @@
1
1
  // One review iteration; runs as a child of ticket-to-pr with the SAME workspace.
2
- // Verbatim from workflows-spec 03 §3.2 (f) (WF-215 / WF-223 — the spec is normative).
2
+ // From workflows-spec 03 §3.2 (f) (WF-215 / WF-223 — the spec is normative). LUA-635: a switch arm is ONE step and a
3
+ // string arm names an agentStep / specialistStep / toolStep / map / workflow declaration (03 §3.2.0) — the listing's
4
+ // `otherwise: 'done'` over a later `.then(createStep({ id: 'done' }))` was a goto the placement rule never had.
3
5
  // src/workflows/pr-review-round.ts — one review iteration; runs as a child of ticket-to-pr with the SAME workspace
4
6
  import { z } from 'zod';
5
7
  import { createStep, createWorkflow, step, eq, lit, template } from 'lua-cli';
6
8
 
7
9
  const pushFix = createStep({
8
10
  id: 'pushFix',
9
- inputSchema: z.any(), outputSchema: z.object({ headSha: z.string() }),
10
- tier: 'job', workspace: { mount: 'rw' }, timeoutSeconds: 600,
11
- async execute({ workspace }) { // git runs through the credential proxy — no token in this container (05 §5.17.3)
12
- const sha = (await $`git -C ${workspace!.path} rev-parse HEAD`).stdout.trim(); // `$` = the Job image's shell helper (`@lua/coding-harness/shell`); a plain child_process spawn works too — the Job site has no REQUIRE_BLOCKLIST for `child_process`
11
+ inputSchema: z.any(),
12
+ outputSchema: z.object({ headSha: z.string() }),
13
+ tier: 'job',
14
+ workspace: { mount: 'rw' },
15
+ timeoutSeconds: 600,
16
+ async execute({ $, workspace, log }) {
17
+ // `ctx.$` (LUA-682) runs an allow-listed binary in the checkout — argv only, no shell — with git's remote on the
18
+ // credential proxy: no token in this container (05 §5.17.3). `child_process` is NOT available to a code step
19
+ // (`lua compile` refuses it: node-capability-unavailable); a coding turn's `shell` tool is the other way to run
20
+ // commands. The harness pushes the branch at the checkpoint / terminal; this step reports what it pushed.
21
+ const sha = (await $!.strict`git rev-parse HEAD`).stdout.trim();
22
+ log(`pushed ${sha} on ${workspace!.branch ?? 'the run branch'}`);
13
23
  return { headSha: sha };
14
24
  },
15
25
  });
16
26
 
27
+ const changesRequested = eq(step('review').path('payload.state'), lit('changes_requested'));
28
+
17
29
  export const prReviewRound = createWorkflow({
18
30
  name: 'pr-review-round',
19
31
  inputSchema: z.object({ prNumber: z.number(), repo: z.string() }),
20
- outputSchema: z.object({ state: z.enum(['approved', 'changes_requested', 'timed_out']), round: z.number().optional() }),
32
+ outputSchema: z.object({
33
+ state: z.enum(['approved', 'changes_requested', 'timed_out']),
34
+ round: z.number().optional(),
35
+ }),
21
36
  })
22
- .waitForSignal('review', { signal: 'github.review', timeoutHours: 168, // delivered by the GitHub webhook (§3.7) — to the PARENT run; the engine descends it into this child (06 §6.5.3 step 2b)
23
- schema: z.object({ state: z.enum(['approved', 'changes_requested', 'commented']), comments: z.array(z.object({ path: z.string().optional(), body: z.string() })) }),
24
- acceptedSources: ['webhook'], onTimeout: 'continue' })
25
- .switch([[eq(step('review').path('payload.state'), lit('changes_requested')), 'addressReview']], 'done')
26
- .agentStep('addressReview', { // a CODING TURN: Claude Code headless with git/gh/shell/edit on the mounted checkout (05 §5.17.6)
27
- agentId: 'swe-implementer', tier: 'job', workspace: { mount: 'rw' }, timeoutSeconds: 7200, jobResources: 'medium',
28
- prompt: template('Address every review comment in ${stepResults.review.payload.comments} on the current branch. Run the relevant tests. Commit with a message that references the comment you addressed. Do not force-push.'),
29
- toolScope: { jobTools: ['shell', 'read', 'write', 'edit', 'glob', 'grep', 'git'] }, // no `gh`: the push + PR update is the next code step
37
+ .waitForSignal('review', {
38
+ signal: 'github.review',
39
+ timeoutHours: 168, // delivered by the GitHub webhook (§3.7) — to the PARENT run; the engine descends it into this child (06 §6.5.3 step 2b)
40
+ schema: z.object({
41
+ state: z.enum(['approved', 'changes_requested', 'commented']),
42
+ comments: z.array(z.object({ path: z.string().optional(), body: z.string() })),
43
+ }),
44
+ acceptedSources: ['webhook'],
45
+ onTimeout: 'continue',
46
+ })
47
+ .switch([[changesRequested, 'addressReview']]) // no `otherwise`: approved / commented / timed out falls through to `done`
48
+ .agentStep('addressReview', {
49
+ // a CODING TURN: Claude Code headless with git/gh/shell/edit on the mounted checkout (05 §5.17.6)
50
+ agentId: 'swe-implementer',
51
+ tier: 'job',
52
+ workspace: { mount: 'rw' },
53
+ timeoutSeconds: 7200,
54
+ jobResources: 'medium',
55
+ prompt: template(
56
+ 'Address every review comment in ${stepResults.review.payload.comments} on the current branch. Run the relevant tests. Commit with a message that references the comment you addressed. Do not force-push.'
57
+ ),
58
+ toolScope: { jobTools: ['shell', 'read', 'write', 'edit', 'glob', 'grep', 'git'] }, // no `gh`: the push + PR update is the next code step
30
59
  outputSchema: z.object({ summary: z.string() }),
31
60
  })
32
- .then(pushFix)
33
- .then(createStep({ id: 'done', inputSchema: z.any(), outputSchema: z.object({ state: z.enum(['approved', 'changes_requested', 'timed_out']) }),
34
- async execute({ getStepResult }) {
35
- const r = getStepResult<{ received: boolean; timedOut?: boolean; payload?: { state: string } }>('review');
36
- return { state: r?.received ? (r.payload!.state === 'changes_requested' ? 'changes_requested' : 'approved') : 'timed_out' };
37
- } }))
61
+ .switch([[changesRequested, pushFix]]) // the fix path's second step — same predicate, an inline createStep as the arm
62
+ .then(
63
+ createStep({
64
+ id: 'done',
65
+ inputSchema: z.any(),
66
+ outputSchema: z.object({ state: z.enum(['approved', 'changes_requested', 'timed_out']) }),
67
+ async execute({ getStepResult }) {
68
+ const r = getStepResult<{ received: boolean; timedOut?: boolean; payload?: { state: string } }>('review');
69
+ return {
70
+ state: r?.received
71
+ ? r.payload!.state === 'changes_requested'
72
+ ? 'changes_requested'
73
+ : 'approved'
74
+ : 'timed_out',
75
+ };
76
+ },
77
+ })
78
+ )
38
79
  .commit();
@@ -1,18 +1,35 @@
1
1
  // Nested workflow + sleep + signal.
2
- // Verbatim from workflows-spec 03 §3.2 (c) (WF-215 / WF-223 — the spec is normative).
2
+ // From workflows-spec 03 §3.2 (c) (WF-215 / WF-223 — the spec is normative). LUA-635: the switch arms are the
3
+ // createStep objects themselves — a STRING arm names an agentStep / specialistStep / toolStep / map / workflow
4
+ // declaration elsewhere in the chain (03 §3.2.0); `.then(createStep({ id }))` places a step, it never declares one.
3
5
  import { z } from 'zod';
4
6
  import { createStep, createWorkflow, step, eq, lit, fromInit } from 'lua-cli';
5
7
 
8
+ const finalize = createStep({
9
+ id: 'finalize',
10
+ inputSchema: z.any(),
11
+ outputSchema: z.object({ ready: z.boolean() }),
12
+ execute: async () => ({ ready: true }),
13
+ });
14
+ const rollback = createStep({
15
+ id: 'rollback',
16
+ inputSchema: z.any(),
17
+ outputSchema: z.object({ ready: z.boolean() }),
18
+ execute: async () => ({ ready: false }),
19
+ });
20
+
6
21
  export const provision = createWorkflow({
7
22
  name: 'provision-tenant',
8
23
  inputSchema: z.object({ tenantId: z.string() }),
9
24
  outputSchema: z.object({ ready: z.boolean() }),
10
25
  })
11
- .workflow('createResources', 'research-brief', { topic: fromInit('tenantId') }) // nested by name (same agent) or LuaWorkflow ref
12
- .sleep(5 * 60 * 1000) // engine-side; holds no compute
13
- .waitForSignal('vendorReady', { signal: 'vendor.ready', schema: z.object({ ok: z.boolean() }),
14
- timeoutHours: 72, acceptedSources: ['webhook', 'api'] })
15
- .switch([[eq(step('vendorReady').path('ok'), lit(true)), 'finalize']], 'rollback')
16
- .then(createStep({ id: 'finalize', inputSchema: z.any(), outputSchema: z.object({ ready: z.boolean() }), execute: async () => ({ ready: true }) }))
17
- .then(createStep({ id: 'rollback', inputSchema: z.any(), outputSchema: z.object({ ready: z.boolean() }), execute: async () => ({ ready: false }) }))
26
+ .workflow('createResources', 'research-brief', { topic: fromInit('tenantId') }) // nested by name (same agent) or LuaWorkflow ref
27
+ .sleep(5 * 60 * 1000) // engine-side; holds no compute
28
+ .waitForSignal('vendorReady', {
29
+ signal: 'vendor.ready',
30
+ schema: z.object({ ok: z.boolean() }),
31
+ timeoutHours: 72,
32
+ acceptedSources: ['webhook', 'api'],
33
+ })
34
+ .switch([[eq(step('vendorReady').path('ok'), lit(true)), finalize]], rollback) // inline code steps; the last entry is a conditional, so the taken arm's output is the run output
18
35
  .commit();
@@ -5,19 +5,26 @@ import { createStep, createWorkflow, template, Integrations } from 'lua-cli';
5
5
 
6
6
  const postRefund = createStep({
7
7
  id: 'postRefund',
8
- inputSchema: z.object({ approved: z.boolean(), editedPayload: z.object({ ticketId: z.string(), amount: z.number() }).optional(),
9
- input: z.object({ ticketId: z.string(), amount: z.number() }) }), // the approval node's output shape (04 §4.2.3)
8
+ inputSchema: z.object({
9
+ approved: z.boolean(),
10
+ editedPayload: z.object({ ticketId: z.string(), amount: z.number() }).optional(),
11
+ input: z.object({ ticketId: z.string(), amount: z.number() }),
12
+ }), // the approval node's output shape (04 §4.2.3)
10
13
  outputSchema: z.object({ refundId: z.string().nullable() }),
11
- sideEffects: 'external', // a platform-fault reclaim PARKS this step (never auto-retried); the run gates `exception` (§06 §6.3.5)
12
- onError: 'park', // a final customer-fault failure parks too — the operator retries / skips / supplies the refundId / fails it
13
- requiredConnections: ['stripe'], // unmountable ⇒ failed{credentials_revoked, reason:'required_connection_unmountable'} → parked via onError:'park' (§11 §11.5.5)
14
+ sideEffects: 'external', // a platform-fault reclaim PARKS this step (never auto-retried); the run gates `exception` (§06 §6.3.5)
15
+ onError: 'park', // a final customer-fault failure parks too — the operator retries / skips / supplies the refundId / fails it
16
+ requiredConnections: ['stripe'], // the declared key below — resolved on the owner agent at run time (LUA-623); no connection of that type ⇒ failed{credentials_unresolved}, unmountable ⇒ failed{credentials_revoked, reason:'required_connection_unmountable'} → parked via onError:'park' (§11 §11.5.5)
14
17
  async execute({ inputData, occurrenceId }) {
15
18
  if (!inputData.approved) return { refundId: null };
16
19
  const r = inputData.editedPayload ?? inputData.input;
17
20
  // `occurrenceId` is `${lineageId}:${stepId}`: the same key on retry-step, on resume and on a REPAIR RUN — the refund collapses to one (§06 §6.3.3 (e)).
18
21
  // A key that must hold across INDEPENDENT runs (two runs for the same ticket) is the caller's: `refund:${r.ticketId}` (§06 §6.3.3 (f)).
19
- const res = await Integrations.passthrough('stripe', { method: 'POST', path: '/v1/refunds',
20
- headers: { 'Idempotency-Key': `refund:${r.ticketId}` }, body: { charge: r.ticketId, amount: r.amount } });
22
+ const res = await Integrations.passthrough('stripe', {
23
+ method: 'POST',
24
+ path: '/v1/refunds',
25
+ headers: { 'Idempotency-Key': `refund:${r.ticketId}` },
26
+ body: { charge: r.ticketId, amount: r.amount },
27
+ });
21
28
  return { refundId: res.body.id };
22
29
  },
23
30
  });
@@ -26,18 +33,24 @@ export const refund = createWorkflow({
26
33
  name: 'refund-approval',
27
34
  inputSchema: z.object({ ticketId: z.string(), amount: z.number(), requesterId: z.string() }),
28
35
  outputSchema: z.object({ refundId: z.string().nullable() }),
29
- budget: { maxDurationSeconds: 14 * 24 * 3600 }, // long enough for two business-hours escalation hops (the compiler warns `deadline-clamped` otherwise)
36
+ budget: { maxDurationSeconds: 14 * 24 * 3600 }, // long enough for two business-hours escalation hops (the compiler warns `deadline-clamped` otherwise)
37
+ connections: [{ key: 'stripe', integrationType: 'stripe', required: true }], // the Stripe connection the refund step acts through, declared once by key — never a frozen connection id (LUA-623)
30
38
  })
31
39
  .approval('approveRefund', {
32
- title: 'Refund request', details: template('Refund ${initData.amount} for ticket ${initData.ticketId}'),
33
- approver: { role: 'support-lead' }, // org role, template-portable; `{users:[…]}` / `{group:'finance'}` are org data (§13 §13.3.3)
34
- excludeInitiator: true, // the requester who started the run can never approve it (maker-checker)
35
- fourEyes: { edit: { role: 'support-lead' }, approve: { role: 'finance-controller' } }, // whoever edits the amount cannot be the one who approves it
36
- timeoutHours: 8, businessHours: { tz: 'Europe/London', calendar: 'mon-fri' }, // 8 business hours, then…
37
- onTimeout: [{ escalateTo: { role: 'finance-controller' }, timeoutHours: 16 }, // …hop 1: finance, 16 business hours, then…
38
- { escalateTo: 'org-admins', timeoutHours: 24 }, // …hop 2: org admins, then…
39
- 'deny'], // …the node completes {approved:false, timedOut:true, escalations:2} — branchable data (§06 §6.4.11)
40
- editable: true, editablePaths: ['amount'], // the approver may lower the amount; the approve call echoes the fingerprint of the revision it saw (§06 §6.4.9)
40
+ title: 'Refund request',
41
+ details: template('Refund ${initData.amount} for ticket ${initData.ticketId}'),
42
+ approver: { role: 'support-lead' }, // org role, template-portable; `{users:[…]}` / `{group:'finance'}` are org data (§13 §13.3.3)
43
+ excludeInitiator: true, // the requester who started the run can never approve it (maker-checker)
44
+ fourEyes: { edit: { role: 'support-lead' }, approve: { role: 'finance-controller' } }, // whoever edits the amount cannot be the one who approves it
45
+ timeoutHours: 8,
46
+ businessHours: { tz: 'Europe/London', calendar: 'mon-fri' }, // 8 business hours, then…
47
+ onTimeout: [
48
+ { escalateTo: { role: 'finance-controller' }, timeoutHours: 16 }, // …hop 1: finance, 16 business hours, then…
49
+ { escalateTo: 'org-admins', timeoutHours: 24 }, // …hop 2: org admins, then…
50
+ 'deny',
51
+ ], // …the node completes {approved:false, timedOut:true, escalations:2} — branchable data (§06 §6.4.11)
52
+ editable: true,
53
+ editablePaths: ['amount'], // the approver may lower the amount; the approve call echoes the fingerprint of the revision it saw (§06 §6.4.9)
41
54
  editedPayloadSchema: z.object({ ticketId: z.string(), amount: z.number().positive().max(500) }),
42
55
  })
43
56
  .then(postRefund)
@@ -1,44 +1,81 @@
1
1
  // Support triage from a customer channel — knowledge grounding, toolScope, dataset ref, ctx.artefacts.
2
- // Verbatim from workflows-spec 03 §3.2 (g) (WF-215 / WF-223 — the spec is normative).
3
- import { createWorkflow, createStep, fromInit, fromKnowledge, rows, template, stepOf, gt, lit } from 'lua-cli';
2
+ // From workflows-spec 03 §3.2 (g) (WF-215 / WF-223 — the spec is normative). LUA-635: the listing's `.conditional([{ when, branch }])`
3
+ // is not in the builder (03 §3.1 has `switch` / `branch` over `[predicate, StepRef]` arms) and it named an approval as an arm, which
4
+ // v1 forbids (approvals are top-level only) — the gate is a `switch` whose confident arm is (e)'s `refund-approval` as a child run.
5
+ // (The listing's `template(…)` approval title was the other drift: the grammar is `title: string`, bindings ride `details`.)
6
+ import {
7
+ createWorkflow,
8
+ createStep,
9
+ fromInit,
10
+ fromStep,
11
+ fromKnowledge,
12
+ rows,
13
+ template,
14
+ stepOf,
15
+ gt,
16
+ lit,
17
+ } from 'lua-cli';
4
18
  import { z } from 'zod';
5
19
 
6
- const pullHistory = createStep({ // array-typed output ⇒ oversize (> 8 MB) becomes a `{__datasetRef}` (NDJSON on CDN), never OUTPUT_TOO_LARGE
7
- id: 'pullHistory', inputSchema: z.object({ customerId: z.string() }),
20
+ const pullHistory = createStep({
21
+ // array-typed output ⇒ oversize (> 8 MB) becomes a `{__datasetRef}` (NDJSON on CDN), never OUTPUT_TOO_LARGE
22
+ id: 'pullHistory',
23
+ inputSchema: z.object({ customerId: z.string() }),
8
24
  outputSchema: z.object({ orders: z.array(z.object({ id: z.string(), total: z.number(), status: z.string() })) }),
9
25
  execute: async (ctx) => {
10
26
  const orders = await Orders.list({ customerId: ctx.inputData.customerId, limit: 50_000 });
11
27
  const csv = ['id,total,status', ...orders.map((o) => `${o.id},${o.total},${o.status}`)].join('\n');
12
- const { artefactId } = await ctx.artefacts.put('orders.csv', csv, { // P1-8: journaled in script form; ≤ 50 per step
13
- contentType: 'text/csv', kind: 'dataset', datasetSchema: { type: 'object', properties: { id: { type: 'string' }, total: { type: 'number' }, status: { type: 'string' } } } });
28
+ const { artefactId } = await ctx.artefacts.put('orders.csv', csv, {
29
+ // P1-8: journaled in script form; 50 per step
30
+ contentType: 'text/csv',
31
+ kind: 'dataset',
32
+ datasetSchema: {
33
+ type: 'object',
34
+ properties: { id: { type: 'string' }, total: { type: 'number' }, status: { type: 'string' } },
35
+ },
36
+ });
14
37
  ctx.log(`orders.csv → ${artefactId}`);
15
38
  return { orders };
16
39
  },
17
40
  });
18
41
 
19
- const classifyOut = z.object({ intent: z.enum(['refund', 'status', 'other']), confidence: z.number(), orderId: z.string().optional() });
42
+ const classifyOut = z.object({
43
+ intent: z.enum(['refund', 'status', 'other']),
44
+ confidence: z.number(),
45
+ orderId: z.string().optional(),
46
+ amount: z.number().optional(),
47
+ }); // `amount`: the order total a confident refund carries into (e)
20
48
 
21
49
  export default createWorkflow({
22
- name: 'support-triage', inputSchema: z.object({ ticketId: z.string(), customerId: z.string(), message: z.string() }),
50
+ name: 'support-triage',
51
+ inputSchema: z.object({ ticketId: z.string(), customerId: z.string(), message: z.string() }),
23
52
  })
24
53
  .then(pullHistory)
25
- .map({
26
- refund: fromKnowledge({ source: 'org-docs', query: 'refund policy', maxChars: 4000, topK: 3 }), // rendered at S2 with a provenance header per chunk
27
- recent: rows('pullHistory', 'orders', { offset: 0, limit: 20 }), // paged rows; the bare ref would be the {__datasetRef} object
28
- }, { id: 'classifyInputs' })
54
+ .map(
55
+ {
56
+ refund: fromKnowledge({ source: 'org-docs', query: 'refund policy', maxChars: 4000, topK: 3 }), // rendered at S2 with a provenance header per chunk
57
+ recent: rows('pullHistory', 'orders', { offset: 0, limit: 20 }), // paged rows; the bare ref would be the {__datasetRef} object
58
+ },
59
+ { id: 'classifyInputs' }
60
+ )
29
61
  .agentStep('classify', {
30
62
  agentId: 'support-agent',
31
63
  // `${initData.message}` is customer-channel content and `refund` is a {knowledge} binding: this step is EXTERNAL-CONTENT — `toolScope` is mandatory.
32
- prompt: template('Ticket ${initData.ticketId}: ${initData.message}\n\nRefund policy:\n${stepResults.classifyInputs.refund}\n\nRecent orders (first page):\n${stepResults.classifyInputs.recent}'),
33
- toolScope: {}, // `{}` = no tools; `defaultToolScopeMode:'deny'` would imply this when absent
64
+ prompt: template(
65
+ 'Ticket ${initData.ticketId}: ${initData.message}\n\nRefund policy:\n${stepResults.classifyInputs.refund}\n\nRecent orders (first page):\n${stepResults.classifyInputs.recent}'
66
+ ),
67
+ toolScope: {}, // `{}` = no tools; `defaultToolScopeMode:'deny'` would imply this when absent
34
68
  outputSchema: classifyOut,
35
69
  })
36
- .approval('refundGate', { title: template('Refund ${stepResults.classify.orderId}?'), approver: 'org-admins', // NOT 'creator': `approver-is-run-principal` on a customer-channel workflow
37
- details: template('${stepResults.classify.intent} @ ${stepResults.classify.confidence}'), timeoutHours: 24,
38
- onTimeout: [{ escalateTo: 'org-admins', timeoutHours: 24 }, 'deny'] }) // approvers re-resolved at suspend AND decision (§06 §6.11); the card previews `orders.csv` (name/size/rowCount + an R32 link)
39
- .conditional([
40
- { when: gt(stepOf<typeof classifyOut>('classify').path('confidence'), lit(0.8)), branch: 'refundGate' },
41
- { when: lit(true), branch: 'handoff' },
42
- ])
43
- .agentStep('handoff', { agentId: 'support-agent', prompt: template('Summarise ${initData.message} for a human agent.'), toolScope: {} })
70
+ .switch([[gt(stepOf<typeof classifyOut>('classify').path('confidence'), lit(0.8)), 'refundGate']], 'handoff') // a confident refund goes to the gate, anything else to a human. An approval is TOP-LEVEL only (03 §3.1), so the gated one is a child run whose approval sits at its own top level — the same shape as a wait inside a loop (03 §3.2 (f))
71
+ .workflow('refundGate', 'refund-approval', {
72
+ ticketId: fromInit('ticketId'),
73
+ amount: fromStep('classify', 'amount'),
74
+ requesterId: fromInit('customerId'),
75
+ }) // (e)'s maker-checker run, nested by name on the same agent; declared here, placed inside the switch arm by the string ref above. Its approver is a role, NOT 'creator': `approver-is-run-principal` on a customer-channel workflow
76
+ .agentStep('handoff', {
77
+ agentId: 'support-agent',
78
+ prompt: template('Summarise ${initData.message} for a human agent.'),
79
+ toolScope: {},
80
+ }) // the switch's `otherwise`
44
81
  .commit();