effects: put the Result envelope in every operation's return type - #1607
Conversation
Stage 3 of the IoEffect migration. Every operation in `fjs/effects/node` and
`fjs/effects/memory` now declares a `Result` return, every runner answers with
one, and host failures are normalized into a structured `IoError` instead of
the bare `unknown` that used to erase the `NotImplemented` distinction.
The envelope lives in the operation's declared return type rather than in a
wrapper a constructor applies, because that is the only channel through which
stage 6 can deliver `error(notImplemented)` to a `Do` node's continuation.
Two aliases carry it: `OpResult<T>` (`Result<T, NotImplemented>`) for an
operation with no failures of its own, and `IoResult<T>` — refined in place —
for one that performs host IO. Runners stay total: `NotImplemented` exists in
the type model and nothing produces it yet.
`IoError` is `readonly['ioError', { code?, message }]`, a tagged tuple beside
`NotImplemented` so the shared channel stays discriminable, living in
`fjs/effects/node/types.ts` beside the operations that report it. `toIoError`
normalizes a thrown host value at the single boundary where an impure runner
catches, and the virtual runner reports the same shape, so a proof against the
virtual filesystem is still evidence about the real one. `SandboxResult.result`
stays a `Result` inside the envelope: it is the sandboxed function's outcome,
returned data rather than effect status.
`Write` and `Read` stayed `OpResult`. They are host IO and could fail (EPIPE),
but this stage's rule for a currently-infallible handler is to wrap its output
in `ok(...)`, not to invent a failure the runner never reported; promoting them
is a behavior change that belongs in its own issue.
The value-discarding sweep is the part `tsc` cannot do: a continuation that
reads the value broke loudly, while `() => next` kept compiling and silently
ignored the new channel. Each such site now states a policy, and the two named
ones are what stage 4 will start from — `unwrapStep` (leave the layer by
panicking; one greppable name rather than an `unwrap` buried in a continuation)
and `exitStep` / `errorMessage` (a `NodeProgram`'s exit-code policy: report on
stderr, exit 1). `errorExit` keeps discarding its own write's outcome
deliberately: the program is already failing, and the exit code is 1 whether or
not stderr accepted the bytes.
`npx tsc` is clean, `fjs t` passes (2919 tests), and `npm run cov` reports
100% line/branch/function with its gate enabled.
Changelog:
- **BREAKING CHANGES:** `effects`: every operation's return type carries a
`Result`. Infallible operations answer `OpResult<T>`
(`Result<T, NotImplemented>`); host IO answers `IoResult<T>`, whose error is
now the structured `NotImplemented | IoError` rather than `unknown`. Runner
handlers wrap their output in `ok(...)`; `isNotFound` takes a channel error
- `effects/node`: new `IoError` / `IoErrorInfo` / `OpResult` types with
`ioError`, `toIoError`, `errorMessage` and `exitStep`, the `NodeProgram`
exit-code policy
- `effects/io`: new `unwrapStep`, which leaves the layer by panicking on the
error branch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | c902eb7 | Commit Preview URL Branch Preview URL |
Aug 16 2026, 06:02 AM |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
One call site was missed by the sweep: fjs/cas/cli/module.f.mjs:53, the cas get handler —
([r, v]) => r === 'error' ? errorExit('e: ' + String(v)) : pure(0)v used to be the thrown Error; it's now the IoError tuple, and String(['ioError', {...}]) gives ioError,[object Object]. Ran the real CLI on both trees with HOME pointed at a temp store:
- main:
e: Error: ENOENT: no such file or directory, open '.../.cas/b2/8v/...' - head:
e: ioError,[object Object]
Exit code is 1 either way and the success path is byte-identical — only the diagnostic is destroyed. Notably String(...) accepted the new shape silently, which is the exact hazard this stage is meant to remove. The fix is already in the PR: errorMessage(v).
Everything else checks out. Behavioral probe of 45 cases — real FS read/write/stat/access/rename/rm/readdir/readBytes/createExclusive/writeFromStream/import/fetch, missing-file and chmod-000 permission-denied, memory ops, virtual FS, and fjs/cas add/read/list end-to-end including the ENOENT-swallowing empty-store path — every success value identical, every failure still a failure. code and message are preserved verbatim (ENOENT/EACCES/EEXIST/ERR_MODULE_NOT_FOUND); stack/cause/errno/syscall/path are dropped and no consumer reads them (exec's numeric code too, but exec has no callers).
"Runners stay total" holds — no error(['notImplemented', …]) exists at this head, and virtual's unimplemented ops still throw as on main.
Harness check, since emergent_testing changed: broke fjs/types/nullable/proof.f.mjs and got 2918 pass / 1 fail, exit 1, failing proof named — still reports correctly.
npx tsc exit 0; fjs t → 2919 pass / 0 fail; npm run cov exit 0 at 100/100/100. Changelog conforms, body carries Changelog:, todo marks Stage 3 done with 4–6 remaining.
(Verified at 8c9a0fb; re-checked at this head — the delta is only the #1606 merge, and cas get is unchanged.)
The conversion replaced several call sites with Io combinators — `pure(ok(…))` became `pureOk`, `mapStep(…, unwrap)` became `unwrapStep`, a program tail became `exitStep` — and left the old imports behind. Two were reported by the code-quality review; the other nine are the same defect in the same change. Changelog: none Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Delta at this head is clean: all 9 hunks are import statements, no executable line touched. The five +1/-1 files are named-import lists losing one name while the module stays imported (mapStep in fjs/ci/nix, pure in fjs/djs, mapStep in fjs/effects/memory/proof, ok in fjs/effects/node, ok in fjs/protocol/mcp/stdio); the four pure deletions drop a whole line. No side-effect risk — there are no bare import '...' statements anywhere in fjs/, and the two modules losing a direct importer contain only imports, comments and export const arrows, so nothing runs at load. npx tsc exit 0; 2925 pass / 0 fail; npm run cov exit 0 at 100/100/100.
The cas get finding is still open, unchanged — fjs/cas/cli/module.f.mjs:53 still does String(v) on the IoError tuple, so a failed cas get prints e: ioError,[object Object] instead of the ENOENT message. errorMessage(v) is the fix.
`cas get`'s failure branch stringified the channel error —
`errorExit('e: ' + String(v))` — which used to print the thrown `Error` and now
prints `ioError,[object Object]`. Reported in review; the diagnostic is the
whole value of that line.
Both handlers now end in `exitStep`, the exit-code policy this stage added, so
the message reaching the user is the host's own. `cas add`'s failure branch was
the same defect one step further along: it exited `1` with nothing on stderr at
all, and now reports too.
The proofs asserted only that *something* was written to stderr, which is what
let a stringified tuple pass. They now assert the message.
Changelog: none
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
|
Fixed in 63a1150 — thank you, that was a real regression and the probe caught exactly the hazard this stage is about:
The proofs are the reason this slipped:
Generated by Claude Code |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Fix confirmed by running the real CLI — cas get for a hash absent from an empty store, HOME pointed at a temp dir:
origin/main: e: Error: ENOENT: no such file or directory, open '…/.cas/mb/mw/wn3xb2rb…', exit 1
this head: ENOENT: no such file or directory, open '…/.cas/mb/mw/wn3xb2rb…', exit 1
Code and path both reach the user again; only the redundant e: Error: prefix is gone. (At 8c9a0fb this printed e: ioError,[object Object].)
exitStep is the better shape than the errorMessage(v) I suggested — step(e, r => r[0] === 'error' ? errorExit(errorMessage(r[1])) : pure(0)) puts one shared policy in place of a per-command string.
Side effects checked: exitStep already existed in this PR and is used by cli, djs, website. The commit also routes cas add through it, which is an improvement — main exited 1 with empty stderr, head prints ENOENT: … / EISDIR: illegal operation on a directory, read. cas add (ok), cas list, cas list on an empty store, cas get (ok), bad-dest and bad-hash-format are byte-identical across both trees.
Swept for the same bug class: no other Result/IoError reaches String(…), ${}, + or JSON.stringify. The one candidate, fjs/emergent_testing/module.f.mjs:358, is the thrown test value from sandbox whose io channel is unwrapped upstream — shape unchanged.
npx tsc exit 0; fjs t → 2929 pass / 0 fail; npm run cov → 100/100/100, exit 0.
Stage 3 of the IoEffect migration
(
fjs/effects/todo/io-effect-migration.md). Stages 1 and 2 — the types and thecomposition API — shipped in #1599 and #1604.
Every operation in
fjs/effects/nodeandfjs/effects/memorynow declares aResultreturn, every runner answers with one, and host failures arenormalized into a structured
IoErrorinstead of the bareunknownthat usedto erase the
NotImplementeddistinction.Where the envelope lives
In the operation's declared return type, not in a wrapper a constructor
applies — that is the only channel through which stage 6 can deliver
error(notImplemented)to aDonode's continuation. Two aliases carry it:OpResult<T>=Result<T, NotImplemented>— an operation with no failuresof its own (
now,randomInt,all,write,read,sandbox,test,the memory ops, …).
IoResult<T>=Result<T, NotImplemented | IoError>— refined in place fromResult<T, unknown>for host IO.Runners stay total:
NotImplementedis in the type model and nothing producesit yet.
IoErrorreadonly['ioError', { code?: string, message: string }]— a tagged tuplebeside
NotImplementedso the shared channel stays discriminable, infjs/effects/node/types.tsbeside the operations that report it. The bareunknownhad to go for a specific reason:NotImplemented | unknowncollapsesto
unknown, so a program could not tell "this runner cannot do it" from "thehost tried and failed".
toIoErrornormalizes a thrown value at the single boundary where an impurerunner catches, keeping the OS
codewhen the host attached one — which iswhat
isNotFoundstill reads. The virtual runner reports the same shape, so aproof against the virtual filesystem is still evidence about the real one.
SandboxResult.resultstays aResultinside the envelope: it is thesandboxed function's outcome — returned data, not effect status.
WriteandReadstayedOpResult. They are host IO and could fail(EPIPE), but this stage's rule for a currently-infallible handler is to wrap
its output in
ok(...), not to invent a failure the runner never reported.Promoting them is a behavior change and belongs in its own issue.
The value-discarding sweep
This is the part
tsccannot do, and the hazard the whole migration is about:a continuation that reads the operation's value broke loudly, while
() => nextkept compiling and silently ignored the new error channel. Everysuch site now states a policy, and two of them are named so the stragglers stay
findable:
unwrapStep(fjs/effects/io/module.f.mjs) — leave the layer bypanicking on the error branch. One greppable name rather than an
unwrapburied in a continuation, so the set of sites that have not yet chosen
anything better is exactly the set this name marks. That is the worklist
stage 4 starts from.
exitStep/errorMessage(fjs/effects/node/module.f.mjs) — aNodeProgram's exit-code policy: report the failure onstderr, exit1.This replaces
step(log(x), () => pure(0))at program tails, which used toexit
0whether or not the write landed.errorExitkeeps discarding its own write's outcome, deliberately and with thereasoning in its JSDoc: the program is already failing, and the exit code is
1whether or notstderraccepted the bytes.Consumers
Per the plan, consumers are not migrated to Io composition here — that is
stage 4. Each one keeps its existing failure policy and changes only what the
envelope forces: modules that already panicked on IO failure (
fjs/dev,fjs/ci,fjs/nanvm/update, the test reporter) now say so withunwrapStep,and the two places where the envelope simplified something took it —
readUtf8Fileis nowmapStepover the Io layer, and the MCP stdio transportreturns
write's own result instead of rebuilding anok.Checks
npx tscclean;fjs t2919 pass / 0 fail;npm run covreports 100%line/branch/function with the #1605 gate enabled;
npm run ci-updateregenerates nothing.
Changelog:
effects: every operation's return type carries aResult. Infallible operations answerOpResult<T>(
Result<T, NotImplemented>); host IO answersIoResult<T>, whose error isnow the structured
NotImplemented | IoErrorrather thanunknown. Runnerhandlers wrap their output in
ok(...);isNotFoundtakes a channel erroreffects/node: newIoError/IoErrorInfo/OpResulttypes withioError,toIoError,errorMessageandexitStep, theNodeProgramexit-code policy
effects/io: newunwrapStep, which leaves the layer by panicking on theerror branch
🤖 Generated with Claude Code
https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
Generated by Claude Code