Skip to content

💥 feat(runtime): resolve the xmd command through API.Env - #184

Merged
taras merged 8 commits into
mainfrom
feat/env-command
Jul 28, 2026
Merged

💥 feat(runtime): resolve the xmd command through API.Env#184
taras merged 8 commits into
mainfrom
feat/env-command

Conversation

@taras

@taras taras commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Why

Preparation for #144, and a slice of #156.

The CLI rebuilt its own relaunch command by inspecting
basename(process.execPath) (cli.ts, resolveWorkerCommand). That had a
real bug — no Bun branch, so under Bun it fell through to the compiled-binary
case and produced bun test-agent instead of bun <entry> test-agent — but
the shape was wrong regardless: an executable path says what is running, not
how it was launched. deno run --allow-all <entry> cannot be recovered from
the string "deno", and a compiled binary has no entry script at all. Only the
module that started the process knows.

execute() had the same problem in miniature, choosing a compiler with
typeof globalThis.Deno.

What changes

Before: shared code guessed the host and reconstructed its own invocation.

After: the invocation is a contextual capability that runtime-named
entrypoints supply, and shared code asks for it.

How it works

API.Env owns both host capabilities

entrypoint installs adapter → <TestAgent> asks command(["test-agent"]) → complete invocation

It returns the complete invocation with args appended, not a prefix for
callers to extend — argument placement differs per runtime and belongs to the
adapter. The default handler fails explicitly ("xmd command not installed")
rather than guessing from process.execPath.

command (how to re-invoke this xmd) and compile (how this host loads a
generated module) are the same kind of thing — only the entrypoint knows
either. Each entrypoint installs both in one call with { at: "min" }, making
them base providers that ordinary middleware wraps:

Entrypoint command(args) compile
deno.ts [execPath, "run", "--allow-all", entry, ...args] compileDataUri
node.ts [execPath, ...execArgv-minus-inspect, entry, ...args] compileTempFile
bun.ts [execPath, entry, ...args] compileDataUri
compiled.ts [execPath, ...args] compileDataUri

Argument construction is inline in each entrypoint's own middleware — there is
no shared builder for them to forward to.

entry comes from each module's own import.meta.url, never
process.argv[1]. The worker is spawned with the document's working
directory, so a path relative to the parent's would not resolve.

Lazy at the <TestAgent> boundary

command is requested when a provider is provisioned, not when components are
installed — so a document that never mentions <TestAgent> runs even where no
adapter exists. installTestAgentComponents takes no argument now and
TestAgentComponentsOptions is gone.

Compiler installation

execute() installs nothing; the entrypoint decides which compiler suits the
host. API.Compiler is gone — compile moved onto API.Env beside
command — and the explicit "not installed" failure moved with it.

{ at: "min" } is what makes this work. An earlier revision installed the
compiler as ordinary middleware and hit a real ordering bug: the
behavior-document policy in installWorkerProfile wraps compile to reject
static imports, but a terminal provider installed earlier answered compile
itself and the restriction never ran — TV4 went green when it should have
stayed red. As a base provider the entrypoint's compiler is wrapped rather
than bypassed, and the policy is plain middleware that validates and
delegates. No installer threading, no ordering rule to remember.

The compiled-block contract

EvalBlock named the generator that compilers happen to emit rather than what
callers may do with it, and eval-handler.ts paid for that twice with
as unknown as Operation<unknown> casts just to run a block. It is now:

type EvalBlock = (env: Record<string, unknown>) => Operation<unknown>;
compile(source: string, options?: { imports: string[] }): Operation<EvalBlock>;

compileBlock() returns Operation<EvalBlock> instead of restating the
concrete signature, both casts are gone, and a block runs with plain yield*.
Generated modules are still function* — that is how a compiler builds one,
not something callers depend on. Spec §4.2 carries the signature.

The type change found every consumer that assumed otherwise: the compilation
suites drove blocks by hand with .next() loops, asserting mechanism rather
than behaviour. They now run the operation and assert what the block did.

Rename

useDenoCompileruseDataUriCompiler, no alias. Nothing in it was ever
Deno-specific — it builds a data: URI, which Bun also loads and Node's tsx
loader rejects. Its call(() => import(...)) moves to until per rule 3.

