diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 9f56a1e9e..f617f52d1 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do + for f in packages/durable-streams/deno.json packages/grid/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/grid-tmux/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -68,6 +68,13 @@ jobs: package: packages/durable-streams version: ${{ needs.version.outputs.value }} + grid: + needs: [version, durable-streams] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/grid + version: ${{ needs.version.outputs.value }} + runtime: needs: [version] uses: ./.github/workflows/publish-one.yml @@ -76,19 +83,26 @@ jobs: version: ${{ needs.version.outputs.value }} core: - needs: [version, durable-streams, runtime] + needs: [version, durable-streams, grid, runtime] uses: ./.github/workflows/publish-one.yml with: package: packages/core version: ${{ needs.version.outputs.value }} acp: - needs: [version, core, runtime] + needs: [version, core, grid, runtime] uses: ./.github/workflows/publish-one.yml with: package: packages/acp version: ${{ needs.version.outputs.value }} + grid-tmux: + needs: [version, grid] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/grid-tmux + version: ${{ needs.version.outputs.value }} + testing: needs: [version, core, durable-streams, runtime] uses: ./.github/workflows/publish-one.yml @@ -97,7 +111,7 @@ jobs: version: ${{ needs.version.outputs.value }} test-agent: - needs: [version, acp, core, durable-streams, runtime, testing] + needs: [version, acp, core, durable-streams, grid, runtime, testing] uses: ./.github/workflows/publish-one.yml with: package: packages/test-agent @@ -118,7 +132,7 @@ jobs: version: ${{ needs.version.outputs.value }} cli: - needs: [version, acp, core, durable-streams, runtime, test-agent, testing, web, workflow] + needs: [version, acp, core, durable-streams, grid, grid-tmux, runtime, test-agent, testing, web, workflow] uses: ./.github/workflows/publish-one.yml with: package: packages/cli diff --git a/architecture.md b/architecture.md index a6b73181b..c1cf8aa33 100644 --- a/architecture.md +++ b/architecture.md @@ -108,10 +108,10 @@ Existing documents and code get aligned to this section retroactively. | session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | -| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | -| terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown | -| terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell | -| pane-terminal lease | the exclusive claim one live interactive operation holds on one terminal pane. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session | +| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | +| grid | one provider-neutral foreground presentation whose direct panes begin concurrently, remain independently usable, and settle under one scope after complete provider and pane teardown | +| pane | one authored position in a grid, identified structurally by its grid and ordinal and presented by its authored title. A paired pane expands its own document flow and a self-closing pane runs the host's default shell; a pane acquires an interactive terminal only when its content requires one | +| pane-terminal lease | the exclusive claim one live interactive operation holds on one pane's terminal capability. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session | | native launcher | the host-owned seam that reserves the foreground terminal or the current pane terminal, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | @@ -3406,47 +3406,46 @@ inside the run's existing deadline rather than opening a lifecycle of its own, and a waiting read is cancellable: cancellation tears the reader down and stays cancellation, never a read failure. -## Interactive terminal grids +## Interactive grids -An executable document can replace its one foreground terminal with one -provider-neutral grid of independently interactive terminal panes: +An executable document can replace its one foreground presentation with one +provider-neutral grid of independently usable panes: ```md - - + + Implement the accepted plan. - - + + Review the implementation against the plan. - - - + + + ``` -`Terminal` names the interactive endpoint the document requires. It does not -name the presentation technology: a tmux integration, another terminal -multiplexer, and a host-native composite UI are providers for the same -contract. A component that elicits values through a terminal UI is a different -abstraction, just as `` is one presentation for ``; it does not -change what an interactive process requires here. +`Grid` and `Pane` name the presentation structure the document requires. They +do not name its technology: a tmux integration, another multiplexer, and a +host-native composite UI are providers for the same contract. Terminal is a +capability a pane acquires when an interactive process or shell requires a PTY; +it is not the identity of the grid or every cell. -The grid and its panes are core-owned structural syntax. `` is -paired, requires a positive integer `columns`, and contains at least one direct -`` child. Whitespace may separate those children, but ordinary text, +The grid and its panes are core-owned structural syntax. `` is paired, +requires a positive integer `columns`, and contains at least one direct +`` child. Whitespace may separate those children, but ordinary text, dynamic control structures, and every other direct element are invalid. A pane requires a non-empty `title`; titles are display labels and need not be unique. Its ordinal among the direct children is its structural identity. Rows are derived in row-major order from the pane count and columns. A paired pane expands ordinary document flow; a self-closing pane runs the host's -default shell. A nested grid and a `` outside a grid are invalid. +default shell. A nested grid and a `` outside a grid are invalid. Neither form accepts a provider, executable, shell, layout identifier, or `as`, and neither renders or returns document content. @@ -3464,6 +3463,124 @@ outside that pane. Each pane also owns its checked-failure ledger. A checked failure settles that pane without poisoning the root or a sibling; core alone observes the pane outcome and applies the grid's settlement rule after close. +### Package ownership + +The grid domain is independent of both the document engine that invokes it and +the presentation provider that implements it. Two publishable workspace +packages make those boundaries explicit: + +- `@executablemd/grid` owns native foreground-launch routing and terminal + reservation; provider-neutral grid and pane requests, composites, states, + errors, and row-major layout; `Grids` and `GridProviders` routing; provider + registration and direct authority delivery; claims, readiness, + stale-authority refusal, lifecycle, reader-close settlement, retained + outcomes, replay, pane-scoped launch routing, terminal process observation, + quiescence, and controlled test surfaces. +- `@executablemd/grid-tmux` owns tmux capability probing and commands, the + hidden server and control clients, explicit layout and pane swaps, visible + attach, authenticated Unix-socket channels and their protocol, the persistent + pane worker and its child, worker invocation, and the provider's one ordered + teardown. No tmux command, type, identifier, protocol value, or host probe is + part of the neutral package. + +`@executablemd/grid` exports its ordinary domain surface from the package root. +Its `./lifecycle` entrypoint exports authority creation, provider installation, +claims, readiness, grid execution, retained outcomes, and reader close. Its +`./processes` entrypoint exports `TerminalProcesses`, process facts and signals, +snapshots, and quiescence. Its `./posix` entrypoint exports the POSIX process and +terminal probes and foreground-child adapter. Its `./test` entrypoint exports +only controlled launchers, composites, logs, and signals. Anything exported +from more than one facet is the same object. + +`@executablemd/grid-tmux` exports `TMUX_PROVIDER`, +`TmuxProviderDependencies`, `tmuxGridProvider`, `installTmuxGridProvider`, the +unchanged `PANE_WORKER_COMMAND`, the hidden pane-worker invocation parser, the +pane-worker process runner, and the provider's documented refusal errors from +its root. Protocol frames, channel handles, tmux process wrappers, layout +mechanics, and teardown hooks stay private. Controlled low-level seams exist +only at its `./test` entrypoint. + +The dependency graph points toward the neutral domain: + +```text +@executablemd/grid-tmux ──> @executablemd/grid +@executablemd/core ──> @executablemd/grid +@executablemd/cli ──> core + runtime + grid + grid-tmux +``` + +The grid package may depend on durable streams, Effection, and EffectionX; it +never imports runtime, core, CLI, or grid-tmux. Grid-tmux never imports runtime, +core, or CLI. Runtime has no grid dependency. Core remains the owner of Markdown +parsing and expansion, `SourcePosition` journal descriptions, +execution-profile installation, and Agent session behavior. Its grid journal +and profile adapters stay in core. CLI retains only entrypoint and execution +composition. + +The unmerged `packages/terminal` and `packages/terminal-tmux` trees become +`packages/grid` and `packages/grid-tmux`. Their manifests, exports, tests, +workspace declarations, generated publication entries, and consumers move with +them. The former runtime and core terminal exports, old CLI terminal +implementation paths, rejected package names, and old authored syntax are +deleted. No compatibility component, package, module, alias, forwarding barrel, +wrapper, subclass, or duplicate descriptor remains. Every repository import is +canonical, and each contextual API and public error constructor has one +definition and import path. + +POSIX process-table, process-group, signal, reachability, and terminal-holder +observation lives behind `@executablemd/grid/posix`, not in the tmux adapter. A +different POSIX presentation provider can reuse the same proof without tmux. +The Deno and compiled CLI entrypoints choose tmux, resolve self-reinvocation, +terminal size and environment, translate host `SIGHUP` into structured +cancellation, and install the POSIX observer in both the supervising run and +each pane worker. Node and Bun install neither observer nor grid provider. + +This boundary change preserves the provider name `tmux`, hidden worker verb +`terminal-worker`, worker protocol and authentication, durable behavior and +identities, readiness, close and replay semantics, terminal capability, and +every provider identity. It deliberately changes the authored names, canonical +package and import paths, public grid descriptors and errors, and diagnostics +that identify those authored constructs. Event registrations remain owned by +the Effection scope whose resource they observe and are removed when that scope +settles. + +Both packages are ordinary lockstep-versioned workspace members. The generated +publication graph places grid after durable-streams, grid-tmux and core after +grid, and CLI after grid-tmux, grid, core, and runtime. Workspace, JSR, npm, +compiled-host, and runtime-test discovery treat them like every other +publishable package. + +The final boundary is established by finite evidence: + +1. A static dependency test proves the graph above and the absence of + grid-to-runtime/core/CLI/tmux and grid-tmux-to-runtime/core/CLI edges. +2. A package-boundary test proves the rejected component names, packages, + runtime and core exports, and CLI implementation paths absent; all grid + imports are canonical; and every public contextual descriptor and error + constructor has one definition. +3. Relocated neutral tests retain foreground launch, routing, authority, + readiness, layout, close, cancellation, replay, process observation, and + quiescence without tmux. +4. Core tests retain grammar, structural validation, source diagnostics, pane + scope, durable identities and records, outcomes, and provider-neutral replay + under `` and ``. +5. Grid-tmux tests retain exact authenticated transport, concurrent panes, + sequential reuse, spawn readiness, display isolation, job control, explicit + row-major layout, atomic attach, distinct close signals, SIGHUP, scope-owned + listeners, cancellation phases, and ordered bounded teardown. +6. Cross-package Agent tests retain pane-native physical routing, root launch, + and natural-key Agent session ownership. +7. CLI evidence retains Deno and compiled tmux assembly and hidden-worker POSIX + observation; Node, Bun, non-TTY, and missing-tmux paths install no partial + provider and keep their refusal boundaries. +8. Workspace and release evidence proves both packages discovered and published + in dependency order, JSR and local-sibling npm consumption, compiled binary + and hidden worker, runtime-test discovery, and dependency-state cleanliness. + +Tests use controlled signals and observable settlement for lifecycle success; +elapsed time is not evidence. Focused feedback uses the smallest explicit tests +that discriminate the boundary, while runtime matrices, lint, typecheck, JSR, +and clean composability remain delivery gates. + ### Terminal authority One grid holds the execution's foreground-terminal lease for its whole visible @@ -3493,15 +3610,35 @@ request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. A pane claim grants one interactive terminal at that ordinal, not an Agent -session. Core installs a pane-scoped native launcher that closes over the claim. -`` in that pane consequently reserves, flushes, and launches on -the pane terminal instead of competing for the root lease. Launches in -different panes may run concurrently; two interactive launches in one pane -cannot. Sequential launches in one paired pane remain ordinary composition. -The session coordinator is unchanged and independently authoritative, so two -panes attempting to own the same logical Agent session still contend and one -is refused. The provider starts a self-closing pane's host-configured default -shell under the same kind of pane claim. +session. Core installs a pane-scoped native launcher that closes over the claim, +the composite, and the authored ordinal. `` in that pane +consequently reserves and flushes the pane, then terminates native-launch +routing at this required provider-neutral composite operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The ordinal exists only in core's live closure and never enters the native or +Agent request. Once nearer native-launch middleware delegates, the pane launcher +calls the composite operation instead of the root foreground launcher. Nearer +middleware may still observe, wrap, refuse, or short-circuit the request. A +composite that cannot execute the pane request refuses explicitly; falling +through to the root would put the child on the wrong physical terminal. Root +`` retains its existing foreground-launch route unchanged. + +Launches in different panes may run concurrently; two interactive launches in +one pane cannot. Sequential launches in one paired pane remain ordinary +composition. The session coordinator is unchanged and independently +authoritative, so two panes attempting to own the same logical Agent session +still contend and one is refused. The composite's separate `shell()` operation +starts a self-closing pane's host-configured default shell under the same kind +of pane claim; it remains separate because its executable is live host policy, +not an authored or Agent-provided native launch request. Each claim also closes over one host-owned readiness latch. The pane-scoped native launcher acknowledges it from the runtime's successful child-spawn event @@ -3563,16 +3700,48 @@ The grid runs as one structured scope: 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The composite remains present after all panes settle until the reader closes or leaves it. -7. Closing begins an ordered teardown: prevent new pane launches, cancel live - pane scopes, await every child and finalizer, detach and destroy the exact - provider composite, restore the root terminal, and only then release the - foreground lease and settle the grid. The document never continues while an - observable pane child or provider-owned process can still act through the - grid. +7. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, detach and destroy the exact provider composite, + restore the root terminal, and only then release the foreground lease and + settle the grid. The document never continues while an observable pane child + or provider-owned process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -3608,7 +3777,7 @@ observable ownership mechanism of its own instead of severing all three links. ### Durability and replay -A terminal grid is a core-owned structured durable region. Its layout identity +A grid is a core-owned structured durable region. Its layout identity contains the columns and the ordered pane forms and titles, never a provider or live terminal identifier. Each pane is a deterministic durable child coroutine, so effects in paired content retain and replay under the same rules they use @@ -3623,8 +3792,41 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. -Partial replay first compares the exact authored layout and refuses divergence -before provider work. It rebuilds a fresh provider composite: completed pane +The reader-close handshake makes cancellation during teardown a completed-grid +case rather than a new partial-replay state. When a pane finalizer delays close +and parent cancellation arrives, the first attempt still finishes every pane +and provider finalizer, writes the pane outcomes and completed grid `Close`, and +only then reports cancellation to its parent. A continuation claims that +completed child and resumes after it without recreating the provider or +re-entering pane work. A host loss can still interrupt the unjournaled live +teardown; panes whose `Close` was acknowledged remain complete, while any pane +and grid without a completed record follow the existing partial-replay rules. + +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own @@ -4970,8 +5172,8 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| grid (`` / ``) | replaces the root foreground presentation with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently usable, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease when its content requires terminal presentation, each interactive pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no grid, terminal, or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717 and renamed before delivery by #781; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final extraction into `@executablemd/grid` and `@executablemd/grid-tmux`, with every repository import moved to the canonical packages and all rejected unshipped names and paths deleted | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/bun.lock b/bun.lock index e05f3854a..dd8a872e6 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", "@effectionx/middleware": "0.1.1", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", @@ -26,6 +26,7 @@ "mdast-util-to-string": "^4", "remark": "15", "remend": "^1.2.2", + "semver": "^7.8.5", "unist-util-select": "^5", "zod": "^4.3.6", }, @@ -42,6 +43,7 @@ "@executablemd/testing": "workspace:*", "@executablemd/workflow": "workspace:*", "@types/node": "^22.0.0", + "@types/semver": "^7.7.0", "expect": "^30.0.0", "oxfmt": "^0.41.0", "oxlint": "1.74.0", @@ -53,7 +55,9 @@ "name": "@executablemd/acp", "version": "0.12.1", "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0", @@ -70,6 +74,8 @@ "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", + "@executablemd/grid-tmux": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", @@ -78,6 +84,7 @@ "@standard-schema/spec": "^1.0.0", "configliere": "^0.4.0", "effection": "4.1.0", + "semver": "^7.8.5", "zod": "^4.3.6", }, }, @@ -94,12 +101,13 @@ "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", "@effectionx/middleware": "0.1.1", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", @@ -125,6 +133,29 @@ "effection": "4.1.0", }, }, + "packages/grid": { + "name": "@executablemd/grid", + "version": "0.11.0", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + }, + }, + "packages/grid-tmux": { + "name": "@executablemd/grid-tmux", + "version": "0.11.0", + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/grid": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6", + }, + }, "packages/runtime": { "name": "@executablemd/runtime", "version": "0.12.1", @@ -132,7 +163,7 @@ "@effectionx/context-api": "0.6.0", "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "effection": "4.1.0", }, @@ -142,12 +173,13 @@ "version": "0.12.1", "dependencies": { "@agentclientprotocol/sdk": "1.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", @@ -160,9 +192,11 @@ "name": "@executablemd/test-support", "version": "0.0.0", "dependencies": { + "@effectionx/fs": "0.3.0", "@effectionx/process": "0.8.1", "@effectionx/test-adapter": "0.7.4", "@effectionx/timebox": "0.4.3", + "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0", "expect": "^30.0.0", }, @@ -176,6 +210,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0", }, }, @@ -183,7 +218,7 @@ "name": "@executablemd/web", "version": "0.12.1", "dependencies": { - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -212,7 +247,6 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/process": "0.8.1", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -249,7 +283,7 @@ "@effectionx/middleware": ["@effectionx/middleware@0.1.1", "", {}, "sha512-ss/bZRkt/xzJNE59r8NR1+0K/xQcIyCm0y9n8FYC8jKdFn51SPe3m3t7EfPcK8zkdjCoTOU7k1UpIXRl26asYA=="], - "@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="], + "@effectionx/node": ["@effectionx/node@0.2.5", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-hL8mROda8Lx375MVS+Ubu86+yMht/I0wOZG5VR6Pel0XUA5ReObQDYvNS6ocW0cNFKnEmvlVLWGWbcjJ+VkVhA=="], "@effectionx/process": ["@effectionx/process@0.8.1", "", { "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/node": "0.2.4", "@effectionx/scope-eval": "0.1.3", "cross-spawn": "^7", "ctrlc-windows": "^2", "shellwords-ts": "^3.0.1" }, "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-xyXlFja0Ill80lQ3IYfksXtJkqVmWuUOogRn/qlHWCAGlZj+MGGF8gOFbyzk/3Kx4pj14riVGgF/cyT5XCzqDw=="], @@ -325,6 +359,10 @@ "@executablemd/durable-streams": ["@executablemd/durable-streams@workspace:packages/durable-streams"], + "@executablemd/grid": ["@executablemd/grid@workspace:packages/grid"], + + "@executablemd/grid-tmux": ["@executablemd/grid-tmux@workspace:packages/grid-tmux"], + "@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"], "@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"], @@ -585,6 +623,8 @@ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -937,6 +977,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "2.0.1", "kind-of": "6.0.3" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1049,10 +1091,16 @@ "@durable-streams/state/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], + "@effectionx/process/@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="], + "@executablemd/cli/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@executablemd/durable-streams/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], + "@executablemd/grid/@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="], + + "@executablemd/grid-tmux/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@executablemd/test-agent/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/deno.lock b/deno.lock index 7772922ea..1f47f40b5 100644 --- a/deno.lock +++ b/deno.lock @@ -48,6 +48,7 @@ "npm:@effectionx/fetch@0.2.1": "0.2.1_effection@4.1.0", "npm:@effectionx/fs@0.3.0": "0.3.0_effection@4.1.0", "npm:@effectionx/middleware@0.1.1": "0.1.1", + "npm:@effectionx/node@0.2.4": "0.2.4_effection@4.1.0", "npm:@effectionx/node@0.2.5": "0.2.5_effection@4.1.0", "npm:@effectionx/process@0.8.1": "0.8.1_effection@4.1.0", "npm:@effectionx/scope-eval@0.1.3": "0.1.3_effection@4.1.0", @@ -4117,6 +4118,27 @@ ] } }, + "packages/grid": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/node@0.2.4", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0" + ] + } + }, + "packages/grid-tmux": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0", + "npm:zod@^4.3.6" + ] + } + }, "packages/runtime": { "packageJson": { "dependencies": [ diff --git a/packages/acp/package.json b/packages/acp/package.json index 18bf4c5d8..1d99f8295 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -10,6 +10,7 @@ "dependencies": { "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0" diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba90..b1a3a4d16 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -22,6 +22,7 @@ import { createChannel, + createScope, ensure, Err, Ok, @@ -87,8 +88,8 @@ import { AgentSessionRecoveryRequired, cwd, ExecutableObservationError, - nativeLaunch, } from "@executablemd/runtime"; +import { nativeLaunch } from "@executablemd/grid"; import type { AgentSessionCoordinator, AgentSessionKey, @@ -2756,24 +2757,57 @@ function* useAcpxProviderState( // the reader's terminal while offering no way to reach the owner it // was waiting for. It refuses instead, and the coordinator is what // refuses it. - yield* authority.perform(request, { - prepare: () => - withSessionRoute(context, () => - prepareLaunch(invocation, agentName, callerCwd, request.instructions, placement), - ), - detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), - exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), + // + // The launch runs in a scope of its own so that this owner can bring + // it down deliberately and watch how that goes. A cancelled launch — + // the reader closing a grid is one — unwinds past every + // statement after it, so a decision written down here would never be + // reached; written as this scope's cleanup, it is reached on every + // path there is. + const [running, stop] = createScope(yield* useScope()); + let stopped = false; + + yield* ensure(function* () { + // Registered after the scope exists, so it runs before the scope + // is destroyed on its own: the launch comes down here, and + // `destroy()` carries the outcome of its teardown. A child that + // could not be proven stopped, or a cleanup that failed, throws + // out of it — and is not quiescence, and is still a failure. + try { + yield* until(stop()); + stopped = true; + } finally { + // Everything this owner started has to be finished with the + // session, and that is two facts rather than one: the native + // child and its cleanup settled, and this provider holds no + // handle for the session — a detach that failed, or a session + // prepared and never handed over, leaves one. Either one + // missing leaves the session owned rather than looking + // finished, which is what the next owner is told to recover + // deliberately. + if (stopped && !holding(placement.sessionKey)) { + ownership.quiesced(); + } + } }); - // Only here, and only once this provider is holding nothing. By the - // time `perform` returns the native child has exited and been reaped, - // so what is left to check is the ACP handle: a handoff that released - // it quiesces, and one that could not — a detach that failed, a - // session prepared but never handed over — leaves the session owned - // rather than looking finished. - if (!holding(placement.sessionKey)) { - ownership.quiesced(); - } + yield* running.run(() => + authority.perform(request, { + prepare: () => + withSessionRoute(context, () => + prepareLaunch( + invocation, + agentName, + callerCwd, + request.instructions, + placement, + ), + ), + detach: (prepared) => + detachSession(invocation, prepared, agentCommandOf(placement)), + exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), + }), + ); }, ); } catch (error) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 7c3df4976..6961108fc 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -27,8 +27,10 @@ import type { PreparedLaunchRecord, Session, } from "@executablemd/core"; -import { flushOutput, installControlledLauncher, reserveTerminal } from "@executablemd/runtime"; -import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime"; +import { flushOutput, NativeLauncher, reserveTerminal } from "@executablemd/grid"; +import { installControlledLauncher } from "@executablemd/grid/test"; +import type { AgentSessionCoordinator } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/grid"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; import { @@ -206,6 +208,14 @@ interface ProviderOptions { withSessionRoute?: AcpxProviderDependencies["withSessionRoute"]; /** Blocks the native child until this resolves. */ hold?: Operation; + /** + * Make the launch's own teardown fail, in place of a child that cannot be + * proven stopped. + * + * Composed in front of the launcher rather than replacing it, so what fails + * is the cleanup of a launch that was otherwise ordinary. + */ + cleanupFails?: string; onLaunch?: () => void; exitCode?: number; /** @@ -328,6 +338,20 @@ function* installLaunchStack( outcome: () => ({ exitCode: options.exitCode ?? 0 }), }); + if (options.cleanupFails !== undefined) { + const reason = options.cleanupFails; + yield* NativeLauncher.around({ + *launch([request, spawned], next) { + // Registered inside the launch, so it unwinds with it — and refuses to + // say the child is gone. + yield* ensure(function* () { + throw new Error(reason); + }); + return yield* next(request, spawned); + }, + }); + } + const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: options.store ?? makeStore(), @@ -2518,11 +2542,51 @@ describe("Tier CX — cancellation before ownership ends", () => { ), ), ).toBe(false); - const released = trace.ownership.events.indexOf("released-active"); + const released = trace.ownership.events.indexOf("released-idle"); expect(trace.ownership.events.indexOf("cancelling") < released).toBe(true); - // A launch that stopped on the way never proved the session stopped, so it - // stays owned rather than looking finished. + // An orderly stop that finished is a stop. The child was proven gone, its + // cleanup settled, and this provider held no handle for the session — so + // nothing this owner started can still act on it, which is exactly what + // quiescence acknowledges. Withholding it here would leave a recovery + // tombstone for a cancellation that had already proved everything a normal + // return proves. + expect(trace.ownership.events).toContain("quiesced"); + expect(trace.ownership.events).not.toContain("released-active"); + }); + + it("CX2: a cancellation whose cleanup could not finish stays owned", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const hold = withResolvers(); + const started = withResolvers(); + let halting = ""; + + yield* scoped(function* () { + yield* installLaunchStack(harness, trace, { + routeStore: createMemorySessionRouteStore(), + cleanupFails: "the native child could not be proven stopped", + hold: (function* () { + started.resolve(); + yield* hold.operation; + })(), + }); + + const launching = yield* spawn(() => Agent.operations.launch(launchRequest(INSTRUCTIONS))); + yield* started.operation; + try { + yield* launching.halt(); + } catch (error) { + halting = error instanceof Error ? error.message : String(error); + } + }); + + // The teardown failed, and said so rather than passing quietly. + expect(halting).toContain("could not be proven stopped"); + // So nothing was acknowledged: a cancellation is not evidence on its own, + // and neither is the lease coming back. The session stays owned, and the + // next owner is told to recover it deliberately. expect(trace.ownership.events).not.toContain("quiesced"); + expect(trace.ownership.events).toContain("released-active"); }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 629ad2ea6..b32527c11 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,8 @@ "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", + "@executablemd/grid-tmux": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 4fdb23ab3..64bf428c1 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -21,7 +21,10 @@ import { registerAgentProvider, } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; -import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; +import { env as readEnv } from "@executablemd/runtime"; +import { installForegroundLauncher } from "@executablemd/grid/posix"; +import { unsupportedGrid } from "./grid-host.ts"; +import type { GridInstaller } from "./grid-host.ts"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and @@ -67,6 +70,14 @@ export interface PlanWriterStack { adapters: EmbeddedAdapters; /** What this host states about machine-wide agent sessions, if anything. */ sessions?: MachineSessionAssembly; + /** + * What presents this host's grids. + * + * Deno and the compiled binary supply the tmux provider; Node and Bun supply + * the one that installs none, so those runtimes describe and validate the + * same grids and open none of them. + */ + installGrid?: GridInstaller; } /** Everything one `xmd run` invocation settled about agents, resolved once. */ @@ -106,6 +117,7 @@ export function* resolvePlanWriterStack( export function* resolveAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installGrid?: GridInstaller, ): Operation> { const config = resolveAgentConfig(flags); if ("error" in config) { @@ -118,7 +130,11 @@ export function* resolveAgentStack( if (!planWriter.ok) { return planWriter; } - return Ok({ ...planWriter.value, permissionMode: config.permissionMode }); + return Ok({ + ...planWriter.value, + permissionMode: config.permissionMode, + ...(installGrid === undefined ? {} : { installGrid }), + }); } /** @@ -179,4 +195,8 @@ export function* installRunAgentStack(stack: AgentStack): Operation { // document inspection and `xmd test` install no launcher, so a document that // reaches under any of them refuses instead of spawning. yield* installForegroundLauncher(); + // And whatever presents this host's grids, which on a host that + // presents none still opens the installation so a grid is validated — the + // refusal a document meets there is core's own. + yield* (stack.installGrid ?? unsupportedGrid)(); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index aa70c95b9..463fc1553 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -96,6 +96,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; import { installRunAgentStack, resolveAgentStack, resolvePlanWriterStack } from "./agent-stack.ts"; +import { unsupportedGrid } from "./grid-host.ts"; +import type { GridInstaller } from "./grid-host.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./plan-writer-profile.ts"; import { useVerboseComponent } from "./verbose-component.ts"; @@ -781,8 +783,9 @@ function* underRunDeadline(timeouts: RunTimeouts, body: () => Operation): function* settleAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installGrid: GridInstaller, ): Operation { - const stack = yield* resolveAgentStack(flags, sessions); + const stack = yield* resolveAgentStack(flags, sessions, installGrid); if (!stack.ok) { console.error(stack.error.message); yield* exit(1); @@ -2368,6 +2371,7 @@ function* dispatch( readStandardInput: StandardInputReader, workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, + installGrid: GridInstaller, ): Operation { // Before the props phase, and before the help short-circuit below. `--help` // is lifted out of argv early enough that a command's own grammar never sees @@ -2466,6 +2470,7 @@ function* dispatch( denyAll: config.denyAll, }, sessions, + installGrid, ); if (runStack === undefined) { break; @@ -2836,6 +2841,10 @@ export function* runXmd( // owns the session or which build it belongs to. A caller that names none // gets no machine sessions at all, which is the ordinary ACP behaviour. sessions?: MachineSessionAssembly, + // What presents a grid on this host. Deno and the compiled binary + // supply the tmux provider; Node and Bun supply the one that installs none, + // so those runtimes describe and validate the same grids and open none. + installGrid: GridInstaller = unsupportedGrid, ): Operation { // Before every scanner, before command selection, and before anything reads a // path. `prompt` names no command, and a first token that names none is a @@ -2905,6 +2914,7 @@ export function* runXmd( readStandardInput, workflowHost, sessions, + installGrid, ); } @@ -2927,6 +2937,7 @@ export function* runXmd( readStandardInput, workflowHost, sessions, + installGrid, ), ); } diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 3a94a6b1e..d15c67421 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -19,6 +19,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/grid-tmux"; +import { foregroundGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; @@ -50,7 +52,13 @@ const UPGRADE = compiledUpgradeAssembly({ }); // Before anything public is parsed, and absent from every public surface. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { @@ -93,6 +101,9 @@ if (isCredentialHelperMode(process.argv.slice(2))) { () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundGrid(), ); }); } diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 3bc6b901e..c3ae4e114 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -22,6 +22,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/grid-tmux"; +import { foregroundGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; @@ -66,7 +68,13 @@ const UPGRADE: UpgradeAssembly = { // The internal helper mode runs before anything public is parsed. It is not a // command: it appears in no help and in no public grammar, and a caller who did // not select it gets the ordinary command line unchanged. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { @@ -112,6 +120,9 @@ if (isCredentialHelperMode(process.argv.slice(2))) { () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundGrid(), ); }); } diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts new file mode 100644 index 000000000..05272fad5 --- /dev/null +++ b/packages/cli/src/grid-host.ts @@ -0,0 +1,205 @@ +/** + * Which hosts open a grid, and which only describe one + * (architecture.md §Package ownership). + * + * Host composition, not a terminal implementation — which is why it sits here + * rather than under a `terminal/` path. The domain is + * `@executablemd/grid`'s and the provider is `@executablemd/grid-tmux`'s; + * what this module does is decide, per entrypoint, whether to install them. + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language, catalog and validation and install no operational provider — a + * document that asks for a grid there is refused before a pane starts, rather + * than part-way through one. + * + * That is a fact about the host, so the entrypoint states it rather than this + * module inferring it. `unsupportedGrid` is the honest half of the same + * choice: it installs nothing, and the refusal a document meets is the one core + * already gives when no provider is installed. + */ + +import { ensure, race, resource, withResolvers } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { Execution, installGridProfile } from "@executablemd/core"; +import { command as hostCommand } from "@executablemd/runtime"; +import { installDenoTerminalProcesses } from "@executablemd/grid/posix"; +import { + installTmuxGridProvider, + PANE_WORKER_COMMAND, + TMUX_PROVIDER, +} from "@executablemd/grid-tmux"; +import type { TmuxProviderDependencies } from "@executablemd/grid-tmux"; + +/** How a host installs whatever presents its grids. */ +export type GridInstaller = () => Operation; + +/** + * A host that describes grids and presents none. + * + * Not an error, and not silence either: the installation is opened so a grid is + * still validated, and core's own refusal is what a document meets when it asks + * for one to be shown. + */ +export function* unsupportedGrid(): Operation { + yield* installGridProfile(); +} + +/** + * Make the host's terminal going away cancel the document. + * + * Not a reader close. A reader who detaches has finished with a grid, and the + * grid settles with a reader-close outcome and the document carries on. A + * terminal that is *gone* is not a decision about this grid — it is the run + * losing the thing every part of it was drawing on, so the document is + * cancelled through the ordinary structured path: the grid's whole teardown + * runs, and no following sibling gets to go. + */ +export function useHangupCancellation(hangup: Operation): Operation { + return Execution.around({ + *document([request], next) { + // The result is returned, not swallowed: canonical execution is what + // produces a document result, and a handler that answered with nothing + // would be refused for having returned before one existed. + return yield* underHangup(hangup, () => next(request)); + }, + }); +} + +/** + * Run `body`, and cancel it if the terminal goes away first. + * + * The losing side of the race is cancelled, which is the whole point: the grid + * comes down through the same teardown a reader close uses, and the run stops + * rather than continuing on a terminal it no longer has. + */ +export function underHangup(hangup: Operation, body: () => Operation): Operation { + return (function* (): Operation { + const outcome = yield* race([ + (function* (): Operation<{ done: true; value: T }> { + return { done: true, value: yield* body() }; + })(), + (function* (): Operation<{ done: false }> { + yield* hangup; + return { done: false }; + })(), + ]); + if (!outcome.done) { + throw new TerminalLost(); + } + return outcome.value; + })(); +} + +/** The host's terminal went away while the document was still running. */ +export class TerminalLost extends Error { + override name = "TerminalLost"; + constructor() { + super( + "this run's terminal went away, so the document was stopped. Anything it " + + "had shown is gone with the terminal; nothing after the point it stopped ran.", + ); + } +} + +/** + * The environment every process in the topology receives. + * + * Named rather than inherited wholesale: a pane's child gets what a terminal + * program needs and nothing this process happens to be carrying. + * + * It is a host decision, so it is made here rather than by the provider. The + * adapter is handed an environment and passes exactly that along; which of + * *this* invocation's variables are worth passing is a question only the + * entrypoint composing the host can answer. + */ +function paneEnvironment(source: Record): Record { + const env: Record = {}; + for (const name of [ + "PATH", + "HOME", + "SHELL", + "LANG", + "TMPDIR", + "USER", + "LOGNAME", + // What a terminal program reads to decide it may use 24-bit colour. + // Passed through when this host has it, absent when it does not: naming a + // capability the reader's terminal lacks is worse than leaving a program + // on the 256 colours `TERM` already promises. It is named here because a + // pane's direct child reads none of the reader's shell startup — a + // variable their `.zshrc` exports reaches an interactive shell in a pane + // and nothing else, which is exactly the difference this closes. + "COLORTERM", + ]) { + const value = source[name]; + if (value !== undefined && value !== "") { + env[name] = value; + } + } + env.TERM = source.TERM ?? "xterm-256color"; + return env; +} + +/** The terminal this run is drawing on, as tmux needs to know it. */ +function windowSize(): { columns: number; rows: number } { + // A terminal that cannot say gets the sizes tmux itself defaults to, which is + // better than a grid that refuses to lay out at all. + return { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }; +} + +/** + * Settle when this process's terminal goes away. + * + * SIGHUP is the terminal saying it is gone. What follows is the ordinary + * structured cancellation a reader's close would cause — the grid comes down + * the same way, through the same teardown, rather than through a second path + * that would have to be kept honest separately. + * + * Registered as a resource so the handler is removed with the run: a listener + * that outlived its grid would answer for a terminal the next one is using. + */ +export function useHangup(): Operation> { + return resource>(function* (provide) { + const hung = withResolvers(); + const onHangup = (): void => hung.resolve(); + process.on("SIGHUP", onHangup); + yield* ensure(() => { + // Removed with the run that installed it. A listener that outlived its + // grid would answer for a terminal the next one is using. + process.off("SIGHUP", onHangup); + }); + yield* provide(hung.operation); + }); +} + +/** + * Install the tmux provider for a foreground host. + * + * `workerCommand` is how this host re-invokes itself for one pane. Reusing the + * executable is what makes a pane work in the compiled distribution, where + * there is no script to run. + */ +export function foregroundGrid(overrides: Partial = {}): GridInstaller { + return function* (): Operation { + const hangup = yield* useHangup(); + // The observer goes in beside the provider, in the same scope: a host that + // presents grids is exactly the host that has to prove a pane is free, and + // one that installs neither refuses rather than guessing at either. + yield* installDenoTerminalProcesses(); + yield* installTmuxGridProvider({ + isTerminal: () => process.stdout.isTTY === true, + env: paneEnvironment(process.env), + workerCommand: (ordinal, directory) => + hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]), + size: windowSize, + ...overrides, + }); + yield* installGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER }); + yield* useHangupCancellation(hangup); + }; +} diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 46464641c..78c1c4bf4 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -28,8 +28,8 @@ import { API, createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, - installControlledLauncher, } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/grid/test"; import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, @@ -39,7 +39,8 @@ import { createMemorySessionRouteStore, } from "@executablemd/acp"; import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; -import type { ExecutableObserver, NativeLaunchRequest } from "@executablemd/runtime"; +import type { ExecutableObserver } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/grid"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, diff --git a/packages/cli/tests/grid-host.test.ts b/packages/cli/tests/grid-host.test.ts new file mode 100644 index 000000000..77dbc912b --- /dev/null +++ b/packages/cli/tests/grid-host.test.ts @@ -0,0 +1,586 @@ +/** + * Tier TH — which hosts open a grid, and which only describe one + * (architecture.md §Package ownership, issue #717). + * + * The host-composition boundary is CLI's, so its evidence is too. The tmux + * adapter's own topology, protocol, worker and teardown rows live with the + * adapter in `@executablemd/grid-tmux`; what is proved here is the part + * only an entrypoint can answer — which runtime installs a provider and an + * observer, which installs neither, what a real document gets in each case, + * and that a terminal going away cancels the run rather than closing the grid. + * + * The fake tmux server and its client fixtures are imported from the adapter's + * own tests. That is a test-only path: it creates no package dependency, and + * the production graph CLI declares is unchanged by it. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, Ok, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import type { ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import process from "node:process"; +import { chmod, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { cliCommand } from "@executablemd/test-support/launch"; +import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { execute } from "@executablemd/core"; +import { installGridProvider, useGridInstallation } from "@executablemd/grid/lifecycle"; +import type { Json } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { registerGridProvider, Grids } from "@executablemd/grid"; +import { installControlledLauncher } from "@executablemd/grid/test"; +import { processReachable } from "@executablemd/grid/processes"; +import { installDenoTerminalProcesses } from "@executablemd/grid/posix"; +import { PANE_WORKER_COMMAND, tmuxGridProvider } from "@executablemd/grid-tmux"; +import { foregroundSignalListeners } from "@executablemd/grid-tmux/test"; +import { createFakeTmux } from "../../grid-tmux/tests/fixtures/fake-tmux.ts"; +import { clientCommand } from "../../grid-tmux/tests/fixtures/client-command.ts"; +import { foregroundGrid, unsupportedGrid } from "../src/grid-host.ts"; + +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useGridInstallation(); + yield* registerGridProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installGridProvider("tmux", { label: "tmux" }, authority); + yield* Grids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + [ + "#!/bin/sh", + // Its own environment, before anything else. A plain script sources no + // startup file, so what this records is what the pane handed it rather + // than what a `.zshrc` added afterwards. + `env > "${room}/shell-env"`, + `echo $$ > "${room}/shell-pid"`, + "while true; do sleep 0.05; done", + "", + ].join("\n"), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** One entrypoint's source, for the rows about what a host assembles. */ +function entrypointSource(name: string): Operation { + return readTextFile(path.resolve("packages/cli/src", name)); +} + +describe("Tier TH — host installation", () => { + it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { + // The document-level end of the same claim: a grid whose teardown could not + // establish the terminal was given back is a failed run, not a run with a + // warning in it. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + // The server refuses to be killed the first time it is asked, so the last + // phase of the teardown cannot establish it is gone. + const tmux = createFakeTmux({ + script, + clientCommand, + spawnPanes: true, + failOnce: { command: "kill-server", message: "refused" }, + }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", "", "AFTER_THE_GRID", ""].join( + "\n", + ), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + expect(outcome?.ok).toBe(false); + const refusal = outcome?.ok === false ? String(outcome.error) : ""; + expect(refusal).toContain("terminal server"); + // Nothing private in it, and nothing after the grid ran. + expect(refusal).not.toContain(room); + expect(output).not.toContain("AFTER_THE_GRID"); + }); + + it("TH1: without a terminal, a grid refuses before anything exists", function* () { + const before = yield* until(readdir(tmpdir())); + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ isTerminal: () => false }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + expect(refusal).toContain("cannot open a grid"); + expect(refusal).toContain("no terminal"); + // Before a directory, a socket, a token, a worker, a server or a pane: the + // host left nothing behind for having tried. + const after = yield* until(readdir(tmpdir())); + expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( + before.filter((name) => name.startsWith("xmd-grid-")).length, + ); + }); + + it("TH2: without a usable tmux, a grid refuses the same way", function* () { + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ + isTerminal: () => true, + // A tmux far too old for an explicit layout string. + version: "tmux 1.8", + }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot open a grid"); + expect(refusal).toContain("older than tmux"); + }); + + it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", "", "AFTER_THE_GRID", ""].join( + "\n", + ), + ); + // The run's foreground lease, which a grid takes before any provider. + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + const sighupBefore = foregroundSignalListeners("SIGHUP"); + let directory = ""; + let installed = 0; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + // The listener is the installer's, and this row uses that one. + installed = foregroundSignalListeners("SIGHUP"); + + yield* spawn(function* () { + // Driven by the pane child's own start: the worker spawned, its channel + // authenticated, and the shell it launched said so. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + process.kill(process.pid, "SIGHUP"); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The installer put its listener on, and took it off with the run. + expect(installed).toBe(sighupBefore + 1); + expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); + + // Cancellation, not a reader close: the run failed and nothing after the + // grid ran in that attempt. + expect(outcome?.ok).toBe(false); + expect(output).not.toContain("AFTER_THE_GRID"); + + // Every teardown phase completed before the result was observed. The pane's + // child is gone, the worker is gone, the server is gone, and the private + // directory — which is removed last, after its sockets have closed — is + // gone with them. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + // Awaited on each process's own exit event, not sampled: a worker that had + // not quite gone yet would make a sampled check pass or fail by timing. + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { + // The same host, the same document and the same live grid as TH4. What + // differs is the ending: the reader leaves rather than the terminal going + // away, so the grid settles and the document carries on — which is the + // branch `useHangupCancellation()` has to hand the result back through. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", "", "AFTER_THE_GRID", ""].join( + "\n", + ), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let directory = ""; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + // Driven by the grid's own progress: the pane child started, and the + // server has a reader's client to report the detach of. No SIGHUP. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The exact result, handed back through the hangup wrapper rather than + // swallowed by it: a handler that answered with nothing would be refused + // for having returned before the document produced a result. + expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); + // The reader closed the grid; the document went on. + expect(output).toContain("AFTER_THE_GRID"); + + // And it went on over a grid that had actually been taken down: the pane's + // child, the workers, the server and the private directory are all gone. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { + for (const name of ["deno.ts", "compiled.ts"]) { + expect((yield* entrypointSource(name)).includes("foregroundGrid()")).toBe(true); + } + for (const name of ["node.ts", "bun.ts"]) { + // Not a different grid: no grid at all, and therefore the default the + // shared entry declares — which is the installation that validates a grid + // and presents none. + expect((yield* entrypointSource(name)).includes("foregroundGrid")).toBe(false); + } + expect(yield* entrypointSource("cli.ts")).toContain( + "installGrid: GridInstaller = unsupportedGrid", + ); + }); + + it("TH7: a pane's child is told the terminal's colour depth without a shell startup", function* () { + // The reported defect: an agent launched into a pane was colourless while + // the same program run by hand in the grid's Shell pane had colour. By + // hand it had colour because an interactive shell sources the reader's + // startup files, and theirs export `COLORTERM`. A pane's direct child + // sources nothing, so what it knows about the terminal is only what the + // host hands it — and `COLORTERM` was not in that list. + // + // Deliberately without an `env` override, so `paneEnvironment()` is what + // builds the environment. The shell here is a plain script: it records what + // it was given before doing anything, so nothing a startup file might add + // can be mistaken for what the pane provided. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + + const hadColor = process.env.COLORTERM; + const hadShell = process.env.SHELL; + process.env.COLORTERM = "truecolor"; + process.env.SHELL = shell; + yield* ensure(() => { + if (hadColor === undefined) { + delete process.env.COLORTERM; + } else { + process.env.COLORTERM = hadColor; + } + if (hadShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = hadShell; + } + }); + + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", ""].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + yield* scoped(function* () { + yield* foregroundGrid({ + isTerminal: () => true, + createTmux: () => tmux, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + yield* execution; + }); + + const given = yield* readTextFile(`${room}/shell-env`); + // What the terminal is, and how much of it the child may use. + expect(given).toContain("TERM="); + expect(given).toContain("COLORTERM=truecolor"); + }); + + it("TH3: a host that installs no provider still validates the grid", function* () { + // Node and Bun: the same language and the same validation, and core's own + // refusal rather than a provider that half-works. + yield* unsupportedGrid(); + let refusal = ""; + try { + yield* Grids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("no grid provider is installed"); + }); +}); diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5d512e000..4ff8190b8 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -21,7 +21,8 @@ import { exists, readTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import process from "node:process"; -import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NativeLauncher } from "@executablemd/grid"; import { InMemoryStream } from "@executablemd/durable-streams"; import { Agent, diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts index 5909b4f2b..c1ce66292 100644 --- a/packages/cli/tests/session-launch-cli.test.ts +++ b/packages/cli/tests/session-launch-cli.test.ts @@ -121,6 +121,20 @@ const ROLES = [ "", ].join("\n"); +/** One authored grid, whose pane content must never run without a provider. */ +const GRID = [ + "", + '', + "PANE_MARKER", + "", + '', + "", + "", +].join("\n"); + +/** A grid the grammar refuses, wherever it is written. */ +const BAD_GRID = ["", '', "", ""].join("\n"); + const NO_LAUNCH = "PLAIN_MARKER\n\nThis document launches nothing.\n"; describe( @@ -183,6 +197,55 @@ describe( expect(result.stdout).toContain("PLAIN_MARKER"); }); + it("CL6: a piped run refuses a grid before any pane starts", function* () { + // `xmd run` under a pipe has no terminal to divide. The grid takes the + // run's foreground lease before it contacts a provider, so it is refused + // there — before a private directory, a socket, a token, a worker, a + // server or a pane exists. + // + // The wording is the foreground launcher's, and it names + // `` even though this document writes none. Recorded as + // it is rather than asserted around: it is the diagnostic a reader + // actually gets. + const result = yield* useFixture({ "grid.md": GRID }, function* (fixture) { + return yield* runCli(["run", "grid.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + // Refused at the foreground lease, which a grid takes before it contacts + // any provider — so this run stopped earlier than the tmux prerequisites, + // and earlier still than a pane. + expect(reported).toContain("needs a terminal"); + // The pane's own content never ran. + expect(reported).not.toContain("PANE_MARKER"); + // And nothing tmux-shaped reaches the reader. + for (const leak of ["tmux -", "socket", "%0", "kill-server"]) { + expect(`${leak}: ${reported.includes(leak)}`).toBe(`${leak}: false`); + } + }); + + it("CL7: the syntax is still catalogued where no grid can open", function* () { + // Node and Bun keep the language and the validation. A grid whose layout + // is wrong is refused as a *grammar* failure wherever it is written, and + // that refusal is not the provider's. + const result = yield* useFixture({ "bad.md": BAD_GRID }, function* (fixture) { + return yield* runCli(["run", "bad.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + // The concrete structural refusal, named and located — not merely the + // absence of a provider message, which an unrelated failure would also + // satisfy. + expect(reported).toContain(' requires a "columns" prop'); + expect(reported).toContain("bad.md:1:1"); + // And it is the grammar's refusal, reached wherever the document is read + // rather than at a provider. + expect(reported).not.toContain("cannot open a grid"); + expect(reported).not.toContain("no grid provider is installed"); + }); + it("CL5: no behavior is keyed to the filename", function* () { const result = yield* useFixture({ "roles/team.md": ROLES }, function* (fixture) { return yield* runCli(["run", "roles/team.md#Architect", "--raw"], env(fixture)).join(); diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 24a997908..80dba87a9 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -301,6 +301,33 @@ describe("Tier SX — the run profile the command describes", () => { ]); }); + it("TG3: describes both grid constructs without probing for a terminal", function* () { + // Whatever this runtime can or cannot open, the language is the same, so + // the one boundary a capability probe would cross is a trap here. + const catalog = yield* scoped(function* () { + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options]): Operation { + throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); + }, + }); + return yield* syntaxSymbols([]); + }); + const [structural, builtIn] = catalog.categories; + + const grid = structural.entries.find((entry) => entry.name === "Grid"); + const pane = structural.entries.find((entry) => entry.name === "Pane"); + expect(grid?.origin).toEqual({ kind: "structural", construct: "Grid" }); + expect(pane?.origin).toEqual({ kind: "structural", construct: "Pane" }); + expect(grid?.syntax).toEqual([""]); + expect(pane?.syntax).toEqual(['', '']); + expect(grid?.description ?? "").not.toBe(""); + expect(pane?.description ?? "").not.toBe(""); + // Reserved syntax, so neither name is a component this profile offers. + expect(names(builtIn.entries)).not.toContain("Grid"); + expect(names(builtIn.entries)).not.toContain("Pane"); + }); + it("SX3: describes without minting an execution claimant", function* () { const catalog = yield* syntaxSymbols([]); const session = catalog.categories[1].entries.find((entry) => entry.name === "Session"); @@ -539,6 +566,22 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("TG3: prints both grid constructs, in markdown and in JSON", function* () { + yield* useWorkspace(WORKSPACE, function* (cwd) { + const markdown = yield* runCli(["syntax"], { cwd }).expect(); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain(""); + expect(markdown.stdout).toContain(''); + expect(markdown.stdout).toContain(''); + + const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); + const structural = parseSymbols(json.stdout).categories[0].entries; + expect(names(structural)).toContain("Grid"); + expect(names(structural)).toContain("Pane"); + }); + }); + it("SX12: succeeds with the defaults in a package tree full of directory links", function* () { yield* useWorkspace( { diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..4bac8e2c1 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,12 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; +// The terminal domain is `@executablemd/grid`'s, and a caller names it +// directly (DEC-016). What core exports here is only what core owns: the +// profile that composes a grid into an `Execution`, adapting the terminal +// lifecycle to this engine's journal descriptions and installation. +export { installGridProfile } from "./src/grid/profile.ts"; +export type { GridProfileOptions } from "./src/grid/profile.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/package.json b/packages/core/package.json index 5962c603a..dd232bd81 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "@effectionx/stream-helpers": "0.8.3", "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 043d02b93..3a3389e85 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -23,7 +23,8 @@ import { sessionPlacement } from "./session-request.ts"; import type { ComponentInvocation, FunctionComponent } from "../types.ts"; import type { IdentityClaimant } from "../invocation-identity.ts"; -import { cwd, flushOutput, parseDuration, reserveTerminal } from "@executablemd/runtime"; +import { cwd, parseDuration } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/grid"; import type { Json, PropsSchema } from "../types.ts"; import type { Expansion } from "../expansion.ts"; import { Agent } from "./agent-api.ts"; diff --git a/packages/core/src/agent/launch-owner.ts b/packages/core/src/agent/launch-owner.ts index 7c7e85285..200741679 100644 --- a/packages/core/src/agent/launch-owner.ts +++ b/packages/core/src/agent/launch-owner.ts @@ -16,7 +16,8 @@ import { createApi } from "@effectionx/context-api"; import { scoped } from "effection"; import type { Operation, Stream } from "effection"; -import { cwd, flushOutput, reserveTerminal } from "@executablemd/runtime"; +import { cwd } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/grid"; import { Agent, AGENT_API } from "./agent-api.ts"; import type { AgentApi, diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 15350db98..d24bad55b 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -74,7 +74,9 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayPaneMessage, switchStructure, + gridStructure, } from "./structural-rules.ts"; import type { StructuralViolation } from "./structural-rules.ts"; import type { @@ -314,6 +316,8 @@ interface LexicalContext { readonly insideIf: boolean; /** Whether a `` in this source lexically encloses this point. */ readonly insideSwitch: boolean; + /** Whether a `` in this source lexically encloses this point. */ + readonly insideGrid: boolean; /** Whether the immediate parent is an ``. */ readonly underAnswers: boolean; } @@ -492,6 +496,7 @@ class ValidationState { insideLoop: false, insideIf: false, insideSwitch: false, + insideGrid: false, underAnswers: false, }); } @@ -1102,6 +1107,24 @@ class ValidationState { return context.insideSwitch ? [] : [{ code: "structural-usage-invalid", source: "Case", message: strayCaseMessage() }]; + case "Grid": + // The whole layout is decided from source, so every pane's own mistake + // is reported where it was written — and so is a construct written + // below the grid that the grid does not lay out. + return gridStructure(segment).violations; + case "Pane": + // A well-placed `` is its grid's, and one placed wrongly + // under a grid is already reported by that grid's own structure. What + // is left is a pane with no grid above it at all. + return context.insideGrid + ? [] + : [ + { + code: "structural-usage-invalid", + source: "Pane", + message: strayPaneMessage(), + }, + ]; case "Else": // A well-placed `` is its ``'s, and one placed wrongly under // an `` is already reported by that ``'s own structure. What is @@ -1272,6 +1295,7 @@ function childContext(segment: ComponentElement, context: LexicalContext): Lexic insideLoop: context.insideLoop || segment.name === "Loop", insideIf: context.insideIf || segment.name === "If", insideSwitch: context.insideSwitch || segment.name === "Switch", + insideGrid: context.insideGrid || segment.name === "Grid", underAnswers: segment.name === "Answers", }; } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 7e35cf22c..5d8ebb366 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,7 +13,7 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, scoped, useScope, withResolvers } from "effection"; +import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import type { FunctionComponent, @@ -57,9 +57,19 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayPaneMessage, switchStructure, + gridColumns, + gridColumnsMissingMessage, + gridStructure, + paneTitle, + paneTitleMissingMessage, } from "./structural-rules.ts"; -import type { StructuralViolation, SwitchCase } from "./structural-rules.ts"; +import type { StructuralViolation, SwitchCase, Pane } from "./structural-rules.ts"; +import { durableGrid, openGrid, gridLayout, toRequest } from "@executablemd/grid/lifecycle"; +import type { PaneWork, PlacedPane } from "@executablemd/grid/lifecycle"; +import { usePaneNativeLauncher, usePaneTerminal } from "@executablemd/grid"; +import { recordGridLayout } from "./grid/journal.ts"; import { asBindingViolation, asExpressionViolation, @@ -135,7 +145,7 @@ import { import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; -import { liveEnvironment } from "./live-env.ts"; +import { derivedEnvironment, liveEnvironment } from "./live-env.ts"; import { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { TestHarnessBinding } from "./test-harness.ts"; @@ -1174,6 +1184,35 @@ function* expandListSegments( break; } + if (segment.name === "Grid") { + // No raise() here, like the branches above: expandGrid + // reports every error it creates. + yield* expandGrid(segment, result, { + parentMeta, + parentProps, + hideSet, + path: elementPath, + checkedFailures, + authority, + }); + break; + } + + if (segment.name === "Pane") { + // A well-placed is consumed by its and + // never expanded on its own. Reaching this branch means the pane sits + // outside every grid, so it names no component and is diagnosed + // rather than resolved from the filesystem. + result.push( + yield* raise({ + type: "error", + message: positioned(strayPaneMessage(), segment), + source: "Pane", + }), + ); + break; + } + if (segment.name === "Break") { result.push(...(yield* expandBreak(segment, loop))); break; @@ -2029,6 +2068,235 @@ function* expandSwitch( ); } +function gridError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Grid" }; +} + +function paneError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Pane" }; +} + +/** + * The value one prop of a grid construct produced, or why evaluating + * it failed. A missing prop is `undefined`, which is also what an expression + * evaluating to `undefined` leaves behind (§6.5) — absence either way, and the + * caller says what its construct requires instead. + */ +function* resolveStructuralProp( + segment: ComponentElement, + construct: string, + prop: string, +): Operation> { + const expression = segment.expressions[prop]; + if (expression === undefined) { + return Ok(segment.props[prop]); + } + try { + const resolved = yield* resolveExpressionProps( + {}, + { [prop]: expression }, + construct, + segment.projectedEnv, + ); + return Ok(resolved[prop]); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +/** + * Open the grid the author wrote (spec §6.21). + * + * The whole layout is decided before anything opens: the panes and their forms + * from source, then `columns` and each pane's `title` from the values the + * document computes. Only once the concrete grid is complete is a terminal + * provider anything's business — and this build has none, so the grid refuses + * there. Nothing beneath a pane has expanded and no shell has started when it + * does, which is what makes the refusal a closed one rather than a partial grid + * left behind. + */ +/** Everything a pane's own content needs to expand where the grid was written. */ +interface GridSite { + readonly parentMeta: Record; + readonly parentProps: Record; + readonly hideSet: Set; + readonly path: string; + readonly checkedFailures: CheckedFailures | undefined; + readonly authority: ExpansionAuthority | undefined; +} + +function* expandGrid(segment: ComponentElement, owner: Segment[], site: GridSite): Operation { + const structure = gridStructure(segment); + if (structure.violations.length > 0) { + for (const violation of structure.violations) { + owner.push(yield* raise(structuralErrorSegment(violation, segment))); + } + return; + } + + const columnsValue = yield* resolveStructuralProp(segment, "Grid", "columns"); + if (!columnsValue.ok) { + owner.push(yield* raise(gridError(segment, columnsValue.error.message))); + return; + } + if (columnsValue.value === undefined) { + owner.push(yield* raise(gridError(segment, gridColumnsMissingMessage()))); + return; + } + const columns = gridColumns(columnsValue.value); + if (!columns.ok) { + owner.push(yield* raise(gridError(segment, columns.error.message))); + return; + } + + const placed: PlacedPane[] = []; + for (const pane of structure.panes) { + const title = yield* resolvePaneTitle(pane); + if (!title.ok) { + owner.push(yield* raise(paneError(pane.element, title.error.message))); + return; + } + placed.push({ title: title.value, form: pane.form }); + } + + const layout = gridLayout(columns.value, placed); + // The grid renders nothing into the document: what a pane shows belongs to + // that pane, and the sibling after `` renders to the root + // again only once the provider has restored it. + const identity = { + path: site.path, + ...(segment.position === undefined ? {} : { position: segment.position }), + }; + + try { + // Recorded in this coroutine, before the lease and before any provider is + // contacted: a resumed run whose grid changed is refused while nothing has + // been opened. It cannot live inside the grid child, because a completed + // child never runs. + yield* recordGridLayout(identity, toRequest(layout)); + + const retained = yield* durableGrid(function* (boundary) { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openGrid(layout, work, boundary); + }); + + const failed = retained.panes.find((pane) => pane.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(gridError(segment, failed.reason))); + } + } catch (error) { + owner.push( + yield* raise(gridError(segment, error instanceof Error ? error.message : String(error))), + ); + } +} + +/** + * What one authored pane does once the grid has minted its claim. + * + * A self-closing pane runs the host's default shell through its claim. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork(pane: Pane, title: string, site: GridSite): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(claim, composite) { + const outcome = yield* claim.admit(() => + composite.shell(pane.ordinal, () => claim.ready()), + ); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + }, + }; + } + + return { + ordinal: pane.ordinal, + *run(claim, composite) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(claim); + const shown: Segment[] = []; + // What this pane has rendered and not yet shown. A native UI is about + // to draw over the pane, so the same rule the root flush follows holds + // here: everything the pane has said reaches the reader first. + const flushPane = function* (): Operation { + const pending = renderSegments(shown); + shown.length = 0; + if (pending.length > 0) { + yield* composite.display(pane.ordinal, pending); + } + }; + // A `` written in this pane finds this launcher simply + // by being here: it reserves and flushes this pane instead of competing + // for the run's one foreground lease, and the child it starts is what + // makes this pane ready. + yield* usePaneNativeLauncher(claim, flushPane, (request, spawned) => + composite.launch(pane.ordinal, request, spawned), + ); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this pane makes is visible to later work in this pane and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + // A counter of its own. Panes expand concurrently, and a shared + // mutable counter would hand two of them block identities that depend + // on which happened to run first. + createBlockCounter(), + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The pane's own ledger: a checked failure settles this pane and + // cannot reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a pane cannot claim + // one outside the grid. + undefined, + ); + yield* flushPane(); + }); + }, + }; +} + +/** The label one pane displays, from the value its own `title` prop produced. */ +function* resolvePaneTitle(pane: Pane): Operation> { + const value = yield* resolveStructuralProp(pane.element, "Pane", "title"); + if (!value.ok) { + return value; + } + if (value.value === undefined) { + return Err(new Error(paneTitleMissingMessage())); + } + return paneTitle(value.value); +} + function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/src/grid/journal.ts b/packages/core/src/grid/journal.ts new file mode 100644 index 000000000..0a12af6ac --- /dev/null +++ b/packages/core/src/grid/journal.ts @@ -0,0 +1,207 @@ +/** + * Which grid a run opened, and how a resumed run is held to it + * (spec §6.21 Durability and replay). + * + * One entry, appended in the **parent** coroutine and **before** the foreground + * lease is taken or any provider is contacted: the columns and rows, and the + * ordered pane forms, titles and positions. A resumed run compares what it + * derived against what is held and refuses a document whose grid changed while + * nothing has been opened and nothing has started. + * + * It sits in the parent deliberately. The grid itself is a durable child, and a + * completed child short-circuits without running — so a comparison written + * inside it would never happen on the run that most needs it. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or pane identifier, no argv or environment, and no terminal byte is + * written here: none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + DurableContext, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { GridRequest } from "@executablemd/grid"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; +import { retainedLayout } from "@executablemd/grid/lifecycle"; +import type { RetainedGrid } from "@executablemd/grid/lifecycle"; + +/** A grid's identity within one execution: where it was written. */ +export interface GridIdentity { + /** The structural path that reached this element (§5.6). */ + readonly path: string; + readonly position?: Readonly; +} + +type RetainedLayout = RetainedGrid["layout"]; + +function describe(identity: GridIdentity): EffectDescription { + return { + type: "terminal_grid_layout", + name: `terminal_grid:${identity.path}:layout`, + ...sourceDescription(identity.position), + }; +} + +/** Whether this expansion has a journal to read and append to at all. */ +function* durable(): Operation { + return (yield* DurableContext.get()) !== undefined; +} + +/** + * Append one entry and return what the entry holds. + * + * Live it is the value passed in; on replay it is the value the journal already + * held, which is the only way a caller tells the two apart. + */ +function* append(description: EffectDescription, value: Json): Workflow { + return yield createDurableOperation(description, function* () { + return value; + }); +} + +/** + * The layout a journal entry holds, parsed member by member. + * + * Total: every field is read and checked, and anything the record does not say + * exactly — a missing member, a member of the wrong kind, an extra one, a pane + * whose ordinal is not its position, a row or column that does not follow from + * the columns it claims — makes the record unreadable rather than half-read. A + * layout is what a resumed run is held to, so a record that cannot be believed + * in full must not be believed in part. + */ +function readLayout(value: unknown): RetainedLayout | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { + return undefined; + } + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.panes; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const panes: RetainedLayout["panes"] = []; + for (const [index, entry] of list.entries()) { + const pane = readPane(entry, index, columns); + if (pane === undefined) { + return undefined; + } + panes.push(pane); + } + // The rows a grid claims have to be the rows its panes need, or the record + // describes a grid nothing could have derived. + if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { + return undefined; + } + return { columns, rows, panes }; +} + +/** One retained pane, checked against the position it claims to occupy. */ +function readPane( + value: unknown, + index: number, + columns: number, +): RetainedLayout["panes"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) { + return undefined; + } + const { ordinal, title, form, row, column } = record; + if (ordinal !== index) { + return undefined; + } + if (typeof title !== "string" || title.length === 0) { + return undefined; + } + if (form !== "paired" && form !== "self-closing") { + return undefined; + } + // Derived, not asserted: a position that does not follow from the ordinal and + // the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { ordinal, title, form, row, column }; +} + +/** The members of a JSON object, or `undefined` for anything else. */ +function members(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +/** Whether a record carries exactly these member names, and no others. */ +function onlyNames(record: Record, names: readonly string[]): boolean { + const present = Object.keys(record); + return present.length === names.length && names.every((name) => name in record); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +/** How two layouts differ, in the words an author can act on. */ +function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.panes.length !== derived.panes.length) { + return `${held.panes.length} panes rather than ${derived.panes.length}`; + } + for (const [index, pane] of derived.panes.entries()) { + const before = held.panes[index]!; + if (before.title !== pane.title) { + return `pane ${index} titled "${before.title}" rather than "${pane.title}"`; + } + if (before.form !== pane.form) { + return `pane ${index} written ${before.form} rather than ${pane.form}`; + } + if (before.row !== pane.row || before.column !== pane.column) { + return ( + `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${pane.row}, column ${pane.column}` + ); + } + } + return undefined; +} + +/** + * Record which grid this is, and refuse a resumed run whose grid changed. + * + * Expansion driven without a journal records nothing and behaves identically. + */ +export function* recordGridLayout(identity: GridIdentity, request: GridRequest): Operation { + if (!(yield* durable())) { + return; + } + const derived = retainedLayout(request); + const description = describe(identity); + const stored = yield* append(description, derived); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a grid layout. Re-run the ` + + "document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, derived); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this grid as a grid with ${changed}. A grid whose layout ` + + "changed cannot be replayed onto this run. Re-run the document from the start rather " + + "than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } +} diff --git a/packages/core/src/grid/profile.ts b/packages/core/src/grid/profile.ts new file mode 100644 index 000000000..106481b8a --- /dev/null +++ b/packages/core/src/grid/profile.ts @@ -0,0 +1,57 @@ +/** + * Opening one grid installation for a live document. + * + * A grid needs two things before it can be durable at all: this execution's + * installation — which owns the generation every request belongs to and the + * registry of the grids it issued — and a provider installed against the + * authority that installation mints. A grid outside one refuses rather than + * presenting something no replay could resume. + * + * The installation's lifetime has to surround authored work and end while the + * journal is still live, which is what `Execution.document` is. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Execution } from "../execute.ts"; +import { installGridProvider, useGridInstallation } from "@executablemd/grid/lifecycle"; + +export interface GridProfileOptions { + /** + * The registered provider to install for this execution. + * + * Omitted, the installation is opened and no provider is installed — which is + * a host that validates and inspects grids but cannot present one, and + * refuses when a document asks for one. + */ + readonly provider?: string; + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label?: string; +} + +/** + * Install the grid profile for the executions composed under it. + * + * The authority reaches the named provider's factory and nothing else: it is + * delivered through the installation handshake rather than published, so a + * handler that answers the install request itself installs no provider and the + * document is told so. + */ +export function installGridProfile(options: GridProfileOptions = {}): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const authority = yield* useGridInstallation(); + if (options.provider !== undefined) { + yield* installGridProvider( + options.provider, + { label: options.label ?? options.provider }, + authority, + ); + } + yield* next(request); + }); + }, + }); +} diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts index e343ad872..4a7895ad9 100644 --- a/packages/core/src/structural-rules.ts +++ b/packages/core/src/structural-rules.ts @@ -1034,3 +1034,226 @@ export function answerViolations(segment: ComponentElement): StructuralViolation } return found; } + +const TERMINAL_GRID_PROPS = new Set(["columns"]); +const TERMINAL_PROPS = new Set(["title"]); + +/** What a `` written outside the grid that lays it out says. */ +export function strayPaneMessage(): string { + return ( + " must be a direct child of . is reserved: it never " + + "resolves a component, and only the grid it belongs to can place it." + ); +} + +/** What a `` written inside another grid says. */ +export function nestedGridMessage(): string { + return ( + " cannot be written inside another . A grid lays out the " + + "panes it is written with, so one pane cannot become a grid of its own." + ); +} + +/** + * How many columns a grid lays its panes across, or why `columns` rejects it. + * + * The same rule wherever the value came from: a literal is checked while the + * document is only being read, and an expression's answer is checked here too + * once expansion has evaluated it. + */ +export function gridColumns(columns: Json): Result { + if (typeof columns !== "number") { + return Err( + new Error(`Prop "columns" on must be a positive integer, not ${jsonKind(columns)}.`), + ); + } + if (!Number.isInteger(columns) || columns < 1) { + return Err( + new Error( + `Prop "columns" on must be a positive integer. Got: ` + + `${JSON.stringify(columns)}.`, + ), + ); + } + return Ok(columns); +} + +/** What a `` naming no column count at all says. */ +export function gridColumnsMissingMessage(): string { + return ' requires a "columns" prop (a positive integer).'; +} + +/** The label one pane displays, or why `title` rejects it. */ +export function paneTitle(title: Json): Result { + if (typeof title !== "string") { + return Err( + new Error(`Prop "title" on must be a non-empty string, not ${jsonKind(title)}.`), + ); + } + if (title.length === 0) { + return Err(new Error('Prop "title" on must be a non-empty string. Got: "".')); + } + return Ok(title); +} + +/** What a `` naming no title at all says. */ +export function paneTitleMissingMessage(): string { + return ' requires a "title" prop (the label the pane displays).'; +} + +/** One pane a grid lays out, and where it sat among its siblings. */ +export interface Pane { + readonly element: ComponentElement; + /** The child index the pane was written at. */ + readonly index: number; + /** + * The pane's structural identity: its position among the grid's panes, + * counting from zero. A title is a display label and identifies nothing. + */ + readonly ordinal: number; + /** Whether the pane runs the markdown it holds or the host's default shell. */ + readonly form: "paired" | "self-closing"; +} + +/** How a `` body divides into panes, and what the division got wrong. */ +export interface GridStructure { + readonly violations: StructuralViolation[]; + /** The direct panes, in authored order. */ + readonly panes: Pane[]; +} + +/** Which of a pane's two forms was written: its own markdown, or a shell. */ +function paneForm(segment: ComponentElement): Pane["form"] { + return segment.selfClosing ? "self-closing" : "paired"; +} + +/** Everything one `` pane decides from what the author wrote (spec §6.21). */ +function paneViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_PROPS.has(name)); + if (unknownProp !== undefined) { + found.push( + violation( + "structural-usage-invalid", + "Pane", + ` only accepts a "title" prop. Got: "${unknownProp}".`, + segment, + ), + ); + } + + if ("title" in segment.props) { + const title = paneTitle(segment.props.title); + if (!title.ok) { + found.push(violation("structural-usage-invalid", "Pane", title.error.message, segment)); + } + } else if (!("title" in segment.expressions)) { + found.push(violation("structural-usage-invalid", "Pane", paneTitleMissingMessage(), segment)); + } + return found; +} + +/** + * Every `` and `` below a grid that the grid does not + * lay out. The walk stops at a nested grid, which is reported where it sits and + * owns whatever is written beneath it. + */ +function misplacedPaneViolations(children: Segment[]): StructuralViolation[] { + const found: StructuralViolation[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Grid") { + if (depth > 0) { + found.push(violation("structural-usage-invalid", "Grid", nestedGridMessage(), segment)); + } + continue; + } + if (segment.name === "Pane" && depth > 0) { + found.push(violation("structural-usage-invalid", "Pane", strayPaneMessage(), segment)); + } + walk(segment.children, depth + 1); + } + }; + + walk(children, 0); + return found; +} + +/** + * Divide a `` body into its panes and validate the division + * (spec §6.21). Everything here is read from source, so a grid whose layout the + * author got wrong is refused before `columns` is evaluated, before a pane's + * content expands, and before any grid provider is asked for anything. + * + * The panes are the grid's direct children and only they: a control structure + * that would produce panes as it ran cannot be one, because which panes exist + * is what the grid must know before it opens anything. + */ +export function gridStructure(segment: ComponentElement): GridStructure { + const violations: StructuralViolation[] = []; + const panes: Pane[] = []; + + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_GRID_PROPS.has(name)); + if (unknownProp !== undefined) { + violations.push( + violation( + "structural-usage-invalid", + "Grid", + ` only accepts a "columns" prop. Got: "${unknownProp}".`, + ), + ); + } + if ("columns" in segment.props) { + const columns = gridColumns(segment.props.columns); + if (!columns.ok) { + violations.push(violation("structural-usage-invalid", "Grid", columns.error.message)); + } + } else if (!("columns" in segment.expressions)) { + violations.push(violation("structural-usage-invalid", "Grid", gridColumnsMissingMessage())); + } + if (segment.selfClosing) { + violations.push( + violation( + "structural-usage-invalid", + "Grid", + " holds the panes it lays out, so it is written paired: " + + '.', + ), + ); + } + + let substantive = 0; + for (const [index, child] of segment.children.entries()) { + if (isBlankText(child)) { + continue; + } + substantive++; + if (child.type !== "component" || child.name !== "Pane") { + violations.push( + violation( + "structural-usage-invalid", + "Grid", + ` holds only panes. Found ${describeSegment(child)} ` + + "directly inside it. Write control flow inside a pane instead.", + child.type === "component" ? child : undefined, + ), + ); + continue; + } + violations.push(...paneViolations(child)); + panes.push({ element: child, index, ordinal: panes.length, form: paneForm(child) }); + } + + if (!segment.selfClosing && substantive === 0) { + violations.push( + violation("structural-usage-invalid", "Grid", " requires at least one pane."), + ); + } + + violations.push(...misplacedPaneViolations(segment.children)); + return { violations, panes }; +} diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index ad36a80ae..7a7e43989 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -169,6 +169,25 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ as: null, context: "A multiline template, in place of the single-line `template` prop.", }, + { + name: "Grid", + syntax: [""], + description: + "Open several panes in one view. " + + '``', + as: null, + context: "The `` panes the grid lays out.", + }, + { + name: "Pane", + syntax: ['', ''], + description: + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', + as: null, + context: "Markdown the pane runs, in the paired form.", + }, ]; /** diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index cc87cbfa1..5dcc38a97 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -13,7 +13,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { ensure, scoped, spawn, until, withResolvers } from "effection"; +import { ensure, resource, scoped, spawn, until, withResolvers } from "effection"; import type { Operation, Result, WithResolvers } from "effection"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { createHash, randomUUID } from "node:crypto"; @@ -33,14 +33,23 @@ import { parsePrepared } from "../src/agent/launch-journal.ts"; import type { AgentLaunchRequest } from "../src/agent/launch-request.ts"; import { installAgentComponents } from "../src/agent/components.ts"; import type { AgentProviderFactory } from "../src/agent/provider-api.ts"; +import { API, useHostFiles } from "@executablemd/runtime"; import { - API, - installControlledLauncher, NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch, - useHostFiles, -} from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; + reserveTerminal, + Grids, +} from "@executablemd/grid"; +import { + installControlledLauncher, + prepareControlledComposite, + gridProviderLog, +} from "@executablemd/grid/test"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/grid"; +import { createGridClaims } from "@executablemd/grid/lifecycle"; +import { usePaneNativeLauncher } from "@executablemd/grid"; +import { installGridProfile } from "../src/grid/profile.ts"; +import { registerGridProvider } from "@executablemd/grid"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; @@ -216,6 +225,19 @@ interface RunOptions { next: (request: AgentLaunchRequest) => Operation, ) => Operation; secretDetection?: boolean; + /** + * Install a controlled grid provider, so the document can open a grid. + * + * The reader stays until every pane has settled, so a row about what a pane + * launched is not racing the close that would cancel it. + */ + grid?: boolean; + /** Start the native child, in place of a runtime that would. */ + start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; + /** Called as the composite shows each pane state, in order. */ + onPaneState?: (ordinal: number, state: string) => void; + /** Called when the composite is shown to the reader. */ + onAttach?: () => void; } interface Run { @@ -224,6 +246,8 @@ interface Run { stub: LaunchStub; launcher: LauncherLog; events: DurableEvent[]; + /** Everything the controlled composite did, in order. */ + composite: string[]; } function* runDoc(doc: string, options: RunOptions = {}): Operation { @@ -277,10 +301,68 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { })(), } : {}), + ...(options.start === undefined ? {} : { start: options.start }), outcome: () => options.outcome ?? { exitCode: 0 }, }); } + const providerLog = gridProviderLog(); + if (options.grid === true) { + // The reader leaves once every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 owns that — but a row about what a pane + // launched must not race the close that cancels it. + const settled = withResolvers(); + let panes = 0; + let done = 0; + yield* registerGridProvider("controlled", function* (_settings, authority) { + yield* Grids.around( + { + *open([request]) { + const composite = yield* prepareControlledComposite(request, { + log: providerLog, + close: () => settled.operation, + // The pane endpoint a paired pane's `` now + // reaches. It records what it was asked to start and answers, + // exactly as the host launcher used to — so these rows are + // about the pane, not about a launcher having moved. + *launch(_ordinal, asked, spawned) { + launcher.requests.push(asked); + launcher.order.push("launch"); + if (options.start) { + yield* options.start(asked, spawned); + } else { + spawned(); + } + return options.outcome ?? { exitCode: 0 }; + }, + // deno-lint-ignore require-yield + *onPrepare(asked) { + panes = asked.panes.length; + }, + // deno-lint-ignore require-yield + *onAttach() { + options.onAttach?.(); + }, + onUpdate(ordinal, state) { + options.onPaneState?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= panes) { + settled.resolve(); + } + } + }, + }); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); + yield* installGridProfile({ provider: "controlled" }); + } + yield* installAgentComponents({ rootProvider: { factory: stub.factory, @@ -317,6 +399,7 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { stub, launcher, events: yield* stream.readAll(), + composite: providerLog.events, }; }); } @@ -768,6 +851,226 @@ describe("Tier SL — native session launch", () => { }); }); +/** + * Tier SP — `` inside a terminal pane + * (specs/native-agent-session-launch-spec.md §Grid composition). + * + * The launch is the same launch. Nothing here passes a pane to it, and its + * request, result and retained phases are the ones a root launch would have. + * What changes is which terminal answers, and these rows are about that: a + * pane's own lease instead of the run's, panes that do not contend with each + * other, one that is exclusive to itself, and a readiness latch nothing but a + * started child can trip. + */ +describe("Tier SP — a launch inside a terminal pane", () => { + /** Two panes, each launching a session of its own. */ + const PANES = [ + "", + '', + 'left work', + "", + '', + 'right work', + "", + "", + "", + ].join("\n"); + + it("SP1: a pane launch takes that pane, not the run's foreground lease", function* () { + const run = yield* runDoc(PANES, { grid: true }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + // The grid took the one root lease, and the two launches inside it did not + // ask for it. Had either delegated, the host launcher would have refused + // the second holder and the document would have failed here. + expect(run.launcher.reserved).toBe(1); + expect(run.launcher.requests.length).toBe(2); + // Both went to the provider unchanged: same argv a root launch builds, and + // nothing about a pane in it. + for (const request of run.launcher.requests) { + expect(request.command).toEqual(["stub-ui", "--resume", run.stub.nativeSessionId]); + expect(JSON.stringify(request)).not.toContain("pane"); + expect(JSON.stringify(request)).not.toContain("ordinal"); + } + }); + + it("SP2: launches in distinct panes hold their terminals at the same time", function* () { + // Each launch waits for the other to have started. Two launches sharing one + // lease would serialise, and the first would wait for a second that cannot + // begin — so this row hangs rather than passing if they contend. + const both = withResolvers(); + let started = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + spawned(); + started++; + if (started === 2) { + both.resolve(); + } + yield* both.operation; + }, + }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(started).toBe(2); + expect(run.launcher.requests.length).toBe(2); + }); + + it("SP3: a pane is ready only once its native child has started", function* () { + const order: string[] = []; + const bothPrepared = withResolvers(); + let prepared = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + // Prepared, reserved, flushed and routed to the provider — and none of + // that is a start. Both launches get this far before either child does. + order.push("prepare"); + prepared++; + if (prepared === 2) { + bothPrepared.resolve(); + } + yield* bothPrepared.operation; + order.push("spawn"); + spawned(); + }, + onPaneState: (_ordinal, state) => { + if (state === "running") { + order.push("running"); + } + }, + onAttach: () => order.push("attach"), + }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + // Neither pane was running, and nothing was shown, while both launches sat + // one step short of starting a child. + expect(order.slice(0, 4)).toEqual(["prepare", "prepare", "spawn", "spawn"]); + expect(order.filter((event) => event === "running").length).toBe(2); + expect(order.indexOf("attach")).toBeGreaterThan(order.lastIndexOf("spawn")); + }); + + it("SP4: a launch that fails before the spawn keeps its phases and shows nothing", function* () { + let started = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + // One pane's child never starts, and nothing is reported: the readiness + // latch belongs to a child that started. + started++; + if (started === 1) { + yield* until(Promise.resolve()); + throw new Error("the native UI could not be started"); + } + spawned(); + }, + }); + + expect(run.result.ok).toBe(false); + // Nothing was ever shown: a grid whose pane failed to start attaches no + // partial composite. + expect(run.composite.includes("attach:0")).toBe(false); + // And what the launch had already made durable is still there. The grid + // does not roll a completed preparation back. + expect(retainedPhases(run.events)).toContain("prepared"); + expect(preparedRecord(run.events).nativeSessionId).toBe(run.stub.nativeSessionId); + }); + + /** + * A lease that outlives the child it protects, and unwinds slowly. + * + * This is the shape a session acquisition has: the provider takes ownership, + * performs the whole launch inside it, and releases it as the launch's scope + * comes down — *inside* the terminal reservation, so the pane is still held + * while it happens. `entered` says the unwinding has begun; `release` lets it + * finish. + */ + function heldLease(entered: WithResolvers, release: WithResolvers): Operation { + return resource(function* (provide) { + yield* ensure(function* () { + entered.resolve(); + yield* release.operation; + }); + yield* provide(); + }); + } + + /** Ask this pane for its terminal, and report the refusal if there is one. */ + function reserveOnce(): Operation { + return (function* (): Operation { + try { + yield* scoped(() => reserveTerminal()); + return "admitted"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + })(); + } + + it("SP5: a pane is held until both the child and the lease around it are done", function* () { + const claims = createGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + const claim = claims.claims[0]!; + const childLive = withResolvers(); + const childMayExit = withResolvers(); + const unwinding = withResolvers(); + const release = withResolvers(); + const asked: string[] = []; + + yield* scoped(function* () { + // The composite's pane endpoint, which is what the pane launcher now + // delegates to. It stands in for a provider here, and behaves like one: + // it reports the start and answers when the child is done. + yield* usePaneNativeLauncher( + claim, + function* () {}, + function* (_request, spawned) { + spawned(); + childLive.resolve(); + yield* childMayExit.operation; + return { exitCode: 0 }; + }, + ); + + const first = yield* spawn(function* () { + // The order a launch composes in: this pane, then the lease, then the + // child. Which is also the order they come back in, reversed. + yield* scoped(function* () { + yield* reserveTerminal(); + yield* heldLease(unwinding, release); + yield* nativeLaunch({ command: ["ui"], cwd: "." }); + }); + }); + + // 1. The native child is live. + yield* childLive.operation; + asked.push(yield* reserveOnce()); + + // 2. The child has gone, but the lease around it is still unwinding — + // which is the half a launch that merely returned would never show. + childMayExit.resolve(); + yield* unwinding.operation; + asked.push(yield* reserveOnce()); + + // 3. Both are done. + release.resolve(); + yield* first; + asked.push(yield* reserveOnce()); + }); + + expect(asked.length).toBe(3); + expect(asked[0]).toContain("already has a live interactive operation"); + expect(asked[1]).toContain("already has a live interactive operation"); + expect(asked[2]).toBe("admitted"); + // The child that started is what made the pane ready, and it did. + expect(claims.readiness[0]?.acknowledged).toBe(true); + }); +}); + /** * Tier FS — the final public launch surface * (issue-518-authority-lease-architect-amendment.md §Launch authority). diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index 6c5f6fd91..3c355f8dc 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -681,6 +681,153 @@ describe("Tier DV: branch selection", () => { }); }); +describe("Tier DV: grids", () => { + const GRID_DOC = [ + "", + '', + '', + "", + '', + "", + "", + ].join("\n"); + + it("TG3: a well-formed grid is valid, and nothing beneath it runs", function* () { + const { result, seen } = yield* validateText(GRID_DOC, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(names(result)).toEqual(["Grid", "Pane", "Widget", "Pane"]); + expect(named(result, "Grid").origin).toEqual({ + kind: "structural", + construct: "Grid", + }); + expect(named(result, "Pane").origin).toEqual({ + kind: "structural", + construct: "Pane", + }); + // A pane's body is walked like any other region, and none of it — no + // shell, no command, no agent, no terminal — was reached to walk it. + expect(seen.effects).toEqual([]); + // Reserved means selection never looked for a file that could supply + // either construct. + expect(seen.reads.some((read) => read.includes("Pane"))).toBe(false); + }); + + it("TG3: reports each invalid authored form, with no execution", function* () { + const invalid: [string, string, string][] = [ + [ + "an unknown prop on the grid", + '\n', + ' only accepts a "columns" prop. Got: "layout".', + ], + [ + "a capture on the grid", + '\n', + ' only accepts a "columns" prop. Got: "as".', + ], + [ + "no column count", + '\n', + ' requires a "columns" prop (a positive integer).', + ], + [ + "a column count that is not a positive integer", + '\n', + 'Prop "columns" on must be a positive integer. Got: 0.', + ], + [ + "an unknown prop on a pane", + '\n', + ' only accepts a "title" prop. Got: "shell".', + ], + [ + "no title on a pane", + "\n", + ' requires a "title" prop (the label the pane displays).', + ], + [ + "an empty title", + '\n', + 'Prop "title" on must be a non-empty string. Got: "".', + ], + ["a self-closing grid", "\n", " holds the panes it lays out"], + [ + "a grid with no pane", + "\n", + " requires at least one pane.", + ], + [ + "text written directly in a grid", + 'a note\n', + ' holds only panes. Found text "a note" directly inside it.', + ], + [ + "a direct element that is not a pane", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a pane produced by control flow", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a nested grid", + '' + + '\n', + " cannot be written inside another .", + ], + [ + "a pane outside every grid", + 'alone\n', + " must be a direct child of .", + ], + [ + "a pane below a grid that is not one of its panes", + '' + "\n", + " must be a direct child of .", + ], + ]; + + for (const [form, source, message] of invalid) { + const { result, seen } = yield* validateText(source, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(`${form}: ${result.outcome}`).toBe(`${form}: invalid`); + expect(`${form}: ${codes(result).includes("structural-usage-invalid")}`).toBe( + `${form}: true`, + ); + const said = result.diagnostics.some((diagnostic) => diagnostic.message.includes(message)); + expect(`${form}: ${said}`).toBe(`${form}: true`); + expect(`${form}: ${JSON.stringify(seen.effects)}`).toBe(`${form}: []`); + } + }); + + it("TG3: answers the same way twice", function* () { + const first = yield* validateText("\n"); + const second = yield* validateText("\n"); + + expect(JSON.stringify(second.result)).toBe(JSON.stringify(first.result)); + }); + + it("TG3: a dynamic column count and title are decided by expansion, not here", function* () { + const { result, seen } = yield* validateText( + ["", "", "", ""].join("\n"), + ); + + // Whether those expressions produce a positive integer and a non-empty + // string is a value the document computes, and evaluating one is + // expansion's alone. + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(seen.effects).toEqual([]); + }); +}); + describe("Tier DV: source, target and declaration failures", () => { const ROWS: { readonly id: string; diff --git a/packages/core/tests/grid-structure.test.ts b/packages/core/tests/grid-structure.test.ts new file mode 100644 index 000000000..0b7f564dc --- /dev/null +++ b/packages/core/tests/grid-structure.test.ts @@ -0,0 +1,356 @@ +/** + * Tier TG — the authored structure of a grid (spec §6.21). + * + * What an author may write, and where each pane lands, decided before anything + * opens. These rows drive the real expansion path: a grid the grammar accepts + * runs until the point a grid provider would be asked for one, and this + * build installs none, so it refuses there and carries the layout it derived + * beside the refusal. + * + * Provider non-observation is asserted rather than assumed. Every run traps the + * two boundaries a pane's body would cross — resolving a component and running + * a code block — and a row is evidence only when both stayed empty. That the + * machine running these tests has no tmux is not evidence of anything: nothing + * here would look for one. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Component } from "../src/component-api.ts"; +import { expandSegments } from "../src/expand.ts"; +import { renderSegments } from "../src/render.ts"; +import { scanSegments } from "../src/scanner.ts"; +import { gridLayout } from "@executablemd/grid/lifecycle"; +import type { Json, Segment } from "../src/types.ts"; + +interface GridRun { + segments: Segment[]; + output: string; + /** Every component the run tried to resolve, in order. */ + imports: string[]; + /** The source of every code block the run ran, in order. */ + blocks: string[]; + /** Every expression the document evaluated, by label, in order. */ + calls: string[]; +} + +/** + * Expand one document with every effect a pane could have trapped. + * + * A component this run resolves, a code block it runs, or an expression it + * evaluates is recorded rather than performed, so "nothing beneath the grid + * happened" is something the row reads back instead of assuming. + */ +function runGrid(source: string, values: Record = {}): Operation { + return scoped(function* () { + const imports: string[] = []; + const blocks: string[] = []; + const calls: string[] = []; + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + imports.push(name); + throw new Error(`Component not found: ${name}`); + }, + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, context], _next) { + blocks.push(context.content); + return { output: "", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + const testEnv = { + values: { + ...values, + seen: (label: string, value: unknown) => { + calls.push(label); + return value; + }, + }, + }; + yield* Component.around({ env: () => testEnv }, { at: "min" }); + const segments = yield* expandSegments(scanSegments(source), {}, {}, new Set()); + return { segments, output: renderSegments(segments), imports, blocks, calls }; + }); +} + +function errorMessages(segments: Segment[]): string[] { + return segments.filter((segment) => segment.type === "error").map((segment) => segment.message); +} + +/** The one message a run that refused for a single reason reports. */ +function soleError(run: GridRun): string { + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(1); + return messages[0]!; +} + +/** + * The grid a run derived, read from the refusal that carries it. + * + * A run that refused for a grammar or placement reason never derived one, so + * asking for it is also how a row states that the grid was complete. + */ +function derivedLayout(run: GridRun): Json { + const refusal = run.segments.find( + (segment) => segment.type === "error" && segment.source === "Grid", + ); + if (refusal === undefined || refusal.type !== "error" || refusal.cause === undefined) { + throw new Error(`no grid refusal carrying a layout: ${errorMessages(run.segments)}`); + } + return refusal.cause; +} + +/** Every boundary a pane's body would have crossed, and none of them did. */ +function reachedNothing(run: GridRun): void { + expect(run.imports).toEqual([]); + expect(run.blocks).toEqual([]); + expect(run.calls).toEqual([]); +} + +/** + * Work a pane's body would do, so a body that expanded would be recorded. + * + * One of each boundary `reachedNothing()` reads: a component to resolve, an + * expression to evaluate, and a command to run. + */ +const PANE_BODY = [ + "", + "", + 'reached', + "", + "```bash exec", + "echo ran", + "```", +].join("\n"); + +describe("Tier TG — the grid grammar", () => { + it("TG1: refuses an unknown prop and `as` on the grid", function* () { + const unknown = yield* runGrid(''); + expect(soleError(unknown)).toContain(' only accepts a "columns" prop. Got: "layout".'); + + const captured = yield* runGrid(''); + expect(soleError(captured)).toContain(' only accepts a "columns" prop. Got: "as".'); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: refuses an unknown prop and `as` on a pane", function* () { + const unknown = yield* runGrid(''); + expect(soleError(unknown)).toContain(' only accepts a "title" prop. Got: "shell".'); + + const captured = yield* runGrid(''); + expect(soleError(captured)).toContain(' only accepts a "title" prop. Got: "as".'); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: requires columns to be a positive integer, however it was written", function* () { + const missing = yield* runGrid(''); + expect(soleError(missing)).toContain(' requires a "columns" prop (a positive integer).'); + + for (const literal of ["{0}", "{-1}", "{2.5}", '"2"', "{null}"]) { + const run = yield* runGrid(``); + expect(soleError(run)).toContain('Prop "columns" on must be a positive'); + reachedNothing(run); + } + + // The same rule reaches a value the document computes, which the source + // could not have decided about. + const computed = yield* runGrid('', { size: 0 }); + expect(soleError(computed)).toContain( + 'Prop "columns" on must be a positive integer. Got: 0.', + ); + reachedNothing(computed); + }); + + it("TG1: requires a non-empty title on every pane, however it was written", function* () { + const missing = yield* runGrid(""); + expect(soleError(missing)).toContain( + ' requires a "title" prop (the label the pane displays).', + ); + + for (const literal of ['""', "{3}", "{null}"]) { + const run = yield* runGrid(``); + expect(soleError(run)).toContain('Prop "title" on must be a non-empty string'); + reachedNothing(run); + } + + const computed = yield* runGrid("", { + label: "", + }); + expect(soleError(computed)).toContain( + 'Prop "title" on must be a non-empty string. Got: "".', + ); + reachedNothing(computed); + }); + + it("TG1: refuses a self-closing grid", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" holds the panes it lays out"); + reachedNothing(run); + }); +}); + +describe("Tier TG — structural placement", () => { + it("TG2: refuses a grid with no pane", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" requires at least one pane."); + reachedNothing(run); + }); + + it("TG2: refuses ordinary text written directly in a grid", function* () { + const run = yield* runGrid('a note'); + expect(soleError(run)).toContain( + ' holds only panes. Found text "a note" directly inside it.', + ); + reachedNothing(run); + }); + + it("TG2: refuses a direct element that is not a pane", function* () { + const run = yield* runGrid(''); + expect(soleError(run)).toContain( + " holds only panes. Found directly inside it.", + ); + // The element was refused as authored structure, so it was never resolved. + reachedNothing(run); + }); + + it("TG2: refuses a control structure that would produce the panes", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(2); + expect(messages[0]).toContain(" holds only panes. Found directly inside it."); + expect(messages[0]).toContain("Write control flow inside a pane instead."); + expect(messages[1]).toContain(" must be a direct child of ."); + // The condition decides which panes would exist, and the grid must know + // that from the source, so it is never evaluated. + reachedNothing(run); + }); + + it("TG2: refuses a grid nested inside a pane", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(run)).toContain(" cannot be written inside another ."); + reachedNothing(run); + }); + + it("TG2: refuses a pane written outside every grid", function* () { + const alone = yield* runGrid('Instructions.'); + expect(soleError(alone)).toContain(" must be a direct child of ."); + + // Below a grid but not one of its panes is the same mistake, reported where + // the pane was written. + const buried = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(buried)).toContain(" must be a direct child of ."); + reachedNothing(alone); + reachedNothing(buried); + }); +}); + +describe("Tier TG — row-major layout", () => { + const positions = (columns: number, panes: number) => + gridLayout( + columns, + Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ).cells.map((cell) => [cell.row, cell.column]); + + it("TG4: places one through five panes row-major across two columns", function* () { + expect(positions(2, 1)).toEqual([[0, 0]]); + expect(positions(2, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(2, 3)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + ]); + expect(positions(2, 4)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]); + expect(positions(2, 5)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + ]); + // The last row is left short rather than balanced or padded. + expect([1, 2, 3, 4, 5].map((panes) => gridLayout(2, filler(panes)).rows)).toEqual([ + 1, 1, 2, 2, 3, + ]); + }); + + it("TG4: places one through five panes row-major across three columns", function* () { + expect(positions(3, 1)).toEqual([[0, 0]]); + expect(positions(3, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(3, 3)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + ]); + expect(positions(3, 4)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + ]); + expect(positions(3, 5)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + ]); + expect([1, 2, 3, 4, 5].map((panes) => gridLayout(3, filler(panes)).rows)).toEqual([ + 1, 1, 1, 2, 2, + ]); + }); +}); + +/** Panes that differ only in count, for a row about rows. */ +function filler(panes: number): { title: string; form: "self-closing" }[] { + return Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })); +} diff --git a/packages/core/tests/grid.test.ts b/packages/core/tests/grid.test.ts new file mode 100644 index 000000000..1e1bd3779 --- /dev/null +++ b/packages/core/tests/grid.test.ts @@ -0,0 +1,1862 @@ +/** + * Tier TG — running a grid through a replaceable provider + * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * settlement, §Durability and replay). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because something called the latch it was + * handed, never because it got far enough. That is what lets "started" and "did + * some work" be told apart at all. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, `` is what a suite writes + * to be that something — and it reaches the pane through the same seam a real + * `` will. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { reserveTerminal, Grids } from "@executablemd/grid"; +import { + installControlledLauncher, + prepareControlledComposite, + gridProviderLog, +} from "@executablemd/grid/test"; +import type { GridComposite, GridRequest } from "@executablemd/grid"; +import type { + ControlledCompositeOptions, + GridProviderLog, + GridProviderResources, +} from "@executablemd/grid/test"; + +import { Component } from "../src/component-api.ts"; +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { + createGridClaims, + GridAuthorityError, + useGridInstallation, +} from "@executablemd/grid/lifecycle"; +import type { GridAuthority } from "@executablemd/grid/lifecycle"; +import { installGridProvider } from "@executablemd/grid/lifecycle"; +import { registerGridProvider, GridProviderInstallError, GridProviders } from "@executablemd/grid"; +import { installGridProfile } from "../src/grid/profile.ts"; +import { paneTerminal } from "@executablemd/grid"; +import type { Json } from "../src/types.ts"; + +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: GridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Everything the composite did, in order. */ + events: string[]; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; + /** Every printed error the run produced, in order. */ + errors: string[]; + /** The journal this run read and appended to. */ + journal: DurableEvent[]; + /** What the controlled provider still held when the run was over. */ + live: GridProviderResources; +} + +/** + * The mark a document records once it is past the grid. + * + * It fires whether the grid ran or replayed, so a harness can stop the run at + * the same point either way — and a replay that hangs never reaches it, which + * is a failure rather than something a deadline would quietly pass. + */ +const PAST_THE_GRID = "past the grid"; + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** The controlled interactive child, and a tripwire. */ +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, +): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + onMark(String(props.mark)); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + yield* sleep(25); + slowMarks.push("ready:slow"); + spawned(); + }); + return ""; + }, + }, + { + // Holds the pane open, and blocks its own teardown until released — so a + // row can interrupt a run while reader-close teardown is in progress. + name: "SlowTeardown", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* ensure(function* () { + yield* teardownHeld(); + }); + // Armed: the finalizer is installed and this pane is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); + yield* suspend(); + return ""; + }, + }, + { + // Waits until the grid has attached, so a pane can fail *after* the + // barrier — which is the failure the grid contains as a status rather + // than the startup failure that fails the whole region. + name: "AfterAttach", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterAttach(); + return ""; + }, + }, + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); +} + +/** + * Register a controlled provider that presents through the authority it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives the authority + * as an argument, prepares a composite of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledCompositeOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: GridRequest) => GridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the authority for a later, unrouted use. */ + readonly capture?: (authority: GridAuthority) => void; + } = {}, +): Operation { + let generation = 0; + return registerGridProvider("controlled", function* (_settings, authority) { + options.capture?.(authority); + yield* Grids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + const composite = yield* prepareControlledComposite(request, options, generation++); + yield* authority.present(options.substitute?.(request) ?? request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); +} + +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const authority = yield* useGridInstallation(); + yield* installGridProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); +} + +/** Close as soon as the reader is asked, which is the ordinary journey. */ +function immediateClose(): () => Operation { + // deno-lint-ignore require-yield + return function* () {}; +} + +/** + * Expand one document against a controlled grid host. + * + * `provider: false` registers nothing, which is how "a host that cannot open a + * grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { + provider?: boolean; + stream?: InMemoryStream; + composite?: ControlledCompositeOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: GridRequest[] = []; + const log = gridProviderLog(); + const ran: string[] = []; + const errors: string[] = []; + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); + yield* useGridComponents(ran, options.slowMarks ?? []); + yield* installControlledLauncher(); + + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.composite ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + yield* installGridProfile(options.provider === false ? {} : { provider: "controlled" }); + + const stream = options.stream ?? new InMemoryStream(); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { + outcome, + output, + requests, + shown: log.shown, + events: log.events, + ran, + errors, + journal: yield* stream.readAll(), + live: log.live, + }; + }); +} + +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} + +/** A grid on its own, which a resumed run can carry to an outcome. */ +function plainDocument(columns: number, panes: string[]): string { + return [``, ...panes, "", ""].join("\n"); +} + +/** A grid, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, panes: string[]): string { + return [ + ``, + ...panes, + "", + "", + // The sibling after the grid. It runs whether the grid ran or replayed, so + // a harness can wait for the document to have moved past the region. + ``, + "", + "", + "", + ].join("\n"); +} + +/** + * Run a document and interrupt it once the grid has journaled its outcome. + * + * A completed *or failed* root replays wholesale, so a second run of it would + * never reach the grid at all. Only a genuinely interrupted run leaves the + * region to be resumed — which is what every replay row below needs. + */ +function runInterrupted( + dir: string, + source: string, + stream: InMemoryStream, + options: { + provider?: boolean; + shell?: ControlledCompositeOptions["shell"]; + /** Let the reader leave, so the grid completes rather than staying open. */ + close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; + /** + * Keep the grid open until a pane reports a failure. + * + * A pane that fails *after* attachment is contained as that pane's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the pane as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** + * Called once a pane's finalizer has been entered and is blocked, with what + * the provider is holding at that moment. + * + * A row reads those counters here to know they ever went up, which is what + * makes reading them again at the end mean something. + */ + onTeardownEntered?: (live: GridProviderResources) => void; + /** + * Called once that finalizer has left. + * + * Kept apart from entering it deliberately: a finalizer that was entered + * and then cancelled reaches the first hook and never the second, which is + * the difference between teardown starting and teardown finishing. + */ + onTeardownExited?: () => void; + /** + * Called once for each time the foreground lease is taken back after the + * run, which the harness always does twice. + * + * It is the grid's lease that has to come back: a run that stranded it + * would refuse the first of those, and one that never released what this + * harness took would refuse the second. + */ + onLeaseReacquired?: () => void; + /** Interrupt the run when this settles rather than at a lifecycle signal. */ + interruptWhen?: Operation; + /** + * Called once cancellation has begun but before it is awaited. + * + * A row that blocks a finalizer has to release it *after* the parent is + * cancelled, or the cancellation would be waiting on the very thing the row + * is holding. Awaiting the halt afterwards is what proves teardown + * completed rather than merely started. + */ + releaseOnInterrupt?: () => void; + /** + * How many panes must have settled before the run is interrupted. + * + * A pane's status is published only after its durable child has returned, + * so this is also how many pane Closes the journal is known to hold. Rows + * that read those records name the number they need; rows that only need an + * open grid name none. + */ + settled?: number; + } = {}, +): Operation { + return scoped(function* () { + const requests: GridRequest[] = []; + const log = gridProviderLog(); + const ran: string[] = []; + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `attached` + // says a grid opened on this run. `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does and what a + // completed-region journal has to be waited for. `panesSettled` says the + // pane children the row cares about have written their own records. + // + // Every one of them is an event this run produced. Nothing here waits for a + // duration, so a replay that hangs reaches none of them and hangs the row — + // it can never hand back a run that looks finished but is not. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const panesSettled = withResolvers(); + let settledPanes = 0; + if ((options.settled ?? 0) === 0) { + panesSettled.resolve(); + } + // The printed errors this run produced, which is how a contained failure is + // observable at all — and the same list on a replayed run is how "the same + // result came back" is read rather than assumed. + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); + const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); + const marked = withResolvers(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } + }, + () => attached.operation, + function* () { + options.onTeardownEntered?.(log.live); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + options.onTeardownExited?.(); + }, + () => armed.resolve(), + ); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: function* (ordinal: number, spawned: () => void) { + spawned(); + if (ordinal !== options.shellFailsAfterAttach) { + return { exitCode: 0 }; + } + // Started, so the grid attaches; it fails only afterwards, which + // is the failure a grid contains as a pane status. + yield* attached.operation; + return { exitCode: 1 }; + }, + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield + *onPrepare(asked) { + requests.push(asked); + }, + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + attached.resolve(); + }, + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } + }, + }); + } + yield* installGridProfile(options.provider === false ? {} : { provider: "controlled" }); + + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const task: Task = yield* spawn(function* () { + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); + yield* execution; + }); + // `close: true` expects the grid to complete, so the run is interrupted only + // once the document has moved past it — which is what leaves a completed + // grid child under an incomplete root. Otherwise the grid is expected to + // stay open, and the run is interrupted once it has opened and the pane + // records the row reads are durable. + if (options.interruptWhen !== undefined) { + yield* options.interruptWhen; + } else if (options.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } + // Cancellation is begun, then released, then awaited. A row that blocks a + // finalizer has to release it after the parent is cancelled, or the + // cancellation would be waiting on the very thing the row is holding; and + // awaiting the halt afterwards is what proves teardown completed rather + // than merely started. + const halting = yield* spawn(() => task.halt()); + options.releaseOnInterrupt?.(); + yield* halting; + // Taken and given back twice, now that the run is over. The first proves + // the grid returned the foreground lease; the second proves this harness + // gave it back too, so the first cannot have passed against a lease nobody + // was holding in the first place. + for (let attempt = 0; attempt < 2; attempt++) { + yield* scoped(function* () { + yield* reserveTerminal(); + options.onLeaseReacquired?.(); + }); + } + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + errors, + journal: yield* stream.readAll(), + live: log.live, + }; + }); +} + +const PANES = ['left', '']; + +describe("Tier TG — the terminal authority", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + expect(run.outcome.ok).toBe(true); + + // The same document, against a provider that answers the routed request + // itself. A return value is not evidence that a grid opened. + const shorted = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ shortCircuit: true }); + yield* installGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, ran }; + }); + + expect(shorted.outcome.ok).toBe(false); + expect(shorted.outcome.ok ? "" : shorted.outcome.error.message).toContain( + "a handler answered without delivering the request to a registered provider", + ); + // Nothing beneath the grid ran either. + expect(shorted.ran).toEqual([]); + }); + + it("TA2: presenting a rebuilt request authorizes nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { + composite: {}, + }); + expect(run.outcome.ok).toBe(true); + + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what the authority reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + yield* installGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; + }); + + expect(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); + }); + + it("TA3: presenting a changed request authorizes nothing", function* () { + const dir = yield* useDir(); + const changed = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), + }); + yield* installGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; + }); + + expect(changed.ok).toBe(false); + expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); + }); + + it("TA4: an authority kept past its grid authorizes nothing", function* () { + const dir = yield* useDir(); + let kept: GridAuthority | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); + + yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* installGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + }); + + // The execution has finished, so the request it issued is no longer live. + let refusal: unknown; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); + try { + yield* kept!.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(GridAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); + }); + + it("TA5: an authority from another installation generation authorizes nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + // Two installations in one scope: the second supersedes the first, so the + // first's authority names a generation the live registry no longer has. + const stale = yield* scoped(function* () { + return yield* useGridInstallation(); + }); + yield* useGridInstallation(); + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); + try { + yield* stale.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(GridAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); + }); + + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + const authority = yield* useGridInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerGridProvider("real", function* () {}); + yield* GridProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); + try { + yield* installGridProvider("real", { label: "real" }, authority); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(GridProviderInstallError); + expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); + }); + + it("TA7: two claims from one grid do not contend; one pane admits one", function* () { + const grid = createGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; + + yield* scoped(function* () { + yield* first.admit(function* () { + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.admit(function* () { + concurrent = true; + }); + }); + }); + + expect(refusal).toBeInstanceOf(GridAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); + }); + + it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { + const request = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], + }; + const first = createGridClaims(request); + const second = createGridClaims(request); + // Sealing one grid says nothing about the other: claims belong to the grid + // that minted them, not to a request shape. + first.seal(); + + let refusal: unknown; + let other = false; + yield* scoped(function* () { + try { + yield* first.claims[0]!.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.claims[0]!.admit(function* () { + other = true; + }); + }); + + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(other).toBe(true); + }); + + it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; + + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TA10: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(GridAuthorityError); + yield* sleep(0); + }); +}); + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]?.panes).toEqual([ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { + const dir = yield* useDir(); + const flushed: string[] = []; + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + { + composite: { + // Preparation happens after the lease and the flush, so what the + // reader had already been given is on screen before the grid covers + // it. + *onPrepare() { + flushed.push("prepared"); + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(flushed).toEqual(["prepared"]); + // Each pane's own text went to that pane. + expect(run.shown.get(0)).toContain("left text"); + expect(run.shown.get(1)).toContain("right text"); + // The grid renders "": the root output holds what surrounds it and no pane + // display at all. + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("left text"); + expect(run.output).not.toContain("right text"); + }); + + it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + '', + "", + "", + '', + "sees {shared}", + "", + '', + "", + "then {mine}", + "", + "", + "", + '', + "sees {shared} and {mine}", + "", + "", + "", + "", + "", + "after {mine}", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Inherited from the grid site. + expect(run.shown.get(0)).toContain("sees site"); + expect(run.shown.get(1)).toContain("sees site"); + // Created inside one pane, visible to later work in that pane. + expect(run.shown.get(0)).toContain("then left"); + // Invisible to the sibling and to the document after the grid: an + // unresolved binding stays the literal text it was written as. + expect(run.shown.get(1)).toContain("and {mine}"); + expect(run.output).toContain("after {mine}"); + }); + + it("TG6: a pane's cannot claim a value body outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "---", + "returns:", + " type: string", + "---", + "", + '', + '', + "", + "", + "", + "", + '', + "", + ].join("\n"), + ); + + // The pane has no enclosing value body to claim, so the written in + // it is refused where it sits rather than becoming the document's value. + expect(failureOf(run)).toContain( + "is not written in the flow of a body that declares `returns`", + ); + expect(failureOf(run)).not.toContain("from the document"); + }); + + it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', + "", + "", + "", + "", + ].join("\n"), + ); + + // Printed inside the pane it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this pane gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this pane gave up"); + }); + + it("TG6: a paired pane runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the pane's *second* component has run, so a + // pane body that stopped after the first would never let the grid close — + // a hang rather than a pass. + const run = yield* runInterrupted( + dir, + heldDocument(2, [ + '', + '', + ]), + stream, + { close: true, closeWhenMarked: "second component" }, + ); + + expect(run.ran).toContain("second component"); + }); + + it("TG9: with no provider installed, no pane body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no grid provider is installed"); + // The pane held work; none of it was reached, and nothing was displayed. + expect(run.ran).toEqual([]); + expect(run.shown.size).toBe(0); + }); +}); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the composite both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + composite: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + timeline.push("ready:shell"); + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join("\n"), + { + composite: { + // Reports its spawn and returns in the same breath. + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready at the spawn event, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + expect(run.events.indexOf("state:0:0:succeeded")).toBeGreaterThan( + run.events.indexOf("attach:0"), + ); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onAttach() { + throw new Error("the composite could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the composite could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + composite: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // The following sibling started only after the composite came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // The reader's close operation is where an active provider can fail. + // deno-lint-ignore require-yield + *close() { + throw new Error("the grid provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the grid provider lost its server"); + expect(run.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + /** + * A grid whose only pane never starts, with its failure contained. + * + * `` keeps the document going, so the root reaches no outcome of + * its own and a resumed run reaches the region rather than replaying the root + * wholesale. + */ + const CONTAINED_FAILURE = [ + "", + "", + '', + '', + "", + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + /** + * Whether the grid child reached a terminal record of its own. + * + * `ok` or `err`: both are outcomes the region settled on. Only a cancelled + * close, or no close at all, means it was interrupted — and that is the + * difference this row exists to depend on. + */ + function completedGrid(run: DocumentRun): boolean { + return run.journal.some( + (event) => + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + (event.result.status === "ok" || event.result.status === "err"), + ); + } + + /** What the grid child retained, read from its own completed `Close`. */ + function retainedGrid(run: DocumentRun): Record | undefined { + for (const event of run.journal) { + if ( + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + event.result.status === "ok" + ) { + const value = event.result.value; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return { ...value }; + } + } + } + return undefined; + } + + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + const panes = retainedGrid(run)?.panes; + return Array.isArray(panes) ? panes : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * say how many records each level wrote and what each one settled to — + * including whether any of them settled as a cancellation. + */ + function closeStatuses(run: DocumentRun, depth: number): string[] { + const statuses: string[] = []; + for (const event of run.journal) { + if (event.type === "close" && String(event.coroutineId).split(".").length === depth) { + statuses.push(event.result.status); + } + } + return statuses; + } + + it("TG15: a completed successful grid replays its exact result, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, GRID, stream, { close: true }); + expect(first.requests).toHaveLength(1); + // The region genuinely completed: without that this row would be about an + // interrupted grid resuming, which is TG16's claim rather than this one. + expect(completedGrid(first)).toBe(true); + + const second = yield* runInterrupted(dir, GRID, stream, { close: true }); + + // No provider was asked for a grid, nothing was prepared or attached, no + // pane content expanded, no shell or launcher ran, and nothing displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG15: a contained failed grid replays the same failure, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + closeAfterFailure: true, + shellFailsAfterAttach: 0, + }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).toBe(true); + // What the failure looked like, as the document reported it. + expect(first.errors.some((message) => message.includes("shell exited with status 1"))).toBe( + true, + ); + + // No provider at all on the resumed run: a replay that contacted one would + // refuse, and the retained result does not need one. + const second = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + close: true, + provider: false, + }); + + // The same result came back, rather than being derived again. + expect(second.errors).toEqual(first.errors); + // And the document carried on from it, exactly as it did the first time. + expect(second.ran).toContain(PAST_THE_GRID); + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // Both panes settle, so both pane children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); + + const closes = first.journal.filter((event) => event.type === "close"); + const paneIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order, and both beneath one grid child. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); + + it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // Interrupted while the grid is open, so its child records a cancelled + // close. Under the repaired spawn policy the resumed run continues that + // region instead of suspending on it forever. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // A fresh composite, built by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); + }); + + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { + spawned(); + yield* suspend(); + return { exitCode: 0 }; + }; + + // The left pane settles; the shell holds, so only one pane record exists. + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + expect(first.ran).toContain("left ran"); + + const second = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + + // The completed pane came back from its retained outcome: its body did not + // run again. + expect(second.ran).not.toContain("left ran"); + // The incomplete shell starts again under current host policy, claiming no + // continuity with the terminal history it had before. + expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); + }); + + /** + * A grid whose `columns` and first `title` come from props. + * + * A continuation executes the retained root, so the document itself cannot + * change between runs — but props are not restored, so these two values are + * exactly what a fixed retained source can still resolve differently. + */ + const PROP_BORNE = [ + "---", + "props:", + " columns:", + " type: number", + " label:", + " type: string", + "---", + "", + "left", + '', + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + it("TG17: a changed prop-borne column count refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 3, label: "Left" }, + }); + + // Refused before the foreground lease and before the provider: nothing was + // prepared, attached or displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + // A replay refusal, not a run that opened something and then failed. The + // sentence is the divergence report's: a refusal raised while retained + // children are still being replayed loses to it, which is established + // behaviour rather than something this row can change. + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 2, label: "Elsewhere" }, + }); + + expect(second.requests).toEqual([]); + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: an unchanged prop-borne layout is admitted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const props = { columns: 2, label: "Left" }; + + yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + const second = yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + + // The discriminator for the two rows above: the same resolved layout + // resumes and opens a grid, so a refusal there is about the change. + expect(second.requests).toHaveLength(1); + }); + + it("TG17: a continuation opens the retained structure, not the file's", function* () { + const structural: [string, string[]][] = [ + ["pane count", [...PANES, '']], + ["pane order", ['', ...PANES.slice(0, 1)]], + ["pane form", ['', '']], + ]; + + for (const [what, panes] of structural) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + const retained = first.requests[0]!; + + // The file now says something else. A continuation executes the root the + // journal retained, so the grid it opens is the one that was recorded. + const second = yield* runInterrupted(dir, heldDocument(2, panes), stream); + + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); + expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( + `${what}: ${JSON.stringify(retained)}`, + ); + } + }); + + it("TG17: the retained record holds the complete authored pane structure", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const layout = run.journal.find( + (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), + ); + expect(layout).toBeDefined(); + const value = + layout?.type === "yield" && layout.result.status === "ok" ? layout.result.value : undefined; + // Every authored pane, with its ordinal, title, form and derived position. + expect(value).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }); + }); + + it("TG17: a malformed retained layout refuses before provider observation", function* () { + /** The retained layout, replaced by something the record cannot mean. */ + const damaged: [string, Json][] = [ + ["a missing member", { columns: 2, panes: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a pane out of position", + { + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 0, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 3, column: 1 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + ]; + + for (const [what, layout] of damaged) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + yield* runInterrupted(dir, GRID, stream); + + // The same journal with only its layout entry replaced, so nothing else + // about the continuation changes. + const damagedStream = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + const isLayout = + event.type === "yield" && String(event.description.name).endsWith(":layout"); + yield* damagedStream.append( + isLayout && event.result.status === "ok" + ? { ...event, result: { status: "ok", value: layout } } + : event, + ); + } + + const second = yield* runDocument(dir, GRID, { stream: damagedStream }); + + expect(`${what}: ${second.outcome.ok}`).toBe(`${what}: false`); + // Refused while reading the record, before anything was asked for. + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.events.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.shown.size}`).toBe(`${what}: 0`); + } + }); + + it("TG19: a cancellation during reader-close teardown waits for it, and replays", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + + // Signals and counters, and nothing else. Every step below is an event this + // run produced, so a lifecycle that never reached one hangs the row rather + // than passing it, and every "exactly once" claim is a count rather than a + // look at the record. + const entered = withResolvers(); + const release = withResolvers(); + let entries = 0; + let exits = 0; + let leases = 0; + let heldWhenBlocked: GridProviderResources | undefined; + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live pane arms its blocking finalizer, and 2. only then does the + // reader leave. + closeWhenArmed: true, + // 3. Entering the finalizer is observed, and it blocks there. + onTeardownEntered: (live) => { + entries++; + heldWhenBlocked = { ...live }; + entered.resolve(); + }, + holdTeardown: () => release.operation, + onTeardownExited: () => { + exits++; + }, + // 4. Cancellation begins while that finalizer is still blocked. + interruptWhen: entered.operation, + // 5. Released afterwards, so the cancellation was not waiting on it. + releaseOnInterrupt: () => release.resolve(), + onLeaseReacquired: () => { + leases++; + }, + }); + + // 6. Teardown ran to the end, and the grid recorded a completed close — + // both before the cancellation was observed, because the document never + // reached the sibling after the grid. + expect(entries).toBe(1); + expect(exits).toBe(1); + expect(first.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(first.ran).toEqual(["pane body"]); + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // The provider's counters went up and came back down. Reading them only at + // the end would be true of counters that never moved. + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0, launches: 0 }); + expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0, launches: 0 }); + // And the foreground lease came back: it was taken and given back twice + // over once the run was done. + expect(leases).toBe(2); + + // 7. Resumed with three tripwires: no provider at all, so a replay that + // asked for a grid would refuse; a mark inside the pane body, so a pane + // that expanded again would say so; and the finalizer, which would + // report being entered a second time. + let reentered = 0; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered++; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(0); + // The retained grid came back and the document carried on from it. + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const written = JSON.stringify(run.journal); + expect(written).toContain('"columns":2'); + expect(written).toContain('"Left"'); + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); diff --git a/packages/core/tests/loop.test.ts b/packages/core/tests/loop.test.ts index db40a891e..e5f4da4bf 100644 --- a/packages/core/tests/loop.test.ts +++ b/packages/core/tests/loop.test.ts @@ -1349,7 +1349,7 @@ describe("Tier LOOP — replay validates the terminal record", () => { // No generic catch sits above the component, so the wrapper reaches the // loop intact — which is what makes this observable. Registered rather // than stubbed through importComponent: execute() installs its own - // terminal provider at { at: "min" }, so an outer stub is never asked. + // grid provider at { at: "min" }, so an outer stub is never asked. yield* registerComponents([ { name: "Wrapped", diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 8339704bb..aab307e1a 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -348,6 +348,77 @@ describe("Tier SY: structural vocabulary", () => { expect(find(entries, "Case").as).toBeUndefined(); }); + it("TG3: freezes the and entries the catalog publishes", function* () { + const catalog = yield* catalogFor({}, []); + const entries = structural(catalog); + + expect(catalog.version).toBe(2); + expect(find(entries, "Grid")).toEqual({ + kind: "structural", + name: "Grid", + origin: { kind: "structural", construct: "Grid" }, + syntax: [""], + description: + "Open several panes in one view. " + + '``', + context: "The `` panes the grid lays out.", + }); + expect(find(entries, "Pane")).toEqual({ + kind: "structural", + name: "Pane", + origin: { kind: "structural", construct: "Pane" }, + syntax: ['', ''], + description: + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', + context: "Markdown the pane runs, in the paired form.", + }); + // Neither construct binds, so neither carries an `as` sentence at all. + expect(find(entries, "Grid").as).toBeUndefined(); + expect(find(entries, "Pane").as).toBeUndefined(); + }); + + it("TG3: a repository file cannot supply the grid or a pane, and neither can a registration", function* () { + const catalog = yield* catalogFor( + { + components: { kind: "directory" }, + "components/Grid.md": markdown("a repository grid\n"), + "components/Pane.md": markdown("a repository pane\n"), + }, + ["components"], + ); + + for (const name of ["Grid", "Pane"]) { + expect(names(structural(catalog))).toContain(name); + expect(names(userProvided(catalog))).not.toContain(name); + expect(names(builtIn(catalog))).not.toContain(name); + } + + for (const name of ["Grid", "Pane"]) { + let refused: unknown; + yield* scoped(function* () { + try { + yield* registerComponents([ + { + name, + origin: "tier-tg", + props: {}, + *fn() { + return ""; + }, + }, + ]); + } catch (error) { + refused = error; + } + }); + expect(refused instanceof Error ? refused.message : "").toContain( + `cannot register "${name}": it is structural syntax the engine owns`, + ); + } + }); + it("SY5b: a repository file cannot supply or , and neither can a registration", function* () { const catalog = yield* catalogFor( { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 179f2cc88..9aefe654a 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -18,15 +18,8 @@ * See protocol spec §7 (structured concurrency), §10 (race semantics). */ -import { - all as effectionAll, - ensure, - race as effectionRace, - spawn, - suspend, - useScope, -} from "effection"; -import type { Operation, Task } from "effection"; +import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; +import type { Operation, Scope, Task } from "effection"; import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, @@ -36,7 +29,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Cancellation, Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -53,13 +46,70 @@ import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; * IMPORTANT: This must be called inside a spawn() so it gets its own scope. * The caller is responsible for spawn(). */ +/** + * What a spawned region does with a retained `Close(cancelled)`. + * + * The two answers are not preferences; they follow from who is going to cancel + * the child on this run. + * + * - `"combinator-cancels"` — `durableRace` and `durableAll`. A retained + * cancelled child is a race loser or a fail-fast sibling, and the same + * combinator will cancel it again, so the child reproduces the original run + * by suspending until it does. + * - `"resume"` — `durableSpawn`. The caller owns the task, and a retained + * cancelled child under a parent that never completed means the *run* was + * interrupted, not that a combinator chose against this child. Nothing will + * cancel it a second time, so suspending would hang the resumed run forever. + * It continues its own retained history instead and finishes the work it had + * left, writing the Close its second life actually reached. + * + * The policy belongs to the combinator, not to its caller: it is fixed at each + * call site below and there is no way to ask for another one. + */ +type CancelledChildPolicy = "combinator-cancels" | "resume"; + +/** + * Whether the caller deliberately stopped the child this run (DEC-040). + * + * Written by the task `durableSpawn` hands out — the only place a deliberate + * halt can be observed — and read once, when the cancelled Close is built. A + * combinator supplies none: a child it cancels stopped because a scope came + * down, which is what `"unwound"` means. + */ +interface CancellationEvidence { + deliberate: boolean; +} + +/** How a cancelled child's stop is recorded. */ +function cancellationOf(evidence: CancellationEvidence | undefined): Cancellation { + return evidence?.deliberate === true ? "caller" : "unwound"; +} + +/** + * Why a retained cancelled child stopped. + * + * Absent is `"caller"`: a record written before this evidence existed says + * nothing, and reviving work nobody asked to be redone is the worse mistake. + */ +function retainedCancellation(close: Close): Cancellation { + if (close.result.status !== "cancelled") { + return "caller"; + } + return close.result.cancellation === "unwound" ? "unwound" : "caller"; +} + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, + cancelledPolicy: CancelledChildPolicy = "combinator-cancels", + evidence?: CancellationEvidence, ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); + // Set when this run continued a retained cancelled child, so its teardown + // writes the Close it reached rather than leaving the stale cancelled one. + let resumedFromCancelled = false; // Short-circuit: child already completed in a previous run. // NOTE: Replay guard validation is not bypassed here — the check phase @@ -73,22 +123,29 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else { - // cancelled — this child was cancelled in a previous run (e.g., - // a race loser). Instead of throwing, we suspend forever. The - // parent combinator (race/all) will cancel this child as part of - // normal structured concurrency teardown, just like the original - // run. The Close(cancelled) event already exists in the journal, - // so we skip re-emitting it (the ensure teardown checks for this). - // - // INVARIANT: This branch is only reachable when a parent combinator - // (durableRace or durableAll with a failed sibling) will cancel this - // child. Close(cancelled) in the journal means the child was - // previously cancelled by structured concurrency, so on replay the - // same combinator will cancel it again. This cannot deadlock. + } else if ( + cancelledPolicy === "combinator-cancels" || + retainedCancellation(closeEvent) === "caller" + ) { + // Either a combinator's child — a race loser, or a sibling `all` + // cancelled when another failed — or a spawned child its own caller + // deliberately halted. Both are reproduced the same way: block until the + // thing that stopped it last time stops it again. A combinator cancels it + // as it did before; a caller reaches the same `halt()` its deterministic + // control flow reached before. In the live run neither child threw, it + // simply stopped. The Close(cancelled) event already exists, so the + // teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; + } else { + // A spawned region whose run was interrupted — involuntarily, which is + // what `"unwound"` records. Nobody is going to cancel this child a second + // time, so suspending would hang the resumed run. + // Forget the retained close — its yields stay replayable, so the child + // continues its own history — and fall through to run the rest. + resumedFromCancelled = true; + replayIndex.reopen(childId); } } @@ -132,13 +189,15 @@ function* runDurableChild( closeEvent = { type: "close", coroutineId: childId, - result: { status: "cancelled" }, + result: { status: "cancelled", cancellation: cancellationOf(evidence) }, }; } // Don't re-emit a Close event if one already exists in the journal - // (e.g., a cancelled child being replayed via suspend()). - if (!replayIndex.hasClose(childId)) { + // (e.g., a cancelled child being replayed via suspend()). A child that + // resumed from a retained cancelled Close is the exception: the record it + // reached this time is the one that describes the work that actually ran. + if (resumedFromCancelled || !replayIndex.hasClose(childId)) { yield* appendDurableEvent(childCtx, closeEvent); } }); @@ -209,33 +268,148 @@ function* runDurableChild( } /** - * Spawn a durable child workflow. + * Spawn a durable child workflow, and hand its task back to the caller. * - * Assigns a deterministic coroutine ID (parentId.N), sets up DurableContext - * on the child scope, and ensures Close events are emitted. + * Assigns a deterministic coroutine ID (`parentId.N`) in call order, sets up + * DurableContext on the child scope, and ensures a Close event is emitted. * - * Returns a Task that can be yield*-ed to get the child's result. + * **The task outlives this call.** It is started in the *routine's* own scope + * rather than inside the effect that returns it, so the caller can await it, + * cancel it, or leave it running beside other work. Spawning it through + * `ephemeral()` instead — as this once did — put it in a scope that closed as + * soon as the effect resolved, so every `yield* task` threw `halted`. * - * Returns Workflow> via ephemeral() — the infrastructure effects - * (useScope, spawn) are durable-safe scope setup that doesn't need - * journaling and re-runs correctly on replay. + * A retained `Close(cancelled)` here is read for *why* it was cancelled, not + * treated as one thing. `"unwound"` — the run was interrupted, and nothing will + * cancel this child again — resumes the work it had left. `"caller"`, and a + * legacy record that says nothing, is a stop this caller chose, and is + * reproduced by suspending until its deterministic control flow chooses it + * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( childWorkflow: () => Workflow, ): Workflow> { - return ephemeral( - (function* (): Operation> { - const scope = yield* useScope(); - const ctx = scope.expect(DurableContext); + return spawnDurableChild(childWorkflow, undefined); +} + +/** + * Spawn a durable child into `scope` rather than into the routine's own. + * + * Same child, same deterministic identity, same cancellation policy — only the + * lifetime differs. A caller that has to finish a region *after* its own + * cancellation has begun needs the child to outlive the scope being torn down, + * and a scope of its own is the only honest way to express that: the child then + * settles normally and writes its ordinary `Close`, and the caller decides when + * to destroy the scope. + * + * It grants nothing a caller does not already have. Placing a child somewhere + * is not replay authority, and the policy stays fixed at the call site. + */ +export function durableSpawnIn( + scope: Scope, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, scope); +} - // Assign deterministic child ID - const childIndex = ctx.childCounter++; - const childId = `${ctx.coroutineId}.${childIndex}`; +/** Both spellings of a durable spawn; `into` is the only thing that differs. */ +function spawnDurableChild( + childWorkflow: () => Workflow, + into: Scope | undefined, +): Workflow> { + return (function* (): Workflow> { + // Reading the context and allocating the child id is ordinary scope setup: + // no journal entry, and it re-runs identically on replay. Allocation is + // synchronous and in call order, so ids follow the order children are + // asked for rather than the order they are scheduled. + const ctx = yield* ephemeral(readDurableContext()); + const childIndex = ctx.childCounter++; + const childId = `${ctx.coroutineId}.${childIndex}`; + const evidence: CancellationEvidence = { deliberate: false }; + return (yield createSpawnEffect( + () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), + evidence, + into, + )) as Task; + })(); +} - // Spawn the child with durable wrapping - return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); - })(), - ); +function* readDurableContext(): Operation { + const scope = yield* useScope(); + return scope.expect(DurableContext); +} + +/** + * Start `child` in the routine's own scope and resolve with its task. + * + * The routine's scope is the workflow's, so the task lives for as long as the + * workflow does — that is the whole repair. Nothing is journaled: the child + * writes its own entries under its own coroutine id. + * + * A child that fails fails the workflow that spawned it, exactly as an ordinary + * Effection `spawn` does. What replay must not do is reach the child's body + * again to discover that. + */ +function createSpawnEffect( + child: () => Operation, + evidence: CancellationEvidence, + into?: Scope, +): DurableEffect> { + return { + description: "durable-spawn", + effectDescription: { type: "ephemeral", name: "durable-spawn" }, + enter(resolve, routine) { + const host = into ?? routine.scope; + resolve({ ok: true, value: observingDisposal(host.run(child), evidence) }); + return (exit) => exit({ ok: true, value: undefined as undefined }); + }, + }; +} + +/** + * The same task, with a deliberate stop recorded as it happens. + * + * The caller receives every member the task defines — `then`, `catch`, + * `finally`, the iterator — copied from the task itself along with its + * prototype, so the public surface is the one `Task` has always had. + * + * **Every** way a caller can stop the task is observed, not just the obvious + * one. `halt()` and `await using` — which reaches `Symbol.asyncDispose` and + * never touches `halt` — are the same decision spelled two ways, and a stop + * recorded as involuntary through either of them would be resumed on the next + * run as work nobody asked to redo. Awaiting the task is not a stop and is left + * exactly as it was. + * + * Copied rather than proxied: a task's members are read-only and + * non-configurable, and a proxy is required to hand back exactly what the + * target holds — so a `get` trap cannot substitute either of them. Each copied + * member is the task's own closure and keeps working on the copy. + */ +function observingDisposal(task: Task, evidence: CancellationEvidence): Task { + const members = Object.getOwnPropertyDescriptors(task); + // Replaced in the descriptor map rather than on the finished object: the + // task's own members are non-configurable, so redefining one afterwards + // throws. + const deliberate = (stop: () => R): (() => R) => { + return () => { + evidence.deliberate = true; + return stop(); + }; + }; + members.halt = { + value: deliberate(() => task.halt()), + enumerable: true, + configurable: false, + writable: false, + }; + members[Symbol.asyncDispose] = { + value: deliberate(() => task[Symbol.asyncDispose]()), + enumerable: false, + configurable: false, + writable: false, + }; + const observed: Task = Object.create(Object.getPrototypeOf(task), members); + return observed; } /** diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 03c313c0f..94a63b438 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -8,6 +8,7 @@ // Protocol types export type { + Cancellation, Close, CoroutineId, CoroutineView, @@ -100,7 +101,7 @@ export type { export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts"; // Structured concurrency combinators -export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; +export { durableAll, durableRace, durableSpawn, durableSpawnIn } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; diff --git a/packages/durable-streams/parse.ts b/packages/durable-streams/parse.ts index 0747bfdcb..aa74b2b6c 100644 --- a/packages/durable-streams/parse.ts +++ b/packages/durable-streams/parse.ts @@ -125,8 +125,20 @@ function parseResult(value: unknown, path: string): Result { return { status: "err", error: parseSerializedError(members.get("error"), `${path}.error`) }; } case "cancelled": { - requireMemberNames(members, ["status"], path); - return { status: "cancelled" }; + requireMemberNames(members, ["status", "cancellation"], path); + const cancellation = members.get("cancellation"); + if (cancellation === undefined) { + // A record written before this evidence existed. DEC-040 reads the + // absence as a deliberate stop, so nothing it left behind is revived. + return { status: "cancelled" }; + } + if (cancellation !== "caller" && cancellation !== "unwound") { + throw new MalformedDurableEventError( + 'expected "caller" or "unwound"', + `${path}.cancellation`, + ); + } + return { status: "cancelled", cancellation }; } default: throw new MalformedDurableEventError('expected "ok", "err" or "cancelled"', `${path}.status`); diff --git a/packages/durable-streams/replay-index.ts b/packages/durable-streams/replay-index.ts index 65e850866..7eeeaf672 100644 --- a/packages/durable-streams/replay-index.ts +++ b/packages/durable-streams/replay-index.ts @@ -77,6 +77,23 @@ export class ReplayIndex { this.disabled.add(coroutineId); } + /** + * Forget the retained Close for one coroutine, keeping its retained yields. + * + * A spawned region whose run was interrupted continues the work it had left, + * so its retained history must stay replayable while its retained + * `Close(cancelled)` stops standing in the way — otherwise the divergence + * guard reads the extra effects as a coroutine continuing past its own close. + * + * Deliberately narrower than `disableReplay`, which would throw the history + * away and re-run the child from the beginning. Internal: nothing exports + * this, because deciding that a closed coroutine may continue is the + * combinator's, and never a caller's. + */ + reopen(coroutineId: CoroutineId): void { + this.closes.delete(coroutineId); + } + /** Returns true if replay has been disabled for this coroutine. */ isReplayDisabled(coroutineId: CoroutineId): boolean { return this.disabled.has(coroutineId); diff --git a/packages/durable-streams/retained.ts b/packages/durable-streams/retained.ts index 380224464..ab872178b 100644 --- a/packages/durable-streams/retained.ts +++ b/packages/durable-streams/retained.ts @@ -170,7 +170,17 @@ function detachResult(result: Result): Result { } return Object.freeze({ status, error: detachError(result.error) }); } - return Object.freeze({ status }); + // The reason a cancellation carries is retained evidence, not decoration: a + // resumed spawned region reads it to tell a deliberate stop from an + // interrupted run (DEC-040). Dropping it here would make every retained + // cancellation read as deliberate, which is the safe default but the wrong + // answer for a run that was interrupted. A value that is not one of the two + // it may be is not retained at all, so a malformed record reads as the safe + // default rather than as something it never said. + const cancellation = result.cancellation; + return Object.freeze( + cancellation === "caller" || cancellation === "unwound" ? { status, cancellation } : { status }, + ); } /** @@ -448,5 +458,10 @@ export function consumable(result: Result): Result { if (result.status === "err") { return { status: "err", error: { ...result.error } }; } - return { status: "cancelled" }; + // The reason travels with the copy: a resumed spawned region reads it to tell + // a deliberate stop from an interrupted run (DEC-040), and dropping it here + // would make every retained cancellation look deliberate. + return result.cancellation === undefined + ? { status: "cancelled" } + : { status: "cancelled", cancellation: result.cancellation }; } diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index cec63a894..6be5d3b60 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -489,6 +489,94 @@ Updated before completion of every phase and committed at the end of each phase. finally block skips re-emitting it (checked via `replayIndex.hasClose()`). - **Consequences:** Replay of race losers is invisible — they block and get cancelled just like the original run. No duplicate Close events. +- **Superseded in part by DEC-039.** The invariant recorded here assumed every + retained `Close(cancelled)` belongs to a child a combinator will cancel + again. That is true of `durableRace` and `durableAll`, and false of + `durableSpawn`. + +## DEC-039: A spawned region resumes a retained cancelled child + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands its task to the caller, so nothing cancels + the child on the caller's behalf. Under DEC-024 a retained + `Close(cancelled)` made such a child `suspend()` forever, and no combinator + was ever going to cancel it a second time — the resumed run hung. The + invariant "this branch is only reachable when a parent combinator will cancel + this child" was simply not true once regions could be spawned. +- **Decision:** `runDurableChild` takes an explicit `CancelledChildPolicy`, + fixed at each combinator's call site and never chosen by a caller: + - `"combinator-cancels"` — `durableRace` and `durableAll` keep DEC-024 + exactly. A retained race loser or fail-fast sibling still suspends until + its combinator cancels it again. + - `"resume"` — `durableSpawn`. A retained `Close(cancelled)` under a parent + that never completed means the *run* was interrupted, not that a combinator + chose against this child, so the child continues the work it had left. +- **Rationale:** The two cases differ in who is going to act next, which is a + fact about the region rather than a preference. Reading a cancelled close as + "interrupted" where nothing will cancel it again is the only answer that + terminates. +- **Mechanism:** Resuming calls the internal `ReplayIndex.reopen(coroutineId)`, + which forgets that coroutine's retained Close while keeping its retained + yields — so the child continues its own history rather than restarting, and + the divergence guard does not read the remaining effects as a coroutine + continuing past its own close. It is deliberately narrower than + `disableReplay`, and neither is exported: deciding that a closed coroutine may + continue belongs to the combinator. +- **Consequences:** A resumed child writes the Close its second life reached, + replacing the retained cancelled one. `durableSpawn` also starts its child in + the routine's own scope rather than inside the `ephemeral` effect that + returns the task, so the task outlives the call and can be awaited or halted; + previously every `yield* task` threw `halted`. +- **Amended by DEC-040.** As first written, `"resume"` fired on *every* retained + `Close(cancelled)` under an incomplete parent. That is too wide: a caller may + deliberately halt the task it owns, and the record of that is + indistinguishable from the record of an interrupted run. DEC-040 supplies the + missing evidence and narrows `"resume"` to involuntary cancellation. + +## DEC-040: A cancelled child records why it was cancelled + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands the task to its caller, and the caller may + call `task.halt()` on purpose — a region it decided to stop. If the run is + later interrupted before the parent completes, the journal holds + `Close(cancelled)` for that child and nothing else. DEC-039's `"resume"` + policy therefore revives work the caller deliberately cancelled, on every + subsequent resumed run. +- **Decision:** The cancelled close carries **why**, written by whichever path + cancelled the child: + - `cancellation: "caller"` — the owner called `halt()` on the task + `durableSpawn` returned. A deliberate stop. + - `cancellation: "unwound"` — anything else: the routine's scope unwinding, + the run being interrupted, the host going away. Involuntary. + + A record with no `cancellation` member is legacy and reads as `"caller"`, + because refusing to revive is the safe direction: it reproduces the original + run rather than performing work nobody asked for twice. + + `runDurableChild`'s policies then read: + - `"combinator-cancels"` (`durableRace`, `durableAll`) — suspend, whatever the + reason. Unchanged from DEC-024. + - `"resume"` (`durableSpawn`) — resume **only** `"unwound"`. A `"caller"` + cancellation suspends, exactly as a combinator-cancelled child does. +- **Rationale:** Suspending is the faithful reproduction of a deliberate halt: + the caller's control flow is deterministic, so it reaches the same + `task.halt()` again and cancels the child a second time — which is DEC-024's + argument, applied to a caller instead of a combinator. A caller that instead + *awaits* a task it previously halted has diverged, and divergence is the + honest answer there rather than a silent revival. +- **Consequences:** Terminal grids get what they need without reviving anything + deliberately stopped. Reader close cooperatively closes each pane inside its + durable child and waits for that child to retain `closed`; it does not halt + the durable pane task. Once reader close takes effect, later parent + cancellation is deferred through pane and grid completion, so a resumed run + short-circuits the completed grid and never reaches those panes. The case that + must resume — the run interrupted while the grid is still active — unwinds + the grid and pane children, retains `"unwound"`, and continues. +- **Scope:** The reason is retained evidence, not authority. Nothing reads it + from outside `runDurableChild`, no public API exposes it, and no caller + chooses a policy: the policy stays fixed at each combinator's call site. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/specs/durable-streams.md b/packages/durable-streams/specs/durable-streams.md index 5f1b1b5ec..95540ba37 100644 --- a/packages/durable-streams/specs/durable-streams.md +++ b/packages/durable-streams/specs/durable-streams.md @@ -130,6 +130,14 @@ function* runWithDurability(operation, producer) { } ``` +A `cancelled` Close also records **why**, because two very different things +produce one: a caller deliberately halting a task it owns, and a run being +interrupted. `cancellation: "caller"` is the deliberate stop; `"unwound"` is +everything involuntary. A resumed spawned region continues an `"unwound"` child +and reproduces a `"caller"` one by suspending, so nothing deliberately stopped +is silently performed again. A record with no `cancellation` member reads as +`"caller"`. See DEC-040. + For **Close events**, the ordering discipline is: ```typescript diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts new file mode 100644 index 000000000..1f35445f7 --- /dev/null +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -0,0 +1,647 @@ +/** + * `durableSpawn` — a durable child the caller owns. + * + * `durableAll` and `durableRace` own their children: they start them, wait for + * them, and cancel them. `durableSpawn` does not — it hands the task back, and + * everything here follows from that. + * + * Two things are easy to get wrong and are checked directly rather than + * inferred. The task has to outlive the call that produced it, or awaiting it + * throws `halted` before the child has done anything. And a retained + * `Close(cancelled)` means something different here than it does under a + * combinator: nobody is going to cancel this child a second time, so a child + * that suspended waiting for that would hang the resumed run forever. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation } from "effection"; + +import { durableRun } from "../run.ts"; +import { durableAll, durableRace, durableSpawn } from "../combinators.ts"; +import { durableCall } from "../operations.ts"; +import { ephemeral } from "../ephemeral.ts"; +import { InMemoryStream } from "../stream.ts"; +import type { Workflow } from "../types.ts"; + +/** A workflow that records that it ran and returns `value`. */ +function marking(marks: string[], mark: string, value: string): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push(mark); + return value; + })(), + ); + }; +} + +describe("durableSpawn — lifetime", () => { + it("returns a task that is still live, and awaitable", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "child", "spawned")); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("spawned"); + expect(marks).toEqual(["child"]); + }); + + it("keeps the task running beside its caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* sleep(5); + marks.push("child finished"); + return "late"; + })(), + ); + }); + // The caller does its own work first. A task spawned into a scope that + // closed with the effect would already be dead by now. + yield* ephemeral( + (function* (): Operation { + marks.push("caller working"); + })(), + ); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("late"); + expect(marks).toEqual(["caller working", "child finished"]); + }); + + it("lets the caller cancel the task it was given", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("child started"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + marks.push("caller halted it"); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(marks).toEqual(["child started", "caller halted it"]); + const closes = (yield* stream.readAll()).filter((event) => event.type === "close"); + // Cancelling the task records the child's cancellation, exactly as a + // combinator-cancelled child records one. + expect(closes.some((event) => event.result.status === "cancelled")).toBe(true); + }); + + it("allocates child ids in the order children are asked for", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const first = yield* durableSpawn(marking([], "a", "a")); + const second = yield* durableSpawn(marking([], "b", "b")); + yield* ephemeral(first); + yield* ephemeral(second); + return "done"; + }, + { stream }, + ); + + const ids = (yield* stream.readAll()) + .filter((event) => event.type === "close") + .map((event) => String(event.coroutineId)); + expect(ids).toContain("root.0"); + expect(ids).toContain("root.1"); + }); +}); + +describe("durableSpawn — replay", () => { + it("replays a completed child without running it again", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const first = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + expect(first).toBe("value"); + expect(marks).toEqual(["ran"]); + + const second = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + + // The retained result, and the workflow never entered. + expect(second).toBe("value"); + expect(marks).toEqual(["ran"]); + }); + + it("resumes an interrupted child rather than hanging on its cancelled close", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + // A run interrupted while the child is still working: the whole run is + // halted, so the child records Close(cancelled) and the parent records no + // Close at all. A parent that completed would replay its own result and the + // child would never be reached. + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* interrupted.halt(); + + expect(marks).toEqual(["first life"]); + + // The resumed run. Nothing is going to cancel this child again, so a child + // that suspended on the retained cancelled close would never settle. + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("second life"); + return "finished"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("finished"); + expect(marks).toEqual(["first life", "second life"]); + // The record now describes the life that actually finished. + const closes = (yield* stream.readAll()).filter( + (event) => event.type === "close" && String(event.coroutineId) === "root.0", + ); + expect(closes[closes.length - 1]?.result.status).toBe("ok"); + }); + + it("continues a resumed child's own retained history", function* () { + const calls: string[] = []; + const stream = new InMemoryStream(); + const step = (name: string) => + durableCall(name, function* () { + calls.push(name); + return name; + }); + + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + yield* step("first"); + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* interrupted.halt(); + + expect(calls).toEqual(["first"]); + + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + yield* step("first"); + yield* step("second"); + return "done"; + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("done"); + // `first` came from the child's own retained history; only the work it had + // left ran again. + expect(calls).toEqual(["first", "second"]); + }); +}); + +describe("durableSpawn — the combinators keep their own policy", () => { + it("a retained race loser still suspends until the race cancels it", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + marks.length = 0; + + // The loser's Close(cancelled) is retained. On replay it suspends and the + // race cancels it again, exactly as the first run did — it does not resume. + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); + + it("a retained fail-fast sibling still suspends under all()", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const both = () => + durableAll([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("failing"); + throw new Error("sibling failed"); + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("cancelled sibling"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + let first: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + first = error; + } + expect(first instanceof Error ? first.message : "").toContain("sibling failed"); + + marks.length = 0; + let second: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + second = error; + } + + expect(second instanceof Error ? second.message : "").toContain("sibling failed"); + // Neither child re-ran: the failure replayed and the sibling suspended. + expect(marks).toEqual([]); + }); +}); + +describe("durableSpawn — why a child was cancelled (DEC-040)", () => { + /** Every cancelled close in a journal, with the reason it recorded. */ + function* cancellations(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + return events + .filter((event) => event.type === "close" && event.result.status === "cancelled") + .map((event) => + event.result.status === "cancelled" ? String(event.result.cancellation) : "", + ); + } + + /** + * A child that says when it is running and then waits to be stopped. + * + * Every row below halts or unwinds a *live* child, and `started` is how each + * one knows the child is live. Nothing waits for a duration: a child that + * never started never resolves it, and the row hangs rather than recording a + * cancellation of something that was not running. + */ + function living( + started: { resolve: () => void }, + mark?: (note: string) => void, + ): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + mark?.("first life"); + started.resolve(); + yield* suspend(); + return "never"; + })(), + ); + }; + } + + it("records a deliberate halt as caller", function* () { + const stream = new InMemoryStream(); + const started = withResolvers(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started)); + yield* ephemeral( + (function* (): Operation { + // The child is running; stopping it now is a deliberate stop of + // live work rather than of whatever a delay happened to reach. + yield* started.operation; + yield* task.halt(); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(yield* cancellations(stream)).toEqual(["caller"]); + }); + + it("records a scope unwinding as unwound", function* () { + const stream = new InMemoryStream(); + const started = withResolvers(); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(living(started)); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + // Interrupted while the child is live, said by the child. + yield* started.operation; + yield* run.halt(); + + expect(yield* cancellations(stream)).toEqual(["unwound"]); + }); + + it("does not revive a child the caller deliberately halted", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const started = withResolvers(); + const halted = withResolvers(); + + // The caller halts the child on purpose, and only then is the run + // interrupted — so the journal holds both facts and only the first decides. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); + yield* ephemeral( + (function* (): Operation { + yield* started.operation; + yield* task.halt(); + halted.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* halted.operation; + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // The resumed run reaches the same deliberate halt. Getting there is the + // proof of non-revival: a revived child would have recorded its mark before + // the caller could halt it, and the mark list is checked after. + const reachedTheHalt = withResolvers(); + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* task.halt(); + reachedTheHalt.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* reachedTheHalt.operation; + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + + it("records disposal through Symbol.asyncDispose as caller, and does not revive", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const started = withResolvers(); + const disposed = withResolvers(); + + // `await using` stops a task without ever touching `halt()`. It is the same + // decision spelled another way, so it has to leave the same evidence. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); + yield* ephemeral( + (function* (): Operation { + yield* started.operation; + yield* until(task[Symbol.asyncDispose]()); + disposed.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* disposed.operation; + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // Resuming that journal must not enter the child again. + const reachedTheDisposal = withResolvers(); + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* until(task[Symbol.asyncDispose]()); + reachedTheDisposal.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* reachedTheDisposal.operation; + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + + it("reads a record with no reason as caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // A journal written before this evidence existed. + yield* stream.append({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled" }, + }); + + const asked = withResolvers(); + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("would revive"); + return "revived"; + })(), + ); + }); + // The child has been asked for and the request has returned. A + // revived child would have recorded its mark by now. + asked.resolve(); + return yield* ephemeral(task); + }, + { stream }, + ); + }); + yield* asked.operation; + yield* run.halt(); + + // Absent evidence is the safe direction: nothing is revived. + expect(marks).toEqual([]); + }); + + it("keeps combinator children on DEC-024 whatever the reason says", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + // The loser's cancellation is involuntary, so it records `unwound` — and a + // combinator child suspends regardless of what the reason says. + expect(yield* cancellations(stream)).toEqual(["unwound"]); + + marks.length = 0; + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); +}); diff --git a/packages/durable-streams/tests/parse.test.ts b/packages/durable-streams/tests/parse.test.ts index a30efa8a4..3dbed1007 100644 --- a/packages/durable-streams/tests/parse.test.ts +++ b/packages/durable-streams/tests/parse.test.ts @@ -270,3 +270,41 @@ describe("parseDurableEvent", () => { expect("polluted" in {}).toBe(false); }); }); + +describe("a cancelled close carries why it was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("round-trips both reasons", function* () { + for (const reason of ["caller", "unwound"] as const) { + const event = cancelled(reason); + const record = serializeDurableEvent(event); + expect(accepted(record)).toEqual(event); + // And back to the same bytes, so a backend retains the event rather than + // an approximation of it. + expect(serializeDurableEvent(accepted(record))).toBe(record); + } + }); + + it("keeps a legacy record's absence an absence", function* () { + const parsed = accepted(serializeDurableEvent(cancelled())); + expect(parsed).toEqual(cancelled()); + expect(parsed.result.status === "cancelled" && "cancellation" in parsed.result).toBe(false); + }); + + it("refuses a reason it does not recognise", function* () { + const refused = refusal( + JSON.stringify({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" }, + }), + ); + expect(refused).toBeInstanceOf(MalformedDurableEventError); + expect(refused.message).toContain("$.result.cancellation"); + }); +}); diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index 72f6251c6..b4a57970e 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { detachJson, retainEvents } from "../retained.ts"; +import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; import type { DurableEvent, Json } from "../types.ts"; @@ -443,3 +443,46 @@ describe("retained history — detached values stay ordinary JSON", () => { expect(caught).toBeInstanceOf(TypeError); }); }); + +describe("retention keeps why a child was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("retains both reasons through the settled copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + const [retained] = retainEvents([cancelled(reason)]); + expect(retained?.type).toBe("close"); + expect(retained?.result).toEqual({ status: "cancelled", cancellation: reason }); + } + }); + + it("leaves a legacy absence absent", function* () { + const [retained] = retainEvents([cancelled()]); + expect(retained?.result).toEqual({ status: "cancelled" }); + expect( + retained !== undefined && + retained.result.status === "cancelled" && + "cancellation" in retained.result, + ).toBe(false); + }); + + it("carries both reasons through an observable copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + expect(consumable(cancelled(reason).result)).toEqual({ + status: "cancelled", + cancellation: reason, + }); + } + expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); + }); + + it("reaches the replay index with its reason intact", function* () { + const index = new ReplayIndex([cancelled("unwound")]); + const close = index.getClose("root.0"); + expect(close?.result).toEqual({ status: "cancelled", cancellation: "unwound" }); + }); +}); diff --git a/packages/durable-streams/types.ts b/packages/durable-streams/types.ts index 2e79846b2..3523ab53d 100644 --- a/packages/durable-streams/types.ts +++ b/packages/durable-streams/types.ts @@ -23,11 +23,23 @@ export interface SerializedError { stack?: string; } +/** + * Why a cancelled coroutine stopped (DEC-040). + * + * Two very different things produce a cancelled Close, and a resumed run has to + * tell them apart: `"caller"` is an owner deliberately halting the task + * `durableSpawn` handed it, and `"unwound"` is anything involuntary — a scope + * coming down, a run interrupted, a host going away. A record written before + * this evidence existed carries neither, and reads as `"caller"`, because + * refusing to revive is the safe direction. + */ +export type Cancellation = "caller" | "unwound"; + /** Result of an effect or coroutine. */ export type Result = | { status: "ok"; value?: Json } | { status: "err"; error: SerializedError } - | { status: "cancelled" }; + | { status: "cancelled"; cancellation?: Cancellation }; /** Dot-delimited hierarchical coroutine path. See spec §3. */ export type CoroutineId = string; diff --git a/packages/grid-tmux/deno.json b/packages/grid-tmux/deno.json new file mode 100644 index 000000000..9efe0de0c --- /dev/null +++ b/packages/grid-tmux/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@executablemd/grid-tmux", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./test": "./testing.ts" + } +} diff --git a/packages/grid-tmux/mod.ts b/packages/grid-tmux/mod.ts new file mode 100644 index 000000000..ee6573c40 --- /dev/null +++ b/packages/grid-tmux/mod.ts @@ -0,0 +1,28 @@ +/** + * The tmux presentation provider for grids + * (architecture.md §Package ownership). + * + * The first implementation of the provider-neutral domain in + * `@executablemd/grid`, and the only place tmux appears. A host that can + * divide its terminal installs this; one that cannot installs nothing and the + * document meets core's own refusal rather than a provider that half-works. + * + * The surface is deliberately narrow: a name, what a host must supply, the + * factory and its installer, the hidden verb one pane's worker is re-invoked + * under, and the refusals a reader can actually meet. Every tmux command, the + * private protocol, the channel handles, the layout mechanics and the teardown + * controls stay inside — a second provider API is not what this is. The seams + * this adapter's own tests drive live in `./test`. + */ + +export { installTmuxGridProvider, TMUX_PROVIDER, tmuxGridProvider } from "./src/provider.ts"; +export type { TmuxProviderDependencies } from "./src/provider.ts"; + +export { + PANE_WORKER_COMMAND, + PaneNotQuiescent, + paneWorkerInvocation, + runPaneWorkerProcess, +} from "./src/pane-worker.ts"; + +export { GridTeardownFailed, TMUX_UNAVAILABLE, TmuxUnavailableError } from "./src/tmux.ts"; diff --git a/packages/grid-tmux/package.json b/packages/grid-tmux/package.json new file mode 100644 index 000000000..93f71f1b7 --- /dev/null +++ b/packages/grid-tmux/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executablemd/grid-tmux", + "version": "0.11.0", + "description": "The tmux presentation provider for executable.md grids.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./test": "./testing.ts" + }, + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/grid": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6" + } +} diff --git a/packages/grid-tmux/src/attach-client.ts b/packages/grid-tmux/src/attach-client.ts new file mode 100644 index 000000000..225cac036 --- /dev/null +++ b/packages/grid-tmux/src/attach-client.ts @@ -0,0 +1,177 @@ +/** + * The one visible client: the reader's own view of a grid + * (architecture.md §Interactive grids). + * + * Deliberately *not* a pane child. A pane's child is settled by sweeping the + * pane's process group and the pane's terminal, because a pane's terminal + * belongs to the grid. This process's terminal belongs to the run: the things + * holding it are XMD itself, whatever started XMD, and everything else in XMD's + * foreground process group. A settlement of that shape pointed at this client + * would be a settlement pointed at the document. + * + * So the rule here is narrow and absolute. This ends **one** process — the exact + * one it started — and nothing else. It signals no group, sweeps no terminal, + * and follows no descendants. Ending it is asked for first, through tmux, so + * the client detaches and restores the terminal itself; a signal is what + * follows only if the ask did not work, and it goes to that pid alone. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { ensure, race, resource, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { deliverSignal, processReachable } from "@executablemd/grid/processes"; +import { GridTeardownFailed } from "./tmux.ts"; + +export interface AttachClient { + /** The client process, once the runtime says it started. */ + readonly pid: number; + /** Settles when it leaves, however it leaves. */ + readonly exited: Operation; + /** + * End it: ask first, then insist on this pid alone. + * + * Idempotent, and safe to call from a finalizer — a client that already left + * is the outcome this was asking for. + */ + stop(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start the visible client, and own exactly its lifetime. + * + * `askToLeave` is the provider's way of telling tmux to detach this client. It + * runs before any signal, because a client asked to detach restores the + * terminal and one that is killed cannot. + */ +export function useAttachClient(options: { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Record; + askToLeave(): Operation; +}): Operation { + return resource(function* (provide) { + const [command, ...args] = options.argv; + if (command === undefined) { + throw new Error("the visible client names no command"); + } + const started = withResolvers(); + const failed = withResolvers(); + const exited = withResolvers(); + let gone = false; + let child: ChildProcess | undefined; + let stopping: ReturnType> | undefined; + + function* stop(): Operation { + if (stopping) { + return yield* stopping.operation; + } + stopping = withResolvers(); + try { + yield* end(); + stopping.resolve(); + } catch (error) { + stopping.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* end(): Operation { + const pid = child?.pid; + if (gone || pid === undefined) { + return; + } + // Asked, not told. This is the only path that gives the reader their + // terminal back in the state they lent it. + yield* options.askToLeave(); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + // It did not leave. From here the escalation names this one pid and + // nothing else: no process group, no terminal holders, no descendants — + // every one of which would, on this terminal, be the run itself. + yield* deliverSignal(pid, "SIGTERM"); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + yield* deliverSignal(pid, "SIGKILL"); + if (yield* leftWithin(KILL_SETTLE_MS, pid)) { + return; + } + // Everything this may do has been done, and the client is still there. + // Saying "torn down" now would be saying it about a process still holding + // the reader's terminal — so the document stops instead. Provider-neutral + // by construction: no socket, session, client name, argv, environment, + // terminal or host message goes into it. + throw new GridTeardownFailed("the grid's visible client did not stop"); + } + + function* leftWithin(limitMs: number, pid: number): Operation { + const deadline = Date.now() + limitMs; + while (true) { + if (gone || !(yield* processReachable(pid))) { + return true; + } + if (Date.now() >= deadline) { + break; + } + yield* sleep(POLL_MS); + } + // One more look, at the boundary itself. A client that left during the + // last interval is gone, and reporting it as still there on the strength + // of a cached event would be reporting a stale reading. + return gone || !(yield* processReachable(pid)); + } + + // Registered before the spawn: a halt between starting a client and + // registering its cleanup would leave it holding the terminal. + yield* ensure(function* () { + yield* stop(); + }); + + child = spawnChild(command, args, { + cwd: options.cwd, + env: options.env, + // The reader's terminal, handed straight through. + stdio: "inherit", + }); + // Named, and removed by this scope. `exit` stays through the wait that + // establishes the client is gone, which is exactly why it is removed with + // the resource rather than after one delivery. + // One of the two arrives, and whichever does takes both off. `exit` stays: + // establishing this client is gone is what waits on it. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); + if (child?.pid !== undefined) { + started.resolve(child.pid); + } + } + function onError(error: Error): void { + settleStartup(); + failed.reject(error); + } + const onExit = (): void => { + gone = true; + exited.resolve(); + }; + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + settleStartup(); + child?.off("exit", onExit); + }); + + // The pid, or whatever arrived instead of a start. + const pid = yield* race([started.operation, failed.operation]); + yield* provide({ pid, exited: exited.operation, stop }); + }); +} diff --git a/packages/grid-tmux/src/layout.ts b/packages/grid-tmux/src/layout.ts new file mode 100644 index 000000000..5e542d630 --- /dev/null +++ b/packages/grid-tmux/src/layout.ts @@ -0,0 +1,197 @@ +/** + * The authored grid as explicit tmux geometry + * (architecture.md §Interactive grids). + * + * `select-layout tiled` picks its own column count from the window's + * dimensions, so it cannot implement a `columns` the author wrote: the same + * four panes become 2×2 in one terminal and 4×1 in another. A layout string + * can. tmux accepts the same description it prints in `#{window_layout}` — a + * checksum, then a tree of cells where `{…}` lays children left to right and + * `[…]` top to bottom, each leaf naming a pane id. + * + * So every cell is sized here, row-major from the pane count and `columns`, and + * tmux is told rather than asked. A final row with fewer panes than columns + * spans the row, because tmux has no empty cells and the author wrote panes + * rather than a rectangle. + * + * One thing the string cannot do is place a *particular* pane: tmux fills the + * leaves in window-list order and ignores the pane ids they name. Authored + * order is imposed afterwards, by swapping panes into position — which is why + * `swapsInto()` lives here beside the geometry rather than in the provider. + */ + +/** One pane's rectangle, in tmux's character coordinates. */ +export interface LayoutCell { + readonly ordinal: number; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * Split `total` into `count` parts, leaving one column or row between them for + * tmux's separator. The remainder goes to the leftmost or topmost parts, which + * is what tmux itself does. + */ +function partition(total: number, count: number): number[] { + const available = total - (count - 1); + const base = Math.floor(available / count); + const extra = available - base * count; + return Array.from({ length: count }, (_, index) => base + (index < extra ? 1 : 0)); +} + +/** The row-major rectangles for `count` panes in `columns` columns. */ +export function rowMajorCells( + width: number, + height: number, + columns: number, + count: number, +): readonly LayoutCell[] { + const rows = Math.ceil(count / columns); + const heights = partition(height, rows); + const cells: LayoutCell[] = []; + let top = 0; + for (let row = 0; row < rows; row++) { + const inRow = Math.min(columns, count - row * columns); + const widths = partition(width, inRow); + const rowHeight = heights[row] ?? 0; + let left = 0; + for (let column = 0; column < inRow; column++) { + const cellWidth = widths[column] ?? 0; + cells.push({ + ordinal: row * columns + column, + left, + top, + width: cellWidth, + height: rowHeight, + }); + left += cellWidth + 1; + } + top += rowHeight + 1; + } + return cells; +} + +/** tmux's `layout_checksum`, so the string is accepted as one of its own. */ +function checksum(layout: string): string { + let sum = 0; + for (let index = 0; index < layout.length; index++) { + sum = ((sum >> 1) + ((sum & 1) << 15)) & 0xffff; + sum = (sum + layout.charCodeAt(index)) & 0xffff; + } + return sum.toString(16).padStart(4, "0"); +} + +/** + * The layout string that gives ordinal `i` the cell `paneIds[i]` names. + * + * Pane ids are the numeric part of tmux's `%N`. tmux ignores which pane each + * leaf names — see `swapsInto()` — but the string still has to name real ones + * for tmux to accept it. + */ +export function layoutString( + width: number, + height: number, + columns: number, + paneIds: readonly number[], +): string { + const cells = rowMajorCells(width, height, columns, paneIds.length); + const rows = Math.ceil(paneIds.length / columns); + const rowStrings: string[] = []; + for (let row = 0; row < rows; row++) { + const inRow = cells.filter((cell) => Math.floor(cell.ordinal / columns) === row); + const leaves = inRow.map( + (cell) => `${cell.width}x${cell.height},${cell.left},${cell.top},${paneIds[cell.ordinal]}`, + ); + const first = inRow[0]; + if (first === undefined) { + continue; + } + rowStrings.push( + leaves.length === 1 + ? (leaves[0] ?? "") + : `${width}x${first.height},0,${first.top}{${leaves.join(",")}}`, + ); + } + const body = + rowStrings.length === 1 + ? (rowStrings[0] ?? "") + : `${width}x${height},0,0[${rowStrings.join(",")}]`; + return `${checksum(body)},${body}`; +} + +/** One swap: put the pane now at `from` into the position `to` holds. */ +export interface PaneSwap { + readonly from: number; + readonly to: number; +} + +/** + * The swaps that turn tmux's window order into the authored one. + * + * `present[i]` is the pane id tmux currently has in position `i`; `wanted[i]` is + * the pane id ordinal `i` was authored for. Selection sort, because each swap + * exchanges two positions and there is no cheaper honest way to say it: the + * result is the shortest sequence that leaves every position holding the pane + * the author put there. + * + * An already-correct order produces no swaps at all, which is the case a + * provider must not do work for. + */ +export function swapsInto( + present: readonly number[], + wanted: readonly number[], +): readonly PaneSwap[] { + const order = [...present]; + const swaps: PaneSwap[] = []; + for (let position = 0; position < wanted.length; position++) { + const target = wanted[position]; + if (target === undefined || order[position] === target) { + continue; + } + const found = order.indexOf(target, position); + if (found === -1) { + // The window does not hold the pane this ordinal was authored for, so no + // sequence of swaps produces the authored order. Saying so is the honest + // answer; swapping anyway would place a pane the author did not write. + throw new Error(`pane ${target} is not in this window, so ordinal ${position} cannot be set`); + } + const displaced = order[position]; + if (displaced === undefined) { + continue; + } + order[position] = target; + order[found] = displaced; + swaps.push({ from: found, to: position }); + } + return swaps; +} + +/** Whether observed geometry is the row-major placement `columns` describes. */ +export function placementProblems( + observed: readonly LayoutCell[], + columns: number, +): readonly string[] { + const problems: string[] = []; + const byOrdinal = [...observed].sort((left, right) => left.ordinal - right.ordinal); + for (const cell of byOrdinal) { + const column = cell.ordinal % columns; + const above = byOrdinal.find((other) => other.ordinal === cell.ordinal - columns); + const leftOf = + column > 0 ? byOrdinal.find((other) => other.ordinal === cell.ordinal - 1) : undefined; + if (above !== undefined && cell.top !== above.top + above.height + 1) { + problems.push(`pane ${cell.ordinal} is not directly below pane ${above.ordinal}`); + } + if ( + leftOf !== undefined && + !(cell.left === leftOf.left + leftOf.width + 1 && cell.top === leftOf.top) + ) { + problems.push(`pane ${cell.ordinal} is not directly right of pane ${leftOf.ordinal}`); + } + if (column === 0 && cell.left !== 0) { + problems.push(`pane ${cell.ordinal} should start a row at the left edge`); + } + } + return problems; +} diff --git a/packages/grid-tmux/src/pane-channel.ts b/packages/grid-tmux/src/pane-channel.ts new file mode 100644 index 000000000..44cfd4652 --- /dev/null +++ b/packages/grid-tmux/src/pane-channel.ts @@ -0,0 +1,402 @@ +/** + * The parent's end of one grid's private worker channels + * (architecture.md §Interactive grids). + * + * One directory per grid, mode 0700, under `$TMPDIR` so the socket paths stay + * inside the 104-byte cap a Unix socket has. Inside it, one socket and one + * mode-0600 token per pane, both written *before* any pane exists — a worker + * that starts finds its socket already listening rather than racing it. + * + * Admission is the whole security boundary. A connection is admitted when its + * first frame is a `hello` naming this pane's ordinal and carrying this pane's + * token; a connection that says anything else, says it too late, names another + * ordinal, or arrives after that pane is already admitted is closed without + * being answered. The token is single-use by construction — the worker removes + * the file as it reads it — so a second reader finds nothing to present. + * + * Everything here dies with the scope: sockets destroyed, servers closed, and + * the directory with its tokens removed, whichever way the grid ended. + */ + +import { randomBytes } from "node:crypto"; +import net from "node:net"; +import type { Server, Socket } from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + ensure, + createSignal, + race, + resource, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { + paneSocketPath, + paneTokenPath, + parseFromWorker, + readFrames, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Hello, ToWorker } from "./pane-protocol.ts"; + +/** How long a connection has to present its `hello` before it is dropped. */ +const HELLO_GRACE_MS = 10_000; + +/** The parent's end of one admitted worker. */ +export interface PaneLink { + readonly ordinal: number; + /** What the worker said about the pane it woke up in. */ + readonly hello: Hello; + send(message: ToWorker): Operation; + /** The next frame, or `undefined` once the worker's connection closed. */ + next(): Operation; + connected(): boolean; +} + +export interface PaneChannels { + /** The private directory, which tmux is told and nothing else learns. */ + readonly directory: string; + /** The admitted worker for `ordinal`; waits for its `hello`. */ + link(ordinal: number): Operation; + /** Connections closed without admission, for a diagnostic to name. */ + refusals(): readonly string[]; + /** + * Close every socket and server, and wait for them. + * + * Callable by a teardown that has to put this in a particular place in its + * order; the scope runs it too, so a caller that never gets there still + * leaves nothing open. Idempotent. + */ + close(): Operation; +} + +interface Slot { + readonly waiting: ReturnType>; + admitted: boolean; +} + +/** + * Open one grid's private directory and listen for `count` workers. + * + * The directory is created 0700 and removed with the scope. A host whose + * temporary directory is world-writable still gets a private grid, because the + * mode is set on the directory this creates rather than inherited from it. + */ +export function usePaneChannels( + count: number, + options: { + onClosed?: () => void; + /** Handed each accepted socket, so a suite can ask what it still holds. */ + onSocket?: (socket: Socket) => void; + /** Handed each listening server, for the same reason. */ + onServer?: (server: Server) => void; + /** + * Called as the directory is removed, with how many of the sockets and + * servers had actually reported closing by then. + * + * Counted from their own `close` events rather than from having asked, so a + * caller can tell "closed" from "told to close". + */ + onRemoved?: (facts: { closed: number; total: number }) => void; + } = {}, +): Operation { + return resource(function* (provide) { + // Directly under `$TMPDIR`: a socket path is capped at 104 bytes, and a + // directory named after a repository path spends most of that before the + // socket name begins. + const directory = path.join(os.tmpdir(), `xmd-grid-${randomBytes(6).toString("hex")}`); + yield* ensureDir(directory); + yield* until(chmod(directory, 0o700)); + // Registered first, so it runs last: the directory goes only after every + // socket and server below has actually closed. Removing it while a server + // still listened would leave a socket bound to a path nothing can name. + yield* ensure(function* () { + options.onRemoved?.({ closed: closedCount, total: closable }); + yield* rm(directory, { recursive: true, force: true }); + }); + + const tokens = new Map(); + const slots = new Map(); + const servers: Server[] = []; + const live = new Set(); + const refusals: string[] = []; + const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); + /** Closures that have actually happened, by their own events. */ + let closedCount = 0; + let closable = 0; + + // Awaited, not asked for. `destroy()` and `close()` are requests; what the + // directory's removal has to wait for is the closures themselves. + let closing: ReturnType> | undefined; + /** Handles that have actually closed, so a retry does not close them twice. */ + const shut = new Set(); + + function* closeAll(): Operation { + if (closing !== undefined) { + // Published before anything is closed, so a second caller arriving + // mid-close waits for this one rather than starting its own or being + // told it had already finished. + return yield* closing.operation; + } + closing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + const waits: Operation[] = []; + + // The requests are inside the same failure boundary as the waits: asking + // a handle to close is as capable of failing as waiting for it, and a + // request that threw must not stop the others being asked. The watch for + // one that threw is abandoned rather than awaited — nothing is going to + // close it — and the handle is left out of `shut`, so a later call asks + // again. + for (const socket of [...live]) { + if (shut.has(socket)) { + continue; + } + const watch = closedSocket(socket, () => { + closedCount++; + shut.add(socket); + }); + try { + socket.destroy(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } + } + for (const server of servers) { + if (shut.has(server)) { + continue; + } + const watch = closedServer(server, () => { + closedCount++; + shut.add(server); + }); + try { + server.close(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } + } + for (const wait of waits) { + try { + yield* wait; + } catch (error) { + failed(error); + } + } + if (failure !== undefined) { + // Cleared, so a later call retries the handles that did not close and + // leaves the ones that did alone. + closing.reject(failure); + closing = undefined; + throw failure; + } + options.onClosed?.(); + closing.resolve(); + } + + yield* ensure(function* () { + yield* closeAll(); + }); + + // Subscribed before a single server listens, so no arrival is missed. + const incoming = yield* arrivals; + + for (let ordinal = 0; ordinal < count; ordinal++) { + const token = randomBytes(16).toString("hex"); + tokens.set(ordinal, token); + slots.set(ordinal, { waiting: withResolvers(), admitted: false }); + yield* writeTextFile(paneTokenPath(directory, ordinal), token); + yield* until(chmod(paneTokenPath(directory, ordinal), 0o600)); + + // Named, every one of them. `createServer(cb)` and `listen(cb)` both + // register anonymous listeners that nothing can take off again. + const server = net.createServer(); + const onConnection = (socket: Socket): void => { + live.add(socket); + closable++; + const onSocketClose = (): void => { + live.delete(socket); + socket.off("close", onSocketClose); + }; + socket.on("close", onSocketClose); + options.onSocket?.(socket); + arrivals.send({ ordinal, socket }); + }; + server.on("connection", onConnection); + servers.push(server); + options.onServer?.(server); + closable++; + yield* ensure(() => { + server.off("connection", onConnection); + }); + + const listening = withResolvers(); + const onListening = (): void => listening.resolve(); + const onListenError = (error: Error): void => listening.reject(error); + server.on("listening", onListening); + server.on("error", onListenError); + server.listen(paneSocketPath(directory, ordinal)); + try { + // Both stay installed through the wait they resolve. + yield* listening.operation; + } finally { + // And come off synchronously once it is over, however it ended. + server.off("listening", onListening); + server.off("error", onListenError); + } + } + + function* admit(ordinal: number, socket: Socket): Operation { + const slot = slots.get(ordinal); + const token = tokens.get(ordinal); + const frames = yield* readFrames(socket, (value) => parseFromWorker(value)); + const first = yield* race([frames.next(), silence()]); + if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { + refusals.push(`pane ${ordinal}: a connection that did not say hello`); + socket.destroy(); + return; + } + const hello = first.value; + if (slot.admitted) { + refusals.push(`pane ${ordinal}: a second connection to an admitted pane`); + socket.destroy(); + return; + } + if (hello.ordinal !== ordinal || hello.token !== token) { + // Deliberately one message for both: an attacker learns nothing from + // which half was wrong. + refusals.push(`pane ${ordinal}: a connection that could not prove it is this pane`); + socket.destroy(); + return; + } + slot.admitted = true; + slot.waiting.resolve({ + ordinal, + hello, + send: (message) => writeFrame(socket, message), + *next() { + const next = yield* frames.next(); + return next.done ? undefined : next.value; + }, + connected: () => !socket.destroyed, + }); + } + + yield* spawn(function* () { + while (true) { + const next = yield* incoming.next(); + if (next.done) { + return; + } + const { ordinal, socket } = next.value; + yield* spawn(function* () { + yield* admit(ordinal, socket); + // The frame reader is this task's, so this task has to outlive the + // admission: a reader torn down at the handshake would leave a link + // that never hears another word. + yield* suspend(); + }); + } + }); + + yield* provide({ + directory, + *link(ordinal) { + const slot = slots.get(ordinal); + if (slot === undefined) { + throw new Error(`this grid has no pane ${ordinal}`); + } + return yield* slot.waiting.operation; + }, + refusals: () => [...refusals], + close: closeAll, + }); + }); +} + +/** Settle once this socket has closed, whether or not it already had. */ +function closedSocket(socket: Socket, onClosed: () => void): CloseWatch { + // Attached now, awaited later. The caller asks for this *before* destroying + // the socket, so a listener attached lazily would miss the close it is + // waiting for — and the directory would go while the socket was still open. + const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; + if (socket.destroyed) { + onClosed(); + done.resolve(); + } else { + socket.on("close", onClose); + } + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + // Removed synchronously when the wait is over, however it ends. + socket.off("close", onClose); + } + })(), + abandon: () => socket.off("close", onClose), + }; +} + +/** + * A closure this code is already listening for. + * + * Two halves because asking a handle to close can fail: the listener has to be + * on before the request, and a request that threw leaves nothing to wait for. + * `abandon` takes the listener off without claiming the handle closed, so the + * handle stays retryable rather than being counted or waited on forever. + */ +interface CloseWatch { + readonly wait: Operation; + abandon(): void; +} + +/** Settle once this server has stopped listening. */ +function closedServer(server: Server, onClosed: () => void): CloseWatch { + const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; + if (!server.listening) { + onClosed(); + done.resolve(); + } else { + server.on("close", onClose); + } + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + server.off("close", onClose); + } + })(), + abandon: () => server.off("close", onClose), + }; +} + +/** A connection that has said nothing for long enough to be nobody. */ +function* silence(): Operation> { + yield* sleep(HELLO_GRACE_MS); + return { done: true, value: undefined }; +} diff --git a/packages/grid-tmux/src/pane-child.ts b/packages/grid-tmux/src/pane-child.ts new file mode 100644 index 000000000..9c4322705 --- /dev/null +++ b/packages/grid-tmux/src/pane-child.ts @@ -0,0 +1,322 @@ +/** + * One interactive child in a pane, and what its settlement establishes + * (architecture.md §Interactive grids). + * + * Two facts the pane topology needs kept apart: + * + * - **readiness** is the runtime's `spawn` event and nothing earlier. A pid is + * not it, and neither is a pane that has shown output; a missing executable + * delivers `error` *instead of* `spawn`, never after it. This is what a grid's + * attach barrier waits for. + * - **settlement** is the escalation and the sweep that follow the child, not + * the `exit` event. A child that exited on its own may have left descendants + * in its process group, or an orphan still holding the pane's terminal, and + * the pane is not free for the next launch until neither is true. + * + * The child shares this process's process group deliberately, so `^C` typed in + * the pane reaches it: `detached: true` would `setsid()` it away from the + * pane's controlling terminal, and job control is the point of a pane. The + * worker ignores those signals itself so the child is the one interrupted. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import process from "node:process"; +import { ensure, Err, Ok, resource, sleep, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { + deliverSignal, + descendantsOf, + groupMembers, + processReachable, + processTable, + terminalHolders, +} from "@executablemd/grid/processes"; +import type { Settlement } from "./pane-protocol.ts"; + +export interface PaneChildRequest { + readonly argv: readonly string[]; + readonly cwd: string; + /** + * The child's environment, or absent to inherit this worker's. + * + * Absent is meaningful: tmux started this worker with the pane's + * environment, so inheriting it is how a launch that named none gets the + * terminal it is drawing on — `TERM`, and `COLORTERM` where the host has one. + * A supplied environment is used exactly, with nothing ambient added. + */ + readonly env?: Record; +} + +export interface PaneChildOutcome { + exitCode?: number; + signal?: string; +} + +export class PaneStartFailure extends Error { + override name = "PaneStartFailure"; + constructor(readonly code: string) { + super(`the pane's child could not be started (${code})`); + } +} + +export interface PaneChild { + /** `Ok(pid)` once the runtime reports the spawn; `Err` if it never will. */ + readonly started: Operation>; + /** Settles when the child exits. Independent of `started`. */ + readonly exited: Operation; + /** Idempotent: every caller of the one settlement gets the same answer. */ + settle(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start one child with the pane's terminal inherited, and own its settlement. + * + * `tty` is the pane's terminal device, when the worker has one. The sweep needs + * it: a descendant that called `setsid()` and outlived its parent is outside + * the process snapshot, and holding the terminal open is the only way it is + * still observable. + */ +export function usePaneChild( + request: PaneChildRequest, + tty: string | undefined, + /** Handed the process, so a suite can ask the emitter what it still holds. */ + observe?: (child: ChildProcess) => void, +): Operation { + return resource(function* (provide) { + const [command, ...args] = request.argv; + if (command === undefined) { + throw new Error("a pane launch names no command"); + } + const started = withResolvers>(); + const exited = withResolvers(); + let child: ChildProcess | undefined; + let outcome: PaneChildOutcome | undefined; + let settling: ReturnType> | undefined; + + function* settle(): Operation { + if (settling) { + return yield* settling.operation; + } + settling = withResolvers(); + try { + const nothingStarted: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], + }; + const settlement = + child === undefined || child.pid === undefined + ? nothingStarted + : yield* escalate(child, child.pid, tty, () => outcome !== undefined); + settling.resolve(settlement); + return settlement; + } catch (error) { + settling.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + // Registered before the spawn: a halt between acquiring a process and + // registering its cleanup leaks the process. + yield* ensure(function* () { + yield* settle(); + }); + + child = spawnChild(command, args, { + cwd: request.cwd, + env: request.env, + // The whole point of a pane: the child reads this terminal and draws on + // it directly, so nothing between it and the reader can buffer, reorder + // or capture what passes. + stdio: "inherit", + }); + // Named, and removed by the scope that installed them. `exit` in + // particular has to stay through the settlement that waits on it, so it is + // removed with the resource rather than after its first delivery. + // `spawn` and `error` are the two answers to one question, and exactly one + // of them arrives. Whichever does takes both off: what is left is `exit`, + // which the settlement still needs. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); + if (child?.pid !== undefined) { + started.resolve(Ok(child.pid)); + } + } + function onError(error: Error & { code?: string }): void { + settleStartup(); + started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); + } + const onExit = (code: number | null, signal: string | null): void => { + const settled: PaneChildOutcome = {}; + if (code !== null) { + settled.exitCode = code; + } + if (signal !== null) { + settled.signal = signal; + } + outcome = settled; + exited.resolve(settled); + }; + observe?.(child); + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + // The startup pair is usually gone already; `exit` is this scope's until + // the end, because a settlement may still be waiting on it. + settleStartup(); + child?.off("exit", onExit); + }); + + yield* provide({ started: started.operation, exited: exited.operation, settle }); + }); +} + +/** + * Interrupt, insist, then reach whatever the interrupt left behind. + * + * The snapshot is taken before the first signal, and that order is the whole + * proof: a killed child stops being anyone's parent and its children reparent + * to init, where an ancestry walk no longer finds them. A descendant that left + * the group with `setsid()` is in the snapshot while its parent lives; one + * created after the snapshot is not, and this says so rather than claiming + * otherwise. + */ +function* escalate( + child: ChildProcess, + pid: number, + tty: string | undefined, + hasExited: () => boolean, +): Operation { + const before = yield* processTable(); + // The child shares this process's group, so the group is looked up rather + // than assumed to be the child's own pid. + const group = before.find((row) => row.pid === pid)?.pgid ?? pid; + // Never anything this worker came from. In a pane the worker is the session + // leader, so its group holds nothing above it — but a settlement that could + // reach an ancestor would be one signal away from killing the run that + // started the grid, and that is not a thing to leave to the topology being + // what it should be. + const forebears = ancestorsOf(before, process.pid); + const related = new Map(); + for (const row of descendantsOf(before, pid)) { + if (!forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + for (const row of groupMembers(before, group)) { + if (row.pid !== pid && row.pid !== process.pid && !forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + + let method: Settlement["method"] = "exited"; + if (!hasExited() && (yield* processReachable(pid))) { + method = "interrupted"; + yield* deliverSignal(pid, "SIGINT"); + const left = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, INTERRUPT_GRACE_MS); + if (!left) { + method = "killed"; + const fatal = yield* deliverSignal(pid, "SIGKILL"); + const gone = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, KILL_SETTLE_MS); + if (!gone && fatal !== "delivered" && fatal !== "absent") { + throw new Error(`could not establish that process ${pid} stopped: SIGKILL was ${fatal}`); + } + } + } + // Deno's `node:child_process` holds the runtime open on a handle it never + // settles once a signal the child ignored has been delivered. + try { + child.unref(); + } catch { + // Already released. + } + + for (const member of related.keys()) { + yield* deliverSignal(member, "SIGKILL"); + } + yield* waitFor(function* () { + for (const member of related.keys()) { + if (yield* processReachable(member)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const member of related.keys()) { + swept.push({ pid: member, gone: !(yield* processReachable(member)) }); + } + if (!(hasExited() || !(yield* processReachable(pid)))) { + swept.unshift({ pid, gone: false }); + } + + // Whatever still has the pane's terminal open, after everything the snapshot + // named is gone. This is where an escaped `setsid()` orphan is still visible. + const holders = yield* sweepHolders(tty); + const quiet = swept.every((entry) => entry.gone) && holders.every((entry) => entry.gone); + return { method, quiet, child: pid, swept, holders }; +} + +/** Clear the pane's terminal of anything but this worker, and report it. */ +export function* sweepHolders( + tty: string | undefined, +): Operation<{ pid: number; gone: boolean }[]> { + if (tty === undefined || tty === "??") { + return []; + } + const found = (yield* terminalHolders(`/dev/${tty}`)).filter((pid) => pid !== process.pid); + for (const pid of found) { + yield* deliverSignal(pid, "SIGKILL"); + } + yield* waitFor(function* () { + for (const pid of found) { + if (yield* processReachable(pid)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const pid of found) { + swept.push({ pid, gone: !(yield* processReachable(pid)) }); + } + return swept; +} + +/** This process and everything it descends from, in one reading of the table. */ +function ancestorsOf(table: readonly { pid: number; ppid: number }[], pid: number): Set { + const found = new Set([pid]); + let current = table.find((row) => row.pid === pid); + while (current !== undefined && current.ppid > 0 && !found.has(current.ppid)) { + found.add(current.ppid); + const parent: number = current.ppid; + current = table.find((row) => row.pid === parent); + } + return found; +} + +function* waitFor(condition: () => Operation, limitMs: number): Operation { + const deadline = Date.now() + limitMs; + while (!(yield* condition())) { + if (Date.now() >= deadline) { + return false; + } + yield* sleep(POLL_MS); + } + return true; +} diff --git a/packages/grid-tmux/src/pane-protocol.ts b/packages/grid-tmux/src/pane-protocol.ts new file mode 100644 index 000000000..d45c7e545 --- /dev/null +++ b/packages/grid-tmux/src/pane-protocol.ts @@ -0,0 +1,318 @@ +/** + * What the parent and one pane worker say to each other, and how + * (architecture.md §Interactive grids). + * + * The channel is invocation-private: one Unix socket per pane, inside a + * mode-0700 directory that exists for one grid. A worker proves which pane it + * is with a token the parent wrote to a mode-0600 file that only that worker + * reads — and removes, so the token is spent the moment it is used. + * + * Everything a launch actually consists of crosses here rather than through + * tmux: the exact argv vector, the working directory and the environment. tmux + * has a command parser, and a command parser is a place where an argument can + * become two arguments, or a quote, or a `;`. What tmux is told instead is a + * directory and an ordinal, which is all its parser ever sees. + * + * Frames are newline-delimited JSON, parsed with a schema on both ends. A frame + * that is not the protocol ends the conversation rather than being interpreted: + * this socket is how one process is asked to start a program with inherited + * terminal streams, so "close to what I expected" is not good enough. + */ + +import { join } from "node:path"; +import type { Socket } from "node:net"; +import { createQueue, ensure, resource, withResolvers } from "effection"; +import type { Operation, Queue } from "effection"; +import { z } from "zod"; + +/** + * The wire format, written out. + * + * Declared rather than inferred from the schemas below, and the schemas are + * then annotated with these types so the compiler holds the two together — a + * schema that stopped producing its declared frame stops compiling, so there is + * no drift to keep an eye on. + * + * Written out because this package is published: an inferred zod type has no + * explicit form to publish, and the frames are the one part of this adapter + * whose shape a reader of the package genuinely needs. The schemas themselves + * stay private — how a frame is validated is nobody else's business, and + * `parseFromWorker`/`parseToWorker` are the seam. + */ + +/** What one worker says about the pane it woke up in. */ +export interface Hello { + type: "hello"; + ordinal: number; + token: string; + pid: number; + pgid: number; + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: string; + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: [boolean, boolean, boolean]; +} + +/** One process the settlement reached, and what reaching it established. */ +export interface Swept { + pid: number; + gone: boolean; +} + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +export interface Settlement { + method: "exited" | "interrupted" | "killed"; + quiet: boolean; + child?: number; + /** Snapshot members reached during the escalation. */ + swept: Swept[]; + /** Anything still holding the pane's terminal after the sweep. */ + holders: Swept[]; +} + +/** Everything a worker may say. */ +export type FromWorker = + | Hello + | { type: "displayed"; seq: number } + /** The runtime's spawn event, and nothing earlier. */ + | { type: "started"; id: string; pid: number } + | { type: "start-failed"; id: string; reason: string } + /** A launch asked for while one is live. */ + | { type: "busy"; id: string } + | { + type: "exited"; + id: string; + exitCode?: number; + signal?: string; + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: Settlement; + } + | { type: "quiet"; id?: string; settlement: Settlement } + | { type: "bye"; holders: Swept[] }; + +/** Everything the parent may say. */ +export type ToWorker = + | { type: "welcome" } + | { type: "display"; seq: number; text: string } + /** + * Start a program on this pane's terminal. + * + * `env` omitted and `env` empty are different instructions, which is why it + * is optional rather than defaulted. Omitted means "the environment you + * already have" — the pane's, which tmux gave this worker — and is what a + * caller that named no environment meant. An empty map means "start this with + * nothing", which is a thing a caller may ask for and which no default should + * silently produce. Collapsing the first into the second is how a launched + * program came to run with no `TERM`, no `PATH` and no `HOME` at all. + */ + | { + type: "launch"; + id: string; + argv: string[]; + cwd: string; + env?: Record; + } + | { type: "cancel"; id: string } + | { type: "shutdown" }; + +/** What one worker says about the pane it woke up in. */ +const HelloSchema = z.object({ + type: z.literal("hello"), + ordinal: z.number().int().nonnegative(), + token: z.string(), + pid: z.number().int(), + pgid: z.number().int(), + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: z.string(), + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: z.tuple([z.boolean(), z.boolean(), z.boolean()]), +}); + +/** One process the settlement reached, and what reaching it established. */ +const SweptSchema = z.object({ + pid: z.number().int(), + gone: z.boolean(), +}); + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +const SettlementSchema = z.object({ + method: z.enum(["exited", "interrupted", "killed"]), + quiet: z.boolean(), + child: z.number().int().optional(), + /** Snapshot members reached during the escalation. */ + swept: z.array(SweptSchema), + /** Anything still holding the pane's terminal after the sweep. */ + holders: z.array(SweptSchema), +}); + +const FromWorkerSchema = z.discriminatedUnion("type", [ + HelloSchema, + z.object({ type: z.literal("displayed"), seq: z.number().int() }), + /** The runtime's spawn event, and nothing earlier. */ + z.object({ type: z.literal("started"), id: z.string(), pid: z.number().int() }), + z.object({ type: z.literal("start-failed"), id: z.string(), reason: z.string() }), + /** A launch asked for while one is live. */ + z.object({ type: z.literal("busy"), id: z.string() }), + z.object({ + type: z.literal("exited"), + id: z.string(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: SettlementSchema, + }), + z.object({ + type: z.literal("quiet"), + id: z.string().optional(), + settlement: SettlementSchema, + }), + z.object({ type: z.literal("bye"), holders: z.array(SweptSchema) }), +]); + +const ToWorkerSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("welcome") }), + z.object({ type: z.literal("display"), seq: z.number().int(), text: z.string() }), + z.object({ + type: z.literal("launch"), + id: z.string(), + argv: z.array(z.string()).min(1), + cwd: z.string(), + // Optional, not defaulted: an absent `env` and an empty one are different + // instructions. Still exact when present — a value that is not a string + // makes the frame malformed rather than being coerced. + env: z.record(z.string(), z.string()).optional(), + }), + z.object({ type: z.literal("cancel"), id: z.string() }), + z.object({ type: z.literal("shutdown") }), +]); + +// The schemas are held to the declared frames rather than the frames being +// read off the schemas. A change to either that the other does not match is a +// type error here, at the one place both are in view. +const _hello: z.ZodType = HelloSchema; +const _settlement: z.ZodType = SettlementSchema; +const _fromWorker: z.ZodType = FromWorkerSchema; +const _toWorker: z.ZodType = ToWorkerSchema; + +/** + * Read one frame in each direction, or refuse it. + * + * The seam is the parse rather than the schema. A schema is how this module + * happens to decide what a frame is; what a caller — including this adapter's + * own tests — actually needs is "turn these bytes into a frame or throw", and + * a function saying exactly that keeps the shape of the wire format private. + * It also keeps it out of the published API, where an inferred zod type has no + * explicit form to publish. + */ +export function parseFromWorker(value: unknown): FromWorker { + return FromWorkerSchema.parse(value); +} + +export function parseToWorker(value: unknown): ToWorker { + return ToWorkerSchema.parse(value); +} + +/** + * Where one pane's socket and token live. + * + * Short by necessity rather than taste: a Unix socket path is capped at 104 + * bytes, which a temporary directory named after a repository path exceeds. + */ +export function paneSocketPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.sock`); +} + +export function paneTokenPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.token`); +} + +/** + * Feed one socket's bytes into a queue of parsed frames. + * + * A frame that does not parse destroys the socket. There is no partial credit + * on this channel. + */ +export function readFrames( + socket: Socket, + parse: (value: unknown) => T, +): Operation> { + return resource>(function* (provide) { + const queue = createQueue(); + let remainder = ""; + socket.setEncoding("utf8"); + + /** Take all three off at once. This reader is over. */ + const detach = (): void => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }; + function onData(chunk: string): void { + const lines = (remainder + chunk).split("\n"); + remainder = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) { + continue; + } + try { + queue.add(parse(JSON.parse(line))); + } catch { + // A frame that is not the protocol ends the conversation. This socket + // is how one process is asked to start a program with inherited + // terminal streams; "close to what I expected" is not good enough. + // The reader is done, so it comes off now rather than at scope exit + // — and its consumers are told, or they would wait for frames from a + // conversation that has ended. + detach(); + queue.close(); + socket.destroy(); + return; + } + } + } + function onClose(): void { + // Terminal: nothing follows a close, so nothing stays listening for one. + detach(); + queue.close(); + } + function onError(): void { + detach(); + queue.close(); + socket.destroy(); + } + + socket.on("data", onData); + socket.on("close", onClose); + socket.on("error", onError); + // Still the resource's, for the paths that terminate nothing: a cancelled + // scope, and a socket that simply never says anything. + yield* ensure(detach); + + yield* provide(queue); + }); +} + +/** Write one frame, and settle once the socket has taken it. */ +export function writeFrame(socket: Socket, message: unknown): Operation { + const written = withResolvers(); + if (socket.destroyed) { + written.resolve(); + return written.operation; + } + socket.write(JSON.stringify(message) + "\n", () => written.resolve()); + return written.operation; +} diff --git a/packages/grid-tmux/src/pane-worker.ts b/packages/grid-tmux/src/pane-worker.ts new file mode 100644 index 000000000..4dc7e7749 --- /dev/null +++ b/packages/grid-tmux/src/pane-worker.ts @@ -0,0 +1,360 @@ +/** + * The persistent pane worker: tmux's initial process in one pane + * (architecture.md §Interactive grids). + * + * It owns the pane's terminal for the pane's whole life, and everything it does + * is asked of it over the private socket — show this text, start this child, + * cancel it, shut down. It never reads the terminal itself, so keystrokes reach + * the foreground child and only the child. + * + * It is the pane's session leader and shares the pane's process group with the + * child, so `^C` on that pane is delivered to both. It handles SIGINT, SIGQUIT + * and SIGTSTP by doing nothing: dispositions reset across `exec`, so the child + * gets the defaults and is the one interrupted. SIGHUP keeps its default — when + * the pane's terminal goes away, so does the worker. + * + * It runs under Effection's `run()` rather than `main()`. `main()` binds SIGINT + * to its own shutdown and exits 130 on the first `^C` typed into the pane — + * which is the exact keystroke the child is supposed to receive. + * + * Nothing here is reachable without the handshake. The worker is started with + * an ordinal and a directory, reads the token only that pane's file holds, + * removes it, and presents it; a worker that cannot do that connects to nothing + * and performs no work at all. + */ + +import net from "node:net"; +import process from "node:process"; +import { readTextFile, rm } from "@effectionx/fs"; +import { ensure, resource, run, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { processTable } from "@executablemd/grid/processes"; +import { installDenoTerminalProcesses } from "@executablemd/grid/posix"; +import { sweepHolders, usePaneChild } from "./pane-child.ts"; +import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; +import { + paneSocketPath, + paneTokenPath, + parseToWorker, + readFrames, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Settlement } from "./pane-protocol.ts"; + +/** The hidden invocation a grid starts a pane with. */ +export const PANE_WORKER_COMMAND = "terminal-worker"; + +/** + * Whether this process was started as a pane worker, and for which pane. + * + * Read from raw argv, because this is decided before any parser exists — the + * worker must not run under Effection's `main()`, so it is dispatched at the + * entrypoint rather than inside the command table. It is in no command table, + * so it appears in no help output and no catalog. + * + * Anything but the exact shape is not a worker invocation and falls through to + * the ordinary commands, where `terminal-worker` names no command and is a + * document reference like any other unknown first token. + */ +export function paneWorkerInvocation( + args: readonly string[], +): { ordinal: number; directory: string } | undefined { + const [name, ordinal, directory, ...rest] = args; + if (name !== PANE_WORKER_COMMAND || ordinal === undefined || directory === undefined) { + return undefined; + } + if (rest.length > 0 || !/^\d+$/.test(ordinal)) { + return undefined; + } + return { ordinal: Number(ordinal), directory }; +} + +/** + * Run this process as a pane worker. + * + * `run()` rather than `main()`, deliberately: `main()` binds SIGINT to its own + * shutdown and would exit 130 on the first `^C` typed into the pane — the exact + * keystroke the foreground child is supposed to receive. The signal handlers go + * on before anything else for the same reason. + * + * Naming this invocation grants nothing. The worker connects to a socket in a + * private directory and must present that pane's single-use token before the + * parent says a word to it, so a caller who types this gets a process that + * fails to connect and performs no work at all. + */ +export function runPaneWorkerProcess(invocation: { + ordinal: number; + directory: string; +}): Promise { + return run(function* () { + // Inside the run scope, so the handlers go on before any work and come off + // with it — rather than living for the process's lifetime regardless. + yield* useForegroundSignals(); + yield* runPaneWorker(invocation.ordinal, invocation.directory); + }); +} + +/** + * A pane that could not be proved free. + * + * Provider-neutral: it names no socket, session, pane, client, argv or + * environment, because a settlement that failed is read in exactly the places a + * private identifier must not appear. + */ +export class PaneNotQuiescent extends Error { + override name = "PaneNotQuiescent"; + constructor(what: string) { + super(`this terminal pane could not be proved free: ${what}`); + } +} + +/** + * What a settlement means for the pane it settled. + * + * Exported because it is the rule, not an implementation detail: everything + * downstream — clearing the pane, reporting a launch settled, admitting the + * next one, letting teardown succeed — is conditional on it, and a rule that + * several callers depend on is one worth being able to state and test on its + * own. + */ +export function requireQuiescent(settlement: Settlement): void { + if (settlement.quiet) { + return; + } + // Everything the worker can do has been done and something is still there: a + // survivor of the escalation, or a holder of the pane's terminal. + throw new PaneNotQuiescent( + settlement.holders.some((holder) => !holder.gone) + ? "something still holds its terminal" + : "something it started is still running", + ); +} + +/** A settlement for a pane that never started anything. */ +const NOTHING_TO_SETTLE: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], +}; + +interface Live { + readonly id: string; + child: PaneChild | undefined; + /** The one settlement of this child, however many callers ask for it. */ + settled: ReturnType> | undefined; +} + +/** + * Ignore the signals that belong to the foreground child. + * + * They are delivered to the whole foreground process group, and this worker is + * in it. Doing nothing is the correct handling: the child inherits default + * dispositions across `exec`, so it receives the same signal and acts on it. + */ +export function useForegroundSignals(): Operation { + return resource(function* (provide) { + const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; + const ignore = (): void => {}; + for (const name of foreground) { + process.on(name, ignore); + } + yield* ensure(() => { + // Installed and removed by the scope that runs this worker, so a worker + // that has finished stops answering for a pane it no longer owns. + for (const name of foreground) { + process.off(name, ignore); + } + }); + yield* provide(); + }); +} + +/** How many handlers this process has for one signal. */ +export function foregroundSignalListeners(name: NodeJS.Signals): number { + return process.listenerCount(name); +} + +function writeOut(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; +} + +/** + * Run one pane worker until the parent says to stop. + * + * The caller has already ignored the foreground signals and is running this + * under `run()`; both are properties of the *process*, not of this operation, + * which is why they are the entrypoint's to establish. + */ +/** + * What a worker uses to start a child. + * + * A seam rather than a hard call, because the one thing a suite cannot arrange + * in another process is a child whose settlement *fails* — a real SIGKILL + * always works, and a real terminal sweep on a pane with no terminal always + * comes back empty. Substituting the child is how the worker's own behaviour on + * that path is observable at all; the alternative would be a fault switch in + * production code, which is not a trade worth making. + */ +export interface PaneWorkerDependencies { + useChild(request: PaneChildRequest, tty: string | undefined): Operation; + /** Whether to install the POSIX observer. A caller that has one says no. */ + observe?: boolean; +} + +export function* runPaneWorker( + ordinal: number, + directory: string, + deps: PaneWorkerDependencies = { useChild: usePaneChild }, +): Operation { + if (deps.observe !== false) { + yield* installDenoTerminalProcesses(); + } + + // Read once, then spent. A second worker for this pane finds no token, so it + // has nothing to present and is refused by the parent. + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + yield* rm(paneTokenPath(directory, ordinal), { force: true }); + + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + // The socket is this scope's, so however this worker ends — a shutdown it was + // asked for, a channel that failed, a settlement it could not prove — the + // parent sees the channel close rather than waiting on a worker that is no + // longer there. + yield* ensure(() => { + socket.destroy(); + }); + const connected = withResolvers(); + const onConnect = (): void => connected.resolve(); + const onConnectError = (error: Error): void => connected.reject(error); + socket.on("connect", onConnect); + socket.on("error", onConnectError); + try { + yield* connected.operation; + } finally { + // Removed synchronously, in the scope that installed them: a listener that + // outlived this wait would answer for a socket this worker has finished + // with. + socket.off("connect", onConnect); + socket.off("error", onConnectError); + } + + const inbound = yield* readFrames(socket, (value) => parseToWorker(value)); + const say = (message: FromWorker) => writeFrame(socket, message); + + const table = yield* processTable(); + const facts = table.find((row) => row.pid === process.pid); + const tty = facts?.tty; + yield* say({ + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: facts?.pgid ?? -1, + tty: tty ?? "??", + isatty: [ + process.stdin.isTTY === true, + process.stdout.isTTY === true, + process.stderr.isTTY === true, + ], + }); + + let live: Live | undefined; + + /** Settle one child once, however many callers ask, and free the pane. */ + function* settle(entry: Live): Operation { + if (entry.settled) { + return yield* entry.settled.operation; + } + entry.settled = withResolvers(); + try { + const settlement = + entry.child === undefined ? NOTHING_TO_SETTLE : yield* entry.child.settle(); + // Fails closed: a settlement that could not prove the pane free leaves it + // uncleared, reports no success, and refuses the next launch. + requireQuiescent(settlement); + // Cleared only after the settlement, so a launch arriving now is refused + // rather than started beside a sweep that would reach it. + if (live === entry) { + live = undefined; + } + entry.settled.resolve(settlement); + return settlement; + } catch (error) { + entry.settled.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* quiesce(): Operation { + return live === undefined ? NOTHING_TO_SETTLE : yield* settle(live); + } + + while (true) { + const next = yield* inbound.next(); + if (next.done) { + return; + } + const message = next.value; + switch (message.type) { + case "welcome": + break; + case "display": + // Written, never read: what the reader types belongs to the child. + yield* writeOut(message.text); + yield* say({ type: "displayed", seq: message.seq }); + break; + case "launch": { + if (live !== undefined) { + yield* say({ type: "busy", id: message.id }); + break; + } + const entry: Live = { id: message.id, child: undefined, settled: undefined }; + live = entry; + yield* spawn(function* () { + const child = yield* deps.useChild( + { argv: message.argv, cwd: message.cwd, env: message.env }, + tty, + ); + entry.child = child; + const started = yield* child.started; + if (!started.ok) { + // Never started, so never ready. The pane's readiness latch is not + // tripped, and the grid it belongs to does not attach. + yield* settle(entry); + yield* say({ type: "start-failed", id: message.id, reason: started.error.message }); + return; + } + yield* say({ type: "started", id: message.id, pid: started.value }); + const outcome = yield* child.exited; + // The exit is not the end of it. `exited` is what frees the pane for + // the next launch, so it follows the whole settlement — and is sent + // only when that settlement proved the pane free. A settlement that + // did not throws out of here instead, and no success is reported. + const settlement = yield* settle(entry); + yield* say({ type: "exited", id: message.id, ...outcome, settlement }); + }); + break; + } + case "cancel": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", id: message.id, settlement }); + break; + } + case "shutdown": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", settlement }); + // The pane's last sweep, by the only process that can still make it: + // once this worker exits, tmux closes the pane's pty master and the + // kernel revokes the slave, after which nothing can name a process that + // kept the terminal open. Every child's settlement already swept, so a + // holder here arrived between that sweep and now. + yield* say({ type: "bye", holders: yield* sweepHolders(tty) }); + socket.end(); + return; + } + } + } +} diff --git a/packages/grid-tmux/src/provider.ts b/packages/grid-tmux/src/provider.ts new file mode 100644 index 000000000..a85cbc21f --- /dev/null +++ b/packages/grid-tmux/src/provider.ts @@ -0,0 +1,457 @@ +/** + * The tmux grid provider, and what a host must be to install it + * (architecture.md §Interactive grids). + * + * This is the one place the provider-neutral request from #730 meets tmux. The + * request names columns, rows and the authored panes; what comes back is a + * composite core drives through its own lifecycle. Nothing tmux-shaped crosses + * in either direction: no socket, session, window, pane, client or server + * identifier appears in a request, a result, a retained record or a diagnostic. + * + * A host installs this only when it can actually present a grid. `xmd run` on a + * terminal with a usable tmux does; `xmd test`, a piped run, a host without + * tmux, and the Node and Bun runtimes do not — they keep the language and the + * validation and install no operational provider, so a document that asks for a + * grid is refused before a pane starts rather than part-way through one. + * + * The hangup is here because it ends the same way. A terminal that goes away + * takes the grid with it, and the way it does that is the ordinary structured + * cancellation every other stop uses — not a second teardown path that would + * have to be kept honest separately. + */ + +import { ensure, resource, withResolvers } from "effection"; +import process from "node:process"; +import type { Operation } from "effection"; +import { registerGridProvider, Grids } from "@executablemd/grid"; +import type { + NativeLaunchOutcome, + NativeLaunchRequest, + GridComposite, + GridRequest, + PaneState, + GridProviderFactory, + ShellOutcome, +} from "@executablemd/grid"; +import { usePaneChannels } from "./pane-channel.ts"; +import { requireQuiescent } from "./pane-worker.ts"; +import type { PaneLink } from "./pane-channel.ts"; +import { useTmuxGrid } from "./tmux-grid.ts"; +import type { TmuxGrid, VisibleClient } from "./tmux-grid.ts"; +import { probeTmux, GridTeardownFailed, tmuxAt, TmuxUnavailableError } from "./tmux.ts"; +import type { Tmux } from "./tmux.ts"; + +/** The name a host installs this provider under. */ +export const TMUX_PROVIDER = "tmux"; + +export interface TmuxProviderDependencies { + /** Whether this invocation has a terminal to divide. */ + isTerminal(): boolean; + /** What every process in the topology receives. */ + readonly env: Record; + /** + * The command that runs one pane's worker: this executable, hidden mode. + * + * An operation because a host resolves its own invocation contextually, and + * every pane's is resolved before the server exists. + */ + workerCommand(ordinal: number, directory: string): Operation; + /** The window to lay panes out in. */ + size(): { columns: number; rows: number }; + /** How a private server is reached. Substituted only by this package's tests. */ + createTmux?: (socket: string, env: Record) => Tmux; + /** What asking tmux its version does. Substituted only by this package. */ + askVersion?: () => Operation<{ code: number; stdout: string }>; +} + +/** + * Build the provider factory a host registers. + * + * The factory receives the terminal authority directly and presents the exact + * request it was routed — a handler that answered without presenting would have + * presented nothing, which is what #730's handshake is for. + */ +export function tmuxGridProvider(deps: TmuxProviderDependencies): GridProviderFactory { + return function* (_options, authority): Operation { + yield* Grids.around( + { + *open([request]): Operation { + const composite = yield* usePresentedGrid(deps, request); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }; +} + +/** + * Everything one grid needs, prepared while it is still hidden. + * + * Ownership, innermost last — which is also the order it comes down in: + * + * grid scope + * ├─ private directory, sockets and tokens (removed last, after they close) + * ├─ the tmux server and its panes (`kill-server`, proved) + * └─ the admitted worker links + */ +/** Everything one grid's teardown has to take down, in the order it does. */ +export interface GridParts { + /** Ask the reader's client to leave, and establish that it did. */ + detachReader(): Operation; + /** Every admitted worker link, in pane order. */ + readonly links: readonly PaneLink[]; + /** Close every private socket and server. */ + closeChannels(): Operation; + /** Stop the server, and establish it is gone. */ + stopServer(): Operation; +} + +/** + * The one teardown, in the one order, however a grid ends. + * + * Core calls it through `destroy()`; the composite's finalizer calls it when + * core never got that far, which is what a preparation that failed halfway + * leaves. A second caller waits on the first rather than skipping past + * unfinished work, and a teardown that *failed* is retried rather than + * remembered as done — marking it complete before it succeeded would let the + * run continue past a pane it never established was free. + * + * The order is the contract, and every step is a proof rather than a request: + * + * detach the reader's client and establish it stopped + * → ask every acquired worker to shut down + * → require its settlement, its holder-free goodbye, and its channel + * closing, in that order + * → close every private channel + * → stop the server and establish it is gone + * + * Every acquired resource is attempted even after an earlier one failed, so one + * bad worker does not strand the server, the channels or the paths. The first + * failure is what surfaces. + */ +export function createGridTeardown(parts: GridParts): () => Operation { + /** The one teardown in flight, so repeat callers observe it rather than skip it. */ + let tearing: ReturnType> | undefined; + let complete = false; + const steps: (() => Operation)[] = [ + () => parts.detachReader(), + ...parts.links.map((link) => () => quiesceWorker(link)), + // Channels before the server: a socket still open onto a pane of a server + // that has gone is a handle onto nothing. + () => parts.closeChannels(), + () => parts.stopServer(), + ]; + /** Phases already proved done, so a retry resumes rather than restarts. */ + const settled = new Set(); + + return function* tearDown(): Operation { + if (complete) { + return; + } + if (tearing) { + return yield* tearing.operation; + } + tearing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + + for (const [index, step] of steps.entries()) { + if (settled.has(index)) { + // A phase that succeeded is not asked again. Re-asking would fail for + // the wrong reason — a worker that has already said goodbye and gone is + // "a worker that was gone" the second time — and that answer would + // replace the reason the first attempt actually could not finish. + continue; + } + try { + yield* step(); + settled.add(index); + } catch (error) { + failed(error); + } + } + + if (failure !== undefined) { + // Retryable: `tearing` is cleared, so a later caller runs the phases that + // did not finish rather than being told a teardown that failed had. + tearing.reject(failure); + tearing = undefined; + throw failure; + } + complete = true; + tearing.resolve(); + }; +} + +function usePresentedGrid( + deps: TmuxProviderDependencies, + request: GridRequest, +): Operation { + return resource(function* (provide) { + const probed = yield* probeTmux({ + isTerminal: deps.isTerminal, + env: deps.env, + ...(deps.askVersion === undefined ? {} : { askVersion: deps.askVersion }), + }); + if (!probed.ok) { + // Before a directory, a socket, a token, a server or a pane exists, so a + // host that cannot present a grid leaves nothing behind for having tried. + throw probed.error; + } + + const channels = yield* usePaneChannels(request.panes.length); + // Resolved before a server exists, so a host that cannot say how to run its + // own worker fails while there is still nothing to take down. + const workers: string[][] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + workers.push([...(yield* deps.workerCommand(ordinal, channels.directory))]); + } + const build = deps.createTmux ?? tmuxAt; + const window = deps.size(); + const grid = yield* useTmuxGrid(build(`${channels.directory}/s`, deps.env), { + session: "xmd", + columns: request.columns, + panes: request.panes.length, + width: window.columns, + height: window.rows, + titles: request.panes.map((pane) => pane.title), + workerCommand: (ordinal) => workers[ordinal] ?? [], + cwd: process.cwd(), + env: deps.env, + }); + + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + + let shown = 0; + let visible: VisibleClient | undefined; + + const tearDown = createGridTeardown({ + *detachReader(): Operation { + // The reader's client first, and asked rather than told: a client that + // detaches restores the terminal, and one that is killed cannot. + if (visible === undefined) { + return; + } + const client = visible; + visible = undefined; + yield* grid.detach(client); + }, + links, + closeChannels: () => channels.close(), + stopServer: function* (): Operation { + yield* grid.stop(); + }, + }); + + yield* ensure(function* () { + yield* tearDown(); + }); + + yield* provide({ + *attach() { + visible = yield* grid.attach(); + }, + *update(ordinal, state) { + // Sanitized status only, and display only: core has already decided + // what this is, and drawing it is not a chance to change it. + yield* label(grid, ordinal, request, state); + }, + *display(ordinal, text) { + const link = links[ordinal]; + if (link === undefined) { + return; + } + yield* link.send({ type: "display", seq: ++shown, text }); + }, + *shell(ordinal, spawned) { + // The host's default shell, derived from live policy — never from the + // document, and never from a request. + return yield* runInPane( + links[ordinal], + { command: [deps.env.SHELL ?? "/bin/sh"], cwd: process.cwd(), env: deps.env }, + spawned, + ); + }, + *launch(ordinal, request, spawned) { + // The exact command vector, working directory and environment the Agent + // provider supplied, over this pane's authenticated channel. tmux's + // parser sees none of it. + return yield* runInPane(links[ordinal], request, spawned); + }, + *closed() { + // The reader leaving, and nothing else. A host hangup is not a reader + // close — it is the terminal going away, which cancels the grid through + // the ordinary structured path rather than selecting a close outcome. + yield* grid.detached(); + }, + *destroy() { + yield* tearDown(); + }, + }); + }); +} + +/** The pane's title, with the state core settled on appended. */ +function* label( + grid: TmuxGrid, + ordinal: number, + request: GridRequest, + state: PaneState, +): Operation { + const pane = request.panes[ordinal]; + if (pane === undefined) { + return; + } + yield* grid.title(ordinal, `${pane.title} — ${state}`); +} + +/** + * Ask one worker to stop, and require what it must say before it has. + * + * Settlement, then a goodbye that names no surviving holder, then the channel + * closing — in that order. A worker that was never there, that has already gone, + * or that stops part-way through is a teardown failure: none of those is a pane + * proved free. + */ +function* quiesceWorker(link: PaneLink): Operation { + if (!link.connected()) { + throw new GridTeardownFailed("a terminal pane's worker was gone before it was asked to stop"); + } + yield* link.send({ type: "shutdown" }); + let quiesced = false; + let farewelled = false; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + if (!quiesced || !farewelled) { + throw new GridTeardownFailed("a terminal pane stopped answering before it was proved free"); + } + return; + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + quiesced = true; + continue; + } + if (frame.type === "bye") { + if (!quiesced) { + throw new GridTeardownFailed("a terminal pane said goodbye before it was proved free"); + } + if (frame.holders.some((holder) => !holder.gone)) { + throw new GridTeardownFailed("something still holds a terminal pane"); + } + farewelled = true; + continue; + } + } +} + +/** + * Run one request in one pane, through that pane's authenticated worker. + * + * The same path for both callers, because they are the same act: a shell whose + * executable came from host policy and a native UI whose argv came from the + * Agent provider are both "start this, on that pane's terminal". What differs + * is who decided the vector, and that is decided before this is called. + */ +export function* runInPane( + link: PaneLink | undefined, + request: NativeLaunchRequest, + spawned: () => void, +): Operation { + if (link === undefined) { + // No fallback. A composite that cannot run this in the pane it was asked + // for refuses, rather than putting a native UI on the root terminal. + throw new Error("this grid cannot run that pane's launch"); + } + const id = `launch-${link.ordinal}-${++started}`; + let settled = false; + // Registered before the launch is asked for: a cancellation between asking + // and hearing back must still end the child. Cancelling is not "stop waiting" + // — it is "ask the pane to stop, and do not come back until it has", because + // this operation returning is what lets the grid above it come down. + yield* ensure(function* () { + if (settled || !link.connected()) { + return; + } + yield* link.send({ type: "cancel", id }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error("the terminal pane stopped answering before its child was settled"); + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + return; + } + if (frame.type === "exited") { + requireQuiescent(frame.settlement); + return; + } + } + }); + yield* link.send({ + type: "launch", + id, + argv: [...request.command], + cwd: request.cwd, + // Carried only when the caller named one. `?? {}` used to sit here, and it + // turned "inherit" into "empty": at the root an absent `env` means the + // child inherits this process's, so a pane collapsing it to `{}` started + // the program with no environment whatsoever — no `TERM`, so no colour, and + // no `PATH` or `HOME` either. An environment that *is* supplied crosses + // exactly, gaining nothing ambient. + ...(request.env === undefined ? {} : { env: request.env }), + }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + // The worker's channel ended mid-launch. Nothing about that says the + // child stopped, so it is a failure rather than an empty outcome. + throw new Error("the terminal pane stopped answering before its launch settled"); + } + if (frame.type === "started") { + // The worker-observed runtime spawn event, and the only thing that makes + // this pane ready. + spawned(); + continue; + } + if (frame.type === "busy") { + settled = true; + throw new Error("that terminal pane already has a live child"); + } + if (frame.type === "start-failed") { + settled = true; + throw new Error("the terminal pane's child could not be started"); + } + if (frame.type === "exited") { + // The worker sends this only once its settlement proved the pane free. + settled = true; + const outcome: NativeLaunchOutcome = {}; + if (frame.exitCode !== undefined) { + outcome.exitCode = frame.exitCode; + } + if (frame.signal !== undefined) { + outcome.signal = frame.signal; + } + return outcome; + } + } +} + +/** Distinguishes one pane's launches from the next in this invocation. */ +let started = 0; + +/** Install the tmux provider for this host, when this host can present one. */ +export function* installTmuxGridProvider(deps: TmuxProviderDependencies): Operation { + yield* registerGridProvider(TMUX_PROVIDER, tmuxGridProvider(deps)); +} + +export { TmuxUnavailableError }; diff --git a/packages/grid-tmux/src/tmux-grid.ts b/packages/grid-tmux/src/tmux-grid.ts new file mode 100644 index 000000000..1d96793f4 --- /dev/null +++ b/packages/grid-tmux/src/tmux-grid.ts @@ -0,0 +1,482 @@ +/** + * One hidden, invocation-private tmux composite + * (architecture.md §Interactive grids, §Atomic presentation). + * + * A grid is built entirely out of sight: its own server on its own socket, a + * pane per authored ordinal each running that pane's worker, the authored + * layout imposed explicitly, and a control-mode client that says what the + * server sees. Nothing is visible until `attach()`, which core calls only after + * every pane has reported a start — so a reader never watches a grid fill in, + * and a grid that failed to start is taken down without ever having been shown. + * + * Three clients, kept apart because they answer different questions: + * + * - the **visible** client is the reader's, attached on this process's terminal + * with the streams inherited; + * - the **control** client attaches with `-f no-output`, so pane bytes never + * travel through this process. What it reports — `%client-detached`, + * `%sessions-changed`, `%exit`, EOF — is how reader detach, server stop and + * control loss are told apart. An attach client's exit code cannot tell them + * apart: it is 0 after `detach-client`, 0 after `kill-session` and 1 after + * `kill-server`; + * - the pane **workers** are not clients at all. They are the panes. + * + * Every tmux identifier — the socket path, session name, window, pane ids, + * client names, the server pid — stays inside this module. None of it reaches a + * request, a result, a retained record or a diagnostic. + */ + +import { exec } from "@effectionx/process"; +import { lines } from "@effectionx/stream-helpers"; +import { createSignal, ensure, resource, sleep, spawn } from "effection"; +import type { Operation } from "effection"; +import { processReachable } from "@executablemd/grid/processes"; +import { layoutString, swapsInto } from "./layout.ts"; +import type { LayoutCell } from "./layout.ts"; +import { useAttachClient } from "./attach-client.ts"; +import type { AttachClient } from "./attach-client.ts"; +import { quietly, GridTeardownFailed } from "./tmux.ts"; +import type { Tmux } from "./tmux.ts"; + +/** What one prepared pane is, from the composite's side. */ +export interface TmuxPane { + readonly ordinal: number; + /** tmux's `%N`. Never leaves this module. */ + readonly id: string; + /** `ttys003`, the pane's terminal, as the worker will name it. */ + readonly tty: string; + readonly pid: number; + readonly cell: LayoutCell; +} + +/** What the control client saw, classified. */ +export type ControlEvent = + | { kind: "client-attached"; client: string } + | { kind: "client-detached"; client: string } + | { kind: "sessions-changed" } + | { kind: "layout-change" } + | { kind: "exit" } + | { kind: "closed" } + | { kind: "other"; line: string }; + +export interface TmuxGridRequest { + readonly session: string; + readonly columns: number; + readonly panes: number; + readonly width: number; + readonly height: number; + readonly titles: readonly string[]; + /** The command that runs one pane's worker. */ + workerCommand(ordinal: number): readonly string[]; + readonly cwd: string; + readonly env: Record; +} + +/** What stopping the server established. */ +export interface ServerStopped { + /** The server process is no longer reachable. */ + readonly gone: boolean; + /** The server refuses to answer for its session. */ + readonly refuses: boolean; +} + +export interface VisibleClient { + readonly client: AttachClient; + /** tmux's name for this client once attached: its tty. */ + readonly name: string; +} + +export interface TmuxGrid { + readonly panes: readonly TmuxPane[]; + /** Everything the control client reported, classified, in order. */ + readonly events: readonly ControlEvent[]; + /** Pane geometry now, for checking placement after a resize. */ + geometry(): Operation; + /** Show one pane's label. Display only; core has settled what it says. */ + title(ordinal: number, text: string): Operation; + /** Settles when the control channel says the reader's client has gone. */ + detached(): Operation; + /** Show the grid on this process's terminal. */ + attach(): Operation; + /** Ask the visible client to leave, so it restores the terminal itself. */ + detach(client: VisibleClient): Operation; + /** + * Stop the server, and establish that it is gone. + * + * Refuses rather than reporting: an unproved teardown throws, because a + * document that continued past one would be continuing while a terminal may + * still be held. + */ + stop(): Operation; +} + +const CLIENT_POLL_MS = 20; +const STOP_LIMIT_MS = 5_000; +const DETACH_LIMIT_MS = 1_000; + +/** + * Prepare the whole hidden composite. + * + * The teardown is registered before the first command, so a cancellation + * anywhere below still takes the server down: a half-built grid is exactly the + * state that would otherwise leave a server, its workers and their sockets + * behind. + */ +export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { + return resource(function* (provide) { + const target = `${request.session}:0`; + let serverPid = -1; + + /** + * Take the server down, and prove it. + * + * `kill-server` succeeding is not the proof. What is asked afterwards, and + * kept asking until both are true, is whether the process this grid started + * is unreachable and whether the server refuses to answer for its own + * session. The socket file is not part of it: it outlives the server. + */ + function* stop(): Operation { + yield* tmux.tryRun(["kill-server"]); + const deadline = Date.now() + STOP_LIMIT_MS; + let stopped: ServerStopped = { gone: false, refuses: false }; + do { + stopped = { + gone: serverPid < 0 || !(yield* processReachable(serverPid)), + refuses: (yield* tmux.tryRun(["has-session", "-t", request.session])) === undefined, + }; + if (stopped.gone && stopped.refuses) { + return stopped; + } + yield* sleep(CLIENT_POLL_MS); + } while (Date.now() < deadline); + // Provider-neutral, deliberately: a reader is told which fact could not + // be established, never the session name or socket that would identify + // this invocation's private server. + throw new GridTeardownFailed( + stopped.gone + ? "the terminal server still answers for its session" + : "the terminal server did not stop", + ); + } + + // Registered before the first command, so a preparation that fails halfway + // is torn down under the same rule — and one that cannot be proved torn + // down says so rather than passing quietly. + yield* ensure(function* () { + yield* stop(); + }); + + yield* tmux.run([ + "new-session", + "-d", + "-s", + request.session, + "-x", + String(request.width), + "-y", + String(request.height), + "-c", + request.cwd, + ...request.workerCommand(0), + ]); + serverPid = Number(yield* tmux.run(["display", "-p", "#{pid}"])); + // A pane whose worker has gone stays a pane, so its death is a fact the + // composite can read rather than a pane that vanishes from under the + // layout. + yield* tmux.run(["set", "-g", "remain-on-exit", "on"]); + yield* tmux.run(["set", "-g", "status", "off"]); + yield* tmux.run(["set", "-g", "pane-border-status", "top"]); + yield* tmux.run(["set", "-g", "pane-border-format", " #{pane_title} "]); + + // Split whichever pane has the most room, so a small window still fits + // every pane. Where each one ends up is the explicit layout's business, + // not this loop's. + const paneIds: string[] = [yield* tmux.run(["display", "-p", "-t", target, "#{pane_id}"])]; + for (let ordinal = 1; ordinal < request.panes; ordinal++) { + const roomiest = yield* largestPane(tmux, target); + const direction = roomiest.width >= roomiest.height * 2 ? "-h" : "-v"; + paneIds.push( + yield* tmux.run([ + "split-window", + "-d", + direction, + "-t", + roomiest.id, + "-c", + request.cwd, + "-P", + "-F", + "#{pane_id}", + ...request.workerCommand(ordinal), + ]), + ); + } + + const [width, height] = (yield* tmux.run([ + "display", + "-p", + "-t", + target, + "#{window_width} #{window_height}", + ])) + .split(" ") + .map(Number); + yield* tmux.run([ + "select-layout", + "-t", + target, + layoutString( + width ?? request.width, + height ?? request.height, + request.columns, + paneIds.map(paneNumber), + ), + ]); + + // tmux fills the layout's leaves in window-list order and ignores the ids + // the string names, so authored order is imposed here. Swapping preserves + // the cells: what moves is which pane is in which one. + const placed = (yield* readPanes(tmux, target, paneIds)).slice().sort(byPosition); + for (const swap of swapsInto( + placed.map((pane) => paneNumber(pane.id)), + paneIds.map(paneNumber), + )) { + const from = placed[swap.from]; + const to = placed[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + yield* tmux.run(["swap-pane", "-d", "-s", from.id, "-t", to.id]); + placed[swap.to] = from; + placed[swap.from] = to; + } + + for (const [ordinal, id] of paneIds.entries()) { + yield* tmux.run([ + "select-pane", + "-t", + id, + "-T", + request.titles[ordinal] ?? `pane ${ordinal}`, + ]); + } + const panes = yield* readPanes(tmux, target, paneIds); + + // The control client. `-f no-output` is what keeps pane bytes out of this + // process: what arrives is the server's own account of its clients. + const events: ControlEvent[] = []; + const reports = createSignal(); + // Subscribed before the client is started, so no report is missed. + const watching = yield* reports; + yield* spawn(function* () { + const [program = "tmux", ...argv] = tmux.argv([ + "-C", + "attach-session", + "-f", + "no-output", + "-t", + request.session, + ]); + // This client's stdout *is* the control protocol, not output, and its + // stderr is tmux's own — both are internal. The suppression is installed + // here, before the child starts, rather than on the handle afterwards: + // tmux sends its first record immediately on attach, so a handler + // attached after `exec()` returns is attached after that record could + // already have been forwarded. Nothing about the parsing below changes — + // the stream is still read and classified exactly as before. + yield* quietly(function* () { + const client = yield* exec(program, { arguments: argv, env: request.env }); + const reported = yield* lines()(client.stdout); + let next = yield* reported.next(); + while (!next.done) { + const event = classify(next.value); + events.push(event); + reports.send(event); + next = yield* reported.next(); + } + }); + // EOF on the control channel is its own event, and is not a detach. + events.push({ kind: "closed" }); + reports.send({ kind: "closed" }); + }); + + yield* provide({ + panes, + events, + *geometry() { + return (yield* readPanes(tmux, target, paneIds)).map((pane) => pane.cell); + }, + *title(ordinal, text) { + const id = paneIds[ordinal]; + if (id === undefined) { + return; + } + yield* tmux.tryRun(["select-pane", "-t", id, "-T", text]); + }, + *detached() { + // The control client's account. An attach client's exit code is 0 after + // a detach, 0 after a session is killed and 1 after the server is, so + // it cannot tell a reader leaving from a grid being taken down. + if (events.some((event) => event.kind === "client-detached")) { + return; + } + while (true) { + const next = yield* watching.next(); + if (next.done || next.value.kind === "client-detached") { + return; + } + } + }, + *attach() { + // Its own lifecycle, not a pane child's. A pane child is settled by + // sweeping its process group and its terminal; this client's terminal + // is the reader's, and everything holding it is the run. + let named: string | undefined; + const client = yield* useAttachClient({ + argv: tmux.argv(["attach-session", "-t", request.session]), + cwd: request.cwd, + env: request.env, + *askToLeave() { + if (named === undefined) { + return; + } + yield* tmux.tryRun(["detach-client", "-t", named]); + }, + }); + named = yield* awaitClient(tmux); + return { client, name: named }; + }, + *detach(client) { + // The ask is inside `stop()`, which is what makes the order the same + // however the grid ends: asked first, and only this exact process + // insisted on afterwards. + yield* client.client.stop(); + }, + stop, + }); + }); +} + +/** `%3` → `3`, which is what a layout string names a pane by. */ +function paneNumber(id: string): number { + return Number(id.replace(/^%/, "")); +} + +function byPosition(left: TmuxPane, right: TmuxPane): number { + return left.cell.top - right.cell.top || left.cell.left - right.cell.left; +} + +/** Every pane the window holds now, in the order `paneIds` names them. */ +function* readPanes( + tmux: Tmux, + target: string, + paneIds: readonly string[], +): Operation { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_tty} #{pane_pid} #{pane_left} #{pane_top} #{pane_width} #{pane_height}", + ]); + const found = new Map(); + for (const line of listed.split("\n")) { + const [id, tty, pid, left, top, paneWidth, paneHeight] = line.trim().split(/\s+/); + if (id === undefined || tty === undefined || pid === undefined) { + continue; + } + found.set(id, { + ordinal: paneIds.indexOf(id), + id, + // The worker reports `ttys003`; tmux reports `/dev/ttys003`. + tty: tty.replace(/^\/dev\//, ""), + pid: Number(pid), + cell: { + ordinal: paneIds.indexOf(id), + left: Number(left), + top: Number(top), + width: Number(paneWidth), + height: Number(paneHeight), + }, + }); + } + const panes: TmuxPane[] = []; + for (const id of paneIds) { + const pane = found.get(id); + if (pane !== undefined) { + panes.push(pane); + } + } + return panes; +} + +/** The pane with the most room, which is where the next split goes. */ +function* largestPane( + tmux: Tmux, + target: string, +): Operation<{ id: string; width: number; height: number }> { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_width} #{pane_height}", + ]); + let best: { id: string; width: number; height: number } | undefined; + for (const line of listed.split("\n")) { + const [id, width, height] = line.trim().split(/\s+/); + if (id === undefined || width === undefined || height === undefined) { + continue; + } + const pane = { id, width: Number(width), height: Number(height) }; + if (best === undefined || pane.width * pane.height > best.width * best.height) { + best = pane; + } + } + if (best === undefined) { + throw new Error("this grid's window holds no panes"); + } + return best; +} + +function* clientNames(tmux: Tmux): Operation { + const listed = yield* tmux.tryRun(["list-clients", "-F", "#{client_name}"]); + return listed === undefined || listed.length === 0 ? [] : listed.split("\n"); +} + +/** The client that just attached, once the server lists one it did not have. */ +function* awaitClient(tmux: Tmux): Operation { + const deadline = Date.now() + DETACH_LIMIT_MS * 5; + while (Date.now() < deadline) { + const names = yield* clientNames(tmux); + // The control client attaches with no tty of its own, so a named client is + // the visible one. + const visible = names.filter((name) => name.length > 0 && name !== "(none)"); + const found = visible.at(-1); + if (found !== undefined) { + return found; + } + yield* sleep(CLIENT_POLL_MS); + } + throw new Error("the grid was shown, but the server never listed a client for it"); +} + +/** One control-mode line, as the lifecycle event it reports. */ +export function classify(line: string): ControlEvent { + if (line.startsWith("%client-detached")) { + return { kind: "client-detached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%client-session-changed") || line.startsWith("%client-attached")) { + return { kind: "client-attached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%sessions-changed")) { + return { kind: "sessions-changed" }; + } + if (line.startsWith("%layout-change")) { + return { kind: "layout-change" }; + } + if (line.startsWith("%exit")) { + return { kind: "exit" }; + } + return { kind: "other", line }; +} diff --git a/packages/grid-tmux/src/tmux.ts b/packages/grid-tmux/src/tmux.ts new file mode 100644 index 000000000..92cc8ac34 --- /dev/null +++ b/packages/grid-tmux/src/tmux.ts @@ -0,0 +1,206 @@ +/** + * The tmux command surface, and what a host must have before a grid is opened + * (architecture.md §Interactive grids). + * + * Everything tmux is ever told goes through here, which is what makes tmux + * substitutable: a grid is built against this interface, so the lifecycle can + * be exercised without a tmux on the machine and without a terminal to draw on. + * + * The server is private to one grid. `-S ` puts it on a socket inside + * the invocation's own directory rather than the user's default one, and + * `-f /dev/null` means the reader's `.tmux.conf` cannot change what a document + * asked for — a grid is the author's layout, not the reader's configuration. + * + * Prerequisites are checked before anything is created. A host with no terminal + * or no usable tmux refuses while there is still nothing to undo: no server, no + * worker, no socket, no token, and no change to the reader's terminal. + */ + +import { exec, Stdio } from "@effectionx/process"; +import { Err, Ok, scoped } from "effection"; +import type { Operation, Result } from "effection"; + +/** + * Run `body` with this package's internal child output kept off the host. + * + * `@effectionx/process` writes every child's stdout and stderr straight to the + * host process — that is `Stdio`'s documented default — so a tmux command's + * output and a control record would be drawn on the reader's terminal and over + * pane prompts. The streams and results themselves are untouched: this + * suppresses *forwarding*, and every caller still parses and classifies exactly + * what it did before. + * + * Installed in the scope that owns the child and *before* it starts, rather + * than on the handle afterwards. Both suppress the first record in practice — + * the parent installs a post-`exec()` handler before the child is ever + * scheduled, which was measured rather than assumed — but only this placement + * cannot lose that race by construction, and the record tmux sends immediately + * on attach is the one with the least margin. + * + * The scope is a child scope so the suppression reaches this package's own + * processes and nothing else: the visible attach client and every pane child + * inherit the terminal deliberately and are never wrapped in this. + */ +export function quietly(body: () => Operation): Operation { + return scoped(function* (): Operation { + yield* Stdio.around({ + // Neither stream reaches the host. Raw tmux stderr is never forwarded — + // what a caller may see is the provider's own normalized refusal. + // deno-lint-ignore require-yield + *stdout() {}, + // deno-lint-ignore require-yield + *stderr() {}, + }); + return yield* body(); + }); +} + +/** One private tmux server, addressed by its socket. */ +export interface Tmux { + readonly socket: string; + /** Run one command; its trimmed stdout, or a failure. */ + run(args: readonly string[]): Operation; + /** The same, answering `undefined` instead of throwing. */ + tryRun(args: readonly string[]): Operation; + /** + * The whole command vector for a client this grid starts itself. + * + * Attaching is not a command that returns; it is a process that runs. It goes + * through this seam anyway, so that everything tmux is ever told is said in + * one place — and so a grid's lifecycle can be exercised against something + * other than tmux. + */ + argv(args: readonly string[]): readonly string[]; +} + +/** + * One tmux command did not work. + * + * The message names the command and nothing else. Not the arguments — they + * carry the socket path, the session name, pane and client identifiers and the + * worker's private directory. Not the exit status text — tmux writes paths into + * it. A provider's private topology is private on every path out of it, + * including the ones only taken when something has gone wrong, which are + * exactly the paths a diagnostic is read on. + */ +export class TmuxCommandFailed extends Error { + override name = "TmuxCommandFailed"; + constructor(readonly command: string) { + super(`the grid provider's "${command}" step failed`); + } +} + +/** + * A grid could not be proved taken down. + * + * Distinct from a command that failed: this is the provider having done + * everything it can and still being unable to say that nothing is left running. + * The document does not continue past it. + */ +export class GridTeardownFailed extends Error { + override name = "GridTeardownFailed"; + constructor(unproved: string) { + super( + `the grid could not be proved torn down: ${unproved}. The document ` + + `stops rather than continuing while a terminal may still be held.`, + ); + } +} + +export const TMUX_UNAVAILABLE = + "this host cannot open a grid: it needs a terminal and a tmux that " + + "supports one. Run xmd from a terminal on a host with tmux 3.0 or newer, or " + + "use a host that installs its own grid provider."; + +export class TmuxUnavailableError extends Error { + override name = "TmuxUnavailableError"; + constructor(readonly reason: string) { + super(`${TMUX_UNAVAILABLE} (${reason})`); + } +} + +/** Talk to the private server on `socket`. */ +export function tmuxAt(socket: string, env: Record): Tmux { + // `-f /dev/null`: the reader's configuration does not get to redecide an + // authored layout, a pane's border, or what a key does to the child. + const base = ["-S", socket, "-f", "/dev/null"]; + return { + socket, + argv: (args) => ["tmux", ...base, ...args], + *run(args) { + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); + if (result.code !== 0) { + // The step name and nothing else. tmux's own stderr is not forwarded + // and does not travel in the refusal: it names sockets, sessions and + // panes, which are this invocation's private topology. + throw new TmuxCommandFailed(args[0] ?? ""); + } + return result.stdout.trim(); + }, + *tryRun(args) { + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); + return result.code === 0 ? result.stdout.trim() : undefined; + }, + }; +} + +/** The oldest tmux whose layout strings and control mode behave as required. */ +const REQUIRED_TMUX = { major: 3, minor: 0 }; + +/** + * Whether this host can present a grid, and why not when it cannot. + * + * Answered before a server exists. Two facts, both of them the host's: there is + * a terminal to divide, and there is a tmux new enough to divide it the way an + * authored layout needs. + */ +export function* probeTmux(options: { + readonly isTerminal: () => boolean; + readonly env: Record; + /** What asking tmux its version does. Substituted only by this package. */ + readonly askVersion?: () => Operation<{ code: number; stdout: string }>; +}): Operation> { + if (!options.isTerminal()) { + return Err(new TmuxUnavailableError("this invocation has no terminal")); + } + const result = + options.askVersion === undefined + ? yield* quietly(() => exec("tmux", { arguments: ["-V"], env: options.env }).join()) + : yield* options.askVersion(); + if (result.code !== 0) { + return Err(new TmuxUnavailableError("tmux is not installed or would not run")); + } + const version = result.stdout.trim(); + const parsed = readVersion(version); + if (parsed === undefined) { + return Err(new TmuxUnavailableError(`tmux did not report a version (${version})`)); + } + if ( + parsed.major < REQUIRED_TMUX.major || + (parsed.major === REQUIRED_TMUX.major && parsed.minor < REQUIRED_TMUX.minor) + ) { + return Err( + new TmuxUnavailableError( + `${version} is older than tmux ${REQUIRED_TMUX.major}.${REQUIRED_TMUX.minor}`, + ), + ); + } + return Ok(version); +} + +/** `tmux 3.6a` and `tmux next-3.7` alike, read to a major and a minor. */ +function readVersion(reported: string): { major: number; minor: number } | undefined { + const match = /(\d+)\.(\d+)/.exec(reported); + if (match === null) { + return undefined; + } + const [, major, minor] = match; + if (major === undefined || minor === undefined) { + return undefined; + } + return { major: Number(major), minor: Number(minor) }; +} diff --git a/packages/grid-tmux/testing.ts b/packages/grid-tmux/testing.ts new file mode 100644 index 000000000..7b166e813 --- /dev/null +++ b/packages/grid-tmux/testing.ts @@ -0,0 +1,55 @@ +/** + * The low-level seams this adapter's own evidence drives + * (architecture.md §Package ownership). + * + * Not a second provider API. These are the pieces a row needs to hold one + * layer to its contract — a channel without a server, a worker without tmux, a + * layout string without a window — and production code imports none of them. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. + */ + +export { useAttachClient } from "./src/attach-client.ts"; +export type { AttachClient } from "./src/attach-client.ts"; +export { layoutString, placementProblems, rowMajorCells, swapsInto } from "./src/layout.ts"; +export type { LayoutCell, PaneSwap } from "./src/layout.ts"; +export { usePaneChannels } from "./src/pane-channel.ts"; +export type { PaneChannels, PaneLink } from "./src/pane-channel.ts"; +export { sweepHolders, usePaneChild } from "./src/pane-child.ts"; +export type { + PaneChild, + PaneChildOutcome, + PaneChildRequest, + PaneStartFailure, +} from "./src/pane-child.ts"; +export { + paneSocketPath, + paneTokenPath, + parseFromWorker, + parseToWorker, + readFrames, + writeFrame, +} from "./src/pane-protocol.ts"; +export type { FromWorker, Hello, Settlement, ToWorker } from "./src/pane-protocol.ts"; +export { + foregroundSignalListeners, + requireQuiescent, + runPaneWorker, + useForegroundSignals, +} from "./src/pane-worker.ts"; +export type { PaneWorkerDependencies } from "./src/pane-worker.ts"; +export { createGridTeardown, runInPane } from "./src/provider.ts"; +export type { GridParts } from "./src/provider.ts"; +export { classify, useTmuxGrid } from "./src/tmux-grid.ts"; +export type { + ControlEvent, + ServerStopped, + TmuxGrid, + TmuxGridRequest, + TmuxPane, + VisibleClient, +} from "./src/tmux-grid.ts"; +export { probeTmux, tmuxAt, TmuxCommandFailed } from "./src/tmux.ts"; +export type { Tmux } from "./src/tmux.ts"; diff --git a/packages/grid-tmux/tests/fixtures/client-command.ts b/packages/grid-tmux/tests/fixtures/client-command.ts new file mode 100644 index 000000000..3fce4e3a0 --- /dev/null +++ b/packages/grid-tmux/tests/fixtures/client-command.ts @@ -0,0 +1,21 @@ +/** + * How to run the stand-in tmux client, resolved from where the fixture lives. + * + * The fixture belongs to this package, so the path is derived from this + * module's own URL rather than written relative to a repository root. A suite + * in another package drives the same client without knowing where it sits, and + * moving the fixture again cannot leave behind a stale string that starts no + * process — a failure that reads as "nothing was signalled" rather than as a + * missing file, and one that a row expecting a client to stay put can pass + * without noticing. + */ + +import { fileURLToPath } from "node:url"; +import { cliCommand } from "@executablemd/test-support/launch"; + +export function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = fileURLToPath(new URL("./tmux-client.ts", import.meta.url)); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; +} diff --git a/packages/grid-tmux/tests/fixtures/fake-tmux.ts b/packages/grid-tmux/tests/fixtures/fake-tmux.ts new file mode 100644 index 000000000..c939ec940 --- /dev/null +++ b/packages/grid-tmux/tests/fixtures/fake-tmux.ts @@ -0,0 +1,337 @@ +/** + * A tmux server, modelled well enough to hold the composite to its contract. + * + * What matters here is the behaviour the production code exists to work + * around, so the fake reproduces it deliberately: + * + * - **a layout string's leaves are filled in window-list order, and the pane + * ids written in them are ignored.** This is why authored order is imposed by + * swaps rather than by describing it, and a fake that honoured the ids would + * make the swap logic untestable and unnecessary-looking; + * - `kill-server` leaves the socket file behind, so "gone" cannot be the file's + * absence; + * - `detach-client` removes a client and lets its process leave, while + * `kill-server` ends everything at once. + * + * The server's own liveness is a number this fake owns, and the composite asks + * the runtime's process seam about it — so a test can say "the server did not + * go away" without there being a process to refuse to die. + */ + +import { appendFile } from "node:fs/promises"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { TmuxCommandFailed } from "../../src/tmux.ts"; +import type { Tmux } from "../../src/tmux.ts"; + +export interface FakePane { + id: string; + tty: string; + pid: number; + left: number; + top: number; + width: number; + height: number; + title: string; + /** The command the pane was created with, so a test can read it back. */ + command: readonly string[]; +} + +export interface FakeTmuxOptions { + /** The window's size, which the layout is computed against. */ + readonly width?: number; + readonly height?: number; + /** Where client fixtures read what the server did. */ + readonly script: string; + /** The program a client fixture runs. */ + readonly clientCommand: (mode: "control" | "attach", script: string) => readonly string[]; + /** Fail this command once, with this message. */ + readonly failOnce?: { readonly command: string; readonly message: string }; + /** Name the server gives an attached client. */ + readonly clientName?: string; + /** + * A client that does not leave when it is asked. + * + * The server still reports the detach, but the client's process stays — which + * is the only way to reach the escalation that follows the ask. + */ + readonly stubbornClient?: boolean; + /** + * Actually start the pane commands, the way a server would. + * + * With this on, `new-session` and `split-window` spawn the exact worker + * command they were given, each in a session of its own — which is what tmux + * gives a pane's initial process. That yields real workers on real sockets + * with no real tmux anywhere. + */ + readonly spawnPanes?: boolean; +} + +export interface FakeTmux extends Tmux { + /** Every command the composite issued, in order, as one string each. */ + readonly issued: readonly string[]; + readonly panes: readonly FakePane[]; + /** The server pid the composite will ask the process seam about. */ + readonly serverPid: number; + readonly alive: () => boolean; + readonly clients: readonly string[]; + /** Every pane process this server actually started. */ + readonly started: readonly ChildProcess[]; + /** End every started pane process. */ + stopPanes(): void; + /** Say something on the control channel, as the server would. */ + say(line: string): Operation; +} + +/** Cells a layout string describes, in the order it lists them. */ +function readLayoutCells( + layout: string, +): { left: number; top: number; width: number; height: number }[] { + const cells: { left: number; top: number; width: number; height: number }[] = []; + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { + const width = options.width ?? 160; + const height = options.height ?? 48; + const issued: string[] = []; + /** Window-list order — the order panes were created, which tmux fills by. */ + const panes: FakePane[] = []; + const clients: string[] = []; + const started: ChildProcess[] = []; + let alive = false; + let nextPane = 0; + let nextPid = 4000; + const serverPid = 3999; + let failed = false; + + function pane(id: string): FakePane | undefined { + return panes.find((candidate) => candidate.id === id); + } + + /** + * Create a pane, and put it in the window list where tmux would. + * + * A split inserts the new pane *immediately after the one it split*, not at + * the end. That is what makes window-list order differ from creation order + * once panes are split by size rather than in sequence — and therefore what + * makes the authored order need imposing. + */ + function create(command: readonly string[], after?: string): FakePane { + const created: FakePane = { + id: `%${nextPane++}`, + tty: `ttys90${nextPane}`, + pid: nextPid++, + left: 0, + top: 0, + width, + height, + title: "", + command, + }; + if (options.spawnPanes === true && command.length > 0) { + const [program, ...argv] = command; + if (program !== undefined) { + started.push( + spawnChild(program, argv, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's initial process is tmux's session leader, so it is its + // own process group — which is also what keeps a worker's own + // settlement from sweeping this test runner. + detached: true, + }), + ); + } + } + const at = after === undefined ? -1 : panes.findIndex((entry) => entry.id === after); + if (at < 0) { + panes.push(created); + } else { + panes.splice(at + 1, 0, created); + } + return created; + } + + /** The pane command trailing one tmux invocation, after its last flag. */ + function trailing(args: readonly string[], lastFlagValue: string): readonly string[] { + const at = args.lastIndexOf(lastFlagValue); + return at < 0 ? [] : args.slice(at + 1); + } + + function* answer(args: readonly string[]): Operation { + issued.push(args.join(" ")); + const [command] = args; + if (options.failOnce !== undefined && !failed && command === options.failOnce.command) { + failed = true; + return undefined; + } + if (command !== "kill-server" && command !== "new-session" && !alive) { + // Every other command needs a server. + return undefined; + } + switch (command) { + case "new-session": { + alive = true; + // `... -c ` + const cwd = args[args.indexOf("-c") + 1] ?? ""; + create(trailing(args, cwd)); + return ""; + } + case "display": { + const format = args.at(-1) ?? ""; + if (format === "#{pid}") { + return String(serverPid); + } + if (format === "#{pane_id}") { + return panes[0]?.id ?? ""; + } + if (format === "#{window_width} #{window_height}") { + return `${width} ${height}`; + } + return ""; + } + case "set": + return ""; + case "split-window": { + // `... -t -c -P -F #{pane_id} ` + const target = args[args.indexOf("-t") + 1]; + return create(trailing(args, "#{pane_id}"), target).id; + } + case "list-panes": { + const format = args.at(-1) ?? ""; + return panes + .map((entry) => + format.includes("pane_tty") + ? `${entry.id} /dev/${entry.tty} ${entry.pid} ${entry.left} ${entry.top} ` + + `${entry.width} ${entry.height}` + : `${entry.id} ${entry.width} ${entry.height}`, + ) + .join("\n"); + } + case "select-layout": { + // The behaviour the swaps exist for: cells go to panes in window-list + // order, and the ids the string names are ignored. + const cells = readLayoutCells(args.at(-1) ?? ""); + for (const [index, entry] of panes.entries()) { + const cell = cells[index]; + if (cell !== undefined) { + entry.left = cell.left; + entry.top = cell.top; + entry.width = cell.width; + entry.height = cell.height; + } + } + return ""; + } + case "swap-pane": { + const source = pane(args[args.indexOf("-s") + 1] ?? ""); + const target = pane(args[args.indexOf("-t") + 1] ?? ""); + if (source === undefined || target === undefined) { + return undefined; + } + // Panes exchange positions; the cells stay where they are. + const held = { + left: source.left, + top: source.top, + width: source.width, + height: source.height, + }; + source.left = target.left; + source.top = target.top; + source.width = target.width; + source.height = target.height; + target.left = held.left; + target.top = held.top; + target.width = held.width; + target.height = held.height; + return ""; + } + case "select-pane": { + const found = pane(args[args.indexOf("-t") + 1] ?? ""); + if (found === undefined) { + return undefined; + } + found.title = args[args.indexOf("-T") + 1] ?? ""; + return ""; + } + case "list-clients": + return clients.join("\n"); + case "detach-client": { + const name = args[args.indexOf("-t") + 1] ?? ""; + const at = clients.indexOf(name); + if (at >= 0) { + clients.splice(at, 1); + } + if (options.stubbornClient !== true) { + yield* until(appendFile(options.script, "detached\n")); + } + yield* until(appendFile(options.script, `%client-detached ${name}\n`)); + return ""; + } + case "has-session": + return alive ? "" : undefined; + case "kill-server": { + if (alive) { + alive = false; + yield* until(appendFile(options.script, "detached\n%exit\n")); + } + clients.length = 0; + return ""; + } + default: + return ""; + } + } + + return { + socket: "/fake/socket", + issued, + panes, + serverPid, + alive: () => alive, + clients, + started, + stopPanes() { + for (const child of started) { + child.kill("SIGKILL"); + } + }, + argv(args) { + const mode = args.includes("-C") ? "control" : "attach"; + if (mode === "attach") { + // A visible client the server can list, named the way tmux names one. + clients.push(options.clientName ?? "/dev/ttys999"); + } + return options.clientCommand(mode, options.script); + }, + *say(line) { + yield* until(appendFile(options.script, `${line}\n`)); + }, + *run(args) { + const answered = yield* answer(args); + if (answered === undefined) { + // The same failure the real surface raises, so what a caller sees on + // this path is what a caller sees on that one. + throw new TmuxCommandFailed(args[0] ?? ""); + } + return answered; + }, + *tryRun(args) { + return yield* answer(args); + }, + }; +} diff --git a/packages/grid-tmux/tests/fixtures/tmux-client.ts b/packages/grid-tmux/tests/fixtures/tmux-client.ts new file mode 100644 index 000000000..bd1c7be47 --- /dev/null +++ b/packages/grid-tmux/tests/fixtures/tmux-client.ts @@ -0,0 +1,93 @@ +/** + * A stand-in for one tmux client, so a grid's lifecycle can be exercised + * without tmux. + * + * Two modes, because the composite keeps two clients apart and a test that + * conflated them would prove nothing about the distinction: + * + * - `control` writes lines to stdout as they appear in the script file, and + * ends at `%exit`. The composite reads it through the same line splitting and + * the same classifier it uses on real control mode, so what is faked is the + * server, never the parsing. + * - `attach` holds the terminal, and leaves when the script file says it was + * detached. It writes nothing. + * + * The script file is how a test says what the server did. Appending to it is + * the fake server's way of speaking, and polling it is this program's; neither + * is a claim about how tmux does it. + * + * Terminal restoration is deliberately outside this: a process that inherits a + * pipe cannot restore a terminal it never had. That a real `tmux attach` gives + * the terminal back when asked to detach is #726's evidence, on real tmux. + */ + +import process from "node:process"; +import { exists, readTextFile } from "@effectionx/fs"; +import { run, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; + +const POLL_MS = 15; + +type Mode = "control" | "attach"; + +/** Everything the script says so far, or nothing while it does not exist. */ +function* said(script: string): Operation { + if (!(yield* exists(script))) { + return []; + } + const text = yield* readTextFile(script); + return text.split("\n").filter((line) => line.length > 0); +} + +function write(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; +} + +function complain(text: string): Operation { + const written = withResolvers(); + process.stderr.write(text, () => written.resolve()); + return written.operation; +} + +/** + * A script line that makes this client complain instead of report. + * + * The two streams mean different things here — stdout is the control protocol + * and stderr is the client saying something went wrong — so a suite needs to + * drive them separately to show that suppressing one leaves the other alone. + */ +const COMPLAIN = "!stderr "; + +/** Follow the script until it says this client is finished. */ +export function* followScript(mode: Mode, script: string): Operation { + let seen = 0; + while (true) { + const lines = yield* said(script); + for (const line of lines.slice(seen)) { + if (mode === "control") { + if (line.startsWith(COMPLAIN)) { + yield* complain(`${line.slice(COMPLAIN.length)}\n`); + continue; + } + yield* write(`${line}\n`); + if (line.startsWith("%exit")) { + return; + } + } else if (line === "detached") { + // The reader left. A real client would restore the terminal here. + return; + } + } + seen = lines.length; + yield* sleep(POLL_MS); + } +} + +const [mode, script] = process.argv.slice(2); +if ((mode !== "control" && mode !== "attach") || script === undefined) { + process.stderr.write("usage: tmux-client.ts \n"); + process.exit(2); +} +await run(() => followScript(mode, script)); diff --git a/packages/grid-tmux/tests/grid-tmux.test.ts b/packages/grid-tmux/tests/grid-tmux.test.ts new file mode 100644 index 000000000..87c0d1679 --- /dev/null +++ b/packages/grid-tmux/tests/grid-tmux.test.ts @@ -0,0 +1,2495 @@ +/** + * Tier TX — the tmux grid provider + * (architecture.md §Interactive grids, issue #732). + * + * The provider is the one production presentation for a grid, and these rows + * hold it to the two things a document can observe about it: that the panes end + * up where the author put them, and that nothing tmux-shaped leaks out of the + * closure. Core lifecycle semantics are the controlled provider's to prove — + * this tier does not restate them. + * + * Geometry first. `select-layout tiled` picks its own column count from the + * window's dimensions, so the same four panes would be 2×2 in one terminal and + * 4×1 in another; an authored `columns` has to be told to tmux rather than + * asked of it. These rows check the string that tells it, at sizes a reader + * would actually have. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + all, + ensure, + Ok, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import net from "node:net"; +import type { Server, Socket } from "node:net"; +import * as path from "node:path"; +import process from "node:process"; +import { cliCommand } from "@executablemd/test-support/launch"; +import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; +import { realpath } from "node:fs/promises"; +import { nativeLaunch, reserveTerminal, Grids } from "@executablemd/grid"; +import type { GridComposite } from "@executablemd/grid"; +import { installControlledLauncher } from "@executablemd/grid/test"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { processReachable, TerminalProcesses } from "@executablemd/grid/processes"; +import type { SignalDelivery } from "@executablemd/grid/processes"; +import { installDenoTerminalProcesses } from "@executablemd/grid/posix"; +import { useTmuxGrid } from "../src/tmux-grid.ts"; +import { tmuxAt } from "../src/tmux.ts"; +import type { ControlEvent, TmuxGrid } from "../src/tmux-grid.ts"; +import { createFakeTmux } from "./fixtures/fake-tmux.ts"; +import type { FakeTmux } from "./fixtures/fake-tmux.ts"; +import { clientCommand } from "./fixtures/client-command.ts"; +import { layoutString, placementProblems, rowMajorCells, swapsInto } from "../src/layout.ts"; +import type { LayoutCell } from "../src/layout.ts"; +import { usePaneChannels } from "../src/pane-channel.ts"; +import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/provider.ts"; +import { installGridProvider, useGridInstallation } from "@executablemd/grid/lifecycle"; +import { registerGridProvider } from "@executablemd/grid"; +import type { Result } from "effection"; +import { processTable } from "@executablemd/grid/processes"; +import { chmod, readdir } from "node:fs/promises"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { PaneChannels, PaneLink } from "../src/pane-channel.ts"; +import { + paneSocketPath, + paneTokenPath, + parseToWorker, + readFrames, + writeFrame, +} from "../src/pane-protocol.ts"; +import { + foregroundSignalListeners, + PANE_WORKER_COMMAND, + paneWorkerInvocation, + runPaneWorker, + useForegroundSignals, +} from "../src/pane-worker.ts"; +import { usePaneChild } from "../src/pane-child.ts"; +import type { PaneChild, PaneChildOutcome } from "../src/pane-child.ts"; +import type { FromWorker, Settlement, ToWorker } from "../src/pane-protocol.ts"; + +/** The cells a layout string describes, read back out of it. */ +function readCells(layout: string): LayoutCell[] { + const cells: LayoutCell[] = []; + // `WxH,left,top,paneId` — the leaves, in the order the string lists them, + // which is the order tmux fills them in. + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + let ordinal = 0; + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + ordinal: ordinal++, + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** Every listener this process holds, across the names this code installs. */ +function processListeners(): number { + return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( + (total, name) => total + foregroundSignalListeners(name), + 0, + ); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useGridInstallation(); + yield* registerGridProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installGridProvider("tmux", { label: "tmux" }, authority); + yield* Grids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Everything gone, for rows whose subject is not the observation. */ +function useDeadObserver(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); +} + +/** A composite whose pane endpoint is the production one, over these links. */ +function paneComposite(links: readonly PaneLink[]): GridComposite { + const refuse = (): never => { + throw new Error("this row drives the pane endpoint only"); + }; + return { + attach: refuse, + update: refuse, + display: refuse, + shell: refuse, + closed: refuse, + destroy: refuse, + launch: (ordinal, request, spawned) => runInPane(links[ordinal], request, spawned), + }; +} + +describe("Tier TX — the tmux grid's geometry", () => { + it("TX1: an authored column count survives every terminal size", function* () { + // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would + // have made the wide one 4×1 and the tall one 1×4. + const sizes: [number, number][] = [ + [80, 24], + [200, 24], + [80, 60], + [211, 51], + ]; + for (const [width, height] of sizes) { + const cells = rowMajorCells(width, height, 2, 4); + const rows = new Set(cells.map((cell) => cell.top)); + const columns = new Set(cells.map((cell) => cell.left)); + const size = `${width}x${height}`; + expect(`${size}: ${rows.size} rows`).toBe(`${size}: 2 rows`); + expect(`${size}: ${columns.size} columns`).toBe(`${size}: 2 columns`); + expect(`${size}: ${placementProblems(cells, 2).join("; ")}`).toBe(`${size}: `); + } + }); + + it("TX2: the cells tile the terminal exactly, with one separator between", function* () { + const cells = rowMajorCells(80, 24, 2, 4); + // Two panes and one separator span the width; two rows and one separator + // span the height. A gap or an overlap would be a grid the reader can see + // is wrong. + const top = cells.filter((cell) => cell.top === 0); + expect(top.reduce((total, cell) => total + cell.width, 0) + (top.length - 1)).toBe(80); + const left = cells.filter((cell) => cell.left === 0); + expect(left.reduce((total, cell) => total + cell.height, 0) + (left.length - 1)).toBe(24); + }); + + it("TX3: a short final row spans it, because tmux has no empty cells", function* () { + // Three panes in two columns: two above, one below across the whole width. + const cells = rowMajorCells(80, 24, 2, 3); + expect(cells.length).toBe(3); + const last = cells[2]; + expect(last?.left).toBe(0); + expect(last?.width).toBe(80); + expect(placementProblems(cells, 2)).toEqual([]); + }); + + it("TX4: one pane and one row need no tree at all", function* () { + expect(rowMajorCells(80, 24, 1, 1)).toEqual([ + { ordinal: 0, left: 0, top: 0, width: 80, height: 24 }, + ]); + // A single row is written flat: nesting one row inside a column tree is a + // layout tmux accepts and a reader would never see the point of. + const single = layoutString(80, 24, 2, [1, 2]); + expect(single).not.toContain("["); + expect(single).toContain("{"); + }); + + it("TX5: the string is one tmux accepts — checksum, then the tree", function* () { + const layout = layoutString(80, 24, 2, [1, 2, 3, 4]); + const [sum, ...rest] = layout.split(","); + expect(sum).toMatch(/^[0-9a-f]{4}$/); + // Rows top to bottom, columns left to right, and every authored pane named. + const body = rest.join(","); + expect(body.startsWith("80x24,0,0[")).toBe(true); + for (const pane of [1, 2, 3, 4]) { + expect(body).toContain(`,${pane}`); + } + // And the geometry it describes is the geometry that was asked for. + expect(placementProblems(readCells(layout), 2)).toEqual([]); + }); + + it("TX6: the checksum changes with the tree, so a stale string is rejected", function* () { + const four = layoutString(80, 24, 2, [1, 2, 3, 4]); + const swapped = layoutString(80, 24, 2, [1, 2, 4, 3]); + expect(four.split(",")[0]).not.toBe(swapped.split(",")[0]); + }); + + it("TX7: authored order is imposed by swaps, because tmux ignores leaf ids", function* () { + // tmux fills the leaves in window-list order, so a window holding panes in + // the wrong order needs them moved rather than re-described. + const swaps = swapsInto([3, 1, 4, 2], [1, 2, 3, 4]); + const order = [3, 1, 4, 2]; + for (const swap of swaps) { + const from = order[swap.from]; + const to = order[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + order[swap.to] = from; + order[swap.from] = to; + } + expect(order).toEqual([1, 2, 3, 4]); + }); + + it("TX8: an order that is already authored is left alone", function* () { + expect(swapsInto([1, 2, 3, 4], [1, 2, 3, 4])).toEqual([]); + }); + + it("TX9: a window missing an authored pane refuses rather than placing another", function* () { + let message = ""; + try { + swapsInto([1, 2, 9], [1, 2, 3]); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("pane 3 is not in this window"); + }); +}); + +/** + * Start one real pane worker, as a real process, over the real socket. + * + * No tmux: a worker is an ordinary program that connects to a socket and does + * what it is told, and every claim in this tier is about that program. tmux's + * part — putting it in a pane with a terminal — is the next tier's. + */ +function useWorker(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const invocation = cliCommand([PANE_WORKER_COMMAND, String(ordinal), directory]); + const child = spawnChild(invocation.command, invocation.arguments, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's worker is tmux's session leader, so it is its own process + // group. Modelled here, because a settlement sweeps the group it is in + // and a worker sharing the test runner's group would be sweeping the + // test runner. + detached: true, + }); + yield* ensure(function* () { + child.kill("SIGKILL"); + yield* until( + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }), + ); + }); + yield* provide(child); + }); +} + +/** Everything one pane's worker said, until it says the one being waited for. */ +function untilFrame(link: PaneLink, type: FromWorker["type"]): Operation { + return (function* (): Operation { + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error(`the worker closed before saying "${type}"`); + } + if (frame.type === type) { + return frame; + } + } + })(); +} + +/** A raw connection to a pane's socket, for the rows about admission. */ +function useImpostor(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + const connected = withResolvers(); + socket.once("connect", () => connected.resolve()); + socket.once("error", (error: Error) => connected.reject(error)); + yield* connected.operation; + yield* ensure(() => { + socket.destroy(); + }); + yield* provide(socket); + }); +} + +/** Settle when a socket closes, or say it did not within the grace given. */ +function closedWithin(socket: net.Socket, limitMs: number): Operation { + return (function* (): Operation { + const closed = withResolvers(); + if (socket.destroyed) { + return true; + } + socket.once("close", () => closed.resolve(true)); + return yield* race([ + closed.operation, + (function* (): Operation { + yield* sleep(limitMs); + return false; + })(), + ]); + })(); +} + +/** + * Tier TP — what the wire format itself admits. + * + * Its own block rather than a row inside Tier TW: these are the schema's + * answers, needing no socket, worker or process, and a tier that spawns real + * children is both slower and a worse place to read them. + */ +describe("Tier TP — the pane protocol's launch frame", () => { + it("TP5: a launch may omit an environment, name one exactly, or be refused", function* () { + // Three distinct answers, because a launch's `env` carries three distinct + // meanings. Omitted is "the environment you already have"; a map is that + // exact map, empty included; anything else is not the protocol. + const base = { type: "launch", id: "x", argv: ["/bin/true"], cwd: "/tmp" }; + + const omitted = parseToWorker(base); + expect(omitted.type === "launch" && omitted.env).toBe(undefined); + + const exact = parseToWorker({ ...base, env: { TERM: "xterm-256color" } }); + expect(exact.type === "launch" && exact.env).toEqual({ TERM: "xterm-256color" }); + + // Empty is a real instruction — start this with nothing — and survives as + // itself rather than being read as "omitted". + const empty = parseToWorker({ ...base, env: {} }); + expect(empty.type === "launch" && empty.env).toEqual({}); + + // Malformed rather than coerced: a number is not an environment value, and + // this channel is how one process is asked to start a program on a + // terminal. + let refused = ""; + try { + parseToWorker({ ...base, env: { TERM: 256 } }); + } catch (error) { + refused = error instanceof Error ? error.name : String(error); + } + expect(refused).not.toBe(""); + }); +}); + +describe("Tier TW — the pane worker and its private channel", () => { + it("TW1: the private directory is 0700 and its tokens 0600", function* () { + const channels: PaneChannels = yield* usePaneChannels(2); + const directory = yield* stat(channels.directory); + expect(directory.mode & 0o777).toBe(0o700); + for (const ordinal of [0, 1]) { + const token = yield* stat(paneTokenPath(channels.directory, ordinal)); + expect(`pane ${ordinal}: ${(token.mode & 0o777).toString(8)}`).toBe(`pane ${ordinal}: 600`); + // The socket exists before any pane does, so a worker that starts finds + // it listening rather than racing it. + expect(yield* exists(paneSocketPath(channels.directory, ordinal))).toBe(true); + } + }); + + it("TW2: the directory and everything in it goes with the grid", function* () { + let directory = ""; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1); + directory = channels.directory; + }); + expect(yield* exists(directory)).toBe(false); + }); + + it("TW3: a real worker connects, proves which pane it is, and spends its token", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + expect(link.hello.ordinal).toBe(0); + expect(link.hello.pid).toBeGreaterThan(0); + // Spent as it was read: a second worker for this pane finds no token, so + // it has nothing to present. + expect(yield* exists(paneTokenPath(channels.directory, 0))).toBe(false); + expect(channels.refusals()).toEqual([]); + }); + + it("TW4: a connection that says nothing the protocol knows is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + socket.write("this is not a frame\n"); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals().length).toBe(1); + }); + + it("TW5: a hello with the wrong token proves nothing and is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals()[0]).toContain("could not prove it is this pane"); + }); + + it("TW6: a second connection to an admitted pane is closed", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + + // The real worker holds this pane. A second caller with the same socket + // path — token or not — is not this pane's worker. + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect( + channels + .refusals() + .some((line) => line.includes("already admitted") || line.includes("second connection")), + ).toBe(true); + }); + + it("TW7: a launch crosses exactly, and readiness is the runtime spawn event", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + // Arguments a command parser would ruin: spaces, a semicolon, a quote and + // a dollar sign. They cross the socket as bytes and reach the child as + // the exact vector. + const awkward = ["a b", "semi;colon", `quote"and'both`, "$HOME"]; + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/echo", ...awkward], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + + const started = yield* untilFrame(link, "started"); + expect(started.type === "started" ? started.pid : 0).toBeGreaterThan(0); + const exited = yield* untilFrame(link, "exited"); + if (exited.type !== "exited") { + throw new Error("expected an exit"); + } + expect(exited.exitCode).toBe(0); + // Settlement follows the exit, and the pane is free only after it. + expect(exited.settlement.quiet).toBe(true); + }); + + it("TW8: a child that never starts reports a failure and never readiness", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "missing", + argv: [path.join(channels.directory, "not-a-program")], + cwd: path.resolve("."), + env: {}, + }); + + // `error` arrives instead of `spawn`, never after it — so the pane's + // readiness latch is never tripped and the grid does not attach. + const failure = yield* untilFrame(link, "start-failed"); + expect(failure.type === "start-failed" ? failure.reason : "").toContain("could not be started"); + }); + + it("TW9: one pane admits one live child, and the next only after it settles", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const sleeper: ToWorker = { + type: "launch", + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }; + yield* link.send(sleeper); + yield* untilFrame(link, "started"); + + // Asked for while the first is live. + yield* link.send({ ...sleeper, id: "second" }); + const refused = yield* untilFrame(link, "busy"); + expect(refused.type === "busy" ? refused.id : "").toBe("second"); + + // Cancelled, settled, and only then is the pane free again. + yield* link.send({ type: "cancel", id: "first" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + + yield* link.send({ ...sleeper, id: "third" }); + const third = yield* untilFrame(link, "started"); + expect(third.type === "started" ? third.id : "").toBe("third"); + }); + + it("TW10: display is written to the pane and never read back from it", function* () { + const channels = yield* usePaneChannels(1); + const worker = yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const shown: string[] = []; + worker.stdout?.setEncoding("utf8"); + worker.stdout?.on("data", (chunk: string) => shown.push(chunk)); + + yield* link.send({ type: "display", seq: 1, text: "pane says hello\n" }); + const displayed = yield* untilFrame(link, "displayed"); + expect(displayed.type === "displayed" ? displayed.seq : 0).toBe(1); + expect(shown.join("")).toContain("pane says hello"); + }); + + it("TW11: shutdown settles, sweeps the terminal, and says goodbye", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + yield* link.send({ type: "shutdown" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + // The pane's last sweep, by the only process that can still make it. + const bye = yield* untilFrame(link, "bye"); + expect(bye.type).toBe("bye"); + }); + + it("TW13: after a settlement that proved nothing, the pane stays unavailable", function* () { + // The worker itself, run in this process against a real channel, with the + // one thing a suite cannot arrange in another process substituted: a child + // whose settlement cannot say the pane is free. A real SIGKILL always + // works, and a sweep of a pane with no terminal always comes back empty. + const channels = yield* usePaneChannels(1); + const stopping = withResolvers(); + const started: string[] = []; + let refusal = ""; + + const held: PaneChild = { + started: (function* () { + return Ok(4242); + })(), + exited: stopping.operation, + // Everything the worker can do has been done, and something still holds + // the pane's terminal. + *settle(): Operation { + return { + method: "killed", + quiet: false, + child: 4242, + swept: [], + holders: [{ pid: 900, gone: false }], + }; + }, + }; + + yield* spawn(function* () { + try { + yield* runPaneWorker(0, channels.directory, { + observe: false, + // deno-lint-ignore require-yield + *useChild(request) { + started.push(request.argv.join(" ")); + return held; + }, + }); + } catch (error) { + // Read as a value: the refusal *is* the behaviour under test, so it + // must not end the row that is testing for it. + refusal = error instanceof Error ? error.message : String(error); + } + }); + yield* useDeadObserver(); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + // Cancelled, and the settlement cannot prove the pane free. Nothing + // downstream may follow: no success frame, no cleared pane, no next child. + yield* link.send({ type: "cancel", id: "first" }); + yield* link.send({ + type: "launch", + id: "second", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + + const said: string[] = []; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + break; + } + said.push(frame.type); + } + + expect(refusal).toContain("could not be proved free"); + expect(said).not.toContain("quiet"); + expect(said).not.toContain("exited"); + // One child was ever started: the pane was never cleared, so the second + // launch had nothing to start in. + expect(started).toEqual(["/bin/sleep 30"]); + }); + + it("TW14: every emitter this code touches is left as it was found", function* () { + // Counted on the emitters themselves — the child process, the socket, the + // server, this process for signals — and after each scope has ended, which + // is when the removal is supposed to have happened. Every `.off()` in the + // touched code is load-bearing here: take one away and one of these counts + // goes up. + yield* installDenoTerminalProcesses(); + + const signalsBefore = processListeners(); + yield* scoped(function* () { + yield* useForegroundSignals(); + expect(processListeners()).toBeGreaterThan(signalsBefore); + }); + expect(processListeners()).toBe(signalsBefore); + + const children: ChildProcess[] = []; + const childListeners = (): number => + children.reduce( + (total, one) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + one.listenerCount(name), + 0, + ), + 0, + ); + + // Delivery: a child that starts and exits. + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; + // Startup is settled, so its pair is already gone; `exit` is still this + // scope's, because a settlement may yet wait on it. + expect(childListeners()).toBeGreaterThan(0); + }); + expect(childListeners()).toBe(0); + + // No delivery, and startup failure: `error` arrives instead of `spawn`. + children.length = 0; + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + }); + expect(childListeners()).toBe(0); + + // Cancellation, while the child is live and its settlement still open. + children.length = 0; + const room = yield* useScratch(); + yield* scoped(function* () { + const running = yield* spawn(function* () { + yield* scoped(function* () { + const child = yield* usePaneChild( + { + argv: ["/bin/sh", "-c", `printf '' > "${room}/on"; while true; do sleep 0.05; done`], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; + }); + }); + // Coordinated by the child's own start, never by a duration. + while (!(yield* exists(`${room}/on`))) { + yield* sleep(15); + } + yield* running.halt(); + }); + expect(childListeners()).toBe(0); + + // And the channel's own emitters: the accepted socket and both servers. + const sockets: Socket[] = []; + const servers: Server[] = []; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1, { + onSocket: (socket) => sockets.push(socket), + onServer: (server) => servers.push(server), + }); + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + expect(servers.length).toBe(1); + expect(sockets.length).toBe(1); + }); + const channelListeners = [ + ...sockets.map((socket) => + (["data", "close", "error"] as const).reduce( + (count, name) => count + socket.listenerCount(name), + 0, + ), + ), + ...servers.map((server) => + (["connection", "listening", "error"] as const).reduce( + (count, name) => count + server.listenerCount(name), + 0, + ), + ), + ]; + expect(channelListeners).toEqual([0, 0]); + }); + + it("TW12: naming the worker invocation is the only way to be one", function* () { + // In no command table, so in no help output and no catalog. What makes it + // safe is not obscurity: a worker that cannot present a pane's single-use + // token is answered by nobody. + expect(paneWorkerInvocation([PANE_WORKER_COMMAND, "0", "/tmp/x"])).toEqual({ + ordinal: 0, + directory: "/tmp/x", + }); + for (const shape of [ + [PANE_WORKER_COMMAND], + [PANE_WORKER_COMMAND, "0"], + [PANE_WORKER_COMMAND, "zero", "/tmp/x"], + [PANE_WORKER_COMMAND, "0", "/tmp/x", "extra"], + ["run", "0", "/tmp/x"], + ]) { + expect(`${shape.join(" ")}: ${paneWorkerInvocation(shape)}`).toBe( + `${shape.join(" ")}: undefined`, + ); + } + }); +}); + +/** + * Tier TG — the hidden composite's lifecycle + * (architecture.md §Atomic presentation and settlement). + * + * Against a fake server, deliberately. What is faked is tmux's *behaviour* — + * including the one this code exists to work around, that a layout string's + * leaves are filled in window-list order and the pane ids in them are ignored. + * What is not faked is the composite: the same layout string, the same swap + * decisions, the same control-mode line splitting and the same classifier run + * here as on a real server. + * + * One thing this tier deliberately does not claim. A client fixture inherits a + * pipe, so it cannot restore a terminal it never had; that a real `tmux attach` + * gives the reader's terminal back when asked to detach is #726's evidence, on + * real tmux, and nothing here stands in for it. + */ +/** Planted where a diagnostic could pick one up, and nowhere a reader looks. */ +const SESSION_MARKER = "sessionmarker7f3a"; +const DIR_MARKER = "/tmp/dirmarker7f3a"; +const CLIENT_MARKER = "clientmarker7f3a"; +const TITLE_MARKER = "titlemarker7f3a"; +const ENV_MARKER = "envmarker7f3a"; +const STDERR_MARKER = "stderrmarker7f3a"; +const TMUX_STDOUT_MARKER = "tmuxstdoutmarker7f3a"; +const TMUX_STDERR_MARKER = "tmuxstderrmarker7f3a"; + +describe("Tier TG — the tmux composite", () => { + /** A host whose processes are all gone, so teardown proves itself. */ + function useDeadServer(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + } + + /** A composite over a fake server, with the pane workers stubbed out. */ + /** + * A `tmux` on `PATH` that answers on both streams. + * + * `list-panes` succeeds and writes to stdout; anything else fails and writes + * to stderr, which is the shape `run()` and `tryRun()` branch on. Being a + * program rather than an injected seam is the point: the forwarding under + * test belongs to the process boundary, so the row needs a real child. + */ + function useFakeTmuxProgram(): Operation { + return resource(function* (provide) { + const at = path.join(tmpdir(), `xmd-fake-tmux-${randomUUID()}`); + yield* ensureDir(at); + yield* ensure(function* () { + yield* rm(at, { recursive: true, force: true }); + }); + yield* writeTextFile( + path.join(at, "tmux"), + [ + "#!/bin/sh", + 'case "$*" in', + ` *list-panes*) echo "${TMUX_STDOUT_MARKER}"; exit 0;;`, + ` *) echo "${TMUX_STDERR_MARKER}" >&2; exit 1;;`, + "esac", + "", + ].join("\n"), + ); + yield* until(chmod(path.join(at, "tmux"), 0o755)); + yield* provide(at); + }); + } + + /** + * Watch what this process actually writes to its own terminal. + * + * The boundary being defended is the host's streams, so that is what is + * observed rather than a provider's intentions: the real writes are replaced + * for the length of the row, recorded, still written so a failing row stays + * readable, and restored on the way out. + */ + function useHostStreams(): Operation<{ written: string[]; complained: string[] }> { + return resource<{ written: string[]; complained: string[] }>(function* (provide) { + const written: string[] = []; + const complained: string[] = []; + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + const record = + (into: string[], real: typeof realOut, stream: NodeJS.WriteStream) => + (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { + into.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return Reflect.apply(real, stream, [chunk, ...rest]); + }; + process.stdout.write = record(written, realOut, process.stdout); + process.stderr.write = record(complained, realErr, process.stderr); + try { + yield* provide({ written, complained }); + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } + }); + } + + function useComposite(options: { + panes: number; + columns: number; + titles?: string[]; + failOnce?: { command: string; message: string }; + }): Operation<{ grid: TmuxGrid; tmux: FakeTmux; script: string }> { + return (function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + ...(options.failOnce === undefined ? {} : { failOnce: options.failOnce }), + }); + // The server's liveness is the fake's to decide, and the composite asks + // the runtime seam about it — so "the server did not go away" is a fact a + // row can state without a process refusing to die. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + return pid === tmux.serverPid && tmux.alive(); + }, + }, + { at: "min" }, + ); + const grid = yield* useTmuxGrid(tmux, { + session: "grid", + columns: options.columns, + panes: options.panes, + width: 160, + height: 48, + titles: + options.titles ?? Array.from({ length: options.panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + return { grid, tmux, script }; + })(); + } + + it("TG1: the server is private, unconfigured, and started hidden", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // Detached, so nothing is shown; sized explicitly, so the layout is + // computed against a window rather than a guess. + const created = tmux.issued.find((line) => line.startsWith("new-session")); + expect(created).toContain("-d"); + expect(created).toContain("-x 160"); + expect(created).toContain("-y 48"); + // Every pane runs a worker, and tmux's parser sees only an ordinal and a + // directory — never a launch's argv. + expect(tmux.panes.length).toBe(2); + for (const [ordinal, pane] of tmux.panes.entries()) { + expect(pane.command.join(" ")).toBe(`xmd terminal-worker ${ordinal} /private/dir`); + } + }); + + it("TG2: the authored order survives a server that ignores the layout's ids", function* () { + const { grid, tmux } = yield* useComposite({ + panes: 4, + columns: 2, + titles: ["Planner", "Implementor", "Reviewer", "Shell"], + }); + + // The fake fills the leaves in window-list order and ignores the ids, which + // is what tmux does. Without the swaps this would be the wrong order. + expect(tmux.issued.some((line) => line.startsWith("swap-pane"))).toBe(true); + const placed = [...grid.panes].sort( + (left, right) => left.cell.top - right.cell.top || left.cell.left - right.cell.left, + ); + expect(placed.map((pane) => pane.ordinal)).toEqual([0, 1, 2, 3]); + expect( + placementProblems( + placed.map((pane) => pane.cell), + 2, + ), + ).toEqual([]); + // And each pane carries the title the author wrote for that ordinal. Read + // by pane id, because the server's window list is not the authored order — + // which is the whole reason the swaps above exist. + const titles = grid.panes.map( + (pane) => tmux.panes.find((entry) => entry.id === pane.id)?.title, + ); + expect(titles).toEqual(["Planner", "Implementor", "Reviewer", "Shell"]); + // The window list really is a different order, so this row is not passing + // because the two happened to coincide. + expect(tmux.panes.map((pane) => pane.id)).not.toEqual(grid.panes.map((pane) => pane.id)); + }); + + it("TG3: nothing is attached while the composite is being built", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // The control client is not the reader's: it attaches with `-f no-output`, + // so pane bytes never reach this process. The visible one has not been + // asked for. + expect(tmux.issued.some((line) => line.startsWith("attach-session"))).toBe(false); + expect(tmux.clients).toEqual([]); + }); + + it("TG4: attaching shows the grid, and the server lists the reader's client", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const client = yield* grid.attach(); + expect(client.name).toBe("/dev/ttys999"); + expect(tmux.clients).toContain("/dev/ttys999"); + }); + + it("TG5: a reader detach is asked for before anything is signalled", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + const client = yield* grid.attach(); + + yield* grid.detach(client); + + // Asked to leave, and gone from the server's list. A client that was + // signalled instead could not have restored the terminal — which is why + // the ask comes first. + const asked = tmux.issued.findIndex((line) => line.startsWith("detach-client")); + expect(asked).toBeGreaterThan(-1); + expect(tmux.clients).not.toContain("/dev/ttys999"); + expect(tmux.issued.slice(0, asked).some((line) => line.startsWith("kill-server"))).toBe(false); + }); + + it("TG14: the control protocol is consumed, never shown to the reader", function* () { + // `@effectionx/process` writes every child's stdout and stderr to this + // process by default, and consuming `client.stdout` does not turn that off + // — the two are independent. So the hidden control client's own records + // (`%session-changed`, `%window-renamed`, `%window-pane-changed`, and every + // other `%` line) were reaching the reader's terminal and any pane prompt + // drawn over it. Nothing about the grid looked wrong; the terminal just had + // protocol on it. + const { written } = yield* useHostStreams(); + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + // Every record the composite classifies, and one it does not, so the claim + // is not limited to the lines this suite happens to care about. + for (const record of [ + "%session-changed $0 xmd", + "%window-renamed @0 pane", + "%window-pane-changed @0 %1", + "%client-detached /dev/ttys999", + ]) { + yield* tmux.say(record); + } + yield* untilEvent(grid, "client-detached"); + + // The records were consumed — the composite classified the one it needed. + expect(grid.events.some((event) => event.kind === "client-detached")).toBe(true); + // And none of them was shown. Asserted on `%` rather than on the four + // strings: what must not reach a terminal is the protocol, not these lines. + const shown = written.join(""); + expect(shown.includes("%session-changed")).toBe(false); + expect(shown.includes("%window-renamed")).toBe(false); + expect(shown.includes("%window-pane-changed")).toBe(false); + expect(shown.includes("%client-detached")).toBe(false); + }); + + it("TG15: a control client that complains says nothing to the reader", function* () { + // The other half of TG14. tmux's own stderr names sockets, sessions and + // panes, so it is this invocation's private topology and never reaches the + // reader — a failing grid is heard through the provider's normalized + // refusal, not through the multiplexer's voice. + const { written, complained } = yield* useHostStreams(); + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + yield* tmux.say("!stderr tmux: no server running on /private/tmp/xmd-grid-abc/s"); + // Ordered behind a record the composite classifies, so the row waits on the + // client having read that far rather than on a duration. + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + + const shown = written.join("") + complained.join(""); + expect(shown.includes("no server running")).toBe(false); + // And the socket path it named is private: nothing on either stream. + expect(shown.includes("xmd-grid-abc")).toBe(false); + }); + + it("TG16: the very first control record does not reach the reader", function* () { + // The record tmux sends immediately on attach, which is the one with the + // least protection: it is waiting before the composite has read anything. + // + // What this row proves is that it is suppressed, not *where* the + // suppression was installed. That distinction was measured rather than + // assumed: with the handler installed on the handle after `exec()` returns + // this still passes, because the parent installs it synchronously before + // the child is ever scheduled — even with the shell client below, which + // writes within a millisecond instead of the ~100ms this suite's Deno + // fixture spends starting. So the pre-spawn placement in `quietly()` rests + // on the mechanism, not on this row; what this row discriminates is + // suppression being absent, which it catches. + const script = yield* useScript(); + yield* writeTextFile(script, "%session-changed $0 xmd\n"); + const { written } = yield* useHostStreams(); + + // A shell rather than this suite's usual client fixture: it writes its + // record within a millisecond of `exec` instead of after a ~100ms Deno + // start, which is the narrowest window this suite can put a record in. + const tmux = createFakeTmux({ + script, + clientCommand: (mode) => + mode === "control" + ? ["/bin/sh", "-c", "echo '%session-changed $0 xmd'; sleep 30"] + : ["/bin/sh", "-c", "sleep 30"], + }); + yield* useDeadObserver(); + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 80, + height: 24, + titles: ["Only"], + workerCommand: () => ["true"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + yield* untilEvent(grid, "other"); + + // Classified — so it really did arrive and really was read. + expect(grid.events.some((event) => event.kind !== "closed")).toBe(true); + expect(written.join("").includes("%session-changed")).toBe(false); + }); + + it("TG17: internal tmux commands show the reader neither output nor error", function* () { + // Every `tmuxAt()` command is internal. A successful one writes its answer + // to stdout, which the provider parses; a failing one writes tmux's own + // complaint to stderr, which the provider turns into `undefined` or into a + // step-named refusal. Neither is the reader's business, and the private + // socket path a real complaint carries is exactly what must not appear. + // + // The tmux here is a program on `PATH` rather than the machine's: what is + // being proved is what this provider forwards, and a row that needed real + // tmux would be a real-tmux gate, which this suite does not have. + const at = yield* useFakeTmuxProgram(); + const { written, complained } = yield* useHostStreams(); + const socket = path.join(tmpdir(), `xmd-quiet-${randomUUID()}`); + const client = tmuxAt(socket, { PATH: at }); + + // Success: the answer is parsed and returned, and stays off the terminal. + expect(yield* client.run(["list-panes"])).toBe(TMUX_STDOUT_MARKER); + + // A soft failure reports nothing rather than throwing. + expect(yield* client.tryRun(["has-session", "-t", "nothing"])).toBe(undefined); + + // A hard failure surfaces the step name and nothing else. + let refusal = ""; + try { + yield* client.run(["has-session", "-t", "nothing"]); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("has-session"); + expect(refusal.includes(socket)).toBe(false); + expect(refusal.includes(TMUX_STDERR_MARKER)).toBe(false); + + const shown = written.join("") + complained.join(""); + expect(shown.includes(TMUX_STDOUT_MARKER)).toBe(false); + expect(shown.includes(TMUX_STDERR_MARKER)).toBe(false); + expect(shown.includes(socket)).toBe(false); + }); + + it("TG18: a whole grid's life leaves no control record on the terminal", function* () { + // Startup, pane switching, detach, server disappearance and teardown, in + // one run, watched at the host's streams. Each step is driven by a record + // the composite classifies, so the row advances on events rather than on a + // duration. + const { written, complained } = yield* useHostStreams(); + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + yield* grid.title(0, "renamed"); + yield* tmux.say("%window-renamed @0 renamed"); + yield* untilEvent(grid, "other"); + yield* tmux.say("%window-pane-changed @0 %1"); + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + const stopped = yield* grid.stop(); + + expect(stopped.gone).toBe(true); + const shown = written.join("") + complained.join(""); + // No record, and no private metadata either: the session name and socket + // this invocation used are its own. + for (const leak of [ + "%session-changed", + "%window-renamed", + "%window-pane-changed", + "%client-detached", + "%sessions-changed", + "%exit", + SESSION_MARKER, + tmux.socket, + ]) { + expect([leak, shown.includes(leak)]).toEqual([leak, false]); + } + }); + + it("TG6: reader detach, control loss and server stop are separate events", function* () { + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + // `%exit` ends the control channel, and its EOF is its own event — an + // attach client's exit code could not tell these three apart. + yield* tmux.say("%exit"); + yield* untilEvent(grid, "closed"); + + const kinds = grid.events.map((event) => event.kind); + expect(kinds).toContain("client-detached"); + expect(kinds).toContain("sessions-changed"); + expect(kinds.indexOf("exit")).toBeLessThan(kinds.lastIndexOf("closed")); + }); + + it("TG7: stopping establishes the server is gone and refuses its session", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const stopped = yield* grid.stop(); + expect(stopped.gone).toBe(true); + expect(stopped.refuses).toBe(true); + expect(tmux.alive()).toBe(false); + }); + + it("TG8: a server that will not go away is a teardown failure, not a report", function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand }); + // The server answers `kill-server` and stays anyway. That the command was + // accepted is not evidence that it worked. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable() { + return true; + }, + }, + { at: "min" }, + ); + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: {}, + }); + yield* grid.stop(); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The document stops rather than continuing while a terminal may be held, + // and it is told which fact could not be established — never the session or + // socket that would name this invocation's private server. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("did not stop"); + for (const marker of [SESSION_MARKER, DIR_MARKER, tmux.socket]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG10: nothing private reaches a surfaced failure", function* () { + // A marker in every place a tmux diagnostic could pick one up: the socket, + // the session, the pane and client identifiers, the worker's private + // directory, the arguments, and what the command wrote to stderr. + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + failOnce: { command: "split-window", message: `stderr ${STDERR_MARKER}` }, + }); + yield* useDeadServer(); + + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: [TITLE_MARKER, TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + // It says which step failed, because that is what a reader can act on. + expect(failure).toContain("split-window"); + // And nothing else. A provider's private topology is private on the paths + // taken when something goes wrong too — which are the paths a diagnostic + // is actually read on. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + STDERR_MARKER, + tmux.socket, + ...tmux.panes.map((pane) => pane.id), + ]) { + expect(`${marker}: ${failure.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG11: ending the visible client signals that process and nothing else", function* () { + const script = yield* useScript(); + // A client that is asked to leave and does not, so the escalation that + // follows the ask is actually reached. + const tmux = createFakeTmux({ script, clientCommand, stubbornClient: true }); + const signalled: string[] = []; + let clientPid = -1; + + // A process table with company: XMD itself, its parent, and two more + // processes sharing XMD's foreground group. A settlement of a pane's shape + // pointed at this client would reach every one of them. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + { pid: 903, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "cousin" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + // Everything holding the reader's terminal. None of it is this + // client's to end. + return [900, 901, 902, 903]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client refuses to leave until it has been signalled once. + return pid === clientPid && !signalled.some((entry) => entry.startsWith(`${pid}:`)); + }, + }, + { at: "min" }, + ); + + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: "visible", + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + yield* grid.detach(visible); + }); + + // Asked first, and then exactly one process insisted on: not XMD, not its + // parent, not a sibling in the same group, and not a holder of the + // reader's terminal. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled.length).toBeGreaterThan(0); + for (const entry of signalled) { + expect(entry.split(":")[0]).toBe(String(clientPid)); + } + for (const bystander of [900, 901, 902, 903]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } + }); + + it("TG13: a visible client that survives every step refuses the teardown", function* () { + const script = yield* useScript(); + // Asked to detach and stays; signalled and stays; killed and stays. There + // is nothing further this may do, and nothing further it may claim. + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + stubbornClient: true, + }); + const signalled: string[] = []; + let clientPid = -1; + + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + return [900, 901, 902]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client never goes. The server does, so the refusal that + // surfaces is the client's rather than the server's. + return pid === clientPid; + }, + }, + { at: "min" }, + ); + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: [TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // It refuses rather than continuing: a document that carried on here would + // carry on while a process still holds the reader's terminal. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("visible client did not stop"); + // Nothing private in it. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + tmux.socket, + "ttys000", + ]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + // The boundary held all the way through the escalation: it was asked + // first, and every signal after that named the client alone. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled).toEqual([`${clientPid}:SIGTERM`, `${clientPid}:SIGKILL`]); + for (const bystander of [900, 901, 902]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } + }); + + it("TG12: every socket and server closes before the private directory goes", function* () { + const order: string[] = []; + let directory = ""; + let atRemoval: { closed: number; total: number } | undefined; + + yield* scoped(function* () { + const channels = yield* usePaneChannels(2, { + onClosed: () => order.push("closed"), + onRemoved: (facts) => { + atRemoval = facts; + order.push("removed"); + }, + }); + directory = channels.directory; + // A worker on one of them, so there is an accepted connection to close as + // well as the servers themselves. + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + }); + + // Counted from the sockets' and servers' own close events, not from having + // asked them to close: every one of them had actually closed by the time + // the directory was removed. + expect(order).toEqual(["closed", "removed"]); + expect(atRemoval?.total).toBeGreaterThan(0); + expect(`${atRemoval?.closed}/${atRemoval?.total}`).toBe( + `${atRemoval?.total}/${atRemoval?.total}`, + ); + expect(yield* exists(directory)).toBe(false); + }); + it("TG9: a composite that fails while being built still takes the server down", function* () { + const script = yield* useScript(); + let stopping = 0; + const tmux = createFakeTmux({ + script, + clientCommand, + // The split for the second pane fails, half-way through preparation. + failOnce: { command: "split-window", message: "no room" }, + }); + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: "grid", + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: ["a", "b"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + expect(failure).toContain("split-window"); + // Registered before the first command, so a half-built composite is still + // taken down: no server is left behind for a grid nobody ever saw. + stopping = tmux.issued.filter((line) => line.startsWith("kill-server")).length; + expect(stopping).toBeGreaterThan(0); + expect(tmux.alive()).toBe(false); + }); +}); + +/** Wait until the composite has classified an event of this kind. */ +function untilEvent(grid: TmuxGrid, kind: ControlEvent["kind"]): Operation { + return (function* (): Operation { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (grid.events.some((event) => event.kind === kind)) { + return; + } + yield* sleep(15); + } + throw new Error( + `the composite never reported "${kind}"; it reported ` + + JSON.stringify(grid.events.map((event) => event.kind)), + ); + })(); +} + +/** + * Tier TG20 — the pane's physical endpoint + * (specs/executable-mdx-spec.md TG20, architecture commit 802b07df). + * + * A `` written inside a paired pane must run on *that pane's* + * terminal. Before the amendment it delegated down the launcher chain and + * reached the root foreground launcher — which on a real host inherits the root + * terminal, the one terminal a pane exists to avoid. It now stops at the + * composite's required pane operation. + * + * Nothing nearer intercepts here: no ``, no controlled launcher in + * front. The request goes to a real worker over a real socket, and a sentinel + * stands where the root foreground launcher would be — entering it at all is + * the failure this tier exists to catch. + */ +describe("Tier TG20 — a pane launch reaches its own worker", () => { + /** A composite over a fake server that really starts its pane workers. */ + function useLiveComposite(panes: number): Operation<{ + composite: GridComposite; + tmux: FakeTmux; + channels: PaneChannels; + }> { + return (function* () { + // The observer a foreground host installs beside the provider: teardown + // proves what it claims, and refuses without it. + yield* installDenoTerminalProcesses(); + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + const channels = yield* usePaneChannels(panes); + const invocation = cliCommand([]); + const grid = yield* useTmuxGrid(tmux, { + session: "live", + columns: panes, + panes, + width: 160, + height: 48, + titles: Array.from({ length: panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + channels.directory, + ], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + void grid; + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < panes; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + const composite = paneComposite(links); + return { composite, tmux, channels }; + })(); + } + + it("TG20a: the exact argv, cwd and environment arrive at that pane's worker", function* () { + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20-${randomUUID()}.json`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + // Arguments a command parser would ruin, an environment entry only this + // launch names, and a working directory that is not the runner's. + const marker = "tg20marker"; + let started = 0; + const outcome = yield* composite.launch( + 0, + { + command: [ + "/bin/sh", + "-c", + `printf '%s' "$XMD_TG20:$PWD:$1" > "${evidence}"`, + "sh", + `a b;'"$${marker}`, + ], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20: marker }, + }, + () => started++, + ); + + expect(outcome.exitCode).toBe(0); + // The spawn was reported once, by the worker that observed it. + expect(started).toBe(1); + const seen = yield* readTextFile(evidence); + const [env, cwd, argument] = seen.split(":"); + expect(env).toBe(marker); + expect(cwd).toBe(yield* until(realpath(tmpdir()))); + // Unchanged through the socket and past tmux, whose parser never saw it. + expect(argument).toBe(`a b;'"$${marker}`); + }); + + it("TG20b: the root foreground launcher is never entered", function* () { + const { composite } = yield* useLiveComposite(1); + const reached: string[] = []; + // A sentinel where the root launcher sits. A pane launch that delegated + // past its endpoint would arrive here — and on a real host that is the + // root terminal. + yield* installControlledLauncher({ + record: (request) => reached.push(request.command.join(" ")), + outcome: () => ({ exitCode: 0 }), + }); + + yield* composite.launch( + 0, + { command: ["/bin/echo", "pane"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => {}, + ); + + expect(reached).toEqual([]); + // And the sentinel is a live one: a *root* launch does reach it. + yield* scoped(function* () { + yield* reserveTerminal(); + yield* nativeLaunch({ command: ["/bin/echo", "root"], cwd: path.resolve(".") }); + }); + expect(reached).toEqual(["/bin/echo root"]); + }); + + it("TG20c: distinct panes launch concurrently", function* () { + const { composite } = yield* useLiveComposite(2); + const room = yield* useScratch(); + + // Each child announces itself and then blocks until *both* have. Two + // children that ran one after the other could never get past this: the + // first would be waiting for a second that had not been started yet. + const child = (ordinal: number): string[] => [ + "/bin/sh", + "-c", + `printf '' > "${room}/started-${ordinal}"; ` + + `while [ ! -f "${room}/go" ]; do sleep 0.02; done`, + ]; + + const releasing = yield* spawn(function* () { + // Released by the starts themselves, never by elapsed time. + while (true) { + if ((yield* exists(`${room}/started-0`)) && (yield* exists(`${room}/started-1`))) { + yield* writeTextFile(`${room}/go`, ""); + return; + } + yield* sleep(15); + } + }); + + const outcomes = yield* all([ + composite.launch( + 0, + { command: child(0), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, + ), + composite.launch( + 1, + { command: child(1), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, + ), + ]); + yield* releasing; + + expect(outcomes.map((outcome) => outcome.exitCode)).toEqual([0, 0]); + // Both were live at the same moment: the release only happened once both + // had announced themselves, and neither could finish before it. + expect(yield* exists(`${room}/go`)).toBe(true); + }); + + it("TG20f: a launch that names no environment inherits the pane's", function* () { + // The defect this row exists for: `runInPane` coerced an absent `env` to + // `{}`, which is not the same instruction. At the root an absent `env` + // means the child inherits, so the pane collapsing it to empty started the + // program with *no* environment — no `TERM`, hence no colour, and no `PATH` + // or `HOME` either. The only production caller of `nativeLaunch` names no + // environment, so this was every real `` into a pane. + // + // TG20a covers a launch that supplies one, and could never have caught it. + const marker = `tg20f-${randomUUID()}`; + process.env.XMD_TG20F = marker; + yield* ensure(() => { + delete process.env.XMD_TG20F; + }); + + // Set before the workers start, because what a pane worker inherits is what + // it hands a child that named no environment. Under the fake that is this + // runner's environment; in production it is the pane's, which tmux gave the + // worker from `paneEnvironment()`. + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20f-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "$XMD_TG20F" > "${evidence}"`], + cwd: tmpdir(), + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + expect(yield* readTextFile(evidence)).toBe(marker); + }); + + it("TG20g: an environment that is supplied crosses exactly, gaining nothing", function* () { + // The other half. Inheriting when none was named must not become merging + // when one was: a caller that named an environment gets that environment, + // and no ambient variable of this process joins it. + const marker = `tg20g-${randomUUID()}`; + process.env.XMD_TG20G = marker; + yield* ensure(() => { + delete process.env.XMD_TG20G; + }); + + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20g-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "[$XMD_TG20G][$XMD_TG20G_OWN]" > "${evidence}"`], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20G_OWN: "named" }, + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + // The named entry arrived; the ambient one did not follow it in. + expect(yield* readTextFile(evidence)).toBe("[][named]"); + }); + + it("TG20e: a cancelled pane launch does not return while its child lives", function* () { + const { composite } = yield* useLiveComposite(1); + const room = yield* useScratch(); + yield* installDenoTerminalProcesses(); + + // Writes its pid, then stays. Nothing here ends it but the cancellation. + const launching = yield* spawn(() => + composite.launch( + 0, + { + command: ["/bin/sh", "-c", `echo $$ > "${room}/pid"; while true; do sleep 0.05; done`], + cwd: room, + env: { PATH: "/usr/bin:/bin" }, + }, + () => {}, + ), + ); + + // Live, and known by pid — a fact this run produced. + while (!(yield* exists(`${room}/pid`))) { + yield* sleep(15); + } + const pid = Number((yield* readTextFile(`${room}/pid`)).trim()); + expect(pid).toBeGreaterThan(0); + expect(yield* processReachable(pid)).toBe(true); + + yield* launching.halt(); + + // The cancellation asked the pane to stop and waited for it to prove that + // it had. Returning while the child was still live is the failure this row + // exists for. + expect(yield* processReachable(pid)).toBe(false); + }); + + it("TG20d: a composite that cannot run a pane's launch refuses", function* () { + const { composite } = yield* useLiveComposite(1); + let refusal = ""; + try { + // No such pane. There is no fallback to fall back to: putting this on + // the root terminal is the one thing that must not happen. + yield* composite.launch( + 3, + { command: ["/bin/echo", "nowhere"], cwd: path.resolve("."), env: {} }, + () => {}, + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot run that pane's launch"); + }); +}); + +/** + * Tier TH — which hosts open a grid, and which only describe one + * (architecture.md §Interactive grids). + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language and validation and install no operational provider, so a document + * that asks for a grid there is refused before a pane starts. + */ +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** A settlement that proved its pane free. */ +function quietSettlement(): Settlement { + return { method: "exited", quiet: true, swept: [], holders: [] }; +} + +/** How one scripted worker answers what the parent tells it. */ +type Reply = ( + frame: ToWorker, + say: (message: FromWorker) => Operation, + socket: Socket, +) => Operation; + +interface ScriptedWorker { + readonly socket: Socket; + /** Everything the parent told this worker, in order. */ + readonly heard: ToWorker["type"][]; +} + +/** + * One pane's worker, over that pane's real socket, saying what a row scripts. + * + * A real connection through the real admission handshake, because the order + * being frozen is the order frames actually arrive in. What is scripted is the + * worker's *answers* — which is where the protocol failures live, and the one + * thing a real worker will not do on request. + */ +function useScriptedWorker( + directory: string, + ordinal: number, + reply: Reply, +): Operation { + return resource(function* (provide) { + const socket = yield* useImpostor(directory, ordinal); + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + const heard: ToWorker["type"][] = []; + const frames = yield* readFrames(socket, (value) => parseToWorker(value)); + yield* writeFrame(socket, { + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: process.pid, + tty: "??", + isatty: [false, false, false], + }); + yield* spawn(function* () { + let next = yield* frames.next(); + while (!next.done) { + heard.push(next.value.type); + yield* reply(next.value, (message) => writeFrame(socket, message), socket); + next = yield* frames.next(); + } + }); + yield* provide({ socket, heard }); + }); +} + +/** A worker that shuts down the way one that worked is supposed to. */ +function quiesces(hold?: Operation): Reply { + return function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + if (hold !== undefined) { + yield* hold; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + yield* say({ type: "bye", holders: [] }); + // A worker that has said goodbye is leaving, and its channel closing is the + // third thing the teardown requires. One that stayed would be a pane still + // holding a connection to a grid that is going away. + socket.destroy(); + }; +} + +/** The link the teardown drives, wrapped so the row sees what it observed. */ +function loggedLink(link: PaneLink, log: string[]): PaneLink { + return { + ordinal: link.ordinal, + hello: link.hello, + *send(message) { + log.push(`${message.type}:${link.ordinal}`); + yield* link.send(message); + }, + *next() { + const frame = yield* link.next(); + log.push(frame === undefined ? `eof:${link.ordinal}` : `${frame.type}:${link.ordinal}`); + return frame; + }, + connected: () => link.connected(), + }; +} + +interface Teardown { + readonly log: string[]; + readonly directory: string; + readonly run: () => Operation; + /** Every private socket and server this grid opened. */ + readonly handles: (Socket | Server)[]; +} + +/** + * A teardown over real private channels, with the reader's client and the + * server standing in for what tmux does with them. + * + * The channels are real, so the closures and the path removal in the frozen + * order are the production ones. The two ends this fixture supplies are the two + * whose failures a row has to be able to choose. + */ +function useTeardown(options: { + readonly workers: readonly (Reply | undefined)[]; + readonly detach?: () => Operation; + readonly stop?: () => Operation; +}): Operation { + return resource(function* (provide) { + const log: string[] = []; + const handles: (Socket | Server)[] = []; + // One server per pane, created in pane order. Which pane a closure belongs + // to is read from the server that accepted the connection, so the order + // this row freezes is per-pane rather than per-event. + let panes = 0; + const belongs = new Map(); + const detachments: (() => void)[] = []; + const noteSocket = (socket: Socket, what: () => string): void => { + handles.push(socket); + const onClose = (): void => { + log.push(what()); + }; + socket.on("close", onClose); + detachments.push(() => socket.off("close", onClose)); + }; + const noteServer = (server: Server, what: () => string): void => { + handles.push(server); + const onClose = (): void => { + log.push(what()); + }; + server.on("close", onClose); + detachments.push(() => server.off("close", onClose)); + }; + yield* ensure(() => { + // This row's own listeners, off the emitters this row put them on. + for (const detach of detachments) { + detach(); + } + }); + const channels = yield* usePaneChannels(options.workers.length, { + onSocket: (socket) => noteSocket(socket, () => `socket-closed:${belongs.get(socket) ?? -1}`), + onServer: (server) => { + const ordinal = panes++; + const onConnection = (socket: Socket): void => { + belongs.set(socket, ordinal); + }; + server.on("connection", onConnection); + detachments.push(() => server.off("connection", onConnection)); + noteServer(server, () => `server-closed:${ordinal}`); + }, + }); + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + yield* useScriptedWorker(channels.directory, ordinal, reply); + } + } + const links: PaneLink[] = []; + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + links.push(loggedLink(yield* channels.link(ordinal), log)); + } + } + const run = createGridTeardown({ + detachReader: + options.detach ?? + function* () { + log.push("detach"); + }, + links, + *closeChannels() { + yield* channels.close(); + }, + stopServer: + options.stop ?? + function* () { + log.push("server-stopped"); + }, + }); + yield* provide({ log, directory: channels.directory, run, handles }); + }); +} + +describe("Tier TD — the combined teardown", () => { + it("TD1: concurrent destroys share one teardown, and every phase happens once", function* () { + const held = withResolvers(); + const fixture = yield* useTeardown({ workers: [quiesces(held.operation), quiesces()] }); + + const first = yield* spawn(() => fixture.run()); + // Held inside the first worker's settlement, so the second destroy arrives + // while the first teardown is genuinely part-way through rather than + // racing it. + while (!fixture.log.includes("shutdown:0")) { + yield* sleep(5); + } + const second = yield* spawn(() => fixture.run()); + held.resolve(); + yield* first; + yield* second; + + const once = (entry: string): number => fixture.log.filter((line) => line === entry).length; + for (const entry of ["detach", "shutdown:0", "shutdown:1", "server-stopped"]) { + expect([entry, once(entry)]).toEqual([entry, 1]); + } + // The channels too: one closure each, not one per caller. + for (const ordinal of [0, 1]) { + expect([ordinal, once(`socket-closed:${ordinal}`)]).toEqual([ordinal, 1]); + expect([ordinal, once(`server-closed:${ordinal}`)]).toEqual([ordinal, 1]); + } + }); + + it("TD2: a worker that was gone before it was asked refuses the teardown", function* () { + const fixture = yield* useTeardown({ workers: [quiesces()] }); + fixture.handles.find((handle): handle is Socket => "destroy" in handle)?.destroy(); + while (!fixture.log.includes("socket-closed:0")) { + yield* sleep(5); + } + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("gone before it was asked to stop"); + }); + + it("TD3: a goodbye before a settlement refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("said goodbye before it was proved free"); + }); + + it("TD4: a settlement with no goodbye after it refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + // EOF where the goodbye belongs: settled, and never established free. + socket.destroy(); + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("stopped answering before it was proved free"); + expect(fixture.log).toContain("eof:0"); + }); + + it("TD5: one pane's failure strands neither the next pane, the channels, nor the server", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + quiesces(), + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The first pane's failure is what surfaced, and everything acquired after + // it was still taken down. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(fixture.log).toContain("shutdown:1"); + expect(fixture.log).toContain("bye:1"); + for (const ordinal of [0, 1]) { + expect(fixture.log).toContain(`socket-closed:${ordinal}`); + expect(fixture.log).toContain(`server-closed:${ordinal}`); + } + expect(fixture.log).toContain("server-stopped"); + }); + + it("TD6: the first failure is the one that surfaces", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + // deno-lint-ignore require-yield + *stop() { + throw new Error("the server would not stop"); + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + // The pane, not the server: a later failure does not replace the reason the + // teardown could not establish this grid was gone. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(refusal).not.toContain("would not stop"); + }); + + /** A worker that stays connected and says nothing. */ + // deno-lint-ignore require-yield + const silent: Reply = function* () {}; + + it("TD10: a retry resumes at the phase that failed and re-asks no finished one", function* () { + const stops: string[] = []; + let refuse = true; + const fixture = yield* useTeardown({ + workers: [quiesces()], + // deno-lint-ignore require-yield + *stop() { + stops.push("asked"); + if (refuse) { + refuse = false; + throw new Error("the server would not stop"); + } + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("would not stop"); + + // The retry finishes the grid, and the phases that were proved done are not + // asked again: a worker that has already said goodbye and gone would answer + // the second ask as "a worker that was gone", which would replace the + // reason the first attempt could not finish with an artifact of its + // succeeding. + yield* fixture.run(); + expect(stops.length).toBe(2); + expect(fixture.log.filter((line) => line === "shutdown:0").length).toBe(1); + expect(fixture.log.filter((line) => line === "bye:0").length).toBe(1); + }); + + it("TD8: a close request that fails is retried, and what closed stays closed", function* () { + const closed: string[] = []; + let panes = 0; + let refuse = true; + const channels = yield* usePaneChannels(2, { + onSocket(socket) { + socket.on("close", () => closed.push("socket")); + }, + onServer(server) { + const ordinal = panes++; + server.on("close", () => closed.push(`server:${ordinal}`)); + if (ordinal !== 0) { + return; + } + // One handle that refuses to be *asked*, once. A close request is as + // capable of failing as the wait after it, and the two have to be + // inside the same boundary or the failure escapes the retry. + const ask = server.close.bind(server); + server.close = (callback?: (error?: Error) => void) => { + if (refuse) { + refuse = false; + throw new Error("this handle refused to be closed"); + } + return ask(callback); + }; + }, + }); + yield* useScriptedWorker(channels.directory, 0, silent); + yield* useScriptedWorker(channels.directory, 1, silent); + yield* channels.link(0); + yield* channels.link(1); + + let refusal = ""; + try { + yield* channels.close(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("refused to be closed"); + // The handles after the failure were still asked, and closed. + expect(closed.filter((name) => name === "socket").length).toBe(2); + expect(closed).toContain("server:1"); + expect(closed).not.toContain("server:0"); + + // The published settlement was cleared rather than remembered, so this call + // asks the handle that refused again — and nothing that already closed is + // closed a second time. + yield* channels.close(); + for (const [name, times] of [ + ["socket", 2], + ["server:0", 1], + ["server:1", 1], + ] as const) { + expect([name, closed.filter((entry) => entry === name).length]).toEqual([name, times]); + } + }); + + it("TD7: the combined order is the frozen one", function* () { + const order: string[] = []; + let directory = ""; + yield* scoped(function* () { + // Registered before the channels exist, so it runs after they are gone: + // the private paths are removed by the channels' own scope, last, once + // everything inside it has closed. + yield* ensure(function* () { + if (directory !== "" && !(yield* exists(directory))) { + order.push("paths-removed"); + } + }); + const fixture = yield* useTeardown({ workers: [quiesces(), quiesces()] }); + directory = fixture.directory; + yield* fixture.run(); + order.push(...fixture.log); + }); + + const at = (entry: string): number => order.indexOf(entry); + const last = (entry: string): number => order.lastIndexOf(entry); + // visible detach → worker settlements → holder-free goodbyes → worker + // channel closures → channel servers closed → server disappearance → + // private path removal. + expect(at("detach")).toBe(0); + for (const ordinal of [0, 1]) { + expect(at(`shutdown:${ordinal}`)).toBeGreaterThan(at("detach")); + expect(at(`quiet:${ordinal}`)).toBeGreaterThan(at(`shutdown:${ordinal}`)); + expect(at(`bye:${ordinal}`)).toBeGreaterThan(at(`quiet:${ordinal}`)); + } + // Each pane's four phases are that pane's, in order — panes are quiesced + // one at a time, so pane zero's channel closes while pane one has not been + // asked yet. What is global is the boundary after them: no server closes + // until every worker channel has. + for (const ordinal of [0, 1]) { + expect(at(`socket-closed:${ordinal}`)).toBeGreaterThan(at(`bye:${ordinal}`)); + expect(at("server-closed:0")).toBeGreaterThan(at(`socket-closed:${ordinal}`)); + } + expect(at("server-closed:1")).toBeGreaterThan(at("server-closed:0") - 1); + expect(at("server-stopped")).toBeGreaterThan( + Math.max(at("server-closed:0"), at("server-closed:1")), + ); + expect(at("paths-removed")).toBe(order.length - 1); + }); +}); diff --git a/packages/grid/deno.json b/packages/grid/deno.json new file mode 100644 index 000000000..708deabb4 --- /dev/null +++ b/packages/grid/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@executablemd/grid", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./testing.ts" + } +} diff --git a/packages/grid/lifecycle.ts b/packages/grid/lifecycle.ts new file mode 100644 index 000000000..dee80051b --- /dev/null +++ b/packages/grid/lifecycle.ts @@ -0,0 +1,53 @@ +/** + * Driving one provider through one grid's life + * (architecture.md §Package ownership). + * + * The direct authority a host installs, the claims and readiness a grid passes + * through before anything is shown, the row-major layout an author's `columns` + * implies, the live and durable grid itself, what it retains, and the + * reader-close boundary that ends it. A facet of `@executablemd/grid`: what + * it shares with the root is the same object, not a copy. + */ + +export { + awaitReadiness, + createGridRegistry, + createGridAuthority, + createGridClaims, + sealOnTeardown, + GridAuthorityError, + gridInstallation, + useGridInstallation, +} from "./src/authority.ts"; +export type { + GridRegistry, + LiveGrid, + PaneReadiness, + GridAuthority, + GridClaims, + GridInstallation, + PaneClaim, +} from "./src/authority.ts"; + +export { installGridProvider } from "./src/provider-api.ts"; + +export { + createCloseBoundary, + durableGrid, + openGrid, + paneNeverStartedMessage, + retainedLayout, + toRequest, +} from "./src/grid.ts"; +export type { + CloseBoundary, + GridCloseKind, + PaneStatus, + PaneWork, + RetainedGrid, + RetainedPane, + RetainedPaneOutcome, +} from "./src/grid.ts"; + +export { gridLayout } from "./src/layout.ts"; +export type { PlacedPane, GridCell, GridLayout } from "./src/layout.ts"; diff --git a/packages/grid/mod.ts b/packages/grid/mod.ts new file mode 100644 index 000000000..683524dc0 --- /dev/null +++ b/packages/grid/mod.ts @@ -0,0 +1,72 @@ +/** + * The provider-neutral grid domain (architecture.md §Package ownership). + * + * Everything here is what a document means by a grid, independent of what + * presents one: a native launch that wants the foreground, a grid of panes and + * the states they pass through, the routing that finds whichever provider a + * host installed, and the errors a caller meets when none did. A terminal is a + * capability a pane acquires, not the identity of the grid. No multiplexer, + * socket, process topology or window identifier appears in this package. + * + * The lifecycle a provider is driven through lives in `./lifecycle`, process + * observation in `./processes`, the POSIX adapters in `./posix`, and the + * controlled fixtures that prove the contract in `./test` — facets of one + * package rather than separate definitions, so a symbol exported by two of them + * is the same object. + * + * Those are boundaries in the module graph, not just in the export lists. This + * root, `./lifecycle` and `./processes` reach contracts and operations only: + * nothing they load spawns a process, reads `process.stdout`, or is a test + * fixture. Anything that performs a launch lives behind `./posix`, and anything + * that pretends to behind `./test`, so importing the domain to describe a grid + * pulls in nothing that could present or fake one. + */ + +export { + flushOutput, + NATIVE_LAUNCHER_UNAVAILABLE, + NativeLauncher, + NativeLauncherUnavailableError, + nativeLaunch, + NO_TERMINAL, + reserveTerminal, +} from "./src/native-launcher.ts"; +export type { + NativeLauncherHandler, + NativeLaunchOutcome, + NativeLaunchRequest, +} from "./src/native-launcher.ts"; + +export { + GRIDS_API, + GRID_PROVIDER_UNAVAILABLE, + Grids, + GridProviderUnavailableError, +} from "./src/composite.ts"; +export type { + GridComposite, + GridApi, + GridRequest, + PaneRequest, + PaneState, + ShellOutcome, +} from "./src/composite.ts"; + +export { + registerGridProvider, + GRID_PROVIDERS_API, + GridProviderInstallError, + GridProviders, +} from "./src/provider-api.ts"; +export type { + GridProviderApi, + GridProviderCall, + GridProviderFactory, + GridProviderInstallRequest, + GridProviderOptions, +} from "./src/provider-api.ts"; + +export { paneTerminal, usePaneTerminal } from "./src/pane.ts"; +export type { PaneTerminal } from "./src/pane.ts"; +export { usePaneNativeLauncher } from "./src/pane-launcher.ts"; +export type { RunInPane } from "./src/pane-launcher.ts"; diff --git a/packages/grid/package.json b/packages/grid/package.json new file mode 100644 index 000000000..03a2cebcb --- /dev/null +++ b/packages/grid/package.json @@ -0,0 +1,21 @@ +{ + "name": "@executablemd/grid", + "version": "0.11.0", + "description": "The provider-neutral grid domain for executable.md documents.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./testing.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0" + } +} diff --git a/packages/grid/posix.ts b/packages/grid/posix.ts new file mode 100644 index 000000000..b2c31bd1b --- /dev/null +++ b/packages/grid/posix.ts @@ -0,0 +1,18 @@ +/** + * What a POSIX host can actually observe and hand over + * (architecture.md §Package ownership). + * + * The process table, process groups, signals, reachability and terminal holders + * as `ps`, `lsof` and `kill` answer them, plus the foreground child that gives + * a native program this run's own terminal. It lives here rather than in a + * presentation provider because a second POSIX provider should reuse the same + * proof without depending on tmux. + * + * Node and Bun install none of it: a host that cannot observe a pane refuses a + * grid rather than reporting one free it never checked. + */ + +export { installForegroundLauncher } from "./src/posix-launcher.ts"; +export type { ForegroundLauncherOptions } from "./src/posix-launcher.ts"; +export { installDenoTerminalProcesses, posixProcessProbes } from "./src/posix-processes.ts"; +export type { ProcessProbes } from "./src/posix-processes.ts"; diff --git a/packages/grid/processes.ts b/packages/grid/processes.ts new file mode 100644 index 000000000..e569c85cd --- /dev/null +++ b/packages/grid/processes.ts @@ -0,0 +1,31 @@ +/** + * What a host may establish about processes and terminals + * (architecture.md §Package ownership). + * + * The contract only. Every answer is a host's, installed through + * `./posix` or by a suite that supplies its own, and every path fails closed: + * a question that could not be answered is never read as "nothing is there". + */ + +export { + deliverSignal, + descendantsOf, + establishQuiescence, + groupMembers, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_API, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + TerminalProcessesUnavailableError, + terminalHolders, +} from "./src/processes.ts"; +export type { + PaneOccupants, + PaneQuiescence, + ProcessFacts, + SignalDelivery, + TerminalProcessHandler, + TerminalSignal, +} from "./src/processes.ts"; diff --git a/packages/grid/src/authority.ts b/packages/grid/src/authority.ts new file mode 100644 index 000000000..c8d5d01f0 --- /dev/null +++ b/packages/grid/src/authority.ts @@ -0,0 +1,302 @@ +/** + * Who is allowed to own a terminal, and what "ready" means (architecture.md + * §Terminal authority). + * + * The provider draws a grid. This decides everything about it that matters: + * which request is live, which provider installation it belongs to, which pane + * ordinals exist, whether an interactive operation may start on one, when a + * pane has actually started, and what the grid settled to. None of that is + * reachable by name. There is no context holding an authority, no member of a + * request that carries one, and no handler return value that produces one — an + * authority reachable by name would be an authority every same-name context and + * every loaded copy could reach. + * + * A claim is the unforgeable carrier. It is minted here for one ordinal of one + * request under one installation generation, and a claim from another grid, + * another ordinal, an earlier generation, or a finished expansion authorizes + * nothing at all. Holding one grants terminal ownership and nothing else: it + * says nothing about which Agent session a pane may own, because that is the + * session coordinator's to answer and stays independently authoritative. + */ + +import { all, createContext, ensure, withResolvers } from "effection"; +import type { Context, Operation } from "effection"; +import type { GridComposite, GridRequest } from "./composite.ts"; + +export class GridAuthorityError extends Error { + override name = "GridAuthorityError"; +} + +/** + * One pane's terminal ownership. + * + * `admit` is the whole of it: an interactive operation runs inside one, and a + * second one on the same pane is refused while the first is live. Two claims for + * two ordinals do not contend at all, which is what lets panes be interactive at + * the same time. + */ +export interface PaneClaim { + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * Refuses while another is live on this pane, and refuses once the grid that + * minted the claim has stopped admitting work — a claim kept past its + * expansion is a claim to a terminal nobody owns any more. + */ + admit(body: () => Operation): Operation; + /** + * Acknowledge the runtime's successful child-spawn event for this pane. + * + * The one thing that makes a pane ready. Called from the spawn event and + * before anything waits for the child to exit, so a child that starts and + * immediately exits is both ready and settled. Acknowledging twice has no + * effect, and a preparation, reservation or spawn that failed never + * acknowledges at all. + */ + ready(): void; +} + +/** What one pane's readiness is waiting on, from the grid's side. */ +export interface PaneReadiness { + /** Settles when the pane's first interactive child reports its spawn event. */ + reached(): Operation; + /** Whether the latch has been acknowledged. */ + readonly acknowledged: boolean; +} + +/** The claims one grid expansion holds, and what they are waiting on. */ +export interface GridClaims { + readonly claims: readonly PaneClaim[]; + readonly readiness: readonly PaneReadiness[]; + /** + * Stop admitting anything on every pane. + * + * Close prevents a later launch before it cancels the live ones, so a pane + * that was about to start one is refused rather than raced. + */ + seal(): void; +} + +/** + * What a registered provider must present in order to act. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else. Presenting the exact request core issued is what takes the + * terminal leases, mints the pane claims, and runs the grid; anything else — + * a copy, a rebuilt lookalike, an earlier grid's request, a request already + * presented, or one belonging to a superseded installation — authorizes + * nothing. + */ +export interface GridAuthority { + present(request: GridRequest, composite: GridComposite): Operation; +} + +/** One grid this execution issued, from the authority's side. */ +export interface LiveGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: GridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Run the grid on a presented composite, and keep what it settled to. */ + run(composite: GridComposite): Operation; + /** Whether this request has already been presented. */ + used: boolean; + /** Whether the grid actually ran to a settlement. */ + settled: boolean; +} + +/** Every grid this execution has issued and not yet finished. */ +export interface GridRegistry { + live(): readonly LiveGrid[]; + add(grid: LiveGrid): void; + remove(grid: LiveGrid): void; +} + +export function createGridRegistry(): GridRegistry { + const grids = new Set(); + return { + live: () => [...grids], + add: (grid) => { + grids.add(grid); + }, + remove: (grid) => { + grids.delete(grid); + }, + }; +} + +/** + * Build the authority one provider installation is given. + * + * It closes over the installation's generation and its registry, so a factory + * that kept an authority from a superseded installation presents into a + * generation that no longer has the grid it names. + */ +export function createGridAuthority( + generation: object, + live: () => readonly LiveGrid[], +): GridAuthority { + return { + *present(request, composite) { + const grid = live().find((candidate) => Object.is(candidate.request, request)); + if (grid === undefined) { + throw new GridAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(grid.generation, generation)) { + throw new GridAuthorityError( + "this grid request belongs to another grid provider installation", + ); + } + if (grid.used) { + throw new GridAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + grid.used = true; + yield* grid.run(composite); + }, + }; +} + +/** One execution's grid installation: its registry and its generation. */ +export interface GridInstallation { + readonly registry: GridRegistry; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + GridInstallation | undefined +>("core.grid.installation", undefined); + +/** + * Open one grid installation for a live document, and hand back the + * authority its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The authority does + * not: it is handed to a provider factory directly. A replaced installation + * therefore produces requests the real authority has never heard of, which is a + * refusal rather than a way in. + */ +export function* useGridInstallation(): Operation { + const registry = createGridRegistry(); + const generation = {}; + yield* Installation.set({ registry, generation }); + return createGridAuthority(generation, () => registry.live()); +} + +/** This execution's grid installation, or `undefined` outside one. */ +export function gridInstallation(): Operation { + return Installation.get(); +} + +/** + * Mint the claims for one grid expansion. + * + * The request is validated against the ordinals it declares before a single + * claim exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +export function createGridClaims(request: GridRequest): GridClaims { + validate(request); + + let sealed = false; + const claims: PaneClaim[] = []; + const readiness: PaneReadiness[] = []; + + for (const pane of request.panes) { + const latch = withResolvers(); + let acknowledged = false; + let live = false; + + readiness.push({ + reached: () => latch.operation, + get acknowledged() { + return acknowledged; + }, + }); + + claims.push({ + ordinal: pane.ordinal, + *admit(body: () => Operation): Operation { + if (sealed) { + throw new GridAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new GridAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(); + } finally { + live = false; + } + }, + ready() { + // Idempotent by construction: readiness is a fact about the pane, and a + // provider that reports the same spawn twice has not started two panes. + if (acknowledged) { + return; + } + acknowledged = true; + latch.resolve(); + }, + }); + } + + return { + claims, + readiness, + seal() { + sealed = true; + }, + }; +} + +function validate(request: GridRequest): void { + if (request.panes.length === 0) { + throw new GridAuthorityError("a grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new GridAuthorityError( + `a grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reaches its latch, so the caller races this against pane failure + * rather than asking the barrier to know about failure. + */ +export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { + return allOf(readiness.map((pane) => pane.reached())); +} + +function* allOf(waits: readonly Operation[]): Operation { + yield* all(waits); +} + +/** Seal the grid as soon as the enclosing scope begins to unwind. */ +export function sealOnTeardown(claims: GridClaims): Operation { + return ensure(() => { + claims.seal(); + }); +} diff --git a/packages/grid/src/composite.ts b/packages/grid/src/composite.ts new file mode 100644 index 000000000..466163330 --- /dev/null +++ b/packages/grid/src/composite.ts @@ -0,0 +1,208 @@ +/** + * The grid boundary — how a host presents one grid of interactive + * panes, and what composing middleware around it may do. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native composite UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases, mints pane claims and settles a + * grid is a non-contextual authority delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole composite while it is hidden, core starts the + * authored panes and waits for every one of them to report a spawn, and only + * then is anything attached. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; + +/** One pane the provider is asked to present, by its authored ordinal. */ +export interface PaneRequest { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** The label to display. Two panes may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this pane's work or the host's default shell + * does. A provider reads it to know which panes it must start a shell in. + */ + readonly form: "paired" | "self-closing"; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or pane identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and the authority compares the object it is presented with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. + */ +export interface GridRequest { + readonly columns: number; + readonly rows: number; + readonly panes: readonly PaneRequest[]; +} + +/** + * What core tells a provider about one pane, as it happens. + * + * A closed set, and display only. `running` follows readiness, `succeeded` and + * `failed` follow the pane's own settlement, and `closed` is a live pane + * cancelled solely because the reader closed the grid — which is not a failure + * and is deliberately spelled differently from one. + */ +export type PaneState = "starting" | "running" | "succeeded" | "failed" | "closed"; + +/** How a pane's default shell ended. */ +export interface ShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One prepared, still-hidden grid. + * + * Everything here belongs to the one preparation that produced it. A composite + * is never reused across expansions, and a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. + */ +export interface GridComposite { + /** + * Show the composite. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * preparation, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: PaneState): Operation; + /** + * Show text a pane's own content rendered. + * + * This is where a paired pane's output goes, and the only place it goes: it + * is never copied into the root document output or into a capture written + * around the grid, because the reader is looking at the pane. Terminal bytes + * an interactive child exchanges with the reader never come through here at + * all — those belong to the pane's terminal and are neither captured nor + * journaled. + */ + display(ordinal: number, text: string): Operation; + /** + * Start the host's default interactive shell in one pane and report how it + * ended. + * + * Which shell that is comes from live host policy, never from the document. + * + * `spawned` is the pane's readiness latch, and calling it is the only thing + * that makes this pane ready. Call it from the runtime's successful + * child-spawn event and before waiting for the child to exit — so a shell + * that starts and exits at once is both ready and settled, while a shell that + * never started leaves the latch alone and the grid never attaches. + */ + shell(ordinal: number, spawned: () => void): Operation; + /** + * Run one native launch in one pane, on that pane's terminal. + * + * This is the physical endpoint for a `` written inside a + * paired pane. Core closes its pane-scoped launcher over this operation and + * the pane's authored ordinal, so the ordinal stays in a live closure and + * enters no request, session key, durable phase, result or diagnostic. What + * crosses is the exact command vector, working directory and environment the + * Agent provider supplied. + * + * Required of every composite, and deliberately not optional: a provider that + * cannot execute a pane launch refuses here. Falling back would put a native + * UI on the root terminal — the one terminal a pane exists to avoid. + * + * `spawned` is the pane's readiness latch, on the same terms as `shell()`: + * called for the child's runtime spawn event and nothing earlier. + * + * Kept apart from `shell()` because they answer different questions. `shell()` + * derives its executable from live host policy; this runs the request it is + * given. + */ + launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ): Operation; + /** + * Settle when the reader closes or leaves the composite. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; + /** + * Take the composite down and give the root terminal back. + * + * Called exactly once for every composite that was prepared, including one + * discarded before it ever attached. + */ + destroy(): Operation; +} + +/** The stable name every loaded copy composes through. */ +export const GRIDS_API = "Grids"; + +export const GRID_PROVIDER_UNAVAILABLE = + "no grid provider is installed — this host does not present a grid of " + + "interactive panes. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class GridProviderUnavailableError extends Error { + override name = "GridProviderUnavailableError"; + constructor(message: string = GRID_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +export interface GridApi { + /** + * Route one grid request to whatever presents it. + * + * Answers `unknown`, and the answer is discarded: a return value is not + * evidence that a grid was opened, and core reads what the authority settled + * instead of what a handler said. + */ + open(request: GridRequest): Operation; +} + +/** + * The public routing surface. Its own default always refuses. + * + * Reaching this default means no registered provider consumed the request, so + * nothing was presented — which is the honest answer for a host that installs + * no provider at all. + */ +export const Grids: Api = createApi(GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: GridRequest): Operation { + throw new GridProviderUnavailableError(); + }, +}); diff --git a/packages/grid/src/controlled-composite.ts b/packages/grid/src/controlled-composite.ts new file mode 100644 index 000000000..e7b8c8782 --- /dev/null +++ b/packages/grid/src/controlled-composite.ts @@ -0,0 +1,201 @@ +/** + * A composite that presents nothing and records everything. + * + * The controlled implementation of the contract in `./composite.ts`, and the + * authority for core's grid lifecycle: it answers the whole contract — attach, + * update, display, shell, launch, close, destroy — so a suite exercises the + * lifecycle without a terminal, a multiplexer, or a process anywhere in it. + * + * It lives apart from the contract for the same reason the controlled launcher + * does: production code must have no path to a fixture, and importing the + * domain must not load one. It is reachable only through + * `@executablemd/grid/test`. + */ + +import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; +import type { GridComposite, GridRequest, PaneState, ShellOutcome } from "./composite.ts"; + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface GridProviderLog { + readonly events: string[]; + /** + * What each pane displayed, by ordinal. + * + * A suite reads this to prove where a pane's output went — and reads the root + * document output to prove where it did not. + */ + readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: GridProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface GridProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; + /** Pane launches started whose outcome has not been returned. */ + launches: number; +} + +/** A fresh, empty record. */ +export function gridProviderLog(): GridProviderLog { + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0, launches: 0 }, + }; +} + +/** + * What a controlled composite does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. + */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ + readonly log?: GridProviderLog; + onPrepare?: (request: GridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting and hoping. + */ + onUpdate?: (ordinal: number, state: PaneState) => void; + shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * What a pane launch does, in place of starting a native UI. + * + * Left out, a launch refuses — which is what a composite that cannot execute + * one must do, and what keeps a suite that says nothing about launching from + * quietly passing one to the root terminal. + */ + launch?: ( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ) => Operation; + close?: () => Operation; +} + +/** + * Prepare one composite that presents nothing and records everything. + * + * It answers the whole contract — attach, update, display, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function prepareControlledComposite( + request: GridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? gridProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; + let destroyed = false; + let attached = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; + } + }, + *launch(ordinal, request, spawned) { + log.events.push(`launch:${generation}:${ordinal}`); + if (options.launch === undefined) { + throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); + } + log.live.launches++; + try { + return yield* options.launch(ordinal, request, spawned); + } finally { + log.live.launches--; + } + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } + }, + }; + })(); +} diff --git a/packages/grid/src/controlled-launcher.ts b/packages/grid/src/controlled-launcher.ts new file mode 100644 index 000000000..ea7c0f75f --- /dev/null +++ b/packages/grid/src/controlled-launcher.ts @@ -0,0 +1,84 @@ +/** + * The launcher a host installs when it has no terminal to give away. + * + * The other implementation of the contract in `./native-launcher.ts`, and the + * one every suite that is not about a real terminal uses. It reaches no + * process and no host stream — a launch here is whatever the row says it is — + * and it lives in its own module so that importing the domain never loads a + * fixture. Production code has no path to it: it is reachable only through + * `@executablemd/grid/test`. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; + +/** + * How a controlled launch behaves. + * + * `record` sees each request in the order the provider made it; `outcome` + * decides what the child did; and `wait` is the operation the launch blocks + * on, so a test controls exactly how long the document stays suspended. + */ +export interface ControlledLauncherOptions { + record?: (request: NativeLaunchRequest) => void; + outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; + wait?: (request: NativeLaunchRequest) => Operation; + /** + * Start the child, in place of a runtime that would. + * + * It receives the spawn report, so a test decides whether this launch starts + * at all: reporting is what a successful start does, and throwing without + * reporting is what a failure before the start does. Left out, the child + * starts at once — a test that says nothing about starting wants a launch + * that started. + */ + start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; + onReserve?: () => void; + onFlush?: () => void; +} + +export function* installControlledLauncher( + options: ControlledLauncherOptions = {}, +): Operation { + let held = false; + yield* NativeLauncher.around( + { + reserve() { + return resource(function* (provide) { + if (held) { + throw new Error( + "another already holds this run's terminal — one " + + "native UI owns the terminal at a time", + ); + } + held = true; + options.onReserve?.(); + try { + yield* provide(); + } finally { + held = false; + } + }); + }, + // deno-lint-ignore require-yield + *flush() { + options.onFlush?.(); + }, + *launch([request, spawned]) { + options.record?.(request); + if (options.start) { + yield* options.start(request, spawned); + } else { + spawned(); + } + if (options.wait) { + yield* options.wait(request); + } + return options.outcome?.(request) ?? { exitCode: 0 }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/grid/src/grid.ts b/packages/grid/src/grid.ts new file mode 100644 index 000000000..799d45c80 --- /dev/null +++ b/packages/grid/src/grid.ts @@ -0,0 +1,589 @@ +/** + * One grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement, §Durability and replay). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The composite is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier discards the hidden composite + * instead of leaving half a grid on the screen. + * + * ``` + * layout recorded → lease → flush → routed to a provider → composite presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Each pane is a **durable child coroutine** of the grid, allocated in authored + * order. That is not decoration: a completed child short-circuits on replay by + * returning its retained result without running, and claiming a completed + * parent claims every descendant history beneath it. Wrapping the region in one + * durable operation instead would leave the panes' entries unconsumed and + * desynchronise the journal on the next run. + */ + +import { + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { Grids } from "./composite.ts"; +import { flushOutput, reserveTerminal } from "./native-launcher.ts"; +import type { GridComposite, GridRequest } from "./composite.ts"; + +import { + awaitReadiness, + createGridClaims, + GridAuthorityError, + gridInstallation, +} from "./authority.ts"; +import type { LiveGrid, PaneClaim } from "./authority.ts"; +import type { GridLayout } from "./layout.ts"; + +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * seal admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} + +/** + * What one pane does once its claim exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both run as the pane's admitted owner, and both are expected + * to report a spawn through the claim before anything can attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(claim: PaneClaim, composite: GridComposite): Operation; +} + +/** + * What a pane that never reported a spawn says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: GridLayout): GridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + panes: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} + +/** The retained shape of one request. */ +export function retainedLayout(request: GridRequest): RetainedGrid["layout"] { + return { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ + ordinal: pane.ordinal, + title: pane.title, + form: pane.form, + row: pane.row, + column: pane.column, + })), + }; +} + +/** + * Open one grid and report what it settled to. + * + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * authority settled. The routed answer is discarded on purpose: a handler that + * short-circuits or fabricates a return has presented nothing, and this says so + * rather than letting the document believe a grid opened. + */ +export function openGrid( + layout: GridLayout, + work: readonly PaneWork[], + boundary: CloseBoundary, +): Operation { + return scoped(function* (): Operation { + const installation = yield* gridInstallation(); + if (installation === undefined) { + throw new GridAuthorityError( + "a grid is available only inside a document execution with an installed " + + "grid provider — a grid outside one retains nothing and could not be resumed", + ); + } + + const request = toRequest(layout); + let settled: RetainedGrid | undefined; + + const grid: LiveGrid = { + request, + generation: installation.generation, + used: false, + settled: false, + *run(composite) { + settled = yield* presentGrid(request, composite, work, boundary); + grid.settled = true; + }, + }; + installation.registry.add(grid); + yield* ensure(() => { + installation.registry.remove(grid); + }); + + // The one foreground-terminal lease, taken before any provider is asked for + // anything. A root and a grid contend for exactly this, so + // neither can begin while the other holds it. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + // Routed, and the answer thrown away. + yield* Grids.operations.open(request); + + if (!grid.settled || settled === undefined) { + throw new GridAuthorityError( + "no grid provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid on the composite a provider presented. + * + * The composite is scope-owned, so every path out of here — success, failure, + * and cancellation alike — destroys exactly the composite that was presented. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function presentGrid( + request: GridRequest, + composite: GridComposite, + work: readonly PaneWork[], + boundary: CloseBoundary, +): Operation { + return scoped(function* (): Operation { + // Registered before a single pane starts: a composite that was presented is + // owed a destroy even if the next line is what fails. + yield* ensure(() => composite.destroy()); + + const grid = createGridClaims(request); + // Nothing new is admitted once teardown begins, so a pane that was about to + // start an interactive child is refused rather than racing the close. + yield* ensure(() => { + grid.seal(); + }); + + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + // Reader close asks the panes to stop; it does not halt them. A pane that + // is asked settles as `closed` and records that outcome as its own, so a + // resumed run restores a pane the reader closed rather than finding a + // cancelled child it must either re-enter or wait on forever. + const closing = withResolvers(); + let attached = false; + + for (const pane of work) { + yield* composite.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { + const claim = grid.claims[index]!; + const readiness = grid.readiness[index]!; + panes.push( + yield* paneChild(function* (): Operation { + return yield* runPane( + pane, + claim, + composite, + readiness, + request, + index, + closing.operation, + ); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a satisfied readiness latch. + for (const [index, task] of panes.entries()) { + yield* spawn(function* () { + const outcome = yield* task; + outcomes[index] = outcome; + // A pane restored from its retained outcome counts as started: it did + // start, on the run that recorded it. + grid.claims[index]!.ready(); + yield* composite.update(work[index]!.ordinal, outcome.status); + if (outcome.status === "failed" && !attached) { + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + }); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for a latch nothing will acknowledge. + try { + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a grid pane failed to start"); + } + + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "running"); + } + } + yield* composite.attach(); + attached = true; + + // The composite stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* composite.closed(); + + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + + // Close prevents new work first, then takes the live panes down: a pane + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the composite is destroyed, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. + grid.seal(); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "closed"); + } + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* panes[index]!; + outcomes[index] ??= outcome; + } + + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); + return retained(request, settled, reason); + }); +} + +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + claim: PaneClaim, + composite: GridComposite, + readiness: { readonly acknowledged: boolean }, + request: GridRequest, + index: number, + closing: Operation, +): Operation { + return (function* (): Operation { + try { + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(claim, composite)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. + yield* running.halt(); + return { status: "closed", reason: "" }; + } + if (!readiness.acknowledged) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The record one grid settled to. */ +function retained( + request: GridRequest, + panes: readonly RetainedPaneOutcome[], + reason: string | undefined, +): RetainedGrid { + return { + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: [...panes], + }; +} + +/** The first failed pane's sentence in authored order, which is the grid's. */ +function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { + return outcomes.find((outcome) => outcome?.status === "failed")?.reason; +} + +/** + * Run one pane as a durable child of the grid. + * + * A pane's identity is derived from the grid's coroutine and its authored + * ordinal, never from a title, a schedule, or a provider identifier — so a + * resumed run restores a completed pane as its outcome without re-running it, + * and continues an incomplete one from its own history. + * + * `durableSpawn` rather than a combinator, because the grid owns the panes + * itself: it has to reach the readiness barrier and attach while they are still + * live, and cancel them one at a time when the reader leaves. A retained + * cancelled pane resumes its remaining work rather than suspending, which is + * `durableSpawn`'s policy for a spawned region. + * + * Without a journal there is no child to derive, and the work simply runs. + */ +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); + } + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); + })(); +} + +/** + * Run the whole grid as one durable child, and return what it retained. + * + * A completed grid replays by returning its retained result: the child's + * workflow never runs, so no provider is contacted, no pane content expands and + * no shell starts — and claiming the completed child claims every pane history + * beneath it, so a resumed run starts nothing. + */ +export function durableGrid( + live: (boundary: CloseBoundary) => Operation, +): Operation { + return (function* (): Operation { + const boundary = createCloseBoundary(); + const durable = yield* DurableContext.get(); + if (durable === undefined) { + // No journal to finish into, so the boundary is crossed as soon as it is + // proposed and the grid closes in one step. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* live(boundary); + } + + // The grid's durable child runs in a scope of its own — a child of this one, + // so it inherits every context the document runs under, and its own so that + // tearing this one down does not reach the child first. + // + // That ordering is what makes the await below genuinely deferred. A scope + // runs its finalizers in reverse, so one registered after this scope exists + // runs before this scope is destroyed: the grid and its panes finish their + // own teardown and append their ordinary completed `Close` records, and only + // then does the cancellation carry on to the parent. + const [detached, destroy] = createScope(yield* useScope()); + const held: { + task?: Task; + outcome?: Result; + } = {}; + + // Registered after the scope and before the await, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && boundary.acknowledged && held.outcome === undefined) { + held.outcome = yield* finish(held.task); + } + yield* until(destroy()); + }); + + held.task = yield* durableSpawnIn(detached, function* (): Workflow { + return yield* ephemeral(live(boundary)); + }); + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + + held.outcome = yield* finish(held.task); + yield* until(destroy()); + if (!held.outcome.ok) { + throw held.outcome.error; + } + return held.outcome.value; + })(); +} + +/** Await one grid child, keeping how it ended rather than re-throwing it here. */ +function* finish(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/packages/grid/src/layout.ts b/packages/grid/src/layout.ts new file mode 100644 index 000000000..f16272b67 --- /dev/null +++ b/packages/grid/src/layout.ts @@ -0,0 +1,73 @@ +/** + * The concrete grid an authored `` derives (spec §6.21). + * + * `structural-rules.ts` decides what the source says: which panes were written, + * in what order, and what is wrong with the way they were written. What it + * cannot decide is where each pane sits, because that also depends on `columns` + * — a value the document may compute. This module is where the two meet, once + * both are known and before anything is opened. + * + * A layout is provider-neutral data. It names no terminal, multiplexer, socket, + * process or window: it says how many columns the author asked for, how many + * rows that many panes fill, and which cell each pane occupies. + */ + +/** + * Whether a pane runs the markdown it holds or the host's default shell. + * + * Declared here rather than imported: the layout is provider-neutral data, and + * core's authored pane — which carries the element it was written as — would + * point this package back at the document engine it is placed for. + */ +export type PaneForm = "paired" | "self-closing"; + +/** One pane, placed. */ +export interface GridCell { + /** The pane's structural identity: its position among the panes, from zero. */ + readonly ordinal: number; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** The label it displays. Two cells may carry the same one. */ + readonly title: string; + /** Whether it runs the markdown the pane holds or the host's default shell. */ + readonly form: PaneForm; +} + +/** The complete grid one `` asked for. */ +export interface GridLayout { + readonly columns: number; + /** How many rows those columns take to hold every pane. */ + readonly rows: number; + /** Every pane, in authored order, which is also row-major order. */ + readonly cells: readonly GridCell[]; +} + +/** One pane's placeable facts, once its title has been resolved. */ +export interface PlacedPane { + readonly title: string; + readonly form: PaneForm; +} + +/** + * Place the panes across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` panes fill the first row, the next fill the + * second, and a count that does not divide leaves the positions at the end of + * the last row unused. Nothing is reordered, padded, or balanced — the author's + * order is the layout, and a pane's ordinal is its identity wherever it lands. + */ +export function gridLayout(columns: number, panes: readonly PlacedPane[]): GridLayout { + return { + columns, + rows: Math.ceil(panes.length / columns), + cells: panes.map((pane, ordinal) => ({ + ordinal, + row: Math.floor(ordinal / columns), + column: ordinal % columns, + title: pane.title, + form: pane.form, + })), + }; +} diff --git a/packages/grid/src/native-launcher.ts b/packages/grid/src/native-launcher.ts new file mode 100644 index 000000000..df2e6f29b --- /dev/null +++ b/packages/grid/src/native-launcher.ts @@ -0,0 +1,139 @@ +/** + * The native launcher contract — how a host hands one child process the + * terminal, and nothing about how any particular host does it. + * + * This is not `exec`. An ordinary command is a captured child: its stdout and + * stderr are piped so a document can display, capture and journal them, and + * its exit status is a value the document reads. A native coding-agent UI is + * the opposite of that. It draws on the terminal, reads the person's + * keystrokes, and owns the conversation it has with them. None of that may + * become an XMD process result or a journaled transcript, and a piped child + * cannot be interactive at all. + * + * So a launch asks for three things in order, and each is refusable on its + * own: + * + * 1. `reserve()` takes the one foreground-terminal lease for the run. A host + * with no terminal refuses here, which is before any session ownership has + * moved. Two launches cannot hold it at once even when they name different + * sessions, so native UIs are sequential by construction. + * 2. `flush()` gives the reader everything the document has produced so far, + * so the native UI does not open on top of half-written output. + * 3. `launch()` spawns the child with the terminal inherited, waits for it, + * and reports its terminal status and nothing else. + * + * There is no host default. `xmd run` installs the foreground launcher from + * `./posix-launcher.ts`; a test or embedding host installs the controlled one + * from `./controlled-launcher.ts`. Until one is installed every operation + * refuses, which is what keeps document help and inspection free of any of + * this. + * + * Nothing here reaches a process, a stream or a host API, and that separation + * is the point rather than a tidiness: this module is what the package root + * exports, so importing the domain does not load `node:child_process`. A + * consumer that only describes a launch pulls in nothing that could perform + * one. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** + * What a provider asks the host to run. + * + * `command` is the complete argv, built by the provider's adapter from the + * provider-native session identity. Raw prepared instructions never appear in + * it, and never in `env`: a process's arguments and environment are readable + * by other processes, so the instruction layer travels through the provider's + * own session API instead. + */ +export interface NativeLaunchRequest { + command: string[]; + cwd: string; + env?: Record; +} + +/** + * How the native UI ended. A child that exited on a signal reports the signal + * and no code, which is how a signalled exit stays distinguishable from + * status 0. + */ +export interface NativeLaunchOutcome { + exitCode?: number; + signal?: string; +} + +export interface NativeLauncherHandler { + reserve(): Operation; + flush(): Operation; + /** + * Start the native UI, wait for it, and report how it ended. + * + * `spawned` is the runtime's child-start event, reported as a parameter + * rather than through the request or the result. A host calls it once the + * child has actually started and before it waits for the exit, so a UI that + * starts and closes at once has still started. Preparation, a reservation, an + * allocated PID and the child's first output are not that event, and a launch + * that never starts never calls it. + * + * At the root nobody is listening and it does nothing. Composed middleware — + * a terminal pane's launcher — is what gives it a meaning, which is why it + * travels here instead of in `NativeLaunchRequest`. + */ + launch(request: NativeLaunchRequest, spawned: () => void): Operation; +} + +export const NATIVE_LAUNCHER_UNAVAILABLE = + "no native launcher is installed — this host does not hand a native agent UI " + + "the terminal. `xmd run` installs one; a test or embedding host installs its own."; + +export class NativeLauncherUnavailableError extends Error { + override name = "NativeLauncherUnavailableError"; + constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { + super(message); + } +} + +export const NativeLauncher: Api = createApi( + "runtime.nativeLauncher", + { + // deno-lint-ignore require-yield + *reserve(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *flush(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { + throw new NativeLauncherUnavailableError(); + }, + }, +); + +/** Hold the foreground-terminal lease for the calling scope. */ +export function reserveTerminal(): Operation { + return NativeLauncher.operations.reserve(); +} + +/** Give the reader everything the document has produced so far. */ +export function flushOutput(): Operation { + return NativeLauncher.operations.flush(); +} + +/** + * Run one native UI as a foreground child and report how it ended. + * + * A provider adapter calls this and hears nothing about the child's start: the + * spawn event is the host's to report and a pane's to act on, and an adapter + * that could observe it could also fake it. + */ +export function nativeLaunch(request: NativeLaunchRequest): Operation { + return NativeLauncher.operations.launch(request, () => {}); +} + +export const NO_TERMINAL = + " needs a terminal: a native agent UI reads keystrokes and " + + "draws on the screen, and this invocation has none. Run xmd from a terminal, " + + "or use a host that installs its own launcher."; diff --git a/packages/grid/src/pane-launcher.ts b/packages/grid/src/pane-launcher.ts new file mode 100644 index 000000000..628c3b5ad --- /dev/null +++ b/packages/grid/src/pane-launcher.ts @@ -0,0 +1,94 @@ +/** + * How a native UI reaches a pane's terminal instead of the run's + * (architecture.md §Terminal authority, spec §Grid composition). + * + * `` written at the root takes the one foreground-terminal + * lease, and every other launch waits for it. Written inside a pane it must + * not: panes stay interactive at the same time, which is the whole reason a + * grid exists. So core installs this in the pane's own scope, and the launch + * finds it simply by being there. + * + * Nothing about the launch changes. It is handed no pane prop, token, + * identifier or mode; its request, its result and its retained phases are the + * ones a root launch would have. What changes is which terminal answers + * `reserve` and `flush`, and that is a composition fact rather than something + * the document or the provider can see. + * + * The claim is the authority, and it is closed over rather than passed on. A + * pane claim buys one interactive terminal at one ordinal — it says nothing + * about which Agent session that pane may own, which stays the session + * coordinator's to answer. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; + +import type { PaneClaim } from "./authority.ts"; + +/** + * Install one pane's native launcher for the scope that runs that pane's work. + * + * `flush` is how this pane catches the reader up. A pane's rendered text + * belongs to the pane, so it goes where the pane's text goes rather than to the + * root's streams — which the native UI is not drawing over. + */ +/** + * How a pane actually runs a native UI: the composite's operation for this + * pane's authored ordinal, bound by core and closed over here. + * + * The ordinal lives in this closure and nowhere else. It reaches no request, no + * Agent request, no session key, no durable phase, no result and no diagnostic. + */ +export type RunInPane = ( + request: NativeLaunchRequest, + spawned: () => void, +) => Operation; + +export function* usePaneNativeLauncher( + claim: PaneClaim, + flush: () => Operation, + runInPane: RunInPane, +): Operation { + yield* NativeLauncher.around({ + /** + * This pane, for as long as the launch holds it. + * + * Deliberately not delegated: delegating would ask for the root lease, + * which the grid itself is already holding, and two panes would contend + * over a terminal neither of them is using. The claim refuses a second live + * launch on *this* pane and does not contend with any other, which is + * exactly the exclusivity a pane has. + * + * It is released when the launch's scope ends, so the pane is free only + * after the launcher has finished with the child it started. + */ + reserve() { + return resource(function* (provide) { + yield* claim.admit(function* () { + yield* provide(); + }); + }); + }, + *flush() { + yield* flush(); + }, + *launch([request, spawned]) { + // The end of the chain, and deliberately so. Middleware written nearer + // the authored launch composes in front of this and may observe, wrap, + // refuse or short-circuit before it delegates here; what it must not do + // is reach past it, because past it is the root foreground launcher and + // the root terminal is the one thing a pane exists to avoid. + // + // The request crosses exactly as it arrived. What this adds is the + // ordinal — from the closure, never from the request — and a listener, so + // the pane is ready when the runtime says the child started and at no + // earlier moment. + return yield* runInPane(request, () => { + claim.ready(); + spawned(); + }); + }, + }); +} diff --git a/packages/grid/src/pane.ts b/packages/grid/src/pane.ts new file mode 100644 index 000000000..603020f6c --- /dev/null +++ b/packages/grid/src/pane.ts @@ -0,0 +1,66 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the authority. The claim it hands + * out was minted for one ordinal of one grid and cannot be forged, copied + * usefully, or kept past the expansion that owns it — so a replaced context + * yields a pane terminal nobody owns rather than a way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { PaneClaim } from "./authority.ts"; + +/** The pane the current work is running in. */ +export interface PaneTerminal { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * `body` receives the pane's readiness latch and must call it from the + * runtime's successful child-spawn event, before it waits for the child to + * exit. A body that never spawns never reports, and the grid it belongs to + * never attaches — which is what stops a pane that failed to start being + * presented as one that is running. + * + * A second interactive operation while one is live on this pane is refused. + * Two panes do not contend with each other at all. + */ + interactive(body: (spawned: () => void) => Operation): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * Set rather than composed: a pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(claim: PaneClaim): Operation { + yield* PaneTerminalContext.set({ + ordinal: claim.ordinal, + interactive(body) { + return claim.admit(() => body(() => claim.ready())); + }, + }); +} diff --git a/packages/runtime/launcher.ts b/packages/grid/src/posix-launcher.ts similarity index 62% rename from packages/runtime/launcher.ts rename to packages/grid/src/posix-launcher.ts index 2a11e374d..4f02f4968 100644 --- a/packages/runtime/launcher.ts +++ b/packages/grid/src/posix-launcher.ts @@ -1,119 +1,28 @@ /** - * The native launcher — how a host hands one child process the terminal. + * The POSIX foreground launcher — how *this* host hands a child the terminal. * - * This is not `exec`. An ordinary command is a captured child: its stdout and - * stderr are piped so a document can display, capture and journal them, and - * its exit status is a value the document reads. A native coding-agent UI is - * the opposite of that. It draws on the terminal, reads the person's - * keystrokes, and owns the conversation it has with them. None of that may - * become an XMD process result or a journaled transcript, and a piped child - * cannot be interactive at all. + * One of two implementations of the contract in `./native-launcher.ts`, and + * the only one that reaches a process. It lives apart from that contract + * because a consumer that merely describes a launch must not load + * `node:child_process` to do it: the package root exports the contract, and + * this module is reachable only through `@executablemd/grid/posix`. * - * So a launch asks for three things in order, and each is refusable on its - * own: - * - * 1. `reserve()` takes the one foreground-terminal lease for the run. A host - * with no terminal refuses here, which is before any session ownership has - * moved. Two launches cannot hold it at once even when they name different - * sessions, so native UIs are sequential by construction. - * 2. `flush()` gives the reader everything the document has produced so far, - * so the native UI does not open on top of half-written output. - * 3. `launch()` spawns the child with the terminal inherited, waits for it, - * and reports its terminal status and nothing else. - * - * There is no host default. `xmd run` installs the foreground launcher; - * a test or embedding host installs a controlled one that needs no terminal. - * Until one is installed every operation refuses, which is what keeps - * document help and inspection free of any of this. + * XMD stays the parent. It does not replace itself with the child, because a + * process that has execed away cannot cancel the document, reap the child, own + * its exit status, or continue after the UI closes. What follows is that + * parenthood made good: a bounded interrupt escalation, a reap that establishes + * the child is actually gone, and a drain that keeps the UI from opening on top + * of half-written output. */ -import { type Api, createApi } from "@effectionx/context-api"; import { ensure, race, resource, scoped, until } from "effection"; import { once } from "@effectionx/node/events"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import process from "node:process"; - -/** - * What a provider asks the host to run. - * - * `command` is the complete argv, built by the provider's adapter from the - * provider-native session identity. Raw prepared instructions never appear in - * it, and never in `env`: a process's arguments and environment are readable - * by other processes, so the instruction layer travels through the provider's - * own session API instead. - */ -export interface NativeLaunchRequest { - command: string[]; - cwd: string; - env?: Record; -} - -/** - * How the native UI ended. A child that exited on a signal reports the signal - * and no code, which is how a signalled exit stays distinguishable from - * status 0. - */ -export interface NativeLaunchOutcome { - exitCode?: number; - signal?: string; -} - -export interface NativeLauncherHandler { - reserve(): Operation; - flush(): Operation; - launch(request: NativeLaunchRequest): Operation; -} - -export const NATIVE_LAUNCHER_UNAVAILABLE = - "no native launcher is installed — this host does not hand a native agent UI " + - "the terminal. `xmd run` installs one; a test or embedding host installs its own."; - -export class NativeLauncherUnavailableError extends Error { - override name = "NativeLauncherUnavailableError"; - constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { - super(message); - } -} - -export const NativeLauncher: Api = createApi( - "runtime.nativeLauncher", - { - // deno-lint-ignore require-yield - *reserve(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *flush(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *launch(_request: NativeLaunchRequest): Operation { - throw new NativeLauncherUnavailableError(); - }, - }, -); - -/** Hold the foreground-terminal lease for the calling scope. */ -export function reserveTerminal(): Operation { - return NativeLauncher.operations.reserve(); -} - -/** Give the reader everything the document has produced so far. */ -export function flushOutput(): Operation { - return NativeLauncher.operations.flush(); -} - -/** Run one native UI as a foreground child and report how it ended. */ -export function nativeLaunch(request: NativeLaunchRequest): Operation { - return NativeLauncher.operations.launch(request); -} - -export const NO_TERMINAL = - " needs a terminal: a native agent UI reads keystrokes and " + - "draws on the screen, and this invocation has none. Run xmd from a terminal, " + - "or use a host that installs its own launcher."; +import { NativeLauncher, NativeLauncherUnavailableError, NO_TERMINAL } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; /** * How long an interrupted child is given to leave on its own before the @@ -128,7 +37,7 @@ const REAP_POLL_MS = 25; /** How long an unanswerable kill is given before the child is called gone. */ const KILL_SETTLE_MS = 500; -interface ForegroundLauncherOptions { +export interface ForegroundLauncherOptions { /** * Whether this host can hand a child the terminal. Read once, when the * launcher installs, so a run learns what it is before a document starts. @@ -180,8 +89,8 @@ export function* installForegroundLauncher( yield* drainStream(process.stdout); yield* drainStream(process.stderr); }, - *launch([request]) { - return yield* runForeground(request); + *launch([request, spawned]) { + return yield* runForeground(request, spawned); }, }, { at: "min" }, @@ -213,7 +122,10 @@ function drainStream(stream: DrainableStream): Operation { ); } -function runForeground(request: NativeLaunchRequest): Operation { +function runForeground( + request: NativeLaunchRequest, + spawned: () => void, +): Operation { return scoped(function* (): Operation { const [command, ...args] = request.command; if (command === undefined) { @@ -239,6 +151,11 @@ function runForeground(request: NativeLaunchRequest): Operation spawned()); + // Raced inline, in the same synchronous run as the spawn, so both arms are // attached before the child can report anything — a spawned race attaches // a turn later. Whichever loses is halted, which is what detaches it. @@ -400,58 +317,3 @@ function isReachable(pid: number): boolean { return false; } } - -/** - * A launcher a host installs when it has no terminal to give away, and no - * intention of starting a native UI. - * - * `record` sees each request in the order the provider made it; `outcome` - * decides what the child did; and `wait` is the operation the launch blocks - * on, so a test controls exactly how long the document stays suspended. - */ -export interface ControlledLauncherOptions { - record?: (request: NativeLaunchRequest) => void; - outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; - wait?: (request: NativeLaunchRequest) => Operation; - onReserve?: () => void; - onFlush?: () => void; -} - -export function* installControlledLauncher( - options: ControlledLauncherOptions = {}, -): Operation { - let held = false; - yield* NativeLauncher.around( - { - reserve() { - return resource(function* (provide) { - if (held) { - throw new Error( - "another already holds this run's terminal — one " + - "native UI owns the terminal at a time", - ); - } - held = true; - options.onReserve?.(); - try { - yield* provide(); - } finally { - held = false; - } - }); - }, - // deno-lint-ignore require-yield - *flush() { - options.onFlush?.(); - }, - *launch([request]) { - options.record?.(request); - if (options.wait) { - yield* options.wait(request); - } - return options.outcome?.(request) ?? { exitCode: 0 }; - }, - }, - { at: "min" }, - ); -} diff --git a/packages/grid/src/posix-processes.ts b/packages/grid/src/posix-processes.ts new file mode 100644 index 000000000..1e9e4067b --- /dev/null +++ b/packages/grid/src/posix-processes.ts @@ -0,0 +1,218 @@ +/** + * What Deno and the compiled binary can observe about processes and terminals. + * + * The interface lives in `terminal-processes.ts`, shared by everything that + * asks. This is the answer, and it is host-specific: `ps` and `lsof` are what a + * POSIX host has. Node and Bun install neither this nor the tmux provider, so a + * grid there is refused before a pane starts rather than being observed badly. + * + * Every path fails closed, because every caller is deciding whether something + * may still be running: + * + * - `kill(pid, 0)` establishes *absence* only for `ESRCH`. `EPERM` means a + * process exists that this user may not signal — the opposite of absence — + * and every other error means the question was not answered. Both raise. + * - a `ps` that would not run is not an empty process table. An empty table + * would make every descendant and group sweep trivially satisfied. + * - `lsof -t` exits non-zero with no output when nothing holds the file, and + * that one documented result is the only failure read as "nobody". Any other + * numeric failure raises rather than becoming an empty holder list. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { execFile } from "node:child_process"; +import process from "node:process"; +import { TerminalProcesses, TerminalProcessesUnavailableError } from "./processes.ts"; +import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./processes.ts"; + +/** What one observation ran, so a suite can answer for it. */ +export interface ProcessProbes { + /** + * Run a tool, and report everything it said. + * + * `stderr` is part of the answer, not noise: `lsof -t` exits 1 with nothing + * at all when a file has no holders, and exits 1 *with a diagnostic* when it + * could not look. Without stderr those two are the same result, and one of + * them means "nobody" while the other means "I do not know". + */ + run( + command: string, + args: readonly string[], + ): Operation<{ code: number; stdout: string; stderr: string }>; + /** Deliver a signal. Throws with a `code` the way `process.kill` does. */ + kill(pid: number, signal: number | TerminalSignal): void; +} + +/** The real ones. */ +export function posixProcessProbes(): ProcessProbes { + return { + run(command, args) { + return until( + new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error && !("code" in error && typeof error.code === "number")) { + // The tool did not run at all. That is not a status. + reject(error); + return; + } + const code = + error && "code" in error && typeof error.code === "number" ? error.code : 0; + resolve({ code, stdout, stderr }); + }); + }), + ); + }, + kill(pid, signal) { + process.kill(pid, signal); + }, + }; +} + +/** The error's `code`, when it has one. */ +function codeOf(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +/** Install the POSIX observer for this host. */ +export function* installDenoTerminalProcesses( + probes: ProcessProbes = posixProcessProbes(), +): Operation { + yield* TerminalProcesses.around( + { + *table(): Operation { + const listed = yield* probes.run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="]); + if (listed.code !== 0) { + // Not an empty table: an empty one would satisfy every descendant and + // group sweep without having looked at anything. + throw new TerminalProcessesUnavailableError( + "this host could not read its process table, so nothing about a pane's " + + "processes has been established.", + ); + } + return readTable(listed.stdout); + }, + *holders([device]): Operation { + const found = yield* probes.run("lsof", ["-t", device]); + const said = found.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + // The exact supported empty result, and nothing near it: `lsof -t` + // exits 1 saying nothing at all when a file has no holders. Exit 1 with + // a diagnostic is a look that did not happen, and "nobody holds it" is + // not the safe guess for it. + if (found.code !== 0) { + if (found.code === 1 && said.length === 0 && found.stderr.trim().length === 0) { + return []; + } + throw new TerminalProcessesUnavailableError( + "this host could not enumerate the holders of a terminal, so it is not " + + "established that nobody holds it.", + ); + } + // A successful run whose output is not entirely pids is output this + // does not understand. Dropping the lines it cannot read would turn a + // partial answer into a confident one. + if (!said.every((line) => /^\d+$/.test(line))) { + throw new TerminalProcessesUnavailableError( + "this host answered with terminal holders it could not read, so it is not " + + "established who holds it.", + ); + } + return said.map(Number); + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + try { + probes.kill(pid, signal); + return "delivered"; + } catch (error) { + // Gone already is the outcome the signal was asking for. Anything + // else is a delivery that did not happen, and says nothing about + // whether the process stopped. + return codeOf(error) === "ESRCH" ? "absent" : "refused"; + } + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + try { + // Signal 0 delivers nothing: it asks the kernel whether the pid is + // reachable, which is the whole question. + probes.kill(pid, 0); + return true; + } catch (error) { + const code = codeOf(error); + if (code === "ESRCH") { + return false; + } + // `EPERM` is a process this user may not signal — a process that + // exists. Reading it as absence would be reading "I may not ask" as + // "nothing is there". + throw new TerminalProcessesUnavailableError( + `this host could not establish whether a process is still running (${ + code ?? "unknown" + }).`, + ); + } + }, + }, + { at: "min" }, + ); +} + +/** + * One reading of `ps`, parsed row by row. + * + * Every non-empty line has to be a row. A reading with lines this cannot parse + * is a reading it does not understand, and dropping them would answer a sweep + * with the processes it happened to recognise — which is a smaller set than the + * ones that are there. + */ +function readTable(output: string): readonly ProcessFacts[] { + const rows: ProcessFacts[] = []; + for (const line of output.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const row = readRow(line); + if (row === undefined) { + throw new TerminalProcessesUnavailableError( + "this host answered with a process table it could not read, so nothing about " + + "a pane's processes has been established.", + ); + } + rows.push(row); + } + return rows; +} + +function readRow(line: string): ProcessFacts | undefined { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); + if (match === null) { + return undefined; + } + const [, pid, ppid, pgid, tty, tpgid, command] = match; + if ( + pid === undefined || + ppid === undefined || + pgid === undefined || + tty === undefined || + tpgid === undefined || + command === undefined + ) { + return undefined; + } + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + tty, + tpgid: Number(tpgid), + command, + }; +} diff --git a/packages/grid/src/processes.ts b/packages/grid/src/processes.ts new file mode 100644 index 000000000..009b65297 --- /dev/null +++ b/packages/grid/src/processes.ts @@ -0,0 +1,246 @@ +/** + * What the host can observe about processes and terminals + * (architecture.md §Interactive grids, "there is no implicit grid + * timeout"). + * + * A grid may not report a pane settled, admit the next launch into it, + * or let the document continue while something a launch started can still act. + * Deciding that is not a matter of having sent a signal: a PID, a successful + * delivery, an attach client going away and an elapsed timeout each prove + * nothing. What proves it is asking the kernel — is this process still there, + * is anything still descended from it, is anything still in its process group, + * does anything still hold its terminal open — and getting "no" to all four. + * + * That asking is host-specific, so it lives behind this seam. `ps` and `lsof` + * are what a POSIX host has; a host with a cheaper primitive replaces the + * handler without touching what a quiescence proof consists of, and a host that + * can observe none of it refuses rather than guessing. The refusal matters as + * much as the answers: a grid that cannot establish these facts is a grid whose + * teardown failed, and the document stops. + * + * Nothing here decides policy. It reports, and the caller — a pane worker + * finishing one launch, a provider tearing a grid down — decides what the + * report means. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** One process, as the host's table describes it. */ +export interface ProcessFacts { + readonly pid: number; + readonly ppid: number; + /** The process group. A launch's group is what its job control acts on. */ + readonly pgid: number; + /** `ttys002`, or `??` for a process with no controlling terminal. */ + readonly tty: string; + /** + * The controlling terminal's foreground process group, or -1. + * + * This is how a shell's job control is observed from outside, rather than + * inferred from what it printed. + */ + readonly tpgid: number; + readonly command: string; +} + +/** What a signal delivery established, which is not the same as what it did. */ +export type SignalDelivery = + /** The kernel accepted it. The process was there to receive it. */ + | "delivered" + /** There was no such process. Gone is the outcome a signal was asking for. */ + | "absent" + /** It could not be delivered. This says nothing about whether it is gone. */ + | "refused"; + +export type TerminalSignal = "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGKILL"; + +export interface TerminalProcessHandler { + /** Every process the host can see, in one consistent reading. */ + table(): Operation; + /** + * Every process holding this terminal device open. + * + * The device is a path — `/dev/ttys002`. An empty answer is the fact a + * teardown is looking for; a host that cannot enumerate holders must refuse + * rather than answer empty, because "nobody" and "I cannot see" are the two + * answers a quiescence proof must never confuse. + */ + holders(device: string): Operation; + /** Send one signal, and say what that established. */ + deliver(pid: number, signal: TerminalSignal): Operation; + /** Whether the kernel still knows this pid. */ + reachable(pid: number): Operation; +} + +export const TERMINAL_PROCESSES_API = "runtime.terminalProcesses"; + +export const TERMINAL_PROCESSES_UNAVAILABLE = + "this host cannot observe processes or terminal holders, so it cannot prove " + + "that a terminal pane is free. `xmd run` on a POSIX host installs the " + + "observer; a host that installs none refuses rather than reporting a pane " + + "quiet it has not checked."; + +export class TerminalProcessesUnavailableError extends Error { + override name = "TerminalProcessesUnavailableError"; + constructor(message: string = TERMINAL_PROCESSES_UNAVAILABLE) { + super(message); + } +} + +/** + * The observation surface. Its own default refuses every question. + * + * Refusing is the safe answer: every caller here is deciding whether something + * may still be running, and a host that cannot see has not established that + * nothing is. + */ +export const TerminalProcesses: Api = createApi( + TERMINAL_PROCESSES_API, + { + // deno-lint-ignore require-yield + *table(): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *holders(_device: string): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *deliver(_pid: number, _signal: TerminalSignal): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *reachable(_pid: number): Operation { + throw new TerminalProcessesUnavailableError(); + }, + }, +); + +export function processTable(): Operation { + return TerminalProcesses.operations.table(); +} + +export function terminalHolders(device: string): Operation { + return TerminalProcesses.operations.holders(device); +} + +export function deliverSignal(pid: number, signal: TerminalSignal): Operation { + return TerminalProcesses.operations.deliver(pid, signal); +} + +export function processReachable(pid: number): Operation { + return TerminalProcesses.operations.reachable(pid); +} + +/** + * Every process below `pid` by parent links, in one reading of the table. + * + * Read from a snapshot rather than the live kernel on purpose: a child that is + * killed reparents to init, so a table taken after the first signal no longer + * says who its children were. The snapshot has to be older than the signal. + */ +export function descendantsOf( + table: readonly ProcessFacts[], + pid: number, +): readonly ProcessFacts[] { + const found: ProcessFacts[] = []; + const seen = new Set([pid]); + const frontier = [pid]; + while (frontier.length > 0) { + const parent = frontier.pop(); + for (const row of table) { + if (row.ppid === parent && !seen.has(row.pid)) { + seen.add(row.pid); + found.push(row); + frontier.push(row.pid); + } + } + } + return found; +} + +/** Every process in one process group, in one reading of the table. */ +export function groupMembers( + table: readonly ProcessFacts[], + pgid: number, +): readonly ProcessFacts[] { + return table.filter((row) => row.pgid === pgid); +} + +/** + * Who a launch is accountable for, taken before anything is signalled. + * + * Order matters and is the whole point: after the first signal a killed child's + * children are reparented, so a snapshot taken then would name fewer processes + * than the launch actually started. + */ +export interface PaneOccupants { + /** The child the launch started. */ + readonly child: number; + /** Everything descended from it when the snapshot was taken. */ + readonly descendants: readonly number[]; + /** Everything sharing its process group when the snapshot was taken. */ + readonly group: readonly number[]; + /** The pane's terminal device, when the host could name one. */ + readonly device?: string; +} + +/** Take that snapshot from one reading of the table. */ +export function paneOccupants( + table: readonly ProcessFacts[], + child: number, + device?: string, +): PaneOccupants { + const facts = table.find((row) => row.pid === child); + const descendants = descendantsOf(table, child).map((row) => row.pid); + const group = + facts === undefined + ? [] + : groupMembers(table, facts.pgid) + .map((row) => row.pid) + .filter((pid) => pid !== child); + return { + child, + descendants, + group, + ...(device === undefined ? {} : { device }), + }; +} + +/** What is still there, out of everything a launch was accountable for. */ +export interface PaneQuiescence { + /** True only when nothing below is still there. */ + readonly quiet: boolean; + /** Snapshot members the kernel still knows. */ + readonly running: readonly number[]; + /** Processes still holding the pane's terminal open. */ + readonly holding: readonly number[]; +} + +/** + * Ask whether everything that snapshot named has stopped, and whether anything + * still holds the pane's terminal. + * + * Both questions, every time. A pane whose child is gone but whose terminal + * something else still holds is not a pane the next launch may have, and a pane + * nobody holds whose process group still has a member in it is not one either. + */ +export function establishQuiescence(occupants: PaneOccupants): Operation { + return (function* (): Operation { + const running: number[] = []; + for (const pid of [occupants.child, ...occupants.descendants, ...occupants.group]) { + if (running.includes(pid)) { + continue; + } + if (yield* processReachable(pid)) { + running.push(pid); + } + } + // Asked even when processes remain, so one report says everything that is + // still true rather than the first thing that was. + const holding = + occupants.device === undefined ? [] : [...(yield* terminalHolders(occupants.device))]; + return { quiet: running.length === 0 && holding.length === 0, running, holding }; + })(); +} diff --git a/packages/grid/src/provider-api.ts b/packages/grid/src/provider-api.ts new file mode 100644 index 000000000..a28dc14a2 --- /dev/null +++ b/packages/grid/src/provider-api.ts @@ -0,0 +1,261 @@ +/** + * How a grid provider is installed, and what installing one grants. + * + * A provider is the only thing that can present a grid, so *selecting* one is + * itself an authority decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's terminal authority and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { GridAuthority } from "./authority.ts"; + +/** What a host says about the provider it is installing. */ +export interface GridProviderOptions { + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label: string; +} + +/** + * A provider factory installs `Grids` middleware for its scope. + * + * The authority is the second argument because it is delivered, not published: + * there is no reader for it, no context holding one, and no request member + * carrying one. A factory closes over it, and only the handler that closed over + * it can pair a routed grid request with it. + */ +export type GridProviderFactory = ( + options: GridProviderOptions, + authority: GridAuthority, +) => Operation; + +/** The stable name every loaded copy composes through. */ +export const GRID_PROVIDERS_API = "GridProviders"; + +/** What public installation middleware sees: the name, and what it runs under. */ +export interface GridProviderInstallRequest { + readonly intent: "install"; + readonly name: string; + readonly options: GridProviderOptions; +} + +/** + * One message on the installation operation. + * + * Public middleware only ever receives the install request. The two private + * members are how the registered provider's handler speaks to the invocation's + * own terminal through the continuation it captured; constructing one grants + * nothing, because the terminal is reachable from that continuation alone. + */ +export type GridProviderCall = + | GridProviderInstallRequest + | { readonly intent: "inspect"; readonly install: GridProviderInstallRequest } + | { readonly intent: "acknowledge"; readonly install: GridProviderInstallRequest }; + +export interface GridProviderApi { + /** + * Install one provider. + * + * Answers nothing: a return value is not evidence a provider was installed, + * and the invocation that issued the request ignores it. + */ + install(call: GridProviderCall): Operation; +} + +export class GridProviderInstallError extends Error { + override name = "GridProviderInstallError"; +} + +/** + * The public installation surface. Its own default always refuses. + * + * Invoking this descriptor with a captured request outside a live installation + * reaches this default and installs nothing. + */ +export const GridProviders: Api = createApi(GRID_PROVIDERS_API, { + // deno-lint-ignore require-yield + *install(call: GridProviderCall): Operation { + const name = call.intent === "install" ? call.name : call.install.name; + throw new GridProviderInstallError(`Unknown grid provider "${name}"`); + }, +}); + +/** Make `factory` installable as `name` for the current scope. */ +export function* registerGridProvider(name: string, factory: GridProviderFactory): Operation { + let registered = true; + yield* ensure(() => { + registered = false; + }); + yield* GridProviders.around( + { + *install([call], next): Operation { + if (call.intent !== "install" || call.name !== name) { + return yield* next(call); + } + if (!registered) { + throw new GridProviderInstallError( + `the "${name}" grid provider registration is no longer live`, + ); + } + // Inspection first, and through the captured continuation: the terminal + // refuses a copied, reused or stale request here, before the factory + // installs anything. + const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); + yield* factory(delivery.options, delivery.authority); + yield* next({ intent: "acknowledge", install: call }); + return undefined; + }, + }, + { at: "min" }, + ); +} + +/** + * What the terminal told this handler, or a refusal. + * + * Parsed rather than believed. The terminal that produced it belongs to the + * canonical copy, and this handler may belong to another; what arrives is a + * value, and reading it as a delivery is this side's decision. + */ +function deliveryOf(value: unknown): { + options: GridProviderOptions; + authority: GridAuthority; +} { + if (typeof value !== "object" || value === null) { + throw new GridProviderInstallError( + "this grid provider installation is not live, so nothing was delivered to it", + ); + } + const options = Reflect.get(value, "options"); + const authority = Reflect.get(value, "authority"); + if (typeof options !== "object" || options === null) { + throw new GridProviderInstallError("the live grid provider installation named no options"); + } + if (typeof authority !== "object" || authority === null) { + throw new GridProviderInstallError("the live grid provider installation carried no authority"); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new GridProviderInstallError("the live grid provider options are not readable"); + } + const present = Reflect.get(authority, "present"); + if (typeof present !== "function") { + throw new GridProviderInstallError( + "the live grid provider installation carried no grid authority", + ); + } + return { + options: { label }, + authority: { + present: (request, composite) => Reflect.apply(present, authority, [request, composite]), + }, + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * The authority reaches whichever factory answers, and nothing else: a handler + * that short-circuits, fabricates a return, or never acknowledges installs no + * provider, and this refuses rather than leaving the caller believing one is + * there. + */ +export function installGridProvider( + name: string, + options: GridProviderOptions, + authority: GridAuthority, +): Operation { + return (function* (): Operation { + const request: GridProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, authority); + // Same stable name, so the shared middleware chain applies; own descriptor, + // so the chain ends in this invocation's terminal rather than in the public + // refusing default. + const invocation = createApi(GRID_PROVIDERS_API, { + install: terminal.install, + }); + yield* invocation.operations.install(request); + if (!terminal.acknowledged()) { + throw new GridProviderInstallError( + `the "${name}" grid provider did not install — a handler answered without ` + + `delivering the request to a registered provider`, + ); + } + terminal.close(); + })(); +} + +function installationTerminal( + request: GridProviderInstallRequest, + options: GridProviderOptions, + authority: GridAuthority, +): { + install: (call: GridProviderCall) => Operation; + acknowledged: () => boolean; + close: () => void; +} { + let state: "available" | "inspected" | "acknowledged" | "closed" = "available"; + + return { + // deno-lint-ignore require-yield + *install(call: GridProviderCall): Operation { + if (call.intent === "install") { + // Reaching the terminal means no registered provider consumed it. + throw new GridProviderInstallError(`Unknown grid provider "${call.name}"`); + } + // Object identity, not shape: a request rebuilt with the same members + // describes the same ask and authorizes nothing. + if (!Object.is(call.install, request)) { + throw new GridProviderInstallError( + "the live grid provider installation received a copied, substituted or foreign request", + ); + } + if (call.intent === "inspect") { + if (state !== "available") { + throw new GridProviderInstallError( + "this grid provider installation is reused, completed or stale", + ); + } + state = "inspected"; + return { options, authority }; + } + if (state !== "inspected") { + throw new GridProviderInstallError( + "this grid provider acknowledgement is unsolicited, duplicated or stale", + ); + } + state = "acknowledged"; + return undefined; + }, + acknowledged: () => state === "acknowledged", + close() { + state = "closed"; + }, + }; +} diff --git a/packages/grid/testing.ts b/packages/grid/testing.ts new file mode 100644 index 000000000..7566419c6 --- /dev/null +++ b/packages/grid/testing.ts @@ -0,0 +1,22 @@ +/** + * Controlled surfaces that prove the neutral contract without a provider + * (architecture.md §Package ownership). + * + * A launcher that hands out no terminal, a composite that presents nothing, and + * a log whose counters are the evidence a lifecycle row reads. Production code + * imports none of it; these exist so core lifecycle semantics can be proved + * without tmux, a terminal, or a subprocess. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. + */ + +export { installControlledLauncher } from "./src/controlled-launcher.ts"; +export type { ControlledLauncherOptions } from "./src/controlled-launcher.ts"; +export { prepareControlledComposite, gridProviderLog } from "./src/controlled-composite.ts"; +export type { + ControlledCompositeOptions, + GridProviderLog, + GridProviderResources, +} from "./src/controlled-composite.ts"; diff --git a/packages/grid/tests/grid-provider.test.ts b/packages/grid/tests/grid-provider.test.ts new file mode 100644 index 000000000..1dbd9e147 --- /dev/null +++ b/packages/grid/tests/grid-provider.test.ts @@ -0,0 +1,264 @@ +/** + * Tier TG — the grid routing surface and the composite contract + * (architecture.md §Terminal authority, spec §6.21). + * + * Two things live here, and neither is an authority. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and core throws the + * answer away. The composite is what a provider prepares, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. + * + * Who may present a grid, and what presenting one authorizes, is core's, and is + * proved in `packages/core/tests/grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { + GRID_PROVIDER_UNAVAILABLE, + Grids, + GridProviderUnavailableError, +} from "../src/composite.ts"; +import type { GridRequest } from "../src/composite.ts"; +import { prepareControlledComposite, gridProviderLog } from "../src/controlled-composite.ts"; + +/** A two-by-one grid: the smallest request that still has two ordinals. */ +function request(overrides: Partial = {}): GridRequest { + return { + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +describe("Tier TG — the routing surface", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* Grids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(GridProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(GRID_PROVIDER_UNAVAILABLE); + }); + + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: GridRequest[] = []; + const reached: GridRequest[] = []; + yield* scoped(function* () { + yield* Grids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* Grids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* Grids.operations.open(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the same object reached the far end. + expect(reached[0]).toBe(seen[0]); + }); + + it("TP2: middleware narrows a request before anything below sees it", function* () { + const reached: GridRequest[] = []; + yield* scoped(function* () { + yield* Grids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* Grids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* Grids.operations.open(request()); + }); + + expect(reached[0]?.columns).toBe(1); + expect(reached[0]?.rows).toBe(2); + }); + + it("TP2: middleware refuses a request, and nothing below is reached", function* () { + const reached: GridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* Grids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* Grids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open grids"); + }, + }); + try { + yield* Grids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe("this host does not open grids"); + expect(reached).toEqual([]); + }); +}); + +describe("Tier TG — the composite contract", () => { + it("TP3: a prepared composite presents nothing until it is attached", function* () { + const log = gridProviderLog(); + const events = yield* scoped(function* () { + yield* prepareControlledComposite(request(), { log }); + return [...log.events]; + }); + + // A composite the reader can see before every pane is ready is the one + // thing atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP3: attach, update, display, shell and destroy record in order", function* () { + const log = gridProviderLog(); + const spawns: number[] = []; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite(request(), { log }); + yield* composite.update(0, "starting"); + yield* composite.display(0, "pane text"); + yield* composite.update(0, "running"); + yield* composite.shell(1, () => spawns.push(1)); + yield* composite.attach(); + yield* composite.update(0, "succeeded"); + yield* composite.closed(); + yield* composite.destroy(); + }); + + expect(log.events).toEqual([ + "prepare:0:2x1", + "state:0:0:starting", + "state:0:0:running", + "shell:0:1", + "attach:0", + "state:0:0:succeeded", + "closed:0", + "destroy:0", + ]); + expect(log.shown.get(0)).toBe("pane text"); + // The default shell starts, and says so through the latch it was handed: + // readiness is reported by the shell rather than assumed by the grid. + expect(spawns).toEqual([1]); + }); + + it("TP4: a shell that never starts never reports a spawn", function* () { + const spawns: number[] = []; + const outcome = yield* scoped(function* () { + const composite = yield* prepareControlledComposite(request(), { + // deno-lint-ignore require-yield + *shell() { + // No spawn event: nothing started, so nothing is acknowledged. + return { exitCode: 127 }; + }, + }); + return yield* composite.shell(1, () => spawns.push(1)); + }); + + expect(outcome).toEqual({ exitCode: 127 }); + expect(spawns).toEqual([]); + }); + + it("TP4: a preparation failure leaves no composite to tear down", function* () { + const log = gridProviderLog(); + let refusal: unknown; + yield* scoped(function* () { + try { + yield* prepareControlledComposite(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the composite existed, so nothing is owed a + // destroy. + expect(log.events).toEqual([]); + }); + + it("TP4: a composite refuses to be destroyed twice", function* () { + let refusal: unknown; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite(request()); + yield* composite.destroy(); + try { + yield* composite.destroy(); + } catch (error) { + refusal = error; + } + }); + + // Teardown ordering is only readable if a double destroy is loud. A silent + // second destroy would let a suite prove an ordering that never held. + expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + }); + + it("TP5: each preparation is its own composite", function* () { + const log = gridProviderLog(); + yield* scoped(function* () { + const first = yield* prepareControlledComposite(request(), { log }, 0); + const second = yield* prepareControlledComposite(request(), { log }, 1); + yield* first.destroy(); + yield* second.destroy(); + }); + + // Two expansions are two grids. A provider that handed the same composite + // back would have presented the second expansion's grid as the first's. + expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + }); +}); diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/grid/tests/native-launcher.test.ts similarity index 86% rename from packages/runtime/tests/native-launcher.test.ts rename to packages/grid/tests/native-launcher.test.ts index c39546b73..4662e2db0 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/grid/tests/native-launcher.test.ts @@ -25,12 +25,12 @@ import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; import { flushOutput, - installForegroundLauncher, nativeLaunch, + NativeLauncher, NO_TERMINAL, - reap, reserveTerminal, -} from "../launcher.ts"; +} from "../src/native-launcher.ts"; +import { installForegroundLauncher, reap } from "../src/posix-launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; @@ -205,6 +205,48 @@ describe("Tier FL — the foreground native launcher", () => { expect(order).toEqual(["drain", "launch"]); }); + it("FL8: the runtime's start event is reported once, before the child is waited on", function* () { + const dir = yield* useTempDir(); + const fake = yield* useFake(dir, "claude"); + const order: string[] = []; + yield* installForegroundLauncher({ isTerminal: () => true }); + yield* reserveTerminal(); + + const outcome = yield* NativeLauncher.operations.launch( + { command: [fake.command, "--resume", "session-abc"], cwd: dir }, + () => order.push("started"), + ); + order.push("exited"); + + expect(outcome.exitCode).toBe(0); + // A start, then an exit. Reported from the runtime's own spawn event, so a + // child that starts and closes at once has still started. + expect(order).toEqual(["started", "exited"]); + expect((yield* fake.read()).argv).toEqual(["--resume", "session-abc"]); + }); + + it("FL9: a child that never starts never reports a start", function* () { + const dir = yield* useTempDir(); + const order: string[] = []; + yield* installForegroundLauncher({ isTerminal: () => true }); + yield* reserveTerminal(); + + let message = ""; + try { + yield* NativeLauncher.operations.launch( + { command: [path.join(dir, "not-a-program")], cwd: dir }, + () => order.push("started"), + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).not.toBe(""); + // Nothing ran, so nothing started — which is what keeps a pane whose launch + // failed from being presented as one that is running. + expect(order).toEqual([]); + }); + it("FL7: cancellation stops a child that ignores the interrupt", function* () { const dir = yield* useTempDir(); const heartbeat = path.join(dir, "heartbeat"); diff --git a/packages/grid/tests/package-boundary.test.ts b/packages/grid/tests/package-boundary.test.ts new file mode 100644 index 000000000..9aa1c51ba --- /dev/null +++ b/packages/grid/tests/package-boundary.test.ts @@ -0,0 +1,709 @@ +/** + * Tier TG21 — the grid package boundary, the vocabulary it replaced, and the + * technical vocabulary it kept (architecture.md §Package ownership, DEC-016). + * + * The stack has not merged, so `Terminal.Grid`, ``, the terminal + * exports that used to sit in runtime, core and CLI, and the + * `@executablemd/terminal` packages were never a compatibility surface — they + * were the naming this rename removes. They are gone, and these rows are what + * keeps them gone. + * + * Five claims, each failing differently if the rename regresses. + * + * Structural: the dependency arrows point at the neutral domain, so a provider + * can be written without CLI or tmux and the domain consumed without either. + * A violation is an import statement, so the evidence is the import statements + * themselves — read from the production sources rather than inferred from a + * manifest, because a manifest records what was declared and a source records + * what is actually reached. + * + * Absence: the old directories, modules, exports, packages and authored names + * are not merely unused but not there. An unused forwarding barrel is exactly + * the thing that lets an import drift back, and a reserved alias is exactly + * what lets an author keep writing the rejected syntax. + * + * Discrimination: every absence row above is a claim over a set that could be + * empty for the wrong reason. One row plants each rejected name into the very + * scanners the others use and requires them to report it. + * + * Exactness: the public roots are pinned as complete sets rather than as + * required names, because a name that reached a root by being added to it is + * what a required-names check lets stay. + * + * Preservation: a terminal is still a real capability. `NO_TERMINAL`, + * `reserveTerminal`, `PaneTerminal` and `TerminalProcesses` describe a PTY, a + * lease and a process boundary, and this rename keeps every one of them — so + * the rows below prove they are still reachable while the presentation names + * that were rejected are not. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { exists, readTextFile } from "@effectionx/fs"; +import { readdir } from "node:fs/promises"; +import * as path from "node:path"; +import { until } from "effection"; +import type { Operation } from "effection"; + +/** + * What a canonical grid name used to be called. + * + * Derived rather than written out, so this file states the rejected spelling + * nowhere and the scans below can read it like any other source without + * reporting themselves. + */ +function rejected(canonical: string): string { + return canonical.replaceAll("grid", "terminal").replaceAll("Grid", "Terminal"); +} + +/** The two packages this rename replaced, by specifier and by directory. */ +const REJECTED_PACKAGES = [ + rejected("@executablemd/grid-tmux"), + rejected("@executablemd/grid"), + rejected("packages/grid-tmux"), + rejected("packages/grid"), +] as const; + +/** The authored names this rename replaced: `Terminal.Grid` and `Terminal`. */ +const REJECTED_CONSTRUCTS = [`${rejected("Grid")}.Grid`, rejected("Grid")] as const; + +/** + * Everything one entrypoint loads, transitively. + * + * Read from the module graph rather than from the entrypoint's own export + * list, because an export list is exactly what hid this: re-exporting three + * names out of a module that also spawns processes narrows what is *reachable + * by name* and nothing about what is *loaded*. A facade passes an export-shape + * check and fails this one. + */ +function* graphOf(entrypoint: string): Operation { + const seen = new Set(); + const pending = [path.resolve("packages/grid", entrypoint)]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file)) { + continue; + } + seen.add(file); + const source = yield* readTextFile(file); + for (const match of source.matchAll(/from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier === undefined) { + continue; + } + if (specifier.startsWith("node:")) { + seen.add(specifier); + continue; + } + if (specifier.startsWith(".")) { + pending.push(path.resolve(path.dirname(file), specifier)); + } + } + } + return [...seen]; +} + +/** + * Trees that are an installer's rather than this repository's. + * + * `node_modules` has to go, and not only for speed: a workspace install links + * every dependency package under its dependents, so `packages/grid-tmux/ + * node_modules/@executablemd/grid/src/...` is the *same file* reached through a + * link. Walking it would count one definition many times and would read a + * vendored copy's imports as if they were the importing package's own — so a + * package would appear to import whatever its dependencies import. Bun's layout + * creates those links and Deno's does not, which is why this was invisible + * until the Bun shard ran. + */ +const INSTALLED = new Set(["node_modules", "npm", "dist", "generated", "vendor"]); + +/** Whether any segment of `relative` names a tree this repository does not author. */ +function installed(relative: string): boolean { + return relative.split(path.sep).some((segment) => INSTALLED.has(segment)); +} + +/** Every production source of one workspace package, tests excluded. */ +function* productionSources(pkg: string): Operation { + const root = path.resolve("packages", pkg); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + const relative = path.relative(root, full); + if (installed(relative)) { + continue; + } + // Tests prove the contract; they do not define the shipped graph. A row may + // reach across packages to drive a fixture without that being a dependency + // of the artifact. + if (relative.startsWith("tests/") || relative.includes(".test.")) { + continue; + } + files.push(full); + } + return files; +} + +/** The package specifiers one source imports from, bare names only. */ +function specifiersOf(source: string): string[] { + const found: string[] = []; + for (const match of source.matchAll(/(?:^|\n)\s*(?:import|export)[^;]*?from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier !== undefined && !specifier.startsWith(".")) { + found.push(specifier); + } + } + return found; +} + +/** Which workspace packages `pkg`'s production code actually imports. */ +function* importsOf(pkg: string): Operation> { + const reached = new Set(); + for (const file of yield* productionSources(pkg)) { + for (const specifier of specifiersOf(yield* readTextFile(file))) { + if (specifier.startsWith("@executablemd/")) { + // `@executablemd/grid/posix` is the grid package. + reached.add(specifier.split("/").slice(0, 2).join("/")); + } + } + } + return reached; +} + +/** Every `.ts` file in the repository's packages, tests included. */ +function* everySource(): Operation { + const root = path.resolve("packages"); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + if (installed(path.relative(root, full))) { + continue; + } + files.push(full); + } + return files; +} + +/** + * The declared dependency state a rejected package name could survive in. + * + * A source that imports a deleted package fails loudly; a manifest, a lockfile + * or the generated publication workflow that still names one fails nothing at + * all until a release runs, which is why they are read here by name. + */ +function* declaredState(): Operation { + const files = [ + "deno.json", + "deno.lock", + "package.json", + "pnpm-lock.yaml", + "bun.lock", + ".github/workflows/publish-packages.yml", + ]; + const present: string[] = []; + for (const file of files) { + if (yield* exists(path.resolve(file))) { + present.push(file); + } + } + const root = path.resolve("packages"); + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || (entry.name !== "package.json" && entry.name !== "deno.json")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + if (installed(path.relative(root, full))) { + continue; + } + present.push(path.relative(path.resolve("."), full)); + } + return present; +} + +/** Where a rejected package name appears in the given text. */ +function namesRejectedPackage(text: string): string[] { + return REJECTED_PACKAGES.filter((name) => text.includes(name)); +} + +/** The names the grid domain owns, whatever path someone might reach for. */ +const GRID_EXPORTS = [ + "NativeLauncher", + "nativeLaunch", + "reserveTerminal", + "flushOutput", + "installForegroundLauncher", + "installControlledLauncher", + "Grids", + "GridProviders", + "TerminalProcesses", + "registerGridProvider", + "installGridProvider", + "useGridInstallation", + "paneTerminal", + "prepareControlledComposite", + "gridProviderLog", + "installDenoTerminalProcesses", + "processTable", + "processReachable", +] as const; + +/** + * Presentation names the rename rejected. + * + * Every one of them described the grid, the pane request, the provider or the + * lifecycle — never a PTY — so none of them may come back under any facet. + */ +const REJECTED_EXPORTS = [ + "TerminalGrids", + "TerminalProviders", + "TerminalComposite", + "registerTerminalProvider", + "installTerminalProvider", + "useTerminalInstallation", + "createTerminalAuthority", + "createTerminalGridClaims", + "openTerminalGrid", + "terminalGridLayout", + "terminalProviderLog", + "TerminalProviderUnavailableError", + "TerminalProviderInstallError", + "TerminalAuthorityError", + "TerminalTeardownFailed", + "TERMINAL_GRIDS_API", + "TERMINAL_PROVIDERS_API", + "TERMINAL_PROVIDER_UNAVAILABLE", +] as const; + +describe("Tier TG21 — the grid package boundary", () => { + it("TG21a: the neutral domain reaches no engine, host or provider", function* () { + const reached = yield* importsOf("grid"); + // The whole point of the extraction: a provider or a consumer takes the + // domain without taking the document engine, the CLI, or tmux with it. + for (const forbidden of [ + "@executablemd/runtime", + "@executablemd/core", + "@executablemd/cli", + "@executablemd/grid-tmux", + ]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21b: the tmux adapter reaches the domain and nothing above it", function* () { + const reached = yield* importsOf("grid-tmux"); + expect(reached.has("@executablemd/grid")).toBe(true); + for (const forbidden of ["@executablemd/runtime", "@executablemd/core", "@executablemd/cli"]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21c: runtime owns no grid dependency, and only CLI composes both", function* () { + // The amendment's load-bearing change: runtime keeps no grid edge at all, + // in its sources or its manifest, because there is no unreleased path left + // for it to keep alive. + expect((yield* importsOf("runtime")).has("@executablemd/grid")).toBe(false); + const manifest = yield* readTextFile(path.resolve("packages/runtime/package.json")); + expect(manifest.includes("@executablemd/grid")).toBe(false); + + expect((yield* importsOf("core")).has("@executablemd/grid")).toBe(true); + // Core is the document engine, not a host: it never selects a provider. + expect((yield* importsOf("core")).has("@executablemd/grid-tmux")).toBe(false); + const cli = yield* importsOf("cli"); + for (const required of [ + "@executablemd/core", + "@executablemd/runtime", + "@executablemd/grid", + "@executablemd/grid-tmux", + ]) { + expect([required, cli.has(required)]).toEqual([required, true]); + } + }); + + it("TG21i: the neutral entrypoints load no host process code and no fixture", function* () { + // The defect this replaced: the root re-exported a handful of neutral names + // from a module that also spawned children and carried a test double, so + // importing the domain loaded `node:child_process` and a fixture. Selective + // re-export narrows the names, never the load. + for (const entrypoint of ["mod.ts", "lifecycle.ts", "processes.ts"]) { + const graph = yield* graphOf(entrypoint); + const host = graph.filter( + (module) => + module === "node:child_process" || + module === "node:process" || + module.endsWith("/posix-launcher.ts") || + module.endsWith("/posix-processes.ts"), + ); + const fixtures = graph.filter((module) => module.includes("/controlled-")); + expect([entrypoint, host]).toEqual([entrypoint, []]); + expect([entrypoint, fixtures]).toEqual([entrypoint, []]); + } + }); + + it("TG21j: the host and fixture facets are where that code actually lives", function* () { + // The complement, and the discriminator for the row above: if the split had + // simply deleted this code rather than moved it, TG21i would pass over an + // empty graph and prove nothing. + const posix = yield* graphOf("posix.ts"); + expect(posix.some((module) => module.endsWith("/posix-launcher.ts"))).toBe(true); + expect(posix.some((module) => module.endsWith("/posix-processes.ts"))).toBe(true); + expect(posix.includes("node:child_process")).toBe(true); + + // `testing.ts`, not `test.ts`: Deno's own test-file pattern matches a bare + // `test.ts`, so an entrypoint by that name would be loaded as a test file. + const fixtures = yield* graphOf("testing.ts"); + expect(fixtures.some((module) => module.endsWith("/controlled-launcher.ts"))).toBe(true); + expect(fixtures.some((module) => module.endsWith("/controlled-composite.ts"))).toBe(true); + }); + + it("TG21l: an installer's linked copies are not read as a package's own source", function* () { + // A workspace install links each dependency under its dependents, so the + // same file is reachable at `packages//node_modules/@executablemd/...`. + // Counting those would report one definition many times, and reading their + // imports would make a package appear to import whatever its dependencies + // import. Bun's layout creates the links, Deno's does not — so every row + // above was passing under one runtime for a reason that does not hold under + // the other. + for (const pkg of ["grid", "grid-tmux", "core", "cli"]) { + const strayed = (yield* productionSources(pkg)).filter((file) => + file.includes(`${path.sep}node_modules${path.sep}`), + ); + expect([pkg, strayed]).toEqual([pkg, []]); + } + expect( + (yield* everySource()).filter((file) => file.includes(`${path.sep}node_modules${path.sep}`)), + ).toEqual([]); + }); + + it("TG21d: a walked package with no sources would not pass vacuously", function* () { + // The rows above are absence claims, and an absence claim over an empty set + // is free. This is the discriminator: the walk finds real files. + expect((yield* productionSources("grid")).length).toBeGreaterThan(10); + expect((yield* productionSources("grid-tmux")).length).toBeGreaterThan(8); + expect((yield* everySource()).length).toBeGreaterThan(100); + }); +}); + +describe("Tier TG21 — the replaced paths and packages are absent", () => { + it("TG21e: no old module, package directory or core subtree remains", function* () { + // Deleted rather than emptied. A module that still resolves is a path an + // import can drift back onto, whether or not anything uses it today, and a + // package directory that still exists is one a workspace glob still finds. + for (const gone of [ + "packages/runtime/launcher.ts", + "packages/runtime/terminal.ts", + "packages/runtime/terminal-processes.ts", + "packages/runtime/deno-terminal-processes.ts", + "packages/core/src/terminal-grid.ts", + rejected("packages/core/src/grid"), + rejected("packages/grid"), + rejected("packages/grid-tmux"), + "packages/cli/src/terminal", + ]) { + expect([gone, yield* exists(path.resolve(gone))]).toEqual([gone, false]); + } + }); + + it("TG21m: no manifest, lock or publication workflow names either old package", function* () { + // A source that imports a deleted package fails at resolution. A manifest, + // a lockfile or the generated publish workflow that still names one fails + // nothing until a release runs, so each is read here by name. + const offenders: string[] = []; + for (const file of yield* declaredState()) { + for (const name of namesRejectedPackage(yield* readTextFile(path.resolve(file)))) { + offenders.push(`${file}: ${name}`); + } + } + expect(offenders).toEqual([]); + // And the state it walked is really there, so the absence is not free. + expect((yield* declaredState()).length).toBeGreaterThan(10); + }); + + it("TG21n: no repository source names either old package", function* () { + const here = path.resolve("packages/grid/tests/package-boundary.test.ts"); + const offenders: string[] = []; + for (const file of yield* everySource()) { + // This file is where the rejected vocabulary is deliberately written + // down, which is why it derives those spellings instead of spelling them. + if (file === here) { + continue; + } + for (const name of namesRejectedPackage(yield* readTextFile(file))) { + offenders.push(`${path.relative(path.resolve("packages"), file)}: ${name}`); + } + } + expect(offenders).toEqual([]); + }); + + it("TG21f: runtime and core export none of the grid domain", function* () { + const runtime = yield* until(import("@executablemd/runtime")); + const core = yield* until(import("@executablemd/core")); + for (const name of GRID_EXPORTS) { + expect([`runtime.${name}`, name in runtime]).toEqual([`runtime.${name}`, false]); + expect([`core.${name}`, name in core]).toEqual([`core.${name}`, false]); + } + // What core does still own is the profile that composes a grid into an + // `Execution` — the adaptation, not the domain. + expect("installGridProfile" in core).toBe(true); + }); + + it("TG21g: every repository grid import names a canonical surface", function* () { + // The complement of TG21f. An export that is gone cannot be imported, but a + // *type-only* import of a vanished name fails at typecheck rather than + // here, and this row is what says where such an import would have to move. + const offenders: string[] = []; + for (const file of yield* everySource()) { + const source = yield* readTextFile(file); + for (const match of source.matchAll( + /(?:^|\n)\s*(?:import|export)[^;]*?from\s+"(@executablemd\/(?:runtime|core))"/g, + )) { + const statement = match[0]; + for (const name of GRID_EXPORTS) { + if (new RegExp(`\\b${name}\\b`).test(statement)) { + offenders.push(`${path.relative(path.resolve("packages"), file)}: ${name}`); + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("TG21h: each descriptor and public error constructor is defined once", function* () { + // Identity used to be provable by comparing two import paths. With one path + // left, the claim that replaces it is that there is only one definition to + // reach — so a second `createApi` or a second class cannot quietly appear + // and split middleware composition between two objects that behave alike. + const sources = yield* everySource(); + const definitions = new Map(); + // Any exported class, not just one whose name ends in `Error`: + // `GridTeardownFailed` is a refusal too, and a scan that keyed on the + // suffix would have reported it as having no definition at all. + const declared = /export\s+(?:const\s+(\w+)\s*(?::[^=]+)?=\s*createApi|class\s+(\w+))/g; + for (const file of sources) { + for (const match of (yield* readTextFile(file)).matchAll(declared)) { + const name = match[1] ?? match[2]; + if (name === undefined) { + continue; + } + definitions.set(name, [ + ...(definitions.get(name) ?? []), + path.relative(path.resolve("packages"), file), + ]); + } + } + + for (const name of [ + "NativeLauncher", + "Grids", + "GridProviders", + "TerminalProcesses", + "NativeLauncherUnavailableError", + "GridProviderUnavailableError", + "TerminalProcessesUnavailableError", + "GridProviderInstallError", + "GridAuthorityError", + "TmuxUnavailableError", + "GridTeardownFailed", + ]) { + expect([name, definitions.get(name) ?? []]).toEqual([name, [expect.any(String)]]); + } + // And the scan is not vacuous: it found the descriptors it was told to look + // for, in the package that owns them. + expect(definitions.get("NativeLauncher")?.[0]).toContain("grid/src/native-launcher.ts"); + expect(definitions.get("TerminalProcesses")?.[0]).toContain("grid/src/processes.ts"); + }); +}); + +describe("Tier TG21 — the authored names and the public roots", () => { + it("TG21o: only Grid and Pane are declared, and neither old construct is reserved", function* () { + const core = yield* until(import("@executablemd/core")); + const declared = core.STRUCTURAL_DECLARATIONS.map((declaration) => declaration.name); + expect(declared).toContain("Grid"); + expect(declared).toContain("Pane"); + for (const construct of REJECTED_CONSTRUCTS) { + expect([construct, declared.includes(construct)]).toEqual([construct, false]); + expect([construct, core.RESERVED_STRUCTURAL.has(construct)]).toEqual([construct, false]); + } + // A declaration describes itself, so a construct cannot be reserved without + // a catalog entry — which is what makes the two checks above one claim. + expect(core.RESERVED_STRUCTURAL.has("Grid")).toBe(true); + expect(core.RESERVED_STRUCTURAL.has("Pane")).toBe(true); + }); + + it("TG21p: restoring an old alias or an old import is what these scans catch", function* () { + // Every row above is an absence claim, and an absence claim proves nothing + // unless the scanner behind it can see the thing it says is gone. Each + // rejected spelling is planted into the exact scanner that must report it. + for (const name of REJECTED_PACKAGES) { + // Reported, not reported *alone*: the scoped names contain the unscoped + // ones, so a `-tmux` mention is honestly two rejected names at once. + const reported = namesRejectedPackage(`a source that mentions ${name} somewhere`); + expect([name, reported.includes(name)]).toEqual([name, true]); + } + expect(namesRejectedPackage("a source that mentions packages/grid and nothing else")).toEqual( + [], + ); + const restored = `import { x } from "${rejected("@executablemd/grid")}";\n`; + expect(specifiersOf(restored)).toEqual([rejected("@executablemd/grid")]); + + const core = yield* until(import("@executablemd/core")); + const withAlias: ReadonlySet = new Set([ + ...core.RESERVED_STRUCTURAL, + REJECTED_CONSTRUCTS[0], + ]); + // The membership test TG21o makes is the same one, on a set that does hold + // the alias — so a reserved set that regained it would be reported rather + // than passing over a check that cannot see it. + expect(withAlias.has(REJECTED_CONSTRUCTS[0])).toBe(true); + expect(core.RESERVED_STRUCTURAL.has(REJECTED_CONSTRUCTS[0])).toBe(false); + }); + + it("TG21q: each public root is exactly this set of names", function* () { + // Pinned as exact sets rather than as required names. `paneEnvironment` — a + // host's decision about which of *its own* variables a pane inherits — + // reached the tmux root by being added to it, and a row that only checked + // for required names would have let it stay. + // Each facet is imported by its literal specifier: a specifier held in a + // variable resolves at runtime but is invisible to the typecheck, and this + // row exists to be checked statically as well as run. + const roots: [string, Record, string[]][] = [ + [ + "@executablemd/grid", + yield* until(import("@executablemd/grid")), + [ + "GRIDS_API", + "GRID_PROVIDERS_API", + "GRID_PROVIDER_UNAVAILABLE", + "GridProviderInstallError", + "GridProviderUnavailableError", + "GridProviders", + "Grids", + "NATIVE_LAUNCHER_UNAVAILABLE", + "NO_TERMINAL", + "NativeLauncher", + "NativeLauncherUnavailableError", + "flushOutput", + "nativeLaunch", + "paneTerminal", + "registerGridProvider", + "reserveTerminal", + "usePaneNativeLauncher", + "usePaneTerminal", + ], + ], + [ + "@executablemd/grid/lifecycle", + yield* until(import("@executablemd/grid/lifecycle")), + [ + "GridAuthorityError", + "awaitReadiness", + "createCloseBoundary", + "createGridAuthority", + "createGridClaims", + "createGridRegistry", + "durableGrid", + "gridInstallation", + "gridLayout", + "installGridProvider", + "openGrid", + "paneNeverStartedMessage", + "retainedLayout", + "sealOnTeardown", + "toRequest", + "useGridInstallation", + ], + ], + [ + "@executablemd/grid/processes", + yield* until(import("@executablemd/grid/processes")), + [ + "TERMINAL_PROCESSES_API", + "TERMINAL_PROCESSES_UNAVAILABLE", + "TerminalProcesses", + "TerminalProcessesUnavailableError", + "deliverSignal", + "descendantsOf", + "establishQuiescence", + "groupMembers", + "paneOccupants", + "processReachable", + "processTable", + "terminalHolders", + ], + ], + [ + "@executablemd/grid/posix", + yield* until(import("@executablemd/grid/posix")), + ["installDenoTerminalProcesses", "installForegroundLauncher", "posixProcessProbes"], + ], + [ + "@executablemd/grid/test", + yield* until(import("@executablemd/grid/test")), + ["gridProviderLog", "installControlledLauncher", "prepareControlledComposite"], + ], + [ + "@executablemd/grid-tmux", + yield* until(import("@executablemd/grid-tmux")), + [ + "GridTeardownFailed", + "PANE_WORKER_COMMAND", + "PaneNotQuiescent", + "TMUX_PROVIDER", + "TMUX_UNAVAILABLE", + "TmuxUnavailableError", + "installTmuxGridProvider", + "paneWorkerInvocation", + "runPaneWorkerProcess", + "tmuxGridProvider", + ], + ], + ]; + for (const [specifier, facet, names] of roots) { + expect([specifier, Object.keys(facet).toSorted()]).toEqual([specifier, names.toSorted()]); + } + + // The low-level tmux seams stay behind `./test`, and are really there — so + // the assertion above is a boundary rather than an empty package. + const seams = yield* until(import("@executablemd/grid-tmux/test")); + for (const name of ["useTmuxGrid", "usePaneChannels", "usePaneChild", "tmuxAt", "runInPane"]) { + expect([name, name in seams]).toEqual([name, true]); + } + }); + + it("TG21r: the technical terminal surface survives and the presentation names do not", function* () { + // The rename kept every name that describes a PTY, a lease, a signal or a + // process boundary. This row is the complement of the absence rows: without + // it, deleting the terminal capability outright would satisfy them all. + const root = yield* until(import("@executablemd/grid")); + for (const kept of ["NO_TERMINAL", "reserveTerminal", "NativeLauncher", "paneTerminal"]) { + expect([kept, kept in root]).toEqual([kept, true]); + } + const processes = yield* until(import("@executablemd/grid/processes")); + for (const kept of ["TerminalProcesses", "TERMINAL_PROCESSES_API", "terminalHolders"]) { + expect([kept, kept in processes]).toEqual([kept, true]); + } + const posix = yield* until(import("@executablemd/grid/posix")); + expect("installDenoTerminalProcesses" in posix).toBe(true); + + // And no facet brings back a presentation name the rename rejected. + const facets: [string, Record][] = [ + ["@executablemd/grid", root], + ["@executablemd/grid/lifecycle", yield* until(import("@executablemd/grid/lifecycle"))], + ["@executablemd/grid/processes", processes], + ["@executablemd/grid/posix", posix], + ["@executablemd/grid/test", yield* until(import("@executablemd/grid/test"))], + ["@executablemd/grid-tmux", yield* until(import("@executablemd/grid-tmux"))], + ]; + for (const [specifier, facet] of facets) { + for (const gone of REJECTED_EXPORTS) { + expect([`${specifier}.${gone}`, gone in facet]).toEqual([`${specifier}.${gone}`, false]); + } + } + }); +}); diff --git a/packages/grid/tests/terminal-processes.test.ts b/packages/grid/tests/terminal-processes.test.ts new file mode 100644 index 000000000..eac32ee4d --- /dev/null +++ b/packages/grid/tests/terminal-processes.test.ts @@ -0,0 +1,383 @@ +/** + * Tier TP — what the host may claim about a terminal pane + * (architecture.md §Interactive grids). + * + * A pane is free when nothing a launch started can still act in it. These rows + * are about the difference between establishing that and assuming it: a signal + * that was delivered, a process that has gone while its children have not, a + * terminal nobody is descended from but somebody still holds open, and a host + * that cannot see any of it and must say so instead of answering "quiet". + * + * The reading half — `ps` and `lsof` — is exercised against this process, which + * is a real process with a real parent and a real group. The deciding half is + * exercised against a substituted handler, because a row about "a descendant is + * still running" must not depend on this machine having one. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { + descendantsOf, + establishQuiescence, + groupMembers, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + terminalHolders, +} from "../src/processes.ts"; +import { installDenoTerminalProcesses } from "../src/posix-processes.ts"; +import type { ProcessProbes } from "../src/posix-processes.ts"; +import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../processes.ts"; + +/** A table written by hand, so a row can describe a machine it is not on. */ +function table(rows: readonly Partial[]): readonly ProcessFacts[] { + return rows.map((row) => ({ + pid: row.pid ?? 0, + ppid: row.ppid ?? 1, + pgid: row.pgid ?? row.pid ?? 0, + tty: row.tty ?? "??", + tpgid: row.tpgid ?? -1, + command: row.command ?? "fake", + })); +} + +interface Substitute { + /** Pids the kernel still knows. */ + running?: readonly number[]; + /** Pids still holding the device open, by device. */ + holding?: Record; + /** Recorded, so a row can say what was asked rather than what was done. */ + asked?: string[]; +} + +/** A host whose answers a row decides, in place of one it cannot control. */ +function useSubstitute(options: Substitute): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table(): Operation { + return []; + }, + // deno-lint-ignore require-yield + *holders([device]): Operation { + options.asked?.push(`holders:${device}`); + return options.holding?.[device] ?? []; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]: [number, TerminalSignal]): Operation { + options.asked?.push(`deliver:${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + options.asked?.push(`reachable:${pid}`); + return (options.running ?? []).includes(pid); + }, + }, + { at: "min" }, + ); +} + +describe("Tier TP — proving a terminal pane is free", () => { + it("TP1: a host that installs no observer refuses every question", function* () { + for (const ask of [ + () => processTable(), + () => terminalHolders("/dev/ttys001"), + () => processReachable(process.pid), + ]) { + let message = ""; + try { + yield* ask(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + // Not "nothing is running" — a host that cannot see has established + // nothing, and answering emptily would be answering for a pane it never + // looked at. + expect(message).toBe(TERMINAL_PROCESSES_UNAVAILABLE); + } + }); + + it("TP2: the POSIX observer reads this process out of the real table", function* () { + yield* installDenoTerminalProcesses(); + + const rows = yield* processTable(); + const self = rows.find((row) => row.pid === process.pid); + expect(self).toBeDefined(); + expect(self?.ppid).toBe(process.ppid); + // A real reading, not a stub: this process is in the group it says it is. + const group = self === undefined ? [] : groupMembers(rows, self.pgid); + expect(group.some((row) => row.pid === process.pid)).toBe(true); + // And the kernel agrees this process exists, while a pid nothing can own + // does not. + expect(yield* processReachable(process.pid)).toBe(true); + expect(yield* processReachable(2 ** 30)).toBe(false); + }); + + /** Probes a row answers for, in place of the machine's. */ + function probes(answers: { + ps?: { code: number; stdout: string; stderr?: string }; + lsof?: { code: number; stdout: string; stderr?: string }; + kill?: (pid: number) => void; + }): ProcessProbes { + const said = ( + answer: { code: number; stdout: string; stderr?: string } | undefined, + ): { code: number; stdout: string; stderr: string } => ({ + code: answer?.code ?? 0, + stdout: answer?.stdout ?? "", + stderr: answer?.stderr ?? "", + }); + return { + // deno-lint-ignore require-yield + *run(command) { + return said(command === "ps" ? answers.ps : answers.lsof); + }, + kill(pid) { + answers.kill?.(pid); + }, + }; + } + + /** An error the way `process.kill` raises one. */ + function refusal(code: string): Error { + return Object.assign(new Error(code), { code }); + } + + it("TP2b: a process this user may not signal is not an absent one", function* () { + yield* installDenoTerminalProcesses( + probes({ + kill: () => { + throw refusal("EPERM"); + }, + }), + ); + + let raised = ""; + try { + // `EPERM` means a process exists that this user may not signal — the + // opposite of absence. Answering `false` would read "I may not ask" as + // "nothing is there", and every quiescence proof downstream would believe + // it. + yield* processReachable(4242); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not establish whether a process is still running"); + expect(raised).toContain("EPERM"); + }); + + it("TP2c: a process table that could not be read is not an empty one", function* () { + yield* installDenoTerminalProcesses(probes({ ps: { code: 1, stdout: "" } })); + + let raised = ""; + try { + // An empty table would satisfy every descendant and group sweep without + // having looked at anything. + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not read its process table"); + }); + + it("TP2d: lsof's documented no-holder result is the only failure read as nobody", function* () { + // `lsof -t` exits 1 with no output when nothing holds the file. That is an + // answer, and the only failing one that is. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 1, stdout: "" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([]); + }); + // And a success with holders is read as holders. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 0, stdout: "900\n901\n" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([900, 901]); + }); + }); + + it("TP2f: exit 1 with a diagnostic is not the empty result", function* () { + // `lsof -t` exits 1 saying nothing when a file has no holders, and exits 1 + // *with a diagnostic* when it could not look. Without reading stderr those + // are the same status, and one means "nobody" while the other means "I do + // not know". + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 1, stdout: "", stderr: "lsof: WARNING: can't stat()" } }), + ); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not enumerate the holders"); + }); + + it("TP2g: output this host cannot read is never an empty set", function* () { + // A successful run whose lines are not all readable. Dropping the ones it + // does not understand would turn a partial answer into a confident one — + // and a sweep would be satisfied by the processes it happened to recognise. + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 0, stdout: "900\nlsof: no pwd entry\n" } }), + ); + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("terminal holders it could not read"); + }); + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ ps: { code: 0, stdout: "1 0 1 ?? -1 launchd\nps: bad output\n" } }), + ); + let raised = ""; + try { + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("process table it could not read"); + }); + }); + + it("TP2e: any other lsof failure is a question that was not answered", function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 9, stdout: "" } })); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + // Not an empty holder list: "nobody holds it" is not the safe guess. + expect(raised).toContain("could not enumerate the holders"); + }); + + it("TP3: descendants come from the snapshot, not from parent links after a kill", function* () { + // A child, a grandchild, and a sibling that is not below the child at all. + const rows = table([ + { pid: 100, ppid: 1, pgid: 100 }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 300, ppid: 200, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + expect(descendantsOf(rows, 100).map((row) => row.pid)).toEqual([200, 300]); + expect(descendantsOf(rows, 400)).toEqual([]); + // The same table after a kill reparents the grandchild to init. Read then, + // it would name nobody — which is why the snapshot has to precede the + // signal rather than follow it. + const reparented = table([ + { pid: 300, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + expect(descendantsOf(reparented, 100)).toEqual([]); + }); + + it("TP4: a snapshot names the child, its descendants and its group", function* () { + const rows = table([ + { pid: 100, ppid: 1, pgid: 100, tty: "ttys003" }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 250, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + const occupants = paneOccupants(rows, 100, "/dev/ttys003"); + expect(occupants.child).toBe(100); + expect(occupants.descendants).toEqual([200]); + // The group member that is not a descendant is named too, and the child + // itself is not repeated into it. + expect(occupants.group).toEqual([200, 250]); + expect(occupants.device).toBe("/dev/ttys003"); + }); + + it("TP5: quiet means every one of them is gone and nobody holds the terminal", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], holding: {}, asked }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(true); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([]); + }); + // Every member was asked about, and so was the terminal. A proof that + // checked the child alone would pass a pane its grandchild is still in. + expect(asked).toEqual([ + "reachable:100", + "reachable:200", + "reachable:250", + "holders:/dev/ttys003", + ]); + }); + + it("TP6: a descendant or a group member still running is not quiet", function* () { + for (const [what, running] of [ + ["the child", [100]], + ["a descendant", [200]], + ["a group member", [250]], + ] as const) { + yield* scoped(function* () { + yield* useSubstitute({ running }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(`${what}: ${quiescence.quiet}`).toBe(`${what}: false`); + expect(`${what}: ${quiescence.running.join()}`).toBe(`${what}: ${running.join()}`); + }); + } + }); + + it("TP7: a terminal somebody still holds is not quiet, whoever they are", function* () { + // Nothing the launch started is left, and the pane is still not free: + // something outside the snapshot has the terminal open. + yield* useSubstitute({ running: [], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [], + group: [], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(false); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP8: everything still true is reported, not just the first thing", function* () { + yield* useSubstitute({ running: [200], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [], + device: "/dev/ttys003", + }); + // A caller deciding what to escalate needs both, so neither short-circuits + // the other. + expect(quiescence.running).toEqual([200]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP9: a pane with no terminal device asks nobody about one", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], asked }); + const occupants: PaneOccupants = { child: 100, descendants: [], group: [] }; + const quiescence = yield* establishQuiescence(occupants); + expect(quiescence.quiet).toBe(true); + }); + expect(asked).toEqual(["reachable:100"]); + }); +}); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c41f34206..fe4e38f68 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -17,8 +17,6 @@ * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) * - `API.Service` — scoped attached service startup (`startService`) - * - `NativeLauncher` — handing one native agent UI the foreground terminal - * (`reserveTerminal`, `flushOutput`, `nativeLaunch`) * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`, * `verbose`) * @@ -129,23 +127,6 @@ export type { FileWriteTarget, GlobInput, } from "./files.ts"; -export { - flushOutput, - installControlledLauncher, - installForegroundLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - NativeLauncher, - NativeLauncherUnavailableError, - nativeLaunch, - NO_TERMINAL, - reserveTerminal, -} from "./launcher.ts"; -export type { - ControlledLauncherOptions, - NativeLauncherHandler, - NativeLaunchOutcome, - NativeLaunchRequest, -} from "./launcher.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/test-agent/package.json b/packages/test-agent/package.json index c96d4104c..5ad528997 100644 --- a/packages/test-agent/package.json +++ b/packages/test-agent/package.json @@ -14,6 +14,7 @@ "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/grid": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", diff --git a/packages/test-agent/src/GridNativeLaunch.implementor.md b/packages/test-agent/src/GridNativeLaunch.implementor.md new file mode 100644 index 000000000..788111705 --- /dev/null +++ b/packages/test-agent/src/GridNativeLaunch.implementor.md @@ -0,0 +1,3 @@ + + +the implementor pane diff --git a/packages/test-agent/src/GridNativeLaunch.planner.md b/packages/test-agent/src/GridNativeLaunch.planner.md new file mode 100644 index 000000000..6b250408f --- /dev/null +++ b/packages/test-agent/src/GridNativeLaunch.planner.md @@ -0,0 +1,3 @@ + + +the planner pane diff --git a/packages/test-agent/src/GridNativeLaunch.reviewer.md b/packages/test-agent/src/GridNativeLaunch.reviewer.md new file mode 100644 index 000000000..61ff6c2c1 --- /dev/null +++ b/packages/test-agent/src/GridNativeLaunch.reviewer.md @@ -0,0 +1,3 @@ + + +the reviewer pane diff --git a/packages/test-agent/src/GridNativeLaunch.test.md b/packages/test-agent/src/GridNativeLaunch.test.md new file mode 100644 index 000000000..106cff524 --- /dev/null +++ b/packages/test-agent/src/GridNativeLaunch.test.md @@ -0,0 +1,72 @@ +# Native sessions in terminal panes + +A `` written at the root takes the run's one foreground +terminal, so native UIs are sequential: the second waits for the first to +close. Inside a `` that would defeat the point of a grid, where every +pane is interactive at the same time. + +So a pane comes with a launcher of its own. `` finds it simply +by being written there — it is handed no pane, no ordinal and no mode, and the +session it prepares, the argv it hands the UI and the phases it retains are the +ones a root launch would have. What changes is which terminal answers. + +Terminal ownership and session ownership stay separate. Holding a pane says +nothing about which Agent session that pane may own, which is still the session +coordinator's to answer. + +Everything below runs against the deterministic test agent and a terminal +provider that presents nothing, so the "native UI" in each pane is a recorded +request rather than a process, and the fourth pane's shell is the same kind of +fiction. + + + + + + +Four panes in two rows: three native Agent sessions and the host's default +shell. None of the four names another, and none waits for one. They start +together, the grid is shown only once all four have started, and they stay +interactive side by side until the reader leaves. + + + + + +You are the repository planner. + + + + +You are the repository implementor. + + + + +You are the repository reviewer. + + + + + +None of the three launches was a turn. Each scenario still holds its one stage, +and the answers say which conversation replied — so the panes prepared three +sessions rather than sharing one between them. + + +which pane are you in? + + + +which pane are you in? + + + +which pane are you in? + + + + + + + diff --git a/packages/test-agent/src/child-configuration.ts b/packages/test-agent/src/child-configuration.ts index 321e56e5f..df3f4da8b 100644 --- a/packages/test-agent/src/child-configuration.ts +++ b/packages/test-agent/src/child-configuration.ts @@ -37,7 +37,7 @@ import type { AgentComponentsOptions, AgentProviderOptions, Json } from "@execut import { createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { installInvocationAgentProvider } from "@executablemd/core/host"; -import { installControlledLauncher } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/grid/test"; import type { ChildDeclaration, ChildDeclarationChild, diff --git a/packages/test-agent/src/components.ts b/packages/test-agent/src/components.ts index 5340d3b11..768820c9b 100644 --- a/packages/test-agent/src/components.ts +++ b/packages/test-agent/src/components.ts @@ -42,7 +42,8 @@ import { import type { ErrorSegment, Json, PropsSchema, Segment } from "@executablemd/core"; import { createMemorySessionRouteStore, createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProvider, SessionRouteContext } from "@executablemd/acp"; -import { command, installControlledLauncher, readTextFile } from "@executablemd/runtime"; +import { command, readTextFile } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/grid/test"; import { Test } from "@executablemd/testing"; import { NativeLaunchObserver, useTestAgentController } from "./controller.ts"; import type { ScenarioHandle, TestAgentControllerInternals } from "./controller.ts"; diff --git a/packages/test-agent/src/controller.ts b/packages/test-agent/src/controller.ts index f27327c79..c4874e89c 100644 --- a/packages/test-agent/src/controller.ts +++ b/packages/test-agent/src/controller.ts @@ -20,7 +20,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; // node:fs/promises primitive directly. import { realpath } from "node:fs/promises"; import { readTextFile, stat } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/grid"; import type { DurableEvent } from "@executablemd/durable-streams"; import { encodeMessage, formatRoute, parseWorkerMessage, PROBE_INSTANCE } from "./protocol.ts"; import type { ControllerMessage, WorkerMessage } from "./protocol.ts"; diff --git a/packages/test-agent/tests/grid-native-launch.test.ts b/packages/test-agent/tests/grid-native-launch.test.ts new file mode 100644 index 000000000..e4ca509e5 --- /dev/null +++ b/packages/test-agent/tests/grid-native-launch.test.ts @@ -0,0 +1,863 @@ +/** + * Tier GN — native Agent sessions in terminal panes + * (specs/native-agent-session-launch-spec.md §Grid composition). + * + * The journey is `packages/test-agent/src/GridNativeLaunch.test.md`, + * and it runs here against the whole TestAgent stack: a real worker over a real + * ACP connection, the deterministic session coordinator, and four panes — three + * launching a native Agent session of their own, one running the host's default + * shell. Two things are substituted, and only two: the launcher, which records + * what it was asked to start, and the grid provider, which presents + * nothing. + * + * The document says what a reader can read. What a document cannot say is + * *when* — whether four children held their pane terminals at the same time, + * whether the grid waited for all of them before it showed anything, and + * whether a cancelled launch had finished with its session before the document + * carried on. So the harness supplies those as signals, and every one of them + * is an event this run produced. Nothing here waits for a duration: a lifecycle + * that never reached a step hangs its row rather than passing it. + */ +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, Err, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Result, Task } from "effection"; +import { copyFile, ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { randomUUID } from "node:crypto"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + agentIdentityComponents, + installAgentComponents, + installGridProfile, + useTempFileCompiler, +} from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { Json } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { registerGridProvider, Grids } from "@executablemd/grid"; +import { + installControlledLauncher, + prepareControlledComposite, + gridProviderLog, +} from "@executablemd/grid/test"; +import type { + NativeLaunchOutcome, + NativeLaunchRequest, + GridRequest, + PaneState, +} from "@executablemd/grid"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { installTestAgentComponents } from "../src/components.ts"; +import { NativeLaunchObserver, NativeSessionObserver } from "../src/controller.ts"; +import type { NativeSessionReport } from "../src/controller.ts"; +import { useTesting } from "@executablemd/testing"; +import type { TestResult } from "@executablemd/testing"; +import { useCommand } from "./command.ts"; +import { cliBase } from "@executablemd/test-support/launch"; + +const WORKER = cliBase(); + +/** The checked-in journey, and the directory its `src=` paths resolve against. */ +const JOURNEY = path.resolve("packages/test-agent/src/GridNativeLaunch.test.md"); +const JOURNEY_DIR = path.dirname(JOURNEY); + +/** The scenario documents a generated variant resolves `src=` against. */ +const SCENARIOS = [ + "GridNativeLaunch.planner.md", + "GridNativeLaunch.implementor.md", + "GridNativeLaunch.reviewer.md", +]; + +/** How many interactive children the checked-in journey starts. */ +const JOURNEY_CHILDREN = 4; + +interface Run { + result: Result; + results: readonly TestResult[]; + /** Every native launch the component's launcher was asked to start. */ + launches: NativeLaunchRequest[]; + /** Every launch the *host's* launcher was asked to start. */ + hostLaunches: NativeLaunchRequest[]; + /** + * Every launch ``'s own launcher was asked to start. + * + * A pane launch must not reach it: the pane launcher is the physical + * endpoint, and anything past it is a terminal that is not the pane's. A + * *root* launch does reach it, which is how the two stay distinguishable. + */ + agentLaunches: NativeLaunchRequest[]; + sessions: NativeSessionReport[]; + events: DurableEvent[]; + /** Everything the controlled composite did, in order. */ + composite: string[]; + /** Each pane state the composite was told to show, as `ordinal:state`. */ + states: string[]; + /** The layout the provider was asked to present. */ + request?: GridRequest; + /** Whether a grid provider was asked for a grid at all. */ + grids: number; + /** The lifecycle marks this run produced, in the order they happened. */ + order: string[]; +} + +/** + * What one interactive child does, once it has started. + * + * `marker` is the pane's own word for itself, read back from the session the + * launch prepared; the shell pane's is `shell`. A row keys its signals by that + * rather than by an ordinal, because a launch request carries no ordinal and + * must not. + */ +type Child = (marker: string, order: string[]) => Operation; + +interface RunOptions { + /** The document to run. Defaults to the checked-in journey. */ + source?: string; + /** + * Where a generated document lives. + * + * A launch retains the directory it was asked for, so two runs that share a + * journal have to share this one — a second directory replays nothing. + */ + dir?: string; + stream?: InMemoryStream; + /** Install a grid provider; omit for a host that cannot present one. */ + provider?: false; + /** How many interactive children the document starts. */ + children?: number; + /** + * What each child does once it has started. + * + * The default holds every one of them until every pane has one, which is the + * concurrency claim: a child that had to wait for a sibling's terminal would + * be waiting for a start that cannot happen. + */ + child?: Child; + /** How a named pane's native UI ended. Others exit successfully. */ + exits?: Record; + /** Called as each pane state is shown, so a row can signal on one. */ + onState?: (ordinal: number, state: PaneState) => void; + /** Let the reader leave; the default waits for every pane to settle. */ + close?: (order: string[], states: string[]) => Operation; + /** Interrupt the run when this settles, instead of letting it finish. */ + interruptWhen?: (order: string[]) => Operation; +} + +/** The word a launch's own instruction layer uses for its pane. */ +function markerOf(request: NativeLaunchRequest, sessions: NativeSessionReport[]): string { + const native = request.command.at(-1); + const report = sessions.find( + (candidate) => candidate.nativeSessionId === native && candidate.systemPrompt !== undefined, + ); + const instructions = report?.systemPrompt ?? ""; + for (const marker of ["planner", "implementor", "reviewer", "failing", "surviving"]) { + if (instructions.includes(marker)) { + return marker; + } + } + return "unknown"; +} + +function* runJourney(options: RunOptions = {}): Operation { + const launches: NativeLaunchRequest[] = []; + const hostLaunches: NativeLaunchRequest[] = []; + const agentLaunches: NativeLaunchRequest[] = []; + const sessions: NativeSessionReport[] = []; + const providerLog = gridProviderLog(); + const states: string[] = []; + const order: string[] = []; + const stream = options.stream ?? new InMemoryStream(); + let grids = 0; + let request: GridRequest | undefined; + + // Every interactive child has started. Resolved by the starts themselves, so + // nothing here waits for a duration. + const children = options.children ?? JOURNEY_CHILDREN; + const everyChild = withResolvers(); + let started = 0; + const child: Child = + options.child ?? + (() => + (function* () { + yield* everyChild.operation; + })()); + + /** Record a start, and settle the barrier once every pane has one. */ + const startedOne = (marker: string): void => { + order.push(`start:${marker}`); + started++; + if (started >= children) { + everyChild.resolve(); + } + }; + + return yield* scoped(function* () { + // A variant is written to a directory of its own, with copies of the + // scenarios its `src=` paths name. Nothing a row generates is ever written + // into the repository, so a run that is killed leaves nothing behind. + let docPath = JOURNEY; + let docDir = JOURNEY_DIR; + if (options.source !== undefined) { + docDir = options.dir ?? path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); + yield* ensureDir(docDir); + if (options.dir === undefined) { + yield* ensure(() => rm(docDir, { recursive: true, force: true })); + } + for (const scenario of SCENARIOS) { + yield* copyFile(path.join(JOURNEY_DIR, scenario), path.join(docDir, scenario)); + } + docPath = path.join(docDir, "generated.test.md"); + yield* writeTextFile(docPath, options.source); + } + + return yield* scoped(function* () { + yield* API.Env.around({ + // deno-lint-ignore require-yield + *cwd() { + return docDir; + }, + }); + yield* useHostFiles(); + yield* NativeSessionObserver.set((report) => sessions.push(report)); + // The launcher `` installs for its own scope. A pane's + // launcher composes in front of it, so this is what a pane launch + // reaches once the pane has answered for the terminal. + // ``'s own launcher. A pane launch must not arrive here — it + // stops at the pane endpoint — so this is a sentinel for everything but a + // root launch. + yield* NativeLaunchObserver.set({ + record: (asked) => agentLaunches.push(asked), + outcome: (asked) => options.exits?.[markerOf(asked, sessions)] ?? { exitCode: 0 }, + }); + // A host launcher too, which is the wrong one for any of this to reach: + // the terminal it would hand over belongs to whoever is running the + // tests, and under `xmd test` there is no host launcher at all. + yield* installControlledLauncher({ + record: (asked) => hostLaunches.push(asked), + outcome: () => ({ exitCode: 0 }), + }); + + if (options.provider !== false) { + // The reader stays until every pane has settled. Leaving sooner is a + // real thing a reader does, and the rows about it say so themselves. + const settled = withResolvers(); + let panes = 0; + let done = 0; + yield* registerGridProvider("controlled", function* (_settings, authority) { + yield* Grids.around( + { + *open([asked]) { + grids++; + const composite = yield* prepareControlledComposite(asked, { + log: providerLog, + close: () => + options.close === undefined ? settled.operation : options.close(order, states), + // deno-lint-ignore require-yield + *onPrepare(seen) { + request = seen; + panes = seen.panes.length; + }, + // deno-lint-ignore require-yield + *onAttach() { + order.push("attach"); + }, + // deno-lint-ignore require-yield + *onDestroy() { + order.push("destroy"); + }, + onUpdate(ordinal: number, state: PaneState) { + states.push(`${ordinal}:${state}`); + options.onState?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= panes) { + settled.resolve(); + } + } + }, + // The host's default shell, a fiction here in exactly the way + // the native UI is. It reports its start the same way and then + // stays live, so the fourth pane is as concurrent as the three + // that launched. + *shell(_ordinal, spawned) { + spawned(); + startedOne("shell"); + try { + yield* child("shell", order); + } finally { + order.push("left:shell"); + } + return { exitCode: 0 }; + }, + // The pane's physical endpoint. A `` written + // in a paired pane arrives here, with the exact request the + // Agent provider built and an ordinal that never left core's + // closure. + *launch(_ordinal, asked, spawned) { + launches.push(asked); + const marker = markerOf(asked, sessions); + spawned(); + startedOne(marker); + try { + yield* child(marker, order); + } finally { + // Reached however the launch left — returned, or + // cancelled by the reader closing the grid. + order.push(`left:${marker}`); + } + return options.exits?.[marker] ?? { exitCode: 0 }; + }, + }); + yield* authority.present(asked, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); + yield* installGridProfile({ provider: "controlled" }); + } + + const testing = yield* useTesting(); + yield* useCommand(WORKER); + yield* installTestAgentComponents(); + yield* installAgentComponents(); + + const execution = yield* executeInstalled({ path: docPath, stream }, [ + { components: agentIdentityComponents() }, + ]); + + if (options.interruptWhen !== undefined) { + // Halted with the child still going, which is the state a crashed run + // leaves its journal in. + const running: Task = yield* spawn(function* () { + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + yield* execution; + }); + yield* options.interruptWhen(order); + yield* running.halt(); + return { + result: Err(new Error("interrupted")), + results: yield* testing.results, + launches, + hostLaunches, + agentLaunches, + sessions, + events: yield* stream.readAll(), + composite: providerLog.events, + states, + ...(request === undefined ? {} : { request }), + grids, + order, + }; + } + + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return { + result: yield* execution, + results: yield* testing.results, + launches, + hostLaunches, + agentLaunches, + sessions, + events: yield* stream.readAll(), + composite: providerLog.events, + states, + ...(request === undefined ? {} : { request }), + grids, + order, + }; + }); + }); +} + +/** Every `agent_session_launch` record the run retained, with its phase name. */ +function launchRecords(events: DurableEvent[]): { name: string; value: Json | undefined }[] { + return events.flatMap((event) => + event.type === "yield" && + event.description.type === "agent_session_launch" && + event.result.status === "ok" + ? [{ name: event.description.name, value: event.result.value }] + : [], + ); +} + +/** The members of one retained record, or nothing when it is not readable. */ +function members(value: Json | undefined): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return { ...value }; +} + +/** Every retained `prepared` record, in order. */ +function preparations(events: DurableEvent[]): Record[] { + return launchRecords(events).flatMap((entry) => { + if (!entry.name.endsWith("/prepared")) { + return []; + } + const record = members(entry.value); + return record === undefined ? [] : [record]; + }); +} + +/** One document that launches the same logical session from both panes. */ +const ONE_SESSION = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'You are the repository planner.', + "", + "", + "", + "", + "", +].join("\n"); + +/** Two panes: one whose native UI ends badly, and one that stays live. */ +const FAILING_AND_SURVIVING = [ + "", + '', + '', + "", + '', + "", + '', + 'You are the failing pane.', + "", + '', + 'You are the surviving pane.', + "", + "", + "", + "", + "", +].join("\n"); + +/** Two live panes, and the sessions they used, asked for again afterwards. */ +const CLOSE_THEN_CONTINUE = [ + "", + '', + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'You are the repository implementor.', + "", + "", + "", + // The same prepared instructions the pane launched, so this is the same + // conversation continuing rather than a second one asking for the name. + 'You are the repository planner.', + "", + "", + "", +].join("\n"); + +/** One pane, launching the same session twice in a row. */ +const SEQUENTIAL = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'which pane are you in?', + "", + "", + "", + "", + "", + "", +].join("\n"); + +/** One pane whose launch is interrupted while the native child is still live. */ +const ONE_PANE = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + "", + "", + "", + "", +].join("\n"); + +describe( + "Tier GN — native sessions in terminal panes", + { sanitizeOps: false, sanitizeResources: false }, + () => { + beforeAll(() => useTempFileCompiler()); + + it("GN1: four panes start together and stay live, in the authored positions", function* () { + const run = yield* runJourney(); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + + // Three launches, three distinct provider-native identities: three + // sessions, not one shared between the panes. + expect(run.launches.length).toBe(3); + const identities = new Set(run.launches.map((asked) => asked.command.at(-1))); + expect(identities.size).toBe(3); + + // Every one of the four children started before anything was shown, and + // each was waiting for its siblings while it did — a serialised set could + // never have reached the barrier at all. + const attached = run.order.indexOf("attach"); + expect(attached).toBeGreaterThan(-1); + const starts = run.order.slice(0, attached).filter((mark) => mark.startsWith("start:")); + expect(new Set(starts)).toEqual( + new Set(["start:planner", "start:implementor", "start:reviewer", "start:shell"]), + ); + // None of them had left by then, so all four held their terminals at once. + expect(run.order.slice(0, attached).some((mark) => mark.startsWith("left:"))).toBe(false); + + // The authored row-major layout, as the provider was asked for it. + expect(run.request?.columns).toBe(2); + expect(run.request?.rows).toBe(2); + expect(run.request?.panes.map((pane) => `${pane.row},${pane.column} ${pane.title}`)).toEqual([ + "0,0 Planner", + "0,1 Implementor", + "1,0 Reviewer", + "1,1 Shell", + ]); + expect(run.request?.panes.map((pane) => pane.form)).toEqual([ + "paired", + "paired", + "paired", + "self-closing", + ]); + expect(run.composite[0]).toBe("prepare:0:2x2"); + expect(run.composite).toContain("destroy:0"); + expect(run.grids).toBe(1); + }); + + it("GN2: no pane identity reaches the launch request or the retained record", function* () { + const run = yield* runJourney(); + + // The launch's own surfaces: what the provider was asked to start, and + // what the launch retained. The grid's layout record is a different thing + // and legitimately names its panes — this is about what the *launch* + // carries. + const written = JSON.stringify({ + launches: run.launches, + records: launchRecords(run.events).map((entry) => entry.value), + }); + // The authored pane titles, the ordinal a layout is keyed by, and the + // structural names a grid is written with. Not the bare word "pane": the + // instruction layer is the author's prose and may legitimately say it. + for (const leak of ["ordinal", "Planner", "Implementor", "Reviewer", "columns"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + // What is there instead is what a root launch would have had: the + // document's own working directory, and the resume vector. + for (const asked of run.launches) { + expect(asked.cwd).toBe(JOURNEY_DIR); + expect(asked.command.length).toBe(3); + expect(asked.command[1]).toBe("--resume"); + } + expect(preparations(run.events).length).toBe(3); + }); + + it("GN3: a pane launch never reaches the host's launcher", function* () { + const run = yield* runJourney(); + + expect(run.result.ok).toBe(true); + expect(run.hostLaunches).toEqual([]); + // Nor ``'s own launcher: a pane launch stops at the pane + // endpoint, and everything past it is a terminal that is not the pane's. + expect(run.agentLaunches).toEqual([]); + expect(run.launches.length).toBe(3); + }); + + it("GN4: two panes naming one session contend, and one is refused", function* () { + // Both panes name the same agent, session and directory, so the natural + // key is one key — and nothing about a pane is in it. One pane takes + // ownership; the other asks while it is held and is told so rather than + // queueing behind a UI that may be there for hours. + const run = yield* runJourney({ source: ONE_SESSION, children: 2 }); + + const failures = run.results.filter((result) => result.status === "fail"); + expect(failures.length).toBe(1); + const refusal = JSON.stringify(failures[0]); + expect(refusal).toContain("another owner is using session"); + // The refusal names the session, not the pane that asked for it. + expect(refusal).not.toContain("Left"); + expect(refusal).not.toContain("Right"); + // Exactly one owner was refused: the other held the session, which is + // what "one owner at a time" means. Two refusals would mean neither did. + const busy = launchRecords(run.events).filter((record) => + JSON.stringify(record.value).includes("session-busy"), + ); + expect(busy.length).toBe(1); + // A pane that never started is a startup failure, so the grid was never + // shown — the reader sees no half-built composite. + expect(run.composite).not.toContain("attach:0"); + }); + + it("GN5: with no grid provider, a pane launch starts nothing at all", function* () { + const run = yield* runJourney({ provider: false }); + + expect(run.result.ok).toBe(false); + // Refused where a grid is refused — before a pane, so before a launch. + expect(run.launches).toEqual([]); + expect(run.hostLaunches).toEqual([]); + expect(run.grids).toBe(0); + }); + + it("GN6: a completed grid replays with no provider, launcher or agent contact", function* () { + const stream = new InMemoryStream(); + const first = yield* runJourney({ stream }); + expect(first.result.ok ? "" : first.result.error.message).toBe(""); + + const second = yield* runJourney({ stream }); + + expect(second.result.ok).toBe(true); + // Nothing was presented, nothing was started, and no session was touched. + expect(second.grids).toBe(0); + expect(second.composite).toEqual([]); + expect(second.launches).toEqual([]); + expect(second.hostLaunches).toEqual([]); + expect(second.sessions).toEqual([]); + }); + + it("GN7: one pane's native exit fails that pane, and the sibling lives on", function* () { + const bothLive = withResolvers(); + const paneFailed = withResolvers(); + const survivedIt = withResolvers(); + const closeNow = withResolvers(); + let live = 0; + const run = yield* runJourney({ + source: FAILING_AND_SURVIVING, + children: 2, + exits: { failing: { exitCode: 4 } }, + child: (marker, marks) => + (function* () { + live++; + if (live === 2) { + bothLive.resolve(); + } + // Both are live and shown before either of them ends. + yield* bothLive.operation; + if (marker === "failing") { + return; + } + // The sibling outlives the failure, and says so from the far side + // of it rather than from before. + yield* paneFailed.operation; + marks.push("surviving:still live"); + survivedIt.resolve(); + yield* closeNow.operation; + })(), + onState: (ordinal, state) => { + if (ordinal === 0 && state === "failed") { + paneFailed.resolve(); + } + }, + close: (marks) => + (function* () { + // The reader leaves only once the sibling has been observed alive + // after the failure, so nothing here is a race. + yield* survivedIt.operation; + marks.push("close"); + closeNow.resolve(); + })(), + }); + + // The failing pane's exit is its own status, and it did not cancel the + // pane beside it: the sibling was still live afterwards and stopped only + // when the reader left. + expect(run.states).toContain("0:failed"); + expect(run.states).toContain("1:closed"); + // Which panes, not how many messages: a pane that had not settled when + // the reader left is told twice — once from the outcome close decided, + // once from its own settlement — and that is display, not a second + // settlement. + expect(new Set(run.states.filter((state) => state.endsWith(":failed")))).toEqual( + new Set(["0:failed"]), + ); + expect(run.order).toContain("surviving:still live"); + expect(run.order.indexOf("close")).toBeGreaterThan(run.order.indexOf("surviving:still live")); + // The grid ends on the pane that failed — the cancellation the close + // caused is not a second failure. + const message = run.result.ok ? "" : run.result.error.message; + expect(message).toContain("status 4"); + expect(run.results.filter((result) => result.status === "fail").length).toBe(1); + }); + + it("GN8: reader close finishes both launches, and the document goes on", function* () { + const bothStarted = withResolvers(); + let started = 0; + const run = yield* runJourney({ + source: CLOSE_THEN_CONTINUE, + children: 2, + child: (_marker, marks) => + (function* () { + started++; + if (started > 2) { + // The launch after the grid. It is the sibling this row is + // waiting to see run, so it runs. + return; + } + if (started === 2) { + bothStarted.resolve(); + } + try { + // Nothing here ever completes it. The only thing that stops this + // child is the reader closing the grid, so a close that did not + // cancel it would hang this row rather than pass it. + yield* suspend(); + } finally { + // Reached as the child is torn down: this is the child actually + // being gone, not the request that it stop. + marks.push("gone"); + } + })(), + close: (marks) => + (function* () { + yield* bothStarted.operation; + marks.push("close"); + })(), + }); + + // Both children were cancelled and both are gone, and neither pane + // failed: a reader leaving is not a pane failure. + expect(run.order.filter((mark) => mark === "gone").length).toBe(2); + expect(run.states.filter((state) => state.endsWith(":failed"))).toEqual([]); + expect(new Set(run.states.filter((state) => state.endsWith(":closed")))).toEqual( + new Set(["0:closed", "1:closed"]), + ); + // Teardown finished after they were gone, not merely after they were + // asked to stop. + const destroyed = run.order.indexOf("destroy"); + expect(destroyed).toBeGreaterThan(-1); + expect(run.order.lastIndexOf("gone")).toBeLessThan(destroyed); + + // The sibling after the grid is a *root* launch naming a session one of + // those panes was holding. It needs three things back: the run's + // foreground terminal, that pane's terminal, and that session's + // ownership — and it gets them, so the grid released every one. + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + // Two at the pane endpoint and one at the root route, which is the + // distinction the pane endpoint exists to make: a launch written in a + // pane never reaches the terminal a root launch takes. + expect(run.launches.length).toBe(2); + expect(run.agentLaunches.length).toBe(1); + // Neither refusal: not one still held by another owner, and not one left + // owned by work that did not finish. An orderly close that finished is a + // finish, and the session it used is ordinarily usable afterwards. + const written = JSON.stringify(run.results); + expect(written).not.toContain("another owner is using session"); + expect(written).not.toContain("was left owned by work that did not finish"); + expect(written).not.toContain("already holds this run's terminal"); + expect(run.hostLaunches).toEqual([]); + }); + + it("GN9: a pane admits the next user only once the last one is wholly done", function* () { + // Sequential composition in one pane, through the real coordinator. The + // prompt after the launch needs two things the launch was holding: that + // pane's terminal, and that session's ownership. It gets an answer, so + // the launch released both — and GN4 is the other half of the same claim, + // where a second owner asking while the first still holds it is refused. + const run = yield* runJourney({ source: SEQUENTIAL, children: 1 }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + expect(run.launches.length).toBe(1); + // The launch had wholly left before the session was used again: a pane + // admits one live user, and the next only once that one is done. + expect(run.order).toContain("left:planner"); + expect(run.order.indexOf("left:planner")).toBeGreaterThan(run.order.indexOf("start:planner")); + // The same conversation the launch prepared answered afterwards. + const native = run.launches[0]?.command.at(-1); + expect(run.sessions.at(-1)?.nativeSessionId).toBe(native); + }); + + it("GN10: an interrupted pane launch resumes its own conversation", function* () { + const stream = new InMemoryStream(); + const live = withResolvers(); + const never = withResolvers(); + // One journal, and one directory for both attempts: a launch retains the + // directory it was asked for, and a second one would replay nothing. + const dir = path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); + yield* ensure(() => rm(dir, { recursive: true, force: true })); + + const interrupted = yield* runJourney({ + source: ONE_PANE, + stream, + dir, + children: 1, + child: () => + (function* () { + live.resolve(); + // Never returns: the run is halted with the child still going. + yield* never.operation; + })(), + interruptWhen: () => live.operation, + }); + + expect(interrupted.launches.length).toBe(1); + const native = interrupted.launches[0]?.command.at(-1); + expect(native).toBeDefined(); + // The launch got as far as handing the session over, and no further. + const crashed = launchRecords(interrupted.events).map((entry) => entry.name); + expect(crashed.some((name) => name.endsWith("/prepared"))).toBe(true); + expect(crashed.some((name) => name.endsWith("/detached"))).toBe(true); + expect(crashed.some((name) => name.endsWith("/exited"))).toBe(false); + const before = preparations(interrupted.events)[0]; + expect(before).toBeDefined(); + + const resumed = yield* runJourney({ source: ONE_PANE, stream, dir, children: 1 }); + + expect(resumed.result.ok ? "" : resumed.result.error.message).toBe(""); + // A fresh composite was built for the pane that had not finished. + expect(resumed.grids).toBe(1); + expect(resumed.composite[0]).toBe("prepare:0:1x1"); + // The native child started again, on the identity the first attempt + // retained — not on a conversation this run made. + expect(resumed.launches.length).toBe(1); + expect(resumed.launches[0]?.command.at(-1)).toBe(native); + expect(resumed.sessions.filter((report) => report.systemPrompt !== undefined)).toEqual([]); + // Nothing was prepared a second time, and everything the first attempt + // retained about how this session was made came back unchanged — the + // provider-native identity, the construction route, the executable + // binding and the phase itself. + const after = preparations(resumed.events); + expect(after.length).toBe(1); + expect(after[0]).toEqual(before); + // The resumed attempt is what added the exit. + const names = launchRecords(resumed.events).map((entry) => entry.name); + expect(names.filter((name) => name.endsWith("/prepared")).length).toBe(1); + expect(names.filter((name) => name.endsWith("/exited")).length).toBe(1); + }); + }, +); diff --git a/packages/test-agent/tests/native-launch.test.ts b/packages/test-agent/tests/native-launch.test.ts index 3f274d040..26b5dc878 100644 --- a/packages/test-agent/tests/native-launch.test.ts +++ b/packages/test-agent/tests/native-launch.test.ts @@ -25,8 +25,9 @@ import * as os from "node:os"; import { installAgentComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; -import { API, installControlledLauncher, useHostFiles } from "@executablemd/runtime"; -import type { NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/grid/test"; +import type { NativeLaunchRequest } from "@executablemd/grid"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea97ff75..d7d6410e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@executablemd/core': specifier: workspace:* version: link:../core + '@executablemd/grid': + specifier: workspace:* + version: link:../grid '@executablemd/runtime': specifier: workspace:* version: link:../runtime @@ -171,6 +174,12 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/grid': + specifier: workspace:* + version: link:../grid + '@executablemd/grid-tmux': + specifier: workspace:* + version: link:../grid-tmux '@executablemd/runtime': specifier: workspace:* version: link:../runtime @@ -239,6 +248,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/grid': + specifier: workspace:* + version: link:../grid '@executablemd/runtime': specifier: workspace:* version: link:../runtime @@ -294,6 +306,45 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/grid: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/node': + specifier: 0.2.4 + version: 0.2.4(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + effection: + specifier: 4.1.0 + version: 4.1.0 + + packages/grid-tmux: + dependencies: + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/grid': + specifier: workspace:* + version: link:../grid + effection: + specifier: 4.1.0 + version: 4.1.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + packages/runtime: dependencies: '@effectionx/context-api': @@ -338,6 +389,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/grid': + specifier: workspace:* + version: link:../grid '@executablemd/runtime': specifier: workspace:* version: link:../runtime diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index c254f2067..39c85f2d3 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -624,6 +624,38 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ }, ]; +/** + * Tests whose subject is the tmux grid provider. + * + * A pane's worker is this executable re-invoked under a hidden + * `terminal-worker` subcommand, and only the hosts that present grids register + * it — the Deno entrypoint and the compiled binary. Node and Bun install no + * grid provider by design, so on those runtimes the same argument vector names + * a *document* called `terminal-worker`, the worker exits with ENOENT before it + * connects, and the parent waits for a pane that will never say hello. The + * suite hangs rather than failing, which would leave a runtime shard running + * forever. + * + * That a runtime without a provider refuses a grid instead of half-presenting + * one is covered portably by TG9 in `packages/core/tests/grid.test.ts`, + * which runs everywhere. The excluded file's own TH3 makes the same claim, but + * it is excluded along with the rest of it and proves nothing here. + */ +const DENO_ONLY_TERMINAL_GRID: RuntimeExclusion[] = [ + { + path: "packages/grid-tmux/tests/grid-tmux.test.ts", + reason: + "the subject is the tmux provider, whose panes are this executable re-invoked as `terminal-worker` — a subcommand only the grid-presenting entrypoints register; under Node and Bun that vector names a document instead, so the worker exits with ENOENT and the pane's admission never completes", + issue: DERIVED_SCOPE, + }, + { + path: "packages/cli/tests/grid-host.test.ts", + reason: + "the host rows open a real grid through the tmux provider, so they spawn the same `terminal-worker` re-invocation; on Node and Bun that vector names a document and the pane never reports, exactly as for the adapter's own suite", + issue: DERIVED_SCOPE, + }, +]; + const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ { path: "packages/workflow/tests/xmd-artifact.test.ts", @@ -635,10 +667,16 @@ const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ export const exclusions: Record = { deno: COMPILED_BINARY, - node: [...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, ...COMPILED_BINARY], + node: [ + ...DENO_ONLY_TOOLING, + ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, + ...COMPILED_BINARY, + ], bun: [ ...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, ...COMPILED_BINARY, ...BUN_MISSING_NODE_SQLITE, ], diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index a10628792..069c6b773 100644 --- a/scripts/tests/jsr-consumer-documentation.test.ts +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -33,7 +33,7 @@ const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const TIMEOUT = 180_000; /** The workspace members a consumer of core has to resolve. */ -const MEMBERS = ["core", "runtime", "durable-streams", "acp"] as const; +const MEMBERS = ["core", "runtime", "durable-streams", "grid", "acp"] as const; /** Every documentation asset the product ships, by package-relative path. */ const ASSETS: Record = { diff --git a/specs/decisions.md b/specs/decisions.md index e36c01658..65f43095d 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -736,3 +736,83 @@ journal- and root-publication-stability snapshots in `packages/cli/tests/workflow-suspension.test.ts`, where an `API.Files` call count is explicitly not once-only evidence — document re-expansion legitimately enters that boundary before the durable effect underneath restores. + +## DEC-016: Grid domain and tmux adapter are separate workspace packages + +**Status:** Decided + +**Date:** 2026-09-03 + +### Context + +The grid delivery proved one provider-neutral lifecycle and one tmux +implementation. Its first public vocabulary called the structure +`Terminal.Grid` and every cell `Terminal`, and extracted the neutral domain as +`@executablemd/terminal`. That makes a physical terminal the identity of every +presentation cell. A read-only Agent session view is pane content without being +the terminal capability itself, and another multiplexer must not require a +second document language. + +The stack has not merged or shipped. Its component names, package names, and +temporary exports are therefore not compatibility surfaces. Preserving them +would make the rejected vocabulary permanent and leave a second provider +coupled to a terminal-specific public domain. + +### Decision + +`Grid` and `Pane` are the provider-neutral presentation concepts. Core owns the +authored `` and `` structural syntax, source-position journal +descriptions, execution-profile composition, Agent sessions, and expansion +integration. A paired pane contains isolated document flow; a self-closing pane +retains the host's default-shell behavior. + +Terminal remains the technical capability used where a PTY, +foreground-terminal lease, terminal process observation, native interactive +process, or shell requires it. It does not name the grid or every pane. + +Ownership is divided between two publishable workspace packages: + +- `@executablemd/grid` owns native foreground-launch routing and terminal + reservation; provider-neutral grid and pane requests, composites, states, + errors, row-major layout, provider registration and direct authority; + readiness, live and durable lifecycle, replay, pane launch routing, terminal + process observation, quiescence, and controlled test surfaces. +- `@executablemd/grid-tmux` implements that domain with tmux: capability + probing, private server and client control, explicit pane placement, + authenticated worker channels and protocol, worker child creation, display, + close-signal distinction, and ordered teardown. + +The canonical descriptors, functions, types, constants, and errors live in +those packages. The former runtime and core terminal exports, old CLI terminal +implementation paths, `@executablemd/terminal`, and +`@executablemd/terminal-tmux` are deleted. Every repository import uses the +canonical grid surface. No compatibility component, package, module, alias, +forwarding barrel, wrapper, subclass, or duplicate descriptor remains. + +The neutral package has no dependency on runtime, core, CLI, or the tmux +package. Core depends on grid. Grid-tmux depends on grid and does not depend on +runtime, core, or CLI. CLI depends on both packages and on core and runtime. +Runtime has no grid dependency. Host-specific POSIX terminal observation is an +explicit grid adapter; Deno and compiled entrypoints install it in the +supervising host and pane worker, while Node and Bun continue to install neither +observer nor provider. + +### Consequences + +Any grid provider implements the public neutral contract without importing CLI +or tmux. The tmux provider remains an `xmd run` facility and adds nothing to +Workflow. This removal is non-breaking because none of the rejected names has +shipped. + +The rename preserves the provider name `tmux`, hidden worker invocation, +durable behavior, private tmux protocol, terminal capability, launch routing, +layout, readiness, cancellation, replay, teardown, and provider identity. It +changes the authored syntax, canonical package and import names, public grid +descriptors and errors, documentation, diagnostics that name the authored +constructs, and the evidence that enforces those surfaces. + +Both packages participate in workspace version lockstep, npm and JSR +publication, generated dependency ordering, package discovery, runtime test +discovery, and release verification. Moving tests changes the measured corpus, +so its weights are remeasured by the repository workflow rather than edited by +hand. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e804f6b4c..8bef21395 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2655,7 +2655,7 @@ A component name is resolved in tiers, and the first tier that answers wins: 1. **structural syntax** — ``, ``, ``, ``, ``, ``/``, ``/``, ``/``, ``, ``/``, and - ``/``. These are the language's own constructs. + ``/``. These are the language's own constructs. They are reserved: a registration cannot claim one, and a repository file named after one never stands in for it. A structural name written where its construct gives it no meaning is a printed error, not a missing component. @@ -9199,36 +9199,36 @@ Its skipped body is absence, not a retained decision. Replay of a completed root is unchanged, and a live or partial expansion reads the value that applies to that execution. -### 6.21 Opening concurrent terminal panes: `` and `` +### 6.21 Opening concurrent panes: `` and `` -Use a terminal grid when several interactive tools must remain available at the +Use a grid when several interactive tools must remain available at the same time in one foreground view: ```md - - + + Implement the accepted plan. - - + + Review the implementation. - - + + Run the focused verification and repair failures. - - - + + + ``` The example opens a two-column, two-row foreground grid. The first three panes @@ -9238,8 +9238,10 @@ grid does not proxy prompts or replace it with an XMD chat surface. Each pane can finish while the others keep running, and its final status stays visible until the reader closes the grid. -`Terminal` names an interactive terminal endpoint, not tmux. The document asks -for panes and their authored layout; the host chooses the presentation provider. +`Grid` and `Pane` name presentation structure, not tmux or a terminal. The +document asks for panes and their authored layout; the host chooses the +presentation provider. Terminal is a technical capability that a pane acquires +when an interactive process or shell requires a PTY. There is no provider, multiplexer, executable, shell, socket, session, window, pane-ID, attach-key, or teardown prop. A terminal-native input component may be a presentation for `` in its own right; it does not change this process @@ -9247,14 +9249,14 @@ terminal contract. #### Forms and props -`` has exactly one paired form: +`` has exactly one paired form: ```md - - - - - + + + + + ``` Its closed props schema contains one required `columns` value, which must @@ -9263,11 +9265,11 @@ not have to divide the pane count; rows are derived by placing direct panes in authored row-major order and leaving unused positions at the end of the last row. -`` has two forms and one required prop: +`` has two forms and one required prop: ```md -... - +... + ``` `title` must resolve to a non-empty string. It is a display label rather than @@ -9291,11 +9293,11 @@ repository-overridable components. A function component receives rendered content after its effects have happened and therefore cannot define this concurrent direct-child boundary. -Only direct `` children may appear in a grid. Whitespace between panes +Only direct `` children may appear in a grid. Whitespace between panes is allowed; ordinary Markdown text and every other direct element are refused. A control structure such as `` or `` cannot dynamically produce the -direct panes. Put control flow inside a paired pane instead. `` outside -a grid, a nested ``, a self-closing grid, paired content on the +direct panes. Put control flow inside a paired pane instead. `` outside a +grid, a nested ``, a self-closing grid, paired content on the self-closing pane form, and a grid with no pane are invalid. Syntax validation checks the two names, closed props, authored forms, placement, @@ -9336,7 +9338,7 @@ sibling render to the root again. Opening a grid is atomic from the reader's perspective: 1. Core validates the whole layout and acquires the root foreground-terminal - lease. Another root native launch or terminal grid cannot hold it at the same + lease. Another root native launch or grid cannot hold it at the same time. 2. The provider validates its live prerequisites and prepares every terminal endpoint in a hidden composite. It presents nothing yet. @@ -9357,6 +9359,35 @@ acknowledges. The self-closing shell does the same. The latch is absent for a root launch and appears in no prop, binding, contextual API, public request, provider return, process result, or durable record. +For a paired pane, core closes the pane-scoped native launcher over the +composite and that pane's authored ordinal. After claim admission and the pane +output flush, delegation reaches the composite's required provider-neutral +operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The request is the exact native command vector, working directory, and +environment supplied by the Agent provider. The ordinal stays in core's live +closure and enters no native request, Agent request, session key, construction +route, durable phase, result, or diagnostic. Native-launch middleware installed +nearer the authored launch may observe, wrap, refuse, or short-circuit before it +delegates. The pane launcher is the physical-terminal endpoint: it calls the +composite operation and never delegates to the root foreground launcher. A +provider unable to execute the pane request refuses explicitly instead of +falling back to the wrong terminal. A root `` keeps the existing +root foreground route unchanged. + +The composite invokes `spawned` only for the child's runtime spawn event. Its +separate `shell()` operation remains the self-closing-pane path because that +operation derives the executable from live host policy rather than accepting an +authored or Agent-provided native launch request. + When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private authenticated channel to that pane owner. The presentation provider's command @@ -9384,11 +9415,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, destroys the exact composite, restores the root terminal, and +releases the foreground lease. The deferred await ends only after the durable +child has settled and its `Close` has been acknowledged. Only then does the +element settle and a later document sibling begin. There is no implicit timeout; +parent cancellation and an enclosing execution deadline use the same complete +teardown. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -9423,6 +9466,12 @@ continues. A provider or host failure cancels the composite and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. +If it arrives after reader close takes effect, reader close still decides the +grid and pane outcomes: then-live panes retain `closed`, already-settled panes +keep their outcomes, and the grid retains `reader` or `failed` under the normal +authored-order rule. The cancellation remains pending until complete teardown +and the grid's durable close, then reaches the parent before any following +document sibling runs. A fatal or cleanup failure keeps its existing precedence. All acquired resources are finalized even when an earlier failure already decides the result, and the existing fatal-infrastructure and cleanup precedence still applies. Before the first cancellation signal, the provider snapshots @@ -9460,11 +9509,35 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then builds a new live composite. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or @@ -9490,6 +9563,55 @@ Node and Bun accept and validate the same syntax but install no provider and therefore refuse before pane start. A controlled provider that is not tmux exercises the same core contract in tests. +#### Package and host boundary + +`@executablemd/grid` is the canonical provider-neutral package for this +contract. Its root exports native launch requests, outcomes and routing; grid +and pane requests, composites and states; `Grids` and `GridProviders`; provider +registration; public errors; and the neutral pane surface. +`@executablemd/grid/lifecycle` exports the direct authority, +installation, claim, readiness, row-major layout, grid lifecycle, retained +outcome, reader-close and replay operations. `@executablemd/grid/processes` +exports `TerminalProcesses`, process facts, signals, snapshots and quiescence. +`@executablemd/grid/posix` exports POSIX process and terminal probes and the +foreground-child adapter. `@executablemd/grid/test` exports the controlled +launcher, composite, log and signal surfaces; production code imports none of +them. + +`@executablemd/grid-tmux` is the first provider. Its root exports only the +provider name, dependency contract, provider factory and installer, unchanged +`PANE_WORKER_COMMAND`, hidden worker invocation parser and runner, and +documented refusal errors. Its tmux +process wrapper, layout mechanics, private protocol, channel handles and +teardown controls remain internal; its tests reach controlled low-level seams +through `@executablemd/grid-tmux/test`. + +The neutral package imports neither runtime, core, CLI nor grid-tmux. Core +imports grid for the lifecycle it invokes and retains only authored +parsing, expansion, source-position journal descriptions, profile composition, +and Agent behavior. Grid-tmux imports grid and imports neither runtime, core nor +CLI. CLI imports the domain and provider to compose the Deno and compiled hosts. +Runtime owns no grid module, export, or dependency. + +The unmerged `packages/terminal` and `packages/terminal-tmux` trees become +`packages/grid` and `packages/grid-tmux`. The previous runtime and core terminal +exports, old CLI terminal implementation paths, rejected package names, and old +authored component names are deleted. They have not shipped and are not +compatibility surfaces. Every repository import names `@executablemd/grid`, one +of its documented subpaths, or `@executablemd/grid-tmux`; no alias or forwarding +barrel keeps an old path reachable. Each contextual API and public error +constructor consequently has one canonical definition. + +The Deno and compiled CLI entrypoints select tmux, supply self-reinvocation, +environment and terminal dimensions, translate `SIGHUP`, and install POSIX +observation in the supervising host. The hidden pane-worker entrypoint installs +the same observation inside its own process; contextual installation in the +parent cannot cross that boundary. Node and Bun install neither the process +observer nor a grid provider. The boundary change preserves the provider name, +worker invocation, protocol, durable behavior and identity, and lifecycle +outcome. It deliberately changes the authored names, package and import paths, +public grid descriptors and errors, and diagnostics that name those constructs. + ## 7. Entry point @@ -11487,7 +11609,7 @@ Each row names the derivation it kills. | AF24 | A Session pins the exact value it was issued | A fresh `` calls `session()` once and hands the same object — by identity, not by key — to every `` nested inside it. A provider decides whether a session may be acted on by that identity, so a rebuilt look-alike is a value nobody issued | | AF25 | A fresh Session performs no provider effect | A self-closing `` places one and renders nothing: no prompt is started, and nothing about the placement appears in the document where the element stood | -### Tier TG — Terminal grids (§6.21) +### Tier TG — Grids (§6.21) Core lifecycle rows use a controlled provider that is not tmux. Production adapter rows use fake tmux processes and exact invocation-private handles; no @@ -11495,7 +11617,7 @@ test derives a core result from a provider identifier. | # | Test | Verify | |---|------|--------| -| TG1 | Frozen grammar | `Terminal.Grid` accepts only paired form with a positive integer `columns`; `Terminal` accepts paired and self-closing forms with a non-empty `title`; both reject unknown props and `as` | +| TG1 | Frozen grammar | `Grid` accepts only paired form with a positive integer `columns`; `Pane` accepts paired and self-closing forms with a non-empty `title`; both reject unknown props and `as` | | TG2 | Structural placement | An empty grid, direct text or non-pane element, a dynamically produced direct pane, a nested grid, and a pane outside a grid are refused before a provider call or body effect; whitespace between direct panes is inert | | TG3 | Catalog and validation are inert | Both reserved entries and exact forms appear under structural syntax on every runtime; syntax and document validation contact no terminal provider, tmux, shell, Agent registry, or session coordinator | | TG4 | Row-major layout | One through five authored panes under two and three columns produce the exact derived positions, keep duplicate titles, and derive identity from ordinal rather than title or scheduling | @@ -11511,8 +11633,11 @@ test derives a core result from a provider identifier. | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | -| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | -| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | +| TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | +| TG21 | Package boundary and canonical imports | Static dependency evidence proves grid imports neither runtime, core, CLI nor grid-tmux; grid-tmux imports grid and none of runtime, core or CLI; runtime has no grid dependency; and CLI alone composes the document engine with the provider and host. The rejected package names and old runtime, core and CLI terminal modules and exports are absent, every repository grid import names a canonical package surface, and each contextual descriptor and public error constructor has one definition. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG2–TG20 and the behaviors in TG1 under `Grid` and `Pane`, preserving provider identity, hidden-worker grammar, protocol and durable records while allowing diagnostics to name the new constructs; Node and Bun still install neither observer nor provider | ### Tier CR — Component registration and resolution @@ -11552,7 +11677,7 @@ so the include-boundary rows are the same on every host. Defined in §5.3. | # | Test | Verify | |---|------|--------| | SY1/SY2 | Versioned shape | `version` is 1, the categories are the fixed tuple, and one structural, one registered and one repository entry appear together | -| SY3/SY4 | Structural vocabulary | The declarations are exactly the reserved names, each with authored forms and a description; `Let`, `Content`, `Else`, `Break`, `Answers`, `Answer`, `Terminal.Grid` and `Terminal` carry the frozen forms, and `as` applies to `Let` and `Each` alone | +| SY3/SY4 | Structural vocabulary | The declarations are exactly the reserved names, each with authored forms and a description; `Let`, `Content`, `Else`, `Break`, `Answers`, `Answer`, `Grid` and `Pane` carry the frozen forms, and `as` applies to `Let` and `Each` alone | | SY5 | Structural stays structural | A repository file named after a construct never moves it out of the structural category | | SY6/SY7 | Repository mapping | Direct `.md`/`.ts`, direct `index`, nested dotted and nested index paths describe names; a lowercase segment, an empty stem, a dotted stem and a dotted directory describe none, and the inversion is held to the single-segment grammar directly | | SY7c | Pruning | A lower-case, hidden or dotted directory is never read — at the top level or deeper — while the direct, nested and index candidates beside it stay discoverable; every skipped directory throws if it is read, and the recorded reads name only the ones a name reaches | diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c422702..7e92bb9d3 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -440,7 +440,7 @@ Given `xmd AGENTS.md#Implementor`: 4. XMD expands the target and renders `Session.Launch` content completely. 5. File reads, captures, parsing, and deterministic evaluation finish or fail. 6. `Session.Launch` takes its applicable terminal lease. At the document root - this is the run's foreground-terminal lease; inside `` it is that + this is the run's foreground-terminal lease; inside `` it is that pane's lease through the pane-scoped native launcher. A host with no applicable terminal refuses here — before an agent is resolved, so learning that this invocation cannot launch anything costs no availability probe. @@ -714,10 +714,10 @@ refuses an advertised agent that names its own sessions, on the same terms and before any provider effect; an agent whose provider returns the identity is unaffected, because it constructs nothing a route governs. -### Terminal-grid composition +### Grid composition Terminal ownership and Agent-session ownership remain independent when a launch -is written inside ``: +is written inside ``: ```text grid foreground lease @@ -745,7 +745,7 @@ cannot begin while the first is live there, and sequential launches work after the first releases it. Release requires the child, its observable descendants and process-group members, and every other holder of that pane terminal to be gone; the pane remains busy if the launcher cannot establish those facts. A -root launch and a terminal grid contend for the root foreground lease, so +root launch and a grid contend for the root foreground lease, so neither can overlap the other. None of that changes the coordinator key or acquisition. Two panes naming the @@ -756,11 +756,22 @@ ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. The pane-scoped launcher keeps the same launch request and provider authority -division as the root launcher. Public middleware can route or refuse a request -but cannot settle it, replace the pane, or mint a launch. Provider-specific grid -or pane identities never enter the `AgentLaunchRequest`, terminal result, -`agent_session_launch` record, construction route, ownership key, diagnostic, -or private instruction file. +division as the root launcher. Core closes it over the terminal composite and +authored pane ordinal. After the claim admits the launch and pane output is +flushed, the launcher calls the composite's required provider-neutral +`launch(ordinal, request, spawned)` operation. It does not delegate to the root +foreground launcher. The ordinal remains in that live closure and never enters +the native request. + +Public middleware installed nearer the authored launch can route, wrap, refuse, +or short-circuit before delegating, but cannot settle the claim, replace the +pane, or mint a launch. Once it delegates, the pane launcher is the physical +terminal endpoint. A composite that cannot execute the request refuses rather +than falling through to the root terminal. Root `Session.Launch` retains its +existing foreground-launch route. Provider-specific grid or pane identities +never enter the `AgentLaunchRequest`, terminal result, `agent_session_launch` +record, construction route, ownership key, diagnostic, or private instruction +file. The grid's readiness barrier observes the launch only at the existing successful interactive-child start boundary. Session preparation, route publication, @@ -788,6 +799,10 @@ launch. It uses Effection's `run()` rather than `main()` so Effection does not convert terminal `SIGINT` into worker exit 130 while the foreground child is handling job control. +The composite's `shell()` path remains separate. It chooses the current host's +default shell as live policy; it does not accept or reinterpret a native launch +request supplied by an Agent provider. + After the grid is visible, a nonzero native exit fails its pane flow but does not cancel sibling panes. Core keeps that failure as the pane's status and selects the first failed pane in authored order when the reader closes the grid. @@ -908,7 +923,7 @@ ownership, because a registry free to answer differently would name a different session than the one this operation prepared. At the root, V1 holds the foreground-terminal lease for the CLI execution. In a -terminal grid, the grid holds that root lease and a launch holds only its current +grid, the grid holds that root lease and a launch holds only its current pane lease. Two launches cannot concurrently own the same root or pane terminal, even when they name different sessions. Launches on distinct panes may run concurrently, and sequential launches on one terminal are ordinary composition. @@ -1133,7 +1148,7 @@ hosts can install a controlled launcher that needs no terminal; a host that installs none — `xmd test`, document inspection, an embedder — refuses every launch, which is what keeps help and inspection free of any of this. -The Deno source host and compiled binary install the first terminal-grid +The Deno source host and compiled binary install the first grid provider for an ordinary foreground run when a TTY and the required tmux capability are available. The provider prepares one invocation-private tmux server and one persistent initial worker per pane. Its per-pane sockets live in @@ -1146,7 +1161,7 @@ inherited-stdio client and no-output control client remain distinct, and loss of the root terminal becomes structured cancellation. A missing prerequisite refuses the grid before pane start. -Node and Bun validate and catalog the same `` and `` +Node and Bun validate and catalog the same `` and `` syntax but install no grid provider. Installing a grid provider advertises no new Agent, launch adapter, session-construction mechanism, or attachment capability; each `` still passes the existing independent @@ -1177,6 +1192,33 @@ remain role and continuity identities. V1 defines no stateful-Agent model selection. A document can explicitly name an Agent where required, but no provider-specific executable or resume syntax appears in `AGENTS.md`. +### Grid package boundary + +`NativeLauncher`, `NativeLaunchRequest`, `NativeLaunchOutcome`, terminal +reservation and output flushing are canonically exported by +`@executablemd/grid`. The same package owns the pane claim and the +provider-neutral composite endpoint that receives a native launch. The Agent +request, construction route, session coordinator and `Session.Launch` +component stay in their existing Agent and core modules; neither acquires a +terminal-provider identity. + +`@executablemd/grid-tmux` consumes that endpoint and supplies the physical +pane worker. It does not import core, runtime or CLI. The Deno and compiled CLI +hosts compose the two domains and provide self-reinvocation and POSIX process +observation; Node and Bun continue to compose neither a foreground grid +provider nor an observer. + +The unmerged `packages/terminal` and `packages/terminal-tmux` trees become +`packages/grid` and `packages/grid-tmux`. The former runtime native-launch +exports, core pane and grid-provider exports, old CLI terminal implementation +paths, rejected package names, and old authored component names are deleted. +They carry no compatibility contract. Every repository consumer imports the +canonical grid packages, and each contextual descriptor and public error +constructor has one definition. The boundary change preserves launch requests, +phases, routes, ownership keys, durable records, results, provider +advertisements, and root-versus-pane behavior; diagnostics that identify the +authored constructs use `Grid` and `Pane`. + ## Testing The test-agent stack supplies deterministic provider state. A controlled native @@ -1184,7 +1226,7 @@ launcher records the request, claims a known provider-native session ID, waits on a test-controlled operation, and exits with a selected status. It never starts Claude, Codex, or a model. -Terminal-grid tests additionally install a controlled provider that is not +Grid tests additionally install a controlled provider that is not tmux. It exposes readiness, independent pane settlement, reader close, provider failure, parent cancellation, and teardown completion as test-controlled operations while using the same core terminal authority and pane-scoped native @@ -1198,6 +1240,15 @@ exercises pane reuse after terminal-holder quiescence; a process that has already started a new session, closed the terminal, and lost its parent is recorded as outside the observable host boundary. +The pane-native route has an explicit physical-terminal regression. A paired +pane delegates through any nearer launcher middleware to its composite endpoint; +the production tmux adapter delivers the unchanged command vector, cwd, and +environment to the authenticated worker for that authored ordinal, while a +root-launcher sentinel proves the foreground endpoint was not entered. Two pane +endpoints launch concurrently. Cancellation remains pending until worker +settlement and pane-terminal quiescence are observed. A separate root launch +still reaches the root foreground launcher. + Focused tests prove: 1. help discovers roles and performs no preparation or launch; @@ -1333,7 +1384,7 @@ and the build binding it produces; ACP attachment to a bound client-native session under its exact retained identity, through runtime partitions keyed by agent command and build; an inherited root- or pane-terminal interactive child with cancellation and -bounded reaping; composition with the terminal grid's independent pane leases +bounded reaping; composition with the grid's independent pane leases without changing session ownership or durable launch identity; and the controlled TestAgent fixture that proves all of it without starting a model. @@ -1373,7 +1424,7 @@ An adapter that cannot prove instruction injection before the first user turn stays unsupported rather than weakening `Session.Launch` semantics. Native UI event mirroring, XMD-rendered interactive chat, simultaneous root -foreground sessions outside a terminal grid, automatic nested `AGENTS.md` +foreground sessions outside a grid, automatic nested `AGENTS.md` discovery, bootstrap model turns, and workflow role scheduling are outside this contract. @@ -1431,7 +1482,7 @@ Implementation review checks these frozen invariants: moment its handle exists; a cancellation observes and settles an ensure it already started before quiescence; quiescence is answered from that account; and a close that failed releases nothing and acknowledges none. -24. A terminal grid holds the root foreground lease while each launch holds only +24. A grid holds the root foreground lease while each launch holds only its current pane lease; distinct panes do not contend for terminal ownership, and one pane remains exclusive until observable processes and terminal holders from the prior launch are gone. @@ -1448,6 +1499,18 @@ Implementation review checks these frozen invariants: keeps worker display out of child input, distinguishes reader detach from control loss and server stop, and proves the bounded process and terminal teardown before pane reuse and grid settlement. +29. A paired pane's native launcher terminates at the required composite + operation for its authored ordinal: the exact native request reaches that + pane's authenticated worker, the root foreground launcher is not entered, + distinct panes launch concurrently, cancellation awaits worker settlement + and pane quiescence, and root launch routing remains unchanged. +30. Native-launch and terminal-provider descriptors and error constructors have + one canonical definition under the terminal packages; the former runtime, + core and CLI terminal paths are absent and every repository import is + canonical. The terminal package imports no Agent, core, runtime, CLI or tmux + module, the tmux package imports only the neutral terminal domain, and the + complete launch evidence above passes without changing any request, route, + record, provider advertisement or diagnostic. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 411f963a5..2199fe18c 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -49,10 +49,11 @@ sequenceDiagram ## 2. Version lockstep Every publishable package (`packages/core`, `packages/cli`, -`packages/durable-streams`, `packages/runtime`, `packages/testing`, -`packages/code-review-agent`, `packages/test-agent`, `packages/acp`, -`packages/web`, `packages/workflow`) declares the same version in its `deno.json` and -`package.json`. A member marked `"private": true` is outside the lockstep +`packages/durable-streams`, `packages/runtime`, `packages/grid`, +`packages/grid-tmux`, `packages/testing`, `packages/code-review-agent`, +`packages/test-agent`, `packages/acp`, `packages/web`, `packages/workflow`) +declares the same version in its `deno.json` and `package.json`. A member marked +`"private": true` is outside the lockstep because it never publishes — `packages/test-support` is the one, and it stays at `0.0.0`. `packages/cli/src/cli.ts` imports `packages/cli/deno.json` and reads `version` @@ -77,6 +78,31 @@ the checked-out revision with `deno task setup` and `deno task build`, then run install the latest published release, so a review always understands the documents at the revision it checks. +### Grid package order + +The grid packages follow the same manifest-derived publication graph as every +other workspace member. `@executablemd/grid` depends on +`@executablemd/durable-streams` and the external Effection packages, not on +runtime, core, CLI, or grid-tmux. `@executablemd/grid-tmux` depends on grid. +Runtime has no grid dependency. Core depends on grid as well as its existing +runtime and durable-stream dependencies. CLI depends on grid-tmux, grid, core, +and runtime. + +The generated npm jobs consequently publish durable-streams before grid; grid +before grid-tmux and core; and grid-tmux, grid, core and runtime before CLI. +Runtime remains an independent leaf. The +workspace package names and versions are also recorded in `bun.lock`. Changing +these manifests or sibling dependencies requires +`deno install --frozen=false`, the repository's normal setup, and +`deno task gen:publish-workflow`; `publish-packages.yml` remains generated and +is never edited by hand. + +Moving grid tests between workspace members changes test-corpus paths. The +runtime exclusions continue to name every deliberately excluded file, and +`test-weights.json` is remeasured by the Measure test weights workflow on the +exact implementation head. No timing value is copied, renamed, or edited by +hand. + ## 3. Workflows - **`draft-release.yml`** (`push: main`): maintains the rolling draft release @@ -191,7 +217,7 @@ already carries at that version, member by member. A rerun after a partial publi therefore completes exactly the members that are missing, and a rerun after a complete publish exits 0 without republishing. Never gate the job on one package's existence — whether `core` is published says nothing about the other -six. +packages. `deno task check:jsr` runs the same command with `--dry-run` and is a required CI job on every PR (§3, `ci.yml`). It enforces JSR's fast-check rules, so every