diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 46ee48a13..a46d8cc06 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -299,6 +299,7 @@ Gate 1 ships three CLI verbs over the journal protocol: ```text flows check [--json] flows run [--json] [--data-dir ] +flows run [--json] [--data-dir ] --input flows resume [--json] [--data-dir ] ``` @@ -309,6 +310,17 @@ existing run from its journal. The data directory defaults to `.relayflowd`. Neither verb starts the daemon implicitly. `--json` writes one report-shaped object to stdout while diagnostics remain on stderr. +A direct `.flow.ts` run requires `--input`. When its argument names an existing +regular file, the CLI parses that file as JSON; otherwise it parses the argument +itself as inline JSON. Direct input is limited to 1,048,576 UTF-8 bytes; file +size is checked before the file is read. Missing, invalid, or oversized input is +refused before the CLI contacts `relayflowd`. After the journal connection is +established, the authored body receives the parsed value as its second argument. +Each awaited `f.run` executes through the journal-backed authored runtime, and +JavaScript control flow observes the output read from `step.completed`. +Unsupported headers, verbs, gates, and completion reasons fail closed rather +than running through a second speculative compiler. + The exit codes are part of the surface contract: | Exit | Outcome | diff --git a/ops/reviews/20260902-1845-pr140-history.md b/ops/reviews/20260902-1845-pr140-history.md new file mode 100644 index 000000000..94ac9757c --- /dev/null +++ b/ops/reviews/20260902-1845-pr140-history.md @@ -0,0 +1,329 @@ +# PR #140 history/regression review + +Date: 2026-09-02 + +Reviewer lens: history/regression fit, issue #132 direct-input scope, live-daemon behavior, v1 preservation, and stacked dependency correctness + +Reviewed head: `2a98a3779e86df53fef6632d2adec59893b8f753` + +Declared base PR: #134 (`feat/v2-surface-package`) + +## Verdict + +FAIL. The direct-input happy path and its pre-daemon refusals work at the reviewed head, but this PR is not ready to merge: + +1. the head is based on the superseded #134 commit and is currently conflicting with #134's live head; relative to that live head, it removes the journal-backed authored-flow executor and other accepted surface work; +2. direct compilation does not actually fail closed for all output-dependent authored control flow. JavaScript truthiness bypasses the proxy traps, so a runtime-dependent branch is silently frozen into the submitted journal spec. + +Issue #132 remains open, appropriately: this PR is only its item-3 slice and does not establish the issue-level integrated done condition. + +## Blocking findings + +### F1 — P1: the stacked branch is stale, conflicting, and semantically regresses the live #134 head + +PR #140's exact head has parent `7266c5134c01151c28c7cc412380b2c0ee6b3dfe`. PR #134 has since advanced to `5092b76decca1530aaf6be0f81897945f9143ce5`, while GitHub still records #140's base OID as the old commit and reports the PR `DIRTY` / `CONFLICTING`. + +Literal command and captured output: + +```text +$ git rev-parse HEAD && git rev-parse origin/feat/v2-surface-package && git rev-list --parents -n 1 HEAD && gh pr view 140 --json baseRefName,baseRefOid,headRefOid,mergeable,mergeStateStatus && git merge-tree --write-tree origin/feat/v2-surface-package HEAD +2a98a3779e86df53fef6632d2adec59893b8f753 +5092b76decca1530aaf6be0f81897945f9143ce5 +2a98a3779e86df53fef6632d2adec59893b8f753 7266c5134c01151c28c7cc412380b2c0ee6b3dfe +{"baseRefName":"feat/v2-surface-package","baseRefOid":"7266c5134c01151c28c7cc412380b2c0ee6b3dfe","headRefOid":"2a98a3779e86df53fef6632d2adec59893b8f753","mergeStateStatus":"DIRTY","mergeable":"CONFLICTING"} +ae177f7ea6617e31fb50cc60a49249f7feaf2189 +100644 cf3f5cb0e4d243eab7c37b71af373e4f80205ea5 1 sdk/tests/authored-flow.test.ts +100644 b197ebed08df4535ced7311fcf2828d9675b2889 2 sdk/tests/authored-flow.test.ts +100644 ef20b6845aaacca7f02b479e8d2c5994204b0da7 3 sdk/tests/authored-flow.test.ts +100644 a3018f92d750d8ff4e4e19c20fb97de21a091184 1 surface/README.md +100644 f56bda555709cb48c3ef3db0691e0c6a35c01cb1 2 surface/README.md +100644 be5701b0e84623c572a79fd604bcff13be5142c0 3 surface/README.md +100644 560117268eff548a471ef3d2c6d034ecab630f8a 1 surface/src/flow.ts +100644 d809e87e8b816799e6ef2fae26475283093f507e 2 surface/src/flow.ts +100644 4b199341d2479e62b1c277df8f3b90a14fe07389 3 surface/src/flow.ts + +Auto-merging docs/SURFACE.md +Auto-merging sdk/tests/authored-flow.test.ts +CONFLICT (content): Merge conflict in sdk/tests/authored-flow.test.ts +Auto-merging surface/README.md +CONFLICT (content): Merge conflict in surface/README.md +Auto-merging surface/src/flow.ts +CONFLICT (content): Merge conflict in surface/src/flow.ts +Auto-merging surface/tests/flow.test.ts +[exit 1] +``` + +The intended PR-only delta against its original parent is 17 files, +662/-28. Against the live stacked base it is 38 files, +715/-595, including deletion of the 276-line `sdk/src/authored-flow-executor.ts` and `surface/src/completion.ts`, removal of SDK exports, and large reversals in the accepted authored-flow tests. + +Literal command and captured output: + +```text +$ git diff --stat 7266c5134c01151c28c7cc412380b2c0ee6b3dfe HEAD + docs/SURFACE.md | 10 ++ + sdk/src/authored-flow-compiler.ts | 236 ++++++++++++++++++++++++++++ + sdk/src/cli.ts | 30 +++- + sdk/src/cli/check.ts | 13 ++ + sdk/src/cli/run.ts | 44 +++++- + sdk/src/direct-input.ts | 52 ++++++ + sdk/src/failure-kinds.ts | 2 + + sdk/tests/authored-flow-compiler.test.ts | 39 +++++ + sdk/tests/authored-flow.test.ts | 2 +- + sdk/tests/cli.test.ts | 10 ++ + sdk/tests/direct-input.test.ts | 156 ++++++++++++++++++ + sdk/tests/fixtures/direct-input.flow.ts | 15 ++ + sdk/tests/fixtures/direct-topology.flow.ts | 10 ++ + sdk/tests/fixtures/output-dependent.flow.ts | 6 + + surface/README.md | 25 ++- + surface/src/flow.ts | 30 ++-- + surface/tests/flow.test.ts | 10 +- + 17 files changed, 662 insertions(+), 28 deletions(-) + +$ git diff --stat origin/feat/v2-surface-package HEAD + .github/workflows/surface-package.yml | 14 +- + docs/SURFACE.md | 14 +- + regressions/cron-succeeded-into-void.green.flow.ts | 2 +- + regressions/cron-succeeded-into-void.red.flow.ts | 2 +- + .../cross-account-workspace-404.green.flow.ts | 2 +- + .../cross-account-workspace-404.red.flow.ts | 2 +- + .../enrollment-token-bearer-auth.green.flow.ts | 2 +- + .../enrollment-token-bearer-auth.red.flow.ts | 2 +- + ...elaycast-workspace-key-repair-500.green.flow.ts | 2 +- + .../relaycast-workspace-key-repair-500.red.flow.ts | 2 +- + regressions/worker-daemon-bun-argv.green.flow.ts | 2 +- + regressions/worker-daemon-bun-argv.red.flow.ts | 2 +- + scripts/surface-package-gate.sh | 17 +- + sdk/src/authored-flow-compiler.ts | 236 ++++++++++++++++++ + sdk/src/authored-flow-executor.ts | 276 --------------------- + sdk/src/authored-flow.ts | 4 +- + sdk/src/cli.ts | 30 ++- + sdk/src/cli/check.ts | 13 + + sdk/src/cli/run.ts | 44 +++- + sdk/src/direct-input.ts | 52 ++++ + sdk/src/failure-kinds.ts | 2 + + sdk/src/index.ts | 7 - + sdk/tests/authored-flow-compiler.test.ts | 39 +++ + sdk/tests/authored-flow.test.ts | 158 ++---------- + sdk/tests/cli.test.ts | 10 + + sdk/tests/direct-input.test.ts | 156 ++++++++++++ + sdk/tests/fixtures/direct-input.flow.ts | 15 + + sdk/tests/fixtures/direct-topology.flow.ts | 10 + + sdk/tests/fixtures/output-dependent.flow.ts | 6 + + sdk/tests/fixtures/runtime-bridge.flow.ts | 5 +- + surface/README.md | 32 ++- + surface/src/cloud.ts | 8 +- + surface/src/completion.ts | 24 -- + surface/src/context.ts | 3 +- + surface/src/flow.ts | 57 ++--- + surface/src/index.ts | 6 - + surface/src/step.ts | 2 +- + surface/tests/flow.test.ts | 50 +--- + 38 files changed, 715 insertions(+), 595 deletions(-) +``` + +Required repair: rebase #140 onto exact #134 head `5092b76decca1530aaf6be0f81897945f9143ce5`, resolve the authored execution model deliberately, and review the resulting delta again. A mechanical conflict resolution is insufficient because #134's new executor journals awaited steps at runtime, whereas #140 introduces an ahead-of-time topology recorder. + +### F2 — P1: output-dependent truthiness silently compiles instead of failing closed + +`RecordingContext.thenable()` fulfills awaited steps with a proxy placeholder. `outputPlaceholder()` traps property access and coercion, which catches template interpolation, but JavaScript `ToBoolean` on an object does not call either trap: every object is truthy. Therefore `if (output)` follows the branch during compilation regardless of the eventual journaled step output. Strict equality has the complementary problem: `output === "value"` silently evaluates false without a trap. + +The existing regression test only interpolates the placeholder, so it exercises `Symbol.toPrimitive` and misses truthiness/equality control flow. + +I created this ephemeral review fixture with `apply_patch` (not a product-code edit): + +```js +import { flow } from "/Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface/dist/index.js"; + +export default flow("truthiness", {}, async (f) => { + const output = await f.run("printf ''"); + if (output) { + await f.run("printf SHOULD_NOT_BE_COMPILED"); + } +}); +``` + +The first step's runtime stdout is empty, yet compilation succeeds and unconditionally places the branch in the journal spec: + +```text +$ node --input-type=module -e 'import { compileAuthoredFlow } from "./sdk/dist/authored-flow-compiler.js"; const result = await compileAuthoredFlow("/tmp/pr140-history-truthiness.flow.mjs", {}); console.log(JSON.stringify(result, null, 2));' +{ + "version": "0.1.0", + "name": "truthiness", + "steps": [ + { + "id": "run-1", + "type": "deterministic", + "dependsOn": [], + "maxIterations": 1, + "command": "printf ''", + "verification": { + "type": "exit_code" + } + }, + { + "id": "run-2", + "type": "deterministic", + "dependsOn": [ + "run-1" + ], + "maxIterations": 1, + "command": "printf SHOULD_NOT_BE_COMPILED", + "verification": { + "type": "exit_code" + } + } + ] +} +``` + +This contradicts the PR body's statement that output-dependent cases are refused before submission and violates the repository's fail-closed rule. It can submit and journal a topology whose control-flow decision was made from a compiler sentinel rather than the durable runtime value. + +Required repair: choose semantics that cannot silently evaluate runtime outputs during ahead-of-time compilation. Reconciliation with #134's journal-backed runtime executor appears directly relevant. Add red/green cases for at least truthiness, negation, loose equality, strict equality, ternaries, and logical operators; do not describe output-dependent control flow as fail-closed until those cases are structurally impossible or correctly executed. + +## Positive behavior verified at the reviewed head + +The focused tests demonstrate these PASS results: + +- inline JSON and existing-file JSON both execute through the built CLI and a real `relayflowd`; +- the authored input reaches the deterministic command and the run carries `completionReason: success`; +- missing and malformed input are refused with exit 2 before daemon contact; +- ordinary YAML/JSON check/run behavior and journal-protocol run/resume behavior remain covered by the 50-test CLI suite; +- direct topology lowering, unsupported header refusal, and the currently tested interpolation form of output dependency behave as asserted. + +Literal command and captured output: + +```text +$ # working directory: /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk +$ ./node_modules/.bin/vitest run tests/authored-flow-compiler.test.ts tests/direct-input.test.ts tests/cli.test.ts --reporter=default + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk + + ✓ tests/authored-flow-compiler.test.ts (3 tests) 2496ms + ✓ authored flow compiler > compiles direct input and await topology into journal dependencies 1359ms + ✓ authored flow compiler > fails closed when author code reads a runtime step output during compilation 415ms + ✓ authored flow compiler > fails closed instead of dropping header fields absent from the kernel contract 718ms + ✓ tests/cli.test.ts (50 tests) 12056ms + ✓ flows check CLI > explains kernel-dialect routing and names the offending mixed-dialect key 433ms + ✓ flows check CLI > names an unknown key and its location in a compiled kernel spec 617ms + ✓ flows check CLI > passes all three canonical ladder flows and prints their resolved CLI 1838ms + ✓ flows check CLI > passes relocated ladder flow hello-llm when no fault is induced 338ms + ✓ flows check CLI > refuses ladder flow hello-deterministic with no_executor under an induced fault 344ms + ✓ flows check CLI > refuses ladder flow hello-llm with cli_missing under an induced fault 384ms + ✓ flows check CLI > refuses ladder flow hello-llm with cli_unauthenticated under an induced fault 594ms + ✓ flows check CLI > refuses ladder flow hello-llm with cli_unresolved under an induced fault 497ms + ✓ flows check CLI > refuses ladder flow hello-llm with no_executor under an induced fault 501ms + ✓ flows check CLI > refuses ladder flow hello-agent with cli_unauthenticated under an induced fault 558ms + ✓ flows run/resume CLI over the journal protocol > parses run options, submits the kernel dialect, and exits 0 on success 311ms + ✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 314ms + ✓ flows run/resume CLI over the journal protocol > reports a needs_human agent step as parked for human recovery 508ms + ✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 817ms + ✓ flows run/resume CLI over the journal protocol > allows a caller to cancel a worker-lease wait 433ms + ✓ tests/direct-input.test.ts (2 tests) 35593ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 25076ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 10501ms + + Test Files 3 passed (3) + Tests 55 passed (55) + Start at 19:15:24 + Duration 47.98s (transform 17.74s, setup 0ms, collect 24.08s, tests 50.15s, environment 10ms, prepare 10.89s) +``` + +The surface typecheck/test gate also passes: + +```text +$ # working directory: /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface +$ ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/tsc -p tsconfig.test.json && ./node_modules/.bin/vitest run --reporter=verbose + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface + + ✓ tests/flow.test.ts > flow > defines a flow with the empty header as the default + ✓ tests/flow.test.ts > flow > accepts an explicit escalation header + ✓ tests/flow.test.ts > flow > retains the body for an authorized runtime without executing it + ✓ tests/flow.test.ts > flow > passes direct-run input to the authored body + ✓ tests/flow.test.ts > flow > refuses counterfeit handles at the runtime boundary + + Test Files 1 passed (1) + Tests 5 passed (5) + Start at 19:13:00 + Duration 1.58s (transform 120ms, setup 0ms, collect 112ms, tests 15ms, environment 0ms, prepare 357ms) +``` + +`parseDirectInput()` implements the advertised existing-file-first choice: it resolves and reads an existing regular file, falls back to inline JSON only for `ENOENT`, and emits typed missing/invalid/unreadable refusals. `runDirectFlow()` parses, compiles, and preflights before `executeCheckedFlow()` creates and connects the journal client. The CLI rejects `--input` for non-authored paths, so v1 YAML/JSON retains its existing path. + +## Broad-suite disclosure + +The exact head's complete SDK run is not green in this worktree. With one worker, 19 files / 226 tests passed before `tests/live-kernel.test.ts` failed during module initialization because `statSync` is called but not imported. That file is not part of PR #140's intended delta, and the same missing import exists on the live #134 head, so I am recording this as a pre-existing verification limitation rather than attributing it to #140. It nevertheless means no broad-suite PASS is claimed here. The failure also differs from the timeout disclosed in the PR body. + +Literal focused reproduction and captured output: + +```text +$ # working directory: /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk +$ ./node_modules/.bin/vitest run tests/live-kernel.test.ts --reporter=default + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk + + ❯ tests/live-kernel.test.ts (0 test) + +⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/live-kernel.test.ts [ tests/live-kernel.test.ts ] +ReferenceError: statSync is not defined + ❯ tests/live-kernel.test.ts:55:37 + 53| .map((entry) => join(TOOLCHAIN_TARGET, entry, 'debug', 'relayf… + 54| .filter((candidate) => existsSync(candidate)) + 55| .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs) + | ^ + 56| : []; + 57| if (keyed[0] !== undefined) return keyed[0]; + ❯ locateRelayflowd tests/live-kernel.test.ts:55:10 + ❯ tests/live-kernel.test.ts:36:61 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests no tests + Start at 19:17:39 + Duration 19.00s (transform 4.05s, setup 0ms, collect 0ms, tests 0ms, environment 2ms, prepare 3.38s) +[exit 1] +``` + +The preparatory kernel build was also unavailable through the repository wrapper in this environment; the focused direct-input tests still exercised an existing real daemon binary and passed as shown above. + +```text +$ # working directory: /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/kernel +$ sh ../ops/cargo.sh build +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +[exit 1] +``` + +## Scope and checks + +The PR body explicitly says this is only issue #132 item 3, stacked on #134, and claims refusal of output-dependent cases. The issue is still open, while #134 is itself open at the newer head: + +```text +$ gh issue view 132 --json number,state,title,url +{"number":132,"state":"OPEN","title":"v2 authoring ergonomics: close the gaps found in the research-flow / v1 / Smithers comparison","url":"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/AgentWorkforce/flows/issues/132"} + +$ gh pr view 134 --json number,state,headRefOid,baseRefOid,mergeable,mergeStateStatus,url +{"baseRefOid":"a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2","headRefOid":"5092b76decca1530aaf6be0f81897945f9143ce5","mergeStateStatus":"CLEAN","mergeable":"MERGEABLE","number":134,"state":"OPEN","url":"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/AgentWorkforce/flows/pull/134"} +``` + +Remote artifact checks currently pass, but they do not resolve either blocking finding: + +```text +$ gh pr checks 140 +CodeRabbit pass 0 Review skipped: reviews are disabled for this base branch +linux-x64-artifact pass 3m1s https://github.com/AgentWorkforce/flows/actions/runs/33655869115/job/100334138356 +packed-consumer pass 19s https://github.com/AgentWorkforce/flows/actions/runs/33655869169/job/100334138444 +``` + +The intended delta has no whitespace errors: + +```text +$ git diff --check 7266c5134c01151c28c7cc412380b2c0ee6b3dfe HEAD +[no output; exit 0] +``` + +No product code, gate, commit, branch, PR, or remote state was modified by this review. Only this report is staged. + +REVIEW_FAILED diff --git a/ops/reviews/20260902-1845-pr140-maintainability.md b/ops/reviews/20260902-1845-pr140-maintainability.md new file mode 100644 index 000000000..8e8761481 --- /dev/null +++ b/ops/reviews/20260902-1845-pr140-maintainability.md @@ -0,0 +1,432 @@ +# PR #140 maintainability / adversarial-test review + +- Review time: 2026-09-02 18:45 CEST assignment, executed 2026-09-02 +- Submitted head: `2a98a3779e86df53fef6632d2adec59893b8f753` +- Submitted stacked base: PR #134 / `7266c5134c01151c28c7cc412380b2c0ee6b3dfe` +- Current remote base branch: `5092b76decca1530aaf6be0f81897945f9143ce5` +- Lens: maintainability, API/type honesty, fail-closed behavior, load-bearing tests +- Verdict: **FAIL** + +The happy path is real: a direct-input flow reaches a real `relayflowd`, its +deterministic step and run completion are journaled, and both carry +`completionReason: success`. Missing, malformed, empty, unreadable, and +non-regular input is refused before daemon contact; the packed surface contains +the generic input declarations; and the focused 55-test slice passes. + +The PR is nevertheless not mergeable. Its compile-by-executing-author-code +design permits unjournaled effects before the daemon is contacted and silently +chooses output-dependent branches that it claims to reject. Unknown header +fields are dropped before the compiler can reject them, and `f.done` is not +required. The feature tests do not cover these paths and are not invoked by +either exact-head GitHub check. Finally, the stacked base has advanced to a new +journal-backed executor and the submitted head now conflicts with that base; +the direct-input path must be reconciled with that executor, not mechanically +rebased as a parallel execution architecture. + +## Findings + +### F1 — P1 — authored code can make unjournaled effects before daemon contact + +`compileAuthoredFlow()` imports the module and then calls +`definition.body(recorder.context, input)` at +`sdk/src/authored-flow-compiler.ts:31-67`. Only calls made through the fake +`Ctx` are recorded. Ordinary JavaScript in the module or body is executed in +the CLI process before `executeCheckedFlow()` creates/connects the +`JournalClient` (`sdk/src/cli/run.ts:84-96`). That lets a valid-input invocation +mutate the filesystem even when no daemon exists and no run/journal is ever +created. It contradicts RFC-0001's journal boundary and effect attribution +contract. + +Probe source: + +```text +$ sed -n '1,120p' /tmp/pr140-maint-review.Hjcc9v/side-effect.flow.mjs +import { writeFileSync } from 'node:fs'; +import { flow } from 'file:///Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface/dist/index.js'; + +export default flow('side-effect-before-journal', {}, async (f, input) => { + writeFileSync(input.marker, 'UNJOURNALED_SIDE_EFFECT'); + await f.run('printf journaled-step'); + f.done('success'); +}); +``` + +Literal pre-daemon control: + +```text +$ review_tmp=/tmp/pr140-maint-review.Hjcc9v +$ marker_path=$review_tmp/marker-fresh.txt +$ if test -e "$marker_path"; then printf 'MARKER_BEFORE=yes\n'; else printf 'MARKER_BEFORE=no\n'; fi +$ node sdk/dist/cli.js run "$review_tmp/side-effect.flow.mjs" --input "{\"marker\":\"$marker_path\"}" --data-dir "$review_tmp/fresh-absent-daemon" +$ cli_status=$? +$ printf 'CLI_EXIT=%s\n' "$cli_status" +$ if test -f "$marker_path"; then printf 'MARKER_AFTER=yes\nMARKER_CONTENT='; sed -n '1p' "$marker_path"; else printf 'MARKER_AFTER=no\n'; fi +MARKER_BEFORE=no +WARNING [unprovable_effects] Step "run-1" command "printf" resolves, but its effects cannot be proven before execution. +REFUSED [daemon_unreachable] No compatible relayflowd is listening at "/tmp/pr140-maint-review.Hjcc9v/fresh-absent-daemon/relayflowd.sock". Start it with: relayflowd --data-dir "/tmp/pr140-maint-review.Hjcc9v/fresh-absent-daemon" serve +CLI_EXIT=2 +MARKER_AFTER=yes +MARKER_CONTENT=UNJOURNALED_SIDE_EFFECT +``` + +Required repair: direct input must run through the base's real +`executeAuthoredFlow`/journal-backed context (or an equivalent journal-owned +execution seam). Do not invoke an arbitrary authored body as a speculative +compiler pass. + +### F2 — P1 — output-dependent control flow fails open + +The implementation promises that output-dependent control flow is refused. +The placeholder proxy can trap property access and coercion, but JavaScript +truthiness and strict identity comparison are not proxy traps. A step result is +an object placeholder, so `if (output)` silently compiles the true branch even +though the real runtime value is not known. The resulting spec is semantically +different from the authored program. + +```text +$ sed -n '1,120p' /tmp/pr140-maint-review.Hjcc9v/truthy-output.flow.mjs +import { flow } from 'file:///Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface/dist/index.js'; + +export default flow('truthy-output', {}, async (f) => { + const output = await f.run('printf runtime-output'); + if (output) { + await f.run('printf true-branch'); + } else { + await f.run('printf false-branch'); + } + f.done('success'); +}); + +$ node --input-type=module -e "import { compileAuthoredFlow } from './sdk/dist/authored-flow-compiler.js'; const spec = await compileAuthoredFlow('/tmp/pr140-maint-review.Hjcc9v/truthy-output.flow.mjs', {}); console.log(JSON.stringify(spec.steps.map(({id, command, dependsOn}) => ({id, command, dependsOn})), null, 2));" +[ + { + "id": "run-1", + "command": "printf runtime-output", + "dependsOn": [] + }, + { + "id": "run-2", + "command": "printf true-branch", + "dependsOn": [ + "run-1" + ] + } +] +EXIT 0 +``` + +The existing negative test only interpolates the placeholder, which invokes +`Symbol.toPrimitive`; it does not cover truthiness or strict comparison. +Because this failure cannot be fixed completely with a proxy, the speculative +recording architecture should be removed rather than extended with more traps. + +### F3 — P1 — the submitted stack is conflicting and targets the superseded execution seam + +PR #134 advanced after this head was cut. The current base adds +`sdk/src/authored-flow-executor.ts` and `executeAuthoredFlow`; PR #140 instead +adds a sibling `authored-flow-compiler.ts` and routes the CLI through it. There +are content conflicts in the public surface and tests, and GitHub reports the +PR as `DIRTY` / `CONFLICTING`. This is architectural reconciliation work, not a +safe conflict-only rebase. + +```text +$ git rev-parse HEAD origin/feat/v2-direct-input origin/feat/v2-surface-package +$ git merge-base HEAD origin/feat/v2-surface-package +$ git merge-tree --write-tree HEAD origin/feat/v2-surface-package +2a98a3779e86df53fef6632d2adec59893b8f753 +2a98a3779e86df53fef6632d2adec59893b8f753 +5092b76decca1530aaf6be0f81897945f9143ce5 +7266c5134c01151c28c7cc412380b2c0ee6b3dfe +7420dbffec3bc3eb0e81013128ad6c771c9d13c5 +100644 cf3f5cb0e4d243eab7c37b71af373e4f80205ea5 1 sdk/tests/authored-flow.test.ts +100644 ef20b6845aaacca7f02b479e8d2c5994204b0da7 2 sdk/tests/authored-flow.test.ts +100644 b197ebed08df4535ced7311fcf2828d9675b2889 3 sdk/tests/authored-flow.test.ts +100644 a3018f92d750d8ff4e4e19c20fb97de21a091184 1 surface/README.md +100644 be5701b0e84623c572a79fd604bcff13be5142c0 2 surface/README.md +100644 f56bda555709cb48c3ef3db0691e0c6a35c01cb1 3 surface/README.md +100644 560117268eff548a471ef3d2c6d034ecab630f8a 1 surface/src/flow.ts +100644 4b199341d2479e62b1c277df8f3b90a14fe07389 2 surface/src/flow.ts +100644 d809e87e8b816799e6ef2fae26475283093f507e 3 surface/src/flow.ts + +Auto-merging docs/SURFACE.md +Auto-merging sdk/tests/authored-flow.test.ts +CONFLICT (content): Merge conflict in sdk/tests/authored-flow.test.ts +Auto-merging surface/README.md +CONFLICT (content): Merge conflict in surface/README.md +Auto-merging surface/src/flow.ts +CONFLICT (content): Merge conflict in surface/src/flow.ts +Auto-merging surface/tests/flow.test.ts +EXIT 1 +``` + +```text +$ git ls-tree -r --name-only HEAD sdk/src | rg 'authored-flow-(compiler|executor)\.ts' +sdk/src/authored-flow-compiler.ts +$ git ls-tree -r --name-only origin/feat/v2-surface-package sdk/src | rg 'authored-flow-(compiler|executor)\.ts' +sdk/src/authored-flow-executor.ts +$ git grep -n 'compileAuthoredFlow\|executeAuthoredFlow' HEAD -- sdk/src +HEAD:sdk/src/authored-flow-compiler.ts:23:export async function compileAuthoredFlow(path: string, input: unknown): Promise { +HEAD:sdk/src/cli/run.ts:2:import { compileAuthoredFlow, AuthoredFlowCompileError } from '../authored-flow-compiler.js'; +HEAD:sdk/src/cli/run.ts:89: const flow = await compileAuthoredFlow(path, input); +$ git grep -n 'compileAuthoredFlow\|executeAuthoredFlow' origin/feat/v2-surface-package -- sdk/src +origin/feat/v2-surface-package:sdk/src/authored-flow-executor.ts:75:export async function executeAuthoredFlow( +origin/feat/v2-surface-package:sdk/src/authored-flow.ts:11: * flow. `executeAuthoredFlow` owns the initial journal-backed context; callers +origin/feat/v2-surface-package:sdk/src/index.ts:58: executeAuthoredFlow, +``` + +### F4 — P2 — fail-closed header and completion claims are not true + +The surface's `freezeHeader()` reconstructs only known fields. Consequently a +JavaScript/`as any` typo such as `identitty` is removed before +`compileAuthoredFlow()` calls `Object.keys(definition.header)`, so direct run +continues to daemon connection instead of returning the promised unsupported +header refusal. + +```text +$ sed -n '1,120p' /tmp/pr140-maint-review.Hjcc9v/unknown-header.flow.mjs +import { flow } from 'file:///Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface/dist/index.js'; + +export default flow('unknown-header', { identitty: 'typo' }, async (f) => { + await f.run('printf header-accepted'); + f.done('success'); +}); +$ node sdk/dist/cli.js run /tmp/pr140-maint-review.Hjcc9v/unknown-header.flow.mjs --input '{}' --data-dir /tmp/pr140-maint-review.Hjcc9v/header-absent-daemon +WARNING [unprovable_effects] Step "run-1" command "printf" resolves, but its effects cannot be proven before execution. +REFUSED [daemon_unreachable] No compatible relayflowd is listening at "/tmp/pr140-maint-review.Hjcc9v/header-absent-daemon/relayflowd.sock". Start it with: relayflowd --data-dir "/tmp/pr140-maint-review.Hjcc9v/header-absent-daemon" serve +CLI_EXIT=2 +``` + +The compiler also tracks whether `done()` was called only to reject later +steps; it never requires terminal completion. A flow with no `f.done(...)` +compiles successfully. The advanced base's executor explicitly has a +`missing_completion` failure, so this must be preserved when input is rebased. + +```text +$ sed -n '1,120p' /tmp/pr140-maint-review.Hjcc9v/no-done.flow.mjs +import { flow } from 'file:///Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface/dist/index.js'; + +export default flow('no-done', {}, async (f) => { + await f.run('printf no-done'); +}); +$ node --input-type=module -e "import { compileAuthoredFlow } from './sdk/dist/authored-flow-compiler.js'; const spec = await compileAuthoredFlow('/tmp/pr140-maint-review.Hjcc9v/no-done.flow.mjs', {}); console.log(JSON.stringify(spec));" +{"version":"0.1.0","name":"no-done","steps":[{"id":"run-1","type":"deterministic","dependsOn":[],"maxIterations":1,"command":"printf no-done","verification":{"type":"exit_code"}}]} +EXIT 0 +``` + +### F5 — P2 — feature tests are green but are not load-bearing in CI + +The focused tests pass, but neither exact-head workflow invokes +`authored-flow-compiler.test.ts` or `direct-input.test.ts`. The cloud artifact +workflow builds the CLI and smoke-checks a YAML flow; the packed-consumer gate +runs only `sdk/tests/authored-flow.test.ts`. Therefore both P1 behaviors above +can land behind green `linux-x64-artifact` and `packed-consumer` checks. + +```text +$ rg -n 'direct-input|authored-flow-compiler' .github/workflows scripts || true +$ rg -n 'vitest|tsc|typecheck' .github/workflows/cloud-runtime-artifact.yml .github/workflows/surface-package.yml scripts/surface-package-gate.sh +scripts/surface-package-gate.sh:13:bun run typecheck:regressions +scripts/surface-package-gate.sh:86:cat > tsconfig.consumer.json <<'JSON' +scripts/surface-package-gate.sh:101:"$repo_root/surface/node_modules/.bin/tsc" -p tsconfig.consumer.json +scripts/surface-package-gate.sh:109:surface/node_modules/.bin/vitest run sdk/tests/authored-flow.test.ts --root "$repo_root" +``` + +```text +$ ./node_modules/.bin/vitest run tests/authored-flow-compiler.test.ts tests/direct-input.test.ts tests/cli.test.ts + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk + + ✓ tests/authored-flow-compiler.test.ts (3 tests) 471ms + ✓ tests/cli.test.ts (50 tests) 3073ms + ✓ tests/direct-input.test.ts (2 tests) 4749ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 2827ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 1919ms + + Test Files 3 passed (3) + Tests 55 passed (55) + Start at 19:12:40 + Duration 6.76s (transform 1.77s, setup 0ms, collect 3.15s, tests 8.29s, environment 4ms, prepare 1.38s) + +EXIT 0 +``` + +The broad SDK suite is not green at this head. Its single failed suite is an +inherited base defect (`statSync` is used but not imported); PR #140 does not +touch that file, so it is disclosed rather than attributed to this diff. + +```text +$ RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 ./node_modules/.bin/vitest run --reporter=dot --maxWorkers=1 --minWorkers=1 + FAIL tests/live-kernel.test.ts [ tests/live-kernel.test.ts ] +ReferenceError: statSync is not defined + ❯ tests/live-kernel.test.ts:55:37 + 53| .map((entry) => join(TOOLCHAIN_TARGET, entry, 'debug', 'relayf… + 54| .filter((candidate) => existsSync(candidate)) + 55| .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs) + | ^ + + Test Files 1 failed | 19 passed (20) + Tests 226 passed (226) + Start at 19:16:53 + Duration 199.33s (transform 7.89s, setup 0ms, collect 15.04s, tests 73.66s, environment 90ms, prepare 26.94s) +EXIT 1 + +$ git diff --quiet 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..2a98a3779e86df53fef6632d2adec59893b8f753 -- sdk/tests/live-kernel.test.ts; printf 'PR_DIFF_TOUCHES_LIVE_KERNEL=%s\n' "$?" +PR_DIFF_TOUCHES_LIVE_KERNEL=0 +``` + +### F6 — P2 — file input is synchronously and unboundedly materialized + +`parseDirectInput()` uses `readFileSync(..., 'utf8')` followed by `JSON.parse` +with no size gate. A 33,554,443-byte file was accepted and fully parsed before +daemon refusal. This is acceptable for the happy-path proof but is an +unbounded memory/latency surface for a CLI intended to fail closed. Add and test +an explicit supported maximum, preferably before allocating the string. + +```text +$ node -e "require('node:fs').writeFileSync(process.argv[1], JSON.stringify({blob:'x'.repeat(32*1024*1024)}))" /tmp/pr140-maint-review.Hjcc9v/large.json +$ wc -c /tmp/pr140-maint-review.Hjcc9v/large.json +$ /usr/bin/time -p node sdk/dist/cli.js run /tmp/pr140-maint-review.Hjcc9v/simple.flow.mjs --input /tmp/pr140-maint-review.Hjcc9v/large.json --data-dir /tmp/pr140-maint-review.Hjcc9v/large-no-daemon + 33554443 /tmp/pr140-maint-review.Hjcc9v/large.json +WARNING [unprovable_effects] Step "run-1" command "printf" resolves, but its effects cannot be proven before execution. +REFUSED [daemon_unreachable] No compatible relayflowd is listening at "/tmp/pr140-maint-review.Hjcc9v/large-no-daemon/relayflowd.sock". Start it with: relayflowd --data-dir "/tmp/pr140-maint-review.Hjcc9v/large-no-daemon" serve +real 0.77 +user 0.27 +sys 0.12 +CLI_EXIT=2 +``` + +## Passing evidence + +### Real `relayflowd`, journal entries, and completion reason — PASS + +```text +$ /Users/khaliqgant/.relayflows-toolchain/target/gate-contract/debug/relayflowd --data-dir /tmp/pr140-maint-review.Hjcc9v/live-data serve & +$ node sdk/dist/cli.js run sdk/tests/fixtures/direct-input.flow.ts --input '{"output":"/tmp/pr140-maint-review.Hjcc9v/live-output.txt","value":"real daemon value"}' --data-dir /tmp/pr140-maint-review.Hjcc9v/live-data +SOCKET_READY=yes +WARNING [unprovable_effects] Step "run-1" command "printf" resolves, but its effects cannot be proven before execution. +RUN 01M1HHTYH90YARKEC9FZ9X1FPK completed (1 steps) completionReason: success +CLI_EXIT=0 +OUTPUT_FILE=real daemon value +DAEMON_EXIT=143 +DATA_FILES +/tmp/pr140-maint-review.Hjcc9v/live-data/relayflowd.sqlite3 +/tmp/pr140-maint-review.Hjcc9v/live-data/runs/01M1HHTYH90YARKEC9FZ9X1FPK.sqlite3 +``` + +```text +$ sqlite3 -header -column /tmp/pr140-maint-review.Hjcc9v/live-data/runs/01M1HHTYH90YARKEC9FZ9X1FPK.sqlite3 'select * from entries order by rowid;' +seq segment_id entry_type step_id attempt at_ms payload +1 1 run.spawned 1788369271337 {"created_by":"protocol-v0","journal_version":1,"parent_run_id":null,"spec":{"name":"direct-input-fixture","steps":[{"command":"printf %s 'real daemon value' > '/tmp/pr140-maint-review.Hjcc9v/live-output.txt'","depends_on":[],"id":"run-1","max_iterations":1,"retry":{"initial_backoff_ms":100,"jitter_percent":20,"max_backoff_ms":60000,"multiplier":2},"type":"deterministic","verification":{}}],"version":"0.1.0"},"spec_hash":"2af31da0ab3c35dad1774f881e98ac703b387313f62038136bdbbcfcca9fe508"} +2 1 step.attempt.started run-1 1 1788369271356 {"executor":"kernel","idempotency_key":"74c5f3c0a55048ab33a5a41f66edff274d5ff1d8911d32d738650240e800d320","lease_deadline_ms":1788369301356,"lease_id":"01M1HHTYHWMT3ZHXGP6AGD87TD","max_iterations":1,"pins":{"streams":[],"workspace":[]},"recovery_mode":null,"step_type":"deterministic"} +3 1 step.completed run-1 1 1788369271372 {"budget":{"dollars":"0","tokens_in":0,"tokens_out":0},"completed_by":"kernel","completionReason":"success","disposition":"step_done","effects":[],"end_pins":null,"next_attempt_at_ms":null,"output":{"exit_code":0,"stderr_tail":"","stdout_tail":""},"verification":{"detail":"all gates passed","gate":"exit_code","verdict":"pass"}} +4 1 run.completed 1788369271375 {"budget_total":{"dollars":"0","tokens_in":0,"tokens_out":0},"completionReason":"success","failed_step_id":null} +``` + +### Missing/malformed/file edge cases before daemon contact — PASS + +```text +$ for input_case in MISSING_ARG MALFORMED_INLINE INVALID_FILE DIRECTORY EMPTY_FILE MISSING_FILE; do ...; done + +CASE=MISSING_ARG +REFUSED [input_missing] A directly run .flow.ts requires --input . +EXIT=2 + +CASE=MALFORMED_INLINE +REFUSED [input_invalid] Inline input is not valid JSON. +EXIT=2 + +CASE=INVALID_FILE +REFUSED [input_invalid] Input file "/tmp/pr140-maint-review.Hjcc9v/invalid.json" is not valid JSON. +EXIT=2 + +CASE=DIRECTORY +REFUSED [input_unreadable] Input file "/tmp/pr140-maint-review.Hjcc9v" is not a regular file. +EXIT=2 + +CASE=EMPTY_FILE +REFUSED [input_invalid] Input file "/tmp/pr140-maint-review.Hjcc9v/empty.json" is not valid JSON. +EXIT=2 + +CASE=MISSING_FILE +REFUSED [input_invalid] Inline input is not valid JSON. +EXIT=2 +``` + +The missing-file result follows the documented existing-file-first rule: a +nonexistent path-shaped argument is treated as malformed inline JSON. + +```text +$ chmod 000 /tmp/pr140-maint-review.Hjcc9v/unreadable.json +$ node sdk/dist/cli.js run /tmp/pr140-maint-review.Hjcc9v/simple.flow.mjs --input /tmp/pr140-maint-review.Hjcc9v/unreadable.json --data-dir /tmp/pr140-maint-review.Hjcc9v/no-daemon +REFUSED [input_unreadable] Input file "/tmp/pr140-maint-review.Hjcc9v/unreadable.json" is not readable. +CLI_EXIT=2 +``` + +### v1/YAML non-regression — PASS for the exercised boundary + +```text +$ node sdk/dist/cli.js check --json testdata/hello-deterministic.flow.yaml +WARNING [unprovable_effects] Step "greet" command "echo" resolves, but its effects cannot be proven before execution. +WARNING [unprovable_effects] Step "shout" command "echo" resolves, but its effects cannot be proven before execution. +{"ok":true,"path":"testdata/hello-deterministic.flow.yaml","projectConfigPath":"/Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/testdata/flows.json","resolutions":[],"diagnostics":[{"severity":"warning","kind":"unprovable_effects","stepId":"greet","message":"Step \"greet\" command \"echo\" resolves, but its effects cannot be proven before execution."},{"severity":"warning","kind":"unprovable_effects","stepId":"shout","message":"Step \"shout\" command \"echo\" resolves, but its effects cannot be proven before execution."}]} +CHECK_EXIT=0 +$ node sdk/dist/cli.js run testdata/hello-deterministic.flow.yaml --input '{}' +REFUSED [invalid_invocation] Usage: flows check [--json] flows run [--json] [--data-dir ] flows run [--json] [--data-dir ] --input flows resume [--json] [--data-dir ] flows hn-monitor start [--data-dir ] [--poll-interval-ms ] +YAML_INPUT_EXIT=2 +``` + +### Typecheck and packed public type boundary — PASS + +```text +$ cd sdk && ./node_modules/.bin/tsc --noEmit +EXIT 0 +$ cd surface && ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/vitest run tests/flow.test.ts + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface + + ✓ tests/flow.test.ts (5 tests) 25ms + + Test Files 1 passed (1) + Tests 5 passed (5) + Start at 19:12:32 + Duration 2.23s (transform 161ms, setup 0ms, collect 140ms, tests 25ms, environment 0ms, prepare 670ms) +EXIT 0 +``` + +```text +$ tar -tzf /tmp/pr140-maint-review.Hjcc9v/pack/relayflows-surface-0.1.0.tgz | rg '^package/dist/(index|flow)\.(d\.ts|js)$' +$ tar -xOf /tmp/pr140-maint-review.Hjcc9v/pack/relayflows-surface-0.1.0.tgz package/dist/flow.d.ts | rg 'FlowBody|AuthoredFlowDefinition|declare function flow' +package/dist/flow.d.ts +package/dist/flow.js +package/dist/index.d.ts +package/dist/index.js +export type FlowBody = (f: Ctx, input: Input) => Promise; +export interface AuthoredFlowDefinition { + readonly body: FlowBody; +export declare function flow(name: string, body: FlowBody): FlowHandle; +export declare function flow(name: string, header: FlowHeader, body: FlowBody): FlowHandle; +export declare function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition; +``` + +### Exact current GitHub state — checks PASS, mergeability FAIL + +```text +$ gh pr view 140 --json number,title,state,isDraft,headRefName,headRefOid,baseRefName,baseRefOid,mergeStateStatus,mergeable,url,statusCheckRollup --jq '{number,title,state,isDraft,headRefName,headRefOid,baseRefName,baseRefOid,mergeStateStatus,mergeable,url,checks:[.statusCheckRollup[]|{name:(.name // .context),status,conclusion,detailsUrl:(.detailsUrl // .targetUrl)}]}' +{"baseRefName":"feat/v2-surface-package","baseRefOid":"7266c5134c01151c28c7cc412380b2c0ee6b3dfe","checks":[{"conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33655869115/job/100334138356","name":"linux-x64-artifact","status":"COMPLETED"},{"conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33655869169/job/100334138444","name":"packed-consumer","status":"COMPLETED"},{"conclusion":null,"detailsUrl":"","name":"CodeRabbit","status":null}],"headRefName":"feat/v2-direct-input","headRefOid":"2a98a3779e86df53fef6632d2adec59893b8f753","isDraft":false,"mergeStateStatus":"DIRTY","mergeable":"CONFLICTING","number":140,"state":"OPEN","title":"feat(cli): run authored flows with direct input","url":"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/AgentWorkforce/flows/pull/140"} + +$ gh pr checks 140 +CodeRabbit pass 0 Review skipped: reviews are disabled for this base branch +linux-x64-artifact pass 3m1s https://github.com/AgentWorkforce/flows/actions/runs/33655869115/job/100334138356 +packed-consumer pass 19s https://github.com/AgentWorkforce/flows/actions/runs/33655869169/job/100334138444 +EXIT 0 +``` + +## Required next gate + +Rebase/rebuild the direct-input slice on exact current PR #134 head, pass input +into its single journal-backed executor, preserve `missing_completion`, reject +unknown headers before normalization, add bounded file input, and add +adversarial tests for pre-journal side effects plus truthy/strict output +branches. Those tests must be invoked by an exact-head GitHub workflow. Then +rerun the real daemon/journal proof and all SDK tests from the new exact head. + +REVIEW_FAILED diff --git a/ops/reviews/20260902-1845-pr140-structure.md b/ops/reviews/20260902-1845-pr140-structure.md new file mode 100644 index 000000000..22db15638 --- /dev/null +++ b/ops/reviews/20260902-1845-pr140-structure.md @@ -0,0 +1,382 @@ +# PR #140 independent structure / RFC-boundary review + +- Review time: 2026-09-02 (Europe/Oslo) +- Reviewed head: `2a98a3779e86df53fef6632d2adec59893b8f753` +- Recorded stacked base: `feat/v2-surface-package` at `7266c5134c01151c28c7cc412380b2c0ee6b3dfe` +- Live PR #134 head inspected for stack interaction: `5092b76decca1530aaf6be0f81897945f9143ce5` +- Lens: input decoding/compiler/executor seams, journal boundary, fail-closed behavior, module boundaries/duplication, package/CI honesty +- Verdict: **FAIL** + +## Blocking findings + +### F1 — P1: output-dependent JavaScript is silently compiled to the wrong branch + +`sdk/src/authored-flow-compiler.ts:196-205` represents a runtime step result with an object `Proxy`. Property access and coercion can be trapped, but JavaScript truthiness cannot. An authored flow that branches on an awaited command result therefore does not fail closed: the proxy is always truthy and the compiler journals the true branch regardless of the real output. + +The probe's first command prints an empty string, so a real execution should select `RIGHT_FALSE_BRANCH`. The compiler instead emits `WRONG_TRUE_BRANCH` and exits successfully: + +```text +COMMAND: +node --input-type=module -e "import { compileAuthoredFlow } from './sdk/dist/authored-flow-compiler.js'; const flow = await compileAuthoredFlow('/tmp/pr140-truthiness.flow.mjs', {}); console.log(JSON.stringify(flow.steps, null, 2));" + +OUTPUT: +[ + { + "id": "run-1", + "type": "deterministic", + "dependsOn": [], + "maxIterations": 1, + "command": "printf \"\"", + "verification": { + "type": "exit_code" + } + }, + { + "id": "run-2", + "type": "deterministic", + "dependsOn": [ + "run-1" + ], + "maxIterations": 1, + "command": "printf WRONG_TRUE_BRANCH", + "verification": { + "type": "exit_code" + } + } +] +EXIT=0 +``` + +This contradicts the PR's fail-closed claim for output-dependent authored control flow and can execute unintended effects. Strict equality, `typeof`, and other non-trappable observations have the same structural problem. A recording proxy cannot safely discover arbitrary JavaScript control flow. + +### F2 — P1: separately awaited fan-out loses a required dependency + +`RecordingContext.queueFlush()` replaces the whole dependency frontier with only the most recently settled microtask batch (`sdk/src/authored-flow-compiler.ts:174-183`). For two siblings created together but awaited separately, both correctly depend on the seed, but awaiting `left` and then `right` leaves only `right` in the frontier. The subsequent joined step can be scheduled before `left` finishes, despite the source having awaited both. + +```text +COMMAND: +node --input-type=module -e "import { compileAuthoredFlow } from './sdk/dist/authored-flow-compiler.js'; const flow = await compileAuthoredFlow('/tmp/pr140-topology.flow.mjs', {}); console.log(JSON.stringify(flow.steps, null, 2));" + +OUTPUT: +[ + { + "id": "run-1", + "type": "deterministic", + "dependsOn": [], + "maxIterations": 1, + "command": "printf seed", + "verification": { + "type": "exit_code" + } + }, + { + "id": "run-2", + "type": "deterministic", + "dependsOn": [ + "run-1" + ], + "maxIterations": 1, + "command": "printf left", + "verification": { + "type": "exit_code" + } + }, + { + "id": "run-3", + "type": "deterministic", + "dependsOn": [ + "run-1" + ], + "maxIterations": 1, + "command": "printf right", + "verification": { + "type": "exit_code" + } + }, + { + "id": "run-4", + "type": "deterministic", + "dependsOn": [ + "run-3" + ], + "maxIterations": 1, + "command": "printf joined", + "verification": { + "type": "exit_code" + } + } +] +EXIT=0 +``` + +Issue #132 explicitly treats `await` as a dependency and requires real kernel fan-out. The single committed topology test covers `Promise.all([left, right])`, not this equally valid authored form. + +### F3 — P1: authored body effects run before any journal connection + +`runDirectFlow()` calls `compileAuthoredFlow()` before `executeCheckedFlow()` constructs and connects `JournalClient` (`sdk/src/cli/run.ts:81-124`). Compilation directly invokes `definition.body(recorder.context, input)` (`sdk/src/authored-flow-compiler.ts:55-68`). Consequently, arbitrary module/body JavaScript can perform unjournaled external effects even when no daemon exists and the CLI ultimately refuses the run. + +This probe writes a marker in the authored body. The journal connection is refused, but the marker already exists: + +```text +COMMAND: +test ! -e /tmp/pr140-prejournal-marker && node sdk/dist/cli.js run /tmp/pr140-side-effect.flow.mjs --input '{"marker":"/tmp/pr140-prejournal-marker"}' --data-dir /tmp/pr140-no-daemon; cli_status=$?; printf 'CLI_EXIT=%s\n' "$cli_status"; if test -f /tmp/pr140-prejournal-marker; then printf 'MARKER='; command cat /tmp/pr140-prejournal-marker; printf '\n'; else printf 'MARKER=absent\n'; fi + +OUTPUT: +WARNING [command_unresolved] Step "run-1" command ":" does not resolve as an executable; it runs only if the shell supplies it. +REFUSED [daemon_unreachable] No compatible relayflowd is listening at "/tmp/pr140-no-daemon/relayflowd.sock". Start it with: relayflowd --data-dir "/tmp/pr140-no-daemon" serve +CLI_EXIT=2 +MARKER=effect-before-journal +SHELL_EXIT=0 +``` + +That reaches around the RFC's journal boundary. Import-time effects have the same issue. Fail-closed error wrapping does not undo an effect already performed. + +### F4 — P1: the stack is stale, conflicting, and now has two incompatible authored-flow engines + +PR #140 is still based on the old PR #134 head `7266c5...`. Live PR #134 is `5092b7...` and added `sdk/src/authored-flow-executor.ts` (276 lines). That executor runs the authored closure with real step outputs and submits each supported step through `JournalClient`; PR #140 independently adds a 236-line recording compiler that executes the closure with placeholders and submits one compiled topology. Header checks, unsupported verbs, step IDs, completion lowering, and authored-body execution now have two owners with materially different semantics. + +The branches overlap in five files and merge with content conflicts: + +```text +COMMAND: +printf 'OVERLAPPING_PATHS\n'; comm -12 <(git diff --name-only 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..5092b76decca1530aaf6be0f81897945f9143ce5 | sort) <(git diff --name-only 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..2a98a3779e86df53fef6632d2adec59893b8f753 | sort); printf 'LIVE_134_EXECUTOR\n'; git diff --stat 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..5092b76decca1530aaf6be0f81897945f9143ce5 -- sdk/src/authored-flow-executor.ts; printf 'MERGE_TREE_CONFLICTS\n'; git merge-tree 7266c5134c01151c28c7cc412380b2c0ee6b3dfe 5092b76decca1530aaf6be0f81897945f9143ce5 2a98a3779e86df53fef6632d2adec59893b8f753 | rg -n 'changed in both|^\+<<<<<<<|^\+=======$|^\+>>>>>>>' + +OUTPUT: +OVERLAPPING_PATHS +docs/SURFACE.md +sdk/tests/authored-flow.test.ts +surface/README.md +surface/src/flow.ts +surface/tests/flow.test.ts +LIVE_134_EXECUTOR + sdk/src/authored-flow-executor.ts | 276 ++++++++++++++++++++++++++++++++++++++ + 1 file changed, 276 insertions(+) +MERGE_TREE_CONFLICTS +1:changed in both +560:changed in both +568:+<<<<<<< .our +573:+======= +578:+>>>>>>> .their +809:changed in both +832:+<<<<<<< .our +835:+======= +838:+>>>>>>> .their +854:changed in both +933:+<<<<<<< .our +941:+======= +944:+>>>>>>> .their +948:changed in both +EXIT=0 +``` + +GitHub independently reports `mergeable: CONFLICTING` and `mergeStateStatus: DIRTY` below. Direct input needs to be rebased onto the live surface repair and integrated behind one authored execution/lowering owner before its package and CI result describes a mergeable system. + +## Boundary assessment + +- **Closed kernel/journal vocabulary: PASS in the reviewed delta.** The new compiler emits only existing `deterministic`, `llm`, and `agent` step specs, funnels them through `toKernelSpec`, and starts the run through `JournalClient`. It does not add kernel entry types. +- **Real surface → SDK/journal bridge: partial PASS, overall FAIL.** The happy path is real and the live-daemon direct-input test passes, but F1-F3 show that the authored JavaScript semantics and effects are not safely bounded by that bridge. +- **Module size: PASS by the repository's 500-line smell threshold.** The new modules are 236 and 52 lines; modified `sdk/src/cli/run.ts` is 432 lines. The problem is duplicated responsibility against live PR #134, not a newly oversized file. +- **Package boundary: PASS at the exact reviewed head.** Dry-run packs include the built surface entry points and the SDK CLI/compiler/input decoder. +- **CI boundary: FAIL for merge readiness.** All checks attached to exact PR #140 head are green, including `packed-consumer`, but they ran against recorded base `7266c5...`; the live base has advanced to `5092b7...`, conflicts, and carries a competing executor. The green checks do not validate the current stacked composition. +- **v1/non-direct CLI path: no regression observed in targeted coverage.** The existing YAML/JSON CLI test suite remained green. This does not mitigate the direct-flow blockers. + +## Captured verification + +### Exact head, base, live stack, and GitHub checks + +```text +COMMAND: +gh pr view 140 --repo AgentWorkforce/flows --json number,state,headRefName,headRefOid,baseRefName,baseRefOid,mergeable,mergeStateStatus,statusCheckRollup,commits + +OUTPUT: +{"baseRefName":"feat/v2-surface-package","baseRefOid":"7266c5134c01151c28c7cc412380b2c0ee6b3dfe","commits":[{"authoredDate":"2026-09-02T16:34:30Z","authors":[{"email":"kjgbot@agentrelay.dev","id":"","login":"","name":"kjgbot"}],"committedDate":"2026-09-02T16:34:30Z","messageBody":"Session-Id: 01a062de-3f73-7621-8286-72efe45639aa","messageHeadline":"feat(cli): run authored flows with direct input","oid":"2a98a3779e86df53fef6632d2adec59893b8f753"}],"headRefName":"feat/v2-direct-input","headRefOid":"2a98a3779e86df53fef6632d2adec59893b8f753","mergeStateStatus":"DIRTY","mergeable":"CONFLICTING","number":140,"state":"OPEN","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-09-02T16:38:43Z","conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33655869115/job/100334138356","name":"linux-x64-artifact","startedAt":"2026-09-02T16:35:42Z","status":"COMPLETED","workflowName":"Relayflow v2 Cloud runtime artifact"},{"__typename":"CheckRun","completedAt":"2026-09-02T16:36:02Z","conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33655869169/job/100334138444","name":"packed-consumer","startedAt":"2026-09-02T16:35:43Z","status":"COMPLETED","workflowName":"Relayflow v2 surface package"},{"__typename":"StatusContext","context":"CodeRabbit","startedAt":"2026-09-02T16:35:43Z","state":"SUCCESS","targetUrl":""}]} +EXIT=0 +``` + +```text +COMMAND: +gh pr view 134 --repo AgentWorkforce/flows --json number,state,headRefName,headRefOid,baseRefName,baseRefOid,mergeable,mergeStateStatus,statusCheckRollup + +OUTPUT: +{"baseRefName":"main","baseRefOid":"a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2","headRefName":"feat/v2-surface-package","headRefOid":"5092b76decca1530aaf6be0f81897945f9143ce5","mergeStateStatus":"CLEAN","mergeable":"MERGEABLE","number":134,"state":"OPEN","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-09-02T16:55:30Z","conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33657554961/job/100339781743","name":"linux-x64-artifact","startedAt":"2026-09-02T16:52:15Z","status":"COMPLETED","workflowName":"Relayflow v2 Cloud runtime artifact"},{"__typename":"CheckRun","completedAt":"2026-09-02T16:52:33Z","conclusion":"SUCCESS","detailsUrl":"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/AgentWorkforce/flows/actions/runs/33657554960/job/100339781951","name":"packed-consumer","startedAt":"2026-09-02T16:52:14Z","status":"COMPLETED","workflowName":"Relayflow v2 surface package"},{"__typename":"StatusContext","context":"CodeRabbit","startedAt":"2026-09-02T16:52:19Z","state":"SUCCESS","targetUrl":""}]} +EXIT=0 +``` + +### Diff hygiene and module sizes + +```text +COMMAND: +git diff --check 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..2a98a3779e86df53fef6632d2adec59893b8f753; printf 'DIFF_CHECK_EXIT=%s\n' "$?"; git diff --stat 7266c5134c01151c28c7cc412380b2c0ee6b3dfe..2a98a3779e86df53fef6632d2adec59893b8f753 + +OUTPUT: +DIFF_CHECK_EXIT=0 + docs/SURFACE.md | 10 ++ + sdk/src/authored-flow-compiler.ts | 236 ++++++++++++++++++++++++++++ + sdk/src/cli.ts | 30 +++- + sdk/src/cli/check.ts | 13 ++ + sdk/src/cli/run.ts | 44 +++++- + sdk/src/direct-input.ts | 52 ++++++ + sdk/src/failure-kinds.ts | 2 + + sdk/tests/authored-flow-compiler.test.ts | 39 +++++ + sdk/tests/authored-flow.test.ts | 2 +- + sdk/tests/cli.test.ts | 10 ++ + sdk/tests/direct-input.test.ts | 156 ++++++++++++++++++ + sdk/tests/fixtures/direct-input.flow.ts | 15 ++ + sdk/tests/fixtures/direct-topology.flow.ts | 10 ++ + sdk/tests/fixtures/output-dependent.flow.ts | 6 + + surface/README.md | 25 ++- + surface/src/flow.ts | 30 ++-- + surface/tests/flow.test.ts | 10 +- + 17 files changed, 662 insertions(+), 28 deletions(-) +SHELL_EXIT=0 +``` + +```text +COMMAND: +wc -l sdk/src/authored-flow-compiler.ts sdk/src/direct-input.ts sdk/src/cli/run.ts sdk/src/cli/check.ts sdk/src/cli.ts surface/src/flow.ts + +OUTPUT: + 236 sdk/src/authored-flow-compiler.ts + 52 sdk/src/direct-input.ts + 432 sdk/src/cli/run.ts + 276 sdk/src/cli/check.ts + 250 sdk/src/cli.ts + 115 surface/src/flow.ts + 1361 total +EXIT=0 +``` + +### Builds and committed tests + +```text +COMMAND: +bun run build + +WORKDIR: +surface + +OUTPUT: +$ tsc +EXIT=0 +``` + +```text +COMMAND: +./node_modules/.bin/tsc && node scripts/make-cli-executable.mjs + +WORKDIR: +sdk + +OUTPUT: +EXIT=0 +``` + +```text +COMMAND: +./node_modules/.bin/vitest run tests/flow.test.ts + +WORKDIR: +surface + +OUTPUT: + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/surface + + ✓ tests/flow.test.ts (5 tests) 489ms + + Test Files 1 passed (1) + Tests 5 passed (5) + Start at 19:17:32 + Duration 10.82s (transform 2.16s, setup 0ms, collect 1.83s, tests 489ms, environment 0ms, prepare 1.68s) + +EXIT=0 +``` + +```text +COMMAND: +./node_modules/.bin/vitest run tests/authored-flow-compiler.test.ts tests/cli.test.ts --reporter=dot --maxWorkers=1 --minWorkers=1 + +WORKDIR: +sdk + +OUTPUT: + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk + + ✓ tests/cli.test.ts (50 tests) 2597ms + ✓ tests/authored-flow-compiler.test.ts (3 tests) 251ms + + Test Files 2 passed (2) + Tests 53 passed (53) + Start at 19:26:14 + Duration 5.65s (transform 988ms, setup 0ms, collect 1.30s, tests 2.85s, environment 1ms, prepare 463ms) + +EXIT=0 +``` + +```text +COMMAND: +RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd ./node_modules/.bin/vitest run tests/direct-input.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1 + +WORKDIR: +sdk + +OUTPUT: + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-direct-input-wt/sdk + + ✓ tests/direct-input.test.ts > direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 7593ms + ✓ tests/direct-input.test.ts > direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 7495ms + + Test Files 1 passed (1) + Tests 2 passed (2) + Start at 19:19:25 + Duration 22.52s (transform 701ms, setup 0ms, collect 737ms, tests 15.12s, environment 1ms, prepare 2.06s) + +EXIT=0 +``` + +### Package dry runs + +```text +COMMAND: +bun pm pack --dry-run 2>&1 | rg 'packed .*(package.json|dist/authored-flow-compiler\.js|dist/direct-input\.js|dist/cli\.js)$|relayflows-sdk|Total files|Unpacked size' + +WORKDIR: +sdk + +OUTPUT: +packed 1.22KB package.json +packed 7.12KB dist/authored-flow-compiler.js +packed 7.98KB dist/cli.js +packed 1.74KB dist/direct-input.js +relayflows-sdk-0.1.0.tgz +Total files: 121 +Unpacked size: 0.53MB +EXIT=0 +``` + +```text +COMMAND: +bun pm pack --dry-run 2>&1 | rg 'packed .*(package.json|dist/index\.js|dist/runtime\.js|dist/flow\.js)$|relayflows-surface|Total files|Unpacked size' + +WORKDIR: +surface + +OUTPUT: +packed 0.80KB package.json +packed 2.35KB dist/flow.js +packed 68B dist/index.js +packed 83B dist/runtime.js +relayflows-surface-0.1.0.tgz +Total files: 32 +Unpacked size: 27.0KB +EXIT=0 +``` + +## Required remediation before signoff + +1. Rebase PR #140 onto live PR #134 and choose one authored-flow execution/lowering architecture rather than carrying both the live executor and the recording compiler. +2. Do not claim arbitrary output-dependent JavaScript fails closed unless every such observation is actually interceptable. Prefer real journal-derived values at execution time or constrain the authored language to a statically analyzable form. +3. Pin dependency semantics for separately awaited siblings and other valid promise shapes with adversarial tests, not only `Promise.all`. +4. Prevent module/body JavaScript from producing unjournaled effects during preflight/compilation, or explicitly constrain and enforce the executable authoring boundary. +5. Rerun the packed-consumer and runtime checks on the rebased, conflict-free composition. + +REVIEW_FAILED diff --git a/sdk/src/authored-flow-executor.ts b/sdk/src/authored-flow-executor.ts index c861d7534..b8582d5f7 100644 --- a/sdk/src/authored-flow-executor.ts +++ b/sdk/src/authored-flow-executor.ts @@ -79,11 +79,12 @@ type JournalStepUsesStepCompletionReason = Assert< * `JournalClient`; values are read back from `step.completed` journal entries. * Unsupported headers, verbs, gates, or completion lowering fail closed. */ -export async function executeAuthoredFlow( +export async function executeAuthoredFlow( handle: FlowHandle, journal: JournalClient, + input?: Input, ): Promise { - const definition = getAuthoredFlowDefinition(handle); + const definition = getAuthoredFlowDefinition(handle); const headerFields = Object.keys(definition.header); if (headerFields.length > 0) { throw new AuthoredFlowExecutionError( @@ -182,7 +183,7 @@ export async function executeAuthoredFlow( let bodyFailed = false; let bodyFailure: unknown; try { - const bodyPromise = lifecycle.runBody(() => definition.body(context)); + const bodyPromise = lifecycle.runBody(() => definition.body(context, input as Input)); await bodyPromise; } catch (error) { bodyFailed = true; diff --git a/sdk/src/authored-flow-loader.ts b/sdk/src/authored-flow-loader.ts new file mode 100644 index 000000000..41f08f3ad --- /dev/null +++ b/sdk/src/authored-flow-loader.ts @@ -0,0 +1,44 @@ +import { accessSync, constants } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { getAuthoredFlowDefinition, type FlowHandle } from './authored-flow.js'; + +export class AuthoredFlowLoadError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthoredFlowLoadError'; + } +} + +/** Import and validate a direct-run module without executing its authored body. */ +export async function loadAuthoredFlow(path: string): Promise { + const absolutePath = resolve(path); + try { + accessSync(absolutePath, constants.R_OK); + } catch { + throw new AuthoredFlowLoadError(`Flow "${path}" is not readable.`); + } + + let authoredModule: Record; + try { + authoredModule = await import(pathToFileURL(absolutePath).href) as Record; + } catch (error) { + throw new AuthoredFlowLoadError( + `Flow "${path}" could not be imported: ${errorMessage(error)}`, + ); + } + + const handle = authoredModule['default'] as FlowHandle; + try { + getAuthoredFlowDefinition(handle); + } catch (error) { + throw new AuthoredFlowLoadError( + `Flow "${path}" must default-export flow(...): ${errorMessage(error)}`, + ); + } + return handle; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'unknown authored-flow error'; +} diff --git a/sdk/src/authored-flow.ts b/sdk/src/authored-flow.ts index ecc36701e..c2f36e1ea 100644 --- a/sdk/src/authored-flow.ts +++ b/sdk/src/authored-flow.ts @@ -12,10 +12,10 @@ import { * progress has no durable root journal. Internal lowering tests recover the * definition here without making that seam a supported runner. */ -export function getAuthoredFlowDefinition( +export function getAuthoredFlowDefinition( handle: FlowHandle, -): AuthoredFlowDefinition { - return getFlowDefinition(handle); +): AuthoredFlowDefinition { + return getFlowDefinition(handle); } export type { AuthoredFlowDefinition, FlowHandle }; diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index 82f063e99..ef314dd11 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -13,6 +13,8 @@ import { type RunExecution, type RunReport, } from './cli/run.js'; +import { runDirectFlow } from './cli/direct-run.js'; +import { isAuthoredFlowPath } from './direct-input.js'; import { runHnMonitor } from './cli/hn-monitor.js'; import { runTickRunner } from './cli/tick-runner.js'; @@ -26,7 +28,8 @@ export interface CliIo { type CliExitCode = 0 | 1 | 2 | 3; type ParsedArgs = | { command: 'check'; json: boolean; value: string } - | { command: 'run' | 'resume'; dataDir: string; json: boolean; value: string } + | { command: 'run'; dataDir: string; input: string | undefined; json: boolean; value: string } + | { command: 'resume'; dataDir: string; json: boolean; value: string } | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined } | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string; intervalMs: number; epochMs: number | undefined; maxCatchUp: number | undefined; @@ -37,6 +40,7 @@ const USAGE = [ 'Usage:', 'flows check [--json] ', 'flows run [--json] [--data-dir ] ', + 'flows run [--json] [--data-dir ] --input ', 'flows tick start --schedule-id --interval-ms [--epoch-ms ] [--max-catch-up ] [--poll-interval-ms ] [--data-dir ] ', 'flows resume [--json] [--data-dir ] ', 'flows hn-monitor start [--data-dir ] [--poll-interval-ms ] ', @@ -107,7 +111,14 @@ export async function runCli( } const execution = parsed.command === 'run' - ? await runFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) }) + ? isAuthoredFlowPath(parsed.value) + ? await runDirectFlow( + parsed.value, + parsed.input, + parsed.dataDir, + { onWait: (progress) => emitWait(progress, io) }, + ) + : await runFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) }) : await resumeFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) }); emitRunReport(execution, parsed.json, io); return execution.exitCode; @@ -132,6 +143,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { let json = false; let dataDir = DEFAULT_DATA_DIR; let sawDataDir = false; + let input: string | undefined; + let sawInput = false; const positionals: string[] = []; for (let index = 1; index < args.length; index += 1) { const argument = args[index]!; @@ -148,14 +161,25 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined { index += 1; continue; } + if (argument === '--input') { + const value = args[index + 1]; + if (command !== 'run' || sawInput || value === undefined || value.startsWith('--')) return undefined; + input = value; + sawInput = true; + index += 1; + continue; + } if (argument.startsWith('-')) return undefined; positionals.push(argument); } if (positionals.length !== 1) return undefined; + if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined; return command === 'check' ? { command, json, value: positionals[0]! } - : { command, dataDir, json, value: positionals[0]! }; + : command === 'run' + ? { command, dataDir, input, json, value: positionals[0]! } + : { command, dataDir, json, value: positionals[0]! }; } function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined { diff --git a/sdk/src/cli/check.ts b/sdk/src/cli/check.ts index 27a0826d0..cb0b1e21b 100644 --- a/sdk/src/cli/check.ts +++ b/sdk/src/cli/check.ts @@ -63,7 +63,20 @@ class CheckFailure extends Error { export function checkFlow(path: string): CheckExecution { const absolutePath = resolve(path); try { - const authoring = readFlow(absolutePath); + const flow = readFlow(absolutePath); + return checkAuthoredFlow(flow, path); + } catch (error) { + const failure = error instanceof CheckFailure + ? error + : new CheckFailure('invalid_spec', `Flow "${path}" could not be checked as a Relayflow spec.`); + return { report: inputFailureReport(failure, path) }; + } +} + +/** Preflight a validated authored flow through the same path as YAML/JSON. */ +export function checkAuthoredFlow(authoring: FlowSpec, path: string): CheckExecution { + const absolutePath = resolve(path); + try { const config = readProjectConfig(dirname(absolutePath)); const probes = systemProbes(dirname(absolutePath), config); const result = preflight(authoring, { diff --git a/sdk/src/cli/direct-run.ts b/sdk/src/cli/direct-run.ts new file mode 100644 index 000000000..e349c7f95 --- /dev/null +++ b/sdk/src/cli/direct-run.ts @@ -0,0 +1,86 @@ +import { + AuthoredFlowExecutionError, + executeAuthoredFlow, +} from '../authored-flow-executor.js'; +import { AuthoredFlowLoadError, loadAuthoredFlow } from '../authored-flow-loader.js'; +import { DirectInputError, parseDirectInput } from '../direct-input.js'; +import { JournalClient } from '../journal-client.js'; +import { inputFailureReport } from './check.js'; +import { + connect, + emptyReport, + fromCheckReport, + protocolFailure, + socketFor, + type RunExecution, + type RunLifecycleOptions, + type RunReport, +} from './run.js'; + +export async function runDirectFlow( + path: string, + inputArgument: string | undefined, + dataDir: string, + _options: RunLifecycleOptions = {}, +): Promise { + let input: unknown; + try { + input = parseDirectInput(inputArgument); + } catch (error) { + const failure = error instanceof DirectInputError ? error : { + kind: 'input_invalid' as const, + message: 'Direct input could not be parsed.', + }; + return { + exitCode: 2, + report: fromCheckReport('run', inputFailureReport(failure, path)), + }; + } + + const socketPath = socketFor(dataDir); + const base: RunReport = { ...emptyReport('run'), path }; + const client = new JournalClient(socketPath); + const connected = await connect(client, 'run', dataDir, base); + if (connected !== undefined) return connected; + + try { + const handle = await loadAuthoredFlow(path); + const result = await executeAuthoredFlow(handle, client, input); + const terminal = result.journalSteps.at(-1); + if (terminal === undefined) { + return protocolFailure('run', base, socketPath, new Error( + `authored flow "${result.name}" completed without a journal step`, + )); + } + return { + exitCode: 0, + report: { + ...base, + ok: true, + runId: terminal.runId, + socketPath, + status: 'completed', + completionReason: result.completionReason, + completedSteps: result.journalSteps.length, + }, + }; + } catch (error) { + if (error instanceof AuthoredFlowLoadError + || (error instanceof AuthoredFlowExecutionError && error.code === 'unsupported_header')) { + return { + exitCode: 2, + report: { + ...fromCheckReport('run', inputFailureReport({ + kind: 'invalid_spec', + message: error.message, + }, path)), + socketPath, + }, + }; + } + const runId = error instanceof AuthoredFlowExecutionError ? error.runId : undefined; + return protocolFailure('run', base, socketPath, error, runId); + } finally { + client.close(); + } +} diff --git a/sdk/src/cli/run.ts b/sdk/src/cli/run.ts index 7b9aa48e0..a3fb1519a 100644 --- a/sdk/src/cli/run.ts +++ b/sdk/src/cli/run.ts @@ -71,13 +71,21 @@ export async function runFlow( return { exitCode: 2, report: fromCheckReport('run', checked.report) }; } + return executeCheckedFlow(checked, dataDir, options); +} + +async function executeCheckedFlow( + checked: ReturnType, + dataDir: string, + options: RunLifecycleOptions, +): Promise { const socketPath = socketFor(dataDir); const client = new JournalClient(socketPath); const connected = await connect(client, 'run', dataDir, checked.report); if (connected !== undefined) return connected; try { - const spec = toKernelSpec(checked.flow); + const spec = toKernelSpec(checked.flow!); const outcome = await client.runStart(spec); return await classifyOutcome(client, 'run', outcome, checked.report, socketPath, options); } catch (error) { @@ -123,7 +131,7 @@ export async function resumeFlow( } } -async function connect( +export async function connect( client: JournalClient, command: RunCommand, dataDir: string, @@ -318,7 +326,7 @@ async function waitForRunningStep( } } -function protocolFailure( +export function protocolFailure( command: RunCommand, base: CheckReport | RunReport, socketPath: string, @@ -340,7 +348,7 @@ function protocolFailure( }; } -function fromCheckReport(command: RunCommand, report: CheckReport): RunReport { +export function fromCheckReport(command: RunCommand, report: CheckReport): RunReport { return { ok: false, command, @@ -351,7 +359,7 @@ function fromCheckReport(command: RunCommand, report: CheckReport): RunReport { }; } -function emptyReport(command: RunCommand): RunReport { +export function emptyReport(command: RunCommand): RunReport { return { ok: false, command, resolutions: [], diagnostics: [] }; } @@ -359,7 +367,7 @@ function fromBase(command: RunCommand, base: CheckReport | RunReport): RunReport return 'command' in base ? base : fromCheckReport(command, base); } -function socketFor(dataDir: string): string { +export function socketFor(dataDir: string): string { return join(resolve(dataDir), 'relayflowd.sock'); } diff --git a/sdk/src/direct-input.ts b/sdk/src/direct-input.ts new file mode 100644 index 000000000..a5e367da5 --- /dev/null +++ b/sdk/src/direct-input.ts @@ -0,0 +1,69 @@ +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { CheckFailureKind } from './failure-kinds.js'; + +export const MAX_DIRECT_INPUT_BYTES = 1_048_576; + +export class DirectInputError extends Error { + constructor(readonly kind: CheckFailureKind, message: string) { + super(message); + this.name = 'DirectInputError'; + } +} + +/** Parse a direct-run input as an existing JSON file, otherwise as inline JSON. */ +export function parseDirectInput(argument: string | undefined): unknown { + if (argument === undefined) { + throw new DirectInputError( + 'input_missing', + 'A directly run .flow.ts requires --input .', + ); + } + + const inputPath = resolve(argument); + let source = argument; + let fromFile = false; + try { + const stat = statSync(inputPath); + if (!stat.isFile()) { + throw new DirectInputError('input_unreadable', `Input file "${argument}" is not a regular file.`); + } + if (stat.size > MAX_DIRECT_INPUT_BYTES) { + throw tooLarge(argument, true); + } + try { + source = readFileSync(inputPath, 'utf8'); + fromFile = true; + } catch { + throw new DirectInputError('input_unreadable', `Input file "${argument}" is not readable.`); + } + } catch (error) { + if (error instanceof DirectInputError) throw error; + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new DirectInputError('input_unreadable', `Input file "${argument}" could not be inspected.`); + } + } + + if (!fromFile && Buffer.byteLength(source, 'utf8') > MAX_DIRECT_INPUT_BYTES) { + throw tooLarge(argument, false); + } + + try { + return JSON.parse(source) as unknown; + } catch { + const sourceKind = fromFile ? `Input file "${argument}"` : 'Inline input'; + throw new DirectInputError('input_invalid', `${sourceKind} is not valid JSON.`); + } +} + +function tooLarge(argument: string, fromFile: boolean): DirectInputError { + const sourceKind = fromFile ? `Input file "${argument}"` : 'Inline input'; + return new DirectInputError( + 'input_too_large', + `${sourceKind} exceeds the ${MAX_DIRECT_INPUT_BYTES}-byte direct input limit.`, + ); +} + +export function isAuthoredFlowPath(path: string): boolean { + return /\.flow\.(?:ts|mts|js|mjs)$/.test(path); +} diff --git a/sdk/src/failure-kinds.ts b/sdk/src/failure-kinds.ts index c60f63789..896208afa 100644 --- a/sdk/src/failure-kinds.ts +++ b/sdk/src/failure-kinds.ts @@ -22,6 +22,9 @@ export const PREFLIGHT_FAILURE_KINDS = [ /** Input/command refusals emitted before the pure preflight predicates run. */ export const CHECK_INPUT_FAILURE_KINDS = [ 'config_invalid', + 'input_invalid', + 'input_missing', + 'input_too_large', 'input_unreadable', 'invalid_invocation', ...SHARED_SPEC_FAILURE_KINDS, diff --git a/sdk/tests/cli.test.ts b/sdk/tests/cli.test.ts index d8a2497f2..e16ddb5d0 100644 --- a/sdk/tests/cli.test.ts +++ b/sdk/tests/cli.test.ts @@ -23,6 +23,7 @@ import { } from './journal-client-loopback.js'; const TESTDATA = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'testdata'); +const DIRECT_INPUT_FLOW = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'direct-input.flow.ts'); const PREFLIGHT = join(TESTDATA, 'preflight'); const LADDER = ['hello-deterministic', 'hello-llm', 'hello-agent'] as const; const TEST_MODELS = [ @@ -632,8 +633,25 @@ steps: const invalidInvocation = capture(); expect(await runCli(['run'], invalidInvocation.io)).toBe(2); + const missingDirectInput = capture(); + expect(await runCli(['run', DIRECT_INPUT_FLOW], missingDirectInput.io)).toBe(2); + const invalidDirectInput = capture(); + expect(await runCli( + ['run', DIRECT_INPUT_FLOW, '--input', '{"broken":'], + invalidDirectInput.io, + )).toBe(2); + const oversizedInputPath = join(directory, 'oversized-input.json'); + writeFileSync(oversizedInputPath, JSON.stringify({ value: 'x'.repeat(1_048_576) })); + const oversizedDirectInput = capture(); + expect(await runCli( + ['run', DIRECT_INPUT_FLOW, '--input', oversizedInputPath], + oversizedDirectInput.io, + )).toBe(2); const outputs = [ invalidInvocation.stderr.join('\n'), + missingDirectInput.stderr.join('\n'), + invalidDirectInput.stderr.join('\n'), + oversizedDirectInput.stderr.join('\n'), (await run(join(directory, 'absent.flow.yaml'))).stderr.join('\n'), (await run(malformed)).stderr.join('\n'), ]; diff --git a/sdk/tests/direct-input.test.ts b/sdk/tests/direct-input.test.ts new file mode 100644 index 000000000..6e0f55118 --- /dev/null +++ b/sdk/tests/direct-input.test.ts @@ -0,0 +1,196 @@ +import { + accessSync, + constants, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const BUILT_CLI = join(ROOT, 'sdk', 'dist', 'cli.js'); +const FLOW = join(ROOT, 'sdk', 'tests', 'fixtures', 'direct-input.flow.ts'); +const CONTROL_FLOW = join(ROOT, 'sdk', 'tests', 'fixtures', 'direct-output-control.flow.ts'); +const SIDE_EFFECT_FLOW = join(ROOT, 'sdk', 'tests', 'fixtures', 'pre-journal-side-effect.flow.ts'); +const TOOLCHAIN_TARGET = process.env['CARGO_TARGET_DIR'] + ?? join(process.env['RELAYFLOWS_TOOLCHAIN_HOME'] ?? join(homedir(), '.relayflows-toolchain'), 'target'); +const RELAYFLOWD = resolve(process.env['RELAYFLOWD_BIN'] ?? locateRelayflowd()); +const temporaryDirectories: string[] = []; +const daemons: ChildProcess[] = []; + +beforeAll(() => { + requireExecutable(RELAYFLOWD, 'relayflowd'); + requireExecutable(BUILT_CLI, 'built flows CLI'); +}); + +afterEach(async () => { + for (const daemon of daemons.splice(0)) await stopDaemon(daemon); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('direct .flow.ts input through the built CLI and live runtime', () => { + it('executes inline and file JSON input through relayflowd', async () => { + const directory = temporaryDirectory(); + const dataDir = join(directory, 'data'); + await startDaemon(dataDir); + + const inlineOutput = join(directory, 'inline.txt'); + const inline = invokeCli([ + 'run', FLOW, '--input', JSON.stringify({ output: inlineOutput, value: 'inline value' }), + '--data-dir', dataDir, + ]); + expect(inline.status, inline.stderr).toBe(0); + expect(inline.stdout).toContain('completionReason: success'); + expect(readFileSync(inlineOutput, 'utf8')).toBe('inline value'); + + const fileOutput = join(directory, 'file.txt'); + const inputPath = join(directory, 'input.json'); + writeFileSync(inputPath, JSON.stringify({ output: fileOutput, value: 'file value' })); + const file = invokeCli(['run', FLOW, '--input', inputPath, '--data-dir', dataDir]); + expect(file.status, file.stderr).toBe(0); + expect(file.stdout).toContain('completionReason: success'); + expect(readFileSync(fileOutput, 'utf8')).toBe('file value'); + + const controlOutput = join(directory, 'control.txt'); + const control = invokeCli([ + 'run', CONTROL_FLOW, '--input', JSON.stringify({ output: controlOutput }), + '--data-dir', dataDir, + ]); + expect(control.status, control.stderr).toBe(0); + expect(control.stdout).toContain('completionReason: success'); + expect(readFileSync(controlOutput, 'utf8')).toBe( + 'truthy,negation,loose,strict,ternary,and,or', + ); + }); + + it('refuses missing and malformed input before contacting relayflowd', () => { + const directory = temporaryDirectory(); + const unreachableDataDir = join(directory, 'absent-daemon'); + + const missing = invokeCli(['run', FLOW, '--data-dir', unreachableDataDir]); + expect(missing.status, missing.stderr).toBe(2); + expect(missing.stderr).toContain('REFUSED [input_missing]'); + expect(missing.stderr).not.toContain('daemon_unreachable'); + + const malformed = invokeCli([ + 'run', FLOW, '--input', '{"broken":', '--data-dir', unreachableDataDir, + ]); + expect(malformed.status, malformed.stderr).toBe(2); + expect(malformed.stderr).toContain('REFUSED [input_invalid]'); + expect(malformed.stderr).not.toContain('daemon_unreachable'); + + const invalidFile = join(directory, 'invalid.json'); + writeFileSync(invalidFile, '{"broken":'); + const invalidFromFile = invokeCli([ + 'run', FLOW, '--input', invalidFile, '--data-dir', unreachableDataDir, + ]); + expect(invalidFromFile.status, invalidFromFile.stderr).toBe(2); + expect(invalidFromFile.stderr).toContain('REFUSED [input_invalid]'); + expect(invalidFromFile.stderr).toContain(`Input file "${invalidFile}" is not valid JSON`); + + const missingInputValue = invokeCli(['run', FLOW, '--input', '--json']); + expect(missingInputValue.status, missingInputValue.stderr).toBe(2); + expect(missingInputValue.stderr).toContain('REFUSED [invalid_invocation]'); + }); + + it('does not import or execute authored code before daemon availability', () => { + const directory = temporaryDirectory(); + const marker = join(directory, 'marker.txt'); + const result = invokeCli([ + 'run', SIDE_EFFECT_FLOW, '--input', JSON.stringify({ marker }), + '--data-dir', join(directory, 'absent-daemon'), + ], { RELAYFLOWS_TEST_IMPORT_MARKER: marker }); + + expect(result.status, result.stderr).toBe(2); + expect(result.stderr).toContain('REFUSED [daemon_unreachable]'); + expect(existsSync(marker)).toBe(false); + }); + + it('refuses oversized file input before contacting relayflowd', () => { + const directory = temporaryDirectory(); + const inputPath = join(directory, 'oversized.json'); + writeFileSync(inputPath, JSON.stringify({ value: 'x'.repeat(1_048_576) })); + const result = invokeCli([ + 'run', FLOW, '--input', inputPath, '--data-dir', join(directory, 'absent-daemon'), + ]); + + expect(result.status, result.stderr).toBe(2); + expect(result.stderr).toContain('REFUSED [input_too_large]'); + expect(result.stderr).not.toContain('daemon_unreachable'); + }); +}); + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'flows-direct-input-')); + temporaryDirectories.push(directory); + return directory; +} + +function invokeCli(args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [BUILT_CLI, ...args], { + cwd: ROOT, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +async function startDaemon(dataDir: string): Promise { + const daemon = spawn(RELAYFLOWD, ['--data-dir', dataDir, 'serve'], { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + }); + daemons.push(daemon); + const socket = join(dataDir, 'relayflowd.sock'); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (existsSync(socket)) return; + if (daemon.exitCode !== null) throw new Error(`relayflowd exited ${daemon.exitCode}`); + await new Promise((resolveWait) => setTimeout(resolveWait, 20)); + } + throw new Error(`relayflowd did not create ${socket}`); +} + +async function stopDaemon(daemon: ChildProcess): Promise { + if (daemon.exitCode !== null) return; + daemon.kill('SIGTERM'); + await new Promise((resolveStop) => { + const timer = setTimeout(() => { + daemon.kill('SIGKILL'); + resolveStop(); + }, 2_000); + daemon.once('exit', () => { + clearTimeout(timer); + resolveStop(); + }); + }); +} + +function locateRelayflowd(): string { + const direct = join(TOOLCHAIN_TARGET, 'debug', 'relayflowd'); + if (existsSync(direct)) return direct; + if (existsSync(TOOLCHAIN_TARGET)) { + const keyed = readdirSync(TOOLCHAIN_TARGET) + .map((entry) => join(TOOLCHAIN_TARGET, entry, 'debug', 'relayflowd')) + .filter((candidate) => existsSync(candidate)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + if (keyed[0] !== undefined) return keyed[0]; + } + return join(ROOT, 'kernel', 'target', 'debug', 'relayflowd'); +} + +function requireExecutable(path: string, label: string): void { + try { + accessSync(path, constants.X_OK); + } catch { + throw new Error(`${label} is not executable at ${path}`); + } +} diff --git a/sdk/tests/fixtures/direct-input.flow.ts b/sdk/tests/fixtures/direct-input.flow.ts new file mode 100644 index 000000000..fc301f673 --- /dev/null +++ b/sdk/tests/fixtures/direct-input.flow.ts @@ -0,0 +1,15 @@ +import { flow } from '@relayflows/surface'; + +interface DirectInput { + output: string; + value: string; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export default flow('direct-input-fixture', {}, async (f, input: DirectInput) => { + await f.run(`printf %s ${shellQuote(input.value)} > ${shellQuote(input.output)}`); + f.done('success'); +}); diff --git a/sdk/tests/fixtures/direct-output-control.flow.ts b/sdk/tests/fixtures/direct-output-control.flow.ts new file mode 100644 index 000000000..6a6f147c5 --- /dev/null +++ b/sdk/tests/fixtures/direct-output-control.flow.ts @@ -0,0 +1,26 @@ +import { flow } from '@relayflows/surface'; + +interface ControlInput { + output: string; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export default flow('direct-output-control', async (f, input: ControlInput) => { + const append = async (value: string): Promise => { + await f.run(`printf %s ${shellQuote(value)} >> ${shellQuote(input.output)}`); + }; + const value = await f.run('printf value'); + const empty = await f.run('printf %s ""'); + + if (value) await append('truthy,'); + if (!empty) await append('negation,'); + if (value == 'value') await append('loose,'); + if (value === 'value') await append('strict,'); + await append(value ? 'ternary,' : 'wrong,'); + value && await append('and,'); + empty || await append('or'); + f.done('success'); +}); diff --git a/sdk/tests/fixtures/pre-journal-side-effect.flow.ts b/sdk/tests/fixtures/pre-journal-side-effect.flow.ts new file mode 100644 index 000000000..dc20d2d82 --- /dev/null +++ b/sdk/tests/fixtures/pre-journal-side-effect.flow.ts @@ -0,0 +1,11 @@ +import { writeFileSync } from 'node:fs'; +import { flow } from '@relayflows/surface'; + +const importMarker = process.env['RELAYFLOWS_TEST_IMPORT_MARKER']; +if (importMarker !== undefined) writeFileSync(importMarker, 'authored module imported'); + +export default flow('pre-journal-side-effect', async (f, input: { marker: string }) => { + writeFileSync(input.marker, 'authored body ran'); + await f.run('true'); + f.done('success'); +}); diff --git a/surface/README.md b/surface/README.md index ac980f2ea..8121887b8 100644 --- a/surface/README.md +++ b/surface/README.md @@ -23,10 +23,11 @@ Note that executing an authored body replaces the global `Promise.all` for the duration of the run. The reason, the scope, and the one documented limit of the lifecycle contract are in `docs/SURFACE.md`, "The authored operation lifecycle". -This is an unpublished contract foundation, not a shipped executable surface. -Direct `.flow.ts` execution, durable authored-root resume, and input remain -tracked in issue #132. Resident trigger handlers (`flow.on(...)`) are gate-2 -work and are not yet part of this package. +This is an in-repository foundation, not a registry-published package. The +repository's `flows run` command can execute a directly authored `.flow.ts` +with required JSON input. Durable authored-root resume remains tracked in issue +#132. Resident trigger handlers (`flow.on(...)`) are gate-2 work and are not yet +part of this package. The repository pins Bun through `surface/bun.lock`. From a fresh checkout: @@ -40,8 +41,22 @@ bun run test ```ts import { flow } from "@relayflows/surface"; -export default flow("release-note", async (f) => { - await f.run("git diff main"); +export default flow<{ base: string }>("release-note", {}, async (f, input) => { + await f.run(`git diff ${input.base}`); f.done("success"); }); ``` + +Run it with inline JSON or the path to a JSON file: + +```sh +flows run release-note.flow.ts --input '{"base":"main"}' +flows run release-note.flow.ts --input ./release-note.input.json +``` + +Direct input is limited to 1,048,576 UTF-8 bytes. Missing, invalid, or +oversized input is refused before the daemon is contacted. + +Direct runs use the same journal-backed executor as other authored flows, so +branches over step output observe the value recorded by `step.completed`. +Unsupported headers, verbs, and code predicate gates fail closed. diff --git a/surface/src/flow.ts b/surface/src/flow.ts index 9ac08c264..fa1f358d8 100644 --- a/surface/src/flow.ts +++ b/surface/src/flow.ts @@ -9,7 +9,7 @@ export interface FlowHeader { workspace?: string; } -export type FlowBody = (f: Ctx) => Promise; +export type FlowBody = (f: Ctx, input: Input) => Promise; export interface ReadonlyFlowHeader { readonly identity?: string; @@ -23,10 +23,10 @@ export interface ReadonlyFlowHeader { } /** Immutable definition retained for the SDK's journal-backed runtime. */ -export interface AuthoredFlowDefinition { +export interface AuthoredFlowDefinition { readonly name: string; readonly header: ReadonlyFlowHeader; - readonly body: FlowBody; + readonly body: FlowBody; } /** Opaque authored-flow handle. Execution stays behind the journal runtime. */ @@ -36,16 +36,16 @@ export interface FlowHandle { const definitions = new WeakMap(); -export function flow(name: string, body: FlowBody): FlowHandle; -export function flow( +export function flow(name: string, body: FlowBody): FlowHandle; +export function flow( name: string, header: FlowHeader, - body: FlowBody, + body: FlowBody, ): FlowHandle; -export function flow( +export function flow( name: string, - headerOrBody: FlowHeader | FlowBody, - body?: FlowBody, + headerOrBody: FlowHeader | FlowBody, + body?: FlowBody, ): FlowHandle { const flowBody = typeof headerOrBody === "function" ? headerOrBody : body; const header = typeof headerOrBody === "function" ? {} : headerOrBody; @@ -58,13 +58,20 @@ export function flow( } assertFlowHeader(header, name); - const definition: AuthoredFlowDefinition = Object.freeze({ + const definition: AuthoredFlowDefinition = Object.freeze({ name, header: freezeHeader(header), body: flowBody, }); const handle: FlowHandle = Object.freeze({ name }); - definitions.set(handle, definition); + // One map holds definitions of many input types, so it is stored at the + // default parameterisation and `getFlowDefinition` re-parameterises on + // the way out. The cast is needed because `body` puts `Input` in a parameter + // position, making the type invariant: `AuthoredFlowDefinition` is not + // assignable to `AuthoredFlowDefinition` even though every read + // recovers the author's own type. Sound here because the handle-to-definition + // pairing is 1:1 and both sides are keyed by the same authored flow. + definitions.set(handle, definition as AuthoredFlowDefinition); return handle; } @@ -72,7 +79,7 @@ export function flow( * Runtime bridge used by the SDK after it imports an authored `.flow.ts`. * The root package deliberately does not re-export this accessor. */ -export function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition { +export function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition { if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) { throw new TypeError("expected an @relayflows/surface flow handle"); } @@ -106,6 +113,16 @@ function isStoredDefinition( } function freezeHeader(header: FlowHeader): ReadonlyFlowHeader { + const unknownFields = Object.keys(header).filter((field) => ![ + "identity", + "memory", + "budget", + "tools", + "workspace", + ].includes(field)); + if (unknownFields.length > 0) { + throw new TypeError(`flow header has unknown fields: ${unknownFields.join(", ")}`); + } const memory = header.memory === undefined ? undefined : Object.freeze({ ...header.memory }); diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts index c81122abf..5c74aa81d 100644 --- a/surface/tests/flow.test.ts +++ b/surface/tests/flow.test.ts @@ -133,10 +133,18 @@ describe("flow", () => { const authored = getFlowDefinition(definition); expect(Object.isFrozen(authored)).toBe(true); - await authored.body({} as Ctx); + await authored.body({} as Ctx, undefined); expect(bodyRan).toBe(true); }); + it("passes direct-run input to the authored body", async () => { + const definition = flow<{ message: string }>("direct", {}, async (_f, input) => { + expect(input.message).toBe("from-cli"); + }); + + await getFlowDefinition(definition).body({} as Ctx, { message: "from-cli" }); + }); + it("refuses malformed and forged handles at the runtime boundary", () => { expect(() => getFlowDefinition({ name: "counterfeit" })).toThrow( "expected an @relayflows/surface flow handle",