What must stay true

  • Shared cli.ts and execute.ts make no decision about command
    reconstruction, compiler selection, or runtime detection — process.execPath
    appears only in the four entrypoints. They do still reach the host for
    terminal and journal I/O; that is Route production host access through Env #156.
  • The behavior-document policy wraps the entrypoint's compiler, not the reverse
    — checked by TV4 (static import rejected).
  • Entry paths stay absolute — checked by XC4, which runs the CLI from a scratch
    directory and requires the worker to launch.
  • The Bun adapter actually relaunches a worker — checked by the Bun entrypoint smoke step in the test-bun job.

How to verify it

Four repository checks with the CI-pinned Deno 2.9.1: lint 0 errors, typecheck
clean, 144 passed / 0 failed, JSR dry run Success. Plus:

  • deno task build./dist/xmd test smoke-test/test-agent/README.md exits 0
    with both assertions passing — the compiled entrypoint relaunching itself.
  • Bun entrypoint smoke, new step in the test-bun CI job:
    bun run packages/cli/src/bun.ts test smoke-test/test-agent/README.md --raw.
    bun run test:bun never loads bun.ts, so it could not have validated the
    very defect this branch fixes. Verified discriminating: reintroducing the old
    behaviour — dropping the entry module, as resolveWorkerCommand did under
    Bun — makes the step exit 1.
  • scripts/tests/cli-npm-bin.test.ts builds the npm package from the new
    bin: ./src/node.ts and runs the emitted bin under Node, which relaunches its
    test-agent worker. This is the regression gate for the entrypoint rewiring.
  • deno task gen:publish-workflow reports no drift.

New coverage: packages/cli/tests/command.test.ts (XC1–XC4 — the explicit
no-adapter failure, argument appending, a max wrapper over the base provider,
and a worker relaunched from another working directory),
packages/test-agent/tests/command-lazy.test.ts (XL1–XL3), and
packages/core/tests/compiler-boundary.test.ts (CB1–CB3 — no eval blocks and
no compiler; an eval block with no compiler; a caller-installed compiler
surviving execute()).

Both contracts are written down: spec §4.2 states compiler ownership and §9.6
states the command contract, rather than only listing the new files.

Scope

Included

  • API.Env.command, the four entrypoints, and the compiler-installation
    contract.
  • scripts/build-npm.ts: an executable's entry now comes from package.json
    bin rather than deno.json exports, so npm ships node.ts while JSR
    gets the Deno entrypoint.
  • 20 test files install useTempFileCompiler() now that execute() does not.

Intentionally unchanged

Risks and limitations

  • Breaking. Programmatic callers running documents with eval blocks must
    install compiler middleware. useDenoCompiler is gone with no alias, and
    TestAgentComponentsOptions is removed. Both specs are updated.
  • XC4 proves the worker launches from a foreign working directory, but with an
    absolute entry path it would also pass under the old process.argv[1]
    implementation. The absolute-path contract is documented in spec §9.6 and in
    each entrypoint rather than pinned by a discriminating test.
  • cli.ts still reaches process.stdout and node:fs/promises directly. It
    makes no decision about re-invocation, compiler choice, or runtime detection —
    the claim is scoped to those three. The remaining host-boundary cleanup is
    Route production host access through Env #156.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

The CLI reconstructed its own relaunch command by inspecting
`basename(process.execPath)`. That guess had no Bun branch at all — under Bun
it fell through to the compiled-binary case and produced `bun test-agent`
instead of `bun <entry> test-agent` — and it could not be right in principle:
a path names the executable but not how it was launched, and `deno run
--allow-all <entry>` is not recoverable from "deno".

`API.Env` gains `command(args?)`, which returns the complete invocation of
this xmd with `args` appended. Its default handler fails explicitly rather
than guessing. Each runtime-named entrypoint — `deno.ts`, `node.ts`,
`bun.ts`, `compiled.ts` — installs its own adapter and takes the entry path
from `import.meta.url`, so the path stays valid in the working directory the
worker is spawned from. `cli.ts` is left runtime-neutral.

`<TestAgent>` asks for the command when it provisions a provider, not when
the components are installed, so a document that never mentions it runs even
where no adapter exists. `installTestAgentComponents` therefore takes no
argument and `TestAgentComponentsOptions` is gone.

execute() no longer picks a compiler by testing `typeof globalThis.Deno`;
installing one is the entrypoint's job. `API.Compiler`'s explicit
"not installed" failure is unchanged, so a programmatic caller that runs eval
blocks must now install compiler middleware itself. Documents without eval
blocks are unaffected. The worker receives the installer rather than
inheriting one, because the behavior-document profile has to wrap the
compiler for its inline-only restriction to run.

`useDenoCompiler` becomes `useDataUriCompiler` with no alias — nothing in it
was ever Deno-specific — and its promise conversion moves to `until`.

Refs #156, which keeps the wider production host-access migration and the
lint rule that will enforce AGENTS.md rule 12.
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

PR #184: 💥 feat(runtime): resolve the xmd command through API.Env

61 files, +974 / -355

Scope

🔴 PR has 1329 lines changed. Split into focused PRs.

🟡 1329 lines changed. PRs under 400 receive more thorough review.

🟡 61 files changed. Are all changes related?

🟡 PR mixes config and source changes.

🟡 package.json changed without dependency justification.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

The spec listed the new entrypoint files without saying what they promise.
Both contracts are observable and neither was written down.

§4.2 now states compiler ownership: execute() neither detects the runtime nor
installs a compiler; a document without eval blocks needs none; one with them
requires the caller to install API.Compiler middleware, and gets an explicit
failure rather than a guess when it has not; a caller's compiler is used
rather than shadowed. The section no longer presents the data: URI as the only
mechanism, since two implementations ship and neither is bound to the runtime
its name once suggested.

§9.6 now states the command contract: the complete invocation with arguments
appended in the position that host needs, no inferred default, an absolute
entry path from import.meta.url, and resolution deferred until <TestAgent>
provisions a provider.

Corrects the claim that cli.ts "detects nothing about the host". It makes no
decision about re-invocation, compiler choice, or runtime detection — but it
still reaches process.stdout and node:fs/promises directly, which is #156.

Tier CB covers the compiler boundary: no eval blocks and no compiler, an eval
block with no compiler, and a caller-installed compiler surviving execute().

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 redundant comment. Inline suggestions to remove them below.

Comment thread packages/test-agent/src/worker/run.ts Outdated
},
});
// Beneath the profile, so its inline-only check runs first and the
// passthrough for an allowed block reaches a real compiler.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// passthrough for an allowed block reaches a real compiler.

`command` and `compile` are the same kind of thing: capabilities only the
entrypoint that knows the host can supply. Keeping them on separate Apis added
a boundary that bought nothing and cost an ordering workaround.

Both now live on API.Env, and each entrypoint installs them together with
`{ at: "min" }` — foundational providers that ordinary middleware wraps rather
than races. That deletes the workaround directly: the behavior-document policy
in the test-agent worker is plain "max" middleware again, validating a block
and delegating to whatever the entrypoint installed, with no need to be
installed in a particular order relative to it.

Removed with it: API.Compiler, XmdOptions.useCompiler, the compiler installer
threaded through runXmd() and runTestAgentWorker(), and the unused `next`
parameters that terminal providers had to declare. A terminal provider now
omits `next` entirely; only middleware that delegates declares it.

The compilers split into the operation and its installer: `compileDataUri` and
`compileTempFile` are what an entrypoint calls inside its `compile` provider,
while `useDataUriCompiler`/`useTempFileCompiler` remain for callers that want
only a compiler, and register at min.

Command construction is inline in each entrypoint's own middleware rather than
extracted to a shared module — that module made the entrypoints forwarding
shells and put the host-adapter boundary in the wrong place. Its unit tests go
with it; argument placement is covered where it is observable, by a worker
that has to actually launch.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

Comment thread packages/core/src/temp-file-compiler.ts Outdated
}
return mod.default;
} finally {
// Clean up temp file — don't await, fire and forget

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Clean up temp file — don't await, fire and forget

Comment thread scripts/build-npm.ts Outdated
isolatedImports[`${name}/`] = `npm:${name}@^${siblingVer}/`;
}
// Preserve the manifest fields alongside the rewritten imports: packages/cli/src/cli.ts
// Preserve the manifest fields alongside the rewritten imports: the CLI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Preserve the manifest fields alongside the rewritten imports: the CLI

taras added 2 commits July 28, 2026 00:44
The defect that motivated this branch is a wrong Bun worker command, and
nothing verified the fix. `bun run test:bun` never loads
packages/cli/src/bun.ts and never relaunches a worker, so its green result
said nothing about the Bun adapter.

The smoke document drives a <TestAgent> scenario, so the Bun parent has to
relaunch bun.ts as `test-agent` through the command it builds. Reintroducing
the original defect — dropping the entry module, as the old
resolveWorkerCommand did under Bun — makes this step exit 1, so it discriminates.

Also drops two comments that restate their code, and corrects the compilers'
"Compiler API interface" wording now that compile lives on API.Env.
The header still said `execute` installs this automatically on Node and Bun,
which the new contract makes false: `execute` installs no compiler at all.

The Node entrypoint calls `compileTempFile` from its own `API.Env.compile`
provider, and a programmatic caller that wants only a compiler installs
`useTempFileCompiler()`. The "for Node and Bun" title went with it — this is
the portable implementation, and which host uses it is the entrypoint's
choice, not a property of the file.
taras added 2 commits July 28, 2026 05:07
EvalBlock named the generator that compilers happen to emit rather than what
callers may do with it. That leaked an implementation detail into the
contract, and eval-handler.ts paid for it twice with
`as unknown as Operation<unknown>` casts to run a block at all.

A compiled block now returns Operation<unknown>. Generated modules are still
`function*` — a generator object already satisfies Operation — but that is how
a block is built, not what the API promises. compileBlock() returns
Operation<EvalBlock> instead of restating the concrete signature, and both
casts are gone: a block runs with plain `yield*`.

The type change immediately found every place that assumed otherwise. The
compilation suites drove blocks by hand with .next() loops, which asserted
mechanism rather than behavior; they now run the operation and assert what the
block did. T17 no longer inspects an intermediate yield to prove `sleep` is in
scope — it asserts the statement after the suspension ran, which is the
observable claim. T23 covers a block's return value reaching the caller, which
nothing asserted directly before.

Enforcement is #185; this PR only avoids introducing what that rule will catch.
Per AGENTS.md rule 4, comments earn their place by describing surprising
behavior. These described the line beneath them: an assertion restated in
prose, four doc comments that repeated the names they sat on, and a `void next`
for a parameter that only existed to be discarded — terminal middleware can
omit `next` entirely.

The --inspect rationale in node.ts was stated twice; it stays in the module
documentation, where a reader meets it before the code.

The constraints stay: why entrypoint providers install at min, why entry paths
come from import.meta.url, why execute() installs no compiler, why command
resolution is lazy, why XL3 does not assert exactly one request, why the
static import anchors must survive `deno compile`, and why the Bun smoke
exists separately from test:bun.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 5 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/bun.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.


await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

Comment thread packages/cli/src/deno.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

Comment thread packages/cli/src/node.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

// No compiler is installed here. Which one suits the host is the
// entrypoint's decision, and installing one from inside this task would
// shadow whatever the caller installed outside it. A document with no
// eval blocks never reaches API.Env.compile at all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// eval blocks never reaches API.Env.compile at all.

eval-handler.ts carried a comment saying the block compiles "via data: URI
module import", which API.Env.compile makes false — the Node entrypoint uses
the temp-file compiler — and a second that only narrated the yield* beneath it.

The EvalBlock documentation claimed a generator object "already satisfies
Operation". That is not true of an arbitrary Generator<unknown, unknown,
unknown> under Effection's typed contract, and the point does not need it: a
compiled block accepts the binding environment and returns an Operation, and
callers do not depend on how a compiler builds one.

Spec §4.2 now carries the public signature, so the contract is readable
without opening apis.ts.

The compilation tests lose their T-label comments and two that restated their
assertions. The one explaining why T17's assertion proves the suspension
resumed stays — that is the part a reader cannot see.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 4 redundant comments. Inline suggestions to remove them below.

Comment thread packages/cli/src/bun.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.


await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

Comment thread packages/cli/src/deno.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

Comment thread packages/cli/src/node.ts

await main(function* (args) {
// The base providers for this host. `at: "min"` puts them beneath ordinary
// middleware, so a policy installed later can wrap either one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// middleware, so a policy installed later can wrap either one.

@taras
taras merged commit ba56b7d into main Jul 28, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant