From 80b87b59fda3da899717b7f322039cd3f69b1faf Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 18 Sep 2026 14:27:17 -0700 Subject: [PATCH] =?UTF-8?q?docs(relayflows):=20f.human=20is=20shipped=20?= =?UTF-8?q?=E2=80=94=20local=20answer/resume,=20Cloud=20delivery=20to=20Sl?= =?UTF-8?q?ack=20and=20GitHub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relayflows 2.0.18 lowers `f.human(question, { to })` onto the kernel's durable `wait.human`; the docs still said it threw `unsupported_verb` and pointed at `f.done('needs_human')` as the only human gate. Every claim below was read from flows origin/main (docs/SURFACE.md §5 "Human gates", docs/CLOUD.md, packages/sdk/src/human-to.ts, packages/sdk/src/cli/answer.ts) and cloud origin/main (lib/flows/human-gate-delivery.ts, human-gate-runtime.ts, human-gate-resume.ts, the runs/[runId]/answer route). The three new TypeScript samples pass `tsc` and `flows check` against the published 2.0.19 surface. - introduction: rung 4 of the ladder now describes a shipped `f.human`; only `dispatch` is still refused. - build: `Ctx.human` returns `Step` (so `.gate()` attaches); the "neither runs" Note is now dispatch-only; new "Human gates" section — content-pipeline sample with `to: 'slack:#marketing'`, the PARKED output, `flows answer` / `flows resume`, the `human-N` journal step and its attribution, the four `to` forms, `human_to_invalid` (flows' current main, release after 2.0.19), and what's not enforced yet (`timeout`). - cloud: new "Human approval on Cloud" section — a `pull_request`-triggered release-notes sample with `to: 'github:@khaliqgant'`, delivery table, how to answer (thread reply, ✅/❌, `@relay yes `), the bot's acknowledgements, REQUIRES / connect prompting, the answer route and resume, and what's not yet (`flows answer --cloud`, reaction retraction). The listener Note no longer calls pull_request events and hosted schedules unshipped: `--on github:events=pull_request` is 2.0.17+, `flows schedule` is 2.0.18+. - cli: usage block is 2.0.18's (adds `answer`, `schedule`/`schedules`/ `unschedule`, `--no-connect`); new "Answer a human gate" section. - reliability: exit 3 and the completion paragraph mention the f.human wait. - multi-agent: the ship-feature sample is a shipped `f.human` with a still unshipped `f.dispatch`, and the Note says only that. - flows gallery (web/app/flows/flow-examples.ts): the five samples that carried "f.human is declared but not yet executed (flows#400)" drop that comment. Co-Authored-By: Claude Opus 5 (1M context) --- web/app/flows/flow-examples.ts | 10 +-- web/content/docs/relayflows/build.mdx | 76 +++++++++++++++++--- web/content/docs/relayflows/cli.mdx | 26 +++++-- web/content/docs/relayflows/cloud.mdx | 66 +++++++++++++++-- web/content/docs/relayflows/introduction.mdx | 2 +- web/content/docs/relayflows/multi-agent.mdx | 18 +++-- web/content/docs/relayflows/reliability.mdx | 4 +- 7 files changed, 163 insertions(+), 39 deletions(-) diff --git a/web/app/flows/flow-examples.ts b/web/app/flows/flow-examples.ts index 2085ae8..06d18b2 100644 --- a/web/app/flows/flow-examples.ts +++ b/web/app/flows/flow-examples.ts @@ -21,7 +21,7 @@ export const flowExamples = [ "title": "Draft the reply. Let a human decide.", "description": "Draft a response to a support thread, get approval, and reply in Slack.", "filename": "support-triage.flow.ts", - "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n message: string;\n channel: string;\n threadTs: string;\n approver: string;\n};\n\nexport default flow(\n \"support-triage\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"triage\", {\n task: `Classify this request: ${input.message}. ` +\n \"Write the category and urgency to triage.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s triage.md` });\n\n await f.agent(\"writer\", {\n task: `Read triage.md and draft a reply to: ` +\n `${input.message}. Write only the reply to reply.md.`,\n }).gate({ type: \"subprocess_gate\", command: `test -s reply.md` });\n\n const reply = await f.run(\"cat reply.md\");\n // f.human is declared but not yet executed by the SDK (flows#400) \u2014 this\n // shows the intended approval gate, not a runnable one, until it lands.\n const approved = await f.human(\n `Send this reply?\\n\\n${reply}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n // Send only after a human approves.\n await f.slack.reply(input.channel, input.threadTs, reply);\n f.done(\"success\");\n },\n);" + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n message: string;\n channel: string;\n threadTs: string;\n approver: string;\n};\n\nexport default flow(\n \"support-triage\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"triage\", {\n task: `Classify this request: ${input.message}. ` +\n \"Write the category and urgency to triage.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s triage.md` });\n\n await f.agent(\"writer\", {\n task: `Read triage.md and draft a reply to: ` +\n `${input.message}. Write only the reply to reply.md.`,\n }).gate({ type: \"subprocess_gate\", command: `test -s reply.md` });\n\n const reply = await f.run(\"cat reply.md\");\n const approved = await f.human(\n `Send this reply?\\n\\n${reply}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n // Send only after a human approves.\n await f.slack.reply(input.channel, input.threadTs, reply);\n f.done(\"success\");\n },\n);" }, { "id": "content-pipeline", @@ -29,7 +29,7 @@ export const flowExamples = [ "title": "Put a fact-check between draft and publish.", "description": "Research, draft, and check a post before a human approves sharing it in Slack.", "filename": "content-pipeline.flow.ts", - "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n topic: string;\n channel: string;\n approver: string;\n};\n\nexport default flow(\n \"content-pipeline\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"researcher\", {\n task: `Research ${input.topic}. Cite your sources. ` +\n \"Write research.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s research.md` });\n\n await f.agent(\"writer\", {\n task: \"Use research.md to write post.md. \" +\n \"Keep the source links with each claim.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s post.md` });\n\n await f.agent(\"fact-checker\", {\n task: \"Check post.md against its sources. \" +\n \"Write checked.passed only if all claims hold up.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s checked.passed` });\n\n const post = await f.run(\"cat post.md\");\n // f.human is declared but not yet executed by the SDK (flows#400) \u2014 this\n // shows the intended approval gate, not a runnable one, until it lands.\n const approved = await f.human(\n `Publish this post?\\n\\n${post}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n await f.slack.post(input.channel, post);\n f.done(\"success\");\n },\n);" + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n topic: string;\n channel: string;\n approver: string;\n};\n\nexport default flow(\n \"content-pipeline\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"researcher\", {\n task: `Research ${input.topic}. Cite your sources. ` +\n \"Write research.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s research.md` });\n\n await f.agent(\"writer\", {\n task: \"Use research.md to write post.md. \" +\n \"Keep the source links with each claim.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s post.md` });\n\n await f.agent(\"fact-checker\", {\n task: \"Check post.md against its sources. \" +\n \"Write checked.passed only if all claims hold up.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s checked.passed` });\n\n const post = await f.run(\"cat post.md\");\n const approved = await f.human(\n `Publish this post?\\n\\n${post}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n await f.slack.post(input.channel, post);\n f.done(\"success\");\n },\n);" }, { "id": "repo-migration", @@ -45,7 +45,7 @@ export const flowExamples = [ "title": "Turn a voicemail into a callback brief.", "description": "Use a transcript to prepare the callback, then send the approved brief to a teammate.", "filename": "voicemail-follow-up.flow.ts", - "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n transcript: string;\n approver: string;\n callbackOwner: string;\n};\n\nexport default flow(\n \"voicemail-follow-up\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"triage\", {\n task: `Read this voicemail: ${input.transcript}. ` +\n \"Identify urgency and the caller's request. \" +\n \"Write triage.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s triage.md` });\n\n await f.agent(\"callback-writer\", {\n task: \"Read triage.md. Prepare a callback brief \" +\n \"with the questions we need to answer. \" +\n \"Write callback.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s callback.md` });\n\n const brief = await f.run(\"cat callback.md\");\n // f.human is declared but not yet executed by the SDK (flows#400) \u2014 this\n // shows the intended approval gate, not a runnable one, until it lands.\n const approved = await f.human(\n `Ready for a callback?\\n\\n${brief}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n await f.slack.dm(input.callbackOwner, brief);\n f.done(\"success\");\n },\n);" + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n transcript: string;\n approver: string;\n callbackOwner: string;\n};\n\nexport default flow(\n \"voicemail-follow-up\",\n { budget: \"$5/run\" },\n async (f, input) => {\n await f.agent(\"triage\", {\n task: `Read this voicemail: ${input.transcript}. ` +\n \"Identify urgency and the caller's request. \" +\n \"Write triage.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s triage.md` });\n\n await f.agent(\"callback-writer\", {\n task: \"Read triage.md. Prepare a callback brief \" +\n \"with the questions we need to answer. \" +\n \"Write callback.md.\",\n }).gate({ type: \"subprocess_gate\", command: `test -s callback.md` });\n\n const brief = await f.run(\"cat callback.md\");\n const approved = await f.human(\n `Ready for a callback?\\n\\n${brief}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n await f.slack.dm(input.callbackOwner, brief);\n f.done(\"success\");\n },\n);" }, { "id": "research-report", @@ -61,7 +61,7 @@ export const flowExamples = [ "title": "Choose what the summarizer gets to see.", "description": "Select the fields needed for the task before putting a record into the agent prompt.", "filename": "redacted-summary.flow.ts", - "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n record: { category: string; status: string; email: string };\n approver: string;\n};\n\nexport default flow(\n \"redacted-summary\",\n { budget: \"$5/run\" },\n async (f, input) => {\n // Select fields in code before building the prompt.\n // The email address is excluded from this input.\n const selected = {\n category: input.record.category,\n status: input.record.status,\n };\n\n await f.agent(\"summarizer\", {\n task: `Summarize this record: ` +\n `${JSON.stringify(selected)}. Write summary.md.`,\n }).gate({ type: \"subprocess_gate\", command: `test -s summary.md` });\n\n const summary = await f.run(\"cat summary.md\");\n // f.human is declared but not yet executed by the SDK (flows#400) \u2014 this\n // shows the intended approval gate, not a runnable one, until it lands.\n const approved = await f.human(\n `Review this summary before sharing:\\n\\n${summary}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n f.done(\"success\");\n },\n);" + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = {\n record: { category: string; status: string; email: string };\n approver: string;\n};\n\nexport default flow(\n \"redacted-summary\",\n { budget: \"$5/run\" },\n async (f, input) => {\n // Select fields in code before building the prompt.\n // The email address is excluded from this input.\n const selected = {\n category: input.record.category,\n status: input.record.status,\n };\n\n await f.agent(\"summarizer\", {\n task: `Summarize this record: ` +\n `${JSON.stringify(selected)}. Write summary.md.`,\n }).gate({ type: \"subprocess_gate\", command: `test -s summary.md` });\n\n const summary = await f.run(\"cat summary.md\");\n const approved = await f.human(\n `Review this summary before sharing:\\n\\n${summary}`,\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n\n f.done(\"success\");\n },\n);" }, { "id": "ci-repair", @@ -77,7 +77,7 @@ export const flowExamples = [ "title": "Send each issue to the right specialist.", "description": "Route bugs to an implementer and documentation requests to a writer. Ask a human when the category is uncertain.", "filename": "issue-routing.flow.ts", - "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = { issue: string; approver: string };\n\nexport default flow(\"issue-routing\", async (f, input) => {\n const classification = await f.agent(\"router\", {\n cli: \"claude\",\n task: `Classify this issue: ${input.issue}. ` +\n \"Return only bugfix, docs, or uncertain as your summary. \" +\n \"Choose uncertain if the request is ambiguous.\",\n });\n let route = classification.summary.trim().toLowerCase();\n\n // Unknown output never silently selects a specialist.\n if (route !== \"bugfix\" && route !== \"docs\") {\n // f.human is declared but not yet executed by the SDK (flows#400) \u2014 this\n // shows the intended approval gate, not a runnable one, until it lands.\n const approved = await f.human(\n `Needs manual triage: ${input.issue}\\n` +\n \"Route this to the bugfix specialist?\",\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n route = \"bugfix\";\n }\n\n if (route === \"bugfix\") {\n await f.agent(\"implementer\", {\n cli: \"codex\",\n task: `Fix this issue and add regression coverage: ${input.issue}`,\n });\n await f.run(\"npm test\");\n } else {\n await f.agent(\"docs-writer\", {\n cli: \"claude\",\n task: `Update the documentation for: ${input.issue}. ` +\n \"Check examples against the implementation.\",\n });\n }\n f.done(\"success\");\n});" + "code": "import { flow } from \"@relayflows/surface\";\n\ntype Input = { issue: string; approver: string };\n\nexport default flow(\"issue-routing\", async (f, input) => {\n const classification = await f.agent(\"router\", {\n cli: \"claude\",\n task: `Classify this issue: ${input.issue}. ` +\n \"Return only bugfix, docs, or uncertain as your summary. \" +\n \"Choose uncertain if the request is ambiguous.\",\n });\n let route = classification.summary.trim().toLowerCase();\n\n // Unknown output never silently selects a specialist.\n if (route !== \"bugfix\" && route !== \"docs\") {\n const approved = await f.human(\n `Needs manual triage: ${input.issue}\\n` +\n \"Route this to the bugfix specialist?\",\n { to: input.approver },\n );\n if (!approved) return f.done(\"declined\");\n route = \"bugfix\";\n }\n\n if (route === \"bugfix\") {\n await f.agent(\"implementer\", {\n cli: \"codex\",\n task: `Fix this issue and add regression coverage: ${input.issue}`,\n });\n await f.run(\"npm test\");\n } else {\n await f.agent(\"docs-writer\", {\n cli: \"claude\",\n task: `Update the documentation for: ${input.issue}. ` +\n \"Check examples against the implementation.\",\n });\n }\n f.done(\"success\");\n});" }, { "id": "competing-implementations", diff --git a/web/content/docs/relayflows/build.mdx b/web/content/docs/relayflows/build.mdx index fc7cfdd..c8f70f7 100644 --- a/web/content/docs/relayflows/build.mdx +++ b/web/content/docs/relayflows/build.mdx @@ -93,7 +93,7 @@ interface Ctx { llm(strings: TemplateStringsArray, ...values: unknown[]): Step; llm(prompt: string, options: { output: JsonSchema; cli?: string; model?: string }): Step; agent(name: string, options: { task: string; workspace?: string; cli?: string; model?: string }): Step<{ summary: string; artifacts: string[] }>; - human(question: string, options: { to: string }): Promise; + human(question: string, options: { to: string }): Step; dispatch(flow: string, input: unknown): Promise; done(reason: 'success' | 'step_failed' | 'needs_human' | 'declined'): void; slack: SlackHelper; github: GithubHelper; /* …every generated helper */ @@ -105,22 +105,76 @@ interface Ctx { This is the whole kernel-level vocabulary a step body speaks: `run`, `llm`, `agent` for work, `human`, `dispatch`, `done` for control. - `human` and `dispatch` are declared and typecheck, but neither runs in - 2.0.16 — verified: a flow that reaches `f.human` fails with - `unsupported_verb: the initial authored executor does not lower f.human`, - and the same for `f.dispatch`. Both are being implemented on flows' - `feat/f-human` branch and land in the release after 2.0.17. Once wired, - `human` is meant to park the run on a durable await instead of a blocking - call — a wait the journal can survive a restart across — and `dispatch` is - meant to hand work to a named child flow and return its typed result. - Until then, the shipped human gate is `f.done('needs_human')`: the run - parks (exit `3`), a person acts, and `flows resume ` continues it. + `human` ships in 2.0.18 — the run parks on a durable kernel wait and the + answer is journal evidence; [Human gates](#human-gates) below has the + contract. `dispatch` is declared and typechecks but still fails closed at + runtime — verified: a flow that reaches `f.dispatch` fails with + `unsupported_verb: the initial authored executor does not lower + f.dispatch`. It is meant to hand work to a named child flow and return its + typed result; until it lands, keep one flow per file. `done` takes one of four authored verdicts. `success` completes the run; `step_failed` says the flow's own checks did not pass (the adversarial review found problems, the tests went red) and exits `1`; `needs_human` parks the run (exit `3`); `declined` records a deliberate decision not to act on the input — a ticket that turned out not to be work — and exits `0` with a `DECLINED` diagnostic. `canceled` and `budget_exceeded` are in the `FlowCompletionReason` type but refused at runtime with `unsupported_completion`: they are kernel facts, recorded when the kernel cancels a run or exhausts its budget, not verdicts a body can declare. `f.run` returns the command's output; a step's `.summary` on `f.agent` is the CLI's final text. `artifacts` is always empty in 2.0.16 — populating it from what the agent actually wrote lands in the next release (flows#449). +## Human gates + +`f.human(question, { to })` asks a person a yes/no question and parks the run until they answer. Nothing blocks: the kernel records a durable `wait.human`, the process exits `3`, and the answer — whenever it comes — is journaled before the body continues from that line. Shipped in 2.0.18. + +```ts +import { flow } from '@relayflows/surface'; + +type Input = { topic: string; channel: string }; + +export default flow('content-pipeline', { budget: '$5/run' }, async (f, input) => { + await f.agent('writer', { + task: `Write a post about ${input.topic} to post.md.`, + }).gate({ type: 'subprocess_gate', command: 'test -s post.md' }); + + const post = await f.run('cat post.md'); + + const approved = await f.human(`Publish this post?\n\n${post}`, { to: 'slack:#marketing' }); + if (!approved) return f.done('declined'); + + await f.slack.post(input.channel, post); + f.done('success'); +}); +``` + +Run it locally and the run parks at the question: + +```text +$ flows run --local-agent content-pipeline.flow.ts --input '{"topic":"the launch","channel":"#marketing"}' +PARKED [run_parked] Run "01M2…" is waiting for slack:#marketing to answer human-1: "Publish this post?\n\n…" +Answer with: flows answer 01M2… human-1 yes|no +Then continue with: flows resume --local-agent 01M2… +``` + +- **The wait is named `human-N`** — the call's ordinal, counted with every other authored operation, so a resumed body finds the same wait. With `--json` the report carries it as `humanWait { waitId, question, to }`. +- **`flows answer yes|no [--note ] [--by ]`** records the decision as `{ answer, note?, answeredBy }`. `answeredBy` is `--by`, else your OS user; the kernel stamps `at_ms` from its own clock and journals `attribution: client_asserted`, because the daemon socket — not the kernel — authenticated whoever ran it. The kernel closes a wait once: a second answer, or an answer to a wait the run isn't asking, is refused as `human_wait_unknown`. +- **`flows resume `** re-runs the body. Every step before the gate is memoized under its admission key, so nothing upstream repeats, and `f.human` resolves from the journaled answer — lowered as a `human-N` deterministic step carrying the answer on stdout, the same evidence shape as every other step. +- **A "no" is a value, not a failure.** The body decides what it means; `f.done('declined')` exits `0`. +- **`f.human` returns a `Step`**, so a postfix named gate attaches like anywhere else: `f.human(…).gate({ type: 'subprocess_gate', command: '…' })` is honoured on the lowered `human-N` step. + +`to` says who is asked. Locally it's recorded with the question and printed in the `PARKED` line; on Cloud it's the delivery address (see [Human approval on Cloud](/docs/relayflows/cloud#human-approval-on-cloud)): + +| `to` | Who's asked | Who may answer | +| --- | --- | --- | +| `'slack:#marketing'` | a post in that Slack channel | anyone in the channel | +| `'slack:@khaliq'` | a Slack DM, mentioning them | that person | +| `'github:@khaliq'` | a comment on the triggering issue or PR, mentioning them | that login | +| `'khaliq'` | the deploy's approver, on the Slack thread or issue/PR the run was triggered from | that person | + +Anything else — `slack:` with no target, `github:#eng`, an unknown provider, a handle with spaces — is refused at the call as `human_to_invalid`, before an ordinal is consumed or anything is journaled, rather than parking the run on a question nobody will receive. That refusal is on flows' current `main` (flows#472) and lands in the release after 2.0.19; earlier releases record any string. + + + Not yet: a `timeout` on the question is recorded but not enforced — a + parked run waits until someone answers. A local run delivers nothing; it + prints the `flows answer` invocation and waits. Slack and GitHub delivery, + and answering by reply, need a Cloud run. + + ## Verification Every step's exit code is checked automatically. On top of that: diff --git a/web/content/docs/relayflows/cli.mdx b/web/content/docs/relayflows/cli.mdx index 0115d27..3ef3d48 100644 --- a/web/content/docs/relayflows/cli.mdx +++ b/web/content/docs/relayflows/cli.mdx @@ -3,21 +3,25 @@ title: 'CLI' description: 'Check a flow, run it locally or in Cloud, resume it, deploy it as a listener — the flows command surface.' --- -The full surface of `relayflows` 2.0.16, as `flows --help` prints it: +The full surface of `relayflows` 2.0.18, as `flows --help` prints it: ```text flows check [--watch] [--json] flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] [--reuse-from ] flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] --input flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir ] [--local-agent] +flows answer [--json] [--no-spawn] [--data-dir ] [--note ] [--by ] flows replay [--allow-human-influenced] [--json] [--data-dir ] [--at ] -flows run --cloud [--json] [--wait] [--sync-code] -flows run --cloud [--json] [--wait] [--sync-code] --input +flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] +flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] --input flows sync [--json] [--dir ] -flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--json] +flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--no-connect] [--json] flows deployments [--json] flows undeploy [--json] +flows schedule [--cron "" | --every ] [--tz ] [--input ] [--name ] [--no-connect] [--json] +flows schedules [--json] +flows unschedule [--json] flows build [--out ] flows build --verify @@ -77,6 +81,16 @@ flows replay --at # read-only reconstruction up to a step `resume` picks a run back up from its journal. Completed steps aren't re-executed; only the work that never finished, or never got a recorded outcome, runs again. A `run_not_found` target exits `2`; any other failure exits `1`, because the journal may already have changed. `replay` walks the journal without executing anything. Both take `--allow-human-influenced` to proceed past a run whose journal records a human intervention. +## Answer a human gate + +```bash +flows answer human-1 yes +flows answer human-1 no --note "not this week" --by slack:@khaliq +flows resume --local-agent +``` + +A run parked on `f.human` exits `3` and prints exactly these two commands with its own run and wait IDs. `answer` records the decision on the kernel's wait — `yes`/`no` (or `true`/`false`), an optional `--note`, and `answeredBy` from `--by` or your OS user, with the kernel's own timestamp and `attribution: client_asserted`. Nothing runs: `answer` attaches no worker, so it's followed by `resume`, which continues the body from the gate with everything before it memoized. Answering a wait the run isn't asking, or one already answered, is refused as `human_wait_unknown` (exit `2`) and names the open questions. There is no `--cloud` form yet (flows#475): a hosted run is answered where its question was delivered or through the run's answer route — see [Human approval on Cloud](/docs/relayflows/cloud#human-approval-on-cloud). + ## Observer links ```bash @@ -93,7 +107,7 @@ flows tick start --schedule-id daily --interval-ms 86400000 spec.json flows hn-monitor start spec.json ``` -`serve-webhook` receives provider events (`POST /providers/slack`, `POST /providers/github`, or a generic `POST /`) and writes them to the daemon's inbox for a bound trigger spec. `tick` is a durable local schedule: each interval gets a unique ID, so a restart can't fire the same interval twice, and `--max-catch-up` bounds how many missed intervals get replayed if the process was down. `hn-monitor` is a narrower, named source built the same way. All of them are processes that must stay running; hosted schedules for v2 flows are not available yet. +`serve-webhook` receives provider events (`POST /providers/slack`, `POST /providers/github`, or a generic `POST /`) and writes them to the daemon's inbox for a bound trigger spec. `tick` is a durable local schedule: each interval gets a unique ID, so a restart can't fire the same interval twice, and `--max-catch-up` bounds how many missed intervals get replayed if the process was down. `hn-monitor` is a narrower, named source built the same way. All of them are processes that must stay running; `flows schedule` (2.0.18+) registers a cron on Cloud instead — see [Cloud](/docs/relayflows/cloud#deploy-it-as-a-listener). ## Bundles @@ -117,7 +131,7 @@ the body is not executed during build ## `--json` -`check`, `run`, `resume`, `replay`, `build`, `sync`, `deploy` (listener form), `deployments`, and `undeploy` accept `--json` for a single machine-readable object on stdout instead of the human-readable lines — the shape a CI step or another program should read. `tick start`, `hn-monitor start`, `serve-webhook`, and `observer` don't take it. +`check`, `run`, `resume`, `answer`, `replay`, `build`, `sync`, `deploy` (listener form), `deployments`, `undeploy`, `schedule`, `schedules`, and `unschedule` accept `--json` for a single machine-readable object on stdout instead of the human-readable lines — the shape a CI step or another program should read. `tick start`, `hn-monitor start`, `serve-webhook`, and `observer` don't take it. ## Exit codes diff --git a/web/content/docs/relayflows/cloud.mdx b/web/content/docs/relayflows/cloud.mdx index 6e5fd14..5bbd011 100644 --- a/web/content/docs/relayflows/cloud.mdx +++ b/web/content/docs/relayflows/cloud.mdx @@ -47,10 +47,68 @@ flows undeploy `--on [:key=value,…]` takes `github` (`repository`, `labels`, `contains`), `slack` (`channel`, `contains`), `linear` (`team`, `contains`), `jira` (`project`, `contains`) or `shortcut` (`workspace`, `contains`), each at most once; a GitHub source without `repository` is scoped to `--repo`. `--agents` names the coding-agent harnesses the flow uses (default `claude`); activation checks their credentials are connected and refuses with `flow_model_not_connected` otherwise. `--draft` saves without activating. Refusals are named — `flow_repository_not_connected`, `flow_name_taken`, … — rather than reported as a bare status. - Today a GitHub listener wakes on `issues.opened` and `issues.labeled`. - Pull-request events (`--on github:events=pull_request`) and hosted - schedules for v2 flows are in progress and not in 2.0.16; the local - `flows tick` schedule still works in the meantime. + A GitHub listener wakes on `issues.opened` and `issues.labeled` by default. + `--on github:events=pull_request` (2.0.17+) wakes instead on a pull request + being opened, pushed to, reopened, or reviewed; that run checks out the + PR's own head and receives `input.pullRequest` (`number`, `title`, `body`, + `headRef`, `headSha`, `baseRef`, `author`, `draft`, `labels`, `url`, and + `review` for a submitted review) beside `input.issue`. Comment and + check-run events are not wake sources yet. Hosted schedules are + `flows schedule --cron "…" | --every 15m` (2.0.18+): each fire + replays the exact request `flows run --cloud` would send, so `--sync-code` + and repository grants are refused on a schedule. + + +## Human approval on Cloud + +A hosted run that reaches `f.human` parks the same way a local one does ([Human gates](/docs/relayflows/build#human-gates)) — then Cloud delivers the question to the person where they already are, and takes the answer from there. This flow drafts release notes for every pull request on a repo and asks one person before it posts them: + +```ts +import { flow } from '@relayflows/surface'; + +type Input = { pullRequest?: { number: number; title?: string } }; + +export default flow('release-notes', { budget: '$5/run' }, async (f, input) => { + if (!input.pullRequest) return f.done('declined'); + + await f.agent('writer', { + task: `Draft release notes for pull request #${input.pullRequest.number} ("${input.pullRequest.title ?? ''}") into NOTES.md.`, + }).gate({ type: 'subprocess_gate', command: 'test -s NOTES.md' }); + + const notes = await f.run('cat NOTES.md'); + const approved = await f.human(`Post these release notes on #${input.pullRequest.number}?\n\n${notes}`, { to: 'github:@khaliqgant' }); + if (!approved) return f.done('declined'); + + await f.github.comment({ owner: 'acme', repo: 'api', number: input.pullRequest.number }, notes); + f.done('success'); +}); +``` + +```bash +flows deploy release-notes.flow.ts --repo acme/api --on github:events=pull_request --approver khaliqgant +``` + +When the run parks, Cloud records `completionReason: needs_human` with `humanWait { waitId, question, to }` on the run, and `to` decides where the question goes: + +| `to` | Delivered as | Who may answer | +| --- | --- | --- | +| `'slack:#marketing'` | a post in that channel | anyone in the channel | +| `'slack:@khaliq'` | a DM, mentioning them | only that Slack user | +| `'github:@khaliqgant'` | a comment on the triggering issue or PR, mentioning them | only that GitHub login | +| `'khaliq'` | the deploy's `--approver`, on the Slack thread or issue/PR the run was triggered from | only that person | + +**Answering.** In Slack, reply **yes** or **no** in the thread under the question, or react ✅ / ❌ on it; in a DM a flat reply works too. On GitHub, comment `@relay yes ` or `@relay no `, where `` is the 8-character code printed in the question comment — it names the question, so two open questions on one PR can't be confused. The bot acknowledges (`Got it — yes. Resuming run `) and the run resumes automatically with the answer applied; the resumed body continues from the gate with every earlier step memoized. Someone other than the addressed person gets `Only <@…> can answer this one.`; a second answer gets `This was already answered yes by .` — the first decision stands. + +**What the flow needs connected.** A literal `slack:` or `github:` `to` is a requirement of the flow, like an `f.slack.post` in the same body: `flows check` lists it under `REQUIRES` (`slack (f.human to)` — on flows' current `main`, in the release after 2.0.19; 2.0.19 lists the helper calls only), and `flows deploy` / `flows run --cloud` offer to connect a missing provider in the terminal before submitting, or refuse with `integration_not_connected` under `--no-connect` or `--json`. A bare approver handle needs whichever provider the run was triggered from. A computed `to` (`input.approver`) is resolved by Cloud at park time. + +**The answer route.** The delivered channel is the intended way to answer, but the same wait is answerable over HTTP: `GET /api/v1/workflows/runs//answer` shows the open question and any recorded answer; `POST` records one. Both take a dashboard session or a `cli:auth` login token, and only the run's owner or an organisation owner may decide there — the run's own credentials cannot, since a gate an agent could satisfy is not a gate. A wait that's already answered is `409`. The route records the decision without launching anything; `POST /api/v1/workflows/run` with `{ resume: , relayflowVersion: 'v2' }` and the run's original source applies it inside the resumed sandbox, where the flows CLI runs `flows answer` before `flows resume` so the kernel closes the wait exactly once. + + + Not yet: `flows answer --cloud` in the CLI (flows#475) — answer where the + question was delivered, or through the route. Removing a ✅ / ❌ reaction + does not retract an answer. A `timeout` on the question is recorded but + not enforced. Delivery needs a Cloud run: a local `flows run` prints the + `flows answer` invocation instead. ## Credentials diff --git a/web/content/docs/relayflows/introduction.mdx b/web/content/docs/relayflows/introduction.mdx index dc95e59..1a781dd 100644 --- a/web/content/docs/relayflows/introduction.mdx +++ b/web/content/docs/relayflows/introduction.mdx @@ -20,7 +20,7 @@ Every flow is built from the same small set of rungs, and you only climb as high 1. **`run`** — a shell command. No model involved. 2. **`llm`** — a bare model call. Prompt in, verified output out, no workspace, no tool use. 3. **`agent`** — a harnessed coding agent in a workspace. Returns an artifact, not just text. -4. **`human` / `dispatch` / `done`** — durable approval, handing work to a child flow, and a typed finish. `human` and `dispatch` are declared in the surface and pass `flows check`, but the 2.0.16 runtime refuses them (`unsupported_verb`); they ship in the release after 2.0.17. Today the shipped human gate is `f.done('needs_human')`, which parks the run for a person to resume. +4. **`human` / `dispatch` / `done`** — durable approval, handing work to a child flow, and a typed finish. `f.human(question, { to })` parks the run until a person answers — locally with `flows answer`, on Cloud by replying **yes** or **no** in the Slack thread or on the issue the question was delivered to (2.0.18+; see [Human gates](/docs/relayflows/build#human-gates)). `dispatch` is declared and passes `flows check`, but the runtime still refuses it (`unsupported_verb`). ## Simple Example Flow diff --git a/web/content/docs/relayflows/multi-agent.mdx b/web/content/docs/relayflows/multi-agent.mdx index 9fcb2f6..6370b24 100644 --- a/web/content/docs/relayflows/multi-agent.mdx +++ b/web/content/docs/relayflows/multi-agent.mdx @@ -77,7 +77,7 @@ In TypeScript every `f.agent` call names its own `cli` and `model` (flows#310); ## Asking a human, then handing off -`Ctx` declares both verbs, and this typechecks and passes `flows check` — but neither runs yet in 2.0.16. Treat the shape below as the intended design, not something to ship on today: +`f.human` is the approval gate, and it ships (2.0.18+): the run parks on a durable wait, and the person's answer comes back as the boolean the body branches on. `f.dispatch` is declared and typechecks, but still fails closed at runtime — treat that half of the sample as the intended shape: ```ts import { flow } from '@relayflows/surface'; @@ -95,17 +95,15 @@ export default flow('ship-feature', async (f) => { }); ``` -Once wired, `f.human` is meant to park the run on a durable wait — nothing sits there blocking a thread, and the wait would survive a restart exactly like a crash mid-step does. A "no" ends the run with `declined`, the verdict for choosing not to proceed; `canceled` is reserved for the kernel. `f.dispatch` is meant to hand the plan to a named child flow and return its typed result, so one large flow decomposes into several smaller ones instead of a script that tries to do everything. +`f.human` parks the run — nothing sits blocking a thread, and the wait survives a restart exactly like a crash mid-step does. Locally, `flows run` exits `3` and prints the `flows answer human-1 yes|no` that records the decision; `flows resume ` continues the body from the gate, with every step before it memoized. On Cloud, a bare handle like `'khaliq'` names the deploy's approver: the question is delivered to the Slack thread or GitHub issue/PR the run was triggered from, they reply **yes** or **no** there, and the run resumes on its own. A "no" ends the run with `declined`, the verdict for choosing not to proceed; `canceled` is reserved for the kernel. [Human gates](/docs/relayflows/build#human-gates) has the full contract and the `to` forms; [Cloud](/docs/relayflows/cloud#human-approval-on-cloud) covers delivery. - Verified directly: `flows run` on a flow that reaches `f.human` fails with - `unsupported_verb: the initial authored executor does not lower f.human`, - and the same for `f.dispatch`. Both are being implemented on flows' - `feat/f-human` branch and ship in the release after 2.0.17. Until then, do - durable approval in YAML instead (`recoveryMode: manual` parks a step as - `needs_human` with a diff for a person to resolve), or use the shipped - human gate directly: `f.done('needs_human')` parks the run (exit `3`) for - a person to act on, then `flows resume ` continues it. + Verified directly: `flows run` on a flow that reaches `f.dispatch` fails + with `unsupported_verb: the initial authored executor does not lower + f.dispatch`. It is meant to hand the plan to a named child flow and return + its typed result, so one large flow decomposes into several smaller ones. + Until it lands, keep the implementation in the same body — an `f.agent` + step after the gate — rather than a second flow. ## Next diff --git a/web/content/docs/relayflows/reliability.mdx b/web/content/docs/relayflows/reliability.mdx index d146978..47f6694 100644 --- a/web/content/docs/relayflows/reliability.mdx +++ b/web/content/docs/relayflows/reliability.mdx @@ -11,7 +11,7 @@ This page covers what makes a run's completion trustworthy: not an agent's own r 0 completionReason: success (a deliberate done("declined") also exits 0, with a DECLINED diagnostic) 1 a declared completionReason failure, or an unknown outcome from a transport/runtime/protocol error 2 refused before any journal write: invalid input, failed preflight, unreachable daemon, a run_not_found resume target -3 parked — waiting on an llm/agent worker or a needs_human recovery wait +3 parked — waiting on an llm/agent worker, a needs_human recovery wait, or a person's answer to f.human ``` Exit `2` is the important one to build on: a bad spec, a missing CLI, or an unauthenticated agent fails before a journal entry is ever written, at `flows check` time or at the top of `flows run` — never at minute 27 of a real run. Preflight checks what it can check up front, so a run either starts clean or doesn't start. @@ -27,7 +27,7 @@ Step: success · verification_failed · retries_exhausted · lease_expired · Run: success · step_failed · canceled · budget_exceeded ``` -An authored TypeScript body declares its verdict with `f.done`: `success`, `step_failed`, `needs_human` (parks), or `declined` (a deliberate decision not to act; the kernel records `success` and the CLI adds a `DECLINED` diagnostic). `canceled` and `budget_exceeded` are the kernel's to record, never a body's. Handle every value in those lists and there's no case left over to surprise you later. A step that fails verification is recorded as `verification_failed`, with exactly which check failed. The agent reporting that it went fine doesn't change the outcome. +An authored TypeScript body declares its verdict with `f.done`: `success`, `step_failed`, `needs_human` (parks), or `declined` (a deliberate decision not to act; the kernel records `success` and the CLI adds a `DECLINED` diagnostic). An `f.human` question parks the run the same way, on a kernel `wait.human`; the answer is a journaled `human-N` step recording who said what and when, so a resumed body branches on evidence, not on a chat message somebody remembers. `canceled` and `budget_exceeded` are the kernel's to record, never a body's. Handle every value in those lists and there's no case left over to surprise you later. A step that fails verification is recorded as `verification_failed`, with exactly which check failed. The agent reporting that it went fine doesn't change the outcome. ## Crashing mid-step