diff --git a/architecture.md b/architecture.md index a6b73181b..be10f394e 100644 --- a/architecture.md +++ b/architecture.md @@ -108,10 +108,10 @@ Existing documents and code get aligned to this section retroactively. | session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | -| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | +| 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 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 | +| pane-terminal lease | the exclusive ownership one live interactive operation holds through a pane's concrete `PaneTerminal`. Different panes do not contend; a second operation in one pane does. The grid lifecycle closes admission when that pane 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 pane terminal, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | @@ -3433,7 +3433,7 @@ provider-neutral grid of independently interactive terminal panes: `Terminal` names the interactive endpoint the document requires. It does not name the presentation technology: a tmux integration, another terminal -multiplexer, and a host-native composite UI are providers for the same +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. @@ -3464,7 +3464,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,49 +3477,79 @@ 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 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 — and it decides all of that *before* the provider's grid +is acquired, so a refused presentation costs the provider nothing and produces +no side effect. No context value, prop, binding, provider result, retained +record, diagnostic, or structurally similar request carries it. + +A provider supplies its grid as a resource rather than an object with a +teardown method. Acquiring it is the grid coming into existence; 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 so no way to call one twice or to forget one. + +Nothing owns a grid but the expansion that submitted it. A grid runs beneath +that operation, which is what keeps its panes' durable identities and inherited +bindings those of the document position that wrote them, and what takes the grid +down whenever that operation unwinds. There is no execution-wide holder of live +grid tasks; what the installation keeps is the smallest lookup that lets a +submitted request and a presentation converge — the request object, its +generation, whether it has been presented, and the operation that runs it. 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 +provider factory closes over the delivered presentation function 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 grid lifecycle creates one concrete `PaneTerminal` for each authored ordinal +and passes it to that pane's work. It carries one operation, `use()`, and no +identity: core already knows which ordinal it built each one for, and a pane +that could name itself would be a pane something else could name. `use()` runs +one terminal activity as that pane's owner, refuses a concurrent use in the same +pane, permits sequential uses after settlement, and awaits the activity's own +cleanup before the pane is free again. Different `PaneTerminal` values do not +contend. Closing the grid prevents every pane terminal from admitting new work +before it asks live work to stop; retaining a pane terminal after its grid closes +grants nothing. + +Core installs that same `PaneTerminal` in the paired pane's scope. A +pane-scoped native launcher reads it and `` consequently +reserves, flushes, and launches on the pane terminal instead of competing for +the root lease. The provider starts a self-closing pane's host-configured +default shell as a terminal activity through the same operation. Sequential work in one pane remains +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. + +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 are private state of the grid +lifecycle, not a second public capability model. There is no pane-claim or +readiness interface, no pane controller, no aggregate object, and no factory or +sealing operation for another package to coordinate. The lifecycle passes only +the concrete `PaneTerminal` across the pane-work boundary. 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 +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 @@ -3542,37 +3572,73 @@ 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. +2. Core admits the presentation — exact request, generation, not already used — + and only then acquires the provider's grid resource. Acquisition prepares the + entire hidden grid: every pane endpoint and its supervision. No grid is + attached yet, and a refused presentation acquires nothing at all. 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. +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 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 + grid. Any acquisition or pane-start failure before this barrier cancels every + pane, 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. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, release the provider's grid resource — which is + what destroys it, once — restore the root terminal, and only then release the + foreground lease and settle the grid. Reader close, pane 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 pane child or provider-owned + process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -3623,8 +3689,41 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. -Partial replay first compares the exact authored layout and refuses divergence -before provider work. It rebuilds a fresh provider composite: completed pane +The reader-close handshake makes cancellation during teardown a completed-grid +case rather than a new partial-replay state. When a pane finalizer delays close +and parent cancellation arrives, the first attempt still finishes every pane +and provider finalizer, writes the pane outcomes and completed grid `Close`, and +only then reports cancellation to its parent. A continuation claims that +completed child and resumes after it without recreating the provider or +re-entering pane work. A host loss can still interrupt the unjournaled live +teardown; panes whose `Close` was acknowledged remain complete, while any pane +and grid without a completed record follow the existing partial-replay rules. + +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It acquires a fresh provider grid: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own @@ -4970,7 +5069,7 @@ 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 | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral grid whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, 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 acquires a fresh provider grid, 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 | | `` | 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 | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..082c8113d 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,29 @@ 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"; +// Only what a provider needs: the refusal it can meet, and the shape of the +// function it is handed. Issuing a grid, opening an installation and converging +// the two are core's own, and a host reaches the whole of it through +// `installTerminalGridProfile`. +export { TerminalGridPresentationError } from "./src/terminal/presentation.ts"; +export type { PresentTerminalGrid } from "./src/terminal/presentation.ts"; +export { + installTerminalProvider, + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/terminal/provider-api.ts"; +export type { + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/terminal/provider-api.ts"; +export { installTerminalGridProfile } from "./src/terminal/profile.ts"; +export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; +export { paneTerminal } from "./src/terminal/pane.ts"; +export type { PaneTerminal } from "./src/terminal/pane.ts"; +export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 933560f28..ff4e5d401 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,6 +68,10 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; +import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; +import type { PaneWork } from "./terminal/grid.ts"; +import { recordGridLayout } from "./terminal/journal.ts"; +import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, asExpressionViolation, @@ -143,7 +147,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"; @@ -1185,7 +1189,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; } @@ -2106,7 +2117,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) { @@ -2141,23 +2166,119 @@ 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 pane shows belongs to + // that pane, and the sibling after `` renders to the root + // again only once the provider has restored it. + const identity = { + path: site.path, + ...(segment.position === undefined ? {} : { position: segment.position }), + }; + + try { + // Recorded in this coroutine, before the lease and before any provider is + // contacted: a resumed run whose grid changed is refused while nothing has + // been opened. It cannot live inside the grid child, because a completed + // child never runs. + yield* recordGridLayout(identity, toRequest(layout)); + + const retained = yield* durableGrid(function* (boundary) { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openTerminalGrid(layout, work, boundary); + }); + + const failed = retained.panes.find((pane) => pane.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 pane does once the grid has created its terminal. + * + * A self-closing pane runs the host's default shell as a terminal activity, + * exactly as a paired pane's content does. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(terminal, grid) { + // The shell is this pane's one terminal activity, and acquiring it is + // what makes the pane ready — the same boundary a paired pane's content + // crosses, rather than a second way in. + const outcome = yield* terminal.use(grid.shell(pane.ordinal)); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } }, - }), - ); + }; + } + + return { + ordinal: pane.ordinal, + *run(terminal, grid) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(terminal); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this pane makes is visible to later work in this pane and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + const shown: Segment[] = []; + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + // A counter of its own. Panes expand concurrently, and a shared + // mutable counter would hand two of them block identities that depend + // on which happened to run first. + createBlockCounter(), + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The pane's own ledger: a checked failure settles this pane and + // cannot reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a pane cannot claim + // one outside the grid. + undefined, + ); + const text = renderSegments(shown); + if (text.length > 0) { + yield* grid.display(pane.ordinal, text); + } + }); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2172,15 +2293,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 new file mode 100644 index 000000000..5f9a32ec6 --- /dev/null +++ b/packages/core/src/terminal/grid.ts @@ -0,0 +1,651 @@ +/** + * One terminal grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement, §Durability and replay). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The grid is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier releases the hidden grid + * instead of leaving half a grid on the screen. + * + * ``` + * layout recorded → lease → flush → routed to a provider → grid presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Each pane is a **durable child coroutine** of the grid, allocated in authored + * order. That is not decoration: a completed child short-circuits on replay by + * returning its retained result without running, and claiming a completed + * parent claims every descendant history beneath it. Wrapping the region in one + * durable operation instead would leave the panes' entries unconsumed and + * desynchronise the journal on the next run. + */ + +import { + all, + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; +import type { TerminalActivity, TerminalGrid, TerminalGridRequest } from "@executablemd/runtime"; + +import { TerminalGridPresentationError, terminalInstallation } from "./presentation.ts"; +import type { PaneTerminal } from "./pane.ts"; +import type { IssuedGrid } from "./presentation.ts"; +import type { TerminalGridLayout } from "../terminal-grid.ts"; + +function validateOrdinals(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalGridPresentationError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalGridPresentationError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * close admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} + +/** + * What one pane does once its terminal exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both reach their terminal through `PaneTerminal.use()`, and + * both are expected to acquire a terminal activity there before anything can + * attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(terminal: PaneTerminal, grid: TerminalGrid): Operation; +} + +/** + * What a pane that never acquired a terminal activity says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + panes: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} + +/** The retained shape of one request. */ +export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layout"] { + return { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ + ordinal: pane.ordinal, + title: pane.title, + form: pane.form, + row: pane.row, + column: pane.column, + })), + }; +} + +/** + * Open one grid and report what it settled to. + * + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * 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. + */ +export function openTerminalGrid( + layout: TerminalGridLayout, + work: readonly PaneWork[], + boundary: CloseBoundary, +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalGridPresentationError( + "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", + ); + } + + const request = toRequest(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, so its panes keep the durable + // identity of the expansion that wrote them and this operation owns their + // cancellation and teardown. + const issued: IssuedGrid = { + request, + generation: installation.generation, + used: false, + *run(grid) { + settled = yield* runGrid(request, grid, work, boundary); + }, + }; + 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 and a grid contend for exactly this, so + // neither can begin while the other holds it. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + // Routed, and the answer thrown away. + yield* TerminalGrids.operations.open(request); + + if (settled === undefined) { + throw new TerminalGridPresentationError( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid a provider presented, on the resource it supplied. + * + * The provider's grid is scope-owned, so every path out of here — success, + * failure, and cancellation alike — releases exactly the grid that was + * presented, exactly once. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function runGrid( + request: TerminalGridRequest, + provided: Operation, + work: readonly PaneWork[], + boundary: CloseBoundary, +): Operation { + return scoped(function* (): Operation { + // Acquired here, inside the grid's own scope: this is the provider's grid + // coming into existence, and this scope's teardown is what takes it down + // again — once, whether the grid succeeds, fails to start, is closed, is + // failed by the provider, or is cancelled. There is nothing to destroy by + // hand and no way to destroy twice. + const grid = yield* provided; + + // One pane's worth of state per authored ordinal, and nothing else knows it + // exists. A pane gets its `PaneTerminal` and only that; the grid asks these + // closures about an ordinal it already knows. + validateOrdinals(request); + const up = request.panes.map(() => withResolvers()); + const started = request.panes.map(() => false); + const busy = request.panes.map(() => false); + let admitting = true; + + /** Count a pane as started. A replayed pane did start, on the run that recorded it. */ + const markStarted = (ordinal: number): void => { + if (started[ordinal]) { + return; + } + started[ordinal] = true; + up[ordinal]!.resolve(); + }; + + const terminals: PaneTerminal[] = request.panes.map((_pane, ordinal) => ({ + *use(activity: TerminalActivity): Operation { + if (!admitting) { + throw new TerminalGridPresentationError( + `pane ${ordinal} is closed: its grid has stopped admitting terminal activities`, + ); + } + if (busy[ordinal]) { + throw new TerminalGridPresentationError( + `pane ${ordinal} already has a live terminal activity — one owns a pane ` + + `terminal at a time`, + ); + } + busy[ordinal] = true; + try { + // Acquired inside this scope, so its cleanup is awaited before the + // pane is free again — and acquiring it at all is what makes the pane + // ready. + return yield* scoped(function* (): Operation { + const outcome = yield* activity; + markStarted(ordinal); + return yield* outcome; + }); + } finally { + busy[ordinal] = false; + } + }, + })); + + // Nothing new is admitted once teardown begins, so a pane that was about to + // start a terminal activity is refused rather than racing the close. + yield* ensure(() => { + admitting = false; + }); + + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + // Reader close asks the panes to stop; it does not halt them. A pane that + // is asked settles as `closed` and records that outcome as its own, so a + // resumed run restores a pane the reader closed rather than finding a + // cancelled child it must either re-enter or wait on forever. + const closing = withResolvers(); + let attached = false; + + for (const pane of work) { + yield* grid.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const children: Task[] = []; + for (const [index, pane] of work.entries()) { + children.push( + yield* paneChild(function* (): Operation { + return yield* runPane( + pane, + terminals[index]!, + () => started[index] === true, + grid, + request, + index, + closing.operation, + ); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a pane the barrier counts as started. + for (const [index, task] of children.entries()) { + yield* spawn(function* () { + const outcome = yield* task; + outcomes[index] = outcome; + // A pane restored from its retained outcome satisfies the barrier + // without acquiring anything: it did start, on the run that recorded it. + markStarted(index); + yield* grid.update(work[index]!.ordinal, outcome.status); + if (outcome.status === "failed" && !attached) { + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + }); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for an acquisition that cannot happen. + try { + yield* race([all(up.map((pane) => pane.operation)), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a terminal grid pane failed to start"); + } + + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* grid.update(pane.ordinal, "running"); + } + } + yield* grid.attach(); + attached = true; + + // The grid stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* grid.closed(); + + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + + // Close prevents new work first, then takes the live panes down: a pane + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the provider's grid is released, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. + admitting = false; + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* grid.update(pane.ordinal, "closed"); + } + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* children[index]!; + outcomes[index] ??= outcome; + } + + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); + return retained(request, settled, reason); + }); +} + +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + terminal: PaneTerminal, + started: () => boolean, + grid: TerminalGrid, + request: TerminalGridRequest, + index: number, + closing: Operation, +): Operation { + return (function* (): Operation { + try { + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(terminal, grid)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. + yield* running.halt(); + return { status: "closed", reason: "" }; + } + if (!started()) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The record one grid settled to. */ +function retained( + request: TerminalGridRequest, + panes: readonly RetainedPaneOutcome[], + reason: string | undefined, +): RetainedGrid { + return { + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: [...panes], + }; +} + +/** The first failed pane's sentence in authored order, which is the grid's. */ +function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { + return outcomes.find((outcome) => outcome?.status === "failed")?.reason; +} + +/** + * Run one pane as a durable child of the grid. + * + * A pane's identity is derived from the grid's coroutine and its authored + * ordinal, never from a title, a schedule, or a provider identifier — so a + * resumed run restores a completed pane as its outcome without re-running it, + * and continues an incomplete one from its own history. + * + * `durableSpawn` rather than a combinator, because the grid owns the panes + * itself: it has to reach the readiness barrier and attach while they are still + * live, and cancel them one at a time when the reader leaves. A retained + * cancelled pane resumes its remaining work rather than suspending, which is + * `durableSpawn`'s policy for a spawned region. + * + * Without a journal there is no child to derive, and the work simply runs. + */ +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); + } + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); + })(); +} + +/** + * Run the whole grid as one durable child, and return what it retained. + * + * A completed grid replays by returning its retained result: the child's + * workflow never runs, so no provider is contacted, no pane content expands and + * no shell starts — and claiming the completed child claims every pane history + * beneath it, so a resumed run starts nothing. + */ +export function durableGrid( + live: (boundary: CloseBoundary) => Operation, +): Operation { + return (function* (): Operation { + const boundary = createCloseBoundary(); + const durable = yield* DurableContext.get(); + if (durable === undefined) { + // No journal to finish into, so the boundary is crossed as soon as it is + // proposed and the grid closes in one step. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* live(boundary); + } + + // The grid's durable child runs in a scope of its own — a child of this one, + // so it inherits every context the document runs under, and its own so that + // tearing this one down does not reach the child first. + // + // That ordering is what makes the await below genuinely deferred. A scope + // runs its finalizers in reverse, so one registered after this scope exists + // runs before this scope is destroyed: the grid and its panes finish their + // own teardown and append their ordinary completed `Close` records, and only + // then does the cancellation carry on to the parent. + const [detached, destroy] = createScope(yield* useScope()); + const held: { + task?: Task; + outcome?: Result; + } = {}; + + // Registered after the scope and before the await, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && boundary.acknowledged && held.outcome === undefined) { + held.outcome = yield* finish(held.task); + } + yield* until(destroy()); + }); + + held.task = yield* durableSpawnIn(detached, function* (): Workflow { + return yield* ephemeral(live(boundary)); + }); + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + + held.outcome = yield* finish(held.task); + yield* until(destroy()); + if (!held.outcome.ok) { + throw held.outcome.error; + } + return held.outcome.value; + })(); +} + +/** Await one grid child, keeping how it ended rather than re-throwing it here. */ +function* finish(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 000000000..3ee4fd8d4 --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,210 @@ +/** + * Which grid a run opened, and how a resumed run is held to it + * (spec §6.21 Durability and replay). + * + * One entry, appended in the **parent** coroutine and **before** the foreground + * lease is taken or any provider is contacted: the columns and rows, and the + * ordered pane forms, titles and positions. A resumed run compares what it + * derived against what is held and refuses a document whose grid changed while + * nothing has been opened and nothing has started. + * + * It sits in the parent deliberately. The grid itself is a durable child, and a + * completed child short-circuits without running — so a comparison written + * inside it would never happen on the run that most needs it. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or pane identifier, no argv or environment, and no terminal byte is + * written here: none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + DurableContext, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; +import { retainedLayout } from "./grid.ts"; +import type { RetainedGrid } from "./grid.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; +} + +type RetainedLayout = RetainedGrid["layout"]; + +function describe(identity: GridIdentity): EffectDescription { + return { + type: "terminal_grid_layout", + name: `terminal_grid:${identity.path}:layout`, + ...sourceDescription(identity.position), + }; +} + +/** Whether this expansion has a journal to read and append to at all. */ +function* durable(): Operation { + return (yield* DurableContext.get()) !== undefined; +} + +/** + * Append one entry and return what the entry holds. + * + * Live it is the value passed in; on replay it is the value the journal already + * held, which is the only way a caller tells the two apart. + */ +function* append(description: EffectDescription, value: Json): Workflow { + return yield createDurableOperation(description, function* () { + return value; + }); +} + +/** + * The layout a journal entry holds, parsed member by member. + * + * Total: every field is read and checked, and anything the record does not say + * exactly — a missing member, a member of the wrong kind, an extra one, a pane + * whose ordinal is not its position, a row or column that does not follow from + * the columns it claims — makes the record unreadable rather than half-read. A + * layout is what a resumed run is held to, so a record that cannot be believed + * in full must not be believed in part. + */ +function readLayout(value: unknown): RetainedLayout | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { + return undefined; + } + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.panes; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const panes: RetainedLayout["panes"] = []; + for (const [index, entry] of list.entries()) { + const pane = readPane(entry, index, columns); + if (pane === undefined) { + return undefined; + } + panes.push(pane); + } + // The rows a grid claims have to be the rows its panes need, or the record + // describes a grid nothing could have derived. + if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { + return undefined; + } + return { columns, rows, panes }; +} + +/** One retained pane, checked against the position it claims to occupy. */ +function readPane( + value: unknown, + index: number, + columns: number, +): RetainedLayout["panes"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) { + return undefined; + } + const { ordinal, title, form, row, column } = record; + if (ordinal !== index) { + return undefined; + } + if (typeof title !== "string" || title.length === 0) { + return undefined; + } + if (form !== "paired" && form !== "self-closing") { + return undefined; + } + // Derived, not asserted: a position that does not follow from the ordinal and + // the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { ordinal, title, form, row, column }; +} + +/** The members of a JSON object, or `undefined` for anything else. */ +function members(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +/** Whether a record carries exactly these member names, and no others. */ +function onlyNames(record: Record, names: readonly string[]): boolean { + const present = Object.keys(record); + return present.length === names.length && names.every((name) => name in record); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +/** How two layouts differ, in the words an author can act on. */ +function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.panes.length !== derived.panes.length) { + return `${held.panes.length} panes rather than ${derived.panes.length}`; + } + for (const [index, pane] of derived.panes.entries()) { + const before = held.panes[index]!; + if (before.title !== pane.title) { + return `pane ${index} titled "${before.title}" rather than "${pane.title}"`; + } + if (before.form !== pane.form) { + return `pane ${index} written ${before.form} rather than ${pane.form}`; + } + if (before.row !== pane.row || before.column !== pane.column) { + return ( + `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${pane.row}, column ${pane.column}` + ); + } + } + return undefined; +} + +/** + * Record which grid this is, and refuse a resumed run whose grid changed. + * + * Expansion driven without a journal records nothing and behaves identically. + */ +export function* recordGridLayout( + identity: GridIdentity, + request: TerminalGridRequest, +): Operation { + if (!(yield* durable())) { + return; + } + const derived = retainedLayout(request); + const description = describe(identity); + const stored = yield* append(description, derived); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run the ` + + "document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, derived); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this 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 }, + ); + } +} diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts new file mode 100644 index 000000000..f5f73f165 --- /dev/null +++ b/packages/core/src/terminal/pane.ts @@ -0,0 +1,72 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the capability. The value it holds + * is the one `PaneTerminal` the grid built for this pane, and it grants nothing + * once that grid stops admitting work — so a replaced context, or one kept past + * the expansion that owns it, yields a pane terminal nobody owns rather than a + * way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalActivity } from "@executablemd/runtime"; + +/** + * The pane the current work is running in. + * + * One operation, because one is all a pane needs: run something interactive + * here, as this pane's owner. There is no identity on it — core knows which + * ordinal it built this for, and a pane that could name itself would be a pane + * something else could name. + */ +export interface PaneTerminal { + /** + * Run one terminal activity as this pane's owner. + * + * The activity is a resource. Acquiring it is the pane becoming ready, which + * is why nothing here takes a callback: a child that could not be prepared or + * spawned fails before acquisition, and a pane whose activity never came up + * never becomes ready — so the grid it belongs to never attaches. + * + * Settlement is awaited inside the same scope, and the activity's own cleanup + * is awaited before the pane is free again. A second use while one is live on + * this pane is refused, and so is any use once the grid has stopped admitting + * work. Sequential uses are ordinary. Two panes do not contend at all. + */ + use(activity: TerminalActivity): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * The terminal is installed as it was given. Wrapping it here would put a + * second object between the pane'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 pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(terminal: PaneTerminal): Operation { + yield* PaneTerminalContext.set(terminal); +} diff --git a/packages/core/src/terminal/presentation.ts b/packages/core/src/terminal/presentation.ts new file mode 100644 index 000000000..19c335768 --- /dev/null +++ b/packages/core/src/terminal/presentation.ts @@ -0,0 +1,142 @@ +/** + * Who may present a grid, and for which request (architecture.md §Terminal + * presentation). + * + * The provider draws a grid. This decides one thing about it: whether the + * request being presented is the exact one core 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 panes 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. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalGrid, TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalGridPresentationError extends Error { + override name = "TerminalGridPresentationError"; +} + +/** + * 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. + * + * The grid arrives as a resource the provider owns. Core acquires it only once + * the presentation has been admitted, so a refused presentation costs the + * provider nothing at all, and releases it exactly once however the grid ends. + */ +export type PresentTerminalGrid = ( + request: TerminalGridRequest, + grid: Operation, +) => Operation; + +/** One grid an expansion submitted, and what it is waiting to be given. */ +interface IssuedGrid { + /** The exact request object core 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(grid: Operation): 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, grid) { + 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 the provider's grid is touched: a refused presentation + // acquires nothing and leaves the provider holding nothing. + found.used = true; + yield* found.run(grid); + }; +} + +/** 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 +>("core.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(); +} + +export type { IssuedGrid }; diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 000000000..ed1bd7b13 --- /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 { Execution } from "../execute.ts"; +import { useTerminalInstallation } from "./presentation.ts"; +import { installTerminalProvider } from "./provider-api.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/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts new file mode 100644 index 000000000..80d2d4377 --- /dev/null +++ b/packages/core/src/terminal/provider-api.ts @@ -0,0 +1,263 @@ +/** + * How a terminal provider is installed, and what installing one grants. + * + * A provider is the only thing that can present a grid, so *selecting* one is + * itself a presentation decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's presentation function and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { PresentTerminalGrid } from "./presentation.ts"; + +/** 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; +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +/** + * 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, grid) => Reflect.apply(present, undefined, [request, grid]), + }; +} + +/** + * 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/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 76c440cba..626cbf34b 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -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..ec78d0f05 --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,2790 @@ +/** + * Tier TG — running a terminal grid through a replaceable provider + * (spec §6.21, architecture.md §Terminal grid presentation, §Atomic presentation and + * settlement, §Durability and replay). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because work in it acquired a terminal + * activity, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. + * + * A paired pane is ready only once something in it acquires a terminal activity + * through `PaneTerminal.use()`. Until the native-launch Story lands, + * `` is what a suite writes to be that something — and it reaches + * the pane through the same seam a real `` will. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { + controlledTerminalGrid, + installControlledLauncher, + reserveTerminal, + TerminalGrids, + terminalProviderLog, +} from "@executablemd/runtime"; +import type { + ControlledTerminalGridOptions, + TerminalActivity, + TerminalShellOutcome, + TerminalGrid, + TerminalGridRequest, + TerminalProviderLog, + TerminalProviderResources, +} from "@executablemd/runtime"; + +import { Component } from "../src/component-api.ts"; +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { + TerminalGridPresentationError, + useTerminalInstallation, +} from "../src/terminal/presentation.ts"; +import type { PresentTerminalGrid } from "../src/terminal/presentation.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + TerminalProviderInstallError, + TerminalProviders, +} from "../src/terminal/provider-api.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +import { paneTerminal } from "../src/terminal/pane.ts"; +import type { PaneTerminal } from "../src/terminal/pane.ts"; +import { createCloseBoundary, openTerminalGrid } from "../src/terminal/grid.ts"; +import type { PaneWork, RetainedGrid } from "../src/terminal/grid.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 pane displayed. */ + shown: Map; + /** Everything the provider's grid 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 pane to be ready: acquired + * at once, settled at once. + */ +function startsAndSettles(onStart?: () => void): TerminalActivity { + return resource(function* (provide) { + onStart?.(); + yield* provide(done(undefined)); + }); +} + +/** An activity whose child spawned and stays until it is released. */ +function startsAndHolds(onStart?: () => void): TerminalActivity { + return resource(function* (provide) { + onStart?.(); + yield* provide(suspend()); + }); +} + +/** + * An activity whose child never spawned. + * + * It fails during acquisition, which is before a pane 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 pane-terminal rows read. + * + * The claim factory these rows used to call directly is gone, and rightly: the + * behaviour it carried is the grid's. So each of these is driven from inside a + * real pane, through the same `PaneTerminal` a `` reaches, and + * read back off an ordered record rather than inferred. + */ +interface PaneProbe { + /** Refusals the document's own work collected, in the order they happened. */ + readonly refusals: string[]; + /** Ordered marks: which pane entered and left its interactive work. */ + readonly marks: string[]; + /** Pane terminals kept past their grid on purpose. */ + readonly kept: PaneTerminal[]; + /** Announce that this pane is inside its interactive body. */ + entered(): void; + /** Settles once every pane this probe expects is inside one at the same time. */ + overlapped(): Operation; +} + +function paneProbe(expected = 2): PaneProbe { + 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); +} + +/** + * Open a grid and own the other side of its close boundary. + * + * `durableGrid()` owns that side in the document path: a grid proposes close and + * waits, and something has to acknowledge. A row that drives the lifecycle + * directly owns it here instead, or its grid waits for an owner that never + * arrives. + */ +function openGridWithCloseOwner(work: readonly PaneWork[]): Operation { + return (function* (): Operation { + const boundary = createCloseBoundary(); + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* openTerminalGrid(ONE_PANE, work, boundary); + })(); +} + +/** + * Pane work that begins a terminal activity and never finishes acquiring one. + * + * The grid therefore sits at the readiness barrier with the provider's grid + * held, which is a live grid a row can cancel without parking on a reader that + * will never leave. + */ +function startingPane(live: { resolve(): void }, finalized: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.use( + resource>(function* (provide) { + yield* ensure(() => { + finalized.push(mark); + }); + live.resolve(); + yield* suspend(); + yield* provide(done(undefined)); + }), + ); + }, + }; +} + +/** One authored pane, for the rows that drive the lifecycle directly. */ +const ONE_PANE = { + columns: 1, + rows: 1, + cells: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], +}; + +/** Pane work that acquires a terminal activity whose child is already finished. */ +function readyPane(opened: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.use(startsAndSettles(() => opened.push(mark))); + }, + }; +} + +/** + * Pane work that starts and then stays, announcing that it is live. + * + * Its finalizer is what a row reads to know the grid was actually taken down + * rather than left running: a pane nobody stopped never records one. + */ +function holdingPane(live: { resolve(): void }, finalized: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.use( + resource>(function* (provide) { + // Acquired, so the pane is ready. Released only when something stops + // the grid, which is what the finalizer records. + yield* ensure(() => { + finalized.push(mark); + }); + live.resolve(); + yield* provide(suspend()); + }), + ); + }, + }; +} + +/** The controlled interactive child, and a tripwire. */ +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, + probe: PaneProbe = paneProbe(), +): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.use(startsAndSettles()); + 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 pane's interactive body and stays there until every other + // pane is inside one too. Two panes 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 pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + const mark = String(props.mark); + yield* pane.use( + resource>(function* (provide) { + // Acquired: this pane is ready and is holding its activity. + probe.marks.push(`enter:${mark}`); + probe.entered(); + yield* provide( + (function* (): Operation { + // Settlement waits for every other pane to be holding one too. + // Panes that contended could never all be here at once; the + // deadline only turns a regression into a failed assertion + // instead of a hung suite. + const together = yield* race([ + (function* (): Operation { + yield* probe.overlapped(); + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${mark}:${together}`); + })(), + ); + }), + ); + probe.marks.push(`leave:${mark}`); + return ""; + }, + }, + { + // One pane, 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 pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.use( + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + try { + yield* pane.use(startsAndSettles(() => probe.marks.push("second entered"))); + } catch (error) { + probe.refusals.push(refusalOf(error)); + } + })(), + ); + }), + ); + // The pane is free again: one owner at a time is not one owner ever. + yield* pane.use(startsAndSettles(() => probe.marks.push("sequential"))); + // Kept deliberately, so a row can ask what it grants after the grid has + // closed. + probe.kept.push(pane); + return ""; + }, + }, + { + // Acquires two activities in turn. One pane started, not two. + name: "SettlesAtOnce", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + // Spawned and finished in the same breath: ready and settled at once. + yield* pane.use(startsAndSettles(() => 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 pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + // Fails before acquisition: a child that never spawned. + yield* pane.use(neverStarts(() => probe.marks.push("tried to start"))); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + // Slow to *start*: readiness is the acquisition, so the grid waits for + // this pane to come up rather than for it to finish. + yield* pane.use( + resource>(function* (provide) { + yield* sleep(25); + slowMarks.push("ready:slow"); + yield* provide(done(undefined)); + }), + ); + return ""; + }, + }, + { + // Holds the pane open, and blocks its own teardown until released — so a + // row can interrupt a run while reader-close teardown is in progress. + name: "SlowTeardown", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* ensure(function* () { + yield* teardownHeld(); + }); + // Armed: the finalizer is installed and this pane is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); + yield* suspend(); + return ""; + }, + }, + { + // Waits until the grid has attached, so a pane can fail *after* the + // barrier — which is the failure the grid contains as a status rather + // than the startup failure that fails the whole region. + name: "AfterAttach", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterAttach(); + return ""; + }, + }, + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); +} + +/** + * Register a controlled provider that presents through the function it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives presentation + * as an argument, supplies a grid resource of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledTerminalGridOptions & { + /** 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; + } = {}, +): Operation { + let generation = 0; + return registerTerminalProvider("controlled", function* (_settings, present) { + options.capture?.(present); + 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, + controlledTerminalGrid(request, options, generation++), + ); + return undefined; + }, + }, + { at: "min" }, + ); + }); +} + +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; + })(); +} + +/** 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?: ControlledTerminalGridOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; + /** What the pane-terminal rows record through the pane seam. */ + probe?: PaneProbe; + } = {}, +): 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 pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.grid ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + yield* 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, + }; + }); +} + +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} + +/** A grid on its own, which a resumed run can carry to an outcome. */ +function plainDocument(columns: number, panes: string[]): string { + return [``, ...panes, "", ""].join("\n"); +} + +/** A grid, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, panes: string[]): string { + return [ + ``, + ...panes, + "", + "", + // The sibling after the grid. It runs whether the grid ran or replayed, so + // a harness can wait for the document to have moved past the region. + ``, + "", + "", + "", + ].join("\n"); +} + +/** + * Run a document and interrupt it once the grid has journaled its outcome. + * + * A completed *or failed* root replays wholesale, so a second run of it would + * never reach the grid at all. Only a genuinely interrupted run leaves the + * region to be resumed — which is what every replay row below needs. + */ +function runInterrupted( + dir: string, + source: string, + stream: InMemoryStream, + options: { + provider?: boolean; + shell?: ControlledTerminalGridOptions["shell"]; + /** Let the reader leave, so the grid completes rather than staying open. */ + close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; + /** + * Keep the grid open until a pane reports a failure. + * + * A pane that fails *after* attachment is contained as that pane's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the pane as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** + * Called once a pane's finalizer has been entered and is blocked, with what + * the provider is holding at that moment. + * + * A row reads those counters here to know they ever went up, which is what + * makes reading them again at the end mean something. + */ + onTeardownEntered?: (live: 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 panes must have settled before the run is interrupted. + * + * A pane's status is published only after its durable child has returned, + * so this is also how many pane Closes the journal is known to hold. Rows + * that read those records name the number they need; rows that only need an + * open grid name none. + */ + settled?: number; + } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `attached` + // says a grid opened on this run. `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does and what a + // completed-region journal has to be waited for. `panesSettled` says the + // pane children the row cares about have written their own records. + // + // Every one of them is an event this run produced. Nothing here waits for a + // duration, so a replay that hangs reaches none of them and hangs the row — + // it can never hand back a run that looks finished but is not. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const panesSettled = withResolvers(); + let settledPanes = 0; + if ((options.settled ?? 0) === 0) { + panesSettled.resolve(); + } + // The printed errors this run produced, which is how a contained failure is + // observable at all — and the same list on a replayed run is how "the same + // result came back" is read rather than assumed. + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); + const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); + const marked = withResolvers(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } + }, + () => attached.operation, + function* () { + options.onTeardownEntered?.(log.live); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + options.onTeardownExited?.(); + }, + () => armed.resolve(), + ); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: (ordinal: number) => + resource>(function* (provide) { + // Acquired, so the pane is ready and the grid attaches; the + // failure is in the settlement afterwards, which is the + // failure a grid contains as a pane status. + if (ordinal !== options.shellFailsAfterAttach) { + yield* provide(done({ exitCode: 0 })); + return; + } + yield* provide( + (function* (): Operation { + yield* attached.operation; + return { exitCode: 1 }; + })(), + ); + }), + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield + *onPrepare(asked) { + requests.push(asked); + }, + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + attached.resolve(); + }, + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } + }, + }); + } + yield* 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 opened and the pane + // records the row reads are durable. + if (options.interruptWhen !== undefined) { + yield* options.interruptWhen; + } else if (options.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } + // Cancellation is begun, then released, then awaited. A row that blocks a + // finalizer has to release it after the parent is cancelled, or the + // cancellation would be waiting on the very thing the row is holding; and + // awaiting the halt afterwards is what proves teardown completed rather + // than merely started. + const halting = yield* spawn(() => task.halt()); + options.releaseOnInterrupt?.(); + yield* halting; + // Taken and given back twice, now that the run is over. The first proves + // the grid returned the foreground lease; the second proves this harness + // gave it back too, so the first cannot have passed against a lease nobody + // was holding in the first place. + for (let attempt = 0; attempt < 2; attempt++) { + yield* scoped(function* () { + yield* reserveTerminal(); + options.onLeaseReacquired?.(); + }); + } + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + errors, + journal: yield* stream.readAll(), + live: log.live, + }; + }); +} + +const PANES = [ + 'left', + '', +]; + +describe("Tier TG — presenting a grid", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { grid: {} as ControlledTerminalGridOptions }); + 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 run = yield* runDocument(dir, GRID, { + grid: {}, + }); + expect(run.outcome.ok).toBe(true); + + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what presentation reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + 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(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); + }); + + it("TA3: presenting a changed request authorizes nothing", function* () { + const dir = yield* useDir(); + const changed = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), + }); + yield* 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("TA4: a presentation function kept past its grid presents nothing", function* () { + const dir = yield* useDir(); + let kept: PresentTerminalGrid | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); + + yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (present) => (kept = present) }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + }); + + // The execution has finished, so the request it issued is no longer live. + let refusal: unknown; + yield* scoped(function* () { + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; + try { + yield* kept!(asked, controlledTerminalGrid(asked, {})); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); + }); + + it("TA5: a presentation function from another installation generation presents nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + // Two installations in one scope: the second supersedes the first, so the + // first's function names a generation the shared lookup no longer matches. + const stale = yield* scoped(function* () { + return yield* useTerminalInstallation(); + }); + yield* useTerminalInstallation(); + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; + try { + yield* stale(asked, controlledTerminalGrid(asked, {})); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); + }); + + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + const 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(refusal instanceof Error ? refusal.message : "").toContain("did not install"); + }); + + it("TA7: two panes are interactive at the same time", function* () { + const dir = yield* useDir(); + const probe = paneProbe(2); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { probe }, + ); + + expect(run.outcome.ok).toBe(true); + // Each pane held its own acquired activity until the other was holding one + // too. Panes that contended could not both report this. + expect(probe.marks).toContain("together:a:true"); + expect(probe.marks).toContain("together:b:true"); + // And both were holding before either let go. + expect(probe.marks.indexOf("enter:b")).toBeLessThan(probe.marks.indexOf("leave:a")); + }); + + it("TA8: one pane refuses overlapping work, and admits the next after it settles", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + expect(run.outcome.ok).toBe(true); + expect(probe.refusals).toHaveLength(1); + expect(probe.refusals[0]).toContain("one owns a pane terminal at a time"); + // The refused operation never ran, and the one written after the first + // settled did: a pane 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 pane terminal kept past its grid admits nothing", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + expect(run.outcome.ok).toBe(true); + const kept = probe.kept[0]; + expect(kept).toBeDefined(); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.use(startsAndSettles()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("its grid 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 = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // Acquired and settled in the same breath: the grid attached rather than + // waiting for a pane that had already finished. + expect(run.outcome.ok).toBe(true); + expect(probe.marks).toContain("started and settled"); + expect(run.events).toContain("attach:0"); + }); + + it("TA11: an activity that fails before acquisition never makes a pane ready", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // The pane owned its terminal and tried. Neither is starting. + expect(probe.marks).toContain("tried to start"); + // The pane 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).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TA12: a layout whose ordinal is not its position is refused before a pane exists", function* () { + // The guard the lifecycle runs before it builds a single pane terminal. + // Asked through the real entry point with a layout core would never derive: + // the second cell calls itself pane 0 while sitting at position 1, so the + // request describes a grid nobody authored. + const attached: string[] = []; + const started: number[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* useGridHost({ + // deno-lint-ignore require-yield + *onAttach() { + attached.push("attach"); + }, + }); + + const work: PaneWork[] = [0, 1].map((ordinal) => ({ + ordinal, + // deno-lint-ignore require-yield + *run() { + started.push(ordinal); + }, + })); + + try { + yield* openTerminalGrid( + { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }, + work, + createCloseBoundary(), + ); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + const message = refusal instanceof Error ? refusal.message : ""; + // The refusal says which ordinal, and where it actually sat. + expect(message).toContain("ordinal 0"); + expect(message).toContain("position 1"); + // Refused before anything could own a pane terminal: no pane work ran, and + // nothing was ever shown. + expect(started).toEqual([]); + expect(attached).toEqual([]); + }); +}); + +/** + * What every completed journey must be able to say. + * + * The provider's grid 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 grid 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, attached: 0, shells: 0 }); +} + +/** + * A provider grid 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 watchedGrid(effects: string[], label: string): Operation { + return resource(function* (provide) { + effects.push(`acquired:${label}`); + yield* ensure(() => { + effects.push(`released:${label}`); + }); + yield* provide({ + // deno-lint-ignore require-yield + *attach() { + effects.push(`attach:${label}`); + }, + // deno-lint-ignore require-yield + *update() {}, + // deno-lint-ignore require-yield + *display() {}, + shell: () => + resource>(function* (provideOutcome) { + effects.push(`shell:${label}`); + yield* provideOutcome(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *closed() {}, + }); + }); +} + +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, + work: readonly PaneWork[], + ): Operation { + return scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, 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 { + return yield* openGridWithCloseOwner(work); + } catch (error) { + return error; + } + }); + } + + it("TR1: a copied request is refused, and the copy's grid is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + 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, + panes: request.panes.map((pane) => ({ ...pane })), + }; + try { + yield* present(copy, watchedGrid(effects, "copy")); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR2: a changed request is refused, and its grid is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider( + function* (present, request) { + try { + yield* present( + { ...request, columns: request.columns + 1 }, + watchedGrid(effects, "changed"), + ); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + 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, watchedGrid(effects, "live")); + }, + [readyPane(opened, "a")], + ); + }); + + // The grid ran and finished, so its submitting operation has unwound and + // the request it issued is no longer anything to present for. + expect(opened).toEqual(["a"]); + expect(effects).toEqual(["acquired:live", "attach:live", "released:live"]); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.present(kept!.request, watchedGrid(effects, "stale")); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("is not live"); + // Nothing new: the stale grid was never acquired. + expect(effects).toEqual(["acquired:live", "attach: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, watchedGrid(effects, "first")); + try { + yield* present(request, watchedGrid(effects, "second")); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + expect(refusalOf(refusal)).toContain("already been presented"); + // One grid acquired and released; the second was never touched. + expect(effects).toEqual(["acquired:first", "attach:first", "released:first"]); + }); + + it("TR5: the exact live request is refused under another installation generation", function* () { + const effects: string[] = []; + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, 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, watchedGrid(effects, "wrong-generation")); + } catch (error) { + refusal = error; + } + // Then the right one presents, so the grid still settles. + yield* presentGrid(request, watchedGrid(effects, "right-generation")); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + yield* openGridWithCloseOwner([holdingPane(live, finalized, "pane")]); + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("belongs to another terminal provider installation"); + // The refused generation's grid 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"); + }); +}); + +describe("Tier TG — issuing, presenting and settling one grid", () => { + /** + * A provider that presents exactly what it was routed, with hooks for the + * rows that need to interrupt it. + * + * Written out rather than reusing the document harness because these rows + * drive `openTerminalGrid()` directly, so the grid's only owner is the + * operation the row is holding. + */ + function usePresentingHost( + log: TerminalProviderLog, + options: { + readonly close?: () => Operation; + readonly onAttach?: () => Operation; + readonly onPresent?: ( + present: () => Operation, + request: TerminalGridRequest, + presentAny: PresentTerminalGrid, + ) => Operation; + readonly seen?: TerminalGridRequest[]; + } = {}, + ): Operation { + return (function* (): Operation { + let generation = 0; + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, present) { + yield* TerminalGrids.around( + { + *open([request]) { + options.seen?.push(request); + const grid = controlledTerminalGrid( + request, + { + log, + ...(options.close === undefined ? {} : { close: options.close }), + ...(options.onAttach === undefined ? {} : { onAttach: options.onAttach }), + }, + generation++, + ); + const presentThis = () => present(request, grid); + if (options.onPresent === undefined) { + yield* presentThis(); + } else { + yield* options.onPresent(presentThis, request, present); + } + return undefined; + }, + }, + { at: "min" }, + ); + }); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; + })(); + } + + it("TS1: a registered provider that is never routed starts nothing", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + + yield* scoped(function* () { + yield* installControlledLauncher(); + // Registered and installed, and it answers the routed request without + // ever presenting: reaching a provider is not opening a grid. + yield* registerTerminalProvider("controlled", function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open() { + return { presented: true }; + }, + }, + { at: "min" }, + ); + }); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + + let refusal: unknown; + try { + yield* openGridWithCloseOwner([readyPane(opened, "a")]); + } catch (error) { + refusal = error; + } + expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); + }); + + // Submitted and never presented: no pane ran and no grid existed. + expect(opened).toEqual([]); + expect(log.events).toEqual([]); + expect(log.live.grids).toBe(0); + }); + + it("TS2: one request opens one grid, however often it is presented", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + let second: unknown; + + yield* scoped(function* () { + yield* usePresentingHost(log, { + *onPresent(present, request, presentAny) { + yield* present(); + // The same request again, with a grid of its own, once the grid + // it named has already run. + try { + yield* presentAny(request, controlledTerminalGrid(request, { log }, 9)); + } catch (error) { + second = error; + } + }, + }); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); + }); + + // The matching grid ran exactly once, and the second presentation of the + // same request opened nothing. + expect(opened).toEqual(["a"]); + expect(second).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(second)).toContain("already been presented"); + }); + + it("TS3: a settled grid is gone, and the next one still opens", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + const seen: TerminalGridRequest[] = []; + let stale: unknown; + + yield* scoped(function* () { + const present = yield* usePresentingHost(log, { seen }); + + yield* openGridWithCloseOwner([readyPane(opened, "first")]); + yield* openGridWithCloseOwner([readyPane(opened, "second")]); + + // The first grid's request is no longer something a provider can present + // for: its entry went when its submitting operation unwound. + try { + yield* present(seen[0]!, controlledTerminalGrid(seen[0]!, { log }, 9)); + } catch (error) { + stale = error; + } + }); + + expect(opened).toEqual(["first", "second"]); + expect(stale).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(stale)).toContain("is not live"); + // Only the settled grid was removed, and only after its own teardown: the + // first grid was released before the second was ever prepared, and + // both grids destroyed theirs. + expect(log.events).toContain("destroy:0"); + expect(log.events).toContain("destroy:1"); + expect(log.events.indexOf("destroy:0")).toBeLessThan(log.events.indexOf("prepare:1:1x1")); + }); + + it("TS4: a presenting call that is cancelled leaves no grid running", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* usePresentingHost(log, { + // The reader never leaves, so the grid stays live until something stops + // it. + close: () => suspend(), + *onPresent(present) { + const presenting = yield* spawn(present); + yield* live.operation; + // The provider's own call goes while its grid is still running. + yield* presenting.halt(); + }, + }); + + try { + yield* openGridWithCloseOwner([holdingPane(live, finalized, "pane")]); + } catch (error) { + refusal = error; + } + }); + + // The grid went with the call that owned it rather than carrying on + // without one: its pane ran its finalizer, and the provider holds nothing. + expect(finalized).toEqual(["pane"]); + expect(log.live.grids).toBe(0); + expect(log.live.attached).toBe(0); + expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); + }); + + it("TS5: cancelling the submitting operation takes its grid down, installation and all still live", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + let heldWhileLive = -1; + + yield* scoped(function* () { + yield* usePresentingHost(log); + + // The grid is live — its provider grid acquired, its pane starting — and + // the row's own branch then wins the race, cancelling the submitting + // operation and nothing else. The installation is untouched: this is what + // owns a grid, structured concurrency beneath the expansion rather than + // anything holding tasks for the execution. + yield* race([ + (function* (): Operation { + yield* openGridWithCloseOwner([startingPane(live, finalized, "pane")]); + })(), + (function* (): Operation { + yield* live.operation; + heldWhileLive = log.live.grids; + })(), + ]); + + expect(heldWhileLive).toBe(1); + // The pane's activity was released and the provider's grid with it. + expect(finalized).toEqual(["pane"]); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + + // And the installation is still live: it issues and settles another grid. + const opened: string[] = []; + yield* openGridWithCloseOwner([readyPane(opened, "after")]); + expect(opened).toEqual(["after"]); + }); + }); + + it("TS7: presenting stays blocked until the grid has settled and been released", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + const order: string[] = []; + + yield* scoped(function* () { + yield* usePresentingHost(log, { + *onPresent(present) { + yield* present(); + // Read the moment presentation returns: the grid must already be + // settled and released, not merely started. + order.push(`returned:${log.live.grids}:${log.live.attached}`); + order.push(...log.events.filter((event) => event.startsWith("destroy:"))); + }, + }); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); + }); + + expect(opened).toEqual(["a"]); + // Nothing was still held when the provider's call came back, and the + // release had already been recorded. + expect(order).toEqual(["returned:0:0", "destroy:0"]); + }); + + it("TS8: an incomplete replay acquires only the activity that must resume", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = [ + "", + '', + '', + "", + "", + ``, + "", + ].join("\n"); + + // The shell holds, so the run is interrupted with the left pane complete + // and the shell pane incomplete. + const holdingShell: ControlledTerminalGridOptions["shell"] = () => + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }); + + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + expect(first.ran).toContain("left ran"); + + // Resumed: the completed pane restores its outcome without acquiring + // anything, and only the shell that must resume acquires an activity. + const second = yield* runInterrupted(dir, source, stream, { settled: 1 }); + expect(second.ran).not.toContain("left ran"); + expect(second.events.filter((event) => event.startsWith("shell:"))).toHaveLength(1); + }); + + it("TS6: a second grid cannot be live beside the first", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* usePresentingHost(log, { close: () => suspend() }); + yield* spawn(() => openGridWithCloseOwner([holdingPane(live, finalized, "first")])); + yield* live.operation; + + // Why "every remaining grid" is one grid: the foreground-terminal lease + // admits a single grid at a time, so a second never reaches the + // lookup at all. + try { + yield* openGridWithCloseOwner([readyPane([], "second")]); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + expect(finalized).toEqual(["first"]); + expect(log.live.grids).toBe(0); + }); +}); + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + // A grid that succeeded released its provider's grid once, holding nothing. + expectReleasedOnce(run); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]?.panes).toEqual([ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { + const dir = yield* useDir(); + const flushed: string[] = []; + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + { + 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 pane's own text went to that pane. + expect(run.shown.get(0)).toContain("left text"); + expect(run.shown.get(1)).toContain("right text"); + // The grid renders "": the root output holds what surrounds it and no pane + // display at all. + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("left text"); + expect(run.output).not.toContain("right text"); + }); + + it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + '', + "", + "", + '', + "sees {shared}", + "", + '', + "", + "then {mine}", + "", + "", + "", + '', + "sees {shared} and {mine}", + "", + "", + "", + "", + "", + "after {mine}", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Inherited from the grid site. + expect(run.shown.get(0)).toContain("sees site"); + expect(run.shown.get(1)).toContain("sees site"); + // Created inside one pane, visible to later work in that pane. + expect(run.shown.get(0)).toContain("then left"); + // Invisible to the sibling and to the document after the grid: an + // unresolved binding stays the literal text it was written as. + expect(run.shown.get(1)).toContain("and {mine}"); + expect(run.output).toContain("after {mine}"); + }); + + it("TG6: a pane's cannot claim a value body outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "---", + "returns:", + " type: string", + "---", + "", + '', + '', + "", + "", + "", + "", + '', + "", + ].join("\n"), + ); + + // The pane has no enclosing value body to claim, so the written in + // it is refused where it sits rather than becoming the document's value. + expect(failureOf(run)).toContain( + "is not written in the flow of a body that declares `returns`", + ); + expect(failureOf(run)).not.toContain("from the document"); + }); + + it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', + "", + "", + "", + "", + ].join("\n"), + ); + + // Printed inside the pane it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this pane gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this pane gave up"); + }); + + it("TG6: a paired pane runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the pane's *second* component has run, so a + // pane body that stopped after the first would never let the grid close — + // a hang rather than a pass. + const run = yield* runInterrupted( + dir, + heldDocument(2, [ + '', + '', + ]), + stream, + { close: true, closeWhenMarked: "second component" }, + ); + + expect(run.ran).toContain("second component"); + }); + + it("TG9: with no provider installed, no pane body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no terminal provider is installed"); + // The pane held work; none of it was reached, and nothing was displayed. + expect(run.ran).toEqual([]); + expect(run.shown.size).toBe(0); + }); +}); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has acquired a terminal activity", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the grid both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + grid: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + shell: () => + resource>(function* (provide) { + timeline.push("ready:shell"); + yield* provide(done({ exitCode: 0 })); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden grid was released — once, + // with nothing of the provider's still held. + expect(run.events).not.toContain("attach:0"); + 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", + ), + { + grid: { + // Spawns and is finished in the same breath: ready and settled. + shell: () => + resource>(function* (provide) { + yield* provide(done({ exitCode: 0 })); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready the moment the activity was acquired, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + expect(run.events.indexOf("state:0:0:succeeded")).toBeGreaterThan( + run.events.indexOf("attach:0"), + ); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and releases it", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // deno-lint-ignore require-yield + *onAttach() { + 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 ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + grid: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // 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 pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + grid: { + // The reader's close operation is where an active provider can fail. + // 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 grid released exactly once. + expectReleasedOnce(run); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + /** + * A grid whose only pane never starts, with its failure contained. + * + * `` keeps the document going, so the root reaches no outcome of + * its own and a resumed run reaches the region rather than replaying the root + * wholesale. + */ + const CONTAINED_FAILURE = [ + "", + "", + '', + '', + "", + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + /** + * Whether the grid child reached a terminal record of its own. + * + * `ok` or `err`: both are outcomes the region settled on. Only a cancelled + * close, or no close at all, means it was interrupted — and that is the + * difference this row exists to depend on. + */ + function completedGrid(run: DocumentRun): boolean { + return run.journal.some( + (event) => + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + (event.result.status === "ok" || event.result.status === "err"), + ); + } + + /** What the grid child retained, read from its own completed `Close`. */ + function retainedGrid(run: DocumentRun): Record | undefined { + for (const event of run.journal) { + if ( + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + event.result.status === "ok" + ) { + const value = event.result.value; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return { ...value }; + } + } + } + return undefined; + } + + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + const panes = retainedGrid(run)?.panes; + return Array.isArray(panes) ? panes : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * say how many records each level wrote and what each one settled to — + * including whether any of them settled as a cancellation. + */ + function closeStatuses(run: DocumentRun, depth: number): string[] { + const statuses: string[] = []; + for (const event of run.journal) { + if (event.type === "close" && String(event.coroutineId).split(".").length === depth) { + statuses.push(event.result.status); + } + } + return statuses; + } + + it("TG15: a completed successful grid replays its exact result, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, GRID, stream, { close: true }); + expect(first.requests).toHaveLength(1); + // The region genuinely completed: without that this row would be about an + // interrupted grid resuming, which is TG16's claim rather than this one. + expect(completedGrid(first)).toBe(true); + + const second = yield* runInterrupted(dir, GRID, stream, { close: true }); + + // No provider was asked for a grid, nothing was prepared or attached, no + // pane content expanded, no shell or launcher ran, and nothing displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG15: a contained failed grid replays the same failure, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + closeAfterFailure: true, + shellFailsAfterAttach: 0, + }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).toBe(true); + // What the failure looked like, as the document reported it. + expect(first.errors.some((message) => message.includes("shell exited with status 1"))).toBe( + true, + ); + + // No provider at all on the resumed run: a replay that contacted one would + // refuse, and the retained result does not need one. + const second = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + close: true, + provider: false, + }); + + // The same result came back, rather than being derived again. + expect(second.errors).toEqual(first.errors); + // And the document carried on from it, exactly as it did the first time. + expect(second.ran).toContain(PAST_THE_GRID); + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // Both panes settle, so both pane children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); + + const closes = first.journal.filter((event) => event.type === "close"); + const paneIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order, and both beneath one grid child. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); + + it("TG16: an interrupted grid acquires a fresh provider grid rather than hanging", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // Interrupted while the grid is open, so its child records a cancelled + // close. Under the repaired spawn policy the resumed run continues that + // region instead of suspending on it forever. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // A fresh provider grid, acquired by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); + }); + + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledTerminalGridOptions["shell"] = () => + resource>(function* (provide) { + // 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 pane settles; the shell holds, so only one pane record exists. + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + expect(first.ran).toContain("left ran"); + + const second = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + + // The completed pane came back from its retained outcome: its body did not + // run again. + expect(second.ran).not.toContain("left ran"); + // The incomplete shell starts again under current host policy, claiming no + // continuity with the terminal history it had before. + expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); + }); + + /** + * A grid whose `columns` and first `title` come from props. + * + * A continuation executes the retained root, so the document itself cannot + * change between runs — but props are not restored, so these two values are + * exactly what a fixed retained source can still resolve differently. + */ + const PROP_BORNE = [ + "---", + "props:", + " columns:", + " type: number", + " label:", + " type: string", + "---", + "", + "left", + '', + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + it("TG17: a changed prop-borne column count refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 3, label: "Left" }, + }); + + // Refused before the foreground lease and before the provider: nothing was + // prepared, attached or displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + // A replay refusal, not a run that opened something and then failed. The + // sentence is the divergence report's: a refusal raised while retained + // children are still being replayed loses to it, which is established + // behaviour rather than something this row can change. + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 2, label: "Elsewhere" }, + }); + + expect(second.requests).toEqual([]); + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: an unchanged prop-borne layout is admitted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const props = { columns: 2, label: "Left" }; + + yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + const second = yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + + // The discriminator for the two rows above: the same resolved layout + // resumes and opens a grid, so a refusal there is about the change. + expect(second.requests).toHaveLength(1); + }); + + it("TG17: a continuation opens the retained structure, not the file's", function* () { + const structural: [string, string[]][] = [ + ["pane count", [...PANES, '']], + ["pane order", ['', ...PANES.slice(0, 1)]], + ["pane form", ['', '']], + ]; + + for (const [what, panes] of structural) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + const retained = first.requests[0]!; + + // The file now says something else. A continuation executes the root the + // journal retained, so the grid it opens is the one that was recorded. + const second = yield* runInterrupted(dir, heldDocument(2, panes), stream); + + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); + expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( + `${what}: ${JSON.stringify(retained)}`, + ); + } + }); + + it("TG17: the retained record holds the complete authored pane structure", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const layout = run.journal.find( + (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), + ); + expect(layout).toBeDefined(); + const value = + layout?.type === "yield" && layout.result.status === "ok" ? layout.result.value : undefined; + // Every authored pane, with its ordinal, title, form and derived position. + expect(value).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }); + }); + + it("TG17: a malformed retained layout refuses before provider observation", function* () { + /** The retained layout, replaced by something the record cannot mean. */ + const damaged: [string, Json][] = [ + ["a missing member", { columns: 2, panes: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a pane out of position", + { + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 0, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 3, column: 1 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + ]; + + for (const [what, layout] of damaged) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + yield* runInterrupted(dir, GRID, stream); + + // The same journal with only its layout entry replaced, so nothing else + // about the continuation changes. + const damagedStream = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + const isLayout = + event.type === "yield" && String(event.description.name).endsWith(":layout"); + yield* damagedStream.append( + isLayout && event.result.status === "ok" + ? { ...event, result: { status: "ok", value: layout } } + : event, + ); + } + + const second = yield* runDocument(dir, GRID, { stream: damagedStream }); + + expect(`${what}: ${second.outcome.ok}`).toBe(`${what}: false`); + // Refused while reading the record, before anything was asked for. + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.events.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.shown.size}`).toBe(`${what}: 0`); + } + }); + + it("TG19: a cancellation during reader-close teardown waits for it, and replays", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + + // Signals and counters, and nothing else. Every step below is an event this + // run produced, so a lifecycle that never reached one hangs the row rather + // than passing it, and every "exactly once" claim is a count rather than a + // look at the record. + const entered = withResolvers(); + const release = withResolvers(); + let entries = 0; + let exits = 0; + let leases = 0; + let heldWhenBlocked: TerminalProviderResources | undefined; + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live pane arms its blocking finalizer, and 2. only then does the + // reader leave. + closeWhenArmed: true, + // 3. Entering the finalizer is observed, and it blocks there. + onTeardownEntered: (live) => { + entries++; + heldWhenBlocked = { ...live }; + entered.resolve(); + }, + holdTeardown: () => release.operation, + onTeardownExited: () => { + exits++; + }, + // 4. Cancellation begins while that finalizer is still blocked. + interruptWhen: entered.operation, + // 5. Released afterwards, so the cancellation was not waiting on it. + releaseOnInterrupt: () => release.resolve(), + onLeaseReacquired: () => { + leases++; + }, + }); + + // 6. Teardown ran to the end, and the grid recorded a completed close — + // both before the cancellation was observed, because the document never + // reached the sibling after the grid. + expect(entries).toBe(1); + expect(exits).toBe(1); + expect(first.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(first.ran).toEqual(["pane body"]); + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // The provider's counters went up and came back down. Reading them only at + // the end would be true of counters that never moved. + expect(heldWhenBlocked).toEqual({ grids: 1, attached: 1, shells: 0 }); + expect(first.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + // And the foreground lease came back: it was taken and given back twice + // over once the run was done. + expect(leases).toBe(2); + + // 7. Resumed with three tripwires: no provider at all, so a replay that + // asked for a grid would refuse; a mark inside the pane body, so a pane + // that expanded again would say so; and the finalizer, which would + // report being entered a second time. + let reentered = 0; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered++; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(0); + // The retained grid came back and the document carried on from it. + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const written = JSON.stringify(run.journal); + expect(written).toContain('"columns":2'); + expect(written).toContain('"Left"'); + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); diff --git a/packages/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..9db92312b 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -146,6 +146,26 @@ export type { NativeLaunchOutcome, NativeLaunchRequest, } from "./launcher.ts"; +export { + controlledTerminalGrid, + TERMINAL_GRIDS_API, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + terminalProviderLog, + TerminalProviderUnavailableError, +} from "./terminal.ts"; +export type { + ControlledTerminalGridOptions, + TerminalActivity, + TerminalGrid, + TerminalGridApi, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalProviderLog, + TerminalProviderResources, + TerminalShellOutcome, +} from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts new file mode 100644 index 000000000..93e573e7b --- /dev/null +++ b/packages/runtime/terminal.ts @@ -0,0 +1,367 @@ +/** + * The terminal grid boundary — how a host presents one grid of interactive + * panes, and what composing middleware around it may do. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native grid UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases and settles a grid is a + * non-contextual presentation function delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole grid while it is hidden, core starts the + * authored panes and waits for every one of them to acquire a terminal + * activity, and only then is anything attached. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure, resource } from "effection"; +import type { Operation } from "effection"; + +/** One pane the provider is asked to present, by its authored ordinal. */ +export interface TerminalPaneRequest { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** The label to display. Two panes may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this pane's work or the host's default shell + * does. A provider reads it to know which panes it must start a shell in. + */ + readonly form: "paired" | "self-closing"; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or pane identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and presentation compares the object it is given with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. + */ +export interface TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly panes: readonly TerminalPaneRequest[]; +} + +/** + * What core tells a provider about one pane, as it happens. + * + * A closed set, and display only. `running` follows readiness, `succeeded` and + * `failed` follow the pane's own settlement, and `closed` is a live pane + * cancelled solely because the reader closed the grid — which is not a failure + * and is deliberately spelled differently from one. + */ +export type TerminalPaneState = "starting" | "running" | "succeeded" | "failed" | "closed"; + +/** How a pane's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One terminal activity: something interactive a pane 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 pane 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. + */ +export type TerminalActivity = Operation>; + +/** + * One provider's realization of one complete grid. + * + * This *is* the grid the provider drew, for the one request it was presented, + * and it belongs to that one preparation: a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. It is + * supplied as a resource, so acquiring it is how a grid comes to exist and + * releasing it is how it goes — 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 it twice. + */ +export interface TerminalGrid { + /** + * Show the grid. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * acquisition, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Show text a pane's own content rendered. + * + * This is where a paired pane's output goes, and the only place it goes: it + * is never copied into the root document output or into a capture written + * around the grid, because the reader is looking at the pane. Terminal bytes + * an interactive child exchanges with the reader never come through here at + * all — those belong to the pane's terminal and are neither captured nor + * journaled. + */ + display(ordinal: number, text: string): Operation; + /** + * The host's default interactive shell in one pane, as a terminal activity. + * + * Which shell that is comes from live host policy, never from the document. + * Acquiring it means the shell started, which is what makes a self-closing + * pane ready; a shell that could not start is a failure before acquisition + * and leaves the pane unready. + */ + shell(ordinal: number): TerminalActivity; + /** + * Settle when the reader closes or leaves the grid. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; +} + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive panes. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +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 core 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(); + }, +}); + +/** + * Everything one controlled grid did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that release took down exactly the grid it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; + /** + * What each pane displayed, by ordinal. + * + * A suite reads this to prove where a pane's output went — and reads the root + * document output to prove where it did not. + */ + readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the grid 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; +} + +/** What one controlled provider holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Grids acquired and not yet released. */ + grids: number; + /** Grids attached and not yet released. */ + attached: number; + /** Shell activities acquired and not yet released. */ + shells: number; +} + +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { + events: [], + shown: new Map(), + live: { grids: 0, attached: 0, shells: 0 }, + }; +} + +/** + * What a controlled grid does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a grid exists, `onAttach` fails the barrier, `shell` decides + * what a self-closing pane's shell did and whether it started at all, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. + */ +export interface ControlledTerminalGridOptions { + /** Appended to as the grid works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; + /** + * The shell activity for one pane. + * + * A suite that wants a shell which never starts supplies one that throws + * before it provides: the pane then never becomes ready, exactly as a real + * spawn failure leaves it. + */ + shell?: (ordinal: number) => TerminalActivity; + close?: () => Operation; +} + +/** An outcome that is already settled, for a child that needed no waiting. */ +function settled(outcome: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return outcome; + })(); +} + +/** + * One controlled grid that presents nothing and records everything. + * + * A resource, like a real provider's: acquiring it is the grid coming into + * existence and releasing it is the grid going away, so a suite reads the + * record to prove that happened exactly once. It answers the whole contract — + * attach, update, display, shell, close — without a terminal, a multiplexer, or + * a process anywhere in it. + */ +export function controlledTerminalGrid( + request: TerminalGridRequest, + options: ControlledTerminalGridOptions = {}, + generation = 0, +): Operation { + return resource(function* (provide) { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.grids++; + let attached = false; + + // Registered before the grid 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:${generation}`); + log.live.grids--; + if (attached) { + attached = false; + log.live.attached--; + } + }); + + yield* provide({ + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + shell(ordinal) { + return resource(function* (provideOutcome) { + if (options.shell) { + // Whatever the suite supplies: it may refuse before providing, + // which is a shell that never started. + const outcome = yield* options.shell(ordinal); + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + yield* ensure(() => { + log.live.shells--; + }); + yield* provideOutcome(outcome); + return; + } + // The default shell starts and is done: a suite that says nothing + // about a pane wants a pane that works. + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + yield* ensure(() => { + log.live.shells--; + }); + yield* provideOutcome(settled({ exitCode: 0 })); + }); + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + }); + }); +} diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts new file mode 100644 index 000000000..9dbbe09aa --- /dev/null +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -0,0 +1,306 @@ +/** + * Tier TG — the terminal grid routing surface and the provider grid contract + * (architecture.md §Terminal grid presentation, spec §6.21). + * + * 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 core throws the + * answer away. The grid is what a provider supplies as a resource, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. + * + * Who may present a grid, and what presenting one authorizes, is core's, and is + * proved in `packages/core/tests/terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { resource, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; + +import { + controlledTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + terminalProviderLog, + TerminalProviderUnavailableError, +} from "../terminal.ts"; +import type { TerminalGridRequest, TerminalShellOutcome } from "../terminal.ts"; + +/** A two-by-one grid: the smallest request that still has two ordinals. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +describe("Tier TG — the routing surface", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* 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; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.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; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { 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([]); + }); +}); + +describe("Tier TG — the provider grid contract", () => { + it("TP3: an acquired grid presents nothing until it is attached", function* () { + const log = terminalProviderLog(); + const events = yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }); + return [...log.events]; + }); + + // A grid the reader can see before every pane is ready is the one thing + // atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP3: attach, update, display, shell and release record in order", function* () { + const log = terminalProviderLog(); + let outcome: TerminalShellOutcome | undefined; + yield* scoped(function* () { + const grid = yield* controlledTerminalGrid(request(), { log }); + yield* grid.update(0, "starting"); + yield* grid.display(0, "pane text"); + yield* grid.update(0, "running"); + yield* scoped(function* () { + // Acquiring the activity is the shell starting. + outcome = yield* yield* grid.shell(1); + }); + yield* grid.attach(); + yield* grid.update(0, "succeeded"); + yield* grid.closed(); + }); + + // The destroy is the resource's own release, recorded without anyone + // calling one. + expect(log.events).toEqual([ + "prepare:0:2x1", + "state:0:0:starting", + "state:0:0:running", + "shell:0:1", + "attach:0", + "state:0:0:succeeded", + "closed:0", + "destroy:0", + ]); + expect(log.shown.get(0)).toBe("pane text"); + expect(outcome).toEqual({ exitCode: 0 }); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + }); + + it("TP4: a shell that never starts is never acquired", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + yield* scoped(function* () { + const grid = yield* controlledTerminalGrid(request(), { + log, + shell: () => + resource>(function* () { + // Fails before it provides: nothing started, so nothing is owed an + // outcome and no pane could call this ready. + throw new Error("no child could be spawned"); + }), + }); + try { + yield* yield* grid.shell(1); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe("no child could be spawned"); + // An activity that never came up was never counted as held, and left no + // shell record behind. + expect(log.events.some((event) => event.startsWith("shell:"))).toBe(false); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + }); + + it("TP4: a preparation failure leaves no grid to release", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + yield* scoped(function* () { + try { + yield* controlledTerminalGrid(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the grid existed, so nothing is owed a + // release. + expect(log.events).toEqual([]); + }); + + it("TP4: release happens once, whatever ended the grid", function* () { + const log = terminalProviderLog(); + + // Settled normally. + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 0); + }); + // Cancelled while live. The child says when it is actually holding a grid, + // so the halt lands on a live one rather than on a task that never began. + yield* scoped(function* () { + const holding = withResolvers(); + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 1); + holding.resolve(); + yield* suspend(); + }); + }); + yield* holding.operation; + yield* task.halt(); + }); + // Failed after acquisition. + yield* scoped(function* () { + try { + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 2); + 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. + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.events.filter((event) => event === "destroy:1")).toEqual(["destroy:1"]); + expect(log.events.filter((event) => event === "destroy:2")).toEqual(["destroy:2"]); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + }); + + it("TP5: each acquisition is its own grid", function* () { + const log = terminalProviderLog(); + yield* scoped(function* () { + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 0); + }); + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 1); + }); + }); + + // Two expansions are two grids. A provider that handed the same grid back + // would have presented the second expansion's grid as the first's. + expect(log.events).toEqual(["prepare:0:2x1", "destroy:0", "prepare:1:2x1", "destroy:1"]); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e804f6b4c..a8f86f5b4 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9328,7 +9328,7 @@ 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 @@ -9339,35 +9339,45 @@ Opening a grid is atomic from the reader's perspective: lease. Another root native launch or terminal grid cannot hold it at the same time. 2. The provider validates its live prerequisites and prepares every terminal - endpoint in a hidden composite. It presents nothing yet. + endpoint in a hidden grid. 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. +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. The provider attaches the complete grid only after every pane is ready. + +The lifecycle passes that pane's work one concrete `PaneTerminal`, carrying one +operation and no identity: `PaneTerminal.use()` runs a terminal activity as the +pane's owner. An activity is a resource acquired only after its child has +spawned, so acquisition is the 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 is awaited before the pane is free. A +startup error fails before acquisition. The self-closing shell uses the same +path. Readiness appears in no prop, binding, public request, provider return, +process result, or durable record, and a root launch has no pane activity. + +The same private lifecycle state admits at most one activity in a pane and stops +admitting new work when the grid closes. Different pane terminals do not +contend. No pane-claim, readiness, controller, aggregate, callback or sealing API +crosses the lifecycle boundary; those are implementation details rather than +provider-neutral concepts. 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 +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. @@ -9375,7 +9385,7 @@ 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`, @@ -9384,11 +9394,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, 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. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -9421,8 +9443,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 @@ -9457,14 +9485,38 @@ ordinal, never from its title, scheduling order, or provider layout. 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, +provider, acquiring a provider grid, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then acquires a new live provider grid. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or @@ -11503,16 +11555,17 @@ test derives a core result from a provider identifier. | 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 | +| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no provider grid 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 | +| TG12 | Reader close | Close prevents a later launch, cancels every live pane scope, awaits each child, shell and provider finalizer, releases the exact provider grid, 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 | | 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 | +| TG16 | Partial replay | Exact layout acquires a fresh provider grid; 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 resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | ### 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..e40802108 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -730,28 +730,27 @@ 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 +concrete `PaneTerminal` per authored pane ordinal, passes it to that pane's +work, and owns the private state that admits work, observes readiness, and stops +admission at close. Core installs that same pane terminal in the paired pane's +scope. `Session.Launch` uses the launcher already in scope; it receives no pane +prop, token, identifier, or mode. A constructed lookalike cannot reach a live +pane, and the genuine terminal admits nothing after its grid closes. + +Different pane terminals do not contend, so native launches in different panes +can hold their terminals concurrently. `PaneTerminal.use()` keeps one pane +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. 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 pane terminal grants no permission to ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. @@ -762,7 +761,7 @@ 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 +770,30 @@ 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 `PaneTerminal.use()` as a terminal activity: a +resource whose acquisition happens only once the child has actually spawned. +Acquisition is the readiness, so nobody is handed an acknowledgement to call — +allocating a PID or observing output is not acquisition, and a preparation, +reservation or spawn error fails before it. The acquired value is the operation +that settles with the child's outcome, and the activity's cleanup is what sweeps +whatever the launch still holds. A root launch has no pane 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 lifecycle exports `PaneTerminal`, not its readiness, +busy-state, or closing machinery. There is no pane-claim or readiness interface, +no pane controller, and no aggregate object. Pane work receives the terminal; the +grid lifecycle alone waits for readiness and closes admission. Under the tmux provider the pane-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. @@ -1437,9 +1446,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.