diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 9f56a1e9e..73f2e2483 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/runtime/deno.json packages/terminal/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 declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -75,8 +75,15 @@ jobs: package: packages/runtime version: ${{ needs.version.outputs.value }} + terminal: + needs: [version, durable-streams] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal + version: ${{ needs.version.outputs.value }} + core: - needs: [version, durable-streams, runtime] + needs: [version, durable-streams, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/core diff --git a/architecture.md b/architecture.md index a6b73181b..9b048407f 100644 --- a/architecture.md +++ b/architecture.md @@ -108,11 +108,12 @@ 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 | -| 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 | +| 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 grid 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 cells begin concurrently, remain independently interactive, and settle under one scope after complete provider and cell teardown | +| terminal cell | one authored position in a terminal grid, identified durably by its position in the ordered grid and presented by its current title. It owns one interactive terminal at a time; a paired cell expands its own document flow and a self-closing cell runs the host's default shell | +| terminal pane | the provider-owned interactive terminal endpoint bound to one live terminal cell; a tmux pane is one implementation, and its identity is never an authored or durable cell identity | +| terminal-cell lease | the exclusive ownership one live interactive operation holds in a terminal cell. Different cells do not contend; a second operation in one cell does. The grid lifecycle closes admission when that cell is closing, and terminal ownership grants no authority over an Agent session | +| native launcher | the host-owned seam that reserves the foreground terminal or the current terminal cell, 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 | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | @@ -3431,21 +3432,22 @@ provider-neutral grid of independently interactive terminal panes: ``` -`Terminal` names the interactive endpoint the document requires. It does not +`Terminal` names the interactive cell 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 +multiplexer, and a host-native grid 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. -The grid and its panes are core-owned structural syntax. `` is +The grid and its cells 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 +cell requires a non-empty `title`; titles are display labels and need not be +unique. Its position in the ordered direct children is its durable structural +identity; no separately stored ordinal or key duplicates that fact. Rows are +derived in row-major order from the cell count and columns. A paired cell +expands ordinary document flow; a self-closing cell runs the host's 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. @@ -3454,7 +3456,7 @@ Core ownership is necessary here. An ordinary function component receives its content as one rendered string, after the effects in that content have already run; it cannot inspect direct authored children or begin them concurrently. Core instead validates the complete static grid before any provider contact, -then creates one durable child operation per pane in authored order. Each pane +then creates one durable child operation per cell in authored order. Each cell gets an isolated binding and evaluation scope. It inherits the bindings, contextual providers, working directory, and configuration visible at the grid site, while bindings and contextual changes made in one pane remain there and @@ -3464,7 +3466,7 @@ 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. -### Terminal authority +### Terminal grid presentation One grid holds the execution's foreground-terminal lease for its whole visible lifetime. A root `` and a grid therefore contend for the same @@ -3477,50 +3479,343 @@ or default shell are never captured or journaled. The grid renders nothing, and root document output resumes only after the provider has restored the root terminal. -The host owns one non-contextual terminal authority, built and delivered -directly to the installed provider. It validates the exact grid request and -provider installation generation, mints one-use claims for the authored pane -ordinals, and is the only capability that can take or release the root and pane -terminal leases. No context value, prop, binding, provider result, retained -record, diagnostic, or structurally similar request carries that authority. - -The stable contextual terminal API is request routing only. Middleware may -observe, narrow, refuse, wrap, or delegate a one-use request. A handler's -return value is ignored, and answering without delegation authorizes and -settles nothing. Core supplies the one request for the exact expansion; the -provider factory closes over the direct authority and must present that same -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. - -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 -and before it waits for exit; failed preparation, reservation, or spawn never -acknowledges it. Allocation of a PID and the child's first output are not this -event. The self-closing shell path acknowledges the same boundary. The latch is -not a request member, contextual value, provider return, public event, or -process handle, and acknowledging it twice has no effect. A root launch has no -grid readiness latch. This is how the grid observes successful interactive -start without changing `Session.Launch`'s result or exposing a child process. +The host owns one non-contextual presentation function, `PresentTerminalGrid`, +built and delivered directly to the installed provider. It validates the exact +grid request, the provider installation generation, and that the request has not +already been presented before any provider resource is acquired. A refused +presentation therefore costs the provider nothing and produces no side effect. +No context value, prop, binding, provider result, retained record, diagnostic, +or structurally similar request carries this authority. + +A provider supplies one `TerminalGridHost` as a resource. Acquiring it is the +provider grid coming into existence; releasing it is the provider grid going +away, exactly once, whether the grid succeeded, failed to start, was closed by +the reader, was failed by the provider, or was cancelled. There is no destroy +operation to call, and so no way to call one twice or forget one. The host +exposes the effects a neutral grid can require — converge state, show the +prepared grid, launch an exact native request in one live cell, and start the +host's default shell in one live cell — together with independent read-only +`closed` and `failed` operations. Reader close is lifecycle input, not an +imperative close action on the grid, and a background provider failure cannot +hide until another action happens to call the provider. + +Nothing owns a grid but the expansion that submitted it. The private +`terminalGrid()` resource runs the whole grid beneath that operation: the +layout reconciliation, durable grid child, foreground-terminal lease and root +output flush, live state, provider resource, cell children, close commitment, +and complete teardown. It returns the grid's running task, so its caller awaits +the computational unit directly: + +```ts +interface TerminalCellWork { + readonly cellId: TerminalCellId; + readonly operation: Operation; +} + +interface RetainedCellOutcome { + readonly status: "succeeded" | "failed" | "closed"; + readonly reason: string; +} + +interface TerminalGridJournal { + reconcileLayout(layout: RetainedGrid["layout"]): Operation; + retainGrid(operation: Operation): Operation; + retainCell( + position: number, + operation: Operation, + ): Operation; +} + +function terminalGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, +): Resource>; + +function appendTerminalCellOutput(text: string): Operation; + +const grid = yield* terminalGrid(layout, cellWork, journal); +const outcome = yield* grid; +``` + +There is no callback-shaped lifecycle API, public close boundary, grid registry, +or execution-wide owner of live grid tasks. Scope parentage takes the whole grid +down whenever its submitting expansion unwinds and keeps each durable child in +the expansion context that authored it. The installation retains only the +minimum admission state that lets one exact submitted request and its provider +presentation converge. + +Core constructs each cell's generator as a lazy `Operation` and mints its +fresh live `cellId`; constructing the operation performs no expansion, shell, +Agent, provider, or journal work. The terminal lifecycle validates that the +ordered cell work matches the layout, runs each operation exactly once inside +the corresponding position-derived durable child, and installs that cell's +issued `TerminalCellUI` and a closure-bound cell-output sink in the operation's +scope before interpreting it. The paired operation expands authored content; +at each completed output boundary core appends those rendered bytes through the +sink with `appendTerminalCellOutput()` and awaits the resulting private store +commit before expansion continues. Empty text is a no-op; every other call +appends in call order, so the aggregate's `content` remains the complete output +produced so far. The sink is an integration facet of `./lifecycle`, not a member +of either UI or the provider view, and it exposes no store, dispatch, title, +status, identity, or host authority. Its operation is inert outside the issued +cell scope. The self-closing operation invokes the contextual cell's shell +action. No lifecycle callback receives a scope or resource. + +Core also constructs the `TerminalGridJournal` for the grid's source expansion. +The adapter closes over core's source-aware journal descriptions and implements +layout reconciliation plus the grid and position-derived cell durable +boundaries. It receives only provider-neutral retained values and lazy +operations. The neutral package defines and calls this interface but never +imports core. `reconcileLayout()` runs before provider admission; +`retainGrid()` may return a completed retained outcome without interpreting its +live operation; and `retainCell()` does the same for one completed cell. This is +how completed replay creates no live state while incomplete replay preserves +the exact durable identities core supplied. The core adapter creates each +source-described durable grid or cell child when the terminal lifecycle invokes +the corresponding retention operation; the terminal lifecycle owns the live +tasks produced by those children and never creates a competing durable lineage. + +An incomplete grid creates one live, immutable `TerminalGridState` and a +`TerminalGridUI` action object. The neutral terminal package owns a private +per-grid StarFX store and its blocking controllers inside the current Effection +scope; it creates no independent root and does not put provider resources under +StarFX's retrying resource management. The store is presentation state only. +The durable journal remains the source of replay and recovery. Every +correctness-bearing action runs its controller synchronously through StarFX's +blocking operation form, so its caller may await all state transitions and host +effects owned by that action. Raw non-blocking dispatch never launches, shows, +closes, or settles terminal work. + +The action surface is intentionally small: + +```ts +interface TerminalGridUI { + readonly state: TerminalGridState; + readonly cells: readonly TerminalCellUI[]; + show(): Operation; +} + +interface TerminalCellUI { + readonly state: TerminalCellState; + launch(request: NativeLaunchRequest): Operation; + shell(): Operation; +} +``` + +The provider side is separate: + +```ts +interface TerminalGridView { + readonly states: Stream; +} + +interface TerminalGridProvider { + host( + request: TerminalGridRequest, + view: TerminalGridView, + ): Resource; +} + +interface TerminalGridHost { + readonly closed: Operation; + readonly failed: Operation; + show(requiredRevision: TerminalGridRevision): Operation; + converge(requiredRevision: TerminalGridRevision): Operation; + launch( + cellId: TerminalCellId, + request: NativeLaunchRequest, + ): TerminalActivity; + shell(cellId: TerminalCellId): TerminalActivity; +} + +type PresentTerminalGrid = ( + request: TerminalGridRequest, + provider: TerminalGridProvider, +) => Operation; +``` + +The provider route hands its `TerminalGridProvider` directly to the +non-contextual presentation authority with the exact routed request. Admission +happens before `provider.host()` is called. The terminal lifecycle then creates +the state and view and acquires the returned host resource in the grid's scope. +A provider object is implementation, not authority: it cannot present another +request, choose another generation, mutate state, or settle the grid by its +return value. + +Core supplies one fresh live identity per authored cell. The terminal lifecycle +creates one stable `TerminalCellUI` handle for it and installs that exact handle +while it interprets the corresponding cell operation. The handle closes over +its live cell identity, so a component calls `launch()` or `shell()` without passing an index or +identifier. Each `state` property reads the current immutable snapshot; a +previously read snapshot never changes. Only the cell UI, not the grid UI, +enters the paired cell's context. The canonical stable contextual API returns it +through `useTerminalCellUI(): Operation`; absence means the +caller is outside a grid cell. The required non-empty `` prop is +the only title input in this Story and seeds state before presentation. Title is +not mutable through `TerminalGridUI`, `TerminalCellUI`, context, or a generic +state action. Title mutation is outside this contract. +Rendered paired-cell content enters state through a private controller; no +public title or content setter, generic dispatch, status setter, success action, +or failure action exists. The cell's durable child alone publishes its final +outcome. Core reaches that controller only through the issued cell-output sink +installed while its lazy operation is interpreted. Each append completes after +the immutable aggregate commit and before the next authored effect; it does not +wait for provider rendering. This makes every earlier completed output emission +causally prior to a later `launch()` or `shell()` controller without exposing an +imperative presentation API to authors, components, or providers. + +Context makes the exact issued cell handle available for composition; it is not +provider authority. A replacement context can intercept or refuse work but +cannot construct an issued handle, admit a provider presentation, or reach the +host effects. Native-launch middleware keeps its ordinary ability to observe, +narrow, wrap, refuse, or delegate a request. The cell-scoped endpoint invokes +the issued cell's `launch()` action rather than falling through to the root +foreground launcher. This changes no native request, Agent-session identity, or +session-coordinator authority. + +The provider receives a separate read-only `TerminalGridView`. Its `states` +stream is the only provider observation surface. Starting a subscription +atomically captures and emits one current snapshot, then emits only snapshots +with strictly greater revisions. Registering that subscriber and capturing the +first snapshot happen in the same serialized store step, so no state commit can +land between a separate read and subscription. Every subscription has this +property; the provider never combines `ui.state` with a later subscription. +The view exposes neither `TerminalGridUI`, a cell action handle, the StarFX +store, nor mutation authority. + +The state is complete desired presentation, not a delta or command log. The +provider runs one scope-owned renderer lane and applies revisions serially. If +several snapshots arrive while one is being applied, it may discard the +intermediate snapshots and next apply the greatest pending revision because +that snapshot subsumes them. It never starts two renders concurrently, never +applies a revision at or below the greatest one it has completed, and advances +its private applied revision only after every provider effect for the chosen +snapshot succeeds. A request to converge through revision `r` completes when a +complete snapshot with revision at least `r` has been applied; coalescing to a +later revision therefore satisfies earlier waiters without rendering old state +after new state. + +Ordinary private state commits do not await rendering. `TerminalGridUI.show()` +is different: its blocking controller commits `visible`, captures that +resulting revision, and calls `host.show(requiredRevision)`. The host first +converges through that captured revision and only then atomically presents the +prepared grid. A later state commit may be included but cannot make `show()` +return before the captured requirement is visible. + +Before `TerminalCellUI.launch()` or `shell()` asks the host to transfer the +cell's terminal, its blocking controller admits the action, commits `launching`, +and captures the resulting revision after all causally prior cell output. It +awaits `host.converge(requiredRevision)` before calling `host.launch()` or +`host.shell()`. A later applied aggregate revision satisfies that requirement +only because it contains the complete desired state, including that cell's +earlier output. Cancellation or failure while convergence is pending invokes no +host launch or shell operation, acquires no terminal activity, and establishes +no readiness. + +The host's `failed` operation is distinct from reader `closed`. The grid +lifecycle observes it from host acquisition until release, including while no +action is awaiting the renderer. It remains pending during ordinary operation +and yields the provider error when background work fails; the lifecycle raises +that exact error. Unexpected termination of the state subscription or renderer +while the host remains acquired is failure, not successful convergence or +reader close. A background subscription, renderer, setup, show, convergence, +launch, shell, or close-observation failure is fatal to that grid, fails every +pending convergence waiter, and enters the same complete structured teardown. +Reader departure settles only `closed`; ordinary resource release settles +neither signal as a false event. The provider is never silently restarted +underneath the same live state. + +The live state is one fixed aggregate: + +```ts +type TerminalGridRevision = number; + +interface TerminalGridState { + readonly revision: TerminalGridRevision; + readonly phase: "preparing" | "visible" | "closing" | "closed"; + readonly columns: number; + readonly rows: number; + readonly cells: readonly TerminalCellState[]; +} + +interface TerminalCellState { + readonly cellId: TerminalCellId; + readonly title: string; + readonly row: number; + readonly column: number; + readonly status: + | "starting" + | "launching" + | "running" + | "succeeded" + | "failed" + | "closed"; + readonly content: string; +} +``` + +Revision is a live-only, per-grid, non-negative safe integer. The initial +snapshot is revision zero. Each atomic state commit that changes the desired +aggregate creates a new immutable snapshot and increments the revision exactly +once; a no-op creates neither a revision nor an emission. Store commits are +serialized even while action effects in distinct cells run concurrently. A +revision is never authored, retained, replayed, diagnosed, or passed to an Agent +or native-launch request. Partial replay starts a new live sequence at zero; +completed replay creates none. An attempted increment past +`Number.MAX_SAFE_INTEGER` fails the grid before publishing an ambiguous +revision. + +`content` is the complete rendered Markdown display desired for that cell, not +an output event. A later snapshot therefore includes all earlier cell output +that remains part of the presentation. Native Agent and shell terminal bytes +never enter this state. + +`cellId` is minted by core for one live grid. It keeps a cell handle, state +updates, provider effects, and a provider's private pane binding together even +when positions change. It is not authored, retained, replayed, +diagnosed, placed in a native or Agent request, or used as provider identity. +The `cells` arrays remain in authored order; row and column say where a cell is +currently presented. Incomplete replay mints fresh live cell identities. The +identity permits movement or resizing without changing cell identity; this +contract exposes no move, resize, reorder, or durable-layout action. + +A cell begins `starting`. Admitting `launch()` or `shell()` publishes +`launching`; acquiring the provider's terminal activity at the actual child +spawn publishes `running`. The action waits for child settlement and the +provider's terminal-quiescence proof. The surrounding durable cell child, not +an individual launch, publishes `succeeded` or `failed` when all authored cell +work settles. Sequential launches may therefore move `running` back through +`launching` while the cell flow remains active. Distinct cells act concurrently. +Overlapping terminal activities in one cell fail immediately as busy rather +than entering a hidden queue, and the next activity is admitted only after the +prior cleanup and quiescence proof. Once close commitment stops admission, +every new public action refuses. + +Readiness is not something anybody acknowledges. A terminal activity is a +resource whose acquisition happens only once its child has actually spawned, and +that acquisition *is* the pane becoming ready; the value acquired is the +operation that settles with how the child ended. So preparation, reservation or +spawn failures all fail before acquisition and leave the pane unready, while a +child that spawns and exits immediately is both ready and settled. Allocation of +a PID and the child's first output are not acquisition. Nothing about readiness +enters a request, a provider result, a process handle or a durable record, and a +root launch has no pane activity at all. + +Readiness, live-use tracking, and closing admission remain private controller +state rather than a second public capability model. The `TerminalCellUI` is a +domain action handle over that state, not a resource owner or an independently +recoverable cell. Holding one after its grid closes grants nothing. A provider whose pane endpoint is owned by a persistent process routes child creation through that process. The launch's exact argv vector, working directory, and environment cross a provider-private authenticated channel; they never pass through the presentation provider's command language. The pane owner creates the child with all three standard streams inherited from the pane -terminal, reports the runtime spawn event, and remains only the lifecycle and -display owner. It writes provider display messages to the terminal but never -reads terminal input, so interactive input belongs to the foreground child. +terminal, provides the activity once that child is running, and remains only the +lifecycle and display owner. It writes provider display messages to the terminal +but never reads terminal input, so interactive input belongs to the foreground +child. It admits one live launch at a time and releases the pane only after that launch's observable terminal ownership has been swept. Sequential launches use the same pane owner and endpoint rather than replacing the pane. @@ -3528,51 +3823,120 @@ the same pane owner and endpoint rather than replacing the pane. Provider-specific commands, socket paths, session names, window identifiers, pane identifiers, attach keys, and process topology remain private inside the provider closure. They appear in no authored surface, durable identity, result, -or diagnostic. Provider-neutral diagnostics identify a grid expansion and pane -ordinal or title only. A provider may show sanitized pane status, but core owns -the operation result; presentation never decides whether a pane or grid -succeeded. Core sends the provider only the closed presentation states -`starting`, `running`, `succeeded`, `failed`, and `closed`: readiness moves a -pane to `running`, pane settlement supplies `succeeded` or `failed`, and a live -pane cancelled solely by reader close becomes `closed` rather than failed. +or diagnostic. Provider-neutral diagnostics identify a grid expansion and a +cell's authored position or title only. The provider observes the closed +presentation states `starting`, `launching`, `running`, `succeeded`, `failed`, +and `closed`; it cannot set them. Admission selects `launching`, terminal +activity acquisition selects `running`, cell-flow settlement supplies +`succeeded` or `failed`, and a live cell cancelled solely by reader close +becomes `closed` rather than failed. ### Atomic presentation and settlement The grid runs as one structured scope: -1. Core validates the whole structural layout, takes the foreground-terminal - lease, and flushes root output. -2. The provider checks its live prerequisites and prepares the entire hidden - composite: every pane endpoint, its supervision, and the default shell where - requested. No grid is attached yet. -3. Core starts the pane child operations concurrently, using deterministic - durable child identities derived from the grid expansion and authored - ordinal. A paired pane begins its document flow and a self-closing pane - begins its shell. -4. A pane is ready only when its interactive child emits the runtime's - successful spawn event. Reserving an endpoint, allocating a process - identifier, or receiving output is not readiness. A child that starts and - exits immediately can be both ready and settled. -5. Only after every pane reaches readiness does the provider attach the one - composite presentation. Any preparation or pane-start failure before this - barrier cancels every pane, awaits complete teardown, discards the hidden - composite, and fails without exposing a partial grid. Agent preparation or - retained route work that occurred before a failed native spawn remains +1. Core validates the whole structural layout, creates the lazy cell operations + and source-aware journal adapter, and acquires `terminalGrid()`. Its task + reconciles the layout before live work, then a non-replayed grid takes the + foreground-terminal lease and flushes root output through terminal's + contextual host boundary. +2. The terminal lifecycle admits the presentation — exact request, generation, + not already used — and only then creates the live UI state and acquires the + provider's host resource with its read-only view. Acquisition prepares the + entire hidden grid: its gap-free state subscription, single renderer lane, + every pane endpoint, provider-failure observation, and supervision. No grid + is visible yet, and a refused presentation creates no store and acquires + nothing at all. +3. The terminal lifecycle starts the lazy cell operations concurrently, using + the core-supplied journal adapter to establish deterministic durable child + identities derived from the grid expansion and authored array position. It + interprets each operation under that cell's issued `TerminalCellUI`. A paired + pane begins its document flow and a self-closing pane begins its shell. +4. A pane is ready only when its terminal activity is acquired, which happens + only once its child has actually spawned. Reserving an endpoint, allocating a + process identifier, or receiving output is not acquisition. A child that + starts and exits immediately can be both ready and settled. +5. Only after every cell reaches readiness does the grid controller run + `TerminalGridUI.show()`. The action publishes `visible`, captures that + revision, and awaits the host's convergence and atomic presentation through + it. Any acquisition, convergence, background-render, or cell-start failure + before this barrier cancels every cell, awaits complete teardown, releases + the hidden grid, and fails without exposing a partial grid. Agent preparation + or retained route work that occurred before a failed native spawn remains durable; atomicity covers terminal presentation and lifecycle, not rollback of earlier provider effects. 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. + visible while siblings continue. The grid remains present after all panes + settle until the reader closes or leaves it. +7. The lifecycle observes the host's independent `closed` and `failed` + operations for its whole acquired lifetime. `closed` first crosses the + private live close boundary, then the controller publishes `closing` and + begins ordered teardown: prevent + new cell actions, ask live cell children to close, await every child and + finalizer, release the provider's host resource — which is what destroys it, + once — restore the root terminal, publish `closed`, and only then release the + foreground lease and settle the grid. Reader close, cell failure and parent + cancellation each decide the durable outcome before disposal begins; cleanup + enforces quiescence and never invents or rewrites a retained outcome. The + document never continues while an observable cell child or provider-owned + process can still act through the grid. + +The task returned by `terminalGrid()` settles only after the selected grid +outcome is retained, every cell and renderer task has settled, the provider host +resource has been released, the root terminal has been restored, and the +foreground lease has been released. Releasing the outer resource before that +settlement cancels the task and waits for the same complete teardown; it does +not detach the computation. + +An action cancelled while waiting for convergence never transfers a terminal. +Before reader-close acknowledgement, parent cancellation cancels the cell action +and the whole grid scope, fails its pending waiters through resource teardown, +and awaits the renderer, cell, and host finalizers before it propagates. Reader +close follows the established cooperative close handshake; it prevents new +actions and cancels any admitted cell work still waiting for convergence before +a provider child exists. After close acknowledgement, parent cancellation +remains deferred by the existing rule. A background provider failure wins as +the fatal grid result under the existing failure and cleanup precedence, even +when no foreground action is currently waiting on the renderer. + +The provider host's read-only `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. + +There is no public `close()` action. Reader-close intent becomes durable only as +that completed grid `Close`, after cell and provider teardown. There is no +standalone durable "closing" state. The live UI phase named `closing` reports +teardown progress and is not retained intent. 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 position-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 @@ -3609,23 +3973,62 @@ 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 -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 -outside a grid. A self-closing shell is a terminal child effect that retains -only provider-neutral start and exit status; its executable, argv, environment, -terminal bytes, and conversation history are live-only. +contains the columns and the ordered cell forms and titles, never an explicit +index, live `cellId`, provider identity, or terminal identifier. Array position +is the durable structural identity. Each cell is a deterministic durable child +coroutine derived from that position, so effects in paired content retain and +replay under the same rules they use outside a grid. A self-closing shell is a +terminal child effect that retains only provider-neutral start and exit status; +its executable, argv, environment, terminal bytes, and conversation history are +live-only. The pre-merge retained shape with explicit ordinal fields has no +migration reader. The completed grid record retains the provider-neutral layout, close kind, and -ordered pane outcomes after the normal secret gate. Completed replay claims the +ordered cell outcomes after the normal secret gate. Completed replay claims the whole region and returns its retained outcome without installing or contacting -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 -children are restored as settled statuses without re-running their effects, +a terminal provider, creating a StarFX store, UI object, cell handle, state +stream, or host, starting a shell, expanding cell content, acquiring an Agent +session, or launching a native UI. The structured durable boundary owns that +short circuit; a public replay context does not. + +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 creates fresh +live state at revision zero and fresh cell identities and acquires a fresh +provider host: completed +cell 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 contract; placing it in a pane neither allocates a replacement session nor @@ -3634,6 +4037,89 @@ authorized default shell and makes no claim to resume its prior terminal history. Provider identifiers are recreated live and are never reconciled with a journal. +### Package ownership + +The package boundary follows the provider boundary. `@executablemd/terminal` +owns the provider-neutral state, UI, view, host and cell action types; the +private StarFX store and controllers; monotonic revisions, atomic observation +and convergence contracts; presentation admission, terminal activities, errors, +grid lifecycle and replay; POSIX process observation and quiescence; and the +controlled evidence provider. `@executablemd/terminal-tmux` owns tmux commands, +workers, private IPC, the serialized renderer, applied-revision tracking, and +the tmux host resource. Core imports the neutral package and keeps structural +expansion, source-aware journal descriptions, execution-profile integration, +and Agent session behavior. It supplies the neutral `TerminalGridJournal` +adapter and lazy cell operations without the terminal package importing core. +The Deno and compiled CLI entrypoints install the POSIX +observer and tmux provider for an ordinary foreground `xmd run`; Node, Bun and +every workflow profile install neither. A terminal grid is run presentation, +not workflow orchestration. + +Dependencies point from core and the tmux adapter into the neutral terminal +package, and from runtime-named CLI entrypoints into the tmux adapter. The +neutral package imports neither core, the tmux adapter, runtime, nor CLI; the +tmux adapter imports neither core, runtime, nor CLI. StarFX remains a private +dependency of the neutral package, and no StarFX type crosses its public or +provider surface. Reusable POSIX observation stays behind +`@executablemd/terminal/posix` so another POSIX provider need not depend on tmux. +The old runtime, core, and CLI terminal implementation modules and exports are +deleted, and repository imports use the two canonical package surfaces directly. +This stack is unmerged, so no compatibility re-export or migration path +preserves those experimental module locations. + +The extraction applies to the stack's implementation modules as follows: + +| Current module | Destination | +| --- | --- | +| `packages/runtime/launcher.ts` | Split among terminal's neutral root, POSIX foreground-child adapter, and controlled test entrypoint | +| `packages/runtime/terminal.ts` | Split between terminal's neutral root and controlled test entrypoint | +| `packages/runtime/terminal-processes.ts` | `@executablemd/terminal/processes` | +| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix` | +| `packages/core/src/terminal/{grid,pane,presentation,provider-api}.ts` | `@executablemd/terminal/lifecycle`, with UI state and controllers replacing the imperative grid and pane surfaces | +| `packages/core/src/terminal-grid.ts` | Split: neutral layout moves to terminal; authored scanning, expansion and source integration stay in core | +| `packages/core/src/terminal/{journal,profile}.ts` | Stay in core as adapters from terminal lifecycle to core journal descriptions and `Execution` | +| `packages/cli/src/terminal/{attach-client,layout,pane-channel,pane-child,pane-protocol,pane-worker,provider,tmux-grid,tmux}.ts` | `@executablemd/terminal-tmux` | +| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in a non-terminal CLI module | + +The neutral package root exports the provider-neutral launch, state, UI, view, +host, activity, layout, routing, journal-adapter and error contracts together +with `useTerminalCellUI()`. Its `./lifecycle` entrypoint +exports grid execution, presentation installation, replay, and the +integration-only cell-output sink core invokes while expanding paired content; +`./processes` +exports process facts, snapshots and quiescence; `./posix` exports reusable +POSIX observation and foreground-child adapters; and `./test` exports only +controlled providers, launchers, logs and signals. These are facets of one +package: a value exported from more than one entrypoint is object-identical. +`@executablemd/terminal-tmux` exports its provider name, dependency contract, +provider factory and installer, pane-worker command and invocation parser, pane +worker runner, and documented provider errors from its root. Protocol frames, +channels, tmux process wrappers, layout mechanics and teardown hooks remain +private; controlled low-level seams exist only under its `./test` entrypoint. +Every contextual API descriptor and public error constructor has one canonical +definition. Re-exporting it from another canonical entrypoint preserves object +identity; no package rebuilds a structurally similar descriptor or error. + +Tests move with the contract they prove. Terminal owns neutral state, action, +authority, lifecycle, replay, process-observation and quiescence evidence; +terminal-tmux owns renderer, protocol, worker and teardown evidence; core keeps +syntax, source integration and journal-description evidence; test-agent keeps +cross-package Agent composition; and CLI keeps entrypoint and compiled-host +selection evidence. Both terminal packages are ordinary lockstep-versioned +workspace members. Publication places terminal after durable streams, +terminal-tmux and core after terminal, and CLI after terminal-tmux, terminal, +core and runtime. + +Issue #781 later changes the unshipped authored names to `` and `` +and the package names to `@executablemd/grid` and +`@executablemd/grid-tmux`. That semantic restack preserves this exact resource, +state revision, atomic observation, convergence, failure, journal-adapter, +replay, cancellation, and teardown contract. It does not restore a mutable title +action, a split current-state/changes provider API, a callback-shaped lifecycle, +or any compatibility export for the names it replaces. + +### tmux provider + The first production provider uses tmux where the Deno or compiled host has a foreground terminal and the required tmux capability. It prepares one private tmux server per grid and starts one persistent pane worker as each pane's @@ -4970,8 +5456,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 | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral grid whose statically declared direct cells begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, and tear down completely before document execution continues. One scope-owned `TerminalGrid` task owns the whole durable computation. Incomplete work creates private immutable StarFX state with monotonic live revisions, stable contextual `TerminalCellUI` action handles and one resource-owned `TerminalGridHost`; the provider observes an atomic current-and-newer stream, serializes and coalesces rendering, and cannot mutate state. The authored title is fixed for this contract. Native and shell actions await convergence of preceding cell output before transferring the terminal, remain exclusive within one cell and concurrent across cells, and `show()` awaits its captured visible revision before atomic presentation. Reader close and background provider failure are distinct observed lifecycle inputs. Core supplies lazy cell operations and a source-aware journal adapter without creating a package cycle. Completed replay creates none of the live state or provider objects; partial replay starts a fresh revision sequence and cell identities while continuing incomplete effects under their existing position-derived durable identities. `@executablemd/terminal` owns the neutral live contract, lifecycle, replay and reusable POSIX observation; `@executablemd/terminal-tmux` owns tmux rendering, IPC and workers; core owns structural syntax, source-aware journal descriptions and profile integration; and runtime-named entrypoints install host wiring with no compatibility exports from old terminal paths | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; controlled non-tmux evidence proves the provider-neutral state, convergence, lifecycle and replay 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 cell's lease through its contextual `TerminalCellUI`. 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..f6698904f 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,8 @@ "mdast-util-to-string": "^4", "remark": "15", "remend": "^1.2.2", + "semver": "^7.8.5", + "starfx": "0.16.1", "unist-util-select": "^5", "zod": "^4.3.6", }, @@ -37,11 +39,13 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/test-support": "workspace:*", "@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,6 +57,7 @@ "name": "@executablemd/acp", "version": "0.12.1", "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", "acpx": "0.12.0", @@ -78,6 +83,7 @@ "@standard-schema/spec": "^1.0.0", "configliere": "^0.4.0", "effection": "4.1.0", + "semver": "^7.8.5", "zod": "^4.3.6", }, }, @@ -94,13 +100,14 @@ "@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/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", @@ -132,17 +139,28 @@ "@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", }, }, + "packages/terminal": { + "name": "@executablemd/terminal", + "version": "0.12.1", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/node": "0.2.5", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + "starfx": "0.16.1", + }, + }, "packages/test-agent": { "name": "@executablemd/test-agent", "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:*", @@ -160,9 +178,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 +196,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0", }, }, @@ -183,7 +204,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 +233,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 +269,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=="], @@ -327,6 +347,8 @@ "@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"], + "@executablemd/terminal": ["@executablemd/terminal@workspace:packages/terminal"], + "@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"], "@executablemd/test-support": ["@executablemd/test-support@workspace:packages/test-support"], @@ -585,6 +607,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=="], @@ -753,6 +777,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -929,6 +955,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -937,6 +965,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=="], @@ -957,6 +987,8 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "starfx": ["starfx@0.16.1", "", { "dependencies": { "effection": "^4", "immer": "^11.1.3", "reselect": "^5.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18", "react-redux": "^9" }, "optionalPeers": ["react", "react-dom", "react-redux"] }, "sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ=="], + "streamx": ["streamx@2.28.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1049,6 +1081,8 @@ "@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=="], diff --git a/deno.lock b/deno.lock index 7772922ea..9b3d7f63d 100644 --- a/deno.lock +++ b/deno.lock @@ -107,6 +107,7 @@ "npm:remend@^1.2.2": "1.3.0", "npm:rollup@^4.55.1": "4.62.2", "npm:semver@^7.8.5": "7.8.5", + "npm:starfx@0.16.1": "0.16.1_react@19.2.0_react-dom@19.2.0__react@19.2.0", "npm:tailwindcss@^4.1.10": "4.3.3", "npm:tsx@^4.19.0": "4.23.1", "npm:typescript@5": "5.9.3", @@ -2755,6 +2756,9 @@ "html-void-elements@3.0.0": { "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" }, + "immer@11.1.18": { + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" + }, "immutable@5.1.5": { "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==" }, @@ -3553,6 +3557,9 @@ "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "reselect@5.3.0": { + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==" + }, "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, @@ -3656,6 +3663,20 @@ "escape-string-regexp" ] }, + "starfx@0.16.1_react@19.2.0_react-dom@19.2.0__react@19.2.0": { + "integrity": "sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ==", + "dependencies": [ + "effection", + "immer", + "react", + "react-dom", + "reselect" + ], + "optionalPeers": [ + "react", + "react-dom" + ] + }, "streamx@2.28.0": { "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dependencies": [ @@ -4025,6 +4046,7 @@ "npm:remark@15", "npm:remend@^1.2.2", "npm:semver@^7.8.5", + "npm:starfx@0.16.1", "npm:tsx@^4.19.0", "npm:typescript@5", "npm:unist-util-select@5", @@ -4129,6 +4151,19 @@ ] } }, + "packages/terminal": { + "dependencies": [ + "npm:starfx@0.16.1" + ], + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/node@0.2.5", + "npm:effection@4.1.0", + "npm:starfx@0.16.1" + ] + } + }, "packages/test-agent": { "dependencies": [ "npm:@agentclientprotocol/sdk@1.3.0", diff --git a/package.json b/package.json index d17b1b1fe..14f21370a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "remark": "15", "remend": "^1.2.2", "semver": "^7.8.5", + "starfx": "0.16.1", "zod": "^4.3.6", "unist-util-select": "^5", "mdast-util-to-string": "^4" @@ -57,6 +58,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/test-support": "workspace:*", "@executablemd/testing": "workspace:*", diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba90..c68e7bd1f 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -87,8 +87,8 @@ import { AgentSessionRecoveryRequired, cwd, ExecutableObservationError, - nativeLaunch, } from "@executablemd/runtime"; +import { nativeLaunch } from "@executablemd/terminal"; import type { AgentSessionCoordinator, AgentSessionKey, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 7c3df4976..fe9e42850 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, reserveTerminal } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; +import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; import { diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 4fdb23ab3..3b1241134 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -21,7 +21,8 @@ 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/terminal/posix"; 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 diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 46464641c..7837bef20 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/terminal/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/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5d512e000..8b47cf4e4 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/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import { Agent, diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..867001e90 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,13 @@ 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"; +// A host reaches the whole terminal-grid capability through +// `installTerminalGridProfile`; the contracts a provider composes against live +// in `@executablemd/terminal` and are imported from there. +export { installTerminalGridProfile } from "./src/terminal/profile.ts"; +export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; +export { createTerminalGridJournal } from "./src/terminal/journal.ts"; +export type { GridIdentity } from "./src/terminal/journal.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/package.json b/packages/core/package.json index 5962c603a..a5024ae5c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 043d02b93..c656e64f1 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/terminal"; 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..3be70ec66 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/terminal"; import { Agent, AGENT_API } from "./agent-api.ts"; import type { AgentApi, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 933560f28..52a40c4ea 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,8 +13,8 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +import { createContext, ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; +import type { Context, Operation, Result } from "effection"; import type { FunctionComponent, Segment, @@ -66,8 +66,10 @@ import { terminalTitleMissingMessage, } from "./structural-rules.ts"; import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; -import { terminalGridLayout } from "./terminal-grid.ts"; -import type { PlacedPane } from "./terminal-grid.ts"; +import { terminalGridLayout, useTerminalCellUI } from "@executablemd/terminal"; +import type { PlacedCell, TerminalCellUI, TerminalCellWork } from "@executablemd/terminal"; +import { appendTerminalCellOutput, terminalGrid } from "@executablemd/terminal/lifecycle"; +import { createTerminalGridJournal } from "./terminal/journal.ts"; import { asBindingViolation, asExpressionViolation, @@ -143,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"; @@ -891,6 +893,11 @@ function* expandListSegments( // Read once: `` publishes its frame for the nested call that expands // its body, so the frame ambient here cannot change while this list runs. const loop = yield* ActiveLoop.get(); + // Whether what this list renders is a terminal cell's own output. Compared by + // identity, because that is the question: these segments reach the cell only + // if they are landing in the array the cell publishes from. + const region = yield* TerminalCellOutput.get(); + const publisher = region !== undefined && region.owner === result ? region : undefined; for (const [index, segment] of segments.entries()) { // A checked failure the document did not authorize ends the work: whatever @@ -1185,7 +1192,14 @@ function* expandListSegments( if (segment.name === "Terminal.Grid") { // No raise() here, like the branches above: expandTerminalGrid // reports every error it creates. - yield* expandTerminalGrid(segment, result); + yield* expandTerminalGrid(segment, result, { + parentMeta, + parentProps, + hideSet, + path: elementPath, + checkedFailures, + authority, + }); break; } @@ -1417,6 +1431,14 @@ function* expandListSegments( } } + // One completed output boundary, at whatever depth this list is running: + // a branch, a loop iteration, a component body and projected content all + // append into this same owner, so the cell's state has the bytes before the + // next authored effect anywhere beneath it begins. + if (publisher !== undefined) { + yield* publisher.publish(); + } + // A `` anywhere below this segment ends the iteration here: what the // list already produced stands, and nothing after it expands. if (loop?.broken) { @@ -2106,7 +2128,21 @@ function* resolveStructuralProp( * does, which is what makes the refusal a closed one rather than a partial grid * left behind. */ -function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { +/** 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* expandTerminalGrid( + segment: ComponentElement, + owner: Segment[], + site: GridSite, +): Operation { const structure = terminalGridStructure(segment); if (structure.violations.length > 0) { for (const violation of structure.violations) { @@ -2130,7 +2166,7 @@ function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Opera return; } - const placed: PlacedPane[] = []; + const placed: PlacedCell[] = []; for (const pane of structure.panes) { const title = yield* resolvePaneTitle(pane); if (!title.ok) { @@ -2141,23 +2177,188 @@ function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Opera } const layout = terminalGridLayout(columns.value, placed); - owner.push( - yield* raise({ - type: "error", - message: positioned(noTerminalProviderMessage(), segment), - source: "Terminal.Grid", - // The grid the author asked for, carried beside the sentence so an - // assertion is about the layout that was derived rather than about the - // wording of a refusal. - cause: { - layout: { - columns: layout.columns, - rows: layout.rows, - cells: layout.cells.map((cell) => ({ ...cell })), - }, - }, - }), + // The grid renders nothing into the document: what a cell shows belongs to + // that cell, 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 { + // One fresh live identity per authored position, minted here and nowhere + // else. It is never retained, replayed, diagnosed, or copied into a native + // or Agent request: array position is what a resumed run reads. + const cells: TerminalCellWork[] = structure.panes.map((pane, index) => ({ + cellId: Symbol(`terminal-cell:${index}`), + operation: cellWork(pane, layout.cells[index]!.title, index, site), + })); + const journal = createTerminalGridJournal(identity, cells.length); + + // The grid is one computational unit: acquiring the resource starts it + // beneath this expansion, and awaiting the task it returns is how this + // expansion learns what the grid retained. Releasing it early cancels and + // joins the same work rather than detaching it. + const running = yield* terminalGrid(layout, cells, journal); + const retained = yield* running; + + const failed = retained.cells.find((cell) => cell.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(terminalGridError(segment, failed.reason))); + } + } catch (error) { + owner.push( + yield* raise( + terminalGridError(segment, error instanceof Error ? error.message : String(error)), + ), + ); + } +} + +/** + * What one authored cell does once its terminal exists. + * + * Constructed lazily and interpreted exactly once, by the terminal lifecycle, + * with that cell's issued handle and output sink installed in its scope. Making + * one performs no expansion, shell, Agent, provider or journal work at all. + * + * A self-closing cell runs the host's default shell; a paired cell 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 cell. Its `` cannot + * reach a loop outside the grid, its `` cannot claim an enclosing body, + * and a checked failure settles the cell rather than poisoning the root or a + * sibling. + */ +function cellWork( + pane: TerminalPane, + title: string, + position: number, + site: GridSite, +): Operation { + if (pane.form === "self-closing") { + return (function* (): Operation { + const cell = yield* requireCellUI(position, title); + // The shell is this cell's one terminal activity, and acquiring it is + // what makes the cell ready — the same boundary a paired cell's content + // crosses, rather than a second way in. + const outcome = yield* cell.shell(); + if (outcome.signal !== undefined) { + throw new Error(`terminal ${position} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `terminal ${position} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + })(); + } + + return scoped(function* () { + // A cell 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); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this cell makes is visible to later work in this cell and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + yield* expandCellContent(pane, site); + }); +} + +/** + * The issued handle for the cell being interpreted. + * + * Absent means this operation is running outside the cell scope that issued it, + * which is a lifecycle defect rather than something an author can write. + */ +function* requireCellUI(position: number, title: string): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error( + `terminal ${position} ("${title}") ran outside the cell scope that issued its handle`, + ); + } + return cell; +} + +/** + * Expand a paired cell's content into the cell's own output owner. + * + * The owner is what makes the appends land where the bytes do. Every construct + * that renders into the document — a branch, a loop iteration, a component + * body, projected content — appends into this same array, so installing it as + * the cell's output region is what puts a boundary at each of those rather than + * only between the cell's direct children. + */ +function* expandCellContent(pane: TerminalPane, site: GridSite): Operation { + const shown: Segment[] = []; + const region = createCellOutputRegion(shown); + yield* TerminalCellOutput.set(region); + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + // A counter of its own. Cells 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 cell's own ledger: a checked failure settles this cell and cannot + // reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a cell cannot claim one + // outside the grid. + undefined, ); + yield* region.publish(); +} + +/** + * Where a paired terminal cell's rendered output goes, and how much of it has + * been published. + * + * The region is identified by the array it owns rather than by being ambient. + * A region that produces a binding, a value or a string expands into a private + * buffer that never merges into the cell, and a buffer that is not this owner + * publishes nothing — its text reaches the cell later, as one segment its + * caller appends, and is published at that boundary instead. + */ +interface TerminalCellOutputRegion { + readonly owner: Segment[]; + /** Append whatever the owner has gained since the last append. */ + publish(): Operation; +} + +const TerminalCellOutput: Context = createContext< + TerminalCellOutputRegion | undefined +>("core.terminalCellOutput", undefined); + +function createCellOutputRegion(owner: Segment[]): TerminalCellOutputRegion { + let published = 0; + return { + owner, + *publish() { + const rendered = renderSegments(owner); + if (rendered.length <= published) { + return; + } + const fresh = rendered.slice(published); + // Recorded before the append is awaited, so a boundary reached while this + // one is still committing cannot send the same bytes twice. + published = rendered.length; + yield* appendTerminalCellOutput(fresh); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2172,15 +2373,6 @@ function* resolvePaneTitle(pane: TerminalPane): Operation> { return terminalTitle(value.value); } -/** What a complete grid says on a host where nothing can open one. */ -function noTerminalProviderMessage(): string { - return ( - "no terminal provider opened this grid. A host installs the terminal-grid capability " + - "explicitly, and this one installs none, so no pane expanded its content and no default " + - "shell started." - ); -} - function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/src/terminal-grid.ts b/packages/core/src/terminal-grid.ts deleted file mode 100644 index 59a08a920..000000000 --- a/packages/core/src/terminal-grid.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 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. - */ - -import type { TerminalPane } from "./structural-rules.ts"; - -/** One pane, placed. */ -export interface TerminalGridCell { - /** 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: TerminalPane["form"]; -} - -/** The complete grid one `` asked for. */ -export interface TerminalGridLayout { - 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 TerminalGridCell[]; -} - -/** One pane's placeable facts, once its title has been resolved. */ -export interface PlacedPane { - readonly title: string; - readonly form: TerminalPane["form"]; -} - -/** - * 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 terminalGridLayout( - columns: number, - panes: readonly PlacedPane[], -): TerminalGridLayout { - 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/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 000000000..13608952f --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,341 @@ +/** + * Core's answer to the neutral terminal journal boundary (spec §6.21 + * Durability and replay). + * + * The terminal package defines three durable boundaries and calls them; this + * is where they become entries in *this* document's journal, described from the + * source position that wrote the grid. Only provider-neutral layouts, outcomes + * and lazy operations cross the boundary, so the terminal package imports + * nothing of core's and core supplies nothing of a provider's. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or terminal 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 { withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { + createDurableOperation, + DurableContext, + durableSpawn, + ephemeral, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { + RetainedCellOutcome, + RetainedGrid, + RetainedGridLayout, + TerminalGridJournal, +} from "@executablemd/terminal"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; + +/** 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; +} + +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 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): RetainedGridLayout | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "cells"])) { + return undefined; + } + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.cells; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const cells: RetainedGridLayout["cells"][number][] = []; + for (const [index, entry] of list.entries()) { + const cell = readCell(entry, index, columns); + if (cell === undefined) { + return undefined; + } + cells.push(cell); + } + // The rows a grid claims have to be the rows its cells need, or the record + // describes a grid nothing could have derived. + if (cells.length === 0 || Math.ceil(cells.length / columns) !== rows) { + return undefined; + } + return { columns, rows, cells }; +} + +/** One retained cell, checked against the position it sits at. */ +function readCell( + value: unknown, + index: number, + columns: number, +): RetainedGridLayout["cells"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["title", "form", "row", "column"])) { + return undefined; + } + const { title, form, row, column } = record; + 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 array index + // and the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { title, form, row, column }; +} + +function readCellOutcome(value: unknown): RetainedCellOutcome | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["status", "reason"])) { + return undefined; + } + const { status, reason } = record; + if (status !== "succeeded" && status !== "failed" && status !== "closed") { + return undefined; + } + if (typeof reason !== "string") { + return undefined; + } + return { status, reason }; +} + +function readGrid(value: unknown): RetainedGrid | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["layout", "close", "cells"])) { + return undefined; + } + const layout = readLayout(record.layout); + const { close } = record; + if (layout === undefined || (close !== "reader" && close !== "failed")) { + return undefined; + } + if (!Array.isArray(record.cells) || record.cells.length !== layout.cells.length) { + return undefined; + } + const cells: RetainedCellOutcome[] = []; + for (const entry of record.cells) { + const outcome = readCellOutcome(entry); + if (outcome === undefined) { + return undefined; + } + cells.push(outcome); + } + return { layout, close, cells }; +} + +/** 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: RetainedGridLayout, derived: RetainedGridLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.cells.length !== derived.cells.length) { + return `${held.cells.length} terminals rather than ${derived.cells.length}`; + } + for (const [index, cell] of derived.cells.entries()) { + const before = held.cells[index]!; + if (before.title !== cell.title) { + return `terminal ${index} titled "${before.title}" rather than "${cell.title}"`; + } + if (before.form !== cell.form) { + return `terminal ${index} written ${before.form} rather than ${cell.form}`; + } + if (before.row !== cell.row || before.column !== cell.column) { + return ( + `terminal ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${cell.row}, column ${cell.column}` + ); + } + } + return undefined; +} + +/** + * Hand out the right to allocate a durable child, in authored order. + * + * The lifecycle starts every cell concurrently, so the order its operations + * *reach* this adapter is whatever the scheduler chose. A durable child's + * identity may not depend on that, so each position waits for the one before it + * to have allocated and then releases the one after — which makes the ordinals + * the journal records the authored ones, on every run. + */ +interface OrderedTurns { + wait(position: number): Operation; + done(position: number): void; +} + +function createOrderedTurns(count: number): OrderedTurns { + const gates = Array.from({ length: count }, () => withResolvers()); + gates[0]?.resolve(); + return { + wait: (position) => gates[position]!.operation, + done: (position) => gates[position + 1]?.resolve(), + }; +} + +/** + * Build the journal one grid expansion retains through. + * + * The adapter closes over the source description, so the terminal lifecycle + * names nothing but an array position and the lazy work at it. + */ +export function createTerminalGridJournal( + identity: GridIdentity, + cellCount: number, +): TerminalGridJournal { + const turns = createOrderedTurns(cellCount); + + /** + * Take a turn, allocate the durable child, and hand the turn straight on. + * + * Only the allocation is ordered. The child runs beside its siblings from the + * moment it exists, so holding the turn any longer would run the grid one + * cell at a time. + */ + function* allocate( + position: number, + operation: Operation, + ): Operation> { + try { + yield* turns.wait(position); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(operation); + }); + } finally { + // Released however this leaves — allocated, refused, or cancelled — so + // the position after this one is never waiting on a turn nobody holds. + turns.done(position); + } + } + + return { + *reconcileLayout(layout: RetainedGridLayout): Operation { + if (!(yield* durable())) { + return; + } + const description = describe(identity); + const stored = yield* append(description, layout); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run ` + + "the document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, layout); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this terminal 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 }, + ); + } + }, + + retainGrid(operation: Operation): Operation { + return (function* (): Operation { + if (!(yield* durable())) { + return yield* operation; + } + // A completed grid short-circuits here: the child's workflow never + // runs, so no provider is contacted, no store is created, no cell + // content expands and no shell starts. + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(operation); + }); + return parsed(readGrid(yield* task), "terminal grid"); + })(); + }, + + retainCell( + position: number, + operation: Operation, + ): Operation { + return (function* (): Operation { + if (!(yield* durable())) { + return yield* operation; + } + const task = yield* allocate(position, operation); + return parsed(readCellOutcome(yield* task), "terminal cell outcome"); + })(); + }, + }; +} + +/** + * What a record said, or a refusal. + * + * A record that cannot be read in full is not read in part: a resumed run that + * believed half of one would present a grid nobody authored. + */ +function parsed(value: T | undefined, what: string): T { + if (value === undefined) { + throw new StaleInputError( + `The journal's record of this ${what} is not one. Re-run the document from the start ` + + "rather than resuming from this journal.", + ); + } + return value; +} diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 000000000..fd1fe64a9 --- /dev/null +++ b/packages/core/src/terminal/profile.ts @@ -0,0 +1,61 @@ +/** + * Opening one terminal installation for a live document. + * + * A grid needs two things before it can be durable at all: this execution's + * installation — which mints the generation every request belongs to, and adds + * the requests it issues to the private lookup presentation converges through — + * and a provider installed against the presentation function that installation + * builds. 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 { installTerminalProvider } from "@executablemd/terminal"; +import { useTerminalInstallation } from "@executablemd/terminal/lifecycle"; + +import { Execution } from "../execute.ts"; + +export interface TerminalGridProfileOptions { + /** + * 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 terminal-grid profile for the executions composed under it. + * + * Presentation 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 installTerminalGridProfile( + options: TerminalGridProfileOptions = {}, +): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const present = yield* useTerminalInstallation(); + if (options.provider !== undefined) { + yield* installTerminalProvider( + options.provider, + { label: options.label ?? options.provider }, + present, + ); + } + yield* next(request); + }); + }, + }); +} diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index cc87cbfa1..5116ae78a 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -33,14 +33,10 @@ 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, - installControlledLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - nativeLaunch, - useHostFiles, -} from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch } from "@executablemd/terminal"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 76c440cba..532be8eea 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -23,7 +23,7 @@ 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 { terminalGridLayout } from "../src/terminal-grid.ts"; +import { terminalGridLayout } from "@executablemd/terminal"; import type { Json, Segment } from "../src/types.ts"; interface GridRun { @@ -130,31 +130,6 @@ const PANE_BODY = [ ].join("\n"); describe("Tier TG — the grid grammar", () => { - it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { - const run = yield* runGrid( - [ - "", - 'Instructions.', - '', - "", - ].join("\n"), - ); - - // The grammar accepted it, so the run reached the one thing this build - // cannot do — and stopped there. - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, - ], - }, - }); - }); - it("TG1: refuses an unknown prop and `as` on the grid", function* () { const unknown = yield* runGrid( '', @@ -330,52 +305,6 @@ describe("Tier TG — structural placement", () => { reachedNothing(alone); reachedNothing(buried); }); - - it("TG2: treats whitespace between panes as nothing at all", function* () { - const run = yield* runGrid( - [ - "", - "", - ' ', - "", - ' ', - "", - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, - ], - }, - }); - }); - - it("TG2: a complete grid refuses before any pane body or default shell", function* () { - const run = yield* runGrid( - [ - "", - '', - "", - PANE_BODY, - "", - '', - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); - // The pane held a component and a command; neither was reached, and the - // grid rendered nothing of its own. - reachedNothing(run); - expect(run.output).toContain("no terminal provider opened this grid"); - }); }); describe("Tier TG — row-major layout", () => { @@ -446,60 +375,6 @@ describe("Tier TG — row-major layout", () => { 1, 1, 1, 2, 2, ]); }); - - it("TG4: an executed grid derives those same positions", function* () { - const run = yield* runGrid( - [ - "", - '', - '', - '', - '', - '', - "", - ].join("\n"), - ); - - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 3, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, - { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, - { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, - ], - }, - }); - }); - - it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { - const run = yield* runGrid( - [ - "", - 'first', - '', - 'third', - "", - ].join("\n"), - ); - - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each one was written in travels with it. - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 2, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, - ], - }, - }); - }); }); /** Panes that differ only in count, for a row about rows. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts new file mode 100644 index 000000000..27a9b587a --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,2165 @@ +/** + * Tier TG — a terminal grid written in a document (spec §6.21, + * architecture.md §Interactive terminal grids, §Atomic presentation and + * settlement, §Durability and replay). + * + * These rows are about what core contributes: the authored structure it + * resolves, the lazy cell work it constructs, the journal it describes, and + * what a document sees when a grid runs, fails, closes or replays. The + * provider-neutral lifecycle those rows run on is proved in + * `packages/terminal/tests/terminal-grid.test.ts`. + * + * 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 showed too early and one that showed on + * time take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a cell becomes ready because work in it acquired a terminal + * activity, never because it got far enough. `` is what a suite + * writes to be that something, and it reaches the cell through the same + * contextual handle a real `` will. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + ensure, + Err, + 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 { + registerTerminalProvider, + reserveTerminal, + TerminalGrids, + useTerminalCellUI, +} from "@executablemd/terminal"; +import type { + TerminalActivity, + TerminalCellUI, + TerminalGridRequest, + TerminalGridState, + TerminalShellOutcome, +} from "@executablemd/terminal"; +import { + controlledTerminalProvider, + installControlledLauncher, + terminalProviderLog, +} from "@executablemd/terminal/test"; +import type { + ControlledProviderOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "@executablemd/terminal/test"; +import { useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import type { PresentTerminalGrid } from "@executablemd/terminal/lifecycle"; + +import { Component } from "../src/component-api.ts"; +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +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: TerminalGridRequest[]; + /** What each cell displayed, by authored position. */ + shown: Map; + /** Everything the provider's host 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: TerminalProviderResources; +} + +/** + * 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); + }); +} + +/** An outcome that is already settled. */ +function done(value: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return value; + })(); +} + +/** + * An activity whose child spawned and is already finished. + * + * The ordinary case a row wants when it only needs a cell to be ready: + * acquired at once, settled at once. + */ +function startsAndSettles(onStart?: () => void): TerminalActivity { + return resource(function* (provide) { + onStart?.(); + yield* provide(done({ exitCode: 0 })); + }); +} + +/** + * An activity whose child never spawned. + * + * It fails during acquisition, which is before a cell could be ready — the + * shape of a preparation or spawn failure rather than of work that ran. + */ +function neverStarts(onAttempt?: () => void): TerminalActivity { + return resource(function* () { + onAttempt?.(); + throw new Error("this activity's child never spawned"); + }); +} + +/** + * What the cell-handle rows read. + * + * Each of these is driven from inside a real cell, through the same + * `TerminalCellUI` a `` reaches, and read back off an ordered + * record rather than inferred. + */ +interface CellProbe { + /** Refusals the document's own work collected, in the order they happened. */ + readonly refusals: string[]; + /** Ordered marks: which cell entered and left its interactive work. */ + readonly marks: string[]; + /** Cell handles kept past their grid on purpose. */ + readonly kept: TerminalCellUI[]; + /** Announce that this cell is inside its interactive body. */ + entered(): void; + /** Settles once every cell this probe expects is inside one at the same time. */ + overlapped(): Operation; +} + +function cellProbe(expected = 2): CellProbe { + const all = withResolvers(); + let inside = 0; + return { + refusals: [], + marks: [], + kept: [], + entered() { + inside += 1; + if (inside >= expected) { + all.resolve(); + } + }, + overlapped: () => all.operation, + }; +} + +function refusalOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** The cell handle the current work is running in, or a failed row. */ +function* cellHandle(name: string): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error(`<${name} /> is written inside a cell`); + } + return cell; +} + +/** The controlled interactive child, and a tripwire. */ +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, + afterShow: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, + probe: CellProbe = cellProbe(), +): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const cell = yield* cellHandle("Interactive"); + yield* cell.shell(); + 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 ""; + }, + }, + { + // Enters its cell's interactive body and stays there until every other + // cell is inside one too. Two cells that contended could never both be + // inside, so the wait is the proof; the deadline only turns a regression + // into a failed assertion instead of a hung suite. + name: "Concurrent", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + *fn(props) { + const cell = yield* cellHandle("Concurrent"); + const mark = String(props.mark); + probe.marks.push(`enter:${mark}`); + probe.entered(); + yield* cell.shell(); + probe.marks.push(`leave:${mark}`); + return ""; + }, + }, + { + // One cell, asked for two interactive operations at once and then for a + // second one after the first settled. + name: "Overlapping", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const cell = yield* cellHandle("Overlapping"); + yield* spawn(function* () { + // Raced against the first shell deliberately: whichever of the two + // reaches admission second is the overlapping one, and it is refused + // rather than queued. + try { + yield* cell.shell(); + probe.marks.push("second entered"); + } catch (error) { + probe.refusals.push(refusalOf(error)); + } + }); + yield* cell.shell(); + // The cell is free again: one owner at a time is not one owner ever. + yield* cell.shell(); + probe.marks.push("sequential"); + // Kept deliberately, so a row can ask what it grants after the grid has + // closed. + probe.kept.push(cell); + return ""; + }, + }, + { + // Acquires one activity that spawns and settles in the same breath. + name: "SettlesAtOnce", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const cell = yield* cellHandle("SettlesAtOnce"); + yield* cell.shell(); + probe.marks.push("started and settled"); + return ""; + }, + }, + { + // Interactive work that never acquires an activity: doing work is not + // starting. + name: "Quiet", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const cell = yield* cellHandle("Quiet"); + probe.marks.push("tried to start"); + yield* cell.shell(); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const cell = yield* cellHandle("Slow"); + yield* sleep(25); + slowMarks.push("ready:slow"); + yield* cell.shell(); + return ""; + }, + }, + { + // Holds the cell 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 cell is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); + yield* suspend(); + return ""; + }, + }, + { + // Waits until the grid has been shown, so a cell 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: "AfterShow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterShow(); + return ""; + }, + }, + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); +} + +/** What a row asks of the controlled provider, plus the ways it may misuse one. */ +type ProviderOptions = ControlledProviderOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the presentation function for a later, unrouted use. */ + readonly capture?: (present: PresentTerminalGrid) => void; +}; + +/** + * Register a controlled provider that presents through the function it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives presentation + * as an argument, supplies a provider of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider(options: ProviderOptions = {}): Operation { + return registerTerminalProvider("controlled", function* (_settings, present) { + options.capture?.(present); + const provider = controlledTerminalProvider(options); + yield* TerminalGrids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + yield* present(options.substitute?.(request) ?? request, provider); + return undefined; + }, + }, + { at: "min" }, + ); + }); +} + +/** 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; + grid?: ProviderOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; + /** What the cell-handle rows record. */ + probe?: CellProbe; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + 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 ?? [], + undefined, + undefined, + undefined, + undefined, + options.probe, + ); + yield* installControlledLauncher(); + + // The reader stays until every cell has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a cell + // rendered must not race the close that cancels it. + const settled = withResolvers(); + const supplied = options.grid ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + *render(state) { + if (supplied.render) { + yield* supplied.render(state); + } + if (state.cells.every((cell) => cell.status !== "starting" && isSettled(cell.status))) { + settled.resolve(); + } + }, + }); + } + yield* installTerminalGridProfile(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, + }; + }); +} + +function isSettled(status: string): boolean { + return status === "succeeded" || status === "failed" || status === "closed"; +} + +/** 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, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, cells: string[]): string { + return [ + ``, + ...cells, + "", + "", + // 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?: ControlledProviderOptions["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 cell reports a failure. + * + * A cell that fails *after* it is shown is contained as that cell's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the cell as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Let the reader leave only once a `` cell is armed. */ + closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; + /** Position of a shell that starts, waits for the grid, then exits badly. */ + shellFailsAfterShow?: number; + /** Holds a `` cell's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** + * Called once a cell'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: TerminalProviderResources) => 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 cells must have settled before the run is interrupted. + * + * A cell's status is published only after its durable child has returned, + * so this is also how many cell Closes the journal is known to hold. + */ + settled?: number; + } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `shown` + // says a grid opened on this run. `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does. `cellsSettled` + // says the cell children the row cares about have written their 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 shown = withResolvers(); + const pastGrid = withResolvers(); + const cellsSettled = withResolvers(); + if ((options.settled ?? 0) === 0) { + cellsSettled.resolve(); + } + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); + const cellFailed = withResolvers(); + // Resolved once a `` cell 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(); + } + }, + () => shown.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 + ? () => cellFailed.operation + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterShow !== undefined + ? { + shell: (position: number) => + resource>(function* (provide) { + // Acquired, so the cell is ready and the grid is shown; the + // failure is in the settlement afterwards, which is the + // failure a grid contains as a cell status. + if (position !== options.shellFailsAfterShow) { + yield* provide(done({ exitCode: 0 })); + return; + } + yield* provide( + (function* (): Operation { + yield* shown.operation; + return { exitCode: 1 }; + })(), + ); + }), + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield + *onPrepare(asked) { + requests.push(asked); + }, + // deno-lint-ignore require-yield + *onShow() { + shown.resolve(); + }, + // deno-lint-ignore require-yield + *render(state) { + for (const cell of state.cells) { + if (cell.status === "failed") { + cellFailed.resolve(); + } + } + const settledCells = state.cells.filter((cell) => isSettled(cell.status)).length; + if (settledCells >= (options.settled ?? 0)) { + cellsSettled.resolve(); + } + }, + }); + } + yield* installTerminalGridProfile(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 been shown and the cell + // 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* shown.operation; + yield* cellsSettled.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: Err(new Error("interrupted")), + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + errors, + journal: yield* stream.readAll(), + live: log.live, + }; + }); +} + +const CELLS = [ + 'left', + '', +]; + +describe("Tier TG — presenting a grid from a document", () => { + const GRID = ["", ...CELLS, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, {}); + 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* installTerminalGridProfile({ 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 forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what presentation reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + cells: request.cells.map((cell) => ({ ...cell })), + }), + }); + yield* installTerminalGridProfile({ 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(forged.outcome.ok).toBe(false); + expect(forged.outcome.ok ? "" : forged.outcome.error.message).toContain( + "this grid request is not live", + ); + expect(forged.ran).toEqual([]); + }); + + 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* installTerminalGridProfile({ 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("TA7: two cells are interactive at the same time", function* () { + const dir = yield* useDir(); + const probe = cellProbe(2); + const gateBoth = withResolvers(); + let inside = 0; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + // Acquired: this cell is holding its activity. Settlement waits + // for every other cell to be holding one too. Cells that + // contended could never all be here at once. + inside += 1; + if (inside >= 2) { + gateBoth.resolve(); + } + yield* provide( + (function* (): Operation { + const together = yield* race([ + (function* (): Operation { + yield* gateBoth.operation; + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${together}`); + return { exitCode: 0 }; + })(), + ); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(probe.marks.filter((mark) => mark === "together:true")).toHaveLength(2); + // And both were holding before either let go. + expect(probe.marks.indexOf("enter:b")).toBeLessThan(probe.marks.indexOf("leave:a")); + }); + + it("TA8: one cell refuses overlapping work, and admits the next after it settles", function* () { + const dir = yield* useDir(); + const probe = cellProbe(1); + const held = withResolvers(); + let first = true; + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + // The first activity holds the cell until the overlapping one has + // been refused, so the refusal is what the row reads rather than + // a schedule it hoped for. + if (first) { + first = false; + yield* provide( + (function* (): Operation { + yield* held.operation; + return { exitCode: 0 }; + })(), + ); + return; + } + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render(state) { + if (probe.refusals.length > 0) { + held.resolve(); + } + void state; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(probe.refusals).toHaveLength(1); + expect(probe.refusals[0]).toContain("one owns a cell terminal at a time"); + // The refused operation never ran, and the one written after the first + // settled did: a cell has one owner at a time, not one owner ever. + expect(probe.marks).not.toContain("second entered"); + expect(probe.marks).toContain("sequential"); + }); + + it("TA9: a cell handle kept past its grid admits nothing", function* () { + const dir = yield* useDir(); + const probe = cellProbe(1); + const held = withResolvers(); + let first = true; + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + if (first) { + first = false; + yield* provide( + (function* (): Operation { + yield* held.operation; + return { exitCode: 0 }; + })(), + ); + return; + } + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render() { + if (probe.refusals.length > 0) { + held.resolve(); + } + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + const kept = probe.kept[0]; + expect(kept).toBeDefined(); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.shell(); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("has stopped admitting"); + }); + + it("TA10: a child that spawns and settles at once is both ready and settled", function* () { + const dir = yield* useDir(); + const probe = cellProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // Acquired and settled in the same breath: the grid was shown rather than + // waiting for a cell that had already finished. + expect(run.outcome.ok).toBe(true); + expect(probe.marks).toContain("started and settled"); + expect(run.events.some((event) => event.startsWith("show:0:"))).toBe(true); + }); + + it("TA11: an activity that fails before acquisition never makes a cell ready", function* () { + const dir = yield* useDir(); + const probe = cellProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe, grid: { shell: () => neverStarts() } }, + ); + + // The cell owned its terminal and tried. Neither is starting. + expect(probe.marks).toContain("tried to start"); + // The cell fails with the reason its activity could not start, rather than + // with the generic "never started anything" — a spawn that failed says why. + expect(failureOf(run)).toContain("this activity's child never spawned"); + expect(run.events.some((event) => event.startsWith("show:"))).toBe(false); + expect(run.events).toContain("destroy:0"); + }); +}); + +/** + * What every completed journey must be able to say. + * + * The provider's host is released exactly once — not zero times, and not twice — + * and nothing it handed out is still held. Both halves matter: a count alone + * would pass for a run that released one host and stranded another. + */ +function expectReleasedOnce(run: DocumentRun, generation = 0): void { + expect(run.events.filter((event) => event === `destroy:${generation}`)).toEqual([ + `destroy:${generation}`, + ]); + expect(run.live).toEqual({ grids: 0, shown: 0, activities: 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); + // Position is identity: no ordinal, index or key duplicates it. + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + cells: [ + { title: "One", row: 0, column: 0, form: "self-closing" }, + { title: "Two", row: 0, column: 1, form: "self-closing" }, + { title: "Three", row: 1, column: 0, form: "self-closing" }, + { title: "Four", row: 1, column: 1, form: "self-closing" }, + { title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + // A grid that succeeded released its provider's host once, holding nothing. + expectReleasedOnce(run); + }); + + it("TG4: duplicate titles stay valid, and identity is the position", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]?.cells).toEqual([ + { title: "Agent", row: 0, column: 0, form: "paired" }, + { title: "Agent", row: 0, column: 1, form: "self-closing" }, + { title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG7: root output is flushed before the grid, and cell text stays in its cell", function* () { + const dir = yield* useDir(); + const flushed: string[] = []; + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + { + grid: { + // 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 cell's own text went to that cell. + 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 cell + // 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("TG20: a cell's output is committed before the effect written after it", function* () { + const dir = yield* useDir(); + // The shell records what the aggregate already said about its own cell at + // the moment it was asked to start. Text written before `` + // must already be there: an append that waited for the next authored effect + // would show empty content here. + const contentAtLaunch: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + "first paragraph", + "", + "", + "", + "second paragraph", + "", + "", + "", + ].join("\n"), + { + grid: { + shell: () => + resource>(function* (provide) { + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render(state) { + for (const cell of state.cells) { + if (cell.status === "launching") { + contentAtLaunch.push(cell.content); + } + } + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(contentAtLaunch).toHaveLength(1); + expect(contentAtLaunch[0]).toContain("first paragraph"); + // And what came after is not there yet: the append is per boundary, not one + // dump at the end. + expect(contentAtLaunch[0]).not.toContain("second paragraph"); + // The complete output is what the cell finally displays. + expect(run.shown.get(0)).toContain("first paragraph"); + expect(run.shown.get(0)).toContain("second paragraph"); + }); + + it("TG20: output nested inside one structural child reaches state before the action beside it", function* () { + const dir = yield* useDir(); + // Every snapshot the renderer worked through, and what the newest of them + // said at the moment the provider was asked for a terminal. Read off the + // renderer rather than the store, so this is the screen the action waited + // for rather than a value beside it. + const rendered: TerminalGridState[] = []; + const convergedContent: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "before the action", + "", + "", + "", + "after the action", + "", + "", + "outside the branch", + "", + "", + "", + ].join("\n"), + { + grid: { + // deno-lint-ignore require-yield + *render(state) { + rendered.push(state); + }, + shell: () => + resource>(function* (provide) { + const applied = rendered[rendered.length - 1]; + convergedContent.push(applied?.cells[0]?.content ?? ""); + yield* provide(done({ exitCode: 0 })); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(convergedContent).toHaveLength(1); + // The output written before the action inside the same branch is already + // part of the desired screen the action converged through. A publication + // that waited for the whole `` to finish would have committed none of + // it by now. + expect(convergedContent[0]).toContain("before the action"); + // And nothing written after it is, at either depth. + expect(convergedContent[0]).not.toContain("after the action"); + expect(convergedContent[0]).not.toContain("outside the branch"); + // All three reach the cell in the end, in authored order. + const shown = run.shown.get(0) ?? ""; + expect(shown).toContain("before the action"); + expect(shown).toContain("after the action"); + expect(shown).toContain("outside the branch"); + expect(shown.indexOf("before the action")).toBeLessThan(shown.indexOf("after the action")); + expect(shown.indexOf("after the action")).toBeLessThan(shown.indexOf("outside the branch")); + // Once each: a boundary that published twice would repeat itself. + expect(shown.split("before the action")).toHaveLength(2); + expect(shown.split("after the action")).toHaveLength(2); + expect(shown.split("outside the branch")).toHaveLength(2); + }); + + it("TG6: a cell 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 cell, visible to later work in that cell. + 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 cell'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 cell 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 cell's checked failure settles that cell and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', + "", + "", + "", + "", + ].join("\n"), + ); + + // Printed inside the cell it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this cell gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this cell gave up"); + }); + + it("TG6: a paired cell runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the cell's *second* component has run, so a + // cell 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 cell body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no terminal provider is installed"); + // The cell 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 in a document", () => { + const TWO = ["", ...CELLS, "", ""].join("\n"); + + it("TG9: nothing is shown until every cell has acquired a terminal activity", function* () { + const dir = yield* useDir(); + // One ordered record the cell and the grid both write to, so "readiness + // came first" is read rather than assumed. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + grid: { + // deno-lint-ignore require-yield + *onShow() { + timeline.push("show"); + }, + shell: (position) => + resource>(function* (provide) { + if (position === 1) { + timeline.push("ready:shell"); + } + yield* provide(done({ exitCode: 0 })); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow cell started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("show"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a cell that never starts fails the grid, and nothing is shown", 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 host was released — once, + // with nothing of the provider's still held. + expect(run.events.some((event) => event.startsWith("show:"))).toBe(false); + expectReleasedOnce(run); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join( + "\n", + ), + ); + + expect(run.outcome.ok).toBe(true); + // Ready the moment the activity was acquired, so the grid was shown; + // settled straight after, so its final status is its own. + expect(run.events.some((event) => event.startsWith("show:0:"))).toBe(true); + expect(run.events).toContain("status:0:0:succeeded"); + expect(run.events.indexOf("status:0:0:succeeded")).toBeGreaterThan( + run.events.findIndex((event) => event.startsWith("show:0:")), + ); + }); + + it("TG9: a preparation failure starts no cell at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no cell endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no cell endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: a failure showing the grid shows no partial grid and releases it", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // deno-lint-ignore require-yield + *onShow() { + throw new Error("the grid could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the grid could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored position", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both cells fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('terminal 0 ("First")'); + expect(failureOf(run)).not.toContain('terminal 1 ("Second")'); + }); + + it("TG12: close cancels a live cell as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + grid: { + // The reader leaves while the cell is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a cell failure. + expect(run.events).toContain("status:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // Released once, after the reader left, with nothing still held. + expectReleasedOnce(run); + // The following sibling started only after the grid came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every cell and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // deno-lint-ignore require-yield + *close() { + throw new Error("the terminal provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the terminal provider lost its server"); + // A provider that failed mid-grid still had its host released exactly once. + expectReleasedOnce(run); + }); + + it("TG21: a background provider failure fails the grid with no foreground action", function* () { + const dir = yield* useDir(); + const background = withResolvers(); + const run = yield* runDocument(dir, TWO, { + grid: { + // Nothing is waiting on the renderer: the reader never leaves, and the + // failure is raised from the provider's own background observation. + close: () => suspend(), + fail: () => background.operation, + // deno-lint-ignore require-yield + *onShow() { + background.resolve(new Error("the renderer lost its channel")); + }, + }, + }); + + expect(failureOf(run)).toContain("the renderer lost its channel"); + expectReleasedOnce(run); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, CELLS); + /** + * A grid whose only cell 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 cell outcomes the grid retained, in authored order. */ + function cellOutcomes(run: DocumentRun): unknown[] { + const cells = retainedGrid(run)?.cells; + return Array.isArray(cells) ? cells : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its cells, 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 host, nothing was prepared, rendered or + // shown, no cell 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, + shellFailsAfterShow: 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 cell is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // Both cells settle, so both cell children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); + + const closes = first.journal.filter((event) => event.type === "close"); + const cellIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(cellIds).toHaveLength(2); + const [left, right] = cellIds; + // 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 acquires a fresh provider host 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. The resumed run continues that region instead of suspending on it. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // A fresh host, acquired by this run, with a state sequence of its own that + // starts at revision zero. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); + expect(second.events).toContain("render:0:0"); + }); + + it("TG16: a completed cell is restored; an incomplete shell starts again", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledProviderOptions["shell"] = (position) => + resource>(function* (provide) { + if (position === 0) { + yield* provide(done({ exitCode: 0 })); + return; + } + // Started, and never finishes on its own. + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }); + + // The left cell settles; the shell holds, so only one cell 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 cell came back from its retained outcome: its body did not + // run again, and no activity was acquired for it. + expect(second.ran).not.toContain("left ran"); + expect(second.events.filter((event) => event.startsWith("shell:"))).toEqual(["shell:0:1"]); + }); + + /** + * 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, rendered 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, and it + // names the value that changed. + expect(failureOf(second)).toContain("columns 2 rather than 3"); + expect(failureOf(second)).toContain("cannot be replayed onto this run"); + }); + + 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('terminal 0 titled "Left" rather than "Elsewhere"'); + 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[]][] = [ + ["cell count", [...CELLS, '']], + ["cell order", ['', ...CELLS.slice(0, 1)]], + ["cell form", ['', '']], + ]; + + for (const [what, cells] 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, cells), 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 cell 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 cell, with its title, form and derived position — and no + // ordinal, index, key or live identity beside them. + expect(value).toEqual({ + columns: 2, + rows: 1, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { 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, cells: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a retained ordinal", + { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + cells: [ + { title: "Left", form: "paired", row: 3, column: 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: TerminalProviderResources | undefined; + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live cell 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(["cell body"]); + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two cell 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(cellOutcomes(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({ grids: 1, shown: 1, activities: 0 }); + expect(first.live).toEqual({ grids: 0, shown: 0, activities: 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 host would refuse; a mark inside the cell body, so a cell + // 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 cell 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", "ordinal"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); 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/runtime/mod.ts b/packages/runtime/mod.ts index c41f34206..2858e7134 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -5,7 +5,7 @@ * `API` is available for middleware (`.around()`). * For normal calls, import operations directly. * - * Seven domain APIs: + * Six domain APIs: * - `API.Process` — subprocess execution (`exec`) * - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`, * `stat`, `lstat`, `readDirectory`, `glob`, `realpath`, `ensureDir`, `rename`, @@ -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/terminal/deno.json b/packages/terminal/deno.json new file mode 100644 index 000000000..422e7c3ca --- /dev/null +++ b/packages/terminal/deno.json @@ -0,0 +1,14 @@ +{ + "name": "@executablemd/terminal", + "version": "0.12.1", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test/mod.ts" + }, + "imports": { + "starfx": "npm:starfx@0.16.1" + } +} diff --git a/packages/terminal/lifecycle.ts b/packages/terminal/lifecycle.ts new file mode 100644 index 000000000..2c6865458 --- /dev/null +++ b/packages/terminal/lifecycle.ts @@ -0,0 +1,29 @@ +/** + * Running one grid, and the integration seam the host expands content through. + * + * Everything here is the lifecycle's own: opening an installation so a + * presentation can be admitted, the resource that runs a whole grid beneath the + * expansion that submitted it, and the cell-output sink core appends rendered + * Markdown through while it interprets a paired cell. + * + * The sink is deliberately not a member of either UI or the provider's view. It + * carries no store, dispatch, identity, title, status or host authority, and + * outside an issued cell scope it is inert. + */ + +export { terminalGrid } from "./src/grid.ts"; +export { + cellBusyMessage, + cellClosedMessage, + cellNeverStartedMessage, + noProviderMessage, + outsideExecutionMessage, +} from "./src/grid.ts"; + +export { appendTerminalCellOutput } from "./src/output.ts"; + +export { terminalInstallation, useTerminalInstallation } from "./src/presentation.ts"; +export type { TerminalInstallation } from "./src/presentation.ts"; + +export { TerminalGridPresentationError } from "./src/errors.ts"; +export type { PresentTerminalGrid } from "./src/host.ts"; diff --git a/packages/terminal/mod.ts b/packages/terminal/mod.ts new file mode 100644 index 000000000..83e4be62b --- /dev/null +++ b/packages/terminal/mod.ts @@ -0,0 +1,104 @@ +/** + * Provider-neutral interactive terminal grids. + * + * A document can replace its one foreground terminal with a grid of terminals + * that stay interactive at the same time. What presents that grid — a tmux + * integration, another multiplexer, a host-native UI, or a controlled surface + * that opens no terminal at all — is a provider, and nothing in this package + * knows which one is installed. + * + * This root owns the contracts everybody shares: the native launch seam, the + * resolved layout and the request derived from it, the immutable live state, the + * domain actions, the provider's read-only view and host, routing and + * installation, the journal boundary, and the errors any of them refuse with. + * + * The grid lifecycle itself is `@executablemd/terminal/lifecycle`; process + * facts are `./processes`; the POSIX host is `./posix`; and `./test` holds the + * controlled surfaces. A value exported from more than one of them is the same + * value. + */ + +export { + NATIVE_LAUNCHER_API, + NativeLauncher, + flushOutput, + nativeLaunch, + reserveTerminal, +} from "./src/launch.ts"; +export type { + NativeLaunchOutcome, + NativeLaunchRequest, + NativeLauncherHandler, +} from "./src/launch.ts"; + +export { + NATIVE_LAUNCHER_UNAVAILABLE, + NO_TERMINAL, + NativeLauncherUnavailableError, + PROCESS_OBSERVATION_UNAVAILABLE, + ProcessObservationUnavailableError, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGridError, + TerminalGridPresentationError, + TerminalProviderInstallError, + TerminalProviderUnavailableError, +} from "./src/errors.ts"; + +export { terminalGridLayout, terminalGridRequest } from "./src/layout.ts"; +export type { + PlacedCell, + TerminalCellForm, + TerminalCellRequest, + TerminalGridCell, + TerminalGridLayout, + TerminalGridRequest, +} from "./src/layout.ts"; + +export type { + TerminalCellId, + TerminalCellState, + TerminalCellStatus, + TerminalGridPhase, + TerminalGridRevision, + TerminalGridState, +} from "./src/state.ts"; + +export type { + PresentTerminalGrid, + TerminalActivity, + TerminalGridHost, + TerminalGridProvider, + TerminalGridView, + TerminalShellOutcome, +} from "./src/host.ts"; + +export { useTerminalCellUI } from "./src/ui.ts"; +export type { TerminalCellUI, TerminalGridUI } from "./src/ui.ts"; + +export { + TERMINAL_GRIDS_API, + TERMINAL_PROVIDERS_API, + TerminalGrids, + TerminalProviders, + installTerminalProvider, + registerTerminalProvider, +} from "./src/routing.ts"; +export type { + TerminalGridApi, + TerminalProviderApi, + TerminalProviderCall, + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/routing.ts"; + +export { retainedGridLayout } from "./src/journal.ts"; +export type { + RetainedCell, + RetainedCellOutcome, + RetainedGrid, + RetainedGridLayout, + TerminalCellWork, + TerminalGridCloseKind, + TerminalGridJournal, +} from "./src/journal.ts"; diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 000000000..bf0a00e8f --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,20 @@ +{ + "name": "@executablemd/terminal", + "version": "0.12.1", + "description": "Provider-neutral interactive terminal grids for executable.md documents.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test/mod.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/node": "0.2.5", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + "starfx": "0.16.1" + } +} diff --git a/packages/terminal/posix.ts b/packages/terminal/posix.ts new file mode 100644 index 000000000..9f9111ae2 --- /dev/null +++ b/packages/terminal/posix.ts @@ -0,0 +1,13 @@ +/** + * The POSIX host adapters. + * + * A host that can hand a child its own terminal installs the foreground + * launcher from here, and a provider that needs process facts installs the + * POSIX observation beside it. Nothing else in this package reaches a + * platform primitive, so a host that is not POSIX installs something else and + * imports none of this. + */ + +export { installForegroundLauncher, installPosixProcessObservation, reap } from "./src/posix.ts"; + +export { NO_TERMINAL, NativeLauncherUnavailableError } from "./src/errors.ts"; diff --git a/packages/terminal/processes.ts b/packages/terminal/processes.ts new file mode 100644 index 000000000..dfdb6db4b --- /dev/null +++ b/packages/terminal/processes.ts @@ -0,0 +1,20 @@ +/** + * What a terminal provider must be able to establish about a process. + * + * Reusable by any host: a provider that needs to prove a child stopped, or that + * nothing still holds a cell's terminal, asks here rather than reaching for a + * platform primitive of its own. + */ + +export { + PROCESS_OBSERVATION_API, + ProcessObservation, + deliverSignal, + processReachable, +} from "./src/processes.ts"; +export type { ProcessObserver, SignalDelivery, TerminalSignal } from "./src/processes.ts"; + +export { + PROCESS_OBSERVATION_UNAVAILABLE, + ProcessObservationUnavailableError, +} from "./src/errors.ts"; diff --git a/packages/terminal/src/errors.ts b/packages/terminal/src/errors.ts new file mode 100644 index 000000000..063a86c6a --- /dev/null +++ b/packages/terminal/src/errors.ts @@ -0,0 +1,66 @@ +/** + * What a terminal grid refuses with, and what a host says when it cannot + * present one. + * + * Each constructor has exactly one definition here. Another entrypoint may + * re-export it, and the value stays object-identical, so a `catch` written + * against the root and one written against `./lifecycle` classify the same + * error. + */ + +/** + * A presentation that authorized nothing: a request that was copied, changed, + * kept past its grid, presented twice, or issued under another installation. + */ +export class TerminalGridPresentationError extends Error { + override name = "TerminalGridPresentationError"; +} + +/** A grid that could not be run: a layout that disagrees with its cell work. */ +export class TerminalGridError extends Error { + override name = "TerminalGridError"; +} + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive terminals. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +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 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."; + +/** What a process observation says when no host installed one. */ +export const PROCESS_OBSERVATION_UNAVAILABLE = + "no process observation is installed — this host cannot say whether a process " + + "is still running. A POSIX host installs `installPosixProcessObservation()`."; + +export class ProcessObservationUnavailableError extends Error { + override name = "ProcessObservationUnavailableError"; + constructor(message: string = PROCESS_OBSERVATION_UNAVAILABLE) { + super(message); + } +} diff --git a/packages/terminal/src/grid.ts b/packages/terminal/src/grid.ts new file mode 100644 index 000000000..6ed74a48b --- /dev/null +++ b/packages/terminal/src/grid.ts @@ -0,0 +1,721 @@ +/** + * One terminal grid, from the lease to the last finalizer. + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The grid is built while it is still hidden, every cell + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier releases the hidden grid + * instead of leaving half a grid on the screen. + * + * ``` + * layout reconciled → lease → flush → routed to a provider → admitted + * → store and provider host → cells start → readiness barrier → show + * → cells settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing owns a grid but the expansion that submitted it. `terminalGrid()` is + * a resource whose task is the whole grid: releasing it early cancels that + * task and waits for the same complete teardown, and there is no registry, + * supervisor or execution-wide owner that could keep one running after the + * work that asked for it has gone. + */ + +import { + all, + createScope, + Err, + ensure, + Ok, + race, + resource, + scoped, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; + +import { TerminalGridError, TerminalGridPresentationError } from "./errors.ts"; +import type { + PresentTerminalGrid, + TerminalActivity, + TerminalGridHost, + TerminalGridProvider, + TerminalShellOutcome, +} from "./host.ts"; +import { flushOutput, reserveTerminal } from "./launch.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import { terminalGridRequest } from "./layout.ts"; +import type { TerminalGridLayout, TerminalGridRequest } from "./layout.ts"; +import { retainedGridLayout } from "./journal.ts"; +import type { + RetainedCellOutcome, + RetainedGrid, + TerminalCellWork, + TerminalGridJournal, +} from "./journal.ts"; +import { installTerminalCellOutput } from "./output.ts"; +import { terminalInstallation } from "./presentation.ts"; +import type { IssuedGrid } from "./presentation.ts"; +import { TerminalGrids } from "./routing.ts"; +import { createTerminalGridStore } from "./store.ts"; +import type { TerminalGridStore } from "./store.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalCellStatus, + TerminalGridPhase, + TerminalGridState, +} from "./state.ts"; +import { installTerminalCellUI } from "./ui.ts"; +import type { TerminalCellUI, TerminalGridUI } from "./ui.ts"; + +export function noProviderMessage(): string { + return ( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider" + ); +} + +export function outsideExecutionMessage(): string { + return ( + "a terminal grid is available only inside a document execution with an installed " + + "terminal provider — a grid outside one retains nothing and could not be resumed" + ); +} + +/** + * What a cell that never acquired a terminal activity says. + * + * A cell whose work finished without ever starting something interactive has + * not started: presenting it as a running cell would be presenting a grid the + * reader cannot use. + */ +export function cellNeverStartedMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A terminal cell runs an interactive child — a , or the ` + + `default shell a self-closing terminal starts.` + ); +} + +export function cellClosedMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") is closed: its grid has stopped admitting terminal ` + + `activities` + ); +} + +export function cellBusyMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") already has a live terminal activity — one owns a cell ` + + `terminal at a time` + ); +} + +/** + * Run one grid beneath the operation that submitted it. + * + * The returned task is the grid. Awaiting it is how the caller learns what the + * grid retained; releasing the resource before that cancels it and waits for + * every cell, renderer, host finalizer and the lease. + */ +export function terminalGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, +): Operation> { + // Written as an operation the caller delegates into rather than with + // `resource()`, because where the deferral finalizer is *registered* decides + // whether it works. A resource body is a task of its own, and a finalizer + // registered inside one unwinds with that task rather than beside the + // detached scope it has to outlive — which lets a cancellation reach the + // grid's durable child before the owner has finished it, and turns a + // completed reader close into a cancelled record a later run would have to + // revive. Delegating puts both on the caller's own frame, in the order they + // were written. Cleanup is still scope-bound, so this is a resource in every + // sense the caller can observe. + return { + *[Symbol.iterator]() { + // Checked before anything exists. A layout that disagrees with its cell + // work is the caller's mistake rather than something a grid came to, so + // it refuses at acquisition and no scope, store or provider is reached. + validateCellWork(layout, cells); + + const close = createCloseHandshake(); + // The grid 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 grid first. That ordering is what makes the + // finalizer below genuinely deferred once close has been acknowledged: the + // grid and its cells finish their own teardown and append their ordinary + // completed records, and only then does a pending cancellation carry on to + // the parent. + // + // A scope rather than an ordinary spawn for a second reason: how a grid + // ended is the caller's answer to read. A spawned task that fails takes its + // host scope down with it, which would unwind the expansion before it could + // account for the failure. + const [detached, destroy] = createScope(yield* useScope()); + const held: { task?: Task; outcome?: Result } = {}; + + // Registered after the scope and before the task, 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 && close.acknowledged && held.outcome === undefined) { + held.outcome = yield* settle(held.task); + } + yield* until(destroy()); + }); + + held.task = detached.run(() => runGrid(layout, cells, journal, close)); + // 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* close.proposed(); + close.acknowledge(); + }); + + return held.task; + }, + }; +} + +/** + * The live boundary reader close crosses. + * + * 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 + * close admission and ask its cells 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. + */ +interface CloseHandshake { + /** 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; +} + +function createCloseHandshake(): CloseHandshake { + 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; + }, + }; +} + +/** + * Reconcile the layout, then run the grid as one durable child of the + * submitting expansion. + */ +function runGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + close: CloseHandshake, +): Operation { + return (function* (): Operation { + // Before the lease and before any provider is contacted: a resumed run + // whose resolved layout changed refuses while nothing has been opened. + yield* journal.reconcileLayout(retainedGridLayout(layout)); + return yield* journal.retainGrid(liveGrid(layout, cells, journal, close)); + })(); +} + +function* settle(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +function validateCellWork(layout: TerminalGridLayout, cells: readonly TerminalCellWork[]): void { + if (layout.cells.length === 0) { + throw new TerminalGridError("a terminal grid layout places no cells"); + } + if (cells.length !== layout.cells.length) { + throw new TerminalGridError( + `a terminal grid layout places ${layout.cells.length} cells and was given ` + + `${cells.length} cell operations: ordered array position is a cell's identity, so the ` + + `two must agree`, + ); + } + const identities = new Set(cells.map((cell) => cell.cellId)); + if (identities.size !== cells.length) { + throw new TerminalGridError( + "a terminal grid was given the same live cell identity twice: one fresh identity is " + + "minted per authored position", + ); + } +} + +/** + * Take the lease, issue the request, and read what presentation 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. + */ +function liveGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + close: CloseHandshake, +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalGridPresentationError(outsideExecutionMessage()); + } + + const request = terminalGridRequest(layout); + let settled: RetainedGrid | undefined; + + // Issued, not started. The lookup holds the request and this work until a + // provider presents a grid for this exact object; the grid then runs + // beneath this operation's own scope. + const issued: IssuedGrid = { + request, + generation: installation.generation, + used: false, + *run(provider) { + settled = yield* runPresented(request, cells, journal, provider, close); + }, + }; + installation.grids.add(issued); + yield* ensure(() => { + installation.grids.delete(issued); + }); + + // The one foreground-terminal lease, taken before any provider is asked + // for anything. A root native launch and a grid contend for exactly this. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before + // the grid covers it up. + yield* flushOutput(); + + yield* TerminalGrids.operations.open(request); + + if (settled === undefined) { + throw new TerminalGridPresentationError(noProviderMessage()); + } + return settled; + }); +} + +/** One cell's live bookkeeping. Private, and never a second capability model. */ +interface CellRuntime { + readonly position: number; + readonly cellId: TerminalCellId; + readonly title: string; + /** Whether this cell has ever acquired a terminal activity, or was restored. */ + ready: boolean; + /** Whether one terminal activity is live in this cell right now. */ + busy: boolean; + outcome?: RetainedCellOutcome; +} + +function runPresented( + request: TerminalGridRequest, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + provider: TerminalGridProvider, + close: CloseHandshake, +): Operation { + return scoped(function* (): Operation { + const runtimes: CellRuntime[] = cells.map((work, position) => ({ + position, + cellId: work.cellId, + title: request.cells[position]!.title, + ready: false, + busy: false, + })); + + const store = yield* createTerminalGridStore({ + columns: request.columns, + rows: request.rows, + cells: runtimes.map((runtime) => ({ + cellId: runtime.cellId, + title: runtime.title, + row: request.cells[runtime.position]!.row, + column: request.cells[runtime.position]!.column, + })), + }); + + // Registered before the host is acquired, so it runs after the host has + // been released and the root terminal restored — which is the only moment + // at which `closed` is the whole truth about this grid. + yield* ensure(function* () { + yield* publishClosedPhase(store); + }); + + const host = yield* provider.host(request, { states: store.states }); + + // Independent of reader close, and observed for the host's whole acquired + // lifetime: a renderer that died while no action was waiting on it may not + // stay hidden until something happens to call the provider again. Spawned + // after acquisition, so it comes down before the host is released and an + // ordinary release settles nothing as a false event. + yield* spawn(function* (): Operation { + throw yield* host.failed; + }); + + let admitting = true; + let shown = false; + const closing = withResolvers(); + const readiness = runtimes.map(() => withResolvers()); + const startupFailed = withResolvers(); + + // Nothing new is admitted once teardown begins, so a cell that was about + // to start a terminal activity is refused rather than racing the close. + yield* ensure(() => { + admitting = false; + }); + + const markReady = (runtime: CellRuntime): void => { + if (runtime.ready) { + return; + } + runtime.ready = true; + readiness[runtime.position]!.resolve(); + }; + + const performActivity = function* ( + runtime: CellRuntime, + start: () => TerminalActivity, + ): Operation { + if (!admitting) { + throw new TerminalGridPresentationError(cellClosedMessage(runtime.position, runtime.title)); + } + if (runtime.busy) { + throw new TerminalGridPresentationError(cellBusyMessage(runtime.position, runtime.title)); + } + runtime.busy = true; + try { + // Admitted, and the revision that includes every causally prior output + // of this cell captured with it. + const admitted = yield* store.commit((state) => + withCellStatus(state, runtime.cellId, "launching"), + ); + // Nothing of the provider's is called until the screen it will draw on + // is the screen this action asked for. A cancellation here therefore + // makes no child call and establishes no readiness. + yield* host.converge(admitted.revision); + return yield* scoped(function* (): Operation { + // Acquisition happens only once the child has actually spawned, and + // acquiring it *is* the cell becoming ready. + const outcome = yield* start(); + yield* store.commit((state) => withCellStatus(state, runtime.cellId, "running")); + markReady(runtime); + return yield* outcome; + }); + } finally { + // The activity's own cleanup has been awaited by the scope above, so + // the next activity is admitted only after this one is quiescent. + runtime.busy = false; + } + }; + + interface LaunchRun { + readonly runtime: CellRuntime; + readonly request: NativeLaunchRequest; + outcome?: NativeLaunchOutcome; + } + interface ShellRun { + readonly runtime: CellRuntime; + outcome?: TerminalShellOutcome; + } + + const launchController = store.controller("terminal.cell.launch", function* (run) { + run.outcome = yield* performActivity(run.runtime, () => + host.launch(run.runtime.cellId, run.request), + ); + }); + const shellController = store.controller("terminal.cell.shell", function* (run) { + run.outcome = yield* performActivity(run.runtime, () => host.shell(run.runtime.cellId)); + }); + const showController = store.controller>( + "terminal.grid.show", + function* () { + const visible = yield* store.commit((state) => withPhase(state, "visible")); + yield* host.show(visible.revision); + }, + ); + + const cellHandles: TerminalCellUI[] = runtimes.map((runtime) => ({ + get state(): TerminalCellState { + return store.state().cells[runtime.position]!; + }, + *launch(nativeRequest: NativeLaunchRequest): Operation { + const run: LaunchRun = { runtime, request: nativeRequest }; + yield* launchController(run); + return run.outcome ?? {}; + }, + *shell(): Operation { + const run: ShellRun = { runtime }; + yield* shellController(run); + return run.outcome ?? {}; + }, + })); + + const ui: TerminalGridUI = { + get state(): TerminalGridState { + return store.state(); + }, + cells: cellHandles, + show: () => showController({}), + }; + + const appendOutput = (runtime: CellRuntime) => + function* (text: string): Operation { + yield* store.commit((state) => withCellContent(state, runtime.cellId, text)); + }; + + const children: Task[] = []; + for (const [position, work] of cells.entries()) { + const runtime = runtimes[position]!; + children.push( + yield* spawn(() => + journal.retainCell( + position, + cellOutcome( + runtime, + work, + ui.cells[position]!, + appendOutput(runtime), + closing.operation, + ), + ), + ), + ); + } + + // Observing each child is what turns a cell's outcome — replayed or live — + // into a published status and a cell the barrier counts as started. + for (const [position, child] of children.entries()) { + const runtime = runtimes[position]!; + yield* spawn(function* () { + const outcome = yield* child; + runtime.outcome = outcome; + // A cell restored from its retained outcome satisfies the barrier + // without acquiring anything: it did start, on the run that recorded it. + markReady(runtime); + yield* store.commit((state) => withCellStatus(state, runtime.cellId, outcome.status)); + if (outcome.status === "failed" && !shown) { + // Before the barrier a cell failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than showing what is + // left. After it, the failure is this cell's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + }); + } + + // Every cell must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose cell + // already failed from waiting forever for an acquisition that cannot happen. + try { + yield* race([all(readiness.map((gate) => gate.operation)), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored position, not by + // whichever rejected the race first. + throw new TerminalGridError(firstReason(runtimes) ?? "a terminal grid cell failed to start"); + } + + yield* ui.show(); + shown = true; + + // The grid stays visible after its cells settle. The reader leaving is + // what finishes the grid, not the last cell exiting. + yield* host.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* close.propose(); + + admitting = false; + yield* store.commit((state) => withPhase(state, "closing")); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a cell + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. + for (const runtime of runtimes) { + if (runtime.outcome === undefined) { + yield* store.commit((state) => withCellStatus(state, runtime.cellId, "closed")); + } + } + for (const [position, child] of children.entries()) { + // Awaited, not halted. Each cell settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* child; + runtimes[position]!.outcome ??= outcome; + } + + const outcomes = runtimes.map((runtime) => runtime.outcome ?? closedOutcome()); + const reason = firstReason(runtimes); + return { + layout: retainedGridLayout({ + columns: request.columns, + rows: request.rows, + cells: request.cells.map((cell) => ({ + title: cell.title, + form: cell.form, + row: cell.row, + column: cell.column, + })), + }), + close: reason === undefined ? "reader" : "failed", + cells: outcomes, + }; + }); +} + +/** + * Publish `closed`, once the host has gone and the root terminal is back. + * + * Cleanup enforces quiescence; it does not decide outcomes. A commit that + * cannot happen here — a grid already at its revision ceiling, a store whose + * scope is coming down — must not replace the result the grid already reached. + */ +function* publishClosedPhase(store: TerminalGridStore): Operation { + try { + yield* store.commit((state) => withPhase(state, "closed")); + } catch { + // The phase is live display state. Nothing reads it after this point, and + // the outcome this grid settled on is already decided. + } +} + +/** Run one cell's work and say what it came to. */ +function cellOutcome( + runtime: CellRuntime, + work: TerminalCellWork, + handle: TerminalCellUI, + append: (text: string) => Operation, + closing: Operation, +): Operation { + return (function* (): Operation { + try { + // The cell'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 cell 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(() => interpretCell(work, handle, append)); + 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 cell'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 (!runtime.ready) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: cellNeverStartedMessage(runtime.position, runtime.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** Interpret one cell's lazy operation exactly once, under its issued handle. */ +function interpretCell( + work: TerminalCellWork, + handle: TerminalCellUI, + append: (text: string) => Operation, +): Operation { + return scoped(function* () { + yield* installTerminalCellUI(handle); + yield* installTerminalCellOutput(append); + yield* work.operation; + }); +} + +/** The first failed cell's sentence in authored order, which is the grid's. */ +function firstReason(runtimes: readonly CellRuntime[]): string | undefined { + return runtimes.find((runtime) => runtime.outcome?.status === "failed")?.outcome?.reason; +} + +/** What a cell the reader closed came to. */ +function closedOutcome(): RetainedCellOutcome { + return { status: "closed", reason: "" }; +} + +function withPhase(state: TerminalGridState, phase: TerminalGridPhase): TerminalGridState { + return { ...state, phase }; +} + +function withCellStatus( + state: TerminalGridState, + cellId: TerminalCellId, + status: TerminalCellStatus, +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell) => (cell.cellId === cellId ? { ...cell, status } : cell)), + }; +} + +function withCellContent( + state: TerminalGridState, + cellId: TerminalCellId, + text: string, +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell) => + cell.cellId === cellId ? { ...cell, content: cell.content + text } : cell, + ), + }; +} + +export type { PresentTerminalGrid, TerminalGridHost }; diff --git a/packages/terminal/src/host.ts b/packages/terminal/src/host.ts new file mode 100644 index 000000000..1b658cea4 --- /dev/null +++ b/packages/terminal/src/host.ts @@ -0,0 +1,115 @@ +/** + * What a provider supplies, and what the grid can ask of it. + * + * The provider draws a grid and nothing else decides: it observes the desired + * state, converges the screen to it, hands one cell's terminal to a child, and + * says when the reader left or when its own machinery failed. It cannot settle + * a grid by returning, close one by calling something, or change a state it + * was shown. + */ + +import type { Operation, Stream } from "effection"; + +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import type { TerminalGridRequest } from "./layout.ts"; +import type { TerminalCellId, TerminalGridRevision, TerminalGridState } from "./state.ts"; + +/** How a cell's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One terminal activity: something interactive a cell runs. + * + * A resource, and the acquisition is the whole point. Preparing a child and + * spawning it happen before the value exists, so a provider that could not + * start one never yields — and the cell it belongs to never becomes ready. + * Acquiring it means the child is running; the value acquired is the operation + * that settles with how that child ended; releasing it kills and reaps + * whatever is left. + * + * A child that starts and exits immediately is therefore both ready and + * settled. Allocating a process identifier and receiving first output are not + * acquisition. + */ +export type TerminalActivity = Operation>; + +/** + * The provider's only window onto the grid. + * + * Starting a subscription atomically registers it and enqueues the current + * snapshot, then delivers only strictly newer revisions. There is no separate + * "read the current state" operation to combine with a later subscription, + * because the gap between those two is exactly where a commit would be lost. + */ +export interface TerminalGridView { + readonly states: Stream; +} + +/** + * One provider's realization of one complete grid. + * + * Supplied as a resource, so acquiring it is the provider grid coming into + * existence and releasing it is the grid going away — exactly once, whether + * the grid succeeded, failed to start, was closed by the reader, was failed by + * the provider, or was cancelled. There is no destroy to call and no way to + * call one twice. + */ +export interface TerminalGridHost { + /** Settles when the reader closes or leaves the grid. */ + readonly closed: Operation; + /** + * Settles with the provider's own error when its background work fails. + * + * Independent of `closed`, and observed for the host's whole acquired + * lifetime: a renderer that died while no action was waiting on it may not + * stay hidden until something happens to call the provider again. + */ + readonly failed: Operation; + /** + * Converge through `requiredRevision` and then present the prepared grid, + * atomically. Called once, and only after every cell is ready. + */ + show(requiredRevision: TerminalGridRevision): Operation; + /** + * Settle once a complete snapshot with a revision of at least + * `requiredRevision` has been fully applied. + * + * A later revision satisfies an earlier requirement, because the aggregate + * subsumes everything before it. + */ + converge(requiredRevision: TerminalGridRevision): Operation; + /** Hand one live cell's terminal to the exact native request. */ + launch( + cellId: TerminalCellId, + request: NativeLaunchRequest, + ): TerminalActivity; + /** Start the host's default interactive shell in one live cell. */ + shell(cellId: TerminalCellId): TerminalActivity; +} + +/** + * What a registered provider is: something that can realize one request. + * + * A provider object is implementation, not authority. It cannot present + * another request, choose another installation generation, mutate state, or + * settle the grid by what it returns. + */ +export interface TerminalGridProvider { + host(request: TerminalGridRequest, view: TerminalGridView): Operation; +} + +/** + * What a registered provider is handed, and the only way to present. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else: it does not travel through a context, a request, a result, a + * prop, a binding or a durable record. Presenting the exact request core + * issued is what runs the grid; anything else authorizes nothing. + */ +export type PresentTerminalGrid = ( + request: TerminalGridRequest, + provider: TerminalGridProvider, +) => Operation; diff --git a/packages/terminal/src/journal.ts b/packages/terminal/src/journal.ts new file mode 100644 index 000000000..f02a3f754 --- /dev/null +++ b/packages/terminal/src/journal.ts @@ -0,0 +1,128 @@ +/** + * What a grid retains, and how the lifecycle reaches a journal it knows + * nothing about. + * + * The neutral package defines this interface and calls it; the host that has a + * journal implements it with its own source-aware descriptions already closed + * over. Only provider-neutral layouts, outcomes and lazy operations cross the + * boundary, so nothing here names a journal type, a coroutine, or a durable + * record shape. + * + * Array position is the durable identity of a cell. No ordinal, index or key + * duplicates that fact, because a stored one could disagree with the position + * it sits at. + */ + +import type { Operation } from "effection"; +import type { Json } from "@executablemd/durable-streams"; + +import type { TerminalCellForm } from "./layout.ts"; +import type { TerminalCellId } from "./state.ts"; + +/** + * One retained cell's placement, in authored order. + * + * Every retained shape here is `Json`, which is the durable stream's own + * vocabulary rather than a structural look-alike: a host writes these records + * straight down, and a type that only resembled JSON would let a value through + * that no journal could hold. It is the one thing this package takes from + * anywhere else, and it is a data type — nothing of core, the CLI, a runtime + * host or a multiplexer crosses this boundary. + */ +export interface RetainedCell extends Record { + readonly title: string; + readonly form: TerminalCellForm; + readonly row: number; + readonly column: number; +} + +/** The provider-neutral layout a grid retains. */ +export interface RetainedGridLayout extends Record { + readonly columns: number; + readonly rows: number; + readonly cells: RetainedCell[]; +} + +/** How a grid ended. */ +export type TerminalGridCloseKind = "reader" | "failed"; + +/** One cell's retained outcome: what it came to, and why when it failed. */ +export interface RetainedCellOutcome extends Record { + readonly status: TerminalCellStatusOutcome; + readonly reason: string; +} + +/** What a cell can have come to, as the journal records it. */ +export type TerminalCellStatusOutcome = "succeeded" | "failed" | "closed"; + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * cell's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or terminal 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 { + readonly layout: RetainedGridLayout; + readonly close: TerminalGridCloseKind; + readonly cells: RetainedCellOutcome[]; +} + +/** + * One cell's work, as the submitting expansion constructed it. + * + * The operation is lazy: constructing it performs no expansion, shell, Agent, + * provider or journal work at all. The lifecycle interprets it exactly once, + * inside the durable child derived from its position, with that cell's issued + * handle and output sink installed. + */ +export interface TerminalCellWork { + readonly cellId: TerminalCellId; + readonly operation: Operation; +} + +/** + * The three durable boundaries a grid has. + * + * `reconcileLayout()` runs before provider admission, so a resumed run whose + * resolved layout changed refuses while nothing has been opened and nothing has + * started. `retainGrid()` may return a completed retained outcome without + * interpreting its live operation, which is how completed replay creates no + * live state at all; `retainCell()` does the same for one completed cell. + */ +export interface TerminalGridJournal { + reconcileLayout(layout: RetainedGridLayout): Operation; + retainGrid(operation: Operation): Operation; + retainCell( + position: number, + operation: Operation, + ): Operation; +} + +/** What a resolved layout has to say to be retained. */ +export interface PlacedGridLayout { + readonly columns: number; + readonly rows: number; + readonly cells: readonly { + readonly title: string; + readonly form: TerminalCellForm; + readonly row: number; + readonly column: number; + }[]; +} + +/** The retained shape of one resolved layout. */ +export function retainedGridLayout(layout: PlacedGridLayout): RetainedGridLayout { + return { + columns: layout.columns, + rows: layout.rows, + cells: layout.cells.map((cell) => ({ + title: cell.title, + form: cell.form, + row: cell.row, + column: cell.column, + })), + }; +} diff --git a/packages/terminal/src/launch.ts b/packages/terminal/src/launch.ts new file mode 100644 index 000000000..df9f78003 --- /dev/null +++ b/packages/terminal/src/launch.ts @@ -0,0 +1,106 @@ +/** + * The native launcher — how a host hands one child process 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. + * + * 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. A terminal grid + * takes the same lease for its whole visible lifetime. + * 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 + * `@executablemd/terminal/posix`; a test or embedding host installs the + * controlled one from `@executablemd/terminal/test`. Until one is installed + * every operation refuses, which is what keeps document help and inspection + * free of any of this. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +import { NativeLauncherUnavailableError } from "./errors.ts"; + +/** + * 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; +} + +/** + * The composition name is stable across loaded copies, so it does not follow + * the module between packages: a host still running the previous copy composes + * with this one through the name they share. + */ +export const NATIVE_LAUNCHER_API = "runtime.nativeLauncher"; + +export const NativeLauncher: Api = createApi( + NATIVE_LAUNCHER_API, + { + // 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); +} diff --git a/packages/terminal/src/layout.ts b/packages/terminal/src/layout.ts new file mode 100644 index 000000000..44b9a2f7d --- /dev/null +++ b/packages/terminal/src/layout.ts @@ -0,0 +1,116 @@ +/** + * The concrete grid an authored terminal grid derives, and the request one + * expansion submits for it. + * + * 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 cells fill, and which position each cell occupies. + * + * A cell's identity is its position in the ordered array, which is why nothing + * here carries an ordinal, index or key. Two arrays in authored order say the + * same thing, and a stored ordinal could disagree with the position it sits at. + */ + +/** Whether a cell runs the markdown it holds or the host's default shell. */ +export type TerminalCellForm = "paired" | "self-closing"; + +/** One cell, placed. */ +export interface TerminalGridCell { + /** 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; + readonly form: TerminalCellForm; +} + +/** The complete grid one terminal-grid element asked for. */ +export interface TerminalGridLayout { + readonly columns: number; + /** How many rows those columns take to hold every cell. */ + readonly rows: number; + /** Every cell, in authored order, which is also row-major order. */ + readonly cells: readonly TerminalGridCell[]; +} + +/** One cell's placeable facts, once its title has been resolved. */ +export interface PlacedCell { + readonly title: string; + readonly form: TerminalCellForm; +} + +/** + * Place the cells across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` cells 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 cell's position is its identity wherever it lands. + */ +export function terminalGridLayout( + columns: number, + cells: readonly PlacedCell[], +): TerminalGridLayout { + return { + columns, + rows: Math.ceil(cells.length / columns), + cells: cells.map((cell, position) => ({ + row: Math.floor(position / columns), + column: position % columns, + title: cell.title, + form: cell.form, + })), + }; +} + +/** One cell the provider is asked to present, by its position in the array. */ +export interface TerminalCellRequest { + /** The label to display. Two cells 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 cell's work or the host's default shell + * does. A provider reads it to know which cells it must start a shell in. + */ + readonly form: TerminalCellForm; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or cell 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 presentation compares the object it is given 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 TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly cells: readonly TerminalCellRequest[]; +} + +/** The provider-neutral request one derived layout asks for. */ +export function terminalGridRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + cells: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} diff --git a/packages/terminal/src/output.ts b/packages/terminal/src/output.ts new file mode 100644 index 000000000..33aed93d4 --- /dev/null +++ b/packages/terminal/src/output.ts @@ -0,0 +1,47 @@ +/** + * Where a cell's rendered Markdown goes. + * + * This is an integration facet, not a capability. It carries no store, no + * dispatch, no identity, and no way to set a title or a status — the only + * thing it can do is add to the content of the one cell whose scope issued it. + * Outside such a scope it is inert, which is what makes a copy kept past its + * grid, or an import reached from ordinary document work, worth nothing. + * + * An append waits for the private aggregate commit and not for rendering. That + * is the whole causal claim: by the time it returns, the output is part of the + * desired state a later `launch()` or `shell()` will converge through. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; + +/** What one issued cell scope does with rendered bytes. */ +export type TerminalCellOutputSink = (text: string) => Operation; + +const CellOutput: Context = createContext< + TerminalCellOutputSink | undefined +>("terminal.cellOutput", undefined); + +/** + * Add rendered Markdown to the current cell's desired content. + * + * Empty text is a no-op. Every other call appends in call order, so `content` + * remains the complete output the cell has produced so far. + */ +export function appendTerminalCellOutput(text: string): Operation { + return (function* (): Operation { + if (text.length === 0) { + return; + } + const sink = yield* CellOutput.get(); + if (sink === undefined) { + return; + } + yield* sink(text); + })(); +} + +/** Install one cell's sink for the scope that interprets its work. */ +export function* installTerminalCellOutput(sink: TerminalCellOutputSink): Operation { + yield* CellOutput.set(sink); +} diff --git a/packages/runtime/launcher.ts b/packages/terminal/src/posix.ts similarity index 58% rename from packages/runtime/launcher.ts rename to packages/terminal/src/posix.ts index 2a11e374d..12ee9ac8b 100644 --- a/packages/runtime/launcher.ts +++ b/packages/terminal/src/posix.ts @@ -1,33 +1,16 @@ /** - * The native launcher — how a host hands one child process the terminal. + * The POSIX host: how this process hands a child its own terminal, and how it + * establishes that a child stopped. * - * 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. + * 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. * - * 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. + * Everything host-specific about a native launch is here rather than beside + * the neutral contracts, so a host that is not POSIX installs something else + * and a package that only describes grids imports none of it. */ -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"; @@ -35,85 +18,11 @@ 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 { NativeLauncherUnavailableError, NO_TERMINAL } from "./errors.ts"; +import { NativeLauncher } from "./launch.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import { ProcessObservation } from "./processes.ts"; +import type { SignalDelivery, TerminalSignal } from "./processes.ts"; /** * How long an interrupted child is given to leave on its own before the @@ -128,6 +37,23 @@ const REAP_POLL_MS = 25; /** How long an unanswerable kill is given before the child is called gone. */ const KILL_SETTLE_MS = 500; +/** Install the POSIX answer to "is this process still there". */ +export function* installPosixProcessObservation(): Operation { + yield* ProcessObservation.around( + { + // deno-lint-ignore require-yield + *reachable([pid]) { + return isReachable(pid); + }, + // deno-lint-ignore require-yield + *deliver([pid, name]) { + return signal(pid, name); + }, + }, + { at: "min" }, + ); +} + interface ForegroundLauncherOptions { /** * Whether this host can hand a child the terminal. Read once, when the @@ -140,10 +66,6 @@ interface ForegroundLauncherOptions { /** * Install the launcher that hands a native UI this process's own terminal. - * - * 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. */ export function* installForegroundLauncher( options: ForegroundLauncherOptions = {}, @@ -244,13 +166,13 @@ function runForeground(request: NativeLaunchRequest): Operation { - const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); + const [code, signalName] = yield* once<[number | null, string | null]>(started, "exit"); const outcome: NativeLaunchOutcome = {}; if (code !== null) { outcome.exitCode = code; } - if (signal !== null) { - outcome.signal = signal; + if (signalName !== null) { + outcome.signal = signalName; } return outcome; })(), @@ -265,10 +187,10 @@ function runForeground(request: NativeLaunchRequest): Operation { // permission error — leaves a child that may still be running, and // reporting that as a successful reap would let the document continue // while a native UI still owns the terminal. - let fatal: Delivery | undefined; + let fatal: SignalDelivery | undefined; const done = (outcome?: Error) => { if (settled) { @@ -355,9 +277,6 @@ export function reap(child: ChildProcess): Promise { }); } -/** What one signal delivery established about the process it was aimed at. */ -type Delivery = "delivered" | "absent" | "refused"; - /** * Send one signal to the child by pid, and report what that established. * @@ -367,7 +286,7 @@ type Delivery = "delivered" | "absent" | "refused"; * run would keep waiting on a native UI still holding the terminal. Addressing * the process directly is what makes escalation real. */ -function signal(pid: number, name: "SIGINT" | "SIGKILL"): Delivery { +function signal(pid: number, name: TerminalSignal): SignalDelivery { try { process.kill(pid, name); return "delivered"; @@ -380,12 +299,10 @@ function signal(pid: number, name: "SIGINT" | "SIGKILL"): Delivery { } function isNoSuchProcess(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === "ESRCH" - ); + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + return Reflect.get(error, "code") === "ESRCH"; } /** @@ -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/terminal/src/presentation.ts b/packages/terminal/src/presentation.ts new file mode 100644 index 000000000..4146412f1 --- /dev/null +++ b/packages/terminal/src/presentation.ts @@ -0,0 +1,123 @@ +/** + * Who may present a grid, and for which request. + * + * The provider draws a grid. This decides one thing about it: whether the + * request being presented is the exact one the lifecycle issued, under the + * installation that issued it, and not one that has been presented already. + * Nothing else here decides anything — and nothing here owns a grid. + * + * Ownership belongs to the expansion that submitted it. A grid runs beneath + * that operation, so its cells keep the durable identity and the bindings of + * the document position that wrote them, and structured concurrency takes the + * grid down whenever that operation unwinds. + * + * What is kept here is the smallest lookup that lets the two sides converge: + * the exact request object an expansion submitted, the generation it belongs + * to, whether it has been presented, and the operation that runs it. That + * lookup holds no tasks and owns no lifetime — an entry is added and removed by + * the submitting expansion itself, so nothing here can keep a grid running + * after the work that asked for it has gone. A request reaching this from + * anywhere else — copied, rebuilt, kept from another grid, belonging to a + * superseded installation, or already used — presents nothing, and the + * provider is never touched. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; + +import { TerminalGridPresentationError } from "./errors.ts"; +import type { PresentTerminalGrid, TerminalGridProvider } from "./host.ts"; +import type { TerminalGridRequest } from "./layout.ts"; + +/** One grid an expansion submitted, and what it is waiting to be given. */ +export interface IssuedGrid { + /** The exact request object the lifecycle issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Whether this request has already been presented. */ + used: boolean; + /** Run the grid, beneath the operation that submitted it. */ + run(provider: TerminalGridProvider): Operation; +} + +/** + * Build the presentation function one provider installation is given. + * + * It closes over the installation's generation, so a factory that kept one from + * a superseded installation presents under a generation the issued requests no + * longer belong to. + */ +export function createPresentTerminalGrid( + generation: object, + issued: ReadonlySet, +): PresentTerminalGrid { + return function* present(request, provider) { + const found = [...issued].find((candidate) => Object.is(candidate.request, request)); + if (found === undefined) { + throw new TerminalGridPresentationError( + "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(found.generation, generation)) { + throw new TerminalGridPresentationError( + "this grid request belongs to another terminal provider installation", + ); + } + if (found.used) { + throw new TerminalGridPresentationError( + "this grid request has already been presented — one request opens one grid", + ); + } + // Admitted before anything of the provider's is touched: a refused + // presentation creates no store, acquires no host, and leaves the provider + // holding nothing. + found.used = true; + yield* found.run(provider); + }; +} + +/** One execution's terminal installation: the grids it has issued, and its generation. */ +export interface TerminalInstallation { + /** + * Every grid this execution has issued and not yet finished. + * + * One lookup for the execution rather than one per installation, so a + * superseded installation's presentation function still *finds* the grid it + * names and is turned away for the reason that is actually true — it belongs + * to another installation — instead of being told the request is unknown. + */ + readonly grids: Set; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + TerminalInstallation | undefined +>("terminal.installation", undefined); + +/** + * Open one terminal installation for a live document, and hand back the + * presentation function 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 presentation + * function does not: it is handed to a provider factory directly. A replaced + * installation therefore produces requests the real one has never heard of, + * which is a refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + // A nested installation supersedes the one around it but shares its lookup: + // the generation is what tells them apart, and sharing is what lets it. + const existing = yield* Installation.get(); + const grids = existing?.grids ?? new Set(); + const generation = {}; + yield* Installation.set({ grids, generation }); + return createPresentTerminalGrid(generation, grids); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} diff --git a/packages/terminal/src/processes.ts b/packages/terminal/src/processes.ts new file mode 100644 index 000000000..a8f28af6a --- /dev/null +++ b/packages/terminal/src/processes.ts @@ -0,0 +1,72 @@ +/** + * What a terminal provider must be able to establish about a process. + * + * A cancelled launch may not leave a child holding a terminal, and a cell may + * not admit its next activity while the previous one still owns the screen. + * Both are claims about processes, and neither can be made from a PID, an + * elapsed timeout, or a signal that was merely sent. This is the neutral + * vocabulary for making them; `@executablemd/terminal/posix` is one host's + * answer. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +import { ProcessObservationUnavailableError } from "./errors.ts"; + +/** + * What one signal delivery established about the process it was aimed at. + * + * `absent` is the outcome the caller was asking for: gone between the decision + * and the delivery is still gone. `refused` is a delivery that did not happen, + * and is evidence of nothing. + */ +export type SignalDelivery = "delivered" | "absent" | "refused"; + +/** The signals a terminal provider has cause to send. */ +export type TerminalSignal = "SIGINT" | "SIGTERM" | "SIGKILL"; + +export interface ProcessObserver { + /** + * Whether the process still exists. + * + * Reachability, not an exit event: an event is a report, and a report is not + * the fact a teardown proof needs. + */ + reachable(pid: number): Operation; + /** Send one signal, and say what that established. */ + deliver(pid: number, signal: TerminalSignal): Operation; +} + +/** The stable name every loaded copy composes through. */ +export const PROCESS_OBSERVATION_API = "terminal.processObservation"; + +/** + * The public observation surface. Its own default always refuses. + * + * A host that cannot observe processes must say so rather than answer "gone" + * for a child it never looked at. + */ +export const ProcessObservation: Api = createApi( + PROCESS_OBSERVATION_API, + { + // deno-lint-ignore require-yield + *reachable(_pid: number): Operation { + throw new ProcessObservationUnavailableError(); + }, + // deno-lint-ignore require-yield + *deliver(_pid: number, _signal: TerminalSignal): Operation { + throw new ProcessObservationUnavailableError(); + }, + }, +); + +/** Whether the process still exists. */ +export function processReachable(pid: number): Operation { + return ProcessObservation.operations.reachable(pid); +} + +/** Send one signal, and report what it established. */ +export function deliverSignal(pid: number, signal: TerminalSignal): Operation { + return ProcessObservation.operations.deliver(pid, signal); +} diff --git a/packages/terminal/src/routing.ts b/packages/terminal/src/routing.ts new file mode 100644 index 000000000..38438c111 --- /dev/null +++ b/packages/terminal/src/routing.ts @@ -0,0 +1,284 @@ +/** + * How a grid request reaches a provider, and how a provider is installed. + * + * **Routing 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 lease and settles a grid is the + * non-contextual presentation function delivered straight to the registered + * provider, so a handler that answers without delegating has presented nothing + * and settled nothing. + * + * Installation works the same way, and deliberately mirrors the Agent provider + * handshake. Selecting a provider *is* a presentation decision, so nothing is + * returned up the public chain: public middleware receives one frozen, one-use + * install request 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 it, and only through it, the + * invocation terminal hands the factory this execution's presentation function + * and records that the provider acknowledged installation. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import { TerminalProviderInstallError, TerminalProviderUnavailableError } from "./errors.ts"; +import type { PresentTerminalGrid } from "./host.ts"; +import type { TerminalGridRequest } from "./layout.ts"; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; + +export interface TerminalGridApi { + /** + * 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 the lifecycle reads what presentation + * settled instead of what a handler said. + */ + open(request: TerminalGridRequest): 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 TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, +}); + +/** What a host says about the provider it is installing. */ +export interface TerminalProviderOptions { + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label: string; +} + +/** + * A provider factory installs `TerminalGrids` middleware for its scope. + * + * Presentation 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 TerminalProviderFactory = ( + options: TerminalProviderOptions, + present: PresentTerminalGrid, +) => Operation; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_PROVIDERS_API = "TerminalProviders"; + +/** What public installation middleware sees: the name, and what it runs under. */ +export interface TerminalProviderInstallRequest { + readonly intent: "install"; + readonly name: string; + readonly options: TerminalProviderOptions; +} + +/** + * 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 TerminalProviderCall = + | TerminalProviderInstallRequest + | { readonly intent: "inspect"; readonly install: TerminalProviderInstallRequest } + | { readonly intent: "acknowledge"; readonly install: TerminalProviderInstallRequest }; + +export interface TerminalProviderApi { + /** + * 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: TerminalProviderCall): Operation; +} + +/** + * 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 TerminalProviders: Api = createApi( + TERMINAL_PROVIDERS_API, + { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + const name = call.intent === "install" ? call.name : call.install.name; + throw new TerminalProviderInstallError(`Unknown terminal provider "${name}"`); + }, + }, +); + +/** Make `factory` installable as `name` for the current scope. */ +export function* registerTerminalProvider( + name: string, + factory: TerminalProviderFactory, +): Operation { + let registered = true; + yield* ensure(() => { + registered = false; + }); + yield* TerminalProviders.around( + { + *install([call], next): Operation { + if (call.intent !== "install" || call.name !== name) { + return yield* next(call); + } + if (!registered) { + throw new TerminalProviderInstallError( + `the "${name}" terminal 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.present); + 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: TerminalProviderOptions; + present: PresentTerminalGrid; +} { + if (typeof value !== "object" || value === null) { + throw new TerminalProviderInstallError( + "this terminal provider installation is not live, so nothing was delivered to it", + ); + } + const options = Reflect.get(value, "options"); + const present = Reflect.get(value, "present"); + if (typeof options !== "object" || options === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation named no options", + ); + } + if (typeof present !== "function") { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no way to present a grid", + ); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new TerminalProviderInstallError("the live terminal provider options are not readable"); + } + return { + options: { label }, + present: (request, provider) => Reflect.apply(present, undefined, [request, provider]), + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * Presentation 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 installTerminalProvider( + name: string, + options: TerminalProviderOptions, + present: PresentTerminalGrid, +): Operation { + return (function* (): Operation { + const request: TerminalProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, present); + // 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(TERMINAL_PROVIDERS_API, { + install: terminal.install, + }); + yield* invocation.operations.install(request); + if (!terminal.acknowledged()) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider did not install — a handler answered without ` + + `delivering the request to a registered provider`, + ); + } + terminal.close(); + })(); +} + +function installationTerminal( + request: TerminalProviderInstallRequest, + options: TerminalProviderOptions, + present: PresentTerminalGrid, +): { + install: (call: TerminalProviderCall) => Operation; + acknowledged: () => boolean; + close: () => void; +} { + let state: "available" | "inspected" | "acknowledged" | "closed" = "available"; + + return { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + if (call.intent === "install") { + // Reaching the terminal means no registered provider consumed it. + throw new TerminalProviderInstallError(`Unknown terminal 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 TerminalProviderInstallError( + "the live terminal provider installation received a copied, substituted or foreign request", + ); + } + if (call.intent === "inspect") { + if (state !== "available") { + throw new TerminalProviderInstallError( + "this terminal provider installation is reused, completed or stale", + ); + } + state = "inspected"; + return { options, present }; + } + if (state !== "inspected") { + throw new TerminalProviderInstallError( + "this terminal provider acknowledgement is unsolicited, duplicated or stale", + ); + } + state = "acknowledged"; + return undefined; + }, + acknowledged: () => state === "acknowledged", + close() { + state = "closed"; + }, + }; +} diff --git a/packages/terminal/src/state.ts b/packages/terminal/src/state.ts new file mode 100644 index 000000000..eed5cdb99 --- /dev/null +++ b/packages/terminal/src/state.ts @@ -0,0 +1,66 @@ +/** + * What a grid currently wants shown. + * + * One immutable aggregate, not a delta or a command log. A provider reads a + * snapshot and makes the screen say that; it never replays the steps that got + * there. So a later snapshot contains every earlier cell output that is still + * part of the presentation, and a provider that skipped one has lost nothing. + * + * `revision` is live-only and per-grid. It starts at zero, increments exactly + * once per commit that changes the aggregate, and is never authored, retained, + * replayed, diagnosed, or placed in a native or Agent request. It exists so a + * caller can name the state it needs on screen and wait for exactly that. + */ + +/** A live grid's monotonic state counter. */ +export type TerminalGridRevision = number; + +/** + * One live cell's identity. + * + * A symbol, because it is live: minted fresh for each cell of each running + * grid, never written down, and unforgeable by anything that did not receive + * it. It keeps a cell handle, its state, the provider's effects and the + * provider's private endpoint binding together even when positions move. It is + * not authored, retained, replayed, diagnosed, placed in a native or Agent + * request, or used as provider identity. + */ +export type TerminalCellId = symbol; + +/** What a cell is doing, as the provider observes it. */ +export type TerminalCellStatus = + | "starting" + | "launching" + | "running" + | "succeeded" + | "failed" + | "closed"; + +/** What the grid as a whole is doing. */ +export type TerminalGridPhase = "preparing" | "visible" | "closing" | "closed"; + +export interface TerminalCellState { + readonly cellId: TerminalCellId; + /** The label to display. Fixed for the life of the grid. */ + readonly title: string; + readonly row: number; + readonly column: number; + readonly status: TerminalCellStatus; + /** + * The complete rendered Markdown display desired for this cell. + * + * Not an output event: a later snapshot carries everything earlier snapshots + * carried. Terminal bytes a native UI or shell exchanges with the reader + * never enter this state at all. + */ + readonly content: string; +} + +export interface TerminalGridState { + readonly revision: TerminalGridRevision; + readonly phase: TerminalGridPhase; + readonly columns: number; + readonly rows: number; + /** Every cell, in authored order. */ + readonly cells: readonly TerminalCellState[]; +} diff --git a/packages/terminal/src/store.ts b/packages/terminal/src/store.ts new file mode 100644 index 000000000..6a50762a0 --- /dev/null +++ b/packages/terminal/src/store.ts @@ -0,0 +1,288 @@ +/** + * One grid's private state, and the only way to change it. + * + * The store is presentation state, not durability: the journal remains the + * source of replay and recovery, and nothing here is ever written down. What + * it owns is the one immutable aggregate a provider renders, the revision that + * names it, and the serialized lane every change goes through. + * + * The lane exists because cells act concurrently. Two cells appending output + * and a third admitting a launch are three effects running at once, and a + * store that let their commits interleave would publish a revision describing + * neither. Each commit therefore runs to completion — compare, increment, + * publish, notify — before the next one begins. + * + * A subscription registers and takes its first snapshot in the same + * synchronous step. There is deliberately no "read the current state" call to + * pair with a later subscribe: the gap between those two is exactly where a + * commit would be lost, and an interface that cannot express the gap cannot + * have the bug. + */ + +import { createChannel, createQueue, ensure, resource, useScope, withResolvers } from "effection"; +import type { Operation, Queue, Stream } from "effection"; +import { createStore, createThunks, StoreUpdateContext } from "starfx"; + +import { TerminalGridError } from "./errors.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalGridRevision, + TerminalGridState, +} from "./state.ts"; + +/** + * A candidate aggregate built from the one in force. + * + * It carries no revision decision: whether this is a change at all, and what + * revision it becomes, belong to the store. + */ +export type GridChange = (current: TerminalGridState) => TerminalGridState; + +/** One cell's fixed placement, as the store is seeded with it. */ +export interface SeededCell { + readonly cellId: TerminalCellId; + readonly title: string; + readonly row: number; + readonly column: number; + /** What this cell starts at: `starting` live, or a restored outcome. */ + readonly status?: TerminalCellState["status"]; +} + +export interface TerminalGridStoreSeed { + readonly columns: number; + readonly rows: number; + readonly cells: readonly SeededCell[]; + /** + * Where this grid's revision sequence begins. + * + * A live grid always begins at zero, partial replay included. The start is a + * parameter because the refusal at the safe-integer ceiling is otherwise + * unreachable, and a ceiling nothing can reach is a ceiling nothing has + * checked. + */ + readonly startRevision?: TerminalGridRevision; +} + +export interface TerminalGridStore { + /** The aggregate in force. A snapshot read earlier never changes. */ + state(): TerminalGridState; + /** + * Every snapshot from this moment on, beginning with the one in force. + * + * Registration and that first snapshot happen in one synchronous step, and + * everything after it has a strictly greater revision. + */ + readonly states: Stream; + /** Apply one semantic change, serialized against every other. */ + commit(change: GridChange): Operation; + /** + * Mint one more blocking controller on this grid's private thunks instance. + * + * The returned operation runs the controller synchronously in its caller, so + * the caller awaits every state transition and host effect the controller + * owns. Nothing reaches one of these through dispatch. + */ + controller

( + name: string, + body: (payload: P) => Operation, + ): (payload: P) => Operation; +} + +/** Where a revision would stop naming exactly one state. */ +const REVISION_CEILING = Number.MAX_SAFE_INTEGER; + +export function revisionCeilingMessage(revision: TerminalGridRevision): string { + return ( + `this terminal grid has published revision ${revision} and cannot publish another: a ` + + `revision past ${REVISION_CEILING} would stop naming exactly one state, so a waiter could ` + + `be satisfied by a screen it never asked for` + ); +} + +type GridStoreState = { grid: TerminalGridState }; + +function frozenCell(cell: TerminalCellState): TerminalCellState { + return Object.freeze({ + cellId: cell.cellId, + title: cell.title, + row: cell.row, + column: cell.column, + status: cell.status, + content: cell.content, + }); +} + +/** One immutable aggregate, built member by member so nothing shares a draft. */ +function frozenState(state: TerminalGridState): TerminalGridState { + return Object.freeze({ + revision: state.revision, + phase: state.phase, + columns: state.columns, + rows: state.rows, + cells: Object.freeze(state.cells.map(frozenCell)), + }); +} + +/** + * Whether two aggregates describe the same desired presentation. + * + * Revision is excluded on purpose: it is what this answer decides. + */ +function sameAggregate(a: TerminalGridState, b: TerminalGridState): boolean { + if (a.phase !== b.phase || a.columns !== b.columns || a.rows !== b.rows) { + return false; + } + if (a.cells.length !== b.cells.length) { + return false; + } + return a.cells.every((cell, index) => { + const other = b.cells[index]!; + return ( + cell.cellId === other.cellId && + cell.title === other.title && + cell.row === other.row && + cell.column === other.column && + cell.status === other.status && + cell.content === other.content + ); + }); +} + +/** + * The lane every commit runs through. + * + * Each entrant waits for the one ahead of it and releases the one behind it + * from a `finally`, so a caller cancelled while queued hands the lane on + * rather than stranding everybody behind it. + */ +interface CommitLane { + run(body: () => Operation): Operation; +} + +function createCommitLane(): CommitLane { + let tail: Operation | undefined; + return { + *run(body: () => Operation): Operation { + const ahead = tail; + const mine = withResolvers(); + tail = mine.operation; + try { + if (ahead !== undefined) { + yield* ahead; + } + return yield* body(); + } finally { + mine.resolve(); + } + }, + }; +} + +function initialState(seed: TerminalGridStoreSeed): TerminalGridState { + return frozenState({ + revision: seed.startRevision ?? 0, + phase: "preparing", + columns: seed.columns, + rows: seed.rows, + cells: seed.cells.map((cell) => ({ + cellId: cell.cellId, + title: cell.title, + row: cell.row, + column: cell.column, + status: cell.status ?? "starting", + content: "", + })), + }); +} + +/** + * Open one grid's store in the calling scope. + * + * The StarFX store is handed this scope rather than making one of its own, so + * the grid's contexts, its store and its controllers come down together with + * the operation that owns them and nothing survives as an independent root. + */ +export function createTerminalGridStore(seed: TerminalGridStoreSeed): Operation { + return (function* (): Operation { + // A channel of this grid's own. StarFX's default is one module-level + // channel that every store in the process would otherwise share. + yield* StoreUpdateContext.set(createChannel()); + + const scope = yield* useScope(); + let current = initialState(seed); + const store = createStore({ initialState: { grid: current }, scope }); + + const subscribers = new Set>(); + const lane = createCommitLane(); + + const thunks = createThunks(); + thunks.use(thunks.routes()); + // Registered so the store knows these controllers exist. Nothing is + // dispatched to them: every caller runs its controller directly, and the + // supervisors this installs sit waiting for actions that never arrive. + yield* scope.spawn(thunks.register); + + function* publish(change: GridChange): Operation { + const candidate = change(current); + if (sameAggregate(current, candidate)) { + return; + } + if (current.revision >= REVISION_CEILING) { + throw new TerminalGridError(revisionCeilingMessage(current.revision)); + } + const next = frozenState({ ...candidate, revision: current.revision + 1 }); + yield* store.update((state) => { + state.grid = next; + }); + current = store.getState().grid; + // Every registered subscriber, in one synchronous pass: a subscriber + // added while this commit was in flight already holds a snapshot at + // least this new, because registration takes one. + for (const queue of subscribers) { + queue.add(current); + } + } + + const commitController = thunks.create<{ readonly change: GridChange }>( + "terminal.grid.commit", + function* (ctx, next) { + yield* publish(ctx.payload.change); + yield* next(); + }, + ); + + const states: Stream = resource(function* (provide) { + const queue = createQueue(); + // Registered before anything is: a subscriber halted while it registers + // must not leave a queue nobody reads being written to forever. + yield* ensure(() => { + subscribers.delete(queue); + }); + subscribers.add(queue); + queue.add(current); + yield* provide({ next: () => queue.next() }); + }); + + return { + state: () => current, + states, + commit(change) { + return lane.run(function* (): Operation { + yield* commitController.run({ change }); + return current; + }); + }, + controller

(name: string, body: (payload: P) => Operation) { + const created = thunks.create<{ readonly input: P }>(name, function* (ctx, next) { + yield* body(ctx.payload.input); + yield* next(); + }); + return (payload: P) => + (function* (): Operation { + yield* created.run({ input: payload }); + })(); + }, + }; + })(); +} diff --git a/packages/terminal/src/ui.ts b/packages/terminal/src/ui.ts new file mode 100644 index 000000000..2cb816595 --- /dev/null +++ b/packages/terminal/src/ui.ts @@ -0,0 +1,82 @@ +/** + * The domain actions a live grid offers, and how work inside a cell reaches + * its own. + * + * These are action handles, not resource owners. A handle closes over the live + * cell it was issued for, so a component calls `launch()` or `shell()` without + * naming an index or an identifier — and holding one after its grid has closed + * grants nothing, because admission is the lifecycle's state rather than the + * handle's. + * + * Only the cell UI enters a cell's context. The grid UI does not: showing the + * grid is the lifecycle's decision, taken once every cell is ready, and a + * component that could take it would be deciding for its siblings. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; + +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import type { TerminalShellOutcome } from "./host.ts"; +import type { TerminalCellState, TerminalGridState } from "./state.ts"; + +export interface TerminalCellUI { + /** + * This cell's current immutable snapshot. + * + * Read each time: a snapshot read earlier never changes, so two reads that + * straddle a commit are two different objects rather than one that moved. + */ + readonly state: TerminalCellState; + /** + * Hand this cell's terminal to one native UI and wait for it. + * + * Refuses after close admission stops, and refuses while another activity is + * live in this cell. Sequential use is ordinary: the next one is admitted + * once the prior activity has settled and its cleanup has finished. + */ + launch(request: NativeLaunchRequest): Operation; + /** Start the host's default shell in this cell and wait for it. */ + shell(): Operation; +} + +export interface TerminalGridUI { + readonly state: TerminalGridState; + /** Every cell's handle, in authored order. */ + readonly cells: readonly TerminalCellUI[]; + /** + * Commit `visible`, and wait until the provider has presented that exact + * revision. + */ + show(): Operation; +} + +const CellUI: Context = createContext( + "terminal.cellUI", + undefined, +); + +/** + * The cell the current work is running in, or `undefined` outside a grid. + * + * Absence is the ordinary case and means "not in a cell": work outside a grid + * reads nothing here and goes on competing for the root foreground lease + * exactly as it always has. + */ +export function useTerminalCellUI(): Operation { + return CellUI.get(); +} + +/** + * Install one cell's issued handle for the scope that interprets its work. + * + * The handle is installed as it was issued. Wrapping it here would put a + * second object between the cell's work and the one the lifecycle is tracking, + * and the refusals and readiness this seam exists to carry are that object's. + * + * Set rather than composed: a cell is not a layer over an enclosing cell, + * because cells do not nest. + */ +export function* installTerminalCellUI(ui: TerminalCellUI): Operation { + yield* CellUI.set(ui); +} diff --git a/packages/terminal/test/launcher.ts b/packages/terminal/test/launcher.ts new file mode 100644 index 000000000..422b056a4 --- /dev/null +++ b/packages/terminal/test/launcher.ts @@ -0,0 +1,61 @@ +/** + * 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. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; + +import { NativeLauncher } from "../mod.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "../mod.ts"; + +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/terminal/test/mod.ts b/packages/terminal/test/mod.ts new file mode 100644 index 000000000..5175282af --- /dev/null +++ b/packages/terminal/test/mod.ts @@ -0,0 +1,27 @@ +/** + * Controlled providers, launchers, gates and records. + * + * Nothing here presents anything, opens a terminal, looks for a multiplexer, + * or starts a process. It exists so that the grammar, authority, lifecycle, + * convergence, settlement and replay contracts can be proved without a + * production provider anywhere in the picture. + */ + +export { installControlledLauncher } from "./launcher.ts"; +export type { ControlledLauncherOptions } from "./launcher.ts"; + +export { + controlledTerminalProvider, + rendererEndedMessage, + settled, + subscriptionEndedMessage, + terminalProviderLog, +} from "./provider.ts"; +export type { + ControlledProviderOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "./provider.ts"; + +export { barrier, gate } from "./signal.ts"; +export type { Barrier, Gate } from "./signal.ts"; diff --git a/packages/terminal/test/provider.ts b/packages/terminal/test/provider.ts new file mode 100644 index 000000000..945ce57d2 --- /dev/null +++ b/packages/terminal/test/provider.ts @@ -0,0 +1,419 @@ +/** + * A terminal provider that presents nothing and records everything. + * + * It opens no terminal, looks for no multiplexer, and starts no process — and + * it answers the whole host contract, which is what makes it evidence that the + * contract does not depend on tmux. Everything a row needs to read is a record + * or a counter this keeps, so ordering claims are read rather than timed. + * + * The renderer here is the reference shape a real provider has to match: one + * scope-owned lane, coalescing forward to the newest pending snapshot, never + * applying an older revision after a newer one, and advancing what it has + * applied only once the whole render effect for that snapshot has succeeded. + */ + +import { ensure, race, resource, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; + +import type { + NativeLaunchOutcome, + NativeLaunchRequest, + TerminalActivity, + TerminalCellId, + TerminalCellStatus, + TerminalGridHost, + TerminalGridProvider, + TerminalGridRequest, + TerminalGridRevision, + TerminalGridState, + TerminalGridView, + TerminalShellOutcome, +} from "../mod.ts"; + +/** What one controlled provider holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Hosts acquired and not yet released. */ + grids: number; + /** Hosts shown and not yet released. */ + shown: number; + /** Terminal activities acquired and not yet released. */ + activities: number; +} + +/** + * Everything one controlled provider did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every cell started, that nothing was shown before the readiness + * barrier, and that release took down exactly the host it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; + /** Every snapshot the renderer fully applied, in the order it applied them. */ + readonly applied: TerminalGridState[]; + /** + * What each cell displays, by authored position, as of the last applied + * snapshot. + * + * A suite reads this to prove where a cell'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 host 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: TerminalProviderResources; +} + +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { + events: [], + applied: [], + shown: new Map(), + live: { grids: 0, shown: 0, activities: 0 }, + }; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a host exists, `render` gates or fails one revision's render, + * `onShow` fails the barrier, `launch` and `shell` decide what a cell's child + * did and whether it started at all, `close` is the operation the host waits on + * so a suite controls exactly when the reader leaves, and `fail` is the + * background failure a suite raises while nothing is waiting on the renderer. + */ +export interface ControlledProviderOptions { + /** Appended to as the host works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onDestroy?: () => Operation; + /** + * The render effect for one chosen snapshot. + * + * A suite that blocks here holds the lane, which is how coalescing becomes + * observable: the revisions that arrive while this is blocked are subsumed by + * the newest one, and only that one is rendered next. + */ + render?: (state: TerminalGridState) => Operation; + onShow?: (revision: TerminalGridRevision) => Operation; + /** The terminal activity for one cell's native launch. */ + launch?: ( + position: number, + request: NativeLaunchRequest, + ) => TerminalActivity; + /** + * The terminal activity for one cell's shell. + * + * A suite that wants a shell which never starts supplies one that throws + * before it provides: the cell then never becomes ready, exactly as a real + * spawn failure leaves it. + */ + shell?: (position: number) => TerminalActivity; + /** Settles when the reader leaves. Never, by default. */ + close?: () => Operation; + /** + * Settles with a background provider failure. + * + * Independent of `close`: a suite uses it to fail the grid while no + * foreground action is waiting on the renderer at all. + */ + fail?: () => Operation; + /** + * Settles when the renderer should stop applying revisions. + * + * A lane that stops while the host is still acquired is a provider failure, + * and this is how a suite produces one without failing a render. + */ + stopRenderer?: () => Operation; + /** + * Settles when the state subscription should stop delivering. + * + * A view that stops while the host is still acquired is a provider failure + * too, and is reported as one. The stream's own type says it cannot close, so + * this is how a suite reaches that report without building a value the + * contract has no way to express. + */ + stopSubscription?: () => Operation; +} + +/** An outcome that is already settled, for a child that needed no waiting. */ +export function settled(outcome: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return outcome; + })(); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +export function subscriptionEndedMessage(): string { + return ( + "the terminal provider's state subscription ended while its grid was still acquired: a " + + "view that stops delivering is a provider failure, not a converged grid" + ); +} + +export function rendererEndedMessage(): string { + return ( + "the terminal provider's renderer stopped while its grid was still acquired: a lane that " + + "stops applying revisions is a provider failure, not a converged grid" + ); +} + +/** + * One controlled provider. + * + * `generation` counts the hosts it has acquired, so a suite can tell a second + * host apart from the first in the record. + */ +export function controlledTerminalProvider( + options: ControlledProviderOptions = {}, +): TerminalGridProvider { + const log = options.log ?? terminalProviderLog(); + let generation = 0; + + return { + host(request: TerminalGridRequest, view: TerminalGridView): Operation { + return resource(function* (provide) { + const mark = generation++; + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${mark}:${request.columns}x${request.rows}`); + log.live.grids++; + let isShown = false; + + // Registered before the host is provided, so every way out of the + // resource runs it once: settled, failed to start, closed, failed by + // the provider, or cancelled. + yield* ensure(function* () { + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${mark}`); + log.live.grids--; + if (isShown) { + isShown = false; + log.live.shown--; + } + }); + + const failure = withResolvers(); + const fail = (error: unknown): void => failure.resolve(toError(error)); + + // Acquired in the host's own scope and released with it, so a + // subscription cannot outlive the grid it describes. + const states = yield* view.states; + const first = yield* states.next(); + if (first.done) { + throw new Error(subscriptionEndedMessage()); + } + // Revision zero, received atomically with the registration that + // produced it. The cell order it carries is how this provider maps a + // live identity to the authored position it reports in the record. + const order: TerminalCellId[] = first.value.cells.map((cell) => cell.cellId); + const positionOf = (cellId: TerminalCellId): number => { + const position = order.indexOf(cellId); + if (position < 0) { + throw new Error("this terminal grid host was asked about a cell it never received"); + } + return position; + }; + + let pending: TerminalGridState | undefined = first.value; + let applied = -1; + const waiters = new Set<{ readonly required: number; readonly wake: () => void }>(); + let awake = withResolvers(); + const nudge = (): void => { + awake.resolve(); + }; + const statuses = new Map(); + + const satisfy = (): void => { + for (const waiter of [...waiters]) { + if (applied >= waiter.required) { + waiters.delete(waiter); + waiter.wake(); + } + } + }; + + // The subscription pump. It keeps only the newest snapshot, because a + // newer aggregate subsumes every older one — which is what makes + // coalescing forward correct rather than lossy. + function* pump(): Operation { + while (true) { + const next = yield* states.next(); + if (next.done) { + // The stream's type says this cannot happen, so reaching it means + // the view came from somewhere that does not honour the contract. + throw new Error(subscriptionEndedMessage()); + } + if (pending === undefined || next.value.revision > pending.revision) { + pending = next.value; + } + nudge(); + } + } + + yield* spawn(function* (): Operation { + try { + yield* race([ + pump(), + (function* (): Operation { + yield* options.stopSubscription ? options.stopSubscription() : suspend(); + })(), + ]); + fail(new Error(subscriptionEndedMessage())); + } catch (error) { + fail(error); + } + }); + + // One lane, and only one. It never starts two renders at once, never + // applies a revision at or below the greatest it has completed, and + // advances what it has applied only after the whole render effect for + // the chosen snapshot has succeeded. + function* renderLane(): Operation { + while (true) { + const chosen = pending; + if (chosen === undefined || chosen.revision <= applied) { + // Re-armed and re-checked before suspending, so a snapshot that + // arrived between the two is not waited for forever. + awake = withResolvers(); + if (pending !== undefined && pending.revision > applied) { + continue; + } + yield* awake.operation; + continue; + } + pending = undefined; + if (options.render) { + yield* options.render(chosen); + } + applied = chosen.revision; + log.applied.push(chosen); + log.events.push(`render:${mark}:${chosen.revision}`); + for (const [position, cell] of chosen.cells.entries()) { + log.shown.set(position, cell.content); + if (statuses.get(position) !== cell.status) { + statuses.set(position, cell.status); + log.events.push(`status:${mark}:${position}:${cell.status}`); + } + } + satisfy(); + } + } + + yield* spawn(function* (): Operation { + try { + yield* race([ + renderLane(), + (function* (): Operation { + yield* options.stopRenderer ? options.stopRenderer() : suspend(); + })(), + ]); + fail(new Error(rendererEndedMessage())); + } catch (error) { + fail(error); + } + }); + + const failHook = options.fail; + if (failHook) { + yield* spawn(function* (): Operation { + try { + fail(yield* failHook()); + } catch (error) { + fail(error); + } + }); + } + + const converge = (required: TerminalGridRevision): Operation => ({ + *[Symbol.iterator]() { + // Recorded before it is answered, so a suite reads *that* the grid + // asked for a screen, and when, rather than inferring it from what + // happened next. + log.events.push(`converge:${mark}:${required}`); + if (applied >= required) { + return; + } + const reached = withResolvers(); + const waiter = { required, wake: () => reached.resolve() }; + waiters.add(waiter); + try { + yield* reached.operation; + } finally { + waiters.delete(waiter); + } + }, + }); + + yield* provide({ + closed: { + *[Symbol.iterator]() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${mark}`); + }, + }, + failed: { + *[Symbol.iterator]() { + return yield* failure.operation; + }, + }, + converge, + *show(required: TerminalGridRevision) { + yield* converge(required); + if (options.onShow) { + yield* options.onShow(required); + } + log.events.push(`show:${mark}:${required}`); + isShown = true; + log.live.shown++; + }, + launch(cellId: TerminalCellId, nativeRequest: NativeLaunchRequest) { + const position = positionOf(cellId); + return resource>(function* (provideOutcome) { + const outcome = options.launch + ? yield* options.launch(position, nativeRequest) + : settled({ exitCode: 0 }); + log.events.push(`launch:${mark}:${position}`); + log.live.activities++; + yield* ensure(() => { + log.live.activities--; + }); + yield* provideOutcome(outcome); + }); + }, + shell(cellId: TerminalCellId) { + const position = positionOf(cellId); + return resource>(function* (provideOutcome) { + const outcome = options.shell + ? yield* options.shell(position) + : settled({ exitCode: 0 }); + log.events.push(`shell:${mark}:${position}`); + log.live.activities++; + yield* ensure(() => { + log.live.activities--; + }); + yield* provideOutcome(outcome); + }); + }, + }); + }); + }, + }; +} diff --git a/packages/terminal/test/signal.ts b/packages/terminal/test/signal.ts new file mode 100644 index 000000000..cebae2568 --- /dev/null +++ b/packages/terminal/test/signal.ts @@ -0,0 +1,77 @@ +/** + * Gates, for suites that need ordering evidence rather than elapsed time. + * + * A grid that converged too early and one that converged on time take the same + * wall clock, so nothing here measures duration. A gate is opened by something + * that happened, and a row that waits on one either gets the event it named or + * hangs — which is a failure the suite can see, not a pass it cannot trust. + */ + +import { withResolvers } from "effection"; +import type { Operation } from "effection"; + +export interface Gate { + /** Settles once the gate has been opened, however long ago. */ + readonly opened: Operation; + /** Open it. Opening a gate twice is opening it once. */ + open(): void; + /** Whether it has been opened. */ + readonly isOpen: boolean; +} + +export function gate(): Gate { + const resolvers = withResolvers(); + let open = false; + return { + opened: resolvers.operation, + open() { + if (open) { + return; + } + open = true; + resolvers.resolve(); + }, + get isOpen() { + return open; + }, + }; +} + +/** + * A gate that opens once `expected` things have arrived. + * + * Written for the rows that prove concurrency: several cells each announce + * that they are inside their interactive work, and the gate opens only when + * all of them are inside at the same time. Cells that contended could never + * open it. + */ +export interface Barrier extends Gate { + /** Announce one arrival. */ + arrive(): void; + /** How many have arrived. */ + readonly arrived: number; +} + +export function barrier(expected: number): Barrier { + const inner = gate(); + let arrived = 0; + if (expected <= 0) { + inner.open(); + } + return { + opened: inner.opened, + open: inner.open, + get isOpen() { + return inner.isOpen; + }, + arrive() { + arrived += 1; + if (arrived >= expected) { + inner.open(); + } + }, + get arrived() { + return arrived; + }, + }; +} diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts similarity index 98% rename from packages/runtime/tests/native-launcher.test.ts rename to packages/terminal/tests/native-launcher.test.ts index c39546b73..65778ae2a 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -23,14 +23,8 @@ import * as path from "node:path"; import * as os from "node:os"; import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; -import { - flushOutput, - installForegroundLauncher, - nativeLaunch, - NO_TERMINAL, - reap, - reserveTerminal, -} from "../launcher.ts"; +import { flushOutput, nativeLaunch, reserveTerminal } from "../mod.ts"; +import { installForegroundLauncher, NO_TERMINAL, reap } from "../posix.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; diff --git a/packages/terminal/tests/terminal-grid.test.ts b/packages/terminal/tests/terminal-grid.test.ts new file mode 100644 index 000000000..eec0f6fdb --- /dev/null +++ b/packages/terminal/tests/terminal-grid.test.ts @@ -0,0 +1,1270 @@ +/** + * Tier TG — the provider-neutral terminal grid lifecycle (architecture.md + * §Terminal grid presentation, §Atomic presentation and settlement, + * §Durability and replay). + * + * These rows drive `terminalGrid()` directly, with cell work written by hand + * and a journal that retains nothing. What they prove is the part of a grid + * that has no document in it: who may present one, what owns the running + * grid, how the immutable state advances, when an action is allowed to ask the + * provider for a terminal, and what teardown must have finished before any of + * it settles. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + * Every ordering claim is read off a record or a gate, because a grid that + * converged too early and one that converged on time take the same wall clock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { each, ensure, race, resource, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; + +import { + installTerminalProvider, + registerTerminalProvider, + reserveTerminal, + TerminalGridPresentationError, + TerminalGrids, + terminalGridLayout, + TerminalProviderInstallError, + TerminalProviders, + useTerminalCellUI, +} from "../mod.ts"; +import type { + PlacedCell, + RetainedCellOutcome, + RetainedGrid, + TerminalActivity, + TerminalCellUI, + TerminalGridHost, + TerminalGridJournal, + TerminalGridLayout, + TerminalGridProvider, + TerminalGridRequest, + TerminalGridState, + TerminalShellOutcome, +} from "../mod.ts"; +import { appendTerminalCellOutput, terminalGrid, useTerminalInstallation } from "../lifecycle.ts"; +import type { PresentTerminalGrid } from "../lifecycle.ts"; +import { + barrier, + controlledTerminalProvider, + gate, + installControlledLauncher, + terminalProviderLog, +} from "../test/mod.ts"; +import type { + ControlledProviderOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "../test/mod.ts"; +import { createTerminalGridStore, revisionCeilingMessage } from "../src/store.ts"; + +/** A journal with nothing behind it: every retention is its live operation. */ +function directJournal(record: string[] = []): TerminalGridJournal { + return { + // deno-lint-ignore require-yield + *reconcileLayout() { + record.push("reconcile"); + }, + retainGrid: (operation) => operation, + retainCell: (position, operation) => + (function* (): Operation { + record.push(`retain:${position}`); + return yield* operation; + })(), + }; +} + +/** A layout of `titles.length` cells across `columns`, all paired. */ +function layoutOf(columns: number, titles: readonly string[]): TerminalGridLayout { + const cells = titles.map((title): PlacedCell => ({ title, form: "paired" })); + return terminalGridLayout(columns, cells); +} + +function refusalOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Everything a controlled host installs, for an in-process grid. */ +function useHost(provider: TerminalGridProvider): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, present) { + yield* TerminalGrids.around( + { + *open([request]) { + yield* present(request, provider); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; + })(); +} + +/** Cell work that acquires one shell and settles with it. */ +function shellCell(marks?: string[], mark = ""): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("this cell work ran outside the scope that issued its handle"); + } + marks?.push(`enter:${mark}`); + yield* cell.shell(); + marks?.push(`leave:${mark}`); + })(); +} + +/** One ordered `{cellId, operation}` record per position. */ +function cellWork( + titles: readonly string[], + body: (position: number) => Operation, +): { cellId: symbol; operation: Operation }[] { + return titles.map((title, position) => ({ + cellId: Symbol(`cell:${position}:${title}`), + operation: body(position), + })); +} + +describe("Tier TG — owning one grid", () => { + it("TG22: cell work is inert until interpreted, and interpreted exactly once", function* () { + const constructed: number[] = []; + const entered: number[] = []; + const log = terminalProviderLog(); + + const retained = yield* scoped(function* (): Operation { + const settled = settledCells(2); + yield* useHost( + controlledTerminalProvider({ log, close: () => settled.opened, render: settled.render }), + ); + const titles = ["a", "b"]; + const cells = titles.map((title, position) => { + // Built here and not entered: constructing a cell's operation performs + // no expansion, shell, provider or journal work at all. + constructed.push(position); + return { + cellId: Symbol(title), + operation: (function* (): Operation { + entered.push(position); + yield* shellCell(); + })(), + }; + }); + expect(entered).toEqual([]); + + const task = yield* terminalGrid(layoutOf(2, titles), cells, directJournal()); + return yield* task; + }); + + expect(constructed).toEqual([0, 1]); + // Once each, in authored order, and never a second time. + expect(entered.slice().sort()).toEqual([0, 1]); + expect(retained.cells).toHaveLength(2); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG22: the returned task is owned by the submitting scope", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let heldWhileLive: TerminalProviderResources | undefined; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + + // The grid is live — its host acquired, its cell holding an activity — + // and the row's own branch then wins the race, cancelling the submitting + // operation and nothing else. + yield* race([ + (function* (): Operation { + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell(finalized, "cell")), + directJournal(), + ); + yield* task; + })(), + (function* (): Operation { + yield* live.opened; + heldWhileLive = { ...log.live }; + })(), + ]); + }); + + expect(heldWhileLive).toEqual({ grids: 1, shown: 0, activities: 1 }); + // The cell's activity was released and the provider's host with it: nothing + // detached, and every finalizer ran. + expect(finalized).toEqual(["cell"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + }); + + it("TG22: releasing the resource early cancels and awaits cells, renderer and host", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + + yield* scoped(function* () { + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + ...heldShell(live), + // deno-lint-ignore require-yield + *onDestroy() { + finalized.push("host"); + }, + }), + ); + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell(finalized, "cell")), + directJournal(), + ); + // Deliberately not awaited: the row releases the resource by + // cancelling the scope that holds it, which is the case the contract + // is about. + void grid; + yield* suspend(); + }); + }); + yield* live.opened; + yield* task.halt(); + }); + + // The cell's finalizer ran, the host was destroyed once, and nothing the + // provider handed out is still held. + expect(finalized).toEqual(["cell", "host"]); + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG22: a layout and its cell work must agree, and no provider is reached", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useHost(controlledTerminalProvider({ log })); + try { + const task = yield* terminalGrid( + layoutOf(2, ["a", "b"]), + cellWork(["a"], () => shellCell()), + directJournal(), + ); + yield* task; + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("places 2 cells and was given 1 cell operations"); + expect(log.events).toEqual([]); + }); + + it("TG22: one live identity per authored position, and never the same one twice", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + const shared = Symbol("shared"); + + yield* scoped(function* () { + yield* useHost(controlledTerminalProvider({ log })); + try { + const task = yield* terminalGrid( + layoutOf(2, ["a", "b"]), + [ + { cellId: shared, operation: shellCell() }, + { cellId: shared, operation: shellCell() }, + ], + directJournal(), + ); + yield* task; + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("the same live cell identity twice"); + expect(log.events).toEqual([]); + }); + + it("TG23: the foreground lease admits one grid at a time", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let refusal: unknown; + + // Caught around the region rather than around the await. A grid that fails + // raises into the scope that submitted it, which is how a document run + // learns about it; a `catch` at the await would never see it. + try { + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + yield* spawn(function* () { + const task = yield* terminalGrid( + layoutOf(1, ["first"]), + cellWork(["first"], () => holdingCell(finalized, "first")), + directJournal(), + ); + yield* task; + }); + yield* live.opened; + + // The foreground-terminal lease admits a single grid at a time, so a + // second never reaches presentation at all. + const second = yield* terminalGrid( + layoutOf(1, ["second"]), + cellWork(["second"], () => shellCell()), + directJournal(), + ); + yield* second; + }); + } catch (error) { + refusal = error; + } + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + // Only one host was ever prepared. + expect(log.events.filter((event) => event.startsWith("prepare:"))).toEqual(["prepare:0:1x1"]); + expect(finalized).toEqual(["first"]); + }); + + it("TG23: a root native launch cannot take the lease a live grid holds", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + yield* spawn(function* () { + const task = yield* terminalGrid( + layoutOf(1, ["grid"]), + cellWork(["grid"], () => holdingCell(finalized, "grid")), + directJournal(), + ); + yield* task; + }); + yield* live.opened; + + try { + yield* scoped(function* () { + yield* reserveTerminal(); + }); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + }); +}); + +/** Cell work that starts something interactive and stays until it is stopped. */ +function holdingCell(finalized: string[], mark: string): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("this cell work ran outside the scope that issued its handle"); + } + yield* ensure(() => { + finalized.push(mark); + }); + yield* cell.shell(); + })(); +} + +/** + * A shell that starts and never finishes, and a gate opened once it is running. + * + * `running` is committed after the activity has been acquired, so a row that + * waits on this gate is waiting for the provider to be holding something. + */ +function heldShell(live: { open(): void }): Pick { + return { + shell: () => + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }), + // deno-lint-ignore require-yield + *render(state: TerminalGridState) { + if (state.cells.some((cell) => cell.status === "running")) { + live.open(); + } + }, + }; +} + +/** + * A render hook that opens its gate once `count` cells have settled. + * + * The gate is opened by a snapshot the renderer actually applied, so a row that + * waits on it waits for an event rather than for a duration. + */ +function settledCells(count: number): { + readonly opened: Operation; + render: (state: TerminalGridState) => Operation; +} { + const reached = gate(); + return { + opened: reached.opened, + // deno-lint-ignore require-yield + *render(state: TerminalGridState) { + const settled = state.cells.filter( + (cell) => + cell.status === "succeeded" || cell.status === "failed" || cell.status === "closed", + ).length; + if (settled >= count) { + reached.open(); + } + }, + }; +} + +describe("Tier TG — the private aggregate", () => { + const seed = () => ({ + columns: 2, + rows: 1, + cells: [ + { cellId: Symbol("a"), title: "Left", row: 0, column: 0 }, + { cellId: Symbol("b"), title: "Right", row: 0, column: 1 }, + ], + }); + + it("TG24: the first snapshot is revision zero", function* () { + const store = yield* createTerminalGridStore(seed()); + const first = store.state(); + expect(first.revision).toBe(0); + expect(first.phase).toBe("preparing"); + expect(first.cells.map((cell) => cell.status)).toEqual(["starting", "starting"]); + expect(first.cells.map((cell) => cell.content)).toEqual(["", ""]); + }); + + it("TG24: a subscription starts from the snapshot in force, with no gap after it", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + // Something has already happened before anybody subscribes. + yield* store.commit((state) => ({ ...state, phase: "visible" })); + + const states = yield* store.states; + // Commits that land after the subscription exists. + yield* store.commit((state) => withStatus(state, 0, "launching")); + yield* store.commit((state) => withStatus(state, 1, "launching")); + + for (let read = 0; read < 3; read++) { + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + } + }); + + // The revision in force when the subscription began, and then every later + // one in order: nothing between the registration and the first snapshot. + expect(seen).toEqual([1, 2, 3]); + }); + + it("TG24: no commit is lost between registering a subscription and its first snapshot", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + const subscribed = withResolvers(); + const reading = yield* spawn(function* () { + const states = yield* store.states; + subscribed.resolve(); + for (let read = 0; read < 2; read++) { + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + } + }); + // Issued while the subscription is being acquired. Registration and the + // first snapshot are one step, so this commit is either already in that + // snapshot or is the next emission — it cannot fall between them. + yield* store.commit((state) => withStatus(state, 0, "launching")); + yield* subscribed.operation; + yield* store.commit((state) => withStatus(state, 1, "launching")); + yield* reading; + }); + + expect(seen).toHaveLength(2); + // Contiguous: a gap here is a commit that happened while nobody was + // listening and nobody ever heard about. + expect(seen[1]).toBe(seen[0]! + 1); + expect(seen[1]).toBe(2); + }); + + it("TG24: a commit that changes nothing creates no revision and no emission", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + const states = yield* store.states; + const first = yield* states.next(); + expect(first.done).toBe(false); + + // Same phase, same statuses, same content: a no-op. + const unchanged = yield* store.commit((state) => ({ ...state })); + expect(unchanged.revision).toBe(0); + const stillZero = yield* store.commit((state) => withStatus(state, 0, "starting")); + expect(stillZero.revision).toBe(0); + // Empty appended content is also a no-op. + const stillZeroAgain = yield* store.commit((state) => withContent(state, 0, "")); + expect(stillZeroAgain.revision).toBe(0); + + const changed = yield* store.commit((state) => withStatus(state, 0, "launching")); + expect(changed.revision).toBe(1); + + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + }); + + // One emission, for the one commit that changed the aggregate. + expect(seen).toEqual([1]); + }); + + it("TG24: a snapshot read earlier never changes, and a title is fixed", function* () { + const store = yield* createTerminalGridStore(seed()); + const before = store.state(); + yield* store.commit((state) => withContent(state, 0, "hello")); + const after = store.state(); + + expect(before.revision).toBe(0); + expect(before.cells[0]!.content).toBe(""); + expect(after.cells[0]!.content).toBe("hello"); + expect(Object.isFrozen(after)).toBe(true); + expect(Object.isFrozen(after.cells)).toBe(true); + expect(Object.isFrozen(after.cells[0]!)).toBe(true); + // Titles come from the authored layout and nothing moves them. + expect(after.cells.map((cell) => cell.title)).toEqual(["Left", "Right"]); + expect(after.cells.map((cell) => [cell.row, cell.column])).toEqual([ + [0, 0], + [0, 1], + ]); + }); + + it("TG24: refuses before publishing past the safe-integer ceiling", function* () { + const seen: number[] = []; + let refusal: unknown; + yield* scoped(function* () { + const store = yield* createTerminalGridStore({ + ...seed(), + startRevision: Number.MAX_SAFE_INTEGER, + }); + const states = yield* store.states; + const first = yield* states.next(); + if (!first.done) { + seen.push(first.value.revision); + } + try { + yield* store.commit((state) => withStatus(state, 0, "launching")); + } catch (error) { + refusal = error; + } + // Nothing was published: the aggregate in force is the one it was. + expect(store.state().revision).toBe(Number.MAX_SAFE_INTEGER); + expect(store.state().cells[0]!.status).toBe("starting"); + }); + + expect(refusalOf(refusal)).toBe(revisionCeilingMessage(Number.MAX_SAFE_INTEGER)); + expect(seen).toEqual([Number.MAX_SAFE_INTEGER]); + }); +}); + +function withStatus( + state: TerminalGridState, + position: number, + status: TerminalGridState["cells"][number]["status"], +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell, index) => (index === position ? { ...cell, status } : cell)), + }; +} + +function withContent(state: TerminalGridState, position: number, text: string): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell, index) => + index === position ? { ...cell, content: cell.content + text } : cell, + ), + }; +} + +/** + * A provider that records every effect it could possibly have. + * + * Lazy on purpose: nothing in here runs until something acquires it. A refusal + * that happens first therefore leaves the record empty, which is the only way + * to tell "refused before the provider was touched" from "refused after". + */ +function watchedProvider(effects: string[], label: string): TerminalGridProvider { + return { + host(_request: TerminalGridRequest, view): Operation { + return resource(function* (provide) { + effects.push(`acquired:${label}`); + yield* ensure(() => { + effects.push(`released:${label}`); + }); + const states = yield* view.states; + const first = yield* states.next(); + if (first.done) { + throw new Error("the state subscription ended"); + } + const order = first.value.cells.map((cell) => cell.cellId); + yield* provide({ + closed: { *[Symbol.iterator]() {} }, + failed: { + *[Symbol.iterator]() { + yield* suspend(); + throw new Error("unreachable"); + }, + }, + // deno-lint-ignore require-yield + *converge() {}, + // deno-lint-ignore require-yield + *show() { + effects.push(`show:${label}`); + }, + launch: () => + resource>(function* (provideOutcome) { + effects.push(`launch:${label}`); + yield* provideOutcome(settledOutcome({ exitCode: 0 })); + }), + shell: (cellId) => + resource>(function* (provideOutcome) { + effects.push(`shell:${label}:${order.indexOf(cellId)}`); + yield* provideOutcome(settledOutcome({ exitCode: 0 })); + }), + }); + }); + }, + }; +} + +interface NativeLaunchOutcomeShape { + exitCode?: number; + signal?: string; +} + +function settledOutcome(value: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return value; + })(); +} + +describe("Tier TG — refusing a presentation before the provider is touched", () => { + /** Drive one grid, letting the row decide what the provider presents. */ + function underProvider( + present: (present: PresentTerminalGrid, request: TerminalGridRequest) => Operation, + opened: string[], + ): Operation { + return scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + yield* present(presentGrid, request); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + try { + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => shellCell(opened, "a")), + directJournal(), + ); + return yield* task; + } catch (error) { + return error; + } + }); + } + + it("TR1: a copied request is refused, and the copy's host is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + try { + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + // Same members, a different object. Identity is what is read. + const copy = { + columns: request.columns, + rows: request.rows, + cells: request.cells.map((cell) => ({ ...cell })), + }; + try { + yield* present(copy, watchedProvider(effects, "copy")); + } catch (error) { + refusal = error; + } + }, opened); + }); + } catch { + // The grid refuses for want of a presentation; the refusal this row reads + // is the one presentation itself produced. + } + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR2: a changed request is refused, and its host is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + try { + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + try { + yield* present( + { ...request, columns: request.columns + 1 }, + watchedProvider(effects, "changed"), + ); + } catch (error) { + refusal = error; + } + }, opened); + }); + } catch { + // As above. + } + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR3: the exact request is refused once it is stale", function* () { + const effects: string[] = []; + const opened: string[] = []; + let kept: { present: PresentTerminalGrid; request: TerminalGridRequest } | undefined; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + kept = { present, request }; + yield* present(request, watchedProvider(effects, "live")); + }, opened); + }); + + // The grid ran and finished, so its submitting operation has unwound and + // the request it issued is no longer anything to present for. This watched + // host's reader leaves the moment it is asked, so the cell is closed while + // its shell is still live and never records a departure of its own. + expect(opened).toEqual(["enter:a"]); + expect(effects).toEqual(["acquired:live", "shell:live:0", "show:live", "released:live"]); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.present(kept!.request, watchedProvider(effects, "stale")); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("is not live"); + // Nothing new: the stale host was never acquired. + expect(effects).toEqual(["acquired:live", "shell:live:0", "show:live", "released:live"]); + }); + + it("TR4: a second presentation of the exact live request is refused", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + yield* present(request, watchedProvider(effects, "first")); + try { + yield* present(request, watchedProvider(effects, "second")); + } catch (error) { + refusal = error; + } + }, opened); + }); + + expect(refusalOf(refusal)).toContain("already been presented"); + // One host acquired and released; the second was never touched. + expect(effects.filter((effect) => effect.includes("second"))).toEqual([]); + }); + + it("TR5: the exact live request is refused under another installation generation", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + // A second installation supersedes the one this grid was issued + // under. It shares the lookup, so it *finds* this request — and + // turns it away for belonging to another installation. + const superseding = yield* useTerminalInstallation(); + try { + yield* superseding(request, watchedProvider(effects, "wrong-generation")); + } catch (error) { + refusal = error; + } + // Then the right one presents, so the grid still settles. + yield* presentGrid(request, watchedProvider(effects, "right-generation")); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => shellCell(opened, "a")), + directJournal(), + ); + yield* task; + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("belongs to another terminal provider installation"); + // The refused generation's host was never acquired; only the admitted one. + expect(effects.filter((effect) => effect.includes("wrong-generation"))).toEqual([]); + expect(effects).toContain("acquired:right-generation"); + expect(effects).toContain("released:right-generation"); + }); + + it("TR6: a presentation function kept past its execution presents nothing", function* () { + const effects: string[] = []; + const opened: string[] = []; + let kept: PresentTerminalGrid | undefined; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + kept = present; + yield* present(request, watchedProvider(effects, "live")); + }, opened); + }); + + let refusal: unknown; + yield* scoped(function* () { + const asked: TerminalGridRequest = { + columns: 1, + rows: 1, + cells: [{ title: "x", row: 0, column: 0, form: "self-closing" }], + }; + try { + yield* kept!(asked, watchedProvider(effects, "unrouted")); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects.filter((effect) => effect.includes("unrouted"))).toEqual([]); + }); + + it("TR7: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + const present = yield* useTerminalInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerTerminalProvider("real", function* () {}); + yield* TerminalProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); + try { + yield* installTerminalProvider("real", { label: "real" }, present); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusalOf(refusal)).toContain("did not install"); + }); +}); + +describe("Tier TG — convergence before transfer", () => { + it("TG24: show stays blocked until the revision it committed is applied", function* () { + const log = terminalProviderLog(); + const held = gate(); + const order: string[] = []; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + *render(state) { + if (state.phase === "visible") { + order.push(`blocked:${state.revision}`); + // Every cell is already running, so nothing but the render is + // keeping the grid hidden. + order.push(`statuses:${state.cells.map((cell) => cell.status).join(",")}`); + order.push(`shown-before:${log.events.some((event) => event.startsWith("show:"))}`); + yield* held.opened; + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell([], "a")), + directJournal(), + ); + yield* grid; + }); + // The renderer is blocked on the `visible` revision, so the grid cannot + // have been shown yet however long this waits. + yield* untilRecorded(order, 3); + expect(log.events.some((event) => event.startsWith("show:"))).toBe(false); + held.open(); + yield* untilEvent(log, (event) => event.startsWith("show:")); + yield* task.halt(); + }); + + expect(order[1]).toBe("statuses:running"); + expect(order[2]).toBe("shown-before:false"); + // The revision `show()` committed is the revision the host was asked for. + const blocked = order[0]!.split(":")[1]; + expect(log.events).toContain(`show:0:${blocked}`); + }); + + it("TG24: a cell action waits for convergence, and a cancelled wait transfers nothing", function* () { + const log = terminalProviderLog(); + const reached = gate(); + const observed: TerminalGridState[] = []; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + *render(state) { + if (state.cells.some((cell) => cell.status === "launching")) { + observed.push(state); + reached.open(); + // Held forever: convergence never completes, so the action can + // never reach the host. + yield* suspend(); + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => outputThenShell("first line\n")), + directJournal(), + ); + yield* grid; + }); + yield* reached.opened; + // Cancelled while the action is waiting for the screen it asked for. + yield* task.halt(); + }); + + // The revision the action captured already contains the output written + // before it, which is what makes convergence through it meaningful. + expect(observed).toHaveLength(1); + expect(observed[0]!.cells[0]!.content).toBe("first line\n"); + expect(observed[0]!.cells[0]!.status).toBe("launching"); + // No child call, no readiness, and nothing was shown. + expect(log.events.filter((event) => event.startsWith("shell:"))).toEqual([]); + expect(log.events.filter((event) => event.startsWith("launch:"))).toEqual([]); + expect(log.events.some((event) => event.startsWith("show:"))).toBe(false); + expect(log.applied.some((state) => state.cells.some((cell) => cell.status === "running"))).toBe( + false, + ); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG24: the host is reached only after the revision the action captured is applied", function* () { + const log = terminalProviderLog(); + const reached = gate(); + const release = gate(); + let launching = -1; + + yield* scoped(function* () { + const settled = settledCells(1); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + *render(state) { + yield* settled.render(state); + if (state.cells.some((cell) => cell.status === "launching") && launching < 0) { + launching = state.revision; + reached.open(); + // The lane is held here, so this revision is not applied until + // the row lets it be. An action that called the host without + // waiting would have reached it while this was blocked, and its + // record would sit before this render's. + yield* release.opened; + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => outputThenShell("first line\n")), + directJournal(), + ); + yield* grid; + }); + yield* reached.opened; + release.open(); + yield* task; + }); + + expect(launching).toBeGreaterThan(0); + // The action asked for exactly the revision it committed, and asked before + // it asked for a terminal. + const asked = log.events.indexOf(`converge:0:${launching}`); + const rendered = log.events.indexOf(`render:0:${launching}`); + const started = log.events.indexOf("shell:0:0"); + expect(asked).toBeGreaterThanOrEqual(0); + expect(rendered).toBeGreaterThanOrEqual(0); + expect(started).toBeGreaterThanOrEqual(0); + expect(asked).toBeLessThan(started); + // And the screen it asked for was applied before the provider was asked + // for a terminal. + expect(rendered).toBeLessThan(started); + }); +}); + +describe("Tier TG — concurrency across cells", () => { + for (const count of [1, 2, 3, 8]) { + it(`TG23: ${count} cell(s) run concurrently, in stable authored order`, function* () { + const log = terminalProviderLog(); + const together = barrier(count); + const titles = Array.from({ length: count }, (_unused, index) => `cell ${index}`); + const identities: string[] = []; + + const retained = yield* scoped(function* (): Operation { + const settled = settledCells(count); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + render: settled.render, + shell: () => + resource>(function* (provide) { + // Acquired: this cell is holding its activity. Settlement waits + // for every other cell to be holding one too, which cells that + // contended could never all do. + together.arrive(); + yield* provide( + (function* (): Operation { + yield* together.opened; + return { exitCode: 0 }; + })(), + ); + }), + }), + ); + const task = yield* terminalGrid( + layoutOf(3, titles), + titles.map((title, position) => ({ + cellId: Symbol(title), + operation: (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + // Each cell sees its own handle and nobody else's. + identities.push(`${position}:${cell.state.title}`); + yield* cell.shell(); + })(), + })), + directJournal(), + ); + return yield* task; + }); + + expect(together.arrived).toBe(count); + expect(identities.slice().sort()).toEqual( + titles.map((title, position) => `${position}:${title}`).sort(), + ); + // Authored order in the retained record, whatever order they ran in. + expect(retained.layout.cells.map((cell) => cell.title)).toEqual(titles); + expect(retained.cells).toHaveLength(count); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + } + + it("TG23: one cell refuses overlap, and admits the next after complete cleanup", function* () { + const log = terminalProviderLog(); + const refusals: string[] = []; + const marks: string[] = []; + const releaseFirst = gate(); + const refused = gate(); + let acquisitions = 0; + const released: number[] = []; + + yield* scoped(function* () { + const settled = settledCells(1); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + render: settled.render, + shell: () => + resource>(function* (provide) { + const mine = ++acquisitions; + yield* ensure(() => { + released.push(mine); + }); + yield* provide( + (function* (): Operation { + if (mine === 1) { + yield* releaseFirst.opened; + } + return { exitCode: 0 }; + })(), + ); + }), + }), + ); + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + [ + { + cellId: Symbol("a"), + operation: (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + yield* spawn(function* () { + try { + yield* cell.shell(); + marks.push("overlapping admitted"); + } catch (error) { + refusals.push(refusalOf(error)); + refused.open(); + } + }); + yield* spawn(function* () { + yield* refused.opened; + releaseFirst.open(); + }); + yield* cell.shell(); + marks.push("first settled"); + // The cell is free again: one owner at a time is not one owner + // ever, and the next one is admitted only after the prior + // activity's own cleanup finished. + marks.push(`released-before-second:${released.length}`); + yield* cell.shell(); + marks.push("sequential"); + })(), + }, + ], + directJournal(), + ); + yield* task; + }); + + expect(refusals).toHaveLength(1); + expect(refusals[0]).toContain("one owns a cell terminal at a time"); + expect(marks).not.toContain("overlapping admitted"); + expect(marks).toContain("sequential"); + expect(marks).toContain("released-before-second:1"); + // Two activities acquired, two released, nothing stranded. + expect(acquisitions).toBe(2); + expect(released).toEqual([1, 2]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); +}); + +/** Cell work that writes output and then asks for its terminal. */ +function outputThenShell(text: string): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + yield* appendTerminalCellOutput(text); + yield* cell.shell(); + })(); +} + +/** Settles once `record` holds at least `count` entries. */ +function untilRecorded(record: readonly string[], count: number): Operation { + return { + *[Symbol.iterator]() { + while (record.length < count) { + yield* nextTurn(); + } + }, + }; +} + +/** Settles once the provider's record holds an event this predicate accepts. */ +function untilEvent( + log: TerminalProviderLog, + accepts: (event: string) => boolean, +): Operation { + return { + *[Symbol.iterator]() { + while (!log.events.some(accepts)) { + yield* nextTurn(); + } + }, + }; +} + +function nextTurn(): Operation { + return { + *[Symbol.iterator]() { + const settled = withResolvers(); + queueMicrotask(() => settled.resolve()); + yield* settled.operation; + }, + }; +} diff --git a/packages/terminal/tests/terminal-processes.test.ts b/packages/terminal/tests/terminal-processes.test.ts new file mode 100644 index 000000000..0086df68e --- /dev/null +++ b/packages/terminal/tests/terminal-processes.test.ts @@ -0,0 +1,100 @@ +/** + * Tier PO — process facts, and the POSIX host that establishes them + * (architecture.md §Package ownership). + * + * A cancelled launch may not leave a child holding a terminal, and a cell may + * not admit its next activity while the previous one still owns the screen. + * Both are claims about processes, and neither can be made from a PID, an + * elapsed timeout, or a signal that was merely sent. These rows are about the + * neutral vocabulary for making them and the one host that answers it. + * + * They start a real child, because a stub that agreed with the implementation + * would prove nothing about the kernel. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, sleep } from "effection"; +import type { Operation } from "effection"; +import { spawn as spawnChild } from "node:child_process"; +import process from "node:process"; +import type { ChildProcess } from "node:child_process"; + +import { TerminalGridPresentationError } from "../mod.ts"; +import { TerminalGridPresentationError as LifecyclePresentationError } from "../lifecycle.ts"; +import { + deliverSignal, + processReachable, + ProcessObservationUnavailableError, +} from "../processes.ts"; +import { installPosixProcessObservation } from "../posix.ts"; + +/** A child that stays until something stops it. */ +function useChild(): Operation { + return (function* (): Operation { + const child = spawnChild(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + yield* ensure(() => { + try { + child.kill("SIGKILL"); + } catch { + // Already gone, which is the state this was asking for. + } + }); + return child; + })(); +} + +describe("Tier PO — process observation", () => { + it("PO1: refuses when no host has installed one", function* () { + let reachable: unknown; + let delivery: unknown; + yield* scoped(function* () { + try { + yield* processReachable(1); + } catch (error) { + reachable = error; + } + try { + yield* deliverSignal(1, "SIGINT"); + } catch (error) { + delivery = error; + } + }); + + // A host that cannot observe processes says so rather than answering + // "gone" for a child it never looked at. + expect(reachable).toBeInstanceOf(ProcessObservationUnavailableError); + expect(delivery).toBeInstanceOf(ProcessObservationUnavailableError); + }); + + it("PO2: the POSIX host reports a live child reachable and a stopped one gone", function* () { + yield* installPosixProcessObservation(); + const child = yield* useChild(); + const pid = child.pid; + expect(pid).toBeDefined(); + + expect(yield* processReachable(pid!)).toBe(true); + + // A fatal signal the kernel accepted, and then the fact itself. + expect(yield* deliverSignal(pid!, "SIGKILL")).toBe("delivered"); + while (yield* processReachable(pid!)) { + yield* sleep(10); + } + expect(yield* processReachable(pid!)).toBe(false); + + // Gone between the decision and the delivery is the outcome the caller + // was asking for, and is reported as such rather than as a refusal. + expect(yield* deliverSignal(pid!, "SIGKILL")).toBe("absent"); + }); +}); + +describe("Tier TG — one package, several facets", () => { + it("TG25: a value two entrypoints publish is the same value", function* () { + // A `catch` written against the root and one written against `./lifecycle` + // classify the same error, because there is one definition of it. + expect(LifecyclePresentationError).toBe(TerminalGridPresentationError); + yield* sleep(0); + }); +}); diff --git a/packages/terminal/tests/terminal-provider.test.ts b/packages/terminal/tests/terminal-provider.test.ts new file mode 100644 index 000000000..b936bb0bc --- /dev/null +++ b/packages/terminal/tests/terminal-provider.test.ts @@ -0,0 +1,518 @@ +/** + * Tier TG — the routing surface and the provider host contract + * (architecture.md §Terminal grid presentation). + * + * Two things live here, and neither decides anything. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and the lifecycle throws + * the answer away. The host is what a provider supplies as a resource, and its + * contract is ordering — prepared hidden, converged serially, shown once, + * destroyed exactly once. + * + * Who may present a grid, and what presenting one authorizes, is proved in + * `terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + * The state a host observes is scripted by the row, so every claim about what + * the renderer did is read off a record rather than inferred from timing. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createQueue, race, resource, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Queue } from "effection"; + +import { + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + TerminalProviderUnavailableError, +} from "../mod.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalGridHost, + TerminalGridRequest, + TerminalGridState, + TerminalGridView, + TerminalShellOutcome, +} from "../mod.ts"; +import { + controlledTerminalProvider, + gate, + rendererEndedMessage, + subscriptionEndedMessage, + terminalProviderLog, +} from "../test/mod.ts"; +import type { ControlledProviderOptions, TerminalProviderLog } from "../test/mod.ts"; + +/** A two-by-one grid: the smallest request that still has two positions. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + cells: [ + { title: "Agent", row: 0, column: 0, form: "paired" }, + { title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +const IDENTITIES: TerminalCellId[] = [Symbol("agent"), Symbol("shell")]; + +/** One snapshot, at `revision`, with whatever this row wants to say. */ +function snapshot(revision: number, overrides: Partial = {}): TerminalGridState { + const cells = IDENTITIES.map((cellId, index): TerminalCellState => { + const cell: TerminalCellState = { + cellId, + title: index === 0 ? "Agent" : "Shell", + row: 0, + column: index, + status: "starting", + content: "", + }; + return Object.freeze(cell); + }); + const state: TerminalGridState = { + revision, + phase: "preparing", + columns: 2, + rows: 1, + cells: Object.freeze(cells), + ...overrides, + }; + return Object.freeze(state); +} + +/** + * A view the row writes to. + * + * The provider is the thing under test here, so the state it observes is + * scripted rather than produced by a running grid. + */ +interface ScriptedView { + readonly view: TerminalGridView; + push(state: TerminalGridState): void; +} + +function scriptedView(initial: TerminalGridState): ScriptedView { + const queue: Queue = createQueue(); + queue.add(initial); + return { + view: { + states: { + // deno-lint-ignore require-yield + *[Symbol.iterator]() { + return { next: () => queue.next() }; + }, + }, + }, + push: (state) => queue.add(state), + }; +} + +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* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: TerminalGridRequest[] = []; + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.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* TerminalGrids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* TerminalGrids.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: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.cells.length }); + }, + }); + yield* TerminalGrids.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: TerminalGridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + { at: "min" }, + ); + yield* TerminalGrids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + expect(reached).toEqual([]); + }); +}); + +/** Acquire one controlled host over a scripted view. */ +function useScriptedHost( + script: ScriptedView, + options: ControlledProviderOptions, +): Operation { + return controlledTerminalProvider(options).host(request(), script.view); +} + +describe("Tier TG — the provider host contract", () => { + it("TP3: an acquired host presents nothing until it is shown", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const events = yield* scoped(function* () { + yield* useScriptedHost(script, { log }); + yield* untilApplied(log, 0); + return [...log.events]; + }); + + // A grid the reader can see before every cell is ready is the one thing + // atomic startup forbids. + expect(events).toEqual([ + "prepare:0:2x1", + "render:0:0", + "status:0:0:starting", + "status:0:1:starting", + ]); + expect(events.some((event) => event.startsWith("show:"))).toBe(false); + }); + + it("TP4: revision zero arrives with the subscription, and newer ones in order", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { log }); + yield* untilApplied(log, 0); + script.push(snapshot(1, { phase: "visible" })); + yield* host.converge(1); + script.push(snapshot(2, { phase: "closing" })); + yield* host.converge(2); + }); + + expect(log.applied.map((state) => state.revision)).toEqual([0, 1, 2]); + }); + + it("TP5: a blocked render coalesces forward, and its waiters are satisfied by the newer state", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const held = gate(); + const reachedOne = gate(); + const waiters: string[] = []; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + *render(state) { + if (state.revision === 1) { + reachedOne.open(); + yield* held.opened; + } + }, + }); + yield* untilApplied(log, 0); + + script.push(snapshot(1, { phase: "visible" })); + yield* reachedOne.opened; + // Both arrive while revision 1 is still being applied. + script.push(snapshot(2, { phase: "closing" })); + script.push(snapshot(3, { phase: "closed" })); + + const two = yield* spawn(function* () { + yield* host.converge(2); + waiters.push(`two:${log.applied[log.applied.length - 1]!.revision}`); + }); + const three = yield* spawn(function* () { + yield* host.converge(3); + waiters.push("three"); + }); + + held.open(); + yield* two; + yield* three; + }); + + // One then three: the intermediate snapshot is subsumed by the newest + // pending one, and an older revision is never applied after a newer one. + expect(log.applied.map((state) => state.revision)).toEqual([0, 1, 3]); + // The waiter for two completed from three, because a complete aggregate at + // three contains everything two described. + expect(waiters).toContain("two:3"); + expect(waiters).toContain("three"); + }); + + it("TP6: applied advances only after the whole render effect succeeds", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let converged = false; + let failure: Error | undefined; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + // deno-lint-ignore require-yield + *render(state) { + if (state.revision === 1) { + throw new Error("this renderer could not draw revision 1"); + } + }, + }); + yield* untilApplied(log, 0); + script.push(snapshot(1, { phase: "visible" })); + + failure = yield* race([ + host.failed, + (function* (): Operation { + yield* host.converge(1); + converged = true; + return new Error("unreachable"); + })(), + ]); + }); + + expect(failure?.message).toBe("this renderer could not draw revision 1"); + // Nothing was applied past the render that failed, and no waiter was told + // the screen had caught up. + expect(converged).toBe(false); + expect(log.applied.map((state) => state.revision)).toEqual([0]); + }); + + it("TP7: a state subscription that stops while acquired is a provider failure", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const stop = gate(); + let failure: Error | undefined; + let closed = false; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + stopSubscription: () => stop.opened, + }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + stop.open(); + failure = yield* host.failed; + }); + + expect(failure?.message).toBe(subscriptionEndedMessage()); + // Failure and reader close are independent observations. + expect(closed).toBe(false); + }); + + it("TP8: a renderer that stops while acquired is a provider failure", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const stop = gate(); + let failure: Error | undefined; + let closed = false; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + stopRenderer: () => stop.opened, + }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + stop.open(); + failure = yield* host.failed; + }); + + expect(failure?.message).toBe(rendererEndedMessage()); + expect(closed).toBe(false); + }); + + it("TP9: an ordinary release settles neither closed nor failed", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let closed = false; + let failed = false; + + yield* scoped(function* () { + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { log, close: () => suspend() }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + yield* spawn(function* () { + yield* host.failed; + failed = true; + }); + }); + }); + + expect(closed).toBe(false); + expect(failed).toBe(false); + // Released once, with nothing still held. + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TP10: a shell that never starts is never acquired, and leaves no record", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let refusal: unknown; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + shell: () => + resource>(function* () { + // Fails before it provides: nothing started, so nothing is owed an + // outcome and no cell could call this ready. + throw new Error("no child could be spawned"); + }), + }); + yield* untilApplied(log, 0); + try { + yield* yield* host.shell(IDENTITIES[1]!); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe("no child could be spawned"); + expect(log.events.some((event) => event.startsWith("shell:"))).toBe(false); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TP11: release happens once, whatever ended the host", function* () { + const log = terminalProviderLog(); + // One provider, three hosts: the generation in the record is how a suite + // tells a second host apart from the first. + const provider = controlledTerminalProvider({ log, close: () => suspend() }); + + // Settled normally. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + yield* provider.host(request(), script.view); + yield* untilApplied(log, 0); + }); + // Cancelled while live. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + const holding = withResolvers(); + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* provider.host(request(), script.view); + holding.resolve(); + yield* suspend(); + }); + }); + yield* holding.operation; + yield* task.halt(); + }); + // Failed after acquisition. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + try { + yield* scoped(function* () { + yield* provider.host(request(), script.view); + throw new Error("the provider failed"); + }); + } catch { + // The failure is the point; the release is what is being counted. + } + }); + + // One destroy each, and nothing left holding anything. A resource cannot be + // released twice, which is why there is no way to call one by hand. + for (const generation of [0, 1, 2]) { + expect(log.events.filter((event) => event === `destroy:${generation}`)).toEqual([ + `destroy:${generation}`, + ]); + } + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); +}); + +/** Settles once the renderer has applied a snapshot at least `revision`. */ +function untilApplied(log: TerminalProviderLog, revision: number): Operation { + return { + *[Symbol.iterator]() { + while (!log.applied.some((state) => state.revision >= revision)) { + const settled = withResolvers(); + queueMicrotask(() => settled.resolve()); + yield* settled.operation; + } + }, + }; +} diff --git a/packages/test-agent/src/child-configuration.ts b/packages/test-agent/src/child-configuration.ts index 321e56e5f..8447867f9 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/terminal/test"; import type { ChildDeclaration, ChildDeclarationChild, diff --git a/packages/test-agent/src/components.ts b/packages/test-agent/src/components.ts index 5340d3b11..b01a2f65a 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/terminal/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..157be43dc 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/terminal"; 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/native-launch.test.ts b/packages/test-agent/tests/native-launch.test.ts index 3f274d040..f21356c40 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/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; 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..1f60c76f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: semver: specifier: ^7.8.5 version: 7.8.5 + starfx: + specifier: 0.16.1 + version: 0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) unist-util-select: specifier: ^5 version: 5.1.0 @@ -105,6 +108,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:packages/runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:packages/terminal '@executablemd/test-agent': specifier: workspace:* version: link:packages/test-agent @@ -242,6 +248,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@secretlint/core': specifier: 13.0.4 version: 13.0.4 @@ -315,6 +324,24 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/terminal: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/node': + specifier: 0.2.5 + version: 0.2.5(effection@4.1.0) + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + effection: + specifier: 4.1.0 + version: 4.1.0 + starfx: + specifier: 0.16.1 + version: 0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + packages/test-agent: dependencies: '@agentclientprotocol/sdk': @@ -2076,6 +2103,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} @@ -2427,6 +2457,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2483,6 +2516,20 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + starfx@0.16.1: + resolution: {integrity: sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + react-redux: ^9 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + react-redux: + optional: true + streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -3915,6 +3962,8 @@ snapshots: html-void-elements@3.0.0: {} + immer@11.1.18: {} + immutable@5.1.5: {} is-extendable@0.1.1: {} @@ -4418,6 +4467,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.3.0: {} + resolve-pkg-maps@1.0.0: {} reusify@1.1.0: {} @@ -4464,6 +4515,15 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + starfx@0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + effection: 4.1.0 + immer: 11.1.18 + reselect: 5.3.0 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + streamx@2.28.0: dependencies: events-universal: 1.0.1 diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index a10628792..3c2da021c 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", "terminal", "acp"] as const; /** Every documentation asset the product ships, by package-relative path. */ const ASSETS: Record = { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e804f6b4c..1249a2bc5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9274,7 +9274,12 @@ row. identity, so duplicate titles are allowed. A paired pane expands its content as ordinary sequential document flow. A self-closing pane starts the default interactive shell configured by the host. The shell choice is live host policy, -not document data. +not document data. An expression may derive the label from document props, such +as the cell's role and current issue. The resolved prop seeds the live cell +title before the grid is shown. It is the only title input in this Story: +neither the grid UI nor the contextual terminal-cell UI exposes a title +mutation. A fresh partial-replay presentation begins from the retained resolved +title. Title-changing components and actions are outside this contract. Neither element accepts `as`. Neither renders content into the surrounding document or returns a value. Text that a paired pane renders is displayed in @@ -9328,74 +9333,248 @@ containment. Before the grid opens, root output is flushed. Display produced by pane content is routed to that pane and is not copied into the root document output or a capture around the grid. The grid itself renders `""`. Only after the provider -has torn down the composite and restored the root terminal can a following +has released the provider grid and restored the root terminal can a following sibling render to the root again. #### Readiness and the visible lifetime +The private grid resource treats the entire grid as one computational unit. It +returns a running task which the submitting expansion awaits directly; it does +not accept pane work through a callback and exposes no `open()`, `close()`, live +grid registry, supervisor, or public close-boundary object. Its neutral +signature accepts the resolved layout, one ordered `{ cellId, operation }` +record per cell, and a `TerminalGridJournal`, and returns +`Resource>`. Each cell operation is a lazy Effection +`Operation`; constructing it performs nothing. The resource owns the +layout reconciliation, durable grid child, foreground-terminal lease and root +output flush, live UI state, provider host, pane children, close commitment, +and teardown in the submitting expansion's scope. This scope parentage +preserves the durable identities and inherited context of the exact authored +position. + +Core mints each live-only `cellId`, constructs the paired expansion or +self-closing shell as that lazy operation, and supplies the journal adapter. The +terminal lifecycle validates the ordered work against the layout and interprets +each operation exactly once beneath its position-derived durable child, after +installing that cell's issued `TerminalCellUI` in its scope. Cell work receives +no callback argument or resource handle; it reads the contextual UI only when +it runs. + +The lifecycle also installs an issued, closure-bound cell-output sink in that +scope. At every completed paired-content output boundary, core appends the +rendered bytes through +`appendTerminalCellOutput(text: string): Operation` and waits for the +private aggregate commit before it continues expansion. Empty text is a no-op; +other calls append in call order, leaving `content` as the complete output so +far. The operation is available only from the terminal package's `./lifecycle` +integration facet; it is not a UI method or provider surface and exposes no +store, dispatch, title, status, identity, or host authority. Outside the issued +cell scope it is inert. An append does not await rendering. It establishes only +that the output belongs to the current immutable desired state before a +causally later cell action begins. + +`TerminalGridJournal` is defined by the neutral terminal package and implemented +by core with the source expansion already closed over. Its three operations +reconcile the resolved retained layout, retain or replay the whole grid +operation, and retain or replay one cell outcome by authored array position. +Only provider-neutral layouts, outcomes, and lazy operations cross this +boundary. The terminal package imports no core journal type. Completed grid +replay returns from the adapter without interpreting the live operation; partial +replay reaches only the incomplete cell operations. Core's adapter creates the +source-described durable grid or cell child when the terminal lifecycle invokes +the corresponding retention operation. The lifecycle owns the resulting live +tasks under the submitting expansion and creates no second durable lineage. + +For incomplete work, the neutral terminal package creates an immutable live +user interface backed by a private per-grid StarFX store in that Effection +scope. The UI exposes its current state, stable cell action handles in authored +order, and `show()`. It creates no independent scope and does not use retrying +resource management for the provider. The provider receives a separate +read-only state view and supplies a resource-owned terminal host with `show`, +state convergence, per-cell `launch` and `shell` effects, and independent +reader-close and provider-failure signals. The +request's exact object and installation generation still authorize presentation +before any of those live values or resources are created. Contextual UI handles +carry no presentation authority. Launch, shell and show use StarFX's blocking +controller execution so callers can await every state transition and non-render +effect the action owns; raw non-blocking dispatch performs none of them. Store +commits remain serialized while effectful actions in distinct cells may run +concurrently. + 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 - time. +1. Core validates the whole layout and supplies the lazy cell operations and + source-aware journal adapter. The grid task reconciles the layout before live + work. Unless completed replay returns without interpreting that work, the + terminal lifecycle acquires the root foreground-terminal lease and flushes + root output through terminal's contextual host boundary. Another root native + launch or terminal grid cannot hold the lease at the same time. 2. The provider validates its live prerequisites and prepares every terminal - endpoint in a hidden composite. It presents nothing yet. -3. All authored pane children begin concurrently. A self-closing pane starts its - shell. A paired pane expands until it starts its first interactive child, - normally ``. -4. A pane reaches readiness only when that interactive child emits the - runtime's child-spawn event. A paired pane that settles without starting one - fails startup. Merely allocating an endpoint or process identifier, or - receiving the child's first output, is not readiness; an interactive child - that starts and immediately exits is both ready and settled. -5. The provider attaches the complete composite only after every pane is ready. - -Successful child start is acknowledged through a private one-use latch held by -the pane terminal claim. The pane-scoped launcher acknowledges from the -runtime's spawn event and before waiting for exit; a startup error never -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. + endpoint, its gap-free state subscription, one serialized renderer, and its + failure observer in a hidden grid. It presents nothing yet. +3. The terminal lifecycle begins all lazy authored pane operations concurrently + under their exact issued cell contexts and position-derived durable children. + A self-closing pane starts its shell. A paired pane expands until it starts + its first interactive child, normally ``. +4. A pane reaches readiness only when its terminal activity is acquired, which + happens only once that interactive child has spawned. A paired pane that + settles without acquiring one fails startup. Merely allocating an endpoint or + process identifier, or receiving the child's first output, is not + acquisition; an interactive child that starts and immediately exits is both + ready and settled. +5. After every pane is ready, the grid commits `visible`, captures that state + revision, and asks the provider to show the grid. The provider converges + through the captured revision before it atomically attaches the complete + grid. + +An incomplete grid owns one live immutable aggregate with a revision, phase, +dimensions, and ordered cell states. Revision zero is the initial snapshot. +Each atomic state commit that changes the aggregate increments that live-only +non-negative safe integer exactly once; a no-op creates no revision or stream +emission. Each cell state contains a live-only `cellId`, the authored title, +row, column, the complete rendered Markdown content desired for that cell, and +one of `starting`, `launching`, `running`, `succeeded`, `failed`, or `closed`. +Agent and shell PTY bytes never enter it. Revisions are never authored, +retained, replayed, diagnosed, or placed in Agent or native launch data. An +attempt to increment beyond `Number.MAX_SAFE_INTEGER` fails the grid before an +ambiguous revision is published. + +Core supplies one fresh identity per authored cell. The terminal layer creates +one stable `TerminalCellUI` handle over that identity and installs it while it +interprets the corresponding cell operation. The handle exposes the current +immutable cell state plus `launch()` and `shell()`; callers pass no cell index +or identity. The authored `title` prop is the only title input and no live title +setter exists in this Story. +The canonical `useTerminalCellUI()` operation returns that exact handle or +`undefined` outside a cell; the grid UI itself is not contextual. +There is no content setter, generic dispatch, status setter, success action, or +failure action. Paired Markdown output and final pane outcomes are published by +the private pane lifecycle. Core's integration-only output sink awaits each +private content commit, so completed output before a later `launch()` or +`shell()` is causally included in the revision that action captures even though +ordinary rendering remains asynchronous. + +`launch()` and `shell()` run a terminal activity as the cell's owner. An +activity is a resource acquired only after its child has spawned, so acquisition +is readiness and no acknowledgement is handed to anybody; the acquired value is +the operation that settles with the child's outcome, and the activity's cleanup +and terminal-quiescence proof complete before the action returns. A startup +error fails before acquisition. Readiness appears in no prop, binding, public +request, provider return, process result, or durable record, and a root launch +has no cell activity. + +The same private lifecycle state admits at most one activity in a cell and stops +admitting all public actions when the grid closes. Different cells do not +contend. An overlapping launch or shell in one cell refuses as busy rather than +waiting in a hidden queue; sequential use is admitted only after the prior +activity has completely released the terminal. 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 parser never sees those values. The pane owner creates the child with -stdin/stdout/stderr inherited from the pane terminal, forwards the runtime -spawn event to the readiness latch, and never reads terminal input itself. +stdin/stdout/stderr inherited from the pane terminal, provides the activity once +that child is running, and never reads terminal input itself. Provider display text is written to the pane without becoming child input. A provider preparation failure, or a pane failure before every pane is ready, -cancels all pane scopes, awaits their finalizers, discards the hidden composite, -restores the root terminal, and fails without showing a partial grid. Effects -that finished before an interactive start failed keep their ordinary durable -records. Grid atomicity is not a transaction that rolls back Agent preparation, -files, commands, or other completed work. +cancels all pane scopes, awaits their finalizers, releases the hidden provider +grid, restores the root terminal, and fails without showing a partial grid. +Effects that finished before an interactive start failed keep their ordinary +durable records. Grid atomicity is not a transaction that rolls back Agent +preparation, files, commands, or other completed work. After attachment, a pane's normal exit or failure changes that pane's visible status and does not cancel its siblings. Paired content may continue with later sequential work after one interactive child exits, including another launch on -the same pane. The composite remains visible when all panes have settled. The +the same pane. The grid remains visible when all panes have settled. The reader closes or leaves it to finish the grid. -The provider receives presentation updates only as `starting`, `running`, -`succeeded`, `failed`, or `closed`. Readiness selects `running`; final pane flow -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. - -#### Native launch ownership inside a pane +The cell begins `starting`; admitting a launch or shell selects `launching`, and +terminal-activity acquisition selects `running`. An individual activity +settling does not finish a paired pane that has more authored work. Its durable +pane child alone selects `succeeded` or `failed` after the whole pane flow +settles, and a live pane cancelled only because the reader closed the grid +becomes `closed`. These states display core's result and never author it. + +The provider receives only a read-only state stream. Each subscription +atomically registers itself and captures one current snapshot in the same +serialized store step. That snapshot is emitted first, followed only by +strictly greater revisions, so there is no read/subscribe gap. The provider +receives no separate current-state read, UI action handle, store, or mutation +authority. -Each pane receives a pane-scoped native launcher. A `` there -reserves and flushes that pane terminal rather than the root foreground lease. -Different pane terminals do not contend, so their native UIs may run -concurrently. Two live interactive launches in one pane contend; sequential +Each snapshot is complete desired state rather than a delta. One scope-owned +renderer applies revisions serially and advances its private applied revision +only after every provider effect for that snapshot succeeds. While one render +is blocked it may coalesce several pending snapshots into the greatest revision +because that full state subsumes them. It never renders at or below an already +applied revision, and a later applied revision satisfies every convergence +waiter for an earlier one. Thus no older state can render after newer state. + +Ordinary private state changes do not await rendering. `show()` commits +`visible`, captures that revision, and waits for the host to converge through it +before atomic attach. Before a cell `launch()` or `shell()` calls the provider +operation, its blocking controller commits `launching`, captures that revision +after all causally prior cell output, and waits for host convergence through it. +Only then may the provider transfer the cell terminal. Cancellation or failure +during that wait invokes no launch or shell, acquires no terminal activity, and +does not satisfy readiness. + +The provider exposes background failure separately from reader close. The grid +lifecycle observes both from host acquisition until release, so a renderer or +subscription failure with no foreground waiter still fails the grid. The +failure operation remains pending during ordinary work, yields the exact +provider error when background work fails, and is cancelled by normal host +release rather than falsely succeeding. Unexpected termination of the state +subscription or renderer while the host remains acquired is provider failure, +not successful convergence or reader close. Provider +setup, render, show, convergence, launch, shell, or close-observation failure +fails pending convergence, cancels the grid, and enters complete teardown; the +provider is not restarted underneath the same state. Reader departure alone +proposes the ordinary cooperative close boundary. + +The provider host's read-only `closed` operation proposes reader close. There is +no callable grid-close action. 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, releases the exact provider grid, 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. + +The task the resource returns settles only after the selected result is +retained, every cell and renderer task has settled, the provider host resource +has been released, the root terminal has been restored, and the foreground +lease has been released. Releasing the resource early cancels the task and +waits for that same teardown; it never detaches the grid. + +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. + +Before reader-close acknowledgement, parent cancellation while `show()`, +`launch()`, or `shell()` waits for convergence cancels the pending action and +whole grid scope, invokes no not-yet-started provider child, and awaits the +renderer, cells, host, and terminal teardown before propagating. Reader close +stops admission and cooperatively closes cell work still waiting for +convergence. After its acknowledgement, the existing cancellation-deferred +close rule applies. A background provider failure remains the fatal grid result +under the existing failure and cleanup precedence even when cancellation or +reader close is also observed. + +#### Native launch ownership inside a cell + +Each cell receives a cell-scoped native launcher. A `` there +reserves and flushes that cell's terminal endpoint rather than the root +foreground lease. Different cells do not contend, so their native UIs may run +concurrently. Two live interactive launches in one cell contend; sequential launches in it are allowed. A launch releases the pane only after its child and every observable process @@ -9421,8 +9600,14 @@ order fails the element; cancellation caused solely by closing the grid is not counted as a failed pane. With no failed pane, close succeeds and the document continues. -A provider or host failure cancels the composite and is the grid failure. +A provider or host failure cancels the grid 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 @@ -9443,28 +9628,65 @@ no process remains descended from a child alive when teardown began, and no process holds a pane terminal. A provider that cannot establish those bounded facts fails teardown. -Diagnostics identify the source grid and a provider-neutral pane ordinal or -authored title. They contain no provider command, socket, server, session, +Diagnostics identify the source grid and a provider-neutral authored cell +position or title. They contain no provider command, socket, server, session, window, pane identifier, executable path, argv, environment, or terminal bytes. #### Durability and replay The grid is one structured durable region. Its identity includes the resolved -column count and ordered pane forms and titles. Each direct pane receives a -deterministic child-coroutine identity derived from the grid expansion and its -ordinal, never from its title, scheduling order, or provider layout. +column count and ordered cell forms and titles. Ordered array position is the +cell's durable structural identity; no explicit ordinal, pane index, live +`cellId`, or provider identity is retained. Each direct cell receives a +deterministic child-coroutine identity derived from the grid expansion and that +position, never from its title, scheduling order, or provider layout. Because +this stack is unmerged, the experimental retained shape with ordinal fields has +no migration reader. The completed region retains its provider-neutral layout, how it closed, and -the ordered pane outcomes after the ordinary secret gate. Completed replay -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 +the ordered cell outcomes after the ordinary secret gate. Completed replay +claims that whole region and restores its result without creating a live state +store, UI object, cell handle, state stream, provider host, or terminal pane; +starting a shell; expanding cell content; resolving an Agent; taking session +ownership; or launching a native UI. + +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 cell's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then mints fresh live `cellId` values, +creates a new live store and UI beginning at revision zero, and acquires a new +provider host. Completed cell children appear as already-settled statuses and +perform no effects; incomplete children continue from their own durable +records. The prior attempt's live revision sequence is neither restored nor +compared. + +The live `cellId` keeps the cell handle and provider endpoint bound if a later +contract permits position changes. This version exposes no move, resize, +reorder, or durable-layout action; rows and columns remain the fixed resolved +layout for the grid's live presentation. + +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 +9712,27 @@ 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. +The provider-neutral terminal contract, live state, controllers, lifecycle, +replay, process-observation contract and reusable POSIX implementation are +exported only from `@executablemd/terminal`. The tmux host, worker, rendering and +private IPC are exported only from `@executablemd/terminal-tmux`. Core owns +structural expansion, source-aware journal descriptions and execution-profile +integration and imports the neutral package. Runtime-named Deno and compiled CLI +entrypoints install terminal's POSIX observer and terminal-tmux's provider. +That installation belongs only to an ordinary foreground `xmd run`; workflow +profiles, Node and Bun install neither. Dependencies never point from either +terminal package into core, runtime or CLI, and the tmux package depends on the +neutral terminal package. StarFX is private to the neutral package. The old +runtime, core and CLI terminal modules and exports do not remain as compatibility +paths; every repository import uses the canonical package surfaces. + +Issue #781 later changes the unshipped surface to `` and `` and the +package names to `@executablemd/grid` and `@executablemd/grid-tmux`. Its semantic +restack preserves this resource, lazy-operation, journal-adapter, state revision, +atomic observation, convergence, background-failure, replay, cancellation, and +teardown contract. It adds no compatibility aliases and does not restore a +mutable title action or split provider read-and-subscribe API. + ## 7. Entry point @@ -11498,21 +11741,27 @@ test derives a core result from a provider identifier. | 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` | | 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 | -| TG5 | Representative 2×2 journey | Three controlled native Agent sessions and one controlled default shell all start before the grid attaches, remain concurrently interactive, and use the authored row-major positions | +| TG4 | Row-major layout | One through five authored cells under two and three columns produce the exact derived positions and keep duplicate titles. Ordered array position supplies durable identity without a stored ordinal, pane index or key; each live grid mints fresh `cellId` values that do not enter the retained request | +| TG5 | Representative 2×2 journey | Three controlled native Agent sessions and one controlled default shell all start before the grid is shown, remain concurrently interactive, and use the authored row-major positions | | TG6 | Isolated pane scopes | Every pane inherits the grid site's values, cwd, repository selection and providers; one pane's new bindings and contextual changes reach later work in that pane only, and its `Break` or `Return` cannot escape the pane | -| TG7 | Pane output | Rendered pane text reaches only that pane and the grid renders `""`; a surrounding capture gets no pane display; nested executable effects retain their ordinary results; native UI and shell bytes enter no capture, process journal or transcript | -| TG8 | Readiness barrier | Endpoint allocation, PID allocation, preparation, route publication, detach and first output are not ready; the runtime child-spawn event is. A paired pane that settles without one fails startup, and a child that spawns then exits immediately is ready and settled | -| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no composite attaches, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous pane failures report the first authored ordinal | -| TG10 | Independent settlement | After attach, one pane can exit successfully or fail while siblings remain live and usable; its status stays visible. Closing a grid with failed panes reports the first failed authored ordinal, while teardown cancellation itself does not create a pane failure | -| TG11 | Terminal versus session ownership | Distinct pane leases permit concurrent native launches, one pane refuses overlapping launches, and a sequential launch is admitted only after the previous child, its observable descendants and group members, and every other holder of that pane terminal are gone; two panes naming one logical Agent session still contend through the unchanged non-waiting coordinator | -| TG12 | Reader close | Close prevents a later launch, cancels every live pane scope, awaits each child, shell and provider finalizer, destroys the exact composite, restores the root terminal, releases the foreground lease, and only then starts the following document sibling | -| TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; an active provider failure cancels every pane and fails the grid; cleanup is attempted for all resources under existing failure precedence | +| TG7 | Cell output | Rendered paired-cell text enters only that cell's immutable presentation state and the grid renders `""`; a surrounding capture gets no cell display; nested executable effects retain their ordinary results; native UI and shell bytes enter neither the state nor a capture, process journal or transcript | +| TG8 | Readiness barrier | A cell begins `starting`, admission selects `launching`, and only the runtime child-spawn event selects `running` and satisfies readiness. Endpoint or PID allocation, preparation, route publication, detach and first output do not. A paired cell that settles without a spawn fails startup, while a child that spawns then exits immediately is ready and settled | +| TG9 | Atomic startup failure | Each provider-preparation position and each authored cell start can fail; the host never shows the grid, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous cell failures report the first authored position | +| TG10 | Independent settlement | After show, one cell can exit successfully or fail while siblings remain live and usable; its final status stays visible. An individual launch settling does not finish a paired cell with later authored work. Closing a grid with failed cells reports the first failed authored position, while teardown cancellation itself does not create a cell failure | +| TG11 | Terminal versus session ownership | Distinct cell leases permit concurrent native launches; one cell refuses overlapping launch or shell actions rather than queueing them; and sequential work is admitted only after the previous child, its observable descendants and group members, and every other holder of that cell's terminal are gone. Two cells naming one logical Agent session still contend through the unchanged non-waiting coordinator | +| TG12 | Reader close | The host's read-only close signal prevents every later public cell action, cancels every live cell scope, awaits each child, shell and provider finalizer, releases the exact provider host, restores the root terminal, releases the foreground lease, and only then starts the following document sibling. No public `close()` action exists | +| TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; a provider setup, render, show, launch, shell or close-observation failure cancels every cell and fails the grid without restarting the provider; cleanup is attempted for all resources under existing failure precedence | | 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 | +| TG15 | Completed replay | A completed successful or failed grid restores its exact result while creating no StarFX store, UI, cell handle, state stream, provider host or pane and contacting no shell, Agent provider, coordinator, pane content or native launcher | +| TG16 | Partial replay | Exact layout creates fresh live cell identities, state and UI and acquires a fresh provider host; completed cell 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 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. Cell 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 cell outcomes contain no explicit ordinal, pane index, live `cellId`, 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–TG24; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, consumes only the read-only state view, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, shows the grid only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before cell start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live cell enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets cell and provider teardown complete, retains the cell 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 cell 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 | Atomic one-directional state | Revision zero is the initial immutable aggregate. A subscription started while a controlled commit crosses the former read/subscribe gap emits either the state before that commit and then the next revision, or the state after it first, but never misses it, duplicates a revision, or moves backward. No-op state work emits nothing, and overflow is refused before publication. The authored title is immutable in this Story. Paired Markdown output reaches state through the lifecycle's issued integration sink: an append is observed committed before the next authored effect but does not await rendering. No public setter exists, and neither provider nor contextual cell code receives store or generic dispatch authority | +| TG21 | One scope-owned computation | `terminalGrid(layout, cells, journal)` accepts ordered live IDs with lazy Effection cell operations and a core-supplied source-aware journal adapter, and yields one task whose result is observed directly. Constructing cell work runs nothing; the terminal lifecycle interprets each operation exactly once under its issued contextual cell UI and position-derived durable child. Its submitting expansion owns the durable grid, store, state subscription, renderer, provider host, cell children and teardown; cancellation of that scope takes all of them down. No core import, registry, supervisor, callback-shaped lifecycle or public close boundary participates, and retained recovery never depends on a live handle | +| TG22 | Canonical package boundary | The neutral terminal package contains the state, controllers, lifecycle, replay, action and provider contracts, process observation and reusable POSIX implementation with StarFX private; the tmux package contains rendering, IPC and worker behavior; core retains structural expansion, source-aware journal descriptions and profile integration; runtime-named Deno and compiled CLI entrypoints install the host wiring. Import and export checks find no old runtime, core or CLI terminal implementation path, compatibility re-export, terminal-to-core/runtime/CLI cycle, or StarFX type on a cross-package surface | +| TG23 | Revision convergence | With revision 1's render blocked, commits 2 and 3 coalesce so the provider completes 1 and then 3, never renders 2 after 3, and advances its applied revision only after each complete provider effect. A waiter for revision 2 is satisfied by applied revision 3. `show()` remains blocked until its captured visible revision is applied and attached. A cell whose preceding Markdown output is pending remains `launching` with zero host launch or shell calls until convergence through its captured revision, then transfers the exact terminal request | +| TG24 | Convergence failure and cancellation | A renderer failure raised while no action is waiting, and an unexpected renderer or state-subscription termination while the host remains acquired, are each observed through the host's separate failure surface, fail every pending waiter, and tear down the grid without being mistaken for reader close. Parent cancellation and reader close are each crossed while a launch waits for convergence; neither starts a provider child or establishes readiness, and releasing the controlled renderer lets every cell, subscription, host and provider finalizer settle before the specified cancellation or close outcome. Signals establish every ordering; elapsed time establishes none | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c422702..75419b318 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -441,11 +441,13 @@ Given `xmd AGENTS.md#Implementor`: 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 - pane's lease through the pane-scoped native launcher. A host with no + cell's lease through the cell-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. -7. The host flushes what that terminal has pending, so the native UI does not - open over half-written output. +7. At the root, the host flushes what that terminal has pending. In a grid cell, + the cell action commits `launching`, captures that live state revision after + all causally prior Markdown output, and awaits provider convergence through + it. In either case the native UI cannot open over pending document output. 8. The provider resolves the logical Agent and Session against the contextual cwd, and takes exclusive ownership of that session. @@ -505,10 +507,10 @@ owner to release — what has to be settled first is which conversation this is: From there both rejoin: 13. The provider spawns the native UI as an interactive child with the selected - root or pane terminal inherited — resuming the native session ID for a - provider-returned adapter, and for a client-allocated one creating it under - the allocated identity from the private file, or resuming it by the same - name when the route already named it. + root or terminal-cell endpoint inherited — resuming the native session ID + for a provider-returned adapter, and for a client-allocated one creating it + under the allocated identity from the private file, or resuming it by the + same name when the route already named it. 14. `Session.Launch` suspends while the child runs. 15. The child handles prompts, tools, permission dialogs, rendering, and native transcript persistence directly. @@ -730,39 +732,41 @@ session coordinator └─ natural key for logical session B ``` -The grid owns the root foreground-terminal lease. The terminal authority mints -one private one-use claim per authored pane ordinal, and core installs a native -launcher in each pane scope that closes over that claim. `Session.Launch` uses -the launcher already in scope; it receives no pane prop, token, identifier, or -mode. The launcher validates the claim through the host's direct terminal -authority and reserves that pane for the launch. A claim from another grid, -provider installation generation, pane ordinal, or completed invocation -authorizes nothing. - -Different pane claims do not contend, so native launches in different panes can -hold their terminals concurrently. One pane remains exclusive: a second launch -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 -neither can overlap the other. +The grid owns the root foreground-terminal lease. Its lifecycle creates one +stable `TerminalCellUI` per authored cell, closes that handle over a fresh +live-only `cellId`, and owns the private state that admits work, observes +readiness, and stops admission at close. Core supplies lazy cell operations; the +terminal lifecycle installs the exact cell UI while interpreting each one in +its position-derived durable child. `Session.Launch` uses the launcher already in scope; it +receives no cell prop, token, identifier, or mode. Context is composition rather +than authority: a constructed lookalike cannot reach a live provider host, and +the issued cell UI admits nothing after its grid closes. + +Different terminal cells do not contend, so native launches in different cells +can hold their terminals concurrently. `TerminalCellUI.launch()` keeps one cell +exclusive: a second launch cannot begin while the first is live there, and +sequential launches work after the first releases it. An overlap refuses as +busy rather than entering a hidden queue. Release requires the child, its +observable descendants and process-group members, and every other holder of +that cell's terminal to be gone; the cell remains busy if the provider cannot +establish those facts. A root launch and a terminal 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 same provider, agent, and logical session still ask for one natural-key owner; one succeeds and the other receives `session-busy` without waiting. Two distinct -sessions may be owned concurrently. A terminal claim grants no permission to +sessions may be owned concurrently. A terminal cell grants no permission to 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 +The cell-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, +but cannot settle it, select another cell, 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. -The grid's readiness barrier observes the launch only at the existing successful +The grid's readiness barrier observes the launch only at the successful interactive-child start boundary. Session preparation, route publication, private-file creation, and detach do not make a pane ready. If spawn fails, the launch keeps the durable phases its contract already completed, fails the pane's @@ -771,20 +775,44 @@ not roll those phases back. A child that successfully starts and exits before the other panes become ready has nevertheless crossed readiness and retains its ordinary exit outcome. -The pane claim carries a private one-use readiness latch. The native launcher -acknowledges it from the runtime's child-spawn event and before waiting for -exit; allocating a PID or observing output is not readiness, and a startup error -never acknowledges. A root launch carries no such latch. It is not added to -`AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, a retained -launch phase, or a process handle, so readiness composition changes neither the -launch's authored nor durable contract. +A native launch runs through `TerminalCellUI.launch()`. The private controller +first admits the cell, commits `launching`, captures the resulting live grid +revision after all causally prior cell output, and awaits provider convergence +through it. Only then does it ask the provider host for a terminal activity: a +resource whose acquisition happens only once the child has actually spawned. +Core publishes paired Markdown at each completed output boundary through the +issued integration-only cell-output sink and awaits that private store commit, +not rendering, before expansion continues. Therefore output written before the +launch invocation is already in the complete snapshot whose revision the +controller captures; no public content setter or provider mutation participates. +Acquisition is readiness, so nobody is handed an acknowledgement to call — +allocating a PID or observing output is not acquisition, and preparation, +convergence, reservation, or spawn failure occurs before it. Cancellation while +convergence is blocked invokes no provider launch and creates no native child. +The controller publishes `running` only after acquisition, awaits the acquired +operation, and does not complete the action until the activity's cleanup has +swept whatever the launch still holds. A root launch has no cell activity at +all. Readiness is not added to `AgentLaunchRequest`, +`AgentLaunchResult`, the public Agent Api, a retained launch phase, or a process +handle, so it changes neither the launch's authored nor its durable contract. + +The provider-neutral terminal package exports the cell UI action contract and +the host convergence and activity boundaries, not readiness, busy-state, or +closing authority. +Pane work receives its issued contextual cell UI; the grid lifecycle alone +waits for readiness, publishes final cell status, and closes admission. The +provider receives only the live `cellId` needed to select its physical endpoint; +that value enters no Agent request, retained record, diagnostic, or provider +identity. -Under the tmux provider the pane-scoped launcher sends exact argv, cwd, and +Under the tmux provider the cell-scoped launcher sends exact argv, cwd, and environment values over a private authenticated socket to the persistent pane worker. The worker, not a tmux command line, creates the native child with all -three standard streams inherited from the pane terminal. It forwards the spawn -event, writes pane display without reading input, and refuses a concurrent -launch. It uses Effection's `run()` rather than `main()` so Effection does not +three standard streams inherited from the pane terminal. It provides the +activity once that child is running — its own observation of the child starting +is provider-private input to that acquisition, not a callback, an +acknowledgement, or a second readiness protocol — writes pane display without +reading input, and refuses a concurrent 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. @@ -909,15 +937,15 @@ 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 -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. +cell lease. Two launches cannot concurrently own the same root terminal or cell, +even when they name different sessions. Launches in distinct cells may run +concurrently, and sequential launches in one cell are ordinary composition. Cancellation interrupts the native interactive process, establishes that it can -no longer execute or hold its root or pane terminal, restores that terminal's -state, and runs every provider finalizer. A process that ignores the initial -interruption is terminated according to the host process adapter's bounded -shutdown policy. +no longer execute or hold its root or terminal-cell endpoint, restores that +terminal's state, and runs every provider finalizer. A process that ignores the +initial interruption is terminated according to the host process adapter's +bounded shutdown policy. The adapter attempts to collect the native exit status, but a runtime-retained defunct PID or a lost exit event is not live process ownership. After a fatal signal was accepted, or the process was already absent, bounded settlement may @@ -1044,10 +1072,10 @@ a replacement or reconstructs state from a transcript. When the launch is a pane child, completed replay of the enclosing completed grid claims the whole structured region before this operation is reached, so it also contacts nothing. Partial grid replay restores a completed launch as a -settled pane status. An incomplete launch is reached under a newly created live -pane terminal and follows the same phase rules above; neither the new provider -layout nor the pane ordinal changes its retained launch or logical-session -identity. +settled cell status. An incomplete launch is reached through a newly created +live terminal-cell UI and follows the same phase rules above; neither the fresh +`cellId` nor the new provider layout changes its retained launch or +logical-session identity. Those are operation/runtime replay semantics: they define how an execution behaves when an embedder, a test, or a future retained execution host supplies @@ -1186,9 +1214,9 @@ starts Claude, Codex, or a model. Terminal-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 -launchers. Separate tmux integration evidence exercises the production adapter; +failure, revision convergence, parent cancellation, and teardown completion as +test-controlled operations while using the same core terminal authority and +cell-scoped native launchers. Separate tmux integration evidence exercises the production adapter; core semantics are not inferred from tmux identifiers or process behavior. The tmux evidence covers exact argv over private IPC, the runtime spawn boundary, display that cannot become child input, real terminal job control, explicit @@ -1249,11 +1277,13 @@ Focused tests prove: failed releases nothing and withholds quiescence; and 23. a canonical version parse accepts exactly one matching line, and refuses zero or several without repeating the output; and -24. launches on distinct pane terminals run concurrently while launches in one - pane remain exclusive, the same logical Agent session still contends across - panes, pane readiness occurs only after successful native-child start, grid - close awaits launch cancellation and session quiescence, and completed and - partial grid replay preserve the launch's existing identity rules. +24. launches in distinct terminal cells run concurrently while launches in one + cell remain exclusive, the same logical Agent session still contends across + cells, a launch invokes no provider child until the captured cell-output + revision has converged, cancellation during that wait launches nothing, + cell readiness occurs only after successful native-child start, grid close + awaits launch cancellation and session quiescence, and completed and partial + grid replay preserve the launch's existing identity rules. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1332,8 +1362,8 @@ in a released unbound form and a bound one; the host-owned executable observer 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 +an inherited root- or terminal-cell interactive child with cancellation and +bounded reaping; composition with the terminal grid's independent cell leases without changing session ownership or durable launch identity; and the controlled TestAgent fixture that proves all of it without starting a model. @@ -1437,9 +1467,10 @@ Implementation review checks these frozen invariants: holders from the prior launch are gone. 25. Pane terminal ownership never replaces or weakens natural-key Agent-session ownership, so two panes naming one session still contend without waiting. -26. A pane is ready only at the runtime child-spawn event; preparation, PID - allocation, route publication, detach, private-file creation and first - output are not readiness, and a failed spawn rolls none of them back. +26. A pane is ready only when its terminal activity is acquired, which happens + at the runtime child-spawn event; preparation, PID allocation, route + publication, detach, private-file creation and first output are not + readiness, and a failed spawn rolls none of them back. 27. Grid cancellation reaches every live launch, awaits its child teardown and session quiescence, and exposes no provider-specific layout identity in an authored, durable, result, or diagnostic surface. diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 411f963a5..cf538356e 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -49,7 +49,8 @@ sequenceDiagram ## 2. Version lockstep Every publishable package (`packages/core`, `packages/cli`, -`packages/durable-streams`, `packages/runtime`, `packages/testing`, +`packages/durable-streams`, `packages/runtime`, `packages/terminal`, +`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