From 6f088a2c76e28e4438bdca41a8646922d5490f3e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:01:37 -0400 Subject: [PATCH 01/22] =?UTF-8?q?=E2=9C=A8=20Add=20the=20terminal=20provid?= =?UTF-8?q?er=20boundary=20and=20pane=20authority=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replaceable seam a terminal grid executes through, before any of the execution that uses it. `packages/runtime/terminal.ts` is the contextual provider: `prepare()` builds the whole composite while it stays hidden, `attach()` shows it once every pane is ready, and `destroy()` gives the root terminal back. The request is provider-neutral — columns, rows, and the authored panes with their derived positions — and names no terminal, socket, process or window. Middleware may observe, narrow, refuse, wrap or delegate; presentation never decides an outcome, so `update()` receives states core has already settled on. `packages/core/src/terminal/authority.ts` mints one-use pane claims for one request's ordinals. A claim admits one interactive operation at a time on its pane and holds that pane's readiness latch. Two claims do not contend, which is what lets panes stay interactive together. `packages/core/src/terminal/pane.ts` is the seam interactive work inside a pane reaches for, so it runs as that pane's owner instead of competing for the root foreground lease. Absence means "not in a pane". Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows. --- packages/core/src/terminal/authority.ts | 184 ++++++++++++ packages/core/src/terminal/pane.ts | 66 ++++ packages/runtime/mod.ts | 17 ++ packages/runtime/terminal.ts | 282 ++++++++++++++++++ .../runtime/tests/terminal-provider.test.ts | 271 +++++++++++++++++ 5 files changed, 820 insertions(+) create mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/pane.ts create mode 100644 packages/runtime/terminal.ts create mode 100644 packages/runtime/tests/terminal-provider.test.ts diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts new file mode 100644 index 000000000..df4feffab --- /dev/null +++ b/packages/core/src/terminal/authority.ts @@ -0,0 +1,184 @@ +/** + * Who is allowed to own a terminal, and what "ready" means (architecture.md + * §Terminal authority). + * + * The provider draws a grid. This decides everything about it that matters: + * which request is live, which provider installation it belongs to, which pane + * ordinals exist, whether an interactive operation may start on one, and when a + * pane has actually started. None of that is reachable by name. There is no + * context holding an authority, no member of a request that carries one, and no + * handler return value that produces one — an authority reachable by name would + * be an authority every same-name context and every loaded copy could reach. + * + * A claim is the unforgeable carrier. It is minted here for one ordinal of one + * request under one installation generation, and a claim from another grid, + * another ordinal, an earlier generation, or a finished expansion authorizes + * nothing at all. Holding one grants terminal ownership and nothing else: it + * says nothing about which Agent session a pane may own, because that is the + * session coordinator's to answer and stays independently authoritative. + */ + +import { all, ensure, withResolvers } from "effection"; +import type { Operation } from "effection"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalAuthorityError extends Error { + override name = "TerminalAuthorityError"; +} + +/** + * One pane's terminal ownership. + * + * `admit` is the whole of it: an interactive operation runs inside one, and a + * second one on the same pane is refused while the first is live. Two claims for + * two ordinals do not contend at all, which is what lets panes be interactive at + * the same time. + */ +export interface TerminalPaneClaim { + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * Refuses while another is live on this pane, and refuses once the grid that + * minted the claim has finished — a claim kept past its expansion is a claim + * to a terminal nobody owns any more. + */ + admit(body: () => Operation): Operation; + /** + * Acknowledge the runtime's successful child-spawn event for this pane. + * + * The one thing that makes a pane ready. Called from the spawn event and + * before anything waits for the child to exit, so a child that starts and + * immediately exits is both ready and settled. Acknowledging twice has no + * effect, and a preparation, reservation or spawn that failed never + * acknowledges at all. + */ + ready(): void; +} + +/** What one pane's readiness is waiting on, from the grid's side. */ +export interface PaneReadiness { + /** Settles when the pane's first interactive child reports its spawn event. */ + reached(): Operation; + /** Whether the latch has been acknowledged. */ + readonly acknowledged: boolean; +} + +/** The claims one grid expansion holds, and what they are waiting on. */ +export interface TerminalGridClaims { + readonly claims: readonly TerminalPaneClaim[]; + readonly readiness: readonly PaneReadiness[]; + /** + * Stop admitting anything on every pane. + * + * Close prevents a later launch before it cancels the live ones, so a pane + * that was about to start one is refused rather than raced. + */ + seal(): void; +} + +/** + * Mint the claims for one grid expansion. + * + * The request is validated against the ordinals it declares before a single + * claim exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { + validate(request); + + let sealed = false; + const claims: TerminalPaneClaim[] = []; + const readiness: PaneReadiness[] = []; + + for (const pane of request.panes) { + const latch = withResolvers(); + let acknowledged = false; + let live = false; + + readiness.push({ + reached: () => latch.operation, + get acknowledged() { + return acknowledged; + }, + }); + + claims.push({ + ordinal: pane.ordinal, + *admit(body: () => Operation): Operation { + if (sealed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(); + } finally { + live = false; + } + }, + ready() { + // Idempotent by construction: readiness is a fact about the pane, and a + // provider that reports the same spawn twice has not started two panes. + if (acknowledged) { + return; + } + acknowledged = true; + latch.resolve(); + }, + }); + } + + return { + claims, + readiness, + seal() { + sealed = true; + }, + }; +} + +function validate(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reaches its latch, so the caller races this against pane failure + * rather than asking the barrier to know about failure. + */ +export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { + return allOf(readiness.map((pane) => pane.reached())); +} + +function* allOf(waits: readonly Operation[]): Operation { + yield* all(waits); +} + +/** Seal the grid as soon as the enclosing scope begins to unwind. */ +export function sealOnTeardown(claims: TerminalGridClaims): Operation { + return ensure(() => { + claims.seal(); + }); +} diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts new file mode 100644 index 000000000..f308de81b --- /dev/null +++ b/packages/core/src/terminal/pane.ts @@ -0,0 +1,66 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the authority. The claim it hands + * out was minted for one ordinal of one grid and cannot be forged, copied + * usefully, or kept past the expansion that owns it — so a replaced context + * yields a pane terminal nobody owns rather than a way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalPaneClaim } from "./authority.ts"; + +/** The pane the current work is running in. */ +export interface PaneTerminal { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * `body` receives the pane's readiness latch and must call it from the + * runtime's successful child-spawn event, before it waits for the child to + * exit. A body that never spawns never reports, and the grid it belongs to + * never attaches — which is what stops a pane that failed to start being + * presented as one that is running. + * + * A second interactive operation while one is live on this pane is refused. + * Two panes do not contend with each other at all. + */ + interactive(body: (spawned: () => void) => Operation): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * Set rather than composed: a pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(claim: TerminalPaneClaim): Operation { + yield* PaneTerminalContext.set({ + ordinal: claim.ordinal, + interactive(body) { + return claim.admit(() => body(() => claim.ready())); + }, + }); +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c41f34206..e9a990c9a 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -146,6 +146,23 @@ export type { NativeLaunchOutcome, NativeLaunchRequest, } from "./launcher.ts"; +export { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "./terminal.ts"; +export type { + ControlledTerminalProviderOptions, + TerminalComposite, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalProviderHandler, + TerminalProviderLog, + 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..1fac60e3d --- /dev/null +++ b/packages/runtime/terminal.ts @@ -0,0 +1,282 @@ +/** + * The terminal provider — how a host presents one grid of interactive panes. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native composite UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * + * 1. `prepare()` builds the whole composite while it is still hidden — every + * pane endpoint and its supervision — and presents nothing. A host that + * cannot open a grid refuses here, before any pane has started work. + * 2. Core starts the authored panes concurrently and waits for every one of + * them to be ready. + * 3. `attach()` shows the composite, once, after that barrier. A failure before + * it discards the hidden composite instead of leaving a partial grid on the + * reader's screen. + * 4. `destroy()` takes it down again and gives the root terminal back. + * + * There is no host default. `xmd run` installs the production provider; a test + * or embedding host installs a controlled one that needs no terminal. Until one + * is installed every operation refuses, which is what keeps writing, inspecting + * and validating a document free of all of this. + * + * **Presentation never decides an outcome.** `update()` receives the pane states + * core has already settled on, so a provider draws them and answers for none of + * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +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. + */ +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 prepared, still-hidden grid. + * + * Everything here belongs to the one `prepare()` that produced it. A composite + * is never reused across expansions, and a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. + */ +export interface TerminalComposite { + /** + * Show the composite. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * preparation, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Start the host's default interactive shell in one pane and report how it + * ended. + * + * Which shell that is comes from live host policy, never from the document. + * The bytes it exchanges with the reader belong to the pane: nothing captures + * or journals them. + * + * `spawned` is the pane's readiness latch, and calling it is the only thing + * that makes this pane ready. Call it from the runtime's successful + * child-spawn event and before waiting for the child to exit — so a shell + * that starts and exits at once is both ready and settled, while a shell that + * never started leaves the latch alone and the grid never attaches. + */ + shell(ordinal: number, spawned: () => void): Operation; + /** + * Settle when the reader closes or leaves the composite. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; + /** + * Take the composite down and give the root terminal back. + * + * Called exactly once for every composite `prepare()` returned, including one + * discarded before it ever attached. + */ + destroy(): Operation; +} + +export interface TerminalProviderHandler { + /** Build the whole hidden composite for `request`, presenting nothing. */ + prepare(request: TerminalGridRequest): Operation; +} + +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); + } +} + +/** + * The stable contextual boundary a grid request travels. + * + * Middleware composed here may observe, narrow, refuse, wrap or delegate a + * request — everything composition needs. What it cannot do is authorize one: + * the terminal authority that mints pane claims and takes terminal ownership is + * delivered directly to the installed provider and reachable from nowhere else, + * so a handler that answers without delegating has presented nothing. + */ +export const TerminalProvider: Api = createApi( + "runtime.terminalProvider", + { + // deno-lint-ignore require-yield + *prepare(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, + }, +); + +/** Build the hidden composite for one grid expansion. */ +export function prepareTerminalGrid(request: TerminalGridRequest): Operation { + return TerminalProvider.operations.prepare(request); +} + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` + * decides what a self-closing pane's shell did and how long it took, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. + */ +export interface ControlledTerminalProviderOptions { + /** Appended to as the provider works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * What a pane's shell did. + * + * It receives the readiness latch, so a suite decides whether this shell + * reports a spawn at all — which is how "never started" is told apart from + * "started and exited immediately". + */ + shell?: (ordinal: number, spawned: () => void) => Operation; + close?: () => Operation; +} + +/** + * Install a provider that presents nothing and records everything. + * + * It answers the whole contract — prepare, attach, update, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function* installControlledTerminalProvider( + options: ControlledTerminalProviderOptions = {}, +): Operation { + const log = options.log ?? { events: [] }; + let prepared = 0; + + yield* TerminalProvider.around( + { + *prepare([request]): Operation { + if (options.onPrepare) { + yield* options.onPrepare(request); + } + const generation = prepared++; + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn + // would hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken + // down more times than it was built, which is exactly the ordering + // claim a suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + }, + }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts new file mode 100644 index 000000000..59ee75e89 --- /dev/null +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -0,0 +1,271 @@ +/** + * Tier TG — the terminal provider boundary (architecture.md §Terminal + * authority, spec §6.21). + * + * What a host installs to present a grid, and what composing middleware around + * it may and may not do. Nothing here opens a terminal, looks for a + * multiplexer, or starts a process: the whole point of the boundary is that the + * language does not depend on any of that, so a suite that needed one would be + * testing the wrong thing. + * + * The controlled provider records what it was asked to do, in order. Ordering + * claims are read off that record rather than inferred from timing, because a + * grid that attached too early and a grid that attached on time can take the + * same wall clock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "../terminal.ts"; +import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } 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, + }; +} + +function log(): TerminalProviderLog { + return { events: [] }; +} + +describe("Tier TG — the provider boundary", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: an installed provider prepares without presenting anything", function* () { + const record = log(); + const events = yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* prepareTerminalGrid(request()); + return [...record.events]; + }); + + // Preparation happened; nothing was shown. A composite the reader can see + // before every pane is ready is the one thing atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { + const record = log(); + const spawns: number[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.update(0, "starting"); + yield* composite.update(0, "running"); + yield* composite.shell(1, () => spawns.push(1)); + yield* composite.attach(); + yield* composite.update(0, "succeeded"); + yield* composite.closed(); + yield* composite.destroy(); + }); + + expect(record.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", + ]); + // The default shell starts, and says so through the latch it was handed: + // readiness is reported by the shell rather than assumed by the grid. + expect(spawns).toEqual([1]); + }); + + it("TP5: a shell that never starts never reports a spawn", function* () { + const spawns: number[] = []; + const outcome = yield* scoped(function* () { + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *shell(_ordinal, _spawned) { + // No spawn event: nothing started, so nothing is acknowledged. + return { exitCode: 127 }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + return yield* composite.shell(1, () => spawns.push(1)); + }); + + expect(outcome).toEqual({ exitCode: 127 }); + expect(spawns).toEqual([]); + }); + + it("TP3: middleware observes a delegated request without changing it", function* () { + const record = log(); + const seen: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the provider still saw the same grid. + expect(record.events).toEqual(["prepare:0:3x2"]); + }); + + it("TP3: middleware refuses a request, and no composite is ever built", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + // deno-lint-ignore require-yield + *prepare(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + // Refusing means refusing: the provider below was never reached, so there + // is no hidden composite left needing teardown. + expect(record.events).toEqual([]); + }); + + it("TP3: middleware narrows a request before the provider sees it", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* prepareTerminalGrid(request()); + }); + + expect(record.events).toEqual(["prepare:0:1x2"]); + }); + + it("TP4: middleware wraps the composite it delegated for", function* () { + const record = log(); + const wrapped: string[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + const composite = yield* next(asked); + return { + ...composite, + *attach() { + wrapped.push("before"); + yield* composite.attach(); + wrapped.push("after"); + }, + }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.attach(); + yield* composite.destroy(); + }); + + expect(wrapped).toEqual(["before", "after"]); + expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); + }); + + it("TP5: a preparation failure leaves nothing to tear down", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ + log: record, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the composite existed, so the record shows + // no composite was built and none is owed a destroy. + expect(record.events).toEqual([]); + }); + + it("TP5: a composite refuses to be destroyed twice", function* () { + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider(); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.destroy(); + try { + yield* composite.destroy(); + } catch (error) { + refusal = error; + } + }); + + // Teardown ordering is only readable if a double destroy is loud. A silent + // second destroy would let a suite prove an ordering that never held. + expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + }); + + it("TP6: each preparation is its own composite", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const first = yield* prepareTerminalGrid(request()); + const second = yield* prepareTerminalGrid(request()); + yield* first.destroy(); + yield* second.destroy(); + }); + + // Two expansions are two grids. A provider that handed the same composite + // back would have presented the second expansion's grid as the first's. + expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + }); +}); From 259520eba3e34df0e991bf8a70119ab4933bd659 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:07:19 -0400 Subject: [PATCH 02/22] =?UTF-8?q?=E2=9C=A8=20Run=20a=20terminal=20grid's?= =?UTF-8?q?=20panes=20concurrently=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's one foreground-terminal lease, flushes root output, prepares the composite while it stays hidden, starts every pane concurrently, and attaches only once every pane has reported a spawn through its claim. Ordering is the contract. The lease and the composite are both scope-owned, so success, failure and cancellation all release the terminal and destroy exactly the composite that was prepared — there is no path that skips teardown. A pane that settles without ever reporting a spawn fails startup rather than being presented as a running pane. Before the barrier a pane failure fails the whole grid closed; after it, the failure is that pane's status and its siblings keep running. Close cancels a live pane as `closed`, which is not a failed pane, and the grid fails with the first failed pane in authored order. `display()` and an `onUpdate` hook complete the provider surface: a pane's rendered text goes to that pane, and a suite reacts to a state the grid decided rather than waiting and hoping. Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and `packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness barrier row was verified by removing the barrier: it fails without it. --- packages/core/src/terminal/grid.ts | 207 +++++++ packages/core/tests/terminal-grid.test.ts | 507 ++++++++++++++++++ packages/runtime/terminal.ts | 34 +- .../runtime/tests/terminal-provider.test.ts | 2 +- 4 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal/grid.ts create mode 100644 packages/core/tests/terminal-grid.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts new file mode 100644 index 000000000..9f712da1d --- /dev/null +++ b/packages/core/src/terminal/grid.ts @@ -0,0 +1,207 @@ +/** + * One terminal grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The composite is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier discards the hidden composite + * instead of leaving half a grid on the screen. + * + * Ordering is the contract, not an implementation detail: + * + * ``` + * lease → flush → prepare → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing here decides what a pane *is* — the layout arrived already derived, + * and the work each pane does is supplied by the caller. What this owns is + * whose terminal it is, when a pane counts as started, what happens when one + * fails, and the order in which it all comes apart. + */ + +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; + +import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; +import type { TerminalPaneClaim } from "./authority.ts"; +import type { TerminalGridLayout } from "../terminal-grid.ts"; + +/** How one pane ended. */ +export type PaneOutcome = + | { readonly kind: "succeeded" } + | { readonly kind: "failed"; readonly error: Error } + /** Live when the reader closed the grid. Cancellation, not failure. */ + | { readonly kind: "closed" }; + +/** + * What one pane does once its claim exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both run as the pane's admitted owner, and both are expected + * to report a spawn through the claim before anything can attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; +} + +/** Everything the grid settled, in authored pane order. */ +export interface GridResult { + readonly outcomes: readonly PaneOutcome[]; + /** Why the grid failed, which is the first failed pane in authored order. */ + readonly failure?: Error; +} + +/** + * What a pane that never reported a spawn says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +class PaneStartupError extends Error { + override name = "PaneStartupError"; + readonly ordinal: number; + constructor(ordinal: number, message: string) { + super(message); + this.ordinal = ordinal; + } +} + +/** + * Run one grid to completion and report what its panes settled to. + * + * The foreground lease and the composite are both scope-owned, so every path + * out of here — success, failure, and cancellation alike — releases the + * terminal and destroys exactly the composite that was prepared. That is why + * teardown is not written as a step: there is no path that can skip it. + */ +export function runTerminalGrid( + layout: TerminalGridLayout, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + const request = toRequest(layout); + + // The one foreground-terminal lease. A root and a grid + // contend for exactly this, so neither can begin while the other holds it, + // and a host with no terminal refuses here — before any pane has done work. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + const composite = yield* prepareTerminalGrid(request); + // Registered before a single pane starts: a composite that was prepared is + // owed a destroy even if the next line is what fails. + yield* ensure(() => composite.destroy()); + + const grid = createTerminalGridClaims(request); + // Nothing new is admitted once teardown begins, so a pane that was about to + // start an interactive child is refused rather than racing the close. + yield* ensure(() => { + grid.seal(); + }); + + const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + let attached = false; + + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { + const claim = grid.claims[index]!; + const readiness = grid.readiness[index]!; + yield* composite.update(pane.ordinal, "starting"); + panes.push( + yield* spawn(function* () { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: that is a startup failure even + // though the work itself raised nothing. + throw new PaneStartupError( + pane.ordinal, + paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + ); + } + outcomes[index] = { kind: "succeeded" }; + yield* composite.update(pane.ordinal, "succeeded"); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + outcomes[index] = { kind: "failed", error: failure }; + // 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 and its siblings + // keep running. + if (!attached) { + startupFailed.reject(failure); + return; + } + yield* composite.update(pane.ordinal, "failed"); + } + }), + ); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for a latch nothing will acknowledge. + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + + for (const pane of work) { + yield* composite.update(pane.ordinal, "running"); + } + yield* composite.attach(); + attached = true; + + // The composite stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* composite.closed(); + + // 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. + grid.seal(); + for (const [index, task] of panes.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(work[index]!.ordinal, "closed"); + outcomes[index] = { kind: "closed" }; + } + yield* task.halt(); + } + + const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); + const failed = settled.find((outcome) => outcome.kind === "failed"); + return { + outcomes: settled, + ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + }; + }); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return { + columns: layout.columns, + rows: layout.rows, + panes: layout.cells.map((cell) => ({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + })), + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts new file mode 100644 index 000000000..19d9e4d73 --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,507 @@ +/** + * Tier TG — running a terminal grid through a replaceable provider + * (spec §6.21, architecture.md §Atomic presentation and settlement). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because something called the latch it was + * handed, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { + installControlledLauncher, + installControlledTerminalProvider, +} from "@executablemd/runtime"; +import type { TerminalProviderLog } from "@executablemd/runtime"; + +import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; +import { runTerminalGrid } from "../src/terminal/grid.ts"; +import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; +import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { TerminalGridLayout } from "../src/terminal-grid.ts"; + +function log(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + +/** A layout of `count` panes across `columns`, titled by ordinal. */ +function layoutOf(columns: number, count: number): TerminalGridLayout { + return terminalGridLayout( + columns, + Array.from({ length: count }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ); +} + +/** A pane that starts, does what `body` says, and settles. */ +function pane(ordinal: number, body?: () => Operation): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + if (body) { + yield* body(); + } + }); + }, + }; +} + +/** Everything a grid run needs installed, with the reader's close under control. */ +function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { + // A grid takes the same one foreground lease a root takes, + // so a host that offers a grid still has to offer that lease. + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ log: record, close }); +} + +/** A pane that records when it started, so ordering is read rather than timed. */ +function readyPane(ordinal: number, timeline: string[]): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + timeline.push(`ready:${ordinal}`); + claim.ready(); + yield* suspend(); + }); + }, + }; +} + +/** Close as soon as the reader is asked, which is the ordinary journey. */ +function immediateClose(): () => Operation { + // deno-lint-ignore require-yield + return function* () {}; +} + +describe("Tier TG — pane claims and readiness", () => { + it("TG8: a claim admits one interactive operation at a time", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; + + yield* scoped(function* () { + yield* first.admit(function* () { + // A second operation on the same pane is refused while this one is live. + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + // A different pane does not contend at all, which is the whole reason a + // grid exists. + yield* second.admit(function* () { + concurrent = true; + }); + }); + }); + + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); + }); + + it("TG8: a pane admits again once its first operation has settled", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let second = false; + + yield* scoped(function* () { + yield* claim.admit(function* () {}); + yield* claim.admit(function* () { + second = true; + }); + }); + + // Sequential work in one pane is ordinary composition, not contention. + expect(second).toBe(true); + }); + + it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + grid.seal(); + let refusal: unknown; + + yield* scoped(function* () { + try { + yield* claim.admit(function* () {}); + } catch (error) { + refusal = error; + } + }); + + // A claim kept past its grid is a claim to a terminal nobody owns. + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + }); + + it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; + + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TG8: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); + }); +}); + +describe("Tier TG — atomic startup", () => { + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const record = log(); + // One ordered record both the panes and the provider 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 would prove nothing — a pane says when it actually started. + const timeline: string[] = []; + const slow = withResolvers(); + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: immediateClose(), + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + }); + return yield* runTerminalGrid(layoutOf(2, 3), [ + readyPane(0, timeline), + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + // Plenty of work before anything starts, and none of it makes the + // grid attachable. The delay is long enough that a grid which + // skipped the barrier would demonstrably attach first. + yield* sleep(25); + timeline.push("ready:1"); + claim.ready(); + yield* slow.operation; + }); + }, + }, + readyPane(2, timeline), + ]); + }); + + expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); + expect(result.failure).toBeUndefined(); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const record = log(); + let failure: unknown; + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0), + { + ordinal: 1, + // Runs, settles, and never reports a spawn. + *run() {}, + }, + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "finished without starting anything interactive", + ); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(record.events).not.toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0, function* () { + started.push(0); + }), + pane(1, function* () { + started.push(1); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toBe( + "no pane endpoint could be created", + ); + expect(started).toEqual([]); + }); + + it("TG9: a grid refuses before preparation when no provider is installed", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + try { + yield* runTerminalGrid(layoutOf(1, 1), [ + pane(0, function* () { + started.push(0); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "no terminal provider is installed", + ); + expect(started).toEqual([]); + }); +}); + +describe("Tier TG — settlement and close", () => { + it("TG10: a pane fails after attach while its siblings stay live", function* () { + const record = log(); + // The reader leaves once the grid has displayed the failure, so the sibling + // is provably still live when that happens rather than probably still live. + const failed = withResolvers(); + let siblingLiveAtFailure = false; + let siblingLive = false; + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: () => failed.operation, + onUpdate(ordinal, state) { + if (ordinal === 0 && state === "failed") { + siblingLiveAtFailure = siblingLive; + failed.resolve(); + } + }, + }); + return yield* runTerminalGrid(layoutOf(2, 2), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* sleep(1); + throw new Error("pane 0 stopped"); + }); + }, + }, + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + siblingLive = true; + try { + yield* suspend(); + } finally { + siblingLive = false; + } + }); + }, + }, + ]); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("state:0:0:failed"); + // The sibling was still running when its neighbour failed: an ordinary pane + // failure after attach is contained as that pane's status. + expect(siblingLiveAtFailure).toBe(true); + expect(result.outcomes[0]?.kind).toBe("failed"); + expect(result.outcomes[1]?.kind).toBe("closed"); + // The grid fails with the first failed pane in authored order. + expect(result.failure?.message).toBe("pane 0 stopped"); + }); + + it("TG12: close cancels a live pane as closed rather than failed", function* () { + const record = log(); + + const result = yield* scoped(function* (): Operation { + yield* useGridHost(record, immediateClose()); + return yield* runTerminalGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + // Still live when the reader leaves. + yield* suspend(); + }); + }, + }, + ]); + }); + + // Teardown cancellation is not a pane failure, and the grid succeeds. + expect(result.outcomes[0]?.kind).toBe("closed"); + expect(result.failure).toBeUndefined(); + expect(record.events).toContain("state:0:0:closed"); + }); + + it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + }); + + const closed = record.events.indexOf("closed:0"); + const destroyed = record.events.indexOf("destroy:0"); + expect(closed).toBeGreaterThan(-1); + expect(destroyed).toBeGreaterThan(closed); + expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); + }); + + it("TG13: parent cancellation tears the grid down completely", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, () => suspend()); + // The grid never closes on its own; the enclosing scope ending is what + // takes it down, and that has to be a complete teardown. + yield* scoped(function* () { + yield* spawnGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* suspend(); + }); + }, + }, + ]); + yield* sleep(2); + }); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — the pane seam", () => { + it("TG6: work inside a pane runs as that pane's owner", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let sawOrdinal: number | undefined; + let acknowledged = false; + + yield* scoped(function* () { + yield* usePaneTerminal(claim); + const seam = yield* paneTerminal(); + sawOrdinal = seam?.ordinal; + yield* seam!.interactive(function* (spawned) { + spawned(); + acknowledged = grid.readiness[0]!.acknowledged; + }); + }); + + expect(sawOrdinal).toBe(0); + // The seam is how anything interactive reports its spawn, so readiness + // travels with the work rather than being asserted around it. + expect(acknowledged).toBe(true); + }); + + it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { + const seam = yield* scoped(function* () { + return yield* paneTerminal(); + }); + expect(seam).toBeUndefined(); + }); +}); + +/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ +function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { + yield* spawn(function* () { + yield* runTerminalGrid(layout, work); + }); +} diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 1fac60e3d..cc34203c0 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -102,6 +102,17 @@ export interface TerminalComposite { * 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; /** * Start the host's default interactive shell in one pane and report how it * ended. @@ -183,6 +194,13 @@ export function prepareTerminalGrid(request: TerminalGridRequest): Operation; } /** @@ -200,6 +218,13 @@ export interface ControlledTerminalProviderOptions { 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 a while and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; /** * What a pane's shell did. * @@ -221,7 +246,8 @@ export interface ControlledTerminalProviderOptions { export function* installControlledTerminalProvider( options: ControlledTerminalProviderOptions = {}, ): Operation { - const log = options.log ?? { events: [] }; + const log = options.log ?? { events: [], shown: new Map() }; + const shown = log.shown; let prepared = 0; yield* TerminalProvider.around( @@ -243,6 +269,12 @@ export function* installControlledTerminalProvider( // 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) { + const pane = shown.get(ordinal) ?? ""; + shown.set(ordinal, pane + text); }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 59ee75e89..9e01204a7 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -42,7 +42,7 @@ function request(overrides: Partial = {}): TerminalGridRequ } function log(): TerminalProviderLog { - return { events: [] }; + return { events: [], shown: new Map() }; } describe("Tier TG — the provider boundary", () => { From 9bfbf18e7d75a479050c85ce56aee1129e529cdd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:39:27 -0400 Subject: [PATCH 03/22] =?UTF-8?q?=E2=9C=A8=20Run=20a=20document's=20termin?= =?UTF-8?q?al=20grid=20panes=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` now executes. Each authored pane becomes a concurrent child of the grid: a self-closing pane runs the host's default shell through its claim, and a paired pane expands its own content in a scope of its own. A pane inherits the bindings, providers, configuration and working directory visible where the grid was written, and keeps everything it creates afterwards. Its `` has no loop to exit, its `` has no enclosing value body to claim, and its checked failures settle the pane rather than reaching the root or a sibling. A pane's rendered text is displayed in that pane; the grid itself renders `""`, so the root output holds what surrounds the grid and no pane display at all. #729's five execution-dependent rows move here, where they assert the layout against the request the provider actually receives rather than reading it off a refusal's cause — the structural suite keeps the grammar, placement and pure-layout rows it owns. Moving them was authorized rather than assumed. Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in `packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core (350) and runtime (14) suites pass. --- packages/core/src/expand.ts | 155 ++++++-- .../tests/terminal-grid-structure.test.ts | 125 ------- packages/core/tests/terminal-grid.test.ts | 344 +++++++++++++++++- 3 files changed, 468 insertions(+), 156 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 933560f28..cdf65fde8 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,6 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; +import { runTerminalGrid } from "./terminal/grid.ts"; +import type { PaneWork } from "./terminal/grid.ts"; +import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, asExpressionViolation, @@ -143,7 +146,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 +1188,15 @@ 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, + counter, + path: elementPath, + checkedFailures, + authority, + }); break; } @@ -2106,7 +2117,22 @@ 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 counter: BlockCounter; + 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 +2167,105 @@ 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 work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site, segment), ); + + try { + const result = yield* runTerminalGrid(layout, work); + if (result.failure !== undefined) { + owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + } + } catch (error) { + owner.push( + yield* raise( + terminalGridError(segment, error instanceof Error ? error.message : String(error)), + ), + ); + } +} + +/** + * What one authored pane does once the grid has minted its claim. + * + * A self-closing pane runs the host's default shell through its claim. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork( + pane: TerminalPane, + title: string, + site: GridSite, + grid: ComponentElement, +): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(claim, composite) { + const outcome = yield* claim.admit(() => + composite.shell(pane.ordinal, () => claim.ready()), + ); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + }, + }; + } + + return { + ordinal: pane.ordinal, + *run(claim, composite) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(claim); + const 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, + site.counter, + 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* composite.display(pane.ordinal, text); + } + }); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2172,15 +2280,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/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 index 19d9e4d73..b360fa3fb 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -16,13 +16,23 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; -import type { Operation } from "effection"; +import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } 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 { installControlledLauncher, installControlledTerminalProvider, } from "@executablemd/runtime"; -import type { TerminalProviderLog } from "@executablemd/runtime"; +import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; + +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { Json } from "../src/types.ts"; import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; import { runTerminalGrid } from "../src/terminal/grid.ts"; @@ -505,3 +515,331 @@ function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Oper yield* runTerminalGrid(layout, work); }); } + +/** 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; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; +} + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** + * The controlled interactive child, and a tripwire. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, this is what a suite writes to be that + * something — and it reaches the pane through the same seam a real launch will. + */ +function useGridComponents(ran: string[]): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + ]); +} + +/** + * Run one document against a controlled grid host. + * + * `provider: false` installs no terminal provider, which is how "a host that + * cannot open a grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const record = log(); + const ran: string[] = []; + yield* useGridComponents(ran); + 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; + if (options.provider !== false) { + yield* installControlledTerminalProvider({ + log: record, + close: () => settled.operation, + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + yield* sleep(0); + }, + onUpdate(_ordinal, state) { + if (state === "succeeded" || state === "failed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, output, requests, shown: record.shown, ran }; + }); +} + +/** 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; +} + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each was written in travels with it. + 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("TG1: both pane forms run, and whitespace between panes is nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + "", + 'Instructions.', + "", + '', + "", + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]).toEqual({ + 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" }, + ], + }); + }); + + it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // 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 reach a loop outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + ); + + // Refused where it was written. Had the reached the loop around the + // grid it would have exited it quietly and the document would have + // succeeded; instead the pane failed with the stray- rule, which is + // what fails the grid and then the document. + expect(failureOf(run)).toContain(" must be written inside a "); + expect(failureOf(run)).toContain("cannot break the loop that invoked it"); + expect(run.ran).toEqual(["iteration"]); + }); + + 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); + }); +}); From 75bdb27d0917eb36082de5e084e48ef43b8cfe91 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:51:58 -0400 Subject: [PATCH 04/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Drop=20an=20unused?= =?UTF-8?q?=20parameter=20from=20the=20grid's=20pane=20work=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paneWork()` never read the grid element it was handed. Its caller has it, and a pane's own diagnostics are positioned at the pane. --- packages/core/src/expand.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index cdf65fde8..6e0f7f53c 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2170,11 +2170,10 @@ function* expandTerminalGrid( // 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 work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site, segment), - ); - try { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); const result = yield* runTerminalGrid(layout, work); if (result.failure !== undefined) { owner.push(yield* raise(terminalGridError(segment, result.failure.message))); @@ -2199,12 +2198,7 @@ function* expandTerminalGrid( * 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, - grid: ComponentElement, -): PaneWork { +function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, From 08c275f705f605d1bf49191f3c07dc780d341e75 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:19:22 -0400 Subject: [PATCH 05/22] =?UTF-8?q?=E2=9C=A8=20Give=20terminal=20grids=20an?= =?UTF-8?q?=20authority=20boundary=20and=20durable=20pane=20children=20(#7?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the authority boundary on the `AgentProviders` handshake, and puts each pane on its own durable child coroutine. **The boundary.** `TerminalGrids` is routing and only routing: `open()` answers `unknown` and core throws the answer away, so middleware may observe, narrow, refuse, wrap or delegate but can never authorize. The capability that takes the leases, mints pane claims and settles a grid is a non-contextual authority delivered straight to the registered provider through a one-use install handshake. Core mints one identity-bearing request per expansion; presenting a copy, a rebuilt lookalike, a changed request, an already-presented one, or one from a superseded installation generation authorizes nothing, and a handler that answers without presenting settles nothing. **Durable children.** Each pane is a durable child of the grid, allocated in authored order, so a pane's identity follows its ordinal rather than the order the runtime scheduled it in. The layout is recorded in the parent coroutine before the lease and before any provider is contacted. **Ordering.** A pane that settles before attach keeps the status it settled to instead of being overwritten with `running`, and simultaneous startup failures are selected by authored ordinal rather than by whichever rejected first. Each pane also expands under a counter of its own, so two concurrent panes cannot take block identities that depend on which ran first. `durableSpawn` could not be used: the task it returns is spawned inside the ephemeral effect's own scope, which closes as the effect resolves, so awaiting it throws `halted`. It has no call sites or tests upstream. `durableAll` is the exercised primitive and is what the panes and the grid child use. Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in `packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15. --- packages/core/mod.ts | 30 + packages/core/src/expand.ts | 37 +- packages/core/src/terminal/authority.ts | 138 +- packages/core/src/terminal/grid.ts | 397 +++-- packages/core/src/terminal/journal.ts | 140 ++ packages/core/src/terminal/profile.ts | 60 + packages/core/src/terminal/provider-api.ts | 271 +++ packages/core/tests/terminal-grid.test.ts | 1452 ++++++++++------- packages/runtime/mod.ts | 11 +- packages/runtime/terminal.ts | 245 ++- .../runtime/tests/terminal-provider.test.ts | 290 ++-- 11 files changed, 2105 insertions(+), 966 deletions(-) create mode 100644 packages/core/src/terminal/journal.ts create mode 100644 packages/core/src/terminal/profile.ts create mode 100644 packages/core/src/terminal/provider-api.ts diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..568ae1e19 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,36 @@ 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"; +export { + createTerminalAuthority, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/terminal/authority.ts"; +export type { + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalPaneClaim, +} from "./src/terminal/authority.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 6e0f7f53c..ae0899dde 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,8 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; -import { runTerminalGrid } 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, @@ -1192,7 +1193,6 @@ function* expandListSegments( parentMeta, parentProps, hideSet, - counter, path: elementPath, checkedFailures, authority, @@ -2122,7 +2122,6 @@ interface GridSite { readonly parentMeta: Record; readonly parentProps: Record; readonly hideSet: Set; - readonly counter: BlockCounter; readonly path: string; readonly checkedFailures: CheckedFailures | undefined; readonly authority: ExpansionAuthority | undefined; @@ -2170,13 +2169,28 @@ function* expandTerminalGrid( // 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 { - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site), - ); - const result = yield* runTerminalGrid(layout, work); - if (result.failure !== undefined) { - owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + // 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* () { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openTerminalGrid(layout, work); + }); + + const failed = retained.panes.find((pane) => pane.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(terminalGridError(segment, failed.reason))); } } catch (error) { owner.push( @@ -2238,7 +2252,10 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { site.parentMeta, site.parentProps, site.hideSet, - site.counter, + // 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, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index df4feffab..64e11fce3 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -4,11 +4,12 @@ * * The provider draws a grid. This decides everything about it that matters: * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, and when a - * pane has actually started. None of that is reachable by name. There is no - * context holding an authority, no member of a request that carries one, and no - * handler return value that produces one — an authority reachable by name would - * be an authority every same-name context and every loaded copy could reach. + * ordinals exist, whether an interactive operation may start on one, when a + * pane has actually started, and what the grid settled to. None of that is + * reachable by name. There is no context holding an authority, no member of a + * request that carries one, and no handler return value that produces one — an + * authority reachable by name would be an authority every same-name context and + * every loaded copy could reach. * * A claim is the unforgeable carrier. It is minted here for one ordinal of one * request under one installation generation, and a claim from another grid, @@ -18,9 +19,9 @@ * session coordinator's to answer and stays independently authoritative. */ -import { all, ensure, withResolvers } from "effection"; -import type { Operation } from "effection"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import { all, createContext, ensure, withResolvers } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; @@ -40,8 +41,8 @@ export interface TerminalPaneClaim { * Run one interactive operation as this pane's owner. * * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has finished — a claim kept past its expansion is a claim - * to a terminal nobody owns any more. + * minted the claim has stopped admitting work — a claim kept past its + * expansion is a claim to a terminal nobody owns any more. */ admit(body: () => Operation): Operation; /** @@ -77,6 +78,123 @@ export interface TerminalGridClaims { seal(): void; } +/** + * What a registered provider must present in order to act. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else. Presenting the exact request core issued is what takes the + * terminal leases, mints the pane claims, and runs the grid; anything else — + * a copy, a rebuilt lookalike, an earlier grid's request, a request already + * presented, or one belonging to a superseded installation — authorizes + * nothing. + */ +export interface TerminalGridAuthority { + present(request: TerminalGridRequest, composite: TerminalComposite): Operation; +} + +/** One grid this execution issued, from the authority's side. */ +export interface LiveGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Run the grid on a presented composite, and keep what it settled to. */ + run(composite: TerminalComposite): Operation; + /** Whether this request has already been presented. */ + used: boolean; + /** Whether the grid actually ran to a settlement. */ + settled: boolean; +} + +/** Every grid this execution has issued and not yet finished. */ +export interface GridRegistry { + live(): readonly LiveGrid[]; + add(grid: LiveGrid): void; + remove(grid: LiveGrid): void; +} + +export function createGridRegistry(): GridRegistry { + const grids = new Set(); + return { + live: () => [...grids], + add: (grid) => { + grids.add(grid); + }, + remove: (grid) => { + grids.delete(grid); + }, + }; +} + +/** + * Build the authority one provider installation is given. + * + * It closes over the installation's generation and its registry, so a factory + * that kept an authority from a superseded installation presents into a + * generation that no longer has the grid it names. + */ +export function createTerminalAuthority( + generation: object, + live: () => readonly LiveGrid[], +): TerminalGridAuthority { + return { + *present(request, composite) { + const grid = live().find((candidate) => Object.is(candidate.request, request)); + if (grid === undefined) { + throw new TerminalAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(grid.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (grid.used) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + grid.used = true; + yield* grid.run(composite); + }, + }; +} + +/** One execution's terminal installation: its registry and its generation. */ +export interface TerminalInstallation { + readonly registry: GridRegistry; + /** 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 + * authority its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The authority does + * not: it is handed to a provider factory directly. A replaced installation + * therefore produces requests the real authority has never heard of, which is a + * refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + const registry = createGridRegistry(); + const generation = {}; + yield* Installation.set({ registry, generation }); + return createTerminalAuthority(generation, () => registry.live()); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} + /** * Mint the claims for one grid expansion. * diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 9f712da1d..3892a43c7 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -1,6 +1,6 @@ /** * One terminal grid, from the lease to the last finalizer (spec §6.21, - * architecture.md §Atomic presentation and settlement). + * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape * of this module. The composite is built while it is still hidden, every pane @@ -8,34 +8,70 @@ * anything appear. A failure before that barrier discards the hidden composite * instead of leaving half a grid on the screen. * - * Ordering is the contract, not an implementation detail: - * * ``` - * lease → flush → prepare → panes start → readiness barrier → attach - * → panes settle independently → reader closes → teardown → lease released + * layout recorded → lease → flush → routed to a provider → composite presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released * ``` * - * Nothing here decides what a pane *is* — the layout arrived already derived, - * and the work each pane does is supplied by the caller. What this owns is - * whose terminal it is, when a pane counts as started, what happens when one - * fails, and the order in which it all comes apart. + * 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 { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; -import type { TerminalPaneClaim } from "./authority.ts"; +import { + awaitReadiness, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, +} from "./authority.ts"; +import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; -/** How one pane ended. */ -export type PaneOutcome = - | { readonly kind: "succeeded" } - | { readonly kind: "failed"; readonly error: Error } - /** Live when the reader closed the grid. Cancellation, not failure. */ - | { readonly kind: "closed" }; +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} /** * What one pane does once its claim exists. @@ -50,13 +86,6 @@ export interface PaneWork { run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; } -/** Everything the grid settled, in authored pane order. */ -export interface GridResult { - readonly outcomes: readonly PaneOutcome[]; - /** Why the grid failed, which is the first failed pane in authored order. */ - readonly failure?: Error; -} - /** * What a pane that never reported a spawn says. * @@ -72,40 +101,117 @@ export function paneNeverStartedMessage(ordinal: number, title: string): string ); } -class PaneStartupError extends Error { - override name = "PaneStartupError"; - readonly ordinal: number; - constructor(ordinal: number, message: string) { - super(message); - this.ordinal = ordinal; - } +/** 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, + })), + }; } /** - * Run one grid to completion and report what its panes settled to. + * Open one grid and report what it settled to. * - * The foreground lease and the composite are both scope-owned, so every path - * out of here — success, failure, and cancellation alike — releases the - * terminal and destroys exactly the composite that was prepared. That is why - * teardown is not written as a step: there is no path that can skip it. + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * authority settled. The routed answer is discarded on purpose: a handler that + * short-circuits or fabricates a return has presented nothing, and this says so + * rather than letting the document believe a grid opened. */ -export function runTerminalGrid( +export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], -): Operation { - return scoped(function* (): Operation { +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalAuthorityError( + "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; + + const grid: LiveGrid = { + request, + generation: installation.generation, + used: false, + settled: false, + *run(composite) { + settled = yield* presentGrid(request, composite, work); + grid.settled = true; + }, + }; + installation.registry.add(grid); + yield* ensure(() => { + installation.registry.remove(grid); + }); - // The one foreground-terminal lease. A root and a grid - // contend for exactly this, so neither can begin while the other holds it, - // and a host with no terminal refuses here — before any pane has done work. + // 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(); - const composite = yield* prepareTerminalGrid(request); - // Registered before a single pane starts: a composite that was prepared is + // Routed, and the answer thrown away. + yield* TerminalGrids.operations.open(request); + + if (!grid.settled || settled === undefined) { + throw new TerminalAuthorityError( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid on the composite a provider presented. + * + * The composite is scope-owned, so every path out of here — success, failure, + * and cancellation alike — destroys exactly the composite that was presented. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function presentGrid( + request: TerminalGridRequest, + composite: TerminalComposite, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + // Registered before a single pane starts: a composite that was presented is // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); @@ -116,53 +222,54 @@ export function runTerminalGrid( grid.seal(); }); - const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); let attached = false; - const panes: Task[] = []; - for (const [index, pane] of work.entries()) { + // Every pane's work, in authored order. The children are allocated in this + // order too, so a pane's durable identity follows its ordinal rather than + // the order the runtime happened to schedule it in. + const paneWorkflows = work.map((pane, index) => { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; + return function* (): Operation { + const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + outcomes[index] = outcome; + yield* composite.update(pane.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)); + } + return outcome; + }; + }); + + for (const pane of work) { yield* composite.update(pane.ordinal, "starting"); - panes.push( - yield* spawn(function* () { - try { - yield* pane.run(claim, composite); - if (!readiness.acknowledged) { - // Settled without ever starting: that is a startup failure even - // though the work itself raised nothing. - throw new PaneStartupError( - pane.ordinal, - paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), - ); - } - outcomes[index] = { kind: "succeeded" }; - yield* composite.update(pane.ordinal, "succeeded"); - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - outcomes[index] = { kind: "failed", error: failure }; - // 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 and its siblings - // keep running. - if (!attached) { - startupFailed.reject(failure); - return; - } - yield* composite.update(pane.ordinal, "failed"); - } - }), - ); } + // Spawned as one task so the coordinator below can reach the readiness + // barrier, attach, and wait for the reader while the panes are still live. + const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + try { + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a terminal grid pane failed to start"); + } - for (const pane of work) { - yield* composite.update(pane.ordinal, "running"); + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "running"); + } } yield* composite.attach(); attached = true; @@ -172,36 +279,118 @@ export function runTerminalGrid( yield* composite.closed(); // 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. + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the composite is destroyed, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. grid.seal(); - for (const [index, task] of panes.entries()) { + for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(work[index]!.ordinal, "closed"); - outcomes[index] = { kind: "closed" }; + yield* composite.update(pane.ordinal, "closed"); + outcomes[index] = { status: "closed", reason: "" }; } - yield* task.halt(); } + yield* panes.halt(); - const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); - const failed = settled.find((outcome) => outcome.kind === "failed"); + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); return { - outcomes: settled, - ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: settled, }; }); } -/** The provider-neutral request one derived layout asks for. */ -export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { - return { - columns: layout.columns, - rows: layout.rows, - panes: layout.cells.map((cell) => ({ - ordinal: cell.ordinal, - title: cell.title, - row: cell.row, - column: cell.column, - form: cell.form, - })), - }; +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + claim: TerminalPaneClaim, + composite: TerminalComposite, + readiness: { readonly acknowledged: boolean }, + request: TerminalGridRequest, + index: number, +): Operation { + return (function* (): Operation { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The 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 every pane as a durable child of the grid, in authored order. + * + * 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. + * + * `durableAll` rather than `durableSpawn`: the latter returns a task spawned + * inside the ephemeral effect's own scope, and that scope closes as the effect + * resolves, so awaiting the task throws `halted`. It has no call sites or tests + * upstream; `durableAll` is the primitive that is exercised. + * + * Without a journal there are no children to derive, and the work simply runs. + */ +function paneChildren( + workflows: readonly (() => Operation)[], +): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* all(workflows.map((workflow) => workflow())); + } + return yield* durableAll( + workflows.map( + (workflow) => + function* (): Workflow { + return yield* ephemeral(workflow()); + }, + ), + ); + })(); +} + +/** + * 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: () => Operation): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* live(); + } + const [retained] = yield* durableAll([ + function* (): Workflow { + return yield* ephemeral(live()); + }, + ]); + return retained!; + })(); } diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 000000000..cedd0b39a --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,140 @@ +/** + * 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 retained layout a journal entry holds, or undefined if it holds anything else. */ +function readLayout(value: unknown): RetainedLayout | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const fields: Record = Object.fromEntries(Object.entries(value)); + const { columns, rows, panes } = fields; + if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + return undefined; + } + return { columns, rows, panes: panes as RetainedLayout["panes"] }; +} + +/** 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/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 000000000..05919b653 --- /dev/null +++ b/packages/core/src/terminal/profile.ts @@ -0,0 +1,60 @@ +/** + * 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 owns the generation every request belongs to and the + * registry of the grids it issued — and a provider installed against the + * authority that installation mints. A grid outside one refuses rather than + * presenting something no replay could resume. + * + * The installation's lifetime has to surround authored work and end while the + * journal is still live, which is what `Execution.document` is. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Execution } from "../execute.ts"; +import { useTerminalInstallation } from "./authority.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. + * + * The authority reaches the named provider's factory and nothing else: it is + * delivered through the installation handshake rather than published, so a + * handler that answers the install request itself installs no provider and the + * document is told so. + */ +export function installTerminalGridProfile( + options: TerminalGridProfileOptions = {}, +): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const authority = yield* useTerminalInstallation(); + if (options.provider !== undefined) { + yield* installTerminalProvider( + options.provider, + { label: options.label ?? options.provider }, + authority, + ); + } + 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..f3537dd99 --- /dev/null +++ b/packages/core/src/terminal/provider-api.ts @@ -0,0 +1,271 @@ +/** + * 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 an authority decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's terminal authority and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { TerminalGridAuthority } from "./authority.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. + * + * The authority is the second argument because it is delivered, not published: + * there is no reader for it, no context holding one, and no request member + * carrying one. A factory closes over it, and only the handler that closed over + * it can pair a routed grid request with it. + */ +export type TerminalProviderFactory = ( + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +) => 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.authority); + yield* next({ intent: "acknowledge", install: call }); + return undefined; + }, + }, + { at: "min" }, + ); +} + +/** + * What the terminal told this handler, or a refusal. + * + * Parsed rather than believed. The terminal that produced it belongs to the + * canonical copy, and this handler may belong to another; what arrives is a + * value, and reading it as a delivery is this side's decision. + */ +function deliveryOf(value: unknown): { + options: TerminalProviderOptions; + authority: TerminalGridAuthority; +} { + 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 authority = Reflect.get(value, "authority"); + if (typeof options !== "object" || options === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation named no options", + ); + } + if (typeof authority !== "object" || authority === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no authority", + ); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new TerminalProviderInstallError("the live terminal provider options are not readable"); + } + const present = Reflect.get(authority, "present"); + if (typeof present !== "function") { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no grid authority", + ); + } + return { + options: { label }, + authority: { + present: (request, composite) => Reflect.apply(present, authority, [request, composite]), + }, + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * The authority reaches whichever factory answers, and nothing else: a handler + * that short-circuits, fabricates a return, or never acknowledges installs no + * provider, and this refuses rather than leaving the caller believing one is + * there. + */ +export function installTerminalProvider( + name: string, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): Operation { + return (function* (): Operation { + const request: TerminalProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, authority); + // Same stable name, so the shared middleware chain applies; own descriptor, + // so the chain ends in this invocation's terminal rather than in the public + // refusing default. + const invocation = createApi(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, + authority: TerminalGridAuthority, +): { + 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, authority }; + } + 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.test.ts b/packages/core/tests/terminal-grid.test.ts index b360fa3fb..ad44b1e74 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,7 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Atomic presentation and settlement). + * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * settlement, §Durability and replay). * * The provider here is controlled and is not tmux: it opens no terminal, starts * no process, and records what it was asked to do in the order it was asked. @@ -10,87 +11,206 @@ * * Readiness is the claim these rows care about most, so it is always driven * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and - * "did some work" be told apart at all. + * handed, never because it got far enough. That is what lets "started" and "did + * some work" be told apart at all. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, `` is what a suite writes + * to be that something — and it reaches the pane through the same seam a real + * `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +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 { installControlledLauncher, - installControlledTerminalProvider, + prepareControlledComposite, + TerminalGrids, + terminalProviderLog, +} from "@executablemd/runtime"; +import type { + ControlledCompositeOptions, + TerminalComposite, + TerminalGridRequest, + TerminalProviderLog, } from "@executablemd/runtime"; -import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; +import { + createTerminalGridClaims, + TerminalAuthorityError, + useTerminalInstallation, +} from "../src/terminal/authority.ts"; +import type { TerminalGridAuthority } from "../src/terminal/authority.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 { Json } from "../src/types.ts"; -import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; -import { runTerminalGrid } from "../src/terminal/grid.ts"; -import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; -import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; -import type { TerminalGridLayout } from "../src/terminal-grid.ts"; - -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; +/** 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 composite did, in order. */ + events: string[]; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; + /** The journal this run read and appended to. */ + journal: DurableEvent[]; } -/** A layout of `count` panes across `columns`, titled by ordinal. */ -function layoutOf(columns: number, count: number): TerminalGridLayout { - return terminalGridLayout( - columns, - Array.from({ length: count }, (_unused, index) => ({ - title: `pane ${index}`, - form: "self-closing" as const, - })), - ); +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); + }); } -/** A pane that starts, does what `body` says, and settles. */ -function pane(ordinal: number, body?: () => Operation): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - if (body) { - yield* body(); +/** The controlled interactive child, and a tripwire. */ +function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); } - }); + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + yield* sleep(25); + slowMarks.push("ready:slow"); + spawned(); + }); + return ""; + }, }, - }; + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); } -/** Everything a grid run needs installed, with the reader's close under control. */ -function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { - // A grid takes the same one foreground lease a root takes, - // so a host that offers a grid still has to offer that lease. - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ log: record, close }); +/** + * Register a controlled provider that presents through the authority it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives the authority + * as an argument, prepares a composite of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledCompositeOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the authority for a later, unrouted use. */ + readonly capture?: (authority: TerminalGridAuthority) => void; + } = {}, +): Operation { + let generation = 0; + return registerTerminalProvider("controlled", function* (_settings, authority) { + options.capture?.(authority); + yield* TerminalGrids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + const composite = yield* prepareControlledComposite(request, options, generation++); + yield* authority.present(options.substitute?.(request) ?? request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); } -/** A pane that records when it started, so ordering is read rather than timed. */ -function readyPane(ordinal: number, timeline: string[]): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - timeline.push(`ready:${ordinal}`); - claim.ready(); - yield* suspend(); - }); - }, - }; +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); } /** Close as soon as the reader is asked, which is the ordinary journey. */ @@ -99,546 +219,463 @@ function immediateClose(): () => Operation { return function* () {}; } -describe("Tier TG — pane claims and readiness", () => { - it("TG8: a claim admits one interactive operation at a time", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; +/** + * Expand one document against a controlled grid host. + * + * `provider: false` registers nothing, which is how "a host that cannot open a + * grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { + provider?: boolean; + stream?: InMemoryStream; + composite?: ControlledCompositeOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + yield* useGridComponents(ran, options.slowMarks ?? []); + yield* installControlledLauncher(); - yield* scoped(function* () { - yield* first.admit(function* () { - // A second operation on the same pane is refused while this one is live. - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - // A different pane does not contend at all, which is the whole reason a - // grid exists. - yield* second.admit(function* () { - concurrent = true; - }); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.composite ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", - ); - expect(concurrent).toBe(true); + const stream = options.stream ?? new InMemoryStream(); + const execution = yield* execute({ path, stream, includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { + outcome, + output, + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a pane admits again once its first operation has settled", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let second = false; +/** 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; +} - yield* scoped(function* () { - yield* claim.admit(function* () {}); - yield* claim.admit(function* () { - second = true; +/** 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, + "", + "", + "", + "", + ].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 } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const opened = withResolvers(); + yield* useGridComponents(ran); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: () => suspend(), + *onPrepare(asked) { + requests.push(asked); + yield* sleep(0); + }, + // Attach is the signal, not `running`: a pane that settles before the + // barrier keeps its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + opened.resolve(); + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - // Sequential work in one pane is ordinary composition, not contention. - expect(second).toBe(true); + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const task: Task = yield* spawn(function* () { + const execution = yield* execute({ path, stream, includes: [dir] }); + yield* execution; + }); + // The grid is open and its panes have settled, so the journal now holds the + // pane children's own entries. A resumed run never attaches at all — the + // region short-circuits — so this is bounded rather than waited on. + yield* race([opened.operation, sleep(120)]); + yield* sleep(5); + yield* task.halt(); + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - grid.seal(); - let refusal: unknown; +const PANES = [ + 'left', + '', +]; - yield* scoped(function* () { - try { - yield* claim.admit(function* () {}); - } catch (error) { - refusal = error; - } +describe("Tier TG — the terminal authority", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + expect(run.outcome.ok).toBe(true); + + // The same document, against a provider that answers the routed request + // itself. A return value is not evidence that a grid opened. + const shorted = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ shortCircuit: true }); + yield* 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 }; }); - // A claim kept past its grid is a claim to a terminal nobody owns. - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + 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("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + it("TA2: presenting a rebuilt request authorizes nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { + composite: {}, }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; + expect(run.outcome.ok).toBe(true); - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what the authority reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + yield* 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; }); - }); - it("TG8: a request whose ordinals are not its positions is refused", function* () { - let refusal: unknown; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - yield* sleep(0); + expect(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); }); -}); - -describe("Tier TG — atomic startup", () => { - it("TG9: nothing attaches until every pane has reported a spawn", function* () { - const record = log(); - // One ordered record both the panes and the provider 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 would prove nothing — a pane says when it actually started. - const timeline: string[] = []; - const slow = withResolvers(); - const result = yield* scoped(function* (): Operation { + 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* installControlledTerminalProvider({ - log: record, - close: immediateClose(), - // deno-lint-ignore require-yield - *onAttach() { - timeline.push("attach"); - }, + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), }); - return yield* runTerminalGrid(layoutOf(2, 3), [ - readyPane(0, timeline), - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - // Plenty of work before anything starts, and none of it makes the - // grid attachable. The delay is long enough that a grid which - // skipped the barrier would demonstrably attach first. - yield* sleep(25); - timeline.push("ready:1"); - claim.ready(); - yield* slow.operation; - }); - }, - }, - readyPane(2, timeline), - ]); + 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(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); - expect(result.failure).toBeUndefined(); + expect(changed.ok).toBe(false); + expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { - const record = log(); - let failure: unknown; + it("TA4: an authority kept past its grid authorizes nothing", function* () { + const dir = yield* useDir(); + let kept: TerminalGridAuthority | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + 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 composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0), + yield* kept!.present( { - ordinal: 1, - // Runs, settles, and never reports a spawn. - *run() {}, + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], }, - ]); + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "finished without starting anything interactive", - ); - // No partial grid was ever shown, and the hidden composite was destroyed. - expect(record.events).not.toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a preparation failure starts no pane at all", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA5: an authority from another installation generation authorizes nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, + // Two installations in one scope: the second supersedes the first, so the + // first's authority names a generation the live registry no longer has. + const stale = yield* scoped(function* () { + return yield* useTerminalInstallation(); }); + yield* useTerminalInstallation(); + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0, function* () { - started.push(0); - }), - pane(1, function* () { - started.push(1); - }), - ]); + yield* stale.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toBe( - "no pane endpoint could be created", - ); - expect(started).toEqual([]); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a grid refuses before preparation when no provider is installed", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); + const authority = 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* runTerminalGrid(layoutOf(1, 1), [ - pane(0, function* () { - started.push(0); - }), - ]); + yield* installTerminalProvider("real", { label: "real" }, authority); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "no terminal provider is installed", - ); - expect(started).toEqual([]); - }); -}); - -describe("Tier TG — settlement and close", () => { - it("TG10: a pane fails after attach while its siblings stay live", function* () { - const record = log(); - // The reader leaves once the grid has displayed the failure, so the sibling - // is provably still live when that happens rather than probably still live. - const failed = withResolvers(); - let siblingLiveAtFailure = false; - let siblingLive = false; - - const result = yield* scoped(function* (): Operation { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: () => failed.operation, - onUpdate(ordinal, state) { - if (ordinal === 0 && state === "failed") { - siblingLiveAtFailure = siblingLive; - failed.resolve(); - } - }, - }); - return yield* runTerminalGrid(layoutOf(2, 2), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* sleep(1); - throw new Error("pane 0 stopped"); - }); - }, - }, - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - siblingLive = true; - try { - yield* suspend(); - } finally { - siblingLive = false; - } - }); - }, - }, - ]); - }); - - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("state:0:0:failed"); - // The sibling was still running when its neighbour failed: an ordinary pane - // failure after attach is contained as that pane's status. - expect(siblingLiveAtFailure).toBe(true); - expect(result.outcomes[0]?.kind).toBe("failed"); - expect(result.outcomes[1]?.kind).toBe("closed"); - // The grid fails with the first failed pane in authored order. - expect(result.failure?.message).toBe("pane 0 stopped"); + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TG12: close cancels a live pane as closed rather than failed", function* () { - const record = log(); - - const result = yield* scoped(function* (): Operation { - yield* useGridHost(record, immediateClose()); - return yield* runTerminalGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - // Still live when the reader leaves. - yield* suspend(); - }); - }, - }, - ]); - }); - - // Teardown cancellation is not a pane failure, and the grid succeeds. - expect(result.outcomes[0]?.kind).toBe("closed"); - expect(result.failure).toBeUndefined(); - expect(record.events).toContain("state:0:0:closed"); - }); - - it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { - const record = log(); - - yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); - yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + it("TA7: two claims from one grid do not contend; one pane admits one", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], }); - - const closed = record.events.indexOf("closed:0"); - const destroyed = record.events.indexOf("destroy:0"); - expect(closed).toBeGreaterThan(-1); - expect(destroyed).toBeGreaterThan(closed); - expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); - }); - - it("TG13: parent cancellation tears the grid down completely", function* () { - const record = log(); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; yield* scoped(function* () { - yield* useGridHost(record, () => suspend()); - // The grid never closes on its own; the enclosing scope ending is what - // takes it down, and that has to be a complete teardown. - yield* scoped(function* () { - yield* spawnGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* suspend(); - }); - }, - }, - ]); - yield* sleep(2); + yield* first.admit(function* () { + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.admit(function* () { + concurrent = true; + }); }); }); - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); }); -}); -describe("Tier TG — the pane seam", () => { - it("TG6: work inside a pane runs as that pane's owner", function* () { - const grid = createTerminalGridClaims({ + it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { + const request = { columns: 1, rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let sawOrdinal: number | undefined; - let acknowledged = false; + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], + }; + const first = createTerminalGridClaims(request); + const second = createTerminalGridClaims(request); + // Sealing one grid says nothing about the other: claims belong to the grid + // that minted them, not to a request shape. + first.seal(); + let refusal: unknown; + let other = false; yield* scoped(function* () { - yield* usePaneTerminal(claim); - const seam = yield* paneTerminal(); - sawOrdinal = seam?.ordinal; - yield* seam!.interactive(function* (spawned) { - spawned(); - acknowledged = grid.readiness[0]!.acknowledged; + try { + yield* first.claims[0]!.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.claims[0]!.admit(function* () { + other = true; }); }); - expect(sawOrdinal).toBe(0); - // The seam is how anything interactive reports its spawn, so readiness - // travels with the work rather than being asserted around it. - expect(acknowledged).toBe(true); - }); - - it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { - const seam = yield* scoped(function* () { - return yield* paneTerminal(); - }); - expect(seam).toBeUndefined(); - }); -}); - -/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ -function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { - yield* spawn(function* () { - yield* runTerminalGrid(layout, work); - }); -} - -/** 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; - /** Every mark a tripwire component recorded, in order. */ - ran: string[]; -} - -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); + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(other).toBe(true); }); -} -/** - * The controlled interactive child, and a tripwire. - * - * A paired pane is ready only when something in it starts and reports a spawn. - * Until the native-launch Story lands, this is what a suite writes to be that - * something — and it reaches the pane through the same seam a real launch will. - */ -function useGridComponents(ran: string[]): Operation { - return registerComponents([ - { - name: "Interactive", - origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { - const pane = yield* paneTerminal(); - if (pane === undefined) { - throw new Error(" is written inside a pane"); - } - yield* pane.interactive(function* (spawned) { - spawned(); - }); - return ""; - }, - }, - { - name: "Ran", - origin: "tier-tg", - props: { - type: "object", - properties: { mark: { type: "string" } }, - required: ["mark"], - additionalProperties: false, - }, - // deno-lint-ignore require-yield - *fn(props) { - ran.push(String(props.mark)); - return ""; - }, - }, - ]); -} + it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; -/** - * Run one document against a controlled grid host. - * - * `provider: false` installs no terminal provider, which is how "a host that - * cannot open a grid refuses" is asked for. - */ -function runDocument( - dir: string, - source: string, - options: { provider?: boolean } = {}, -): Operation { - return scoped(function* () { - const path = join(dir, "doc.md"); - yield* writeTextFile(path, source); - const requests: TerminalGridRequest[] = []; - const record = log(); - const ran: string[] = []; - yield* useGridComponents(ran); - 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; - if (options.provider !== false) { - yield* installControlledTerminalProvider({ - log: record, - close: () => settled.operation, - *onPrepare(asked) { - expected = asked.panes.length; - requests.push(asked); - yield* sleep(0); - }, - onUpdate(_ordinal, state) { - if (state === "succeeded" || state === "failed") { - done++; - if (done >= expected) { - settled.resolve(); - } - } - }, + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TA10: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], }); + } catch (error) { + refusal = error; } - const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); - const outcome = yield* execution; - const output = yield* forEach(function* (_chunk: string) {}, execution.output); - return { outcome, output, requests, shown: record.shown, ran }; + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); }); -} - -/** 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; -} +}); describe("Tier TG — a grid written in a document", () => { it("TG4: the provider is asked for exactly the authored row-major layout", function* () { @@ -687,8 +724,6 @@ describe("Tier TG — a grid written in a document", () => { ); expect(run.outcome.ok).toBe(true); - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each was written in travels with it. 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" }, @@ -696,35 +731,9 @@ describe("Tier TG — a grid written in a document", () => { ]); }); - it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { - const dir = yield* useDir(); - const run = yield* runDocument( - dir, - [ - "", - "", - 'Instructions.', - "", - '', - "", - "", - "", - ].join("\n"), - ); - - expect(run.outcome.ok).toBe(true); - expect(run.requests[0]).toEqual({ - 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" }, - ], - }); - }); - - it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + 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, [ @@ -738,9 +747,20 @@ describe("Tier TG — a grid written in a document", () => { "after", "", ].join("\n"), + { + composite: { + // Preparation happens after the lease and the flush, so what the + // reader had already been given is on screen before the grid covers + // it. + *onPrepare() { + flushed.push("prepared"); + }, + }, + }, ); expect(run.outcome.ok).toBe(true); + expect(flushed).toEqual(["prepared"]); // Each pane's own text went to that pane. expect(run.shown.get(0)).toContain("left text"); expect(run.shown.get(1)).toContain("right text"); @@ -793,31 +813,60 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).toContain("after {mine}"); }); - it("TG6: a pane's cannot reach a loop outside the grid", function* () { + 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"), ); - // Refused where it was written. Had the reached the loop around the - // grid it would have exited it quietly and the document would have - // succeeded; instead the pane failed with the stray- rule, which is - // what fails the grid and then the document. - expect(failureOf(run)).toContain(" must be written inside a "); - expect(failureOf(run)).toContain("cannot break the loop that invoked it"); - expect(run.ran).toEqual(["iteration"]); + // 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("TG9: with no provider installed, no pane body or shell runs", function* () { @@ -843,3 +892,286 @@ describe("Tier TG — a grid written in a document", () => { expect(run.shown.size).toBe(0); }); }); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the composite both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + composite: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + timeline.push("ready:shell"); + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join( + "\n", + ), + { + composite: { + // Reports its spawn and returns in the same breath. + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready enough to attach, and settled enough to be `succeeded`. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + // A pane that already settled keeps the status it settled to. + expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); + expect(run.events).not.toContain("state:0:0:running"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onAttach() { + throw new Error("the composite could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the composite could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + composite: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // The following sibling started only after the composite came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // The reader's close operation is where an active provider can fail. + // deno-lint-ignore require-yield + *close() { + throw new Error("the terminal provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the terminal provider lost its server"); + expect(run.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + + /** Every terminal-grid entry the journal holds. */ + function gridEntries(run: DocumentRun): DurableEvent[] { + return run.journal.filter( + (event) => + event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), + ); + } + + it("TG15: a completed grid replays without contacting a provider at all", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // The grid opened and its panes ran; the document was then interrupted, so + // the root reached no outcome and a resumed run reaches the grid again. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // The region's retained result is the answer: no provider was asked for a + // grid, no pane content expanded, and nothing was displayed. + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG15: a completed grid replays even where no provider could open one", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + yield* runInterrupted(dir, GRID, stream); + // This host installs no provider at all. A replay that contacted one would + // refuse here; the retained result does not need one. + const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const ids = closes.map((event) => String(event.coroutineId)).sort(); + // Two pane children beneath one grid child: `..`. + const paneIds = ids.filter((id) => id.split(".").length >= 3); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order. + 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("TG17: the layout is recorded before any provider is contacted", 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(); + // Written before the grid child that opens anything, so a comparison + // against it happens while nothing has been presented. + const layoutIndex = run.journal.indexOf(layout!); + const opened = run.journal.findIndex( + (event) => event.type === "close" && String(event.coroutineId).includes("."), + ); + expect(layoutIndex).toBeGreaterThan(-1); + if (opened > -1) { + expect(layoutIndex).toBeLessThan(opened); + } + }); + + it("TG17: the retained record holds provider-neutral facts only", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const entries = gridEntries(run); + expect(entries.length).toBeGreaterThan(0); + + const written = JSON.stringify(run.journal); + // The layout the author wrote, and nothing about whatever presented it. + 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/runtime/mod.ts b/packages/runtime/mod.ts index e9a990c9a..eba02abb8 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,19 +147,20 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, + TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledTerminalProviderOptions, + ControlledCompositeOptions, TerminalComposite, + TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, TerminalPaneState, - TerminalProviderHandler, TerminalProviderLog, TerminalShellOutcome, } from "./terminal.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index cc34203c0..12827a30b 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -1,5 +1,6 @@ /** - * The terminal provider — how a host presents one grid of interactive panes. + * 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 @@ -9,26 +10,18 @@ * appears in the document: `` asks for panes and their authored * layout, and the host chooses what presents them. * - * A grid is prepared before it is shown, which is what makes opening one atomic: - * - * 1. `prepare()` builds the whole composite while it is still hidden — every - * pane endpoint and its supervision — and presents nothing. A host that - * cannot open a grid refuses here, before any pane has started work. - * 2. Core starts the authored panes concurrently and waits for every one of - * them to be ready. - * 3. `attach()` shows the composite, once, after that barrier. A failure before - * it discards the hidden composite instead of leaving a partial grid on the - * reader's screen. - * 4. `destroy()` takes it down again and gives the root terminal back. + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases, mints pane claims and settles a + * grid is a non-contextual authority delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. * - * There is no host default. `xmd run` installs the production provider; a test - * or embedding host installs a controlled one that needs no terminal. Until one - * is installed every operation refuses, which is what keeps writing, inspecting - * and validating a document free of all of this. - * - * **Presentation never decides an outcome.** `update()` receives the pane states - * core has already settled on, so a provider draws them and answers for none of - * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole composite while it is hidden, core starts the + * authored panes and waits for every one of them to report a spawn, and only + * then is anything attached. */ import { type Api, createApi } from "@effectionx/context-api"; @@ -57,6 +50,11 @@ export interface TerminalPaneRequest { * Provider-neutral throughout: it names no terminal, multiplexer, socket, * process, window or pane identifier, and carries no command, argv or * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and the authority compares the object it is presented with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. */ export interface TerminalGridRequest { readonly columns: number; @@ -83,7 +81,7 @@ export interface TerminalShellOutcome { /** * One prepared, still-hidden grid. * - * Everything here belongs to the one `prepare()` that produced it. A composite + * Everything here belongs to the one preparation that produced it. A composite * is never reused across expansions, and a provider that hands the same one * back twice has handed back a grid the second expansion did not ask for. */ @@ -118,8 +116,6 @@ export interface TerminalComposite { * ended. * * Which shell that is comes from live host policy, never from the document. - * The bytes it exchanges with the reader belong to the pane: nothing captures - * or journals them. * * `spawned` is the pane's readiness latch, and calling it is the only thing * that makes this pane ready. Call it from the runtime's successful @@ -138,16 +134,14 @@ export interface TerminalComposite { /** * Take the composite down and give the root terminal back. * - * Called exactly once for every composite `prepare()` returned, including one + * Called exactly once for every composite that was prepared, including one * discarded before it ever attached. */ destroy(): Operation; } -export interface TerminalProviderHandler { - /** Build the whole hidden composite for `request`, presenting nothing. */ - prepare(request: TerminalGridRequest): 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 " + @@ -161,29 +155,30 @@ export class TerminalProviderUnavailableError extends Error { } } +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 the authority settled + * instead of what a handler said. + */ + open(request: TerminalGridRequest): Operation; +} + /** - * The stable contextual boundary a grid request travels. + * The public routing surface. Its own default always refuses. * - * Middleware composed here may observe, narrow, refuse, wrap or delegate a - * request — everything composition needs. What it cannot do is authorize one: - * the terminal authority that mints pane claims and takes terminal ownership is - * delivered directly to the installed provider and reachable from nowhere else, - * so a handler that answers without delegating has presented nothing. + * 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 TerminalProvider: Api = createApi( - "runtime.terminalProvider", - { - // deno-lint-ignore require-yield - *prepare(_request: TerminalGridRequest): Operation { - throw new TerminalProviderUnavailableError(); - }, +export const TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); }, -); - -/** Build the hidden composite for one grid expansion. */ -export function prepareTerminalGrid(request: TerminalGridRequest): Operation { - return TerminalProvider.operations.prepare(request); -} +}); /** * Everything one controlled composite did, in the order it did it. @@ -203,17 +198,22 @@ export interface TerminalProviderLog { readonly shown: Map; } +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + /** - * What a controlled provider does instead of opening a terminal. + * What a controlled composite does instead of opening a terminal. * * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` - * decides what a self-closing pane's shell did and how long it took, and - * `close` is the operation the grid waits on, so a suite controls exactly when - * the reader leaves. + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. */ -export interface ControlledTerminalProviderOptions { - /** Appended to as the provider works, so ordering is read rather than timed. */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ readonly log?: TerminalProviderLog; onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; @@ -222,93 +222,78 @@ export interface ControlledTerminalProviderOptions { * 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 a while and hoping. + * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - /** - * What a pane's shell did. - * - * It receives the readiness latch, so a suite decides whether this shell - * reports a spawn at all — which is how "never started" is told apart from - * "started and exited immediately". - */ shell?: (ordinal: number, spawned: () => void) => Operation; close?: () => Operation; } /** - * Install a provider that presents nothing and records everything. + * Prepare one composite that presents nothing and records everything. * - * It answers the whole contract — prepare, attach, update, shell, close, + * It answers the whole contract — attach, update, display, shell, close, * destroy — so a suite exercises core's lifecycle without a terminal, a * multiplexer, or a process anywhere in it. */ -export function* installControlledTerminalProvider( - options: ControlledTerminalProviderOptions = {}, -): Operation { - const log = options.log ?? { events: [], shown: new Map() }; - const shown = log.shown; - let prepared = 0; - - yield* TerminalProvider.around( - { - *prepare([request]): Operation { - if (options.onPrepare) { - yield* options.onPrepare(request); +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); } - const generation = prepared++; - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - let destroyed = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - }, - // 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) { - const pane = shown.get(ordinal) ?? ""; - shown.set(ordinal, pane + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn - // would hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken - // down more times than it was built, which is exactly the ordering - // claim a suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - }, - }; + log.events.push(`destroy:${generation}`); }, - }, - { at: "min" }, - ); + }; + })(); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 9e01204a7..3c88c9d83 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,17 +1,17 @@ /** - * Tier TG — the terminal provider boundary (architecture.md §Terminal - * authority, spec §6.21). + * Tier TG — the terminal grid routing surface and the composite contract + * (architecture.md §Terminal authority, spec §6.21). * - * What a host installs to present a grid, and what composing middleware around - * it may and may not do. Nothing here opens a terminal, looks for a - * multiplexer, or starts a process: the whole point of the boundary is that the - * language does not depend on any of that, so a suite that needed one would be - * testing the wrong thing. + * Two things live here, and neither is an authority. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and core throws the + * answer away. The composite is what a provider prepares, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. * - * The controlled provider records what it was asked to do, in order. Ordering - * claims are read off that record rather than inferred from timing, because a - * grid that attached too early and a grid that attached on time can take the - * same wall clock. + * 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"; @@ -20,13 +20,13 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; +import type { TerminalGridRequest } from "../terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { @@ -41,16 +41,12 @@ function request(overrides: Partial = {}): TerminalGridRequ }; } -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; -} - -describe("Tier TG — the provider boundary", () => { +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* prepareTerminalGrid(request()); + yield* TerminalGrids.operations.open(request()); } catch (error) { refusal = error; } @@ -60,27 +56,118 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); }); - it("TP2: an installed provider prepares without presenting anything", function* () { - const record = log(); + 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 composite contract", () => { + it("TP3: a prepared composite presents nothing until it is attached", function* () { + const log = terminalProviderLog(); const events = yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* prepareTerminalGrid(request()); - return [...record.events]; + yield* prepareControlledComposite(request(), { log }); + return [...log.events]; }); - // Preparation happened; nothing was shown. A composite the reader can see - // before every pane is ready is the one thing atomic startup forbids. + // A composite the reader can see before every pane is ready is the one + // thing atomic startup forbids. expect(events).toEqual(["prepare:0:2x1"]); expect(events.some((event) => event.startsWith("attach:"))).toBe(false); }); - it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { - const record = log(); + it("TP3: attach, update, display, shell and destroy record in order", function* () { + const log = terminalProviderLog(); const spawns: number[] = []; yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request(), { log }); yield* composite.update(0, "starting"); + yield* composite.display(0, "pane text"); yield* composite.update(0, "running"); yield* composite.shell(1, () => spawns.push(1)); yield* composite.attach(); @@ -89,7 +176,7 @@ describe("Tier TG — the provider boundary", () => { yield* composite.destroy(); }); - expect(record.events).toEqual([ + expect(log.events).toEqual([ "prepare:0:2x1", "state:0:0:starting", "state:0:0:running", @@ -99,22 +186,22 @@ describe("Tier TG — the provider boundary", () => { "closed:0", "destroy:0", ]); + expect(log.shown.get(0)).toBe("pane text"); // The default shell starts, and says so through the latch it was handed: // readiness is reported by the shell rather than assumed by the grid. expect(spawns).toEqual([1]); }); - it("TP5: a shell that never starts never reports a spawn", function* () { + it("TP4: a shell that never starts never reports a spawn", function* () { const spawns: number[] = []; const outcome = yield* scoped(function* () { - yield* installControlledTerminalProvider({ + const composite = yield* prepareControlledComposite(request(), { // deno-lint-ignore require-yield - *shell(_ordinal, _spawned) { + *shell() { // No spawn event: nothing started, so nothing is acknowledged. return { exitCode: 127 }; }, }); - const composite = yield* prepareTerminalGrid(request()); return yield* composite.shell(1, () => spawns.push(1)); }); @@ -122,107 +209,18 @@ describe("Tier TG — the provider boundary", () => { expect(spawns).toEqual([]); }); - it("TP3: middleware observes a delegated request without changing it", function* () { - const record = log(); - const seen: TerminalGridRequest[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - seen.push(asked); - return yield* next(asked); - }, - }); - yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); - }); - - expect(seen).toHaveLength(1); - expect(seen[0]?.columns).toBe(3); - // Observation is not interference: the provider still saw the same grid. - expect(record.events).toEqual(["prepare:0:3x2"]); - }); - - it("TP3: middleware refuses a request, and no composite is ever built", function* () { - const record = log(); - let refusal: unknown; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - // deno-lint-ignore require-yield - *prepare(): Operation { - throw new Error("this host does not open terminal grids"); - }, - }); - try { - yield* prepareTerminalGrid(request()); - } catch (error) { - refusal = error; - } - }); - - expect(refusal instanceof Error ? refusal.message : "").toBe( - "this host does not open terminal grids", - ); - // Refusing means refusing: the provider below was never reached, so there - // is no hidden composite left needing teardown. - expect(record.events).toEqual([]); - }); - - it("TP3: middleware narrows a request before the provider sees it", function* () { - const record = log(); - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); - }, - }); - yield* prepareTerminalGrid(request()); - }); - - expect(record.events).toEqual(["prepare:0:1x2"]); - }); - - it("TP4: middleware wraps the composite it delegated for", function* () { - const record = log(); - const wrapped: string[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - const composite = yield* next(asked); - return { - ...composite, - *attach() { - wrapped.push("before"); - yield* composite.attach(); - wrapped.push("after"); - }, - }; - }, - }); - const composite = yield* prepareTerminalGrid(request()); - yield* composite.attach(); - yield* composite.destroy(); - }); - - expect(wrapped).toEqual(["before", "after"]); - expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); - }); - - it("TP5: a preparation failure leaves nothing to tear down", function* () { - const record = log(); + it("TP4: a preparation failure leaves no composite to tear down", function* () { + const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider({ - log: record, - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, - }); try { - yield* prepareTerminalGrid(request()); + yield* prepareControlledComposite(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); } catch (error) { refusal = error; } @@ -231,16 +229,15 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so the record shows - // no composite was built and none is owed a destroy. - expect(record.events).toEqual([]); + // The failure happened before the composite existed, so nothing is owed a + // destroy. + expect(log.events).toEqual([]); }); - it("TP5: a composite refuses to be destroyed twice", function* () { + it("TP4: a composite refuses to be destroyed twice", function* () { let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider(); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request()); yield* composite.destroy(); try { yield* composite.destroy(); @@ -254,18 +251,17 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); }); - it("TP6: each preparation is its own composite", function* () { - const record = log(); + it("TP5: each preparation is its own composite", function* () { + const log = terminalProviderLog(); yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const first = yield* prepareTerminalGrid(request()); - const second = yield* prepareTerminalGrid(request()); + const first = yield* prepareControlledComposite(request(), { log }, 0); + const second = yield* prepareControlledComposite(request(), { log }, 1); yield* first.destroy(); yield* second.destroy(); }); // Two expansions are two grids. A provider that handed the same composite // back would have presented the second expansion's grid as the first's. - expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); }); }); From 14ae876c7a161ed345cd72f861daee3321a1c192 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:45:27 -0400 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=90=9B=20Repair=20durableSpawn,=20a?= =?UTF-8?q?nd=20put=20the=20grid=20on=20it=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `durableSpawn` returned a task spawned inside the `ephemeral` effect's own scope, and that scope closed as the effect resolved — so every `yield* task` threw `halted`. It had no call sites and no tests. It now starts the child in the routine's own scope, so the task outlives the call and can be awaited or halted by whoever asked for it. A retained `Close(cancelled)` meant one thing to the code and two things in practice. Under `durableRace` and `durableAll` it is a race loser or a fail-fast sibling, and the same combinator cancels it again — those keep DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so suspending hung the resumed run forever. `runDurableChild` now takes an explicit `CancelledChildPolicy`, fixed at each combinator's call site and never chosen by a caller. Resuming uses a new internal `ReplayIndex.reopen()`, which forgets one coroutine's retained Close while keeping its yields — so the child continues its own history rather than restarting, and the divergence guard stops reading the remaining effects as a coroutine continuing past its own close. Neither it nor `disableReplay` is exported. DEC-039 records the policy and marks DEC-024's invariant as superseded in part: it assumed every cancelled child belongs to race or all. The grid uses the repaired primitive: the whole grid is one durable child, each pane is its own durable child allocated in authored ordinal order, and each pane task is observed outside its child — so a replayed pane's retained outcome publishes its status and satisfies the readiness barrier without entering a body, a shell, or a launcher. Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts` (lifetime, completed replay, interrupted resume, retained-history continuation, and both combinators keeping their own policy); 30 rows in `packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349, runtime 15. --- packages/core/src/terminal/grid.ts | 98 ++--- packages/core/tests/terminal-grid.test.ts | 148 ++++--- packages/durable-streams/combinators.ts | 126 ++++-- packages/durable-streams/replay-index.ts | 17 + packages/durable-streams/specs/DECISIONS.md | 39 ++ .../tests/durable-spawn.test.ts | 366 ++++++++++++++++++ 6 files changed, 654 insertions(+), 140 deletions(-) create mode 100644 packages/durable-streams/tests/durable-spawn.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 3892a43c7..fa2a09f0e 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,9 @@ * desynchronise the journal on the next run. */ -import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation } from "effection"; -import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -226,32 +226,45 @@ function presentGrid( const startupFailed = withResolvers(); let attached = false; - // Every pane's work, in authored order. The children are allocated in this - // order too, so a pane's durable identity follows its ordinal rather than - // the order the runtime happened to schedule it in. - const paneWorkflows = work.map((pane, index) => { + for (const pane of work) { + yield* composite.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; - return function* (): Operation { - const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + panes.push( + yield* paneChild(function* (): Operation { + return yield* runPane(pane, claim, composite, readiness, request, index); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a satisfied readiness latch. + for (const [index, task] of panes.entries()) { + yield* spawn(function* () { + const outcome = yield* task; outcomes[index] = outcome; - yield* composite.update(pane.ordinal, outcome.status); + // A pane restored from its retained outcome counts as started: it did + // start, on the run that recorded it. + grid.claims[index]!.ready(); + yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has // been shown, so the grid fails closed rather than attaching what is // left. After it, the failure is this pane's status alone. startupFailed.reject(new Error(outcome.reason)); } - return outcome; - }; - }); - - for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + }); } - // Spawned as one task so the coordinator below can reach the readiness - // barrier, attach, and wait for the reader while the panes are still live. - const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane @@ -290,8 +303,8 @@ function presentGrid( yield* composite.update(pane.ordinal, "closed"); outcomes[index] = { status: "closed", reason: "" }; } + yield* panes[index]!.halt(); } - yield* panes.halt(); const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); @@ -339,36 +352,33 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st } /** - * Run every pane as a durable child of the grid, in authored order. + * 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. * - * `durableAll` rather than `durableSpawn`: the latter returns a task spawned - * inside the ephemeral effect's own scope, and that scope closes as the effect - * resolves, so awaiting the task throws `halted`. It has no call sites or tests - * upstream; `durableAll` is the primitive that is exercised. + * `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 are no children to derive, and the work simply runs. + * Without a journal there is no child to derive, and the work simply runs. */ -function paneChildren( - workflows: readonly (() => Operation)[], -): Operation { - return (function* (): Operation { +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* all(workflows.map((workflow) => workflow())); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableAll( - workflows.map( - (workflow) => - function* (): Workflow { - return yield* ephemeral(workflow()); - }, - ), - ); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); })(); } @@ -386,11 +396,9 @@ export function durableGrid(live: () => Operation): Operation([ - function* (): Workflow { - return yield* ephemeral(live()); - }, - ]); - return retained!; + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(live()); + }); + return yield* task; })(); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index ad44b1e74..1cc5e340f 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -89,6 +89,15 @@ interface DocumentRun { journal: DurableEvent[]; } +/** + * 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-"))); @@ -100,7 +109,11 @@ function useDir(): Operation { } /** The controlled interactive child, and a tripwire. */ -function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, +): Operation { return registerComponents([ { name: "Interactive", @@ -129,6 +142,7 @@ function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; const opened = withResolvers(); - yield* useGridComponents(ran); + // Two signals, neither a deadline: the grid opened on a live run, or the + // document reached the sibling after it — which is what a replayed grid + // does. A replay that hangs reaches neither and hangs the row, rather than + // passing on a timer. + yield* useGridComponents(ran, [], (mark) => { + if (mark === PAST_THE_GRID) { + opened.resolve(); + } + }); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: () => suspend(), + close: options.close === true ? immediateClose() : () => suspend(), + ...(options.shell === undefined ? {} : { shell: options.shell }), *onPrepare(asked) { requests.push(asked); yield* sleep(0); @@ -365,7 +393,7 @@ function runInterrupted( // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* race([opened.operation, sleep(120)]); + yield* opened.operation; yield* sleep(5); yield* task.halt(); return { @@ -974,12 +1002,14 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(run.outcome.ok).toBe(true); - // Ready enough to attach, and settled enough to be `succeeded`. + // Ready at the spawn event, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. expect(run.events).toContain("attach:0"); expect(run.events).toContain("state:0:0:succeeded"); - // A pane that already settled keeps the status it settled to. - expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); - expect(run.events).not.toContain("state:0:0:running"); + 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* () { @@ -1080,61 +1110,65 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); - /** Every terminal-grid entry the journal holds. */ - function gridEntries(run: DocumentRun): DurableEvent[] { - return run.journal.filter( - (event) => - event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), - ); - } + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + 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("TG15: a completed grid replays without contacting a provider at all", function* () { + it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - // The grid opened and its panes ran; the document was then interrupted, so - // the root reached no outcome and a resumed run reaches the grid again. + // 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); - // The region's retained result is the answer: no provider was asked for a - // grid, no pane content expanded, and nothing was displayed. - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); + // A fresh composite, built by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); }); - it("TG15: a completed grid replays even where no provider could open one", function* () { + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { + spawned(); + yield* suspend(); + return { exitCode: 0 }; + }; - yield* runInterrupted(dir, GRID, stream); - // This host installs no provider at all. A replay that contacted one would - // refuse here; the retained result does not need one. - const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + expect(first.ran).toContain("left ran"); - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); - }); - - it("TG16: each pane is a durable child of the grid, in authored order", function* () { - const dir = yield* useDir(); - const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); - const closes = first.journal.filter((event) => event.type === "close"); - const ids = closes.map((event) => String(event.coroutineId)).sort(); - // Two pane children beneath one grid child: `..`. - const paneIds = ids.filter((id) => id.split(".").length >= 3); - expect(paneIds).toHaveLength(2); - const [left, right] = paneIds; - // Authored order, not scheduling order. - expect(left!.endsWith(".0")).toBe(true); - expect(right!.endsWith(".1")).toBe(true); - expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + // 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); }); it("TG17: the layout is recorded before any provider is contacted", function* () { @@ -1142,32 +1176,24 @@ describe("Tier TG — durability and replay", () => { const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layout = run.journal.find( + const layoutIndex = run.journal.findIndex( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - expect(layout).toBeDefined(); - // Written before the grid child that opens anything, so a comparison - // against it happens while nothing has been presented. - const layoutIndex = run.journal.indexOf(layout!); - const opened = run.journal.findIndex( + const firstChildClose = run.journal.findIndex( (event) => event.type === "close" && String(event.coroutineId).includes("."), ); expect(layoutIndex).toBeGreaterThan(-1); - if (opened > -1) { - expect(layoutIndex).toBeLessThan(opened); + if (firstChildClose > -1) { + expect(layoutIndex).toBeLessThan(firstChildClose); } }); - it("TG17: the retained record holds provider-neutral facts only", function* () { + 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 entries = gridEntries(run); - expect(entries.length).toBeGreaterThan(0); - const written = JSON.stringify(run.journal); - // The layout the author wrote, and nothing about whatever presented it. expect(written).toContain('"columns":2'); expect(written).toContain('"Left"'); for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 179f2cc88..dcd203592 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -36,7 +36,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 { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -53,13 +53,39 @@ 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"; + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, + cancelledPolicy: CancelledChildPolicy = "combinator-cancels", ): 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 +99,22 @@ 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") { + // A race loser, or a sibling `all` cancelled when another failed. The + // same combinator cancels it again on this run, so reproducing the + // original execution means blocking until it does — in the live run this + // child never 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. 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); } } @@ -137,8 +163,10 @@ function* runDurableChild( } // 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 +237,63 @@ 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 means the run was interrupted, not that a + * combinator chose against this child, so the child resumes its remaining work. + * See `CancelledChildPolicy`. */ export function durableSpawn( childWorkflow: () => Workflow, ): Workflow> { - return ephemeral( - (function* (): Operation> { - const scope = yield* useScope(); - const ctx = scope.expect(DurableContext); + 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}`; + return (yield createSpawnEffect(() => + runDurableChild(childWorkflow, childId, ctx, "resume"), + )) as Task; + })(); +} - // Assign deterministic child ID - const childIndex = ctx.childCounter++; - const childId = `${ctx.coroutineId}.${childIndex}`; +function* readDurableContext(): Operation { + const scope = yield* useScope(); + return scope.expect(DurableContext); +} - // Spawn the child with durable wrapping - return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); - })(), - ); +/** + * 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): DurableEffect> { + return { + description: "durable-spawn", + effectDescription: { type: "ephemeral", name: "durable-spawn" }, + enter(resolve, routine) { + resolve({ ok: true, value: routine.scope.run(child) }); + return (exit) => exit({ ok: true, value: undefined as undefined }); + }, + }; } /** 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/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index cec63a894..6e3cb8479 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -489,6 +489,45 @@ 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`. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error 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..4dcd3a919 --- /dev/null +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -0,0 +1,366 @@ +/** + * `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 } 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([]); + }); +}); From 271c6272c9319d1ac1100e9e6d434624077a7dc5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:52:57 -0400 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=93=9D=20Decide=20the=20cancelled-c?= =?UTF-8?q?hild=20contract=20and=20TG17's=20replay=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions exposed while implementing #730, and no implementation. **DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on every retained `Close(cancelled)` under an incomplete parent, which revives work a caller deliberately halted: the record of a deliberate `task.halt()` and the record of an interrupted run are the same event. The cancelled close now carries `cancellation: "caller"` or `"unwound"`, written by whichever path cancelled the child, and `"resume"` continues only `"unwound"`. A deliberate stop suspends, which is DEC-024's reproduction argument applied to a caller instead of a combinator; a legacy record with no reason reads as `"caller"`, because refusing to revive is the safe direction. The reason is retained evidence, not authority: nothing outside `runDurableChild` reads it, and no caller chooses a policy. Terminal grids need nothing wider. A grid halts its pane tasks at close, so those retain `"caller"` — and the grid child completes, so a resumed run short-circuits the region and never reaches them. The case that must resume, an interrupted run, unwinds and retains `"unwound"`. **TG17 narrows to the resolved layout.** A continuation executes the root the journal retained; the supplied source is not read, compared or refused (proved in #722). A grid's authored structure — pane count, order, form — is therefore fixed for the life of a journal and cannot differ between runs, so comparing it compares a value with itself, which is why the refusal never fired. What a fixed retained document still resolves differently is `columns` and each `title`, through prop-borne values, since props are not restored. Those refuse before the lease and before provider contact. Authored-structure change is a root-definition compatibility question, not a grid one. Root-definition authority is preserved rather than overridden by a pre-replay comparison against the current file, and the versioned root boundary that would refuse a changed source stays open work. --- architecture.md | 27 ++++++++++- packages/durable-streams/specs/DECISIONS.md | 47 +++++++++++++++++++ .../durable-streams/specs/durable-streams.md | 8 ++++ specs/executable-mdx-spec.md | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/architecture.md b/architecture.md index a6b73181b..0d8f22608 100644 --- a/architecture.md +++ b/architecture.md @@ -3623,8 +3623,31 @@ 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 +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 6e3cb8479..8c422ffbf 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -528,6 +528,53 @@ Updated before completion of every phase and committed at the end of each phase. 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. A grid halts each pane task when the reader closes, so + those panes retain `"caller"` — and the grid child completes, so a resumed run + short-circuits the whole region and never reaches them. The case that must + resume — the run interrupted while the grid is open — 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/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e804f6b4c..1b4e690fa 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11511,7 +11511,7 @@ test derives a core result from a provider identifier. | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | -| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| 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; 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 | ### Tier CR — Component registration and resolution From de092e773d262459b8c03a668b107081620ff408 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 16:45:40 -0400 Subject: [PATCH 08/22] =?UTF-8?q?=E2=9C=A8=20Implement=20DEC-040,=20and=20?= =?UTF-8?q?complete=20TG15=20and=20TG17=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **DEC-040.** A cancelled Close now records why: `cancellation: "caller"` when the owner halts the task `durableSpawn` returned, `"unwound"` for anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate stop suspends until the caller's deterministic control flow halts it again, and a record with no reason reads as `"caller"` so nothing legacy is revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason says. The halt is intercepted without changing the public `Task` surface: the returned task carries every member the real one defines, copied with its prototype, and only `halt` is replaced. A proxy cannot do this — a task's members are read-only and non-configurable, so a `get` trap is required to hand back exactly what the target holds. The reason had to survive three boundaries that were dropping it: the protocol parser, the observable copy, and — the one that actually mattered — `detachResult`, which froze every cancellation down to `{ status }`. **TG15.** The harness's `attached` and `pastGrid` signals are now separate, and a run that expects its grid to complete waits for the sibling *after* the grid before halting the root at ``. That is what leaves a completed grid child under an incomplete root, which is the only state in which a completed region can be observed replaying at all. Both a successful grid and a contained failed one replay their exact retained result with no provider, pane content, shell or launcher work, and each row asserts the grid child genuinely recorded a terminal close. No timeouts. **TG17.** Prop-borne `columns` and `title` change independently against one fixed retained document — the only things a fixed retained root can still resolve differently — and each refuses with zero provider observation. For supplied-file changes to pane count, order and form, the continuation opens the retained structure rather than the file's, asserted request-for-request. The retained record carries every authored pane's ordinal, title, form and derived position. `readLayout()` parses totally: the layout object and every pane field, with missing, extra, mistyped, out-of-position and self-inconsistent records all refused rather than half-read. Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13, provider 10. Packages: durable-streams 33, core 349, runtime 15, workflow 172. --- packages/core/src/terminal/journal.ts | 82 +++++- packages/core/tests/terminal-grid.test.ts | 277 ++++++++++++++++-- packages/durable-streams/combinators.ts | 112 +++++-- packages/durable-streams/mod.ts | 1 + packages/durable-streams/parse.ts | 16 +- packages/durable-streams/retained.ts | 19 +- .../tests/durable-spawn.test.ts | 202 +++++++++++++ packages/durable-streams/types.ts | 14 +- 8 files changed, 669 insertions(+), 54 deletions(-) diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index cedd0b39a..3ee4fd8d4 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -66,17 +66,87 @@ function* append(description: EffectDescription, value: Json): Workflow }); } -/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +/** + * 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 { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { return undefined; } - const fields: Record = Object.fromEntries(Object.entries(value)); - const { columns, rows, panes } = fields; - if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + 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: panes as RetainedLayout["panes"] }; + 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. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 1cc5e340f..9da63e051 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -248,6 +248,8 @@ function runDocument( composite?: ControlledCompositeOptions; /** Where `` records that it started. */ slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { @@ -292,7 +294,12 @@ function runDocument( yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); const stream = options.stream ?? new InMemoryStream(); - const execution = yield* execute({ path, stream, includes: [dir] }); + 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 { @@ -327,6 +334,10 @@ function heldDocument(columns: number, panes: string[]): string { ...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"); @@ -348,20 +359,26 @@ function runInterrupted( shell?: ControlledCompositeOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - const opened = withResolvers(); - // Two signals, neither a deadline: the grid opened on a live run, or the - // document reached the sibling after it — which is what a replayed grid - // does. A replay that hangs reaches neither and hangs the row, rather than - // passing on a timer. + // Two 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 needs to be waited for. Neither is a deadline: a + // replay that hangs reaches neither and hangs the row rather than passing + // on a timer. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const destroyed = withResolvers(); yield* useGridComponents(ran, [], (mark) => { if (mark === PAST_THE_GRID) { - opened.resolve(); + pastGrid.resolve(); } }); yield* installControlledLauncher(); @@ -374,11 +391,15 @@ function runInterrupted( requests.push(asked); yield* sleep(0); }, - // Attach is the signal, not `running`: a pane that settles before the - // barrier keeps its own status and never becomes runnable. + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. // deno-lint-ignore require-yield *onAttach() { - opened.resolve(); + attached.resolve(); + }, + // deno-lint-ignore require-yield + *onDestroy() { + destroyed.resolve(); }, }); } @@ -387,13 +408,36 @@ function runInterrupted( const path = join(dir, "doc.md"); yield* writeTextFile(path, source); const task: Task = yield* spawn(function* () { - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); yield* execution; }); // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* opened.operation; + // `close: true` means the grid is expected to complete, so the run is + // halted only once the document has moved past it — that is what leaves a + // completed grid child under an incomplete root. Otherwise the grid is + // expected to stay open, and attaching is as far as it gets. + yield* race([ + options.close === true ? pastGrid.operation : attached.operation, + (function* (): Operation { + yield* sleep(1500); + // deno-lint-ignore no-console + const evts = yield* stream.readAll(); + // deno-lint-ignore no-console + console.log( + "PROBE3 closes", + JSON.stringify( + evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), + ), + ); + })(), + ]); yield* sleep(5); yield* task.halt(); return { @@ -1109,6 +1153,81 @@ describe("Tier TG — startup, settlement and teardown", () => { 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 = [ + "", + "", + 'nothing interactive here', + "", + "", + "", + ``, + "", + "", + "", + ].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"), + ); + } + + 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, { close: true }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).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, + }); + + 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(); @@ -1171,21 +1290,137 @@ describe("Tier TG — durability and replay", () => { expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); - it("TG17: the layout is recorded before any provider is contacted", function* () { + /** + * 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 layoutIndex = run.journal.findIndex( + const layout = run.journal.find( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - const firstChildClose = run.journal.findIndex( - (event) => event.type === "close" && String(event.coroutineId).includes("."), - ); - expect(layoutIndex).toBeGreaterThan(-1); - if (firstChildClose > -1) { - expect(layoutIndex).toBeLessThan(firstChildClose); - } + 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: the retained layout and pane outcomes are provider-neutral", function* () { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index dcd203592..11d410fa5 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -18,14 +18,7 @@ * See protocol spec §7 (structured concurrency), §10 (race semantics). */ -import { - all as effectionAll, - ensure, - race as effectionRace, - spawn, - suspend, - useScope, -} from "effection"; +import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; import type { Operation, Task } from "effection"; import { DurableContext } from "./context.ts"; import { @@ -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, DurableEffect, 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 @@ -75,11 +68,42 @@ import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./type */ 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); @@ -99,18 +123,25 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else if (cancelledPolicy === "combinator-cancels") { - // A race loser, or a sibling `all` cancelled when another failed. The - // same combinator cancels it again on this run, so reproducing the - // original execution means blocking until it does — in the live run this - // child never threw, it simply stopped. The Close(cancelled) event - // already exists, so the teardown below skips re-emitting it. + } 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. Nobody is going to cancel - // this child a second time, so suspending would hang the resumed run. + // 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; @@ -158,7 +189,7 @@ function* runDurableChild( closeEvent = { type: "close", coroutineId: childId, - result: { status: "cancelled" }, + result: { status: "cancelled", cancellation: cancellationOf(evidence) }, }; } @@ -263,8 +294,10 @@ export function durableSpawn( const ctx = yield* ephemeral(readDurableContext()); const childIndex = ctx.childCounter++; const childId = `${ctx.coroutineId}.${childIndex}`; - return (yield createSpawnEffect(() => - runDurableChild(childWorkflow, childId, ctx, "resume"), + const evidence: CancellationEvidence = { deliberate: false }; + return (yield createSpawnEffect( + () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), + evidence, )) as Task; })(); } @@ -285,17 +318,52 @@ function* readDurableContext(): Operation { * Effection `spawn` does. What replay must not do is reach the child's body * again to discover that. */ -function createSpawnEffect(child: () => Operation): DurableEffect> { +function createSpawnEffect( + child: () => Operation, + evidence: CancellationEvidence, +): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: routine.scope.run(child) }); + resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } +/** + * The same task, with a deliberate `halt()` recorded as it happens. + * + * The caller receives every member the task defines — `then`, `catch`, + * `finally`, the async dispose, the iterator — copied from the task itself + * along with its prototype, so the public surface is the one `Task` has always + * had. Only `halt` is replaced, and only to note that someone stopped the child + * on purpose before stopping it. + * + * 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 `halt` at all. Each copied + * member is the task's own closure and keeps working on the copy. + */ +function observingHalt(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. + members.halt = { + value: () => { + evidence.deliberate = true; + return task.halt(); + }, + enumerable: true, + configurable: false, + writable: false, + }; + const observed: Task = Object.create(Object.getPrototypeOf(task), members); + return observed; +} + /** * Run multiple durable workflows concurrently and wait for all to complete. * diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 03c313c0f..581dabd57 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, 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/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/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4dcd3a919..07285ce1c 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -364,3 +364,205 @@ describe("durableSpawn — the combinators keep their own policy", () => { 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) : "", + ); + } + + it("records a deliberate halt as caller", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + 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 run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + 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(); + // The caller halts the child, then the run is interrupted before it + // completes. Both facts are in the journal; only the first decides. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // The resumed run reaches the same deliberate halt, so the child suspends + // until it does rather than performing work nobody asked to redo. + 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* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(10); + 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 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"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + }); + yield* sleep(10); + 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/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; From 871c9bda23704e3fd9e4d167c371ca693f5129a3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:04:51 -0400 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=90=9B=20Make=20the=20replay=20evid?= =?UTF-8?q?ence=20deterministic,=20and=20pin=20DEC-040's=20boundaries=20(#?= =?UTF-8?q?730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The harness cannot pass a hung replay any more.** `runInterrupted()` had a 1500ms timer racing its signals, so a replay that hung returned a DocumentRun that looked finished; it also slept a fixed 5ms to let records land. Both are gone. It now waits only on events the run produced: `attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane records — a pane's status is published only after its durable child returned, so counting settled panes is also counting durable pane closes. A replay that hangs now reaches none of them and hangs the row. **TG15's failed case is a real contained failure.** A pane that fails before attachment fails the whole region, so the old document could not both fail and continue. The failing pane is now a shell that starts, waits for attachment, and only then exits badly — contained as that pane's status, with the grid settling as failed and the document carrying on. Both runs capture the printed errors, and the row asserts the replayed run produced the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or launcher work. **DEC-040 gets boundary tests where the evidence actually travels.** `parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy absence absent, and refuses an unrecognised reason at `$.result.cancellation`. `retained.test.ts` proves retention and `consumable()` carry both reasons, leave a legacy absence absent, drop an unrecognised one to the safe default, and that the reason reaches the replay index. The DEC-040 rows in `durable-spawn.test.ts` no longer coordinate by delay: a child says when it is running, and the caller says when it has halted. **Malformed retained layouts** are covered by replaying a real journal with only its layout entry replaced — a missing member, an extra one, a mistyped one, a pane out of position, and a record that disagrees with itself. Each refuses with zero provider observation. The `durableSpawn` doc comment no longer says every retained cancellation is an interrupted run. --- packages/core/tests/terminal-grid.test.ts | 271 +++++++++++++++--- packages/durable-streams/combinators.ts | 9 +- .../tests/durable-spawn.test.ts | 2 +- packages/durable-streams/tests/parse.test.ts | 38 +++ .../durable-streams/tests/retained.test.ts | 60 +++- 5 files changed, 329 insertions(+), 51 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 9da63e051..20d004322 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -54,6 +54,7 @@ import type { TerminalProviderLog, } from "@executablemd/runtime"; +import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; import { @@ -85,6 +86,8 @@ interface DocumentRun { 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[]; } @@ -113,6 +116,7 @@ function useGridComponents( ran: string[], slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -164,6 +168,18 @@ function useGridComponents( 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", @@ -258,6 +274,13 @@ function runDocument( 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 ?? []); yield* installControlledLauncher(); @@ -309,6 +332,7 @@ function runDocument( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -361,35 +385,97 @@ function runInterrupted( 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; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** + * 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[] = []; - // Two signals, kept apart because they mean different things. `attached` - // says a grid opened on this run; `pastGrid` says the document reached the + 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 needs to be waited for. Neither is a deadline: a - // replay that hangs reaches neither and hangs the row rather than passing - // on a timer. + // 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 destroyed = withResolvers(); - yield* useGridComponents(ran, [], (mark) => { - if (mark === PAST_THE_GRID) { - pastGrid.resolve(); - } + 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(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + }, + () => attached.operation, + ); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: options.close === true ? immediateClose() : () => suspend(), - ...(options.shell === undefined ? {} : { shell: options.shell }), + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: function* (ordinal: number, spawned: () => void) { + spawned(); + if (ordinal !== options.shellFailsAfterAttach) { + return { exitCode: 0 }; + } + // Started, so the grid attaches; it fails only afterwards, which + // is the failure a grid contains as a pane status. + yield* attached.operation; + return { exitCode: 1 }; + }, + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield *onPrepare(asked) { requests.push(asked); - yield* sleep(0); }, // Attach, not `running`: a pane that settles before the barrier keeps // its own status and never becomes runnable. @@ -397,9 +483,16 @@ function runInterrupted( *onAttach() { attached.resolve(); }, - // deno-lint-ignore require-yield - *onDestroy() { - destroyed.resolve(); + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } }, }); } @@ -416,29 +509,17 @@ function runInterrupted( }); yield* execution; }); - // The grid is open and its panes have settled, so the journal now holds the - // pane children's own entries. A resumed run never attaches at all — the - // region short-circuits — so this is bounded rather than waited on. - // `close: true` means the grid is expected to complete, so the run is - // halted only once the document has moved past it — that is what leaves a - // completed grid child under an incomplete root. Otherwise the grid is - // expected to stay open, and attaching is as far as it gets. - yield* race([ - options.close === true ? pastGrid.operation : attached.operation, - (function* (): Operation { - yield* sleep(1500); - // deno-lint-ignore no-console - const evts = yield* stream.readAll(); - // deno-lint-ignore no-console - console.log( - "PROBE3 closes", - JSON.stringify( - evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), - ), - ); - })(), - ]); - yield* sleep(5); + // `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.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } yield* task.halt(); return { outcome: { ok: false, error: new Error("interrupted") } as Result, @@ -447,6 +528,7 @@ function runInterrupted( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -1162,8 +1244,9 @@ describe("Tier TG — durability and replay", () => { */ const CONTAINED_FAILURE = [ "", - "", - 'nothing interactive here', + "", + '', + '', "", "", "", @@ -1213,9 +1296,16 @@ describe("Tier TG — durability and replay", () => { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + 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. @@ -1224,6 +1314,10 @@ describe("Tier TG — durability and replay", () => { 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); @@ -1232,7 +1326,8 @@ describe("Tier TG — durability and replay", () => { it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + // 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 @@ -1277,10 +1372,17 @@ describe("Tier TG — durability and replay", () => { return { exitCode: 0 }; }; - const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + // 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 }); + 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. @@ -1423,6 +1525,85 @@ describe("Tier TG — durability and replay", () => { }); }); + 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("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 11d410fa5..2a4027020 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -279,9 +279,12 @@ function* runDurableChild( * `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`. * - * A retained `Close(cancelled)` here means the run was interrupted, not that a - * combinator chose against this child, so the child resumes its remaining work. - * See `CancelledChildPolicy`. + * 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, diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 07285ce1c..179ded8ab 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend } from "effection"; +import { sleep, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; 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..fc52ab191 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -15,9 +15,9 @@ 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"; +import type { Close, DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -443,3 +443,59 @@ 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("does not retain a reason it does not recognise", function* () { + // A record that says something else says nothing this reads, and the safe + // default — a deliberate stop — is what an absent reason already means. + const [retained] = retainEvents([ + { + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], + }, + ]); + expect(retained?.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" }); + }); +}); From ce6a37a123530e244baef7de0c1d13d1a02d06f1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:21:41 -0400 Subject: [PATCH 10/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Coordinate=20the=20D?= =?UTF-8?q?EC-040=20rows=20by=20signal,=20not=20by=20duration=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEC-040 block still slept where it meant to synchronise — my previous replacements silently failed to match after the file was reformatted, so none of them landed. The block is rewritten rather than patched. Every row now waits on something the run reported. A shared `living()` child resolves a `started` signal and then suspends, so each row halts or unwinds a child that is provably live rather than one a delay happened to reach. The caller resolves `halted` after performing its deliberate halt, so a run is interrupted only once both facts — the deliberate stop and the interruption — are in the journal. Non-revival is established by control flow rather than by waiting: the resumed run reaches its own `task.halt()` and says so, and a revived child would have recorded its mark before the caller could get there. The legacy-absence row signals once the child has been asked for and the request returned. No new timeout, and `sleep` stays imported because the lifetime rows above still use it deliberately. `retained.test.ts` drops the cast and the row it supported: rejecting an unrecognised reason is the parser's, proved there, and retention proves only that `"caller"`, `"unwound"` and a legacy absence survive. --- .../tests/durable-spawn.test.ts | 89 +++++++++++-------- .../durable-streams/tests/retained.test.ts | 15 +--- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 179ded8ab..4d826b846 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -376,22 +376,42 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { ); } + /** + * 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(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + // 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(); })(), ); @@ -405,18 +425,12 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { 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(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { yield* suspend(); @@ -427,7 +441,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(3); + // Interrupted while the child is live, said by the child. + yield* started.operation; yield* run.halt(); expect(yield* cancellations(stream)).toEqual(["unwound"]); @@ -436,24 +451,20 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("does not revive a child the caller deliberately halted", function* () { const marks: string[] = []; const stream = new InMemoryStream(); - // The caller halts the child, then the run is interrupted before it - // completes. Both facts are in the journal; only the first decides. + 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(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - marks.push("first life"); - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + yield* started.operation; yield* task.halt(); + halted.resolve(); yield* suspend(); })(), ); @@ -462,14 +473,16 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(5); + 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, so the child suspends - // until it does rather than performing work nobody asked to redo. + // 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 { @@ -483,8 +496,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { }); yield* ephemeral( (function* (): Operation { - yield* sleep(1); yield* task.halt(); + reachedTheHalt.resolve(); yield* suspend(); })(), ); @@ -493,7 +506,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(10); + yield* reachedTheHalt.operation; yield* second.halt(); expect(marks).toEqual(["first life"]); @@ -509,6 +522,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { result: { status: "cancelled" }, }); + const asked = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -520,12 +534,15 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { })(), ); }); + // 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* sleep(10); + yield* asked.operation; yield* run.halt(); // Absent evidence is the safe direction: nothing is revived. diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index fc52ab191..b4a57970e 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { Close, DurableEvent, Json } from "../types.ts"; +import type { DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -480,19 +480,6 @@ describe("retention keeps why a child was cancelled (DEC-040)", () => { expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); }); - it("does not retain a reason it does not recognise", function* () { - // A record that says something else says nothing this reads, and the safe - // default — a deliberate stop — is what an absent reason already means. - const [retained] = retainEvents([ - { - type: "close", - coroutineId: "root.0", - result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], - }, - ]); - expect(retained?.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"); From 64292a41bfc35211ad9613e4a4efa75dc266d876 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:07:57 -0400 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=90=9B=20Observe=20every=20disposal?= =?UTF-8?q?=20surface,=20and=20make=20reader=20close=20cooperative=20(#730?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task `durableSpawn` returns copied it from the original unchanged, so `await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded `cancellation: "unwound"` and the next run revived the child. `halt()` and the async dispose are the same decision spelled two ways, and both are now observed. Awaiting a task is not a stop and is left exactly as it was. A regression disposes a live task, asserts the retained reason is `"caller"`, resumes the journal, and proves the body is not entered again. **Reader close no longer halts panes.** It asks them to stop: a pane races its work against a close signal, settles as `closed`, and records that outcome as its own. Nothing on the ordinary close path is a caller-cancelled child any more, 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. Statuses are published before anything is awaited, so a pane with slow finalizers cannot delay the outcome the grid already knows. **§6.21 now agrees with architecture.md and TG17.** Partial replay compares the resolved layout — columns and titles. Pane count, order and form come from the retained root and cannot diverge within a continuation, so a changed supplied file is ignored in favour of the retained structure; refusing a changed authored structure is a root-definition boundary this specification does not yet define. DEC-040 is unchanged and nothing deliberately stopped is revived. --- packages/core/src/terminal/grid.ts | 48 ++++++++++++-- packages/core/tests/terminal-grid.test.ts | 49 +++++++++++++- packages/durable-streams/combinators.ts | 38 +++++++---- .../tests/durable-spawn.test.ts | 64 ++++++++++++++++++- specs/executable-mdx-spec.md | 24 +++++-- 5 files changed, 199 insertions(+), 24 deletions(-) diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa2a09f0e..fa91a00f3 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -224,6 +224,11 @@ function presentGrid( 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) { @@ -242,7 +247,15 @@ function presentGrid( const readiness = grid.readiness[index]!; panes.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, claim, composite, readiness, request, index); + return yield* runPane( + pane, + claim, + composite, + readiness, + request, + index, + closing.operation, + ); }), ); } @@ -298,12 +311,20 @@ function presentGrid( // lease released and the following sibling started only once nothing a pane // acquired can still act. grid.seal(); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { yield* composite.update(pane.ordinal, "closed"); - outcomes[index] = { status: "closed", reason: "" }; } - yield* panes[index]!.halt(); + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* panes[index]!; + outcomes[index] ??= outcome; } const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); @@ -324,10 +345,29 @@ function runPane( readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, + closing: Operation, ): Operation { return (function* (): Operation { try { - yield* pane.run(claim, composite); + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(claim, composite)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + return { status: "closed", reason: "" }; + } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 20d004322..4c6961c25 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -117,6 +117,7 @@ function useGridComponents( slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -168,6 +169,20 @@ function useGridComponents( 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(); + }); + 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 @@ -395,6 +410,21 @@ function runInterrupted( closeAfterFailure?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** Resolved once a pane's finalizer has been entered and is blocked. */ + onTeardownEntered?: () => 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. * @@ -446,6 +476,12 @@ function runInterrupted( } }, () => attached.operation, + function* () { + options.onTeardownEntered?.(); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + }, ); yield* installControlledLauncher(); if (options.provider !== false) { @@ -514,13 +550,22 @@ function runInterrupted( // 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.close === true || options.closeAfterFailure === true) { + 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; } - yield* task.halt(); + // 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; return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 2a4027020..b6b5b9a12 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -329,40 +329,54 @@ function createSpawnEffect( description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); + resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } /** - * The same task, with a deliberate `halt()` recorded as it happens. + * The same task, with a deliberate stop recorded as it happens. * * The caller receives every member the task defines — `then`, `catch`, - * `finally`, the async dispose, the iterator — copied from the task itself - * along with its prototype, so the public surface is the one `Task` has always - * had. Only `halt` is replaced, and only to note that someone stopped the child - * on purpose before stopping it. + * `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 `halt` at all. Each copied + * 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 observingHalt(task: Task, evidence: CancellationEvidence): Task { +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. - members.halt = { - value: () => { + const deliberate = (stop: () => R): (() => R) => { + return () => { evidence.deliberate = true; - return task.halt(); - }, + 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/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4d826b846..1f35445f7 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend, withResolvers } from "effection"; +import { sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; @@ -512,6 +512,68 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { 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(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 1b4e690fa..b77b4bee2 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9460,11 +9460,25 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then builds a new live composite. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or From 4cb91ed6a33f666c31661d70d1cd2f586de3e5dd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:48:27 -0400 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=93=9D=20Define=20reader-close=20ca?= =?UTF-8?q?ncellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 58 ++++++++++++++++++--- packages/durable-streams/specs/DECISIONS.md | 12 +++-- specs/executable-mdx-spec.md | 41 ++++++++++++--- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index 0d8f22608..ad0566f89 100644 --- a/architecture.md +++ b/architecture.md @@ -3563,16 +3563,48 @@ The grid runs as one structured scope: 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The composite remains present after all panes settle until the reader closes or leaves it. -7. Closing begins an ordered teardown: prevent new pane launches, cancel live - pane scopes, await every child and finalizer, detach and destroy the exact - provider composite, restore the root terminal, and only then release the - foreground lease and settle the grid. The document never continues while an - observable pane child or provider-owned process can still act through the - grid. +7. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, detach and destroy the exact provider composite, + restore the root terminal, and only then release the foreground lease and + settle the grid. The document never continues while an observable pane child + or provider-owned process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -3623,6 +3655,16 @@ 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. +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. diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 8c422ffbf..6be5d3b60 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -567,11 +567,13 @@ Updated before completion of every phase and committed at the end of each phase. *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. A grid halts each pane task when the reader closes, so - those panes retain `"caller"` — and the grid child completes, so a resumed run - short-circuits the whole region and never reaches them. The case that must - resume — the run interrupted while the grid is open — unwinds the grid and - pane children, retains `"unwound"`, and continues. + 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. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index b77b4bee2..67d6d996b 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9384,11 +9384,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, destroys the exact composite, restores the root terminal, and +releases the foreground lease. The deferred await ends only after the durable +child has settled and its `Close` has been acknowledged. Only then does the +element settle and a later document sibling begin. There is no implicit timeout; +parent cancellation and an enclosing execution deadline use the same complete +teardown. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -9423,6 +9435,12 @@ continues. A provider or host failure cancels the composite and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. +If it arrives after reader close takes effect, reader close still decides the +grid and pane outcomes: then-live panes retain `closed`, already-settled panes +keep their outcomes, and the grid retains `reader` or `failed` under the normal +authored-order rule. The cancellation remains pending until complete teardown +and the grid's durable close, then reaches the parent before any following +document sibling runs. A fatal or cleanup failure keeps its existing precedence. All acquired resources are finalized even when an earlier failure already decides the result, and the existing fatal-infrastructure and cleanup precedence still applies. Before the first cancellation signal, the provider snapshots @@ -9460,6 +9478,16 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken and before any provider is contacted. It then builds a new live composite. @@ -11526,7 +11554,8 @@ test derives a core result from a provider identifier. | 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 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; 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 | +| 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 From 4f22ff4412b2e4d64a71a0fc511b1d8677cbaa7b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:04:51 -0400 Subject: [PATCH 13/22] =?UTF-8?q?=E2=9C=A8=20Implement=20the=20reader-clos?= =?UTF-8?q?e=20cancellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the amendment at 18338707 without revising it. **The live handshake.** `composite.closed()` settling now only *proposes* the boundary. The grid's durable child publishes that proposal and waits; the owner awaiting the child acknowledges it; and only then does the grid seal admission and ask its panes to close. The handshake is one live rendezvous — no provider identity, nothing journaled. **Committing an outcome before the scope finishes unwinding.** A durable child can now declare its terminal value, and `runDurableChild` records that value if the child never reaches a normal ending. That is the piece the contract needs: the grid commits its retained record as the boundary is crossed, and each pane live at that moment commits `closed`, so a cancellation arriving while pane and provider finalizers are still running records what close decided rather than a cancellation. Committing is live state; it reaches the journal only as the ordinary `Close`. A child that returns or throws normally overrides it, and a child that never committed still records the cancellation it actually reached — DEC-040 untouched. Cancellation stays deferred because Effection completes a child's teardown — pane finalizers, provider destroy, terminal restoration, lease release, the `Close` append and the task's settlement — before the halt reaches the owner. **Pane work stays inside its ordinal-derived durable child.** Reader close asks the pane to close; it never halts the pane's durable task. The pane commits `closed`, stops its live nested work through its own scope, and settles only once that work and its finalizers have settled. No durable closing marker was added, and completed replay is unchanged. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 146 +++++++++++++++++++--- packages/core/tests/terminal-grid.test.ts | 9 ++ packages/durable-streams/combinators.ts | 40 +++++- packages/durable-streams/mod.ts | 1 + 5 files changed, 178 insertions(+), 22 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index ae0899dde..8d4107045 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2181,11 +2181,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* () { + const retained = yield* durableGrid(function* (boundary, commit) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work); + return yield* openTerminalGrid(layout, work, boundary, commit); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa91a00f3..2a4677feb 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -38,6 +38,55 @@ import { import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * seal admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + /** How one pane ended, as the journal records it. */ export type PaneStatus = "succeeded" | "failed" | "closed"; @@ -148,6 +197,8 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -167,7 +218,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work); + settled = yield* presentGrid(request, composite, work, boundary, commit); grid.settled = true; }, }; @@ -209,6 +260,8 @@ function presentGrid( request: TerminalGridRequest, composite: TerminalComposite, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -246,7 +299,9 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* (): Operation { + yield* paneChild(function* ( + commitPane: (outcome: RetainedPaneOutcome) => void, + ): Operation { return yield* runPane( pane, claim, @@ -255,6 +310,7 @@ function presentGrid( request, index, closing.operation, + commitPane, ); }), ); @@ -304,6 +360,12 @@ function presentGrid( // what finishes the grid, not the last pane exiting. yield* composite.closed(); + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's @@ -312,6 +374,12 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); + // The outcome is decided the moment the boundary is crossed: every settled + // pane keeps its own, every pane still live is closed. Committed here, so a + // cancellation arriving while pane and provider finalizers are still going + // records what close decided rather than a cancellation. + const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + commit(retained(request, decided, firstReason(decided))); // 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. @@ -329,11 +397,7 @@ function presentGrid( const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); - return { - layout: retainedLayout(request), - close: reason === undefined ? "reader" : "failed", - panes: settled, - }; + return retained(request, settled, reason); }); } @@ -346,6 +410,7 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, + commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -366,7 +431,17 @@ function runPane( })(), ]); if (closed) { - return { status: "closed", reason: "" }; + const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; + // Decided at the boundary, so a cancellation arriving while this pane's + // finalizers are still going records the close rather than a + // cancellation — and never a caller-cancelled child a later run would + // have to revive or wait on. + commitPane(outcome); + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles only once they + // have. + yield* running.halt(); + return outcome; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -386,6 +461,19 @@ function runPane( })(); } +/** 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; @@ -408,16 +496,19 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: () => Operation, + body: (commit: (outcome: RetainedPaneOutcome) => void) => 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); + // No journal behind this run: an ordinary spawned child, with nothing to + // commit an outcome into. + return yield* spawn(() => body(() => {})); } - return yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(body()); + return yield* durableSpawn(function* ( + commit: (outcome: RetainedPaneOutcome) => void, + ): Workflow { + return yield* ephemeral(body(commit)); }); })(); } @@ -430,14 +521,35 @@ function paneChild( * no shell starts — and claiming the completed child claims every pane history * beneath it, so a resumed run starts nothing. */ -export function durableGrid(live: () => Operation): Operation { +export function durableGrid( + live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, +): Operation { return (function* (): Operation { + const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* live(); + // No journal to commit 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, () => {}); } - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(live()); + const task = yield* durableSpawn(function* ( + commit: (grid: RetainedGrid) => void, + ): Workflow { + return yield* ephemeral(live(boundary, commit)); + }); + // The owner's cancellation-deferred await. Acknowledging happens here, + // inside it: from this point a cancellation cannot pre-empt the close, + // because the child commits its outcome as the boundary is crossed and + // Effection completes a child's teardown — its pane and provider + // finalizers, its `Close` append and its settlement — before the halt + // reaches whoever asked for it. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); }); return yield* task; })(); diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4c6961c25..f80813595 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -118,6 +118,7 @@ function useGridComponents( onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, ): Operation { return registerComponents([ { @@ -179,6 +180,9 @@ function useGridComponents( 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 ""; }, @@ -408,6 +412,8 @@ function runInterrupted( * would record the pane as cancelled by the close instead. */ closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -467,6 +473,8 @@ function runInterrupted( }, }); const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +490,7 @@ function runInterrupted( yield* options.holdTeardown(); } }, + () => armed.resolve(), ); yield* installControlledLauncher(); if (options.provider !== false) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index b6b5b9a12..ed50a2921 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -98,8 +98,24 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } +/** + * Declare a child's terminal value before its scope has finished unwinding. + * + * A child that has already decided what it settled to — a terminal grid that + * crossed its reader-close boundary, say — must record that outcome even if the + * run is cancelled while its finalizers are still going. Without this, a halt + * arriving during teardown loses the decision and the child records a + * cancellation instead, which is a different thing entirely. + * + * Committing is live state, never journaled on its own: the value reaches the + * journal only as the child's ordinary `Close`, written where it always was. + * A child that goes on to return or throw normally overrides what it committed, + * because that is the outcome it actually reached. + */ +export type CommitOutcome = (value: T) => void; + function* runDurableChild( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -163,12 +179,30 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; + // What the child declared it had settled to before its scope finished coming + // down. Read only when the child never reached a normal ending. + let committed: { value: T } | undefined; + const commit: CommitOutcome = (value) => { + committed = { value }; + }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } + // A child that committed an outcome and was then cancelled mid-teardown + // settled: the decision was made before the cancellation arrived, and the + // record has to say so. The cancellation is still a cancellation for + // whoever asked for it — it is simply delivered after this. + if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { + closeEvent = { + type: "close", + coroutineId: childId, + result: { status: "ok", value: committed.value as Json }, + }; + } + // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -205,7 +239,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(); + const result: T = yield* childWorkflow(commit); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -287,7 +321,7 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 581dabd57..4d2f5747d 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -102,6 +102,7 @@ export { durableAction, durableCall, durableSleep, versionCheck } from "./operat // Structured concurrency combinators export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; +export type { CommitOutcome } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 824d2f04ce20ee3e17752a922b806c235646eb4b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:34:37 -0400 Subject: [PATCH 14/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Defer=20the=20grid?= =?UTF-8?q?=20owner's=20cancellation=20at=20the=20live=20close=20boundary?= =?UTF-8?q?=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture 18338707 at the owner boundary. The grid's durable child now runs in a scope of its own — a child of the owner's, so it inherits every context the document runs under, and its own so that tearing the owner down does not reach it first. A finalizer registered after that scope exists runs before it is destroyed, and that is the cancellation-deferred await: once the owner has acknowledged the provider's close proposal, the grid and its panes finish teardown and append their ordinary completed Close records, and only then does the cancellation carry on to the parent. Removes the exported CommitOutcome/durableSpawn(commit) API. Cancellation is never turned into success inside runDurableChild; durableSpawnIn only says where a child lives, and grants nothing a caller does not already have. TG19 proves the ordering with signals alone: a live pane arms a blocking finalizer, the reader leaves, the finalizer is entered and held, cancellation begins, the finalizer is released, and the run ends with the composite destroyed, a completed grid Close retained, the live pane retained as closed — and the sibling after the grid never reached. The continuation then replays past it with no provider, no pane body and no finalizer re-entered. TG6 isolates paired-pane sequencing on its own: the reader leaves only once the pane's second component has run. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 128 ++++++++++++++-------- packages/core/tests/terminal-grid.test.ts | 117 +++++++++++++++++++- packages/durable-streams/combinators.ts | 75 ++++++------- packages/durable-streams/mod.ts | 3 +- 5 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 8d4107045..a9e97ffd1 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2181,11 +2181,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* (boundary, commit) { + 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, commit); + return yield* openTerminalGrid(layout, work, boundary); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 2a4677feb..f96fb091a 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,25 @@ * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; +import { + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -198,7 +214,6 @@ export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -218,7 +233,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary, commit); + settled = yield* presentGrid(request, composite, work, boundary); grid.settled = true; }, }; @@ -261,7 +276,6 @@ function presentGrid( composite: TerminalComposite, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -299,9 +313,7 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* ( - commitPane: (outcome: RetainedPaneOutcome) => void, - ): Operation { + yield* paneChild(function* (): Operation { return yield* runPane( pane, claim, @@ -310,7 +322,6 @@ function presentGrid( request, index, closing.operation, - commitPane, ); }), ); @@ -374,12 +385,6 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); - // The outcome is decided the moment the boundary is crossed: every settled - // pane keeps its own, every pane still live is closed. Committed here, so a - // cancellation arriving while pane and provider finalizers are still going - // records what close decided rather than a cancellation. - const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); - commit(retained(request, decided, firstReason(decided))); // 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. @@ -410,7 +415,6 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, - commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -431,17 +435,11 @@ function runPane( })(), ]); if (closed) { - const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; - // Decided at the boundary, so a cancellation arriving while this pane's - // finalizers are still going records the close rather than a - // cancellation — and never a caller-cancelled child a later run would - // have to revive or wait on. - commitPane(outcome); // The nested work is stopped by this pane's own scope, and its - // finalizers are awaited here: the durable child settles only once they - // have. + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. yield* running.halt(); - return outcome; + return { status: "closed", reason: "" }; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -496,19 +494,16 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, + body: () => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child, with nothing to - // commit an outcome into. - return yield* spawn(() => body(() => {})); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableSpawn(function* ( - commit: (outcome: RetainedPaneOutcome) => void, - ): Workflow { - return yield* ephemeral(body(commit)); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); }); })(); } @@ -522,35 +517,72 @@ function paneChild( * beneath it, so a resumed run starts nothing. */ export function durableGrid( - live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, + live: (boundary: CloseBoundary) => Operation, ): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal to commit into, so the boundary is crossed as soon as it is + // 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, () => {}); + return yield* live(boundary); } - const task = yield* durableSpawn(function* ( - commit: (grid: RetainedGrid) => void, - ): Workflow { - return yield* ephemeral(live(boundary, commit)); + + // 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's cancellation-deferred await. Acknowledging happens here, - // inside it: from this point a cancellation cannot pre-empt the close, - // because the child commits its outcome as the boundary is crossed and - // Effection completes a child's teardown — its pane and provider - // finalizers, its `Close` append and its settlement — before the halt - // reaches whoever asked for it. + // 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(); }); - return yield* task; + + 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/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index f80813595..52571e69a 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -414,6 +414,8 @@ function runInterrupted( 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. */ @@ -475,6 +477,7 @@ function runInterrupted( const paneFailed = withResolvers(); // Resolved once a `` pane has installed its finalizer. const armed = withResolvers(); + const marked = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +485,9 @@ function runInterrupted( if (mark === PAST_THE_GRID) { pastGrid.resolve(); } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } }, () => attached.operation, function* () { @@ -499,9 +505,13 @@ function runInterrupted( close: options.closeAfterFailure === true ? () => paneFailed.operation - : options.close === true - ? immediateClose() - : () => suspend(), + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { shell: function* (ordinal: number, spawned: () => void) { @@ -1077,6 +1087,25 @@ describe("Tier TG — a grid written in a document", () => { 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( @@ -1326,6 +1355,26 @@ describe("Tier TG — durability and replay", () => { ); } + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + 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)) { + const panes = Reflect.get(value, "panes"); + if (Array.isArray(panes)) { + return panes; + } + } + } + } + return []; + } + it("TG15: a completed successful grid replays its exact result, with no work", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -1658,6 +1707,68 @@ describe("Tier TG — durability and replay", () => { } }); + 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, [ + '', + '', + ]); + + // Every step below is an event this run produced. Nothing waits for a + // duration, so a lifecycle that never reached a step hangs the row rather + // than passing it. + const entered = withResolvers(); + const release = withResolvers(); + + 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: () => entered.resolve(), + holdTeardown: () => release.operation, + // 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(), + }); + + // 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(first.events).toContain("destroy:0"); + expect(completedGrid(first)).toBe(true); + expect(first.ran).toEqual(["pane body"]); + // The live pane settled as closed rather than cancelled: a cancelled child + // is what a later run would have to revive, and this one has nothing left + // to do. + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // 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 = false; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered = true; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(false); + // 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(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index ed50a2921..9aefe654a 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -19,7 +19,7 @@ */ import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; -import type { Operation, Task } from "effection"; +import type { Operation, Scope, Task } from "effection"; import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, @@ -98,24 +98,8 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } -/** - * Declare a child's terminal value before its scope has finished unwinding. - * - * A child that has already decided what it settled to — a terminal grid that - * crossed its reader-close boundary, say — must record that outcome even if the - * run is cancelled while its finalizers are still going. Without this, a halt - * arriving during teardown loses the decision and the child records a - * cancellation instead, which is a different thing entirely. - * - * Committing is live state, never journaled on its own: the value reaches the - * journal only as the child's ordinary `Close`, written where it always was. - * A child that goes on to return or throw normally overrides what it committed, - * because that is the outcome it actually reached. - */ -export type CommitOutcome = (value: T) => void; - function* runDurableChild( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -179,30 +163,12 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; - // What the child declared it had settled to before its scope finished coming - // down. Read only when the child never reached a normal ending. - let committed: { value: T } | undefined; - const commit: CommitOutcome = (value) => { - committed = { value }; - }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } - // A child that committed an outcome and was then cancelled mid-teardown - // settled: the decision was made before the cancellation arrived, and the - // record has to say so. The cancellation is still a cancellation for - // whoever asked for it — it is simply delivered after this. - if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { - closeEvent = { - type: "close", - coroutineId: childId, - result: { status: "ok", value: committed.value as Json }, - }; - } - // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -239,7 +205,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(commit); + const result: T = yield* childWorkflow(); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -321,7 +287,35 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, +): Workflow> { + 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); +} + +/** 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: @@ -335,6 +329,7 @@ export function durableSpawn( return (yield createSpawnEffect( () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), evidence, + into, )) as Task; })(); } @@ -358,12 +353,14 @@ function* readDurableContext(): Operation { function createSpawnEffect( child: () => Operation, evidence: CancellationEvidence, + into?: Scope, ): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); + const host = into ?? routine.scope; + resolve({ ok: true, value: observingDisposal(host.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 4d2f5747d..94a63b438 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -101,8 +101,7 @@ export type { export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts"; // Structured concurrency combinators -export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; -export type { CommitOutcome } from "./combinators.ts"; +export { durableAll, durableRace, durableSpawn, durableSpawnIn } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 5b5198856091dd65a6538c15a9885b88a557ea00 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:44:55 -0400 Subject: [PATCH 15/22] =?UTF-8?q?=E2=9C=85=20Count=20what=20TG19=20proves:?= =?UTF-8?q?=20resources,=20records=20and=20the=20lease=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production lifecycle is unchanged. TG19 now reads counters and journal records rather than a log's shape. The controlled composite keeps live resource counters — composites prepared, composites attached, shells started — each raised when it takes something and lowered when it gives it back, however it left. TG19 reads them once while a pane finalizer is blocked, so it knows they went up, and again when the cancellation has completed, so it knows they came back down. The harness now says when a blocked finalizer *leaves*, not only when it is entered: a finalizer that was entered and then cancelled reaches the first hook and never the second. And after every interrupted run it takes the foreground lease and gives it back twice — the first proves the grid returned it, the second proves the harness did. TG19 adds: one grid Close(ok) retaining close: "reader"; two pane Closes, both completed, with no cancellation recorded at either level; the finalizer entered and left exactly once; destroy:0 exactly once. The first-attempt claim that no following sibling ran and the replay tripwires are unchanged. Every one of these was broken on purpose and re-run: dropping any of the three counter releases, the deferral, the finalizer-exit hook, or double-logging destroy fails TG19, and a second holder of the foreground lease is refused. --- packages/core/tests/terminal-grid.test.ts | 133 ++++++++++++++++++---- packages/runtime/mod.ts | 1 + packages/runtime/terminal.ts | 56 +++++++-- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 52571e69a..4acf29a8d 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -44,6 +44,7 @@ import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, prepareControlledComposite, + reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; @@ -52,6 +53,7 @@ import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog, + TerminalProviderResources, } from "@executablemd/runtime"; import { Component } from "../src/component-api.ts"; @@ -90,6 +92,8 @@ interface DocumentRun { 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; } /** @@ -353,6 +357,7 @@ function runDocument( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -420,8 +425,31 @@ function runInterrupted( shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ holdTeardown?: () => Operation; - /** Resolved once a pane's finalizer has been entered and is blocked. */ - onTeardownEntered?: () => void; + /** + * 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; /** @@ -491,10 +519,11 @@ function runInterrupted( }, () => attached.operation, function* () { - options.onTeardownEntered?.(); + options.onTeardownEntered?.(log.live); if (options.holdTeardown) { yield* options.holdTeardown(); } + options.onTeardownExited?.(); }, () => armed.resolve(), ); @@ -585,6 +614,16 @@ function runInterrupted( 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: "", @@ -594,6 +633,7 @@ function runInterrupted( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -1355,8 +1395,8 @@ describe("Tier TG — durability and replay", () => { ); } - /** The pane outcomes the grid retained, in authored order. */ - function paneOutcomes(run: DocumentRun): unknown[] { + /** 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" && @@ -1365,14 +1405,34 @@ describe("Tier TG — durability and replay", () => { ) { const value = event.result.value; if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const panes = Reflect.get(value, "panes"); - if (Array.isArray(panes)) { - return panes; - } + return { ...value }; } } } - return []; + 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* () { @@ -1715,56 +1775,85 @@ describe("Tier TG — durability and replay", () => { '', ]); - // Every step below is an event this run produced. Nothing waits for a - // duration, so a lifecycle that never reached a step hangs the row rather - // than passing it. + // 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: () => entered.resolve(), + 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(first.events).toContain("destroy:0"); - expect(completedGrid(first)).toBe(true); + 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"]); - // The live pane settled as closed rather than cancelled: a cancelled child - // is what a later run would have to revive, and this one has nothing left - // to do. + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); expect(paneOutcomes(first)).toEqual([ { status: "closed", reason: "" }, { status: "succeeded", reason: "" }, ]); + // The provider's counters went up and came back down. Reading them only at + // the end would be true of counters that never moved. + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); + expect(first.live).toEqual({ composites: 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 = false; + let reentered = 0; const second = yield* runInterrupted(dir, source, stream, { close: true, provider: false, onTeardownEntered: () => { - reentered = true; + reentered++; }, }); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); - expect(reentered).toBe(false); + expect(reentered).toBe(0); // The retained grid came back and the document carried on from it. expect(second.ran).toEqual([PAST_THE_GRID]); }); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index eba02abb8..c9a9d60d1 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -162,6 +162,7 @@ export type { TerminalPaneRequest, TerminalPaneState, TerminalProviderLog, + TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 12827a30b..b03275a8f 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -196,11 +196,34 @@ export interface TerminalProviderLog { * document output to prove where it did not. */ readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; } /** A fresh, empty record. */ export function terminalProviderLog(): TerminalProviderLog { - return { events: [], shown: new Map() }; + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0 }, + }; } /** @@ -247,13 +270,17 @@ export function prepareControlledComposite( yield* options.onPrepare(request); } log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; let destroyed = false; + let attached = false; return { *attach() { if (options.onAttach) { yield* options.onAttach(); } log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; }, // deno-lint-ignore require-yield *update(ordinal, state) { @@ -266,14 +293,22 @@ export function prepareControlledComposite( }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; }, *closed() { if (options.close) { @@ -293,6 +328,11 @@ export function prepareControlledComposite( yield* options.onDestroy(); } log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } }, }; })(); From 101829187e827bd8d91a3bcce70c77bd81bf0f56 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 10 Sep 2026 22:35:34 -0400 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=93=9D=20Make=20PaneTerminal=20the?= =?UTF-8?q?=20pane-work=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the required per-pane exclusivity, spawn readiness, and close admission inside the grid lifecycle. Pane work receives the concrete PaneTerminal operation; pane-claim, readiness, aggregate-claims, and sealing APIs are no longer public architecture. --- architecture.md | 59 +++++++++++++---------- specs/executable-mdx-spec.md | 19 +++++--- specs/native-agent-session-launch-spec.md | 55 +++++++++++---------- 3 files changed, 77 insertions(+), 56 deletions(-) diff --git a/architecture.md b/architecture.md index ad0566f89..0c74544eb 100644 --- a/architecture.md +++ b/architecture.md @@ -111,7 +111,7 @@ Existing documents and code get aligned to this section retroactively. | foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | | terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown | | terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell | -| pane-terminal lease | the exclusive claim one live interactive operation holds on one terminal pane. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session | +| 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 | @@ -3479,10 +3479,10 @@ 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. +provider installation generation and permits only that provider to present the +grid core issued. No context value, prop, binding, provider result, retained +record, diagnostic, or structurally similar request carries that presentation +authority. The stable contextual terminal API is request routing only. Middleware may observe, narrow, refuse, wrap, or delegate a one-use request. A handler's @@ -3492,26 +3492,35 @@ provider factory closes over the direct authority and must present that same request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. -A pane claim grants one interactive terminal at that ordinal, not an Agent -session. Core installs a pane-scoped native launcher that closes over the claim. -`` in that pane consequently reserves, flushes, and launches on -the pane terminal instead of competing for the root lease. Launches in -different panes may run concurrently; two interactive launches in one pane -cannot. Sequential launches in one paired pane remain ordinary composition. -The session coordinator is unchanged and independently authoritative, so two -panes attempting to own the same logical Agent session still contend and one -is refused. The provider starts a self-closing pane's host-configured default -shell under the same kind of pane claim. - -Each claim also closes over one host-owned readiness latch. The pane-scoped -native launcher acknowledges it from the runtime's successful child-spawn event -and before it waits for exit; failed preparation, reservation, or spawn never -acknowledges it. Allocation of a PID and the child's first output are not this -event. The self-closing shell path acknowledges the same boundary. The latch is -not a request member, contextual value, provider return, public event, or -process handle, and acknowledging it twice has no effect. A root launch has no -grid readiness latch. This is how the grid observes successful interactive -start without changing `Session.Launch`'s result or exposing a child process. +The grid lifecycle creates one concrete `PaneTerminal` for each authored +ordinal and passes it to that pane's work. Its `interactive()` operation owns +the pane terminal for one live operation, refuses a concurrent operation in the +same pane, and supplies that operation with the one-use `spawned` +acknowledgement that makes the pane ready. 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 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, live-operation tracking, and closing admission are private state of +the grid lifecycle, not a second public capability model. There is no public +pane-claim or readiness interface, no aggregate grid-claims object, and no +factory or sealing operation for another package to coordinate. The lifecycle +passes only the concrete `PaneTerminal` across the pane-work boundary. Its +`spawned` acknowledgement resolves the private readiness latch from the +runtime's successful child-spawn event and before the launcher 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 acknowledgement is +idempotent, a root launch receives none, and neither the callback nor the latch +enters a request, provider result, process handle, or durable record. A provider whose pane endpoint is owned by a persistent process routes child creation through that process. The launch's exact argv vector, working diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 67d6d996b..82b33edd5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9350,12 +9350,19 @@ Opening a grid is atomic from the reader's perspective: 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. +The grid lifecycle owns one private readiness latch for each pane. It passes +that pane's work one concrete `PaneTerminal`; `PaneTerminal.interactive()` +supplies the live operation with a one-use `spawned` acknowledgement. The +pane-scoped launcher calls it 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, +public request, provider return, process result, or durable record. + +The same private lifecycle state admits at most one interactive operation in a +pane and stops admitting new work when the grid closes. Different pane +terminals do not contend. No pane-claim, readiness, aggregate claims, 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 diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c422702..06532b23a 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.interactive()` 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. @@ -771,13 +770,19 @@ 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. +`PaneTerminal.interactive()` supplies the native launch with a one-use +`spawned` acknowledgement backed by the grid lifecycle's private readiness +latch. The launcher calls 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 receives no such callback. 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. + +The provider-neutral lifecycle exports `PaneTerminal`, not its readiness, +busy-state, or closing machinery. There is no public pane-claim or readiness +interface and no aggregate grid-claims 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 From 4b1bf533d8555cab499d78a144551412c04a648b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:00:54 -0400 Subject: [PATCH 17/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Make=20PaneTerminal?= =?UTF-8?q?=20the=20only=20capability=20pane=20work=20receives=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behaviours the claims carried are required and all still here: one interactive operation owns a pane at a time, different panes never contend, a pane is ready only when something in it reports a child spawn, closing stops new work before it stops live work, and a pane terminal kept past its grid grants nothing. What went is the second capability model they were dressed as. `PaneWork.run()` receives the pane's concrete `PaneTerminal` — the same value `` already reaches through the pane seam — and `usePaneTerminal()` installs what it was handed rather than wrapping a claim in another object. Readiness, live-operation tracking and closing admission are now private state of the grid lifecycle, held per pane beside the terminal they belong to. Nothing exports a pane claim, a readiness view, an aggregate of either, or a factory and sealing pair for another package to coordinate through; the lifecycle asks its own questions of its own state, and the ordinal validation that guarded the minting now guards building the terminals. A self-closing pane's shell runs through `interactive()` like any other pane's work, so one boundary reports every spawn. The rows that drove the removed factory directly now drive the real lifecycle: two panes rendezvous inside their interactive bodies to prove they overlap, one pane refuses an operation while another is live and admits the next after it settles, a terminal kept past its grid refuses, two acknowledgements are one started pane, and work that reports no spawn fails startup. The ordinal guard is asked through `openTerminalGrid()` with a layout whose second cell calls itself pane 0 from position 1: the refusal names both, and no pane work runs and nothing attaches, because the guard runs before a pane terminal exists. Every one of those rows was checked against a deliberately broken lifecycle before being trusted — including the ordinal row, which passes and fails with the guard rather than beside it. Provider presentation authority is untouched: the request object is still the unforgeable carrier, and this only stops that authority from also minting a per-pane one. --- packages/core/mod.ts | 8 +- packages/core/src/expand.ts | 18 +- packages/core/src/terminal/authority.ts | 172 +--------- packages/core/src/terminal/grid.ts | 195 +++++++++-- packages/core/src/terminal/pane.ts | 38 +-- packages/core/tests/terminal-grid.test.ts | 391 +++++++++++++++++----- 6 files changed, 505 insertions(+), 317 deletions(-) diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 568ae1e19..d330284d6 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -154,17 +154,11 @@ export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; export { createTerminalAuthority, - createTerminalGridClaims, TerminalAuthorityError, terminalInstallation, useTerminalInstallation, } from "./src/terminal/authority.ts"; -export type { - PaneReadiness, - TerminalGridAuthority, - TerminalGridClaims, - TerminalPaneClaim, -} from "./src/terminal/authority.ts"; +export type { TerminalGridAuthority } from "./src/terminal/authority.ts"; export { installTerminalProvider, registerTerminalProvider, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index a9e97ffd1..7bc2969f9 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2202,9 +2202,10 @@ function* expandTerminalGrid( } /** - * What one authored pane does once the grid has minted its claim. + * What one authored pane does once the grid has created its terminal. * - * A self-closing pane runs the host's default shell through its claim. A paired + * A self-closing pane runs the host's default shell through that terminal's + * interactive operation, 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 @@ -2216,9 +2217,12 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, - *run(claim, composite) { - const outcome = yield* claim.admit(() => - composite.shell(pane.ordinal, () => claim.ready()), + *run(terminal, composite) { + // The shell is this pane's one interactive operation, and the spawn it + // reports 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.interactive((spawned) => + composite.shell(pane.ordinal, spawned), ); if (outcome.signal !== undefined) { throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); @@ -2234,12 +2238,12 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { return { ordinal: pane.ordinal, - *run(claim, composite) { + *run(terminal, composite) { yield* scoped(function* () { // A pane is not inside the loop the grid was written in, so a // in its content has no loop to exit and says so. yield* ActiveLoop.set(undefined); - yield* usePaneTerminal(claim); + 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 diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index 64e11fce3..a11a99dd1 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -11,15 +11,18 @@ * authority reachable by name would be an authority every same-name context and * every loaded copy could reach. * - * A claim is the unforgeable carrier. It is minted here for one ordinal of one - * request under one installation generation, and a claim from another grid, - * another ordinal, an earlier generation, or a finished expansion authorizes - * nothing at all. Holding one grants terminal ownership and nothing else: it + * The request object is the unforgeable carrier. It is issued here for one grid + * under one installation generation, and a request from another grid, an + * earlier generation, or a finished expansion presents nothing at all. + * Presenting one grants the provider its drawing surface and nothing else: it * says nothing about which Agent session a pane may own, because that is the * session coordinator's to answer and stays independently authoritative. + * + * What a pane's work may do with its terminal is not decided here. The grid + * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. */ -import { all, createContext, ensure, withResolvers } from "effection"; +import { createContext } from "effection"; import type { Context, Operation } from "effection"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -27,63 +30,12 @@ export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; } -/** - * One pane's terminal ownership. - * - * `admit` is the whole of it: an interactive operation runs inside one, and a - * second one on the same pane is refused while the first is live. Two claims for - * two ordinals do not contend at all, which is what lets panes be interactive at - * the same time. - */ -export interface TerminalPaneClaim { - readonly ordinal: number; - /** - * Run one interactive operation as this pane's owner. - * - * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has stopped admitting work — a claim kept past its - * expansion is a claim to a terminal nobody owns any more. - */ - admit(body: () => Operation): Operation; - /** - * Acknowledge the runtime's successful child-spawn event for this pane. - * - * The one thing that makes a pane ready. Called from the spawn event and - * before anything waits for the child to exit, so a child that starts and - * immediately exits is both ready and settled. Acknowledging twice has no - * effect, and a preparation, reservation or spawn that failed never - * acknowledges at all. - */ - ready(): void; -} - -/** What one pane's readiness is waiting on, from the grid's side. */ -export interface PaneReadiness { - /** Settles when the pane's first interactive child reports its spawn event. */ - reached(): Operation; - /** Whether the latch has been acknowledged. */ - readonly acknowledged: boolean; -} - -/** The claims one grid expansion holds, and what they are waiting on. */ -export interface TerminalGridClaims { - readonly claims: readonly TerminalPaneClaim[]; - readonly readiness: readonly PaneReadiness[]; - /** - * Stop admitting anything on every pane. - * - * Close prevents a later launch before it cancels the live ones, so a pane - * that was about to start one is refused rather than raced. - */ - seal(): void; -} - /** * What a registered provider must present in order to act. * * Delivered directly to the provider factory as it installs, and reachable * nowhere else. Presenting the exact request core issued is what takes the - * terminal leases, mints the pane claims, and runs the grid; anything else — + * terminal leases and runs the grid; anything else — * a copy, a rebuilt lookalike, an earlier grid's request, a request already * presented, or one belonging to a superseded installation — authorizes * nothing. @@ -194,109 +146,3 @@ export function* useTerminalInstallation(): Operation { export function terminalInstallation(): Operation { return Installation.get(); } - -/** - * Mint the claims for one grid expansion. - * - * The request is validated against the ordinals it declares before a single - * claim exists: a request whose panes are not exactly `0..n-1` in order - * describes a grid core did not derive, and answering it would be answering for - * a layout nobody authored. - */ -export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { - validate(request); - - let sealed = false; - const claims: TerminalPaneClaim[] = []; - const readiness: PaneReadiness[] = []; - - for (const pane of request.panes) { - const latch = withResolvers(); - let acknowledged = false; - let live = false; - - readiness.push({ - reached: () => latch.operation, - get acknowledged() { - return acknowledged; - }, - }); - - claims.push({ - ordinal: pane.ordinal, - *admit(body: () => Operation): Operation { - if (sealed) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, - ); - } - if (live) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + - `terminal at a time`, - ); - } - live = true; - try { - return yield* body(); - } finally { - live = false; - } - }, - ready() { - // Idempotent by construction: readiness is a fact about the pane, and a - // provider that reports the same spawn twice has not started two panes. - if (acknowledged) { - return; - } - acknowledged = true; - latch.resolve(); - }, - }); - } - - return { - claims, - readiness, - seal() { - sealed = true; - }, - }; -} - -function validate(request: TerminalGridRequest): void { - if (request.panes.length === 0) { - throw new TerminalAuthorityError("a terminal grid request names no panes"); - } - for (const [index, pane] of request.panes.entries()) { - if (pane.ordinal !== index) { - throw new TerminalAuthorityError( - `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + - `a pane's ordinal is its position among the grid's panes`, - ); - } - } -} - -/** - * Settle once every pane has reported its spawn event. - * - * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing - * run deadline or parent cancellation is what bounds it. A pane that fails to - * start never reaches its latch, so the caller races this against pane failure - * rather than asking the barrier to know about failure. - */ -export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { - return allOf(readiness.map((pane) => pane.reached())); -} - -function* allOf(waits: readonly Operation[]): Operation { - yield* all(waits); -} - -/** Seal the grid as soon as the enclosing scope begins to unwind. */ -export function sealOnTeardown(claims: TerminalGridClaims): Operation { - return ensure(() => { - claims.seal(); - }); -} diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index f96fb091a..45c2d6786 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -23,6 +23,7 @@ */ import { + all, createScope, Err, ensure, @@ -45,15 +46,133 @@ import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { - awaitReadiness, - createTerminalGridClaims, - TerminalAuthorityError, - terminalInstallation, -} from "./authority.ts"; -import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; +import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; +import type { LiveGrid } from "./authority.ts"; +import type { PaneTerminal } from "./pane.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * One pane's terminal, and the state only the grid lifecycle may touch. + * + * The terminal is the whole of what crosses into pane work. Everything else + * here answers a question the lifecycle asks about a pane — has it started, + * may it still start anything — and is deliberately not reachable from the + * pane, from a provider, or from a document: a second capability model beside + * `PaneTerminal` would be a second way to own a pane. + */ +interface LivePane { + /** What this pane's work runs on. */ + readonly terminal: PaneTerminal; + /** Settles once this pane has reported its child-spawn event. */ + spawnReported(): Operation; + /** Whether that event has been reported. */ + readonly hasSpawned: boolean; + /** + * Report the event without entering the pane's work. + * + * A pane restored from its retained outcome did start — on the run that + * recorded it — so the barrier is satisfied without a child existing now. + */ + recordSpawn(): void; + /** Refuse any further interactive work in this pane. */ + closeAdmission(): void; +} + +/** + * Build one pane terminal per authored ordinal. + * + * The request is validated against the ordinals it declares before a single + * terminal exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +function livePanes(request: TerminalGridRequest): LivePane[] { + validateOrdinals(request); + + return request.panes.map((pane) => { + const reported = withResolvers(); + let hasSpawned = false; + let live = false; + let closed = false; + + return { + terminal: { + ordinal: pane.ordinal, + *interactive(body: (spawned: () => void) => Operation): Operation { + if (closed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(() => { + // Idempotent by construction: readiness is a fact about the pane, + // and a provider that reported the same spawn twice has not + // started two panes. + if (hasSpawned) { + return; + } + hasSpawned = true; + reported.resolve(); + }); + } finally { + // Released on every ending, so a pane that settled admits the next + // operation written after it. + live = false; + } + }, + }, + spawnReported: () => reported.operation, + get hasSpawned() { + return hasSpawned; + }, + recordSpawn() { + if (hasSpawned) { + return; + } + hasSpawned = true; + reported.resolve(); + }, + closeAdmission() { + closed = true; + }, + }; + }); +} + +function validateOrdinals(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reports, so the caller races this against pane failure rather + * than asking the barrier to know about failure. + */ +function* everyPaneStarted(panes: readonly LivePane[]): Operation { + yield* all(panes.map((pane) => pane.spawnReported())); +} + /** * The live boundary reader close crosses (architecture.md §Atomic presentation * and settlement). @@ -61,7 +180,7 @@ import type { TerminalGridLayout } from "../terminal-grid.ts"; * The provider settling `closed()` only *proposes* the boundary. It is crossed * when the owner awaiting the grid's durable child acknowledges that proposal * from inside its own cancellation-deferred await — and only then may the grid - * seal admission and ask its panes to close. + * 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 @@ -139,16 +258,16 @@ export interface RetainedGrid extends Record { } /** - * What one pane does once its claim exists. + * 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 run as the pane's admitted owner, and both are expected - * to report a spawn through the claim before anything can attach. + * default shell. Both run through `terminal.interactive()`, and both are + * expected to report a spawn from inside it before anything can attach. */ export interface PaneWork { readonly ordinal: number; - run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; + run(terminal: PaneTerminal, composite: TerminalComposite): Operation; } /** @@ -282,11 +401,11 @@ function presentGrid( // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); - const grid = createTerminalGridClaims(request); + const panes = livePanes(request); // Nothing new is admitted once teardown begins, so a pane that was about to // start an interactive child is refused rather than racing the close. yield* ensure(() => { - grid.seal(); + closeAdmission(panes); }); const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); @@ -308,34 +427,25 @@ function presentGrid( // completed pane returns its retained outcome without entering a body, a // shell, or a launcher, and that outcome is what publishes its status and // satisfies the readiness barrier. - const panes: Task[] = []; + const children: Task[] = []; for (const [index, pane] of work.entries()) { - const claim = grid.claims[index]!; - const readiness = grid.readiness[index]!; - panes.push( + const live = panes[index]!; + children.push( yield* paneChild(function* (): Operation { - return yield* runPane( - pane, - claim, - composite, - readiness, - request, - index, - closing.operation, - ); + return yield* runPane(pane, live, composite, request, index, closing.operation); }), ); } // Observing each task is what turns a pane's outcome — replayed or live — - // into a published status and a satisfied readiness latch. - for (const [index, task] of panes.entries()) { + // 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 counts as started: it did // start, on the run that recorded it. - grid.claims[index]!.ready(); + panes[index]!.recordSpawn(); yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has @@ -350,7 +460,7 @@ function presentGrid( // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. try { - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + yield* race([everyPaneStarted(panes), startupFailed.operation]); } catch { // Simultaneous startup failures are selected by authored ordinal, not by // whichever rejected the race first. @@ -383,7 +493,7 @@ function presentGrid( // own teardown after this returns — so the composite is destroyed, the // lease released and the following sibling started only once nothing a pane // acquired can still act. - grid.seal(); + closeAdmission(panes); 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 @@ -396,7 +506,7 @@ function presentGrid( for (const [index] of work.entries()) { // Awaited, not halted. Each pane settles on the close signal and records // the outcome it reached, which is what a resumed run reads. - const outcome = yield* panes[index]!; + const outcome = yield* children[index]!; outcomes[index] ??= outcome; } @@ -406,12 +516,23 @@ function presentGrid( }); } +/** + * Stop every pane admitting new interactive work. + * + * Called before the live panes are asked to stop, so a pane that was about to + * start an interactive child is refused rather than racing the close. + */ +function closeAdmission(panes: readonly LivePane[]): void { + for (const pane of panes) { + pane.closeAdmission(); + } +} + /** Run one pane's work and say what it came to. */ function runPane( pane: PaneWork, - claim: TerminalPaneClaim, + live: LivePane, composite: TerminalComposite, - readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, closing: Operation, @@ -423,7 +544,7 @@ function runPane( // comes down in the enclosing scope's own teardown — so a pane whose // finalizers are slow cannot hold up the outcome the grid already knows, // and the record a resumed run reads is written either way. - const running = yield* spawn(() => pane.run(claim, composite)); + const running = yield* spawn(() => pane.run(live.terminal, composite)); const closed = yield* race([ (function* (): Operation { yield* running; @@ -441,7 +562,7 @@ function runPane( yield* running.halt(); return { status: "closed", reason: "" }; } - if (!readiness.acknowledged) { + if (!live.hasSpawned) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. return { diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts index f308de81b..f6039ffef 100644 --- a/packages/core/src/terminal/pane.ts +++ b/packages/core/src/terminal/pane.ts @@ -7,10 +7,11 @@ * is the whole reason a grid exists. So core installs this in each pane's own * scope, and anything interactive asks here first. * - * What travels contextually is the seam, not the authority. The claim it hands - * out was minted for one ordinal of one grid and cannot be forged, copied - * usefully, or kept past the expansion that owns it — so a replaced context - * yields a pane terminal nobody owns rather than a way into one somebody does. + * What travels contextually is the seam, not the authority. The value it holds + * is the one `PaneTerminal` the grid lifecycle created for this ordinal, 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 @@ -19,7 +20,6 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; -import type { TerminalPaneClaim } from "./authority.ts"; /** The pane the current work is running in. */ export interface PaneTerminal { @@ -28,14 +28,15 @@ export interface PaneTerminal { /** * Run one interactive operation as this pane's owner. * - * `body` receives the pane's readiness latch and must call it from the - * runtime's successful child-spawn event, before it waits for the child to - * exit. A body that never spawns never reports, and the grid it belongs to - * never attaches — which is what stops a pane that failed to start being - * presented as one that is running. + * `body` receives this pane's one-use `spawned` acknowledgement and must call + * it from the runtime's successful child-spawn event, before it waits for the + * child to exit. Acknowledging twice is one event. A body that never spawns + * never reports, and the grid it belongs to never attaches — which is what + * stops a pane that failed to start being presented as one that is running. * - * A second interactive operation while one is live on this pane is refused. - * Two panes do not contend with each other at all. + * A second interactive operation while one is live on this pane is refused, + * and so is any operation once the grid has stopped admitting work. Sequential + * operations in one pane are ordinary. Two panes do not contend at all. */ interactive(body: (spawned: () => void) => Operation): Operation; } @@ -52,15 +53,14 @@ export function paneTerminal(): Operation { /** * 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(claim: TerminalPaneClaim): Operation { - yield* PaneTerminalContext.set({ - ordinal: claim.ordinal, - interactive(body) { - return claim.admit(() => body(() => claim.ready())); - }, - }); +export function* usePaneTerminal(terminal: PaneTerminal): Operation { + yield* PaneTerminalContext.set(terminal); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4acf29a8d..c0d623567 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -59,11 +59,7 @@ import type { import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { - createTerminalGridClaims, - TerminalAuthorityError, - useTerminalInstallation, -} from "../src/terminal/authority.ts"; +import { TerminalAuthorityError, useTerminalInstallation } from "../src/terminal/authority.ts"; import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; import { installTerminalProvider, @@ -73,6 +69,9 @@ import { } 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 } from "../src/terminal/grid.ts"; import type { Json } from "../src/types.ts"; /** One document run against a controlled grid host. */ @@ -115,6 +114,48 @@ function useDir(): Operation { }); } +/** + * 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); +} + /** The controlled interactive child, and a tripwire. */ function useGridComponents( ran: string[], @@ -123,6 +164,7 @@ function useGridComponents( afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, teardownArmed: () => void = () => {}, + probe: PaneProbe = paneProbe(), ): Operation { return registerComponents([ { @@ -156,6 +198,104 @@ function useGridComponents( 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: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + probe.marks.push(`enter:${pane.ordinal}`); + probe.entered(); + const together = yield* race([ + (function* (): Operation { + yield* probe.overlapped(); + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${pane.ordinal}:${together}`); + spawned(); + }); + probe.marks.push(`leave:${pane.ordinal}`); + 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.interactive(function* (spawned) { + spawned(); + try { + yield* pane.interactive(function* () { + 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.interactive(function* () { + probe.marks.push("sequential"); + }); + // Kept deliberately, so a row can ask what it grants after the grid has + // closed. + probe.kept.push(pane); + return ""; + }, + }, + { + // Reports the same spawn twice. One pane started, not two. + name: "TwiceSpawned", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + spawned(); + probe.marks.push("spawned twice"); + }); + return ""; + }, + }, + { + // Interactive work that never reports a spawn: 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"); + } + yield* pane.interactive(function* () { + probe.marks.push("worked without spawning"); + }); + return ""; + }, + }, { // Starts interactively, slowly, and records when it did. name: "Slow", @@ -289,6 +429,8 @@ function runDocument( 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* () { @@ -304,7 +446,15 @@ function runDocument( return yield* next(segment); }, }); - yield* useGridComponents(ran, options.slowMarks ?? []); + 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 @@ -833,105 +983,178 @@ describe("Tier TG — the terminal authority", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TA7: two claims from one grid do not contend; one pane admits one", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; + 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 }, + ); - yield* scoped(function* () { - yield* first.admit(function* () { - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - yield* second.admit(function* () { - concurrent = true; - }); - }); - }); + expect(run.outcome.ok).toBe(true); + // Each pane waited inside its own interactive body until the other was + // inside one too. Panes that contended could not both report this. + expect(probe.marks).toContain("together:0:true"); + expect(probe.marks).toContain("together:1:true"); + // And both were inside before either left. + expect(probe.marks.indexOf("enter:1")).toBeLessThan(probe.marks.indexOf("leave:0")); + }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", + 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(concurrent).toBe(true); + + 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("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { - const request = { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], - }; - const first = createTerminalGridClaims(request); - const second = createTerminalGridClaims(request); - // Sealing one grid says nothing about the other: claims belong to the grid - // that minted them, not to a request shape. - first.seal(); + 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; - let other = false; yield* scoped(function* () { try { - yield* first.claims[0]!.admit(function* () {}); + yield* kept!.interactive(function* () {}); } catch (error) { refusal = error; } - yield* second.claims[0]!.admit(function* () { - other = true; - }); }); - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); - expect(other).toBe(true); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusalOf(refusal)).toContain("its grid has stopped admitting"); }); - it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; - - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); - }); + it("TA10: only a reported spawn is readiness, and reporting twice is one event", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // Two acknowledgements are one started pane: the grid attached once and + // settled, rather than waiting for a second pane nobody authored. + expect(run.outcome.ok).toBe(true); + expect(probe.marks).toContain("spawned twice"); + expect(run.events).toContain("attach:0"); + }); + + it("TA11: interactive work that reports no spawn has not started", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // The pane owned its terminal and did work in it. Neither is starting. + expect(probe.marks).toContain("worked without spawning"); + expect(failureOf(run)).toContain("finished without starting anything interactive"); + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); }); - it("TA10: a request whose ordinals are not its positions is refused", function* () { + 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; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], + + yield* scoped(function* () { + yield* useGridHost({ + // deno-lint-ignore require-yield + *onAttach() { + attached.push("attach"); + }, }); - } catch (error) { - refusal = error; - } + + 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(TerminalAuthorityError); - yield* sleep(0); + 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([]); }); }); From bb5c1255e471967380b7ab06b2af782ab77bc4df Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:35:48 -0400 Subject: [PATCH 18/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Give=20one=20owner?= =?UTF-8?q?=20the=20grids=20a=20terminal=20installation=20issued=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was a Set the authority searched. Two things have to meet before a grid exists — the document submits the authored request and its pane work, a provider presents a composite for that exact object — and nothing held both sides or decided when they had met. The supervisor does: it keeps the entries, converges them by object identity and installation generation, and is where every refusal a presentation can meet now lives. Each grid runs as a task the supervisor keeps, in a scope of its own beneath the operation that submitted it. Beneath, and not beneath the supervisor, because a pane's durable identity and the bindings its content reads are the expansion's: parenting grids to the supervisor's own scope loses both, and the suite says so loudly — pane durable order, pane bindings, the startup-failure paths and the provider-failure path all fail together. That parentage is also what makes a grid impossible to strand, which is worth stating plainly: removing either finalizer the supervisor registers changes no test, because the submitting operation unwinds whenever the call that routed it does and takes the grid with it. They are kept as the supervisor saying what it owns rather than as the thing that makes it true, and the comments say which. Six rows drive the ownership directly, each one finite and signalled rather than timed: a provider that answers without presenting opens nothing, one request opens one grid however often it is presented, a settled grid is gone while the next still opens and its composite was destroyed first, a presenting call that is cancelled leaves no pane and no composite behind, an installation teardown stops the grid still live and waits for it, and a second grid cannot be live beside the first — which is why "every remaining grid" is one grid: the foreground-terminal lease admits a single grid at a time. Everything the refactor was meant to preserve is unchanged and still proven by the rows that already existed: readiness and attachment, per-pane exclusivity, close admission, reader close distinct from cancellation, completed and incomplete replay, exact-request and generation refusals, and pane work that still receives only its PaneTerminal. No document names a registry, a live-grid holder or a supervisor, so no stated contract moved with this. --- packages/core/src/terminal/authority.ts | 227 ++++++++++++---- packages/core/src/terminal/grid.ts | 17 +- packages/core/tests/terminal-grid.test.ts | 308 +++++++++++++++++++++- 3 files changed, 489 insertions(+), 63 deletions(-) diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index a11a99dd1..aaf8eba9e 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -22,8 +22,8 @@ * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. */ -import { createContext } from "effection"; -import type { Context, Operation } from "effection"; +import { createContext, createScope, ensure, resource, until } from "effection"; +import type { Context, Operation, Scope, Task } from "effection"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { @@ -44,79 +44,202 @@ export interface TerminalGridAuthority { present(request: TerminalGridRequest, composite: TerminalComposite): Operation; } -/** One grid this execution issued, from the authority's side. */ -export interface LiveGrid { +/** + * One grid the supervisor has been asked to run, before a composite exists. + * + * The scope is the submitting operation's own. A grid keeps the contexts of the + * expansion that wrote it — its durable child above all, which is what gives its + * panes their identities — so the supervisor owns when a grid stops, never what + * it runs under. + */ +export interface SubmittedGrid { /** The exact request object core issued. Compared by identity, never shape. */ readonly request: TerminalGridRequest; /** The installation this grid belongs to. */ readonly generation: object; - /** Run the grid on a presented composite, and keep what it settled to. */ + /** Where the grid runs: a scope of its own, beneath the submitter's. */ + readonly scope: Scope; + /** Run the grid on a presented composite. */ run(composite: TerminalComposite): Operation; - /** Whether this request has already been presented. */ - used: boolean; - /** Whether the grid actually ran to a settlement. */ +} + +/** What the submitting operation can ask about its own grid afterwards. */ +export interface GridSubmission { + /** Whether a provider presented for this request and the grid ran through. */ + readonly settled: boolean; +} + +/** One submitted grid, and whatever of it is currently live. */ +interface Entry extends SubmittedGrid, GridSubmission { + presented: boolean; settled: boolean; + task?: Task; + destroy?: () => Promise; } -/** Every grid this execution has issued and not yet finished. */ -export interface GridRegistry { - live(): readonly LiveGrid[]; - add(grid: LiveGrid): void; - remove(grid: LiveGrid): void; +/** + * Who owns the grids one terminal installation has issued. + * + * Two things have to meet before a grid exists: the document submits the + * authored request and the work its panes do, and a provider presents a + * composite for that exact request. Neither alone starts anything — a + * registration that never routes and a presentation of a request nobody + * submitted both open nothing — and the supervisor is what makes them converge + * by object identity and installation generation rather than by shape. + * + * It holds what it starts. Each grid runs as a task the supervisor keeps, in a + * scope of its own beneath the operation that submitted it — beneath, because a + * pane's durable identity and the bindings its content reads are the + * expansion's, and a grid parented anywhere else is a grid whose panes belong + * to nobody in particular. + * + * That parentage is also what makes a grid impossible to strand: the submitting + * operation unwinds whenever the call that routed it does, and takes the grid + * with it. The supervisor stopping its own entries at installation teardown, + * and stopping one whose presenting call was cancelled, is therefore belt and + * braces rather than the mechanism — deliberately so, because the mechanism is + * a structural property nobody reading this file can see. + * + * Private to core: nothing reachable by importing this package can submit a + * grid, present for one, or ask what is live. + */ +export interface GridSupervisor { + /** + * Register one authored request and its work. + * + * The entry is removed when the submitting operation unwinds — after that + * operation's own finalizers, so the foreground-terminal lease is released + * before the grid stops being something a provider could present for. + */ + submit(grid: SubmittedGrid): Operation; + /** Run the grid this exact request names, under this exact generation. */ + present( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ): Operation; } -export function createGridRegistry(): GridRegistry { - const grids = new Set(); - return { - live: () => [...grids], - add: (grid) => { - grids.add(grid); - }, - remove: (grid) => { - grids.delete(grid); - }, - }; +/** + * Stop one grid and wait for all of it. + * + * Halting the task settles its panes, runs their finalizers and destroys the + * composite; destroying the scope is what releases everything the grid itself + * established. Both are idempotent here, because a grid may be stopped by the + * presenting call that was cancelled, by installation teardown, or by neither. + */ +function* stopGrid(entry: Entry): Operation { + const task = entry.task; + entry.task = undefined; + if (task !== undefined) { + yield* task.halt(); + } + const destroy = entry.destroy; + entry.destroy = undefined; + if (destroy !== undefined) { + yield* until(destroy()); + } +} + +/** Open the supervisor one execution's grids belong to. */ +export function useGridSupervisor(): Operation { + return resource(function* (provide) { + const entries = new Set(); + + // Installation teardown. Every grid still live is stopped here and waited + // for. Scope parentage already reaches each one, so this is the supervisor + // saying so itself rather than the only thing that says it. + yield* ensure(function* () { + for (const entry of [...entries]) { + yield* stopGrid(entry); + } + }); + + yield* provide({ + *submit(grid: SubmittedGrid): Operation { + const entry: Entry = { ...grid, presented: false, settled: false }; + entries.add(entry); + yield* ensure(() => { + entries.delete(entry); + }); + return entry; + }, + *present( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ): Operation { + const entry = [...entries].find((candidate) => Object.is(candidate.request, request)); + if (entry === undefined) { + throw new TerminalAuthorityError( + "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(entry.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (entry.presented) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + entry.presented = true; + + // A scope of its own beneath the submitter's: the grid inherits the + // expansion's contexts, and the supervisor still holds the task. + const [scope, destroy] = createScope(entry.scope); + entry.destroy = destroy; + entry.task = scope.run(() => entry.run(composite)); + + // A presenting call that unwinds takes its grid with it. The submitter + // unwinding would too, which is why removing this changes no test — + // it is here so the supervisor's ownership does not depend on a + // structural coincidence holding forever. + yield* ensure(function* () { + yield* stopGrid(entry); + }); + + yield* entry.task; + entry.settled = true; + // Settled, so nothing is owed: the scope goes now rather than waiting + // for the provider's own call to end. + entry.task = undefined; + yield* until(destroy()); + entry.destroy = undefined; + }, + }); + }); } /** * Build the authority one provider installation is given. * - * It closes over the installation's generation and its registry, so a factory - * that kept an authority from a superseded installation presents into a - * generation that no longer has the grid it names. + * It closes over the installation's generation, so a factory that kept an + * authority from a superseded installation presents under a generation the + * supervisor no longer has the grid for. Deciding that is the supervisor's, and + * this is the seam that carries the generation to it. */ export function createTerminalAuthority( generation: object, - live: () => readonly LiveGrid[], + present: ( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ) => Operation, ): TerminalGridAuthority { return { *present(request, composite) { - const grid = live().find((candidate) => Object.is(candidate.request, request)); - if (grid === undefined) { - throw new TerminalAuthorityError( - "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + - "belongs to an execution that has finished", - ); - } - if (!Object.is(grid.generation, generation)) { - throw new TerminalAuthorityError( - "this grid request belongs to another terminal provider installation", - ); - } - if (grid.used) { - throw new TerminalAuthorityError( - "this grid request has already been presented — one request opens one grid", - ); - } - grid.used = true; - yield* grid.run(composite); + yield* present(request, composite, generation); }, }; } -/** One execution's terminal installation: its registry and its generation. */ +/** One execution's terminal installation: its supervisor and its generation. */ export interface TerminalInstallation { - readonly registry: GridRegistry; + readonly supervisor: GridSupervisor; /** Identifies this execution's provider installation, and nothing else. */ readonly generation: object; } @@ -136,10 +259,10 @@ const Installation: Context = createContext< * refusal rather than a way in. */ export function* useTerminalInstallation(): Operation { - const registry = createGridRegistry(); + const supervisor = yield* useGridSupervisor(); const generation = {}; - yield* Installation.set({ registry, generation }); - return createTerminalAuthority(generation, () => registry.live()); + yield* Installation.set({ supervisor, generation }); + return createTerminalAuthority(generation, supervisor.present); } /** This execution's terminal installation, or `undefined` outside one. */ diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 45c2d6786..ecfccf15a 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -47,7 +47,6 @@ import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runti import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; -import type { LiveGrid } from "./authority.ts"; import type { PaneTerminal } from "./pane.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; @@ -346,19 +345,17 @@ export function openTerminalGrid( const request = toRequest(layout); let settled: RetainedGrid | undefined; - const grid: LiveGrid = { + // Submitted, not started. The supervisor holds the request and this work + // until a provider presents a composite for this exact object, and the grid + // runs beneath this operation's own scope so its panes keep the durable + // identity of the expansion that wrote them. + const submission = yield* installation.supervisor.submit({ request, generation: installation.generation, - used: false, - settled: false, + scope: yield* useScope(), *run(composite) { settled = yield* presentGrid(request, composite, work, boundary); - grid.settled = true; }, - }; - installation.registry.add(grid); - yield* ensure(() => { - installation.registry.remove(grid); }); // The one foreground-terminal lease, taken before any provider is asked for @@ -372,7 +369,7 @@ export function openTerminalGrid( // Routed, and the answer thrown away. yield* TerminalGrids.operations.open(request); - if (!grid.settled || settled === undefined) { + if (!submission.settled || settled === undefined) { throw new TerminalAuthorityError( "no terminal provider opened this grid — a handler answered without delivering the " + "request to a registered provider", diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index c0d623567..8204c41e0 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -71,7 +71,7 @@ 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 } 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. */ @@ -156,6 +156,67 @@ function refusalOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Open a grid with an owner that crosses its close boundary. + * + * `durableGrid()` supplies this in the document path: a grid proposes close and + * waits, and something has to acknowledge. A row that drives the lifecycle + * directly owns that side itself, or its grid waits for an owner that never + * arrives. + */ +function supervisedGrid(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); + })(); +} + +/** 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 starts, reports its spawn, and is done. */ +function readyPane(opened: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.interactive(function* (spawned) { + opened.push(mark); + spawned(); + }); + }, + }; +} + +/** + * 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.interactive(function* (spawned) { + yield* ensure(() => { + finalized.push(mark); + }); + spawned(); + live.resolve(); + yield* suspend(); + }); + }, + }; +} + /** The controlled interactive child, and a tripwire. */ function useGridComponents( ran: string[], @@ -1158,6 +1219,251 @@ describe("Tier TG — the terminal authority", () => { }); }); +describe("Tier TG — the grid supervisor", () => { + /** + * 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 are + * about ownership: they drive `openTerminalGrid()` directly, so the grid's + * only owner is the operation the row is holding. + */ + function useSupervisedHost( + log: TerminalProviderLog, + options: { + readonly close?: () => Operation; + readonly onAttach?: () => Operation; + readonly onPresent?: ( + present: () => Operation, + request: TerminalGridRequest, + authority: TerminalGridAuthority, + ) => Operation; + readonly seen?: TerminalGridRequest[]; + } = {}, + ): Operation { + return (function* (): Operation { + let generation = 0; + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* TerminalGrids.around( + { + *open([request]) { + options.seen?.push(request); + const composite = yield* prepareControlledComposite( + request, + { + log, + ...(options.close === undefined ? {} : { close: options.close }), + ...(options.onAttach === undefined ? {} : { onAttach: options.onAttach }), + }, + generation++, + ); + const present = () => authority.present(request, composite); + if (options.onPresent === undefined) { + yield* present(); + } else { + yield* options.onPresent(present, request, authority); + } + return undefined; + }, + }, + { at: "min" }, + ); + }); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); + } + + 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 authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + + let refusal: unknown; + try { + yield* supervisedGrid([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 composite existed. + expect(opened).toEqual([]); + expect(log.events).toEqual([]); + expect(log.live.composites).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* useSupervisedHost(log, { + *onPresent(present, request, authority) { + yield* present(); + // The same request again, with a composite of its own, once the grid + // it named has already run. + const again = yield* prepareControlledComposite(request, { log }, 9); + try { + yield* authority.present(request, again); + } catch (error) { + second = error; + } + }, + }); + yield* supervisedGrid([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(TerminalAuthorityError); + 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 authority = yield* useSupervisedHost(log, { seen }); + + yield* supervisedGrid([readyPane(opened, "first")]); + yield* supervisedGrid([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. + const late = yield* prepareControlledComposite(seen[0]!, { log }, 9); + try { + yield* authority.present(seen[0]!, late); + } catch (error) { + stale = error; + } + }); + + expect(opened).toEqual(["first", "second"]); + expect(stale).toBeInstanceOf(TerminalAuthorityError); + expect(refusalOf(stale)).toContain("is not live"); + // Only the settled grid was removed, and only after its own teardown: the + // first composite was destroyed 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* useSupervisedHost(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* supervisedGrid([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.composites).toBe(0); + expect(log.live.attached).toBe(0); + expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); + }); + + it("TS5: installation teardown stops the grid still live, and waits for it", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + const shown = withResolvers(); + + yield* scoped(function* () { + yield* useSupervisedHost(log, { + close: () => suspend(), + // deno-lint-ignore require-yield + *onAttach() { + shown.resolve(); + }, + }); + yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "pane")])); + // Held open: the row leaves the scope with an attached grid still running. + yield* live.operation; + yield* shown.operation; + expect(log.live.attached).toBe(1); + }); + + // The installation went, and took the grid with it — awaited, not abandoned. + expect(finalized).toEqual(["pane"]); + expect(log.live.composites).toBe(0); + expect(log.live.attached).toBe(0); + expect(log.live.shells).toBe(0); + }); + + 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* useSupervisedHost(log, { close: () => suspend() }); + yield* spawn(() => supervisedGrid([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 + // supervisor at all. + try { + yield* supervisedGrid([readyPane([], "second")]); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + expect(finalized).toEqual(["first"]); + expect(log.live.composites).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(); From 08287748fe4d2baaaa823a26ce3d454fd28f35a9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:57:39 -0400 Subject: [PATCH 19/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Present=20a=20provid?= =?UTF-8?q?er's=20grid=20as=20a=20resource,=20and=20make=20acquisition=20r?= =?UTF-8?q?eadiness=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supervisor added at bb5c1255 is gone. Its own evidence disproved it: neither finalizer it registered changed any test, because a grid already runs beneath the expansion that submitted it and structured concurrency already takes it down. What remains is the smallest lookup two sides need to converge, and nothing is named as an owner of anything. Readiness, admission, concurrency and closing are local state and closures inside `runGrid` — no aggregate, no controller, no factory, and `PaneTerminal.use()` is still the only capability pane work receives. `TerminalGridAuthority` was an object standing in for a function. It is now `PresentTerminalGrid`, delivered to the provider factory directly, and `TerminalAuthorityError` is `TerminalGridPresentationError`. Every refusal survives, and all are decided before the provider's grid is acquired. That lookup is now the execution's rather than each installation's. Paired one to one, the generation check could never fire: a superseded installation searched its own empty set and said the request was unknown, which was true of the set and false of the world. Sharing it means a superseded presentation finds the grid it names and is turned away for the reason that actually holds. `TerminalComposite` becomes `TerminalGrid`: this provider's realization of this grid, supplied as a resource. Acquiring it is the grid coming into existence, releasing it is the grid going away — once, after success, startup failure, reader close, provider failure and cancellation alike, each of which now says so. `destroy()` is gone, and with it both the double-destroy guard and the chance of forgetting. The pane capability is one operation. `use()` takes a terminal activity: a resource acquired only after its child has spawned, which is the readiness signal, and whose acquired value settles with that child's outcome. No `spawned` callback, no ordinal, no `LivePane`. A spawn failure fails before acquisition and now says why in its own words. Five refusal rows drive copied, changed, stale, twice-presented and wrong-generation requests against lazy grids that record every effect they could have; each proves the refusal left the record empty. Only what a provider needs is public: the error and the function's shape. Replay is unchanged and still proven: a completed grid contacts no provider, a completed pane restores its outcome without acquiring anything, and only the pane that must resume acquires an activity. --- architecture.md | 127 ++- packages/core/mod.ts | 13 +- packages/core/src/expand.ts | 20 +- packages/core/src/terminal/authority.ts | 271 ----- packages/core/src/terminal/grid.ts | 293 +++--- packages/core/src/terminal/pane.ts | 42 +- packages/core/src/terminal/presentation.ts | 142 +++ packages/core/src/terminal/profile.ts | 17 +- packages/core/src/terminal/provider-api.ts | 40 +- packages/core/tests/terminal-grid.test.ts | 934 +++++++++++++----- packages/runtime/mod.ts | 7 +- packages/runtime/terminal.ts | 220 +++-- .../runtime/tests/terminal-provider.test.ts | 159 +-- specs/executable-mdx-spec.md | 71 +- specs/native-agent-session-launch-spec.md | 45 +- 15 files changed, 1351 insertions(+), 1050 deletions(-) delete mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/presentation.ts diff --git a/architecture.md b/architecture.md index 0c74544eb..be10f394e 100644 --- a/architecture.md +++ b/architecture.md @@ -108,7 +108,7 @@ 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 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 | @@ -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,58 +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 and permits only that provider to present the -grid core issued. No context value, prop, binding, provider result, retained -record, diagnostic, or structurally similar request carries that presentation -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. -The grid lifecycle creates one concrete `PaneTerminal` for each authored -ordinal and passes it to that pane's work. Its `interactive()` operation owns -the pane terminal for one live operation, refuses a concurrent operation in the -same pane, and supplies that operation with the one-use `spawned` -acknowledgement that makes the pane ready. 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. +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 through the same operation. Sequential work in one pane remains +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, live-operation tracking, and closing admission are private state of -the grid lifecycle, not a second public capability model. There is no public -pane-claim or readiness interface, no aggregate grid-claims object, and no -factory or sealing operation for another package to coordinate. The lifecycle -passes only the concrete `PaneTerminal` across the pane-work boundary. Its -`spawned` acknowledgement resolves the private readiness latch from the -runtime's successful child-spawn event and before the launcher 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 acknowledgement is -idempotent, a root launch receives none, and neither the callback nor the latch -enters a request, provider result, process handle, or durable record. +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 @@ -3551,33 +3572,37 @@ 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. + 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, 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. + 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 @@ -3698,7 +3723,7 @@ the retained root stays authoritative, and deciding whether a changed source should be refused rather than ignored belongs to a versioned root boundary that does not exist yet. Until it does, the grid's obligation is the narrower one it can actually discharge: retain the complete authored structure, and open the -structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane +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 @@ -5044,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 d330284d6..082c8113d 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,13 +152,12 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; -export { - createTerminalAuthority, - TerminalAuthorityError, - terminalInstallation, - useTerminalInstallation, -} from "./src/terminal/authority.ts"; -export type { TerminalGridAuthority } from "./src/terminal/authority.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, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 7bc2969f9..ff4e5d401 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2204,8 +2204,8 @@ function* expandTerminalGrid( /** * What one authored pane does once the grid has created its terminal. * - * A self-closing pane runs the host's default shell through that terminal's - * interactive operation, exactly as a paired pane's content does. A paired + * 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 @@ -2217,13 +2217,11 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, - *run(terminal, composite) { - // The shell is this pane's one interactive operation, and the spawn it - // reports 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.interactive((spawned) => - composite.shell(pane.ordinal, spawned), - ); + *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}`); } @@ -2238,7 +2236,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { return { ordinal: pane.ordinal, - *run(terminal, composite) { + *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. @@ -2276,7 +2274,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { ); const text = renderSegments(shown); if (text.length > 0) { - yield* composite.display(pane.ordinal, text); + yield* grid.display(pane.ordinal, text); } }); }, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts deleted file mode 100644 index aaf8eba9e..000000000 --- a/packages/core/src/terminal/authority.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Who is allowed to own a terminal, and what "ready" means (architecture.md - * §Terminal authority). - * - * The provider draws a grid. This decides everything about it that matters: - * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, when a - * pane has actually started, and what the grid settled to. None of that is - * reachable by name. There is no context holding an authority, no member of a - * request that carries one, and no handler return value that produces one — an - * authority reachable by name would be an authority every same-name context and - * every loaded copy could reach. - * - * The request object is the unforgeable carrier. It is issued here for one grid - * under one installation generation, and a request from another grid, an - * earlier generation, or a finished expansion presents nothing at all. - * Presenting one grants the provider its drawing surface and nothing else: it - * says nothing about which Agent session a pane may own, because that is the - * session coordinator's to answer and stays independently authoritative. - * - * What a pane's work may do with its terminal is not decided here. The grid - * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. - */ - -import { createContext, createScope, ensure, resource, until } from "effection"; -import type { Context, Operation, Scope, Task } from "effection"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; - -export class TerminalAuthorityError extends Error { - override name = "TerminalAuthorityError"; -} - -/** - * What a registered provider must present in order to act. - * - * Delivered directly to the provider factory as it installs, and reachable - * nowhere else. Presenting the exact request core issued is what takes the - * terminal leases and runs the grid; anything else — - * a copy, a rebuilt lookalike, an earlier grid's request, a request already - * presented, or one belonging to a superseded installation — authorizes - * nothing. - */ -export interface TerminalGridAuthority { - present(request: TerminalGridRequest, composite: TerminalComposite): Operation; -} - -/** - * One grid the supervisor has been asked to run, before a composite exists. - * - * The scope is the submitting operation's own. A grid keeps the contexts of the - * expansion that wrote it — its durable child above all, which is what gives its - * panes their identities — so the supervisor owns when a grid stops, never what - * it runs under. - */ -export interface SubmittedGrid { - /** The exact request object core issued. Compared by identity, never shape. */ - readonly request: TerminalGridRequest; - /** The installation this grid belongs to. */ - readonly generation: object; - /** Where the grid runs: a scope of its own, beneath the submitter's. */ - readonly scope: Scope; - /** Run the grid on a presented composite. */ - run(composite: TerminalComposite): Operation; -} - -/** What the submitting operation can ask about its own grid afterwards. */ -export interface GridSubmission { - /** Whether a provider presented for this request and the grid ran through. */ - readonly settled: boolean; -} - -/** One submitted grid, and whatever of it is currently live. */ -interface Entry extends SubmittedGrid, GridSubmission { - presented: boolean; - settled: boolean; - task?: Task; - destroy?: () => Promise; -} - -/** - * Who owns the grids one terminal installation has issued. - * - * Two things have to meet before a grid exists: the document submits the - * authored request and the work its panes do, and a provider presents a - * composite for that exact request. Neither alone starts anything — a - * registration that never routes and a presentation of a request nobody - * submitted both open nothing — and the supervisor is what makes them converge - * by object identity and installation generation rather than by shape. - * - * It holds what it starts. Each grid runs as a task the supervisor keeps, in a - * scope of its own beneath the operation that submitted it — beneath, because a - * pane's durable identity and the bindings its content reads are the - * expansion's, and a grid parented anywhere else is a grid whose panes belong - * to nobody in particular. - * - * That parentage is also what makes a grid impossible to strand: the submitting - * operation unwinds whenever the call that routed it does, and takes the grid - * with it. The supervisor stopping its own entries at installation teardown, - * and stopping one whose presenting call was cancelled, is therefore belt and - * braces rather than the mechanism — deliberately so, because the mechanism is - * a structural property nobody reading this file can see. - * - * Private to core: nothing reachable by importing this package can submit a - * grid, present for one, or ask what is live. - */ -export interface GridSupervisor { - /** - * Register one authored request and its work. - * - * The entry is removed when the submitting operation unwinds — after that - * operation's own finalizers, so the foreground-terminal lease is released - * before the grid stops being something a provider could present for. - */ - submit(grid: SubmittedGrid): Operation; - /** Run the grid this exact request names, under this exact generation. */ - present( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ): Operation; -} - -/** - * Stop one grid and wait for all of it. - * - * Halting the task settles its panes, runs their finalizers and destroys the - * composite; destroying the scope is what releases everything the grid itself - * established. Both are idempotent here, because a grid may be stopped by the - * presenting call that was cancelled, by installation teardown, or by neither. - */ -function* stopGrid(entry: Entry): Operation { - const task = entry.task; - entry.task = undefined; - if (task !== undefined) { - yield* task.halt(); - } - const destroy = entry.destroy; - entry.destroy = undefined; - if (destroy !== undefined) { - yield* until(destroy()); - } -} - -/** Open the supervisor one execution's grids belong to. */ -export function useGridSupervisor(): Operation { - return resource(function* (provide) { - const entries = new Set(); - - // Installation teardown. Every grid still live is stopped here and waited - // for. Scope parentage already reaches each one, so this is the supervisor - // saying so itself rather than the only thing that says it. - yield* ensure(function* () { - for (const entry of [...entries]) { - yield* stopGrid(entry); - } - }); - - yield* provide({ - *submit(grid: SubmittedGrid): Operation { - const entry: Entry = { ...grid, presented: false, settled: false }; - entries.add(entry); - yield* ensure(() => { - entries.delete(entry); - }); - return entry; - }, - *present( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ): Operation { - const entry = [...entries].find((candidate) => Object.is(candidate.request, request)); - if (entry === undefined) { - throw new TerminalAuthorityError( - "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(entry.generation, generation)) { - throw new TerminalAuthorityError( - "this grid request belongs to another terminal provider installation", - ); - } - if (entry.presented) { - throw new TerminalAuthorityError( - "this grid request has already been presented — one request opens one grid", - ); - } - entry.presented = true; - - // A scope of its own beneath the submitter's: the grid inherits the - // expansion's contexts, and the supervisor still holds the task. - const [scope, destroy] = createScope(entry.scope); - entry.destroy = destroy; - entry.task = scope.run(() => entry.run(composite)); - - // A presenting call that unwinds takes its grid with it. The submitter - // unwinding would too, which is why removing this changes no test — - // it is here so the supervisor's ownership does not depend on a - // structural coincidence holding forever. - yield* ensure(function* () { - yield* stopGrid(entry); - }); - - yield* entry.task; - entry.settled = true; - // Settled, so nothing is owed: the scope goes now rather than waiting - // for the provider's own call to end. - entry.task = undefined; - yield* until(destroy()); - entry.destroy = undefined; - }, - }); - }); -} - -/** - * Build the authority one provider installation is given. - * - * It closes over the installation's generation, so a factory that kept an - * authority from a superseded installation presents under a generation the - * supervisor no longer has the grid for. Deciding that is the supervisor's, and - * this is the seam that carries the generation to it. - */ -export function createTerminalAuthority( - generation: object, - present: ( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ) => Operation, -): TerminalGridAuthority { - return { - *present(request, composite) { - yield* present(request, composite, generation); - }, - }; -} - -/** One execution's terminal installation: its supervisor and its generation. */ -export interface TerminalInstallation { - readonly supervisor: GridSupervisor; - /** 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 - * authority its providers are installed with. - * - * What travels contextually is the installation — composition data, so a - * document and the components it expands find the same one. The authority does - * not: it is handed to a provider factory directly. A replaced installation - * therefore produces requests the real authority has never heard of, which is a - * refusal rather than a way in. - */ -export function* useTerminalInstallation(): Operation { - const supervisor = yield* useGridSupervisor(); - const generation = {}; - yield* Installation.set({ supervisor, generation }); - return createTerminalAuthority(generation, supervisor.present); -} - -/** This execution's terminal installation, or `undefined` outside one. */ -export function terminalInstallation(): Operation { - return Installation.get(); -} diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index ecfccf15a..5f9a32ec6 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -3,13 +3,13 @@ * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape - * of this module. The composite is built while it is still hidden, every pane + * 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 discards the hidden composite + * 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 → composite presented + * layout recorded → lease → flush → routed to a provider → grid presented * → panes start → readiness barrier → attach * → panes settle independently → reader closes → teardown → lease released * ``` @@ -44,115 +44,20 @@ import { } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalActivity, TerminalGrid, TerminalGridRequest } from "@executablemd/runtime"; -import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; +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"; -/** - * One pane's terminal, and the state only the grid lifecycle may touch. - * - * The terminal is the whole of what crosses into pane work. Everything else - * here answers a question the lifecycle asks about a pane — has it started, - * may it still start anything — and is deliberately not reachable from the - * pane, from a provider, or from a document: a second capability model beside - * `PaneTerminal` would be a second way to own a pane. - */ -interface LivePane { - /** What this pane's work runs on. */ - readonly terminal: PaneTerminal; - /** Settles once this pane has reported its child-spawn event. */ - spawnReported(): Operation; - /** Whether that event has been reported. */ - readonly hasSpawned: boolean; - /** - * Report the event without entering the pane's work. - * - * A pane restored from its retained outcome did start — on the run that - * recorded it — so the barrier is satisfied without a child existing now. - */ - recordSpawn(): void; - /** Refuse any further interactive work in this pane. */ - closeAdmission(): void; -} - -/** - * Build one pane terminal per authored ordinal. - * - * The request is validated against the ordinals it declares before a single - * terminal exists: a request whose panes are not exactly `0..n-1` in order - * describes a grid core did not derive, and answering it would be answering for - * a layout nobody authored. - */ -function livePanes(request: TerminalGridRequest): LivePane[] { - validateOrdinals(request); - - return request.panes.map((pane) => { - const reported = withResolvers(); - let hasSpawned = false; - let live = false; - let closed = false; - - return { - terminal: { - ordinal: pane.ordinal, - *interactive(body: (spawned: () => void) => Operation): Operation { - if (closed) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, - ); - } - if (live) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + - `terminal at a time`, - ); - } - live = true; - try { - return yield* body(() => { - // Idempotent by construction: readiness is a fact about the pane, - // and a provider that reported the same spawn twice has not - // started two panes. - if (hasSpawned) { - return; - } - hasSpawned = true; - reported.resolve(); - }); - } finally { - // Released on every ending, so a pane that settled admits the next - // operation written after it. - live = false; - } - }, - }, - spawnReported: () => reported.operation, - get hasSpawned() { - return hasSpawned; - }, - recordSpawn() { - if (hasSpawned) { - return; - } - hasSpawned = true; - reported.resolve(); - }, - closeAdmission() { - closed = true; - }, - }; - }); -} - function validateOrdinals(request: TerminalGridRequest): void { if (request.panes.length === 0) { - throw new TerminalAuthorityError("a terminal grid request names no panes"); + throw new TerminalGridPresentationError("a terminal grid request names no panes"); } for (const [index, pane] of request.panes.entries()) { if (pane.ordinal !== index) { - throw new TerminalAuthorityError( + 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`, ); @@ -160,18 +65,6 @@ function validateOrdinals(request: TerminalGridRequest): void { } } -/** - * Settle once every pane has reported its spawn event. - * - * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing - * run deadline or parent cancellation is what bounds it. A pane that fails to - * start never reports, so the caller races this against pane failure rather - * than asking the barrier to know about failure. - */ -function* everyPaneStarted(panes: readonly LivePane[]): Operation { - yield* all(panes.map((pane) => pane.spawnReported())); -} - /** * The live boundary reader close crosses (architecture.md §Atomic presentation * and settlement). @@ -261,16 +154,17 @@ export interface RetainedGrid extends Record { * * The caller supplies this because a pane's work is the document's: a paired * pane expands its authored content, and a self-closing one runs the host's - * default shell. Both run through `terminal.interactive()`, and both are - * expected to report a spawn from inside it before anything can attach. + * 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, composite: TerminalComposite): Operation; + run(terminal: PaneTerminal, grid: TerminalGrid): Operation; } /** - * What a pane that never reported a spawn says. + * 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 @@ -324,7 +218,7 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo * Core mints the one request for this expansion, takes the run's foreground * lease, flushes what the document has already produced, registers the request * as live, routes it through the public surface, and then reads what the - * authority settled. The routed answer is discarded on purpose: a handler that + * 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. */ @@ -336,7 +230,7 @@ export function openTerminalGrid( return scoped(function* (): Operation { const installation = yield* terminalInstallation(); if (installation === undefined) { - throw new TerminalAuthorityError( + 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", ); @@ -345,17 +239,22 @@ export function openTerminalGrid( const request = toRequest(layout); let settled: RetainedGrid | undefined; - // Submitted, not started. The supervisor holds the request and this work - // until a provider presents a composite for this exact object, and the grid - // runs beneath this operation's own scope so its panes keep the durable - // identity of the expansion that wrote them. - const submission = yield* installation.supervisor.submit({ + // 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, - scope: yield* useScope(), - *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary); + 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 @@ -369,8 +268,8 @@ export function openTerminalGrid( // Routed, and the answer thrown away. yield* TerminalGrids.operations.open(request); - if (!submission.settled || settled === undefined) { - throw new TerminalAuthorityError( + if (settled === undefined) { + throw new TerminalGridPresentationError( "no terminal provider opened this grid — a handler answered without delivering the " + "request to a registered provider", ); @@ -380,29 +279,79 @@ export function openTerminalGrid( } /** - * Run the grid on the composite a provider presented. + * Run the grid a provider presented, on the resource it supplied. * - * The composite is scope-owned, so every path out of here — success, failure, - * and cancellation alike — destroys exactly the composite that was presented. + * 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 presentGrid( +function runGrid( request: TerminalGridRequest, - composite: TerminalComposite, + provided: Operation, work: readonly PaneWork[], boundary: CloseBoundary, ): Operation { return scoped(function* (): Operation { - // Registered before a single pane starts: a composite that was presented is - // owed a destroy even if the next line is what fails. - yield* ensure(() => composite.destroy()); + // 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; + } + }, + })); - const panes = livePanes(request); // Nothing new is admitted once teardown begins, so a pane that was about to - // start an interactive child is refused rather than racing the close. + // start a terminal activity is refused rather than racing the close. yield* ensure(() => { - closeAdmission(panes); + admitting = false; }); const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); @@ -415,7 +364,7 @@ function presentGrid( let attached = false; for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + yield* grid.update(pane.ordinal, "starting"); } // One durable child per pane, allocated here in authored order, so a pane's @@ -426,10 +375,17 @@ function presentGrid( // satisfies the readiness barrier. const children: Task[] = []; for (const [index, pane] of work.entries()) { - const live = panes[index]!; children.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, live, composite, request, index, closing.operation); + return yield* runPane( + pane, + terminals[index]!, + () => started[index] === true, + grid, + request, + index, + closing.operation, + ); }), ); } @@ -440,10 +396,10 @@ function presentGrid( yield* spawn(function* () { const outcome = yield* task; outcomes[index] = outcome; - // A pane restored from its retained outcome counts as started: it did - // start, on the run that recorded it. - panes[index]!.recordSpawn(); - yield* composite.update(work[index]!.ordinal, outcome.status); + // 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 @@ -455,9 +411,9 @@ function presentGrid( // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane - // already failed from waiting forever for a latch nothing will acknowledge. + // already failed from waiting forever for an acquisition that cannot happen. try { - yield* race([everyPaneStarted(panes), startupFailed.operation]); + 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. @@ -468,15 +424,15 @@ function presentGrid( // it with `running` would tell the reader a finished pane is live. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(pane.ordinal, "running"); + yield* grid.update(pane.ordinal, "running"); } } - yield* composite.attach(); + yield* grid.attach(); attached = true; - // The composite stays visible after its panes settle. The reader leaving is + // The grid stays visible after its panes settle. The reader leaving is // what finishes the grid, not the last pane exiting. - yield* composite.closed(); + yield* grid.closed(); // Proposed, then acknowledged by the owner from inside its own // cancellation-deferred await. Until it is crossed, a cancellation cancels @@ -487,17 +443,17 @@ function presentGrid( // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's - // own teardown after this returns — so the composite is destroyed, the + // 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. - closeAdmission(panes); + 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* composite.update(pane.ordinal, "closed"); + yield* grid.update(pane.ordinal, "closed"); } } for (const [index] of work.entries()) { @@ -513,23 +469,12 @@ function presentGrid( }); } -/** - * Stop every pane admitting new interactive work. - * - * Called before the live panes are asked to stop, so a pane that was about to - * start an interactive child is refused rather than racing the close. - */ -function closeAdmission(panes: readonly LivePane[]): void { - for (const pane of panes) { - pane.closeAdmission(); - } -} - /** Run one pane's work and say what it came to. */ function runPane( pane: PaneWork, - live: LivePane, - composite: TerminalComposite, + terminal: PaneTerminal, + started: () => boolean, + grid: TerminalGrid, request: TerminalGridRequest, index: number, closing: Operation, @@ -541,7 +486,7 @@ function runPane( // 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(live.terminal, composite)); + const running = yield* spawn(() => pane.run(terminal, grid)); const closed = yield* race([ (function* (): Operation { yield* running; @@ -559,7 +504,7 @@ function runPane( yield* running.halt(); return { status: "closed", reason: "" }; } - if (!live.hasSpawned) { + if (!started()) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. return { diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts index f6039ffef..f5f73f165 100644 --- a/packages/core/src/terminal/pane.ts +++ b/packages/core/src/terminal/pane.ts @@ -7,11 +7,11 @@ * is the whole reason a grid exists. So core installs this in each pane's own * scope, and anything interactive asks here first. * - * What travels contextually is the seam, not the authority. The value it holds - * is the one `PaneTerminal` the grid lifecycle created for this ordinal, 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. + * 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 @@ -20,25 +20,31 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; +import type { TerminalActivity } from "@executablemd/runtime"; -/** The pane the current work is running in. */ +/** + * 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 { - /** The pane's identity: its position among the grid's panes, from zero. */ - readonly ordinal: number; /** - * Run one interactive operation as this pane's owner. + * Run one terminal activity as this pane's owner. * - * `body` receives this pane's one-use `spawned` acknowledgement and must call - * it from the runtime's successful child-spawn event, before it waits for the - * child to exit. Acknowledging twice is one event. A body that never spawns - * never reports, and the grid it belongs to never attaches — which is what - * stops a pane that failed to start being presented as one that is running. + * 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. * - * A second interactive operation while one is live on this pane is refused, - * and so is any operation once the grid has stopped admitting work. Sequential - * operations in one pane are ordinary. Two panes do not contend at all. + * 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. */ - interactive(body: (spawned: () => void) => Operation): Operation; + use(activity: TerminalActivity): Operation; } const PaneTerminalContext: Context = createContext< 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 index 05919b653..ed1bd7b13 100644 --- a/packages/core/src/terminal/profile.ts +++ b/packages/core/src/terminal/profile.ts @@ -2,10 +2,11 @@ * 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 owns the generation every request belongs to and the - * registry of the grids it issued — and a provider installed against the - * authority that installation mints. A grid outside one refuses rather than - * presenting something no replay could resume. + * 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. @@ -15,7 +16,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { Execution } from "../execute.ts"; -import { useTerminalInstallation } from "./authority.ts"; +import { useTerminalInstallation } from "./presentation.ts"; import { installTerminalProvider } from "./provider-api.ts"; export interface TerminalGridProfileOptions { @@ -34,7 +35,7 @@ export interface TerminalGridProfileOptions { /** * Install the terminal-grid profile for the executions composed under it. * - * The authority reaches the named provider's factory and nothing else: it is + * 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. @@ -45,12 +46,12 @@ export function installTerminalGridProfile( return Execution.around({ *document([request], next) { yield* scoped(function* () { - const authority = yield* useTerminalInstallation(); + const present = yield* useTerminalInstallation(); if (options.provider !== undefined) { yield* installTerminalProvider( options.provider, { label: options.label ?? options.provider }, - authority, + present, ); } yield* next(request); diff --git a/packages/core/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts index f3537dd99..80d2d4377 100644 --- a/packages/core/src/terminal/provider-api.ts +++ b/packages/core/src/terminal/provider-api.ts @@ -2,7 +2,7 @@ * 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 an authority decision. Returning a factory up the public chain would + * 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. * @@ -12,7 +12,7 @@ * handler sits at the terminal end of that chain and holds its own captured * continuation — a parameter of its generator, carried by no request and no * return value. Through that continuation, and only through it, the invocation - * terminal hands the factory this execution's terminal authority and records + * 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 @@ -29,7 +29,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import { ensure } from "effection"; import type { Operation } from "effection"; -import type { TerminalGridAuthority } from "./authority.ts"; +import type { PresentTerminalGrid } from "./presentation.ts"; /** What a host says about the provider it is installing. */ export interface TerminalProviderOptions { @@ -40,14 +40,14 @@ export interface TerminalProviderOptions { /** * A provider factory installs `TerminalGrids` middleware for its scope. * - * The authority is the second argument because it is delivered, not published: + * 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, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ) => Operation; /** The stable name every loaded copy composes through. */ @@ -128,7 +128,7 @@ export function* registerTerminalProvider( // refuses a copied, reused or stale request here, before the factory // installs anything. const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); - yield* factory(delivery.options, delivery.authority); + yield* factory(delivery.options, delivery.present); yield* next({ intent: "acknowledge", install: call }); return undefined; }, @@ -146,7 +146,7 @@ export function* registerTerminalProvider( */ function deliveryOf(value: unknown): { options: TerminalProviderOptions; - authority: TerminalGridAuthority; + present: PresentTerminalGrid; } { if (typeof value !== "object" || value === null) { throw new TerminalProviderInstallError( @@ -154,32 +154,24 @@ function deliveryOf(value: unknown): { ); } const options = Reflect.get(value, "options"); - const authority = Reflect.get(value, "authority"); + 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 authority !== "object" || authority === null) { + if (typeof present !== "function") { throw new TerminalProviderInstallError( - "the live terminal provider installation carried no authority", + "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"); } - const present = Reflect.get(authority, "present"); - if (typeof present !== "function") { - throw new TerminalProviderInstallError( - "the live terminal provider installation carried no grid authority", - ); - } return { options: { label }, - authority: { - present: (request, composite) => Reflect.apply(present, authority, [request, composite]), - }, + present: (request, grid) => Reflect.apply(present, undefined, [request, grid]), }; } @@ -187,7 +179,7 @@ function deliveryOf(value: unknown): { * Install the provider registered as `name`, under `options`, for the calling * operation. * - * The authority reaches whichever factory answers, and nothing else: a handler + * 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. @@ -195,7 +187,7 @@ function deliveryOf(value: unknown): { export function installTerminalProvider( name: string, options: TerminalProviderOptions, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ): Operation { return (function* (): Operation { const request: TerminalProviderInstallRequest = Object.freeze({ @@ -203,7 +195,7 @@ export function installTerminalProvider( name, options: Object.freeze({ ...options }), }); - const terminal = installationTerminal(request, options, authority); + 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. @@ -224,7 +216,7 @@ export function installTerminalProvider( function installationTerminal( request: TerminalProviderInstallRequest, options: TerminalProviderOptions, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ): { install: (call: TerminalProviderCall) => Operation; acknowledged: () => boolean; @@ -253,7 +245,7 @@ function installationTerminal( ); } state = "inspected"; - return { options, authority }; + return { options, present }; } if (state !== "inspected") { throw new TerminalProviderInstallError( diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 8204c41e0..ec78d0f05 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,6 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * (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 @@ -10,14 +10,14 @@ * take the same wall clock. * * Readiness is the claim these rows care about most, so it is always driven - * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and "did - * some work" be told apart at all. + * 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 when something in it starts and reports a spawn. - * Until the native-launch Story lands, `` is what a suite writes - * to be that something — and it reaches the pane through the same seam a real - * `` will. + * 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"; @@ -42,15 +42,17 @@ import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { + controlledTerminalGrid, installControlledLauncher, - prepareControlledComposite, reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; import type { - ControlledCompositeOptions, - TerminalComposite, + ControlledTerminalGridOptions, + TerminalActivity, + TerminalShellOutcome, + TerminalGrid, TerminalGridRequest, TerminalProviderLog, TerminalProviderResources, @@ -59,8 +61,11 @@ import type { import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { TerminalAuthorityError, useTerminalInstallation } from "../src/terminal/authority.ts"; -import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +import { + TerminalGridPresentationError, + useTerminalInstallation, +} from "../src/terminal/presentation.ts"; +import type { PresentTerminalGrid } from "../src/terminal/presentation.ts"; import { installTerminalProvider, registerTerminalProvider, @@ -83,7 +88,7 @@ interface DocumentRun { requests: TerminalGridRequest[]; /** What each pane displayed. */ shown: Map; - /** Everything the composite did, in order. */ + /** Everything the provider's grid did, in order. */ events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; @@ -114,6 +119,48 @@ function useDir(): Operation { }); } +/** 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. * @@ -157,14 +204,14 @@ function refusalOf(error: unknown): string { } /** - * Open a grid with an owner that crosses its close boundary. + * Open a grid and own the other side of its close boundary. * - * `durableGrid()` supplies this in the document path: a grid proposes close and + * `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 that side itself, or its grid waits for an owner that never + * directly owns it here instead, or its grid waits for an owner that never * arrives. */ -function supervisedGrid(work: readonly PaneWork[]): Operation { +function openGridWithCloseOwner(work: readonly PaneWork[]): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); yield* spawn(function* () { @@ -175,6 +222,31 @@ function supervisedGrid(work: readonly PaneWork[]): Operation { })(); } +/** + * 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, @@ -182,15 +254,12 @@ const ONE_PANE = { cells: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], }; -/** Pane work that starts, reports its spawn, and is done. */ +/** 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.interactive(function* (spawned) { - opened.push(mark); - spawned(); - }); + yield* terminal.use(startsAndSettles(() => opened.push(mark))); }, }; } @@ -205,14 +274,17 @@ function holdingPane(live: { resolve(): void }, finalized: string[], mark: strin return { ordinal: 0, *run(terminal) { - yield* terminal.interactive(function* (spawned) { - yield* ensure(() => { - finalized.push(mark); - }); - spawned(); - live.resolve(); - yield* suspend(); - }); + 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()); + }), + ); }, }; } @@ -237,9 +309,7 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - }); + yield* pane.use(startsAndSettles()); return ""; }, }, @@ -266,29 +336,45 @@ function useGridComponents( // into a failed assertion instead of a hung suite. name: "Concurrent", origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { + 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"); } - yield* pane.interactive(function* (spawned) { - probe.marks.push(`enter:${pane.ordinal}`); - probe.entered(); - const together = yield* race([ - (function* (): Operation { - yield* probe.overlapped(); - return true; - })(), - (function* (): Operation { - yield* sleep(2000); - return false; - })(), - ]); - probe.marks.push(`together:${pane.ordinal}:${together}`); - spawned(); - }); - probe.marks.push(`leave:${pane.ordinal}`); + 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 ""; }, }, @@ -303,20 +389,21 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - try { - yield* pane.interactive(function* () { - probe.marks.push("second entered"); - }); - } catch (error) { - probe.refusals.push(refusalOf(error)); - } - }); + 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.interactive(function* () { - probe.marks.push("sequential"); - }); + 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); @@ -324,8 +411,8 @@ function useGridComponents( }, }, { - // Reports the same spawn twice. One pane started, not two. - name: "TwiceSpawned", + // Acquires two activities in turn. One pane started, not two. + name: "SettlesAtOnce", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { @@ -333,16 +420,13 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - spawned(); - probe.marks.push("spawned twice"); - }); + // 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 reports a spawn: doing work is not starting. + // Interactive work that never acquires an activity: doing work is not starting. name: "Quiet", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, @@ -351,9 +435,8 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* () { - probe.marks.push("worked without spawning"); - }); + // Fails before acquisition: a child that never spawned. + yield* pane.use(neverStarts(() => probe.marks.push("tried to start"))); return ""; }, }, @@ -367,11 +450,15 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - yield* sleep(25); - slowMarks.push("ready:slow"); - spawned(); - }); + // 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 ""; }, }, @@ -417,26 +504,26 @@ function useGridComponents( } /** - * Register a controlled provider that presents through the authority it was + * Register a controlled provider that presents through the function it was * delivered. * - * This is the whole handshake in miniature: the factory receives the authority - * as an argument, prepares a composite of its own, and presents the exact + * 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: ControlledCompositeOptions & { + 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 authority for a later, unrouted use. */ - readonly capture?: (authority: TerminalGridAuthority) => void; + /** Keep the presentation function for a later, unrouted use. */ + readonly capture?: (present: PresentTerminalGrid) => void; } = {}, ): Operation { let generation = 0; - return registerTerminalProvider("controlled", function* (_settings, authority) { - options.capture?.(authority); + return registerTerminalProvider("controlled", function* (_settings, present) { + options.capture?.(present); yield* TerminalGrids.around( { *open([request]) { @@ -444,8 +531,10 @@ function useControlledProvider( // Answers, presents nothing. Core must not believe this. return { presented: true }; } - const composite = yield* prepareControlledComposite(request, options, generation++); - yield* authority.present(options.substitute?.(request) ?? request, composite); + yield* present( + options.substitute?.(request) ?? request, + controlledTerminalGrid(request, options, generation++), + ); return undefined; }, }, @@ -457,13 +546,13 @@ function useControlledProvider( /** Everything a controlled grid host installs, for an in-process grid. */ function useGridHost( options: Parameters[0] = {}, -): Operation { - return (function* (): Operation { +): Operation { + return (function* (): Operation { yield* installControlledLauncher(); yield* useControlledProvider(options); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); - return authority; + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; })(); } @@ -485,7 +574,7 @@ function runDocument( options: { provider?: boolean; stream?: InMemoryStream; - composite?: ControlledCompositeOptions; + grid?: ControlledTerminalGridOptions; /** Where `` records that it started. */ slowMarks?: string[]; /** Props this run supplies. Props are not restored across a continuation. */ @@ -524,7 +613,7 @@ function runDocument( const settled = withResolvers(); let expected = 0; let done = 0; - const supplied = options.composite ?? {}; + const supplied = options.grid ?? {}; if (options.provider !== false) { yield* useControlledProvider({ ...supplied, @@ -615,7 +704,7 @@ function runInterrupted( stream: InMemoryStream, options: { provider?: boolean; - shell?: ControlledCompositeOptions["shell"]; + 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. */ @@ -754,16 +843,22 @@ function runInterrupted( : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { - shell: function* (ordinal: number, spawned: () => void) { - spawned(); - if (ordinal !== options.shellFailsAfterAttach) { - return { exitCode: 0 }; - } - // Started, so the grid attaches; it fails only afterwards, which - // is the failure a grid contains as a pane status. - yield* attached.operation; - return { exitCode: 1 }; - }, + 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 ? {} @@ -854,12 +949,12 @@ const PANES = [ '', ]; -describe("Tier TG — the terminal authority", () => { +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, { composite: {} as ControlledCompositeOptions }); + 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 @@ -888,7 +983,7 @@ describe("Tier TG — the terminal authority", () => { it("TA2: presenting a rebuilt request authorizes nothing", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, GRID, { - composite: {}, + grid: {}, }); expect(run.outcome.ok).toBe(true); @@ -897,7 +992,7 @@ describe("Tier TG — the terminal authority", () => { const ran: string[] = []; yield* useGridComponents(ran); yield* installControlledLauncher(); - // Same members, different object. Identity is what the authority reads. + // Same members, different object. Identity is what presentation reads. yield* useControlledProvider({ substitute: (request) => ({ columns: request.columns, @@ -938,9 +1033,9 @@ describe("Tier TG — the terminal authority", () => { expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TA4: an authority kept past its grid authorizes nothing", function* () { + it("TA4: a presentation function kept past its grid presents nothing", function* () { const dir = yield* useDir(); - let kept: TerminalGridAuthority | undefined; + let kept: PresentTerminalGrid | undefined; const run = yield* runDocument(dir, GRID, {}); expect(run.outcome.ok).toBe(true); @@ -949,7 +1044,7 @@ describe("Tier TG — the terminal authority", () => { const ran: string[] = []; yield* useGridComponents(ran); yield* installControlledLauncher(); - yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* useControlledProvider({ capture: (present) => (kept = present) }); yield* installTerminalGridProfile({ provider: "controlled" }); const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); yield* execution; @@ -959,71 +1054,51 @@ describe("Tier TG — the terminal authority", () => { // The execution has finished, so the request it issued is no longer live. let refusal: unknown; yield* scoped(function* () { - const composite = yield* prepareControlledComposite( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - {}, - ); + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; try { - yield* kept!.present( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - composite, - ); + yield* kept!(asked, controlledTerminalGrid(asked, {})); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TA5: an authority from another installation generation authorizes nothing", function* () { + 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 authority names a generation the live registry no longer has. + // first's function names a generation the shared lookup no longer matches. const stale = yield* scoped(function* () { return yield* useTerminalInstallation(); }); yield* useTerminalInstallation(); - const composite = yield* prepareControlledComposite( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - {}, - ); + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; try { - yield* stale.present( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - composite, - ); + yield* stale(asked, controlledTerminalGrid(asked, {})); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + 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 authority = yield* useTerminalInstallation(); + const present = yield* useTerminalInstallation(); // A handler that answers the install request without delivering it to a // registered provider. yield* registerTerminalProvider("real", function* () {}); @@ -1034,7 +1109,7 @@ describe("Tier TG — the terminal authority", () => { }, }); try { - yield* installTerminalProvider("real", { label: "real" }, authority); + yield* installTerminalProvider("real", { label: "real" }, present); } catch (error) { refusal = error; } @@ -1051,8 +1126,8 @@ describe("Tier TG — the terminal authority", () => { dir, [ "", - '', - '', + '', + '', "", "", ].join("\n"), @@ -1060,12 +1135,12 @@ describe("Tier TG — the terminal authority", () => { ); expect(run.outcome.ok).toBe(true); - // Each pane waited inside its own interactive body until the other was - // inside one too. Panes that contended could not both report this. - expect(probe.marks).toContain("together:0:true"); - expect(probe.marks).toContain("together:1:true"); - // And both were inside before either left. - expect(probe.marks.indexOf("enter:1")).toBeLessThan(probe.marks.indexOf("leave:0")); + // 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* () { @@ -1112,38 +1187,38 @@ describe("Tier TG — the terminal authority", () => { let refusal: unknown; yield* scoped(function* () { try { - yield* kept!.interactive(function* () {}); + yield* kept!.use(startsAndSettles()); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); expect(refusalOf(refusal)).toContain("its grid has stopped admitting"); }); - it("TA10: only a reported spawn is readiness, and reporting twice is one event", function* () { + 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 }, ); - // Two acknowledgements are one started pane: the grid attached once and - // settled, rather than waiting for a second pane nobody authored. + // 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("spawned twice"); + expect(probe.marks).toContain("started and settled"); expect(run.events).toContain("attach:0"); }); - it("TA11: interactive work that reports no spawn has not started", function* () { + 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( @@ -1157,9 +1232,11 @@ describe("Tier TG — the terminal authority", () => { { probe }, ); - // The pane owned its terminal and did work in it. Neither is starting. - expect(probe.marks).toContain("worked without spawning"); - expect(failureOf(run)).toContain("finished without starting anything interactive"); + // 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"); }); @@ -1207,7 +1284,7 @@ describe("Tier TG — the terminal authority", () => { } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + 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"); @@ -1219,16 +1296,248 @@ describe("Tier TG — the terminal authority", () => { }); }); -describe("Tier TG — the grid supervisor", () => { +/** + * 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 are - * about ownership: they drive `openTerminalGrid()` directly, so the grid's - * only owner is the operation the row is holding. + * 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 useSupervisedHost( + function usePresentingHost( log: TerminalProviderLog, options: { readonly close?: () => Operation; @@ -1236,20 +1545,20 @@ describe("Tier TG — the grid supervisor", () => { readonly onPresent?: ( present: () => Operation, request: TerminalGridRequest, - authority: TerminalGridAuthority, + presentAny: PresentTerminalGrid, ) => Operation; readonly seen?: TerminalGridRequest[]; } = {}, - ): Operation { - return (function* (): Operation { + ): Operation { + return (function* (): Operation { let generation = 0; yield* installControlledLauncher(); - yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* registerTerminalProvider("controlled", function* (_settings, present) { yield* TerminalGrids.around( { *open([request]) { options.seen?.push(request); - const composite = yield* prepareControlledComposite( + const grid = controlledTerminalGrid( request, { log, @@ -1258,11 +1567,11 @@ describe("Tier TG — the grid supervisor", () => { }, generation++, ); - const present = () => authority.present(request, composite); + const presentThis = () => present(request, grid); if (options.onPresent === undefined) { - yield* present(); + yield* presentThis(); } else { - yield* options.onPresent(present, request, authority); + yield* options.onPresent(presentThis, request, present); } return undefined; }, @@ -1270,9 +1579,9 @@ describe("Tier TG — the grid supervisor", () => { { at: "min" }, ); }); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); - return authority; + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; })(); } @@ -1295,22 +1604,22 @@ describe("Tier TG — the grid supervisor", () => { { at: "min" }, ); }); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); let refusal: unknown; try { - yield* supervisedGrid([readyPane(opened, "a")]); + 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 composite existed. + // Submitted and never presented: no pane ran and no grid existed. expect(opened).toEqual([]); expect(log.events).toEqual([]); - expect(log.live.composites).toBe(0); + expect(log.live.grids).toBe(0); }); it("TS2: one request opens one grid, however often it is presented", function* () { @@ -1319,26 +1628,25 @@ describe("Tier TG — the grid supervisor", () => { let second: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { - *onPresent(present, request, authority) { + yield* usePresentingHost(log, { + *onPresent(present, request, presentAny) { yield* present(); - // The same request again, with a composite of its own, once the grid + // The same request again, with a grid of its own, once the grid // it named has already run. - const again = yield* prepareControlledComposite(request, { log }, 9); try { - yield* authority.present(request, again); + yield* presentAny(request, controlledTerminalGrid(request, { log }, 9)); } catch (error) { second = error; } }, }); - yield* supervisedGrid([readyPane(opened, "a")]); + 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(TerminalAuthorityError); + expect(second).toBeInstanceOf(TerminalGridPresentationError); expect(refusalOf(second)).toContain("already been presented"); }); @@ -1349,26 +1657,25 @@ describe("Tier TG — the grid supervisor", () => { let stale: unknown; yield* scoped(function* () { - const authority = yield* useSupervisedHost(log, { seen }); + const present = yield* usePresentingHost(log, { seen }); - yield* supervisedGrid([readyPane(opened, "first")]); - yield* supervisedGrid([readyPane(opened, "second")]); + 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. - const late = yield* prepareControlledComposite(seen[0]!, { log }, 9); try { - yield* authority.present(seen[0]!, late); + yield* present(seen[0]!, controlledTerminalGrid(seen[0]!, { log }, 9)); } catch (error) { stale = error; } }); expect(opened).toEqual(["first", "second"]); - expect(stale).toBeInstanceOf(TerminalAuthorityError); + 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 composite was destroyed before the second was ever prepared, and + // 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"); @@ -1382,7 +1689,7 @@ describe("Tier TG — the grid supervisor", () => { let refusal: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { + yield* usePresentingHost(log, { // The reader never leaves, so the grid stays live until something stops // it. close: () => suspend(), @@ -1395,7 +1702,7 @@ describe("Tier TG — the grid supervisor", () => { }); try { - yield* supervisedGrid([holdingPane(live, finalized, "pane")]); + yield* openGridWithCloseOwner([holdingPane(live, finalized, "pane")]); } catch (error) { refusal = error; } @@ -1404,37 +1711,108 @@ describe("Tier TG — the grid supervisor", () => { // 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.composites).toBe(0); + expect(log.live.grids).toBe(0); expect(log.live.attached).toBe(0); expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); }); - it("TS5: installation teardown stops the grid still live, and waits for it", function* () { + 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(); - const shown = withResolvers(); + let heldWhileLive = -1; yield* scoped(function* () { - yield* useSupervisedHost(log, { - close: () => suspend(), - // deno-lint-ignore require-yield - *onAttach() { - shown.resolve(); + 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* spawn(() => supervisedGrid([holdingPane(live, finalized, "pane")])); - // Held open: the row leaves the scope with an attached grid still running. - yield* live.operation; - yield* shown.operation; - expect(log.live.attached).toBe(1); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); }); - // The installation went, and took the grid with it — awaited, not abandoned. - expect(finalized).toEqual(["pane"]); - expect(log.live.composites).toBe(0); - expect(log.live.attached).toBe(0); - expect(log.live.shells).toBe(0); + 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* () { @@ -1444,15 +1822,15 @@ describe("Tier TG — the grid supervisor", () => { let refusal: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { close: () => suspend() }); - yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "first")])); + 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 - // supervisor at all. + // lookup at all. try { - yield* supervisedGrid([readyPane([], "second")]); + yield* openGridWithCloseOwner([readyPane([], "second")]); } catch (error) { refusal = error; } @@ -1460,7 +1838,7 @@ describe("Tier TG — the grid supervisor", () => { expect(refusalOf(refusal)).toContain("owns the terminal at a time"); expect(finalized).toEqual(["first"]); - expect(log.live.composites).toBe(0); + expect(log.live.grids).toBe(0); }); }); @@ -1494,6 +1872,8 @@ describe("Tier TG — a grid written in a document", () => { { 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* () { @@ -1535,7 +1915,7 @@ describe("Tier TG — a grid written in a document", () => { "", ].join("\n"), { - composite: { + 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. @@ -1702,9 +2082,9 @@ describe("Tier TG — a grid written in a document", () => { describe("Tier TG — startup, settlement and teardown", () => { const TWO = ["", ...PANES, "", ""].join("\n"); - it("TG9: nothing attaches until every pane has reported a spawn", function* () { + it("TG9: nothing attaches until every pane has acquired a terminal activity", function* () { const dir = yield* useDir(); - // One ordered record the pane and the composite both write to, so + // 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. @@ -1720,17 +2100,16 @@ describe("Tier TG — startup, settlement and teardown", () => { ].join("\n"), { slowMarks: timeline, - composite: { + grid: { // deno-lint-ignore require-yield *onAttach() { timeline.push("attach"); }, - // deno-lint-ignore require-yield - *shell(_ordinal, spawned) { - timeline.push("ready:shell"); - spawned(); - return { exitCode: 0 }; - }, + shell: () => + resource>(function* (provide) { + timeline.push("ready:shell"); + yield* provide(done({ exitCode: 0 })); + }), }, }, ); @@ -1755,9 +2134,10 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(failureOf(run)).toContain("finished without starting anything interactive"); - // No partial grid was ever shown, and the hidden composite was destroyed. + // 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"); - expect(run.events).toContain("destroy:0"); + expectReleasedOnce(run); }); it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { @@ -1768,19 +2148,18 @@ describe("Tier TG — startup, settlement and teardown", () => { "\n", ), { - composite: { - // Reports its spawn and returns in the same breath. - // deno-lint-ignore require-yield - *shell(_ordinal, spawned) { - spawned(); - return { exitCode: 0 }; - }, + 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 at the spawn event, so the grid attached; settled straight after, + // 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"); @@ -1793,7 +2172,7 @@ describe("Tier TG — startup, settlement and teardown", () => { it("TG9: a preparation failure starts no pane at all", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { - composite: { + grid: { // deno-lint-ignore require-yield *onPrepare() { throw new Error("no pane endpoint could be created"); @@ -1805,18 +2184,18 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.shown.size).toBe(0); }); - it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + it("TG9: an attach failure shows no partial grid and releases it", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { - composite: { + grid: { // deno-lint-ignore require-yield *onAttach() { - throw new Error("the composite could not be shown"); + throw new Error("the grid could not be shown"); }, }, }); - expect(failureOf(run)).toContain("the composite could not be shown"); + expect(failureOf(run)).toContain("the grid could not be shown"); expect(run.events).toContain("destroy:0"); }); @@ -1852,7 +2231,7 @@ describe("Tier TG — startup, settlement and teardown", () => { "", ].join("\n"), { - composite: { + grid: { // The reader leaves while the pane is still live. close: immediateClose(), }, @@ -1864,14 +2243,16 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.events).toContain("state:0:0:closed"); const destroyed = run.events.indexOf("destroy:0"); expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); - // The following sibling started only after the composite came down. + // 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, { - composite: { + grid: { // The reader's close operation is where an active provider can fail. // deno-lint-ignore require-yield *close() { @@ -1881,7 +2262,8 @@ describe("Tier TG — startup, settlement and teardown", () => { }); expect(failureOf(run)).toContain("the terminal provider lost its server"); - expect(run.events).toContain("destroy:0"); + // A provider that failed mid-grid still had its grid released exactly once. + expectReleasedOnce(run); }); }); @@ -2034,7 +2416,7 @@ describe("Tier TG — durability and replay", () => { expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); }); - it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { + it("TG16: an interrupted grid acquires a fresh provider grid rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -2046,7 +2428,7 @@ describe("Tier TG — durability and replay", () => { const second = yield* runInterrupted(dir, GRID, stream); - // A fresh composite, built by this run. + // A fresh provider grid, acquired by this run. expect(second.requests).toHaveLength(1); expect(second.events).toContain("prepare:0:2x1"); }); @@ -2058,11 +2440,17 @@ describe("Tier TG — durability and replay", () => { '', '', ]); - const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { - spawned(); - yield* suspend(); - return { exitCode: 0 }; - }; + 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, { @@ -2360,8 +2748,8 @@ describe("Tier TG — durability and replay", () => { // The provider's counters went up and came back down. Reading them only at // the end would be true of counters that never moved. - expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); - expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + 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); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c9a9d60d1..9db92312b 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,7 +147,7 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - prepareControlledComposite, + controlledTerminalGrid, TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, @@ -155,8 +155,9 @@ export { TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledCompositeOptions, - TerminalComposite, + ControlledTerminalGridOptions, + TerminalActivity, + TerminalGrid, TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index b03275a8f..93e573e7b 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -5,7 +5,7 @@ * This is not the native launcher. A launch hands **one** child the whole * foreground terminal and waits for it; a grid divides that terminal into * several panes that stay interactive at the same time, each with its own - * lifetime. tmux is one way to do that, a host-native composite UI is another, + * 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. @@ -13,18 +13,19 @@ * **This surface is routing, and only routing.** Middleware here may observe, * narrow, refuse, wrap or delegate one grid request. What it cannot do is open * a grid: `open()` answers `unknown`, and the answer is thrown away. The - * capability that takes the terminal leases, mints pane claims and settles a - * grid is a non-contextual authority delivered straight to the registered + * 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 composite while it is hidden, core starts the - * authored panes and waits for every one of them to report a spawn, and only - * then is anything attached. + * 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. */ @@ -52,7 +53,7 @@ export interface TerminalPaneRequest { * environment. It is what the author wrote, resolved. * * It is also **one-use and identity-bearing**. Core mints exactly one of these - * per grid expansion and the authority compares the object it is presented with + * 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. */ @@ -79,18 +80,37 @@ export interface TerminalShellOutcome { } /** - * One prepared, still-hidden grid. + * One terminal activity: something interactive a pane runs. * - * Everything here belongs to the one preparation that produced it. A composite - * is never reused across expansions, and a provider that hands the same one - * back twice has handed back a grid the second expansion did not ask for. + * 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 TerminalComposite { +export interface TerminalGrid { /** - * Show the composite. Called once, and only after every pane is ready. + * 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 - * preparation, so the reader never sees a grid fill in. + * acquisition, so the reader never sees a grid fill in. */ attach(): Operation; /** @@ -112,32 +132,21 @@ export interface TerminalComposite { */ display(ordinal: number, text: string): Operation; /** - * Start the host's default interactive shell in one pane and report how it - * ended. + * 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. - * - * `spawned` is the pane's readiness latch, and calling it is the only thing - * that makes this pane ready. Call it from the runtime's successful - * child-spawn event and before waiting for the child to exit — so a shell - * that starts and exits at once is both ready and settled, while a shell that - * never started leaves the latch alone and the grid never attaches. + * 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, spawned: () => void): Operation; + shell(ordinal: number): TerminalActivity; /** - * Settle when the reader closes or leaves the composite. + * 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; - /** - * Take the composite down and give the root terminal back. - * - * Called exactly once for every composite that was prepared, including one - * discarded before it ever attached. - */ - destroy(): Operation; } /** The stable name every loaded copy composes through. */ @@ -160,7 +169,7 @@ 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 the authority settled + * evidence that a grid was opened, and core reads what presentation settled * instead of what a handler said. */ open(request: TerminalGridRequest): Operation; @@ -181,11 +190,11 @@ export const TerminalGrids: Api = createApi(TE }); /** - * Everything one controlled composite did, in the order it did it. + * 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 teardown destroyed exactly the composite it prepared. + * barrier, and that release took down exactly the grid it prepared. */ export interface TerminalProviderLog { readonly events: string[]; @@ -199,7 +208,7 @@ export interface TerminalProviderLog { /** * What the provider still holds, counted rather than described. * - * Each one goes up when the composite takes something and down when it gives + * 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. @@ -207,13 +216,13 @@ export interface TerminalProviderLog { readonly live: TerminalProviderResources; } -/** What one controlled composite holds at a moment, by kind. */ +/** What one controlled provider holds at a moment, by kind. */ export interface TerminalProviderResources { - /** Composites prepared and not yet destroyed. */ - composites: number; - /** Composites attached and not yet destroyed. */ + /** Grids acquired and not yet released. */ + grids: number; + /** Grids attached and not yet released. */ attached: number; - /** Shells started whose outcome has not been returned. */ + /** Shell activities acquired and not yet released. */ shells: number; } @@ -222,21 +231,21 @@ export function terminalProviderLog(): TerminalProviderLog { return { events: [], shown: new Map(), - live: { composites: 0, attached: 0, shells: 0 }, + live: { grids: 0, attached: 0, shells: 0 }, }; } /** - * What a controlled composite does instead of opening a terminal. + * 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 composite exists, `onAttach` fails the barrier, `shell` - * decides what a self-closing pane's shell did and whether it started at all, - * and `close` is the operation the grid waits on, so a suite controls exactly - * when the reader leaves. + * 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 ControlledCompositeOptions { - /** Appended to as the composite works, so ordering is read rather than timed. */ +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; @@ -248,32 +257,64 @@ export interface ControlledCompositeOptions { * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * 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; + })(); +} + /** - * Prepare one composite that presents nothing and records everything. + * One controlled grid that presents nothing and records everything. * - * It answers the whole contract — attach, update, display, shell, close, - * destroy — so a suite exercises core's lifecycle without a terminal, a - * multiplexer, or a process anywhere in it. + * 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 prepareControlledComposite( +export function controlledTerminalGrid( request: TerminalGridRequest, - options: ControlledCompositeOptions = {}, + options: ControlledTerminalGridOptions = {}, generation = 0, -): Operation { - return (function* (): Operation { +): 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.composites++; - let destroyed = false; + log.live.grids++; let attached = false; - return { + + // 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(); @@ -291,24 +332,29 @@ export function prepareControlledComposite( *display(ordinal, text) { log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - log.live.shells++; - try { + shell(ordinal) { + return resource(function* (provideOutcome) { if (options.shell) { - return yield* options.shell(ordinal, spawned); + // 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: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - } finally { - // Counted down however the shell left — returned, thrown, or - // cancelled — because a shell a suite can still find is a shell the - // provider is still holding. - log.live.shells--; - } + // 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) { @@ -316,24 +362,6 @@ export function prepareControlledComposite( } log.events.push(`closed:${generation}`); }, - *destroy() { - // Destroying twice would make the record say a composite was taken down - // more times than it was built, which is exactly the ordering claim a - // suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - log.live.composites--; - if (attached) { - attached = false; - log.live.attached--; - } - }, - }; - })(); + }); + }); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 3c88c9d83..9dbbe09aa 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,11 +1,11 @@ /** - * Tier TG — the terminal grid routing surface and the composite contract - * (architecture.md §Terminal authority, spec §6.21). + * 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 is an authority. The routing surface is + * 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 composite is what a provider prepares, and its contract is + * 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 @@ -16,17 +16,17 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped } from "effection"; +import { resource, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { - prepareControlledComposite, + controlledTerminalGrid, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalGridRequest } 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 { @@ -147,35 +147,39 @@ describe("Tier TG — the routing surface", () => { }); }); -describe("Tier TG — the composite contract", () => { - it("TP3: a prepared composite presents nothing until it is attached", function* () { +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* prepareControlledComposite(request(), { log }); + yield* controlledTerminalGrid(request(), { log }); return [...log.events]; }); - // A composite the reader can see before every pane is ready is the one - // thing atomic startup forbids. + // 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 destroy record in order", function* () { + it("TP3: attach, update, display, shell and release record in order", function* () { const log = terminalProviderLog(); - const spawns: number[] = []; + let outcome: TerminalShellOutcome | undefined; yield* scoped(function* () { - const composite = yield* prepareControlledComposite(request(), { log }); - yield* composite.update(0, "starting"); - yield* composite.display(0, "pane text"); - yield* composite.update(0, "running"); - yield* composite.shell(1, () => spawns.push(1)); - yield* composite.attach(); - yield* composite.update(0, "succeeded"); - yield* composite.closed(); - yield* composite.destroy(); + 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", @@ -187,34 +191,43 @@ describe("Tier TG — the composite contract", () => { "destroy:0", ]); expect(log.shown.get(0)).toBe("pane text"); - // The default shell starts, and says so through the latch it was handed: - // readiness is reported by the shell rather than assumed by the grid. - expect(spawns).toEqual([1]); + expect(outcome).toEqual({ exitCode: 0 }); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); }); - it("TP4: a shell that never starts never reports a spawn", function* () { - const spawns: number[] = []; - const outcome = yield* scoped(function* () { - const composite = yield* prepareControlledComposite(request(), { - // deno-lint-ignore require-yield - *shell() { - // No spawn event: nothing started, so nothing is acknowledged. - return { exitCode: 127 }; - }, + 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"); + }), }); - return yield* composite.shell(1, () => spawns.push(1)); + try { + yield* yield* grid.shell(1); + } catch (error) { + refusal = error; + } }); - expect(outcome).toEqual({ exitCode: 127 }); - expect(spawns).toEqual([]); + 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 composite to tear down", function* () { + it("TP4: a preparation failure leaves no grid to release", function* () { const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { try { - yield* prepareControlledComposite(request(), { + yield* controlledTerminalGrid(request(), { log, // deno-lint-ignore require-yield *onPrepare() { @@ -229,39 +242,65 @@ describe("Tier TG — the composite contract", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so nothing is owed a - // destroy. + // The failure happened before the grid existed, so nothing is owed a + // release. expect(log.events).toEqual([]); }); - it("TP4: a composite refuses to be destroyed twice", function* () { - let refusal: unknown; + 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* () { - const composite = yield* prepareControlledComposite(request()); - yield* composite.destroy(); try { - yield* composite.destroy(); - } catch (error) { - refusal = error; + 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. } }); - // Teardown ordering is only readable if a double destroy is loud. A silent - // second destroy would let a suite prove an ordering that never held. - expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + // 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 preparation is its own composite", function* () { + it("TP5: each acquisition is its own grid", function* () { const log = terminalProviderLog(); yield* scoped(function* () { - const first = yield* prepareControlledComposite(request(), { log }, 0); - const second = yield* prepareControlledComposite(request(), { log }, 1); - yield* first.destroy(); - yield* second.destroy(); + 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 composite - // back would have presented the second expansion's grid as the first's. - expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + // 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 82b33edd5..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,42 +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. - -The grid lifecycle owns one private readiness latch for each pane. It passes -that pane's work one concrete `PaneTerminal`; `PaneTerminal.interactive()` -supplies the live operation with a one-use `spawned` acknowledgement. The -pane-scoped launcher calls it 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, -public request, provider return, process result, or durable record. - -The same private lifecycle state admits at most one interactive operation in a -pane and stops admitting new work when the grid closes. Different pane -terminals do not contend. No pane-claim, readiness, aggregate claims, or sealing -API crosses the lifecycle boundary; those are implementation details rather -than provider-neutral concepts. +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. @@ -9382,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`, @@ -9396,7 +9399,7 @@ effect when the grid owner has entered a cancellation-deferred await of the grid's durable child and acknowledges that proposal. Before that acknowledgement reaches the child, no close signal reaches a pane. Close then prevents new pane launches, asks every live pane child to close, awaits every child and provider -finalizer, destroys the exact composite, restores the root terminal, and +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; @@ -9440,7 +9443,7 @@ 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 @@ -9482,7 +9485,7 @@ 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. Reader-close intent has no separate durable `closing` state. It becomes durable @@ -9497,7 +9500,7 @@ whose completed `Close` was acknowledged remains settled. Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken -and before any provider is contacted. It then builds a new live composite. +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. @@ -11552,14 +11555,14 @@ 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 | +| 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 | diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 06532b23a..e40802108 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -739,8 +739,8 @@ 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.interactive()` keeps one -pane exclusive: a second launch cannot begin while the first is live there, and +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 @@ -761,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 @@ -770,26 +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. -`PaneTerminal.interactive()` supplies the native launch with a one-use -`spawned` acknowledgement backed by the grid lifecycle's private readiness -latch. The launcher calls 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 receives no such callback. 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 public pane-claim or readiness -interface and no aggregate grid-claims object. Pane work receives the terminal; -the grid lifecycle alone waits for readiness and closes admission. +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. @@ -1442,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. From 55b17845fd8ac8acdd7c5c7ed827efad30bd16c0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 13:48:14 -0400 Subject: [PATCH 20/22] =?UTF-8?q?=F0=9F=93=9D=20Define=20one-directional?= =?UTF-8?q?=20terminal=20grid=20UI=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 424 ++++++++++++++++------ specs/executable-mdx-spec.md | 209 +++++++---- specs/native-agent-session-launch-spec.md | 120 +++--- 3 files changed, 525 insertions(+), 228 deletions(-) diff --git a/architecture.md b/architecture.md index be10f394e..2b218101f 100644 --- a/architecture.md +++ b/architecture.md @@ -109,10 +109,11 @@ Existing documents and code get aligned to this section retroactively. | 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 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 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 | +| terminal grid | one provider-neutral foreground region whose direct terminal cells begin concurrently, remain independently interactive, and settle under one scope after complete provider and cell teardown | +| terminal cell | one authored position in a terminal grid, identified durably by its position in the ordered grid and presented by its current title. It owns one interactive terminal at a time; a paired cell expands its own document flow and a self-closing cell runs the host's default shell | +| terminal pane | the provider-owned interactive terminal endpoint bound to one live terminal cell; a tmux pane is one implementation, and its identity is never an authored or durable cell identity | +| terminal-cell lease | the exclusive ownership one live interactive operation holds in a terminal cell. Different cells do not contend; a second operation in one cell does. The grid lifecycle closes admission when that cell is closing, and terminal ownership grants no authority over an Agent session | +| native launcher | the host-owned seam that reserves the foreground terminal or the current terminal cell, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | @@ -3431,21 +3432,22 @@ provider-neutral grid of independently interactive terminal panes: ``` -`Terminal` names the interactive endpoint the document requires. It does not +`Terminal` names the interactive cell the document requires. It does not name the presentation technology: a tmux integration, another terminal multiplexer, and a host-native grid UI are providers for the same contract. A component that elicits values through a terminal UI is a different abstraction, just as `` is one presentation for ``; it does not change what an interactive process requires here. -The grid and its panes are core-owned structural syntax. `` is +The grid and its cells are core-owned structural syntax. `` is paired, requires a positive integer `columns`, and contains at least one direct `` child. Whitespace may separate those children, but ordinary text, dynamic control structures, and every other direct element are invalid. A -pane requires a non-empty `title`; titles are display labels and need not be -unique. Its ordinal among the direct children is its structural identity. -Rows are derived in row-major order from the pane count and columns. A paired -pane expands ordinary document flow; a self-closing pane runs the host's +cell requires a non-empty `title`; titles are display labels and need not be +unique. Its position in the ordered direct children is its durable structural +identity; no separately stored ordinal or key duplicates that fact. Rows are +derived in row-major order from the cell count and columns. A paired cell +expands ordinary document flow; a self-closing cell runs the host's default shell. A nested grid and a `` outside a grid are invalid. Neither form accepts a provider, executable, shell, layout identifier, or `as`, and neither renders or returns document content. @@ -3454,7 +3456,7 @@ Core ownership is necessary here. An ordinary function component receives its content as one rendered string, after the effects in that content have already run; it cannot inspect direct authored children or begin them concurrently. Core instead validates the complete static grid before any provider contact, -then creates one durable child operation per pane in authored order. Each pane +then creates one durable child operation per cell in authored order. Each cell gets an isolated binding and evaluation scope. It inherits the bindings, contextual providers, working directory, and configuration visible at the grid site, while bindings and contextual changes made in one pane remain there and @@ -3480,52 +3482,173 @@ terminal. 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 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. - -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. +already been presented before any provider resource is acquired. A refused +presentation therefore costs the provider nothing and produces no side effect. +No context value, prop, binding, provider result, retained record, diagnostic, +or structurally similar request carries this authority. + +A provider supplies one `TerminalGridHost` as a resource. Acquiring it is the +provider grid coming into existence; releasing it is the provider grid going +away, exactly once, whether the grid succeeded, failed to start, was closed by +the reader, was failed by the provider, or was cancelled. There is no destroy +operation to call, and so no way to call one twice or forget one. The host +exposes the effects a neutral grid can require — show the prepared grid, launch +an exact native request in one live cell, and start the host's default shell in +one live cell — together with a read-only `closed` operation that settles when +the reader leaves. Reader close is lifecycle input, not an imperative close +action on the grid. + +Nothing owns a grid but the expansion that submitted it. The private +`terminalGrid()` resource runs the whole grid beneath that operation: the +durable grid child, live state, provider resource, cell children, close +commitment, and complete teardown. It returns the grid's running task, so its +caller awaits the computational unit directly: + +```ts +const grid = yield* terminalGrid(layout, cellWork); +const outcome = yield* grid; +``` + +There is no callback-shaped lifecycle API, public close boundary, grid registry, +or execution-wide owner of live grid tasks. Scope parentage takes the whole grid +down whenever its submitting expansion unwinds and keeps each durable child in +the expansion context that authored it. The installation retains only the +minimum admission state that lets one exact submitted request and its provider +presentation converge. + +An incomplete grid creates one live, immutable `TerminalGridState` and a +`TerminalGridUI` action object. The neutral terminal package owns a private +per-grid StarFX store and its blocking controllers inside the current Effection +scope; it creates no independent root and does not put provider resources under +StarFX's retrying resource management. The store is presentation state only. +The durable journal remains the source of replay and recovery. Every +correctness-bearing action runs its controller synchronously through StarFX's +blocking operation form, so its caller may await all state transitions and host +effects owned by that action. Raw non-blocking dispatch never launches, shows, +closes, or settles terminal work. + +The action surface is intentionally small: + +```ts +interface TerminalGridUI { + readonly state: TerminalGridState; + readonly cells: readonly TerminalCellUI[]; + show(): Operation; +} + +interface TerminalCellUI { + readonly state: TerminalCellState; + setTitle(title: string): Operation; + launch(request: NativeLaunchRequest): Operation; + shell(): Operation; +} +``` + +The provider side is separate: + +```ts +interface TerminalGridView { + readonly state: TerminalGridState; + readonly changes: Stream; +} + +interface TerminalGridHost { + readonly closed: Operation; + show(): Operation; + launch( + cellId: TerminalCellId, + request: NativeLaunchRequest, + ): TerminalActivity; + shell(cellId: TerminalCellId): TerminalActivity; +} +``` + +Core supplies one fresh live identity per authored cell. The terminal lifecycle +creates one stable `TerminalCellUI` handle for it, and core installs that exact +handle in the paired cell's scope. The handle closes over its live +cell identity, so a component calls `setTitle()`, `launch()`, or `shell()` +without passing an index or identifier. Each `state` property reads the current +immutable snapshot; a previously read snapshot never changes. Only the cell UI, +not the grid UI, enters the paired cell's context. The canonical stable +contextual API returns it through +`useTerminalCellUI(): Operation`; absence means the +caller is outside a grid cell. The required `` prop seeds state +before presentation. `setTitle()` accepts the same non-empty string and replaces +that title until another update or grid teardown. It completes once that state +transition commits and does not wait for rendering. A title update owns no +resource and has no cleanup action. It is live presentation state, not a durable +effect: a fresh incomplete presentation starts from the retained grid's resolved +authored title, and only cell work that runs again can update it again. Rendered +paired-cell content enters the same state through a private controller; no +public `setContent()`, status setter, success action, or failure action exists. +The cell's durable child alone publishes its final outcome. + +Context makes the exact issued cell handle available for composition; it is not +provider authority. A replacement context can intercept or refuse work but +cannot construct an issued handle, admit a provider presentation, or reach the +host effects. Native-launch middleware keeps its ordinary ability to observe, +narrow, wrap, refuse, or delegate a request. The cell-scoped endpoint invokes +the issued cell's `launch()` action rather than falling through to the root +foreground launcher. This changes no native request, Agent-session identity, or +session-coordinator authority. + +The provider receives a separate read-only `TerminalGridView`: its current +immutable state and a scope-bound stream of subsequent snapshots. It never +receives `TerminalGridUI`, a cell action handle, the StarFX store, or mutation +authority. It may coalesce intermediate snapshots while converging on the most +recent one, but it never intentionally renders an older snapshot after a newer +one. Ordinary state actions do not await rendering. `show()` is different: it +sets the desired grid phase to visible and awaits the provider effect that +presents the complete latest grid. A rendering, setup, show, launch, shell, or +close-observation failure is fatal to that grid; the provider is never silently +restarted underneath the same live state. + +The live state is one fixed aggregate: + +```ts +interface TerminalGridState { + readonly phase: "preparing" | "visible" | "closing" | "closed"; + readonly columns: number; + readonly rows: number; + readonly cells: readonly TerminalCellState[]; +} + +interface TerminalCellState { + readonly cellId: TerminalCellId; + readonly title: string; + readonly row: number; + readonly column: number; + readonly status: + | "starting" + | "launching" + | "running" + | "succeeded" + | "failed" + | "closed"; + readonly content: string; +} +``` + +`cellId` is minted by core for one live grid. It keeps a cell handle, state +updates, provider effects, and a provider's private pane binding together even +if a later feature changes positions. It is not authored, retained, replayed, +diagnosed, placed in a native or Agent request, or used as provider identity. +The `cells` arrays remain in authored order; row and column say where a cell is +currently presented. Incomplete replay mints fresh live cell identities. The +identity is the seam a later Story may use for live movement or resizing; this +contract adds no move, resize, reorder, or durable-layout action. + +A cell begins `starting`. Admitting `launch()` or `shell()` publishes +`launching`; acquiring the provider's terminal activity at the actual child +spawn publishes `running`. The action waits for child settlement and the +provider's terminal-quiescence proof. The surrounding durable cell child, not +an individual launch, publishes `succeeded` or `failed` when all authored cell +work settles. Sequential launches may therefore move `running` back through +`launching` while the cell flow remains active. Distinct cells act concurrently. +Overlapping terminal activities in one cell fail immediately as busy rather +than entering a hidden queue, and the next activity is admitted only after the +prior cleanup and quiescence proof. `setTitle()` may run while an activity is +live. Once close commitment stops admission, every new public action refuses. Readiness is not something anybody acknowledges. A terminal activity is a resource whose acquisition happens only once its child has actually spawned, and @@ -3537,11 +3660,10 @@ 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. +Readiness, live-use tracking, and closing admission remain private controller +state rather than a second public capability model. The `TerminalCellUI` is a +domain action handle over that state, not a resource owner or an independently +recoverable cell. Holding one after its grid closes grants nothing. A provider whose pane endpoint is owned by a persistent process routes child creation through that process. The launch's exact argv vector, working @@ -3549,8 +3671,9 @@ 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, 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. +lifecycle and display owner. It writes provider display messages to the terminal +but never reads terminal input, so interactive input belongs to the foreground +child. It admits one live launch at a time and releases the pane only after that launch's observable terminal ownership has been swept. Sequential launches use the same pane owner and endpoint rather than replacing the pane. @@ -3558,13 +3681,13 @@ the same pane owner and endpoint rather than replacing the pane. Provider-specific commands, socket paths, session names, window identifiers, pane identifiers, attach keys, and process topology remain private inside the provider closure. They appear in no authored surface, durable identity, result, -or diagnostic. Provider-neutral diagnostics identify a grid expansion and pane -ordinal or title only. A provider may show sanitized pane status, but core owns -the operation result; presentation never decides whether a pane or grid -succeeded. Core sends the provider only the closed presentation states -`starting`, `running`, `succeeded`, `failed`, and `closed`: readiness moves a -pane to `running`, pane settlement supplies `succeeded` or `failed`, and a live -pane cancelled solely by reader close becomes `closed` rather than failed. +or diagnostic. Provider-neutral diagnostics identify a grid expansion and a +cell's authored position or title only. The provider observes the closed +presentation states `starting`, `launching`, `running`, `succeeded`, `failed`, +and `closed`; it cannot set them. Admission selects `launching`, terminal +activity acquisition selects `running`, cell-flow settlement supplies +`succeeded` or `failed`, and a live cell cancelled solely by reader close +becomes `closed` rather than failed. ### Atomic presentation and settlement @@ -3572,51 +3695,59 @@ The grid runs as one structured scope: 1. Core validates the whole structural layout, takes the foreground-terminal lease, and flushes root output. -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. +2. The terminal lifecycle admits the presentation — exact request, generation, + not already used — and only then creates the live UI state and acquires the + provider's host resource with its read-only view. Acquisition prepares the + entire hidden + grid: every pane endpoint and its supervision. No grid is visible yet, and a + refused presentation creates no store and 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 + durable child identities derived from the grid expansion and authored array + position. A paired pane begins its document flow and a self-closing pane begins its shell. 4. A pane is ready only when its terminal activity is acquired, which happens only once its child has actually spawned. Reserving an endpoint, allocating a process identifier, or receiving output is not acquisition. A child that starts and exits immediately can be both ready and settled. -5. Only after every pane reaches readiness does the provider attach the one - 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 +5. Only after every cell reaches readiness does the grid controller run + `TerminalGridUI.show()`. The action publishes `visible` and awaits the host's + atomic presentation of the complete latest state. Any acquisition or + cell-start failure before this + barrier cancels every cell, awaits complete teardown, releases the hidden + grid, and fails without exposing a partial grid. Agent preparation or retained route work that occurred before a failed native spawn remains durable; atomicity covers terminal presentation and lifecycle, not rollback of earlier provider effects. 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The 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 +7. The host's `closed` operation first crosses the private live close boundary, + then the controller publishes `closing` and begins ordered teardown: prevent + new cell actions, ask live cell children to close, await every child and + finalizer, release the provider's host resource — which is what destroys it, + once — restore the root terminal, publish `closed`, and only then release the + foreground lease and settle the grid. Reader close, cell failure and parent cancellation each decide the durable outcome before disposal begins; cleanup enforces quiescence and never invents or rewrites a retained outcome. The - document never continues while an observable pane child or provider-owned + document never continues while an observable cell 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 +The provider host's read-only `closed` settlement proposes the live close +boundary. The boundary is crossed when the grid owner has entered a +cancellation-deferred await of the grid's durable child and acknowledges that proposal; only then may the child signal pane close. That await ends only when the task has settled and its durable `Close` has been acknowledged, not when the grid body has merely chosen an outcome. This handshake has no provider identity and is not itself journaled. -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 +There is no public `close()` action. Reader-close intent becomes durable only as +that completed grid `Close`, after cell and provider teardown. There is no +standalone durable "closing" state. The live UI phase named `closing` reports +teardown progress and is not retained intent. The gap between observing close +and committing it is safe because ordinary parent cancellation is held pending +across the whole gap. A cancellation that arrives before the owner acknowledges +the close boundary cancels the active grid. One that arrives afterward does not rewrite grid or pane outcomes: panes already settled keep their outcomes, each then-live pane completes its own scope and retains `closed`, and the grid retains the same `reader` or `failed` result it would @@ -3630,7 +3761,7 @@ 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 +have settled. This preserves the pane's position-derived identity and never turns a deliberate reader close into a caller-cancelled durable child that a later run could revive or wait on forever. @@ -3675,19 +3806,23 @@ observable ownership mechanism of its own instead of severing all three links. ### Durability and replay A terminal grid is a core-owned structured durable region. Its layout identity -contains the columns and the ordered pane forms and titles, never a provider or -live terminal identifier. Each pane is a deterministic durable child coroutine, -so effects in paired content retain and replay under the same rules they use -outside a grid. A self-closing shell is a terminal child effect that retains -only provider-neutral start and exit status; its executable, argv, environment, -terminal bytes, and conversation history are live-only. +contains the columns and the ordered cell forms and titles, never an explicit +index, live `cellId`, provider identity, or terminal identifier. Array position +is the durable structural identity. Each cell is a deterministic durable child +coroutine derived from that position, so effects in paired content retain and +replay under the same rules they use outside a grid. A self-closing shell is a +terminal child effect that retains only provider-neutral start and exit status; +its executable, argv, environment, terminal bytes, and conversation history are +live-only. The pre-merge retained shape with explicit ordinal fields has no +migration reader. The completed grid record retains the provider-neutral layout, close kind, and -ordered pane outcomes after the normal secret gate. Completed replay claims the +ordered cell outcomes after the normal secret gate. Completed replay claims the whole region and returns its retained outcome without installing or contacting -a terminal provider, starting a shell, expanding pane content, acquiring an -Agent session, or launching a native UI. The structured durable boundary owns -that short circuit; a public replay context does not. +a terminal provider, creating a StarFX store, UI object, cell handle, state +stream, or host, starting a shell, expanding cell content, acquiring an Agent +session, or launching a native UI. The structured durable boundary owns that +short circuit; a public replay context does not. The reader-close handshake makes cancellation during teardown a completed-grid case rather than a new partial-replay state. When a pane finalizer delays close @@ -3723,8 +3858,9 @@ 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, +structure it retained rather than the one the file now shows. It creates fresh +live state and cell identities and acquires a fresh provider host: completed +cell children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own contract; placing it in a pane neither allocates a replacement session nor @@ -3733,6 +3869,76 @@ authorized default shell and makes no claim to resume its prior terminal history. Provider identifiers are recreated live and are never reconciled with a journal. +### Package ownership + +The package boundary follows the provider boundary. `@executablemd/terminal` +owns the provider-neutral state, UI, view, host and cell action types; the +private StarFX store and controllers; presentation admission, terminal +activities, errors, grid lifecycle and replay; POSIX process observation and +quiescence; and the controlled evidence provider. `@executablemd/terminal-tmux` +owns tmux commands, workers, private IPC, rendering convergence, and the tmux +host resource. Core imports the neutral package and keeps structural expansion, +source-aware journal descriptions, execution-profile integration, and Agent +session behavior. The Deno and compiled CLI entrypoints install the POSIX +observer and tmux provider for an ordinary foreground `xmd run`; Node, Bun and +every workflow profile install neither. A terminal grid is run presentation, +not workflow orchestration. + +Dependencies point from core and the tmux adapter into the neutral terminal +package, and from runtime-named CLI entrypoints into the tmux adapter. The +neutral package imports neither core, the tmux adapter, runtime, nor CLI; the +tmux adapter imports neither core, runtime, nor CLI. StarFX remains a private +dependency of the neutral package, and no StarFX type crosses its public or +provider surface. Reusable POSIX observation stays behind +`@executablemd/terminal/posix` so another POSIX provider need not depend on tmux. +The old runtime, core, and CLI terminal implementation modules and exports are +deleted, and repository imports use the two canonical package surfaces directly. +This stack is unmerged, so no compatibility re-export or migration path +preserves those experimental module locations. + +The extraction applies to the stack's implementation modules as follows: + +| Current module | Destination | +| --- | --- | +| `packages/runtime/launcher.ts` | Split among terminal's neutral root, POSIX foreground-child adapter, and controlled test entrypoint | +| `packages/runtime/terminal.ts` | Split between terminal's neutral root and controlled test entrypoint | +| `packages/runtime/terminal-processes.ts` | `@executablemd/terminal/processes` | +| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix` | +| `packages/core/src/terminal/{grid,pane,presentation,provider-api}.ts` | `@executablemd/terminal/lifecycle`, with UI state and controllers replacing the imperative grid and pane surfaces | +| `packages/core/src/terminal-grid.ts` | Split: neutral layout moves to terminal; authored scanning, expansion and source integration stay in core | +| `packages/core/src/terminal/{journal,profile}.ts` | Stay in core as adapters from terminal lifecycle to core journal descriptions and `Execution` | +| `packages/cli/src/terminal/{attach-client,layout,pane-channel,pane-child,pane-protocol,pane-worker,provider,tmux-grid,tmux}.ts` | `@executablemd/terminal-tmux` | +| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in a non-terminal CLI module | + +The neutral package root exports the provider-neutral launch, state, UI, view, +host, activity, layout, routing and error contracts together with +`useTerminalCellUI()`. Its `./lifecycle` entrypoint +exports grid execution, presentation installation and replay; `./processes` +exports process facts, snapshots and quiescence; `./posix` exports reusable +POSIX observation and foreground-child adapters; and `./test` exports only +controlled providers, launchers, logs and signals. These are facets of one +package: a value exported from more than one entrypoint is object-identical. +`@executablemd/terminal-tmux` exports its provider name, dependency contract, +provider factory and installer, pane-worker command and invocation parser, pane +worker runner, and documented provider errors from its root. Protocol frames, +channels, tmux process wrappers, layout mechanics and teardown hooks remain +private; controlled low-level seams exist only under its `./test` entrypoint. +Every contextual API descriptor and public error constructor has one canonical +definition. Re-exporting it from another canonical entrypoint preserves object +identity; no package rebuilds a structurally similar descriptor or error. + +Tests move with the contract they prove. Terminal owns neutral state, action, +authority, lifecycle, replay, process-observation and quiescence evidence; +terminal-tmux owns renderer, protocol, worker and teardown evidence; core keeps +syntax, source integration and journal-description evidence; test-agent keeps +cross-package Agent composition; and CLI keeps entrypoint and compiled-host +selection evidence. Both terminal packages are ordinary lockstep-versioned +workspace members. Publication places terminal after durable streams, +terminal-tmux and core after terminal, and CLI after terminal-tmux, terminal, +core and runtime. + +### tmux provider + The first production provider uses tmux where the Deno or compiled host has a foreground terminal and the required tmux capability. It prepares one private tmux server per grid and starts one persistent pane worker as each pane's @@ -5069,8 +5275,8 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral 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 | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral grid whose statically declared direct cells begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, and tear down completely before document execution continues. One scope-owned `TerminalGrid` task owns the whole durable computation. Incomplete work creates a per-grid immutable StarFX state, stable contextual `TerminalCellUI` action handles and one resource-owned `TerminalGridHost`; the provider observes a read-only view and cannot mutate state. The title prop seeds state, later title actions update it, native and shell actions are exclusive within one cell but concurrent across cells, and `show()` presents the complete latest grid after readiness. Reader close is an implicit host lifecycle signal rather than a public action. Completed replay creates none of the live state or provider objects; partial replay mints fresh live cell identities and continues incomplete effects under their existing position-derived durable identities. `@executablemd/terminal` owns the neutral live contract, lifecycle, replay and reusable POSIX observation; `@executablemd/terminal-tmux` owns tmux rendering, IPC and workers; core owns structural syntax, source-aware journal descriptions and profile integration; and runtime-named entrypoints install host wiring with no compatibility exports from old terminal paths | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; controlled non-tmux evidence proves the provider-neutral state, action, lifecycle and replay contract; implementation unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that cell's lease through its contextual `TerminalCellUI`. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index a8f86f5b4..97f13424e 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9274,7 +9274,15 @@ row. identity, so duplicate titles are allowed. A paired pane expands its content as ordinary sequential document flow. A self-closing pane starts the default interactive shell configured by the host. The shell choice is live host policy, -not document data. +not document data. An expression may derive the label from document props, such +as the cell's role and current issue. The resolved prop seeds the live cell +title before the grid is shown. A component running in that cell may replace the +title through its contextual terminal-cell UI. The replacement must also be a +non-empty string; it persists until another update or grid teardown and owns no +resource. There is no second authored title form in this version. The update is +live presentation state, not a durable effect; a fresh +partial-replay presentation begins from the retained resolved title, and only +cell work that runs again may update it again. Neither element accepts `as`. Neither renders content into the surrounding document or returns a value. Text that a paired pane renders is displayed in @@ -9333,6 +9341,28 @@ sibling render to the root again. #### Readiness and the visible lifetime +The private grid resource treats the entire grid as one computational unit. It +returns a running task which the submitting expansion awaits directly; it does +not accept pane work through a callback and exposes no `open()`, `close()`, live +grid registry, supervisor, or public close-boundary object. The resource owns +the durable grid child, live UI state, provider host, pane children, close +commitment, and teardown in the submitting expansion's scope. This scope +parentage preserves the durable identities and inherited context of the exact +authored position. + +For incomplete work, the neutral terminal package creates an immutable live +user interface backed by a private per-grid StarFX store in that Effection +scope. The UI exposes its current state, stable cell action handles in authored +order, and `show()`. It creates no independent scope and does not use retrying +resource management for the provider. The provider receives a separate +read-only state view and supplies a resource-owned terminal host with `show`, +per-cell `launch` and `shell` effects, and the reader-close signal. The +request's exact object and installation generation still authorize presentation +before any of those live values or resources are created. Contextual UI handles +carry no presentation authority. Launch, shell and show use StarFX's blocking +controller execution so callers can await every state transition and non-render +effect the action owns; raw non-blocking dispatch performs none of them. + Opening a grid is atomic from the reader's perspective: 1. Core validates the whole layout and acquires the root foreground-terminal @@ -9351,21 +9381,36 @@ Opening a grid is atomic from the reader's perspective: 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. +An incomplete grid owns one live immutable aggregate with phase, dimensions, +and ordered cell states. Each cell state contains a live-only `cellId`, title, +row, column, rendered Markdown content, and one of `starting`, `launching`, +`running`, `succeeded`, `failed`, or `closed`. Agent and shell PTY bytes never +enter it. Core supplies one fresh identity per authored cell; the terminal layer +creates one stable `TerminalCellUI` handle over that identity, and core places it +in the paired cell's context. The handle exposes the current immutable cell +state plus `setTitle()`, `launch()`, and `shell()`; callers pass no cell index or +identity. +The canonical `useTerminalCellUI()` operation returns that exact handle or +`undefined` outside a cell; the grid UI itself is not contextual. +There is no content setter, generic dispatch, status setter, success action, or +failure action. Paired Markdown output and final pane outcomes are published by +the private pane lifecycle. + +`launch()` and `shell()` run a terminal activity as the cell's owner. An +activity is a resource acquired only after its child has spawned, so acquisition +is readiness and no acknowledgement is handed to anybody; the acquired value is +the operation that settles with the child's outcome, and the activity's cleanup +and terminal-quiescence proof complete before the action returns. A startup +error fails before acquisition. Readiness appears in no prop, binding, public +request, provider return, process result, or durable record, and a root launch +has no cell activity. + +The same private lifecycle state admits at most one activity in a cell and stops +admitting all public actions when the grid closes. Different cells do not +contend. An overlapping launch or shell in one cell refuses as busy rather than +waiting in a hidden queue; sequential use is admitted only after the prior +activity has completely released the terminal. `setTitle()` remains available +while an activity is live. When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private @@ -9377,10 +9422,10 @@ 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, 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. +grid, restores the root terminal, and fails without showing a partial grid. +Effects that finished before an interactive start failed keep their ordinary +durable records. Grid atomicity is not a transaction that rolls back Agent +preparation, files, commands, or other completed work. After attachment, a pane's normal exit or failure changes that pane's visible status and does not cancel its siblings. Paired content may continue with later @@ -9388,16 +9433,28 @@ sequential work after one interactive child exits, including another launch on the same pane. The grid remains visible when all panes have settled. The reader closes or leaves it to finish the grid. -The provider receives presentation updates only as `starting`, `running`, -`succeeded`, `failed`, or `closed`. Readiness selects `running`; final pane flow -selects success or failure; a live pane cancelled only because the reader -closed the grid becomes `closed`. These states display core's result and never -author it. - -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 +The cell begins `starting`; admitting a launch or shell selects `launching`, and +terminal-activity acquisition selects `running`. An individual activity +settling does not finish a paired pane that has more authored work. Its durable +pane child alone selects `succeeded` or `failed` after the whole pane flow +settles, and a live pane cancelled only because the reader closed the grid +becomes `closed`. These states display core's result and never author it. + +The provider receives only a read-only view of the current immutable aggregate +and a scope-bound stream of later snapshots. It receives no UI action handle, +store, or mutation authority. It may skip transient snapshots while converging +on the newest one, but never intentionally renders an older snapshot after a +newer one. Title and state actions do not wait for an ordinary render. The one +`show()` action publishes a visible grid phase and awaits the provider's atomic +presentation of the complete latest state. A provider setup, render, show, +launch, shell, or close-observation failure fails the grid and tears it down; +the provider is not restarted underneath the same state. + +The provider host's read-only `closed` operation proposes reader close. There is +no callable grid-close action. Reader close takes effect when the grid owner has +entered a cancellation-deferred await of the grid's durable child and +acknowledges that proposal. Before that acknowledgement reaches the child, no +close signal reaches a pane. Close then prevents new pane launches, asks every live pane child to close, awaits every child and provider finalizer, releases the exact provider grid, restores the root terminal, and releases the foreground lease. The deferred await ends only after the durable @@ -9412,12 +9469,12 @@ 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 +#### Native launch ownership inside a cell -Each pane receives a pane-scoped native launcher. A `` there -reserves and flushes that pane terminal rather than the root foreground lease. -Different pane terminals do not contend, so their native UIs may run -concurrently. Two live interactive launches in one pane contend; sequential +Each cell receives a cell-scoped native launcher. A `` there +reserves and flushes that cell's terminal endpoint rather than the root +foreground lease. Different cells do not contend, so their native UIs may run +concurrently. Two live interactive launches in one cell contend; sequential launches in it are allowed. A launch releases the pane only after its child and every observable process @@ -9471,22 +9528,27 @@ no process remains descended from a child alive when teardown began, and no process holds a pane terminal. A provider that cannot establish those bounded facts fails teardown. -Diagnostics identify the source grid and a provider-neutral pane ordinal or -authored title. They contain no provider command, socket, server, session, +Diagnostics identify the source grid and a provider-neutral authored cell +position or title. They contain no provider command, socket, server, session, window, pane identifier, executable path, argv, environment, or terminal bytes. #### Durability and replay The grid is one structured durable region. Its identity includes the resolved -column count and ordered pane forms and titles. Each direct pane receives a -deterministic child-coroutine identity derived from the grid expansion and its -ordinal, never from its title, scheduling order, or provider layout. +column count and ordered cell forms and titles. Ordered array position is the +cell's durable structural identity; no explicit ordinal, pane index, live +`cellId`, or provider identity is retained. Each direct cell receives a +deterministic child-coroutine identity derived from the grid expansion and that +position, never from its title, scheduling order, or provider layout. Because +this stack is unmerged, the experimental retained shape with ordinal fields has +no migration reader. The completed region retains its provider-neutral layout, how it closed, and -the ordered pane outcomes after the ordinary secret gate. Completed replay -claims that whole region and restores its result without contacting a terminal -provider, acquiring a provider grid, starting a shell, expanding pane content, -resolving an Agent, taking session ownership, or launching a native UI. +the ordered cell outcomes after the ordinary secret gate. Completed replay +claims that whole region and restores its result without creating a live state +store, UI object, cell handle, state stream, provider host, or terminal pane; +starting a shell; expanding cell content; resolving an Agent; taking session +ownership; or launching a native UI. Reader-close intent has no separate durable `closing` state. It becomes durable as the completed grid `Close`, after all pane and provider teardown. The live @@ -9499,10 +9561,16 @@ 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. +each cell's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then mints fresh live `cellId` values, +creates a new live store and UI, and acquires a new provider host. Completed cell +children appear as already-settled statuses and perform no effects; incomplete +children continue from their own durable records. + +The live `cellId` keeps the cell handle and provider endpoint bound if a later +contract permits position changes. This version exposes no move, resize, +reorder, or durable-layout action; rows and columns remain the fixed resolved +layout for the grid's live presentation. Pane count, order and form are not compared, because they cannot differ. A continuation executes the root document the journal retained: the source the new @@ -9542,6 +9610,20 @@ Node and Bun accept and validate the same syntax but install no provider and therefore refuse before pane start. A controlled provider that is not tmux exercises the same core contract in tests. +The provider-neutral terminal contract, live state, controllers, lifecycle, +replay, process-observation contract and reusable POSIX implementation are +exported only from `@executablemd/terminal`. The tmux host, worker, rendering and +private IPC are exported only from `@executablemd/terminal-tmux`. Core owns +structural expansion, source-aware journal descriptions and execution-profile +integration and imports the neutral package. Runtime-named Deno and compiled CLI +entrypoints install terminal's POSIX observer and terminal-tmux's provider. +That installation belongs only to an ordinary foreground `xmd run`; workflow +profiles, Node and Bun install neither. Dependencies never point from either +terminal package into core, runtime or CLI, and the tmux package depends on the +neutral terminal package. StarFX is private to the neutral package. The old +runtime, core and CLI terminal modules and exports do not remain as compatibility +paths; every repository import uses the canonical package surfaces. + ## 7. Entry point @@ -11550,22 +11632,25 @@ test derives a core result from a provider identifier. | TG1 | Frozen grammar | `Terminal.Grid` accepts only paired form with a positive integer `columns`; `Terminal` accepts paired and self-closing forms with a non-empty `title`; both reject unknown props and `as` | | TG2 | Structural placement | An empty grid, direct text or non-pane element, a dynamically produced direct pane, a nested grid, and a pane outside a grid are refused before a provider call or body effect; whitespace between direct panes is inert | | TG3 | Catalog and validation are inert | Both reserved entries and exact forms appear under structural syntax on every runtime; syntax and document validation contact no terminal provider, tmux, shell, Agent registry, or session coordinator | -| TG4 | Row-major layout | One through five authored panes under two and three columns produce the exact derived positions, keep duplicate titles, and derive identity from ordinal rather than title or scheduling | -| TG5 | Representative 2×2 journey | Three controlled native Agent sessions and one controlled default shell all start before the grid attaches, remain concurrently interactive, and use the authored row-major positions | +| TG4 | Row-major layout | One through five authored cells under two and three columns produce the exact derived positions and keep duplicate titles. Ordered array position supplies durable identity without a stored ordinal, pane index or key; each live grid mints fresh `cellId` values that do not enter the retained request | +| TG5 | Representative 2×2 journey | Three controlled native Agent sessions and one controlled default shell all start before the grid is shown, remain concurrently interactive, and use the authored row-major positions | | TG6 | Isolated pane scopes | Every pane inherits the grid site's values, cwd, repository selection and providers; one pane's new bindings and contextual changes reach later work in that pane only, and its `Break` or `Return` cannot escape the pane | -| TG7 | Pane output | Rendered pane text reaches only that pane and the grid renders `""`; a surrounding capture gets no pane display; nested executable effects retain their ordinary results; native UI and shell bytes enter no capture, process journal or transcript | -| TG8 | Readiness barrier | Endpoint allocation, PID allocation, preparation, route publication, detach and first output are not ready; the runtime child-spawn event is. A paired pane that settles without one fails startup, and a child that spawns then exits immediately is ready and settled | -| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no 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, 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 | +| TG7 | Cell output | Rendered paired-cell text enters only that cell's immutable presentation state and the grid renders `""`; a surrounding capture gets no cell display; nested executable effects retain their ordinary results; native UI and shell bytes enter neither the state nor a capture, process journal or transcript | +| TG8 | Readiness barrier | A cell begins `starting`, admission selects `launching`, and only the runtime child-spawn event selects `running` and satisfies readiness. Endpoint or PID allocation, preparation, route publication, detach and first output do not. A paired cell that settles without a spawn fails startup, while a child that spawns then exits immediately is ready and settled | +| TG9 | Atomic startup failure | Each provider-preparation position and each authored cell start can fail; the host never shows the grid, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous cell failures report the first authored position | +| TG10 | Independent settlement | After show, one cell can exit successfully or fail while siblings remain live and usable; its final status stays visible. An individual launch settling does not finish a paired cell with later authored work. Closing a grid with failed cells reports the first failed authored position, while teardown cancellation itself does not create a cell failure | +| TG11 | Terminal versus session ownership | Distinct cell leases permit concurrent native launches; one cell refuses overlapping launch or shell actions rather than queueing them; and sequential work is admitted only after the previous child, its observable descendants and group members, and every other holder of that cell's terminal are gone. Title updates remain admissible while a child runs. Two cells naming one logical Agent session still contend through the unchanged non-waiting coordinator | +| TG12 | Reader close | The host's read-only close signal prevents every later public cell action, cancels every live cell scope, awaits each child, shell and provider finalizer, releases the exact provider host, restores the root terminal, releases the foreground lease, and only then starts the following document sibling. No public `close()` action exists | +| TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; a provider setup, render, show, launch, shell or close-observation failure cancels every cell and fails the grid without restarting the provider; cleanup is attempted for all resources under existing failure precedence | | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | -| TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | -| TG16 | Partial replay | Exact layout 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 | +| TG15 | Completed replay | A completed successful or failed grid restores its exact result while creating no StarFX store, UI, cell handle, state stream, provider host or pane and contacting no shell, Agent provider, coordinator, pane content or native launcher | +| TG16 | Partial replay | Exact layout creates fresh live cell identities, state and UI and acquires a fresh provider host; completed cell children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | +| TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Cell count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and cell outcomes contain no explicit ordinal, pane index, live `cellId`, provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19–TG21; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, consumes only the read-only state view, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, shows the grid only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before cell start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live cell enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets cell and provider teardown complete, retains the cell as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters cell work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | +| TG20 | One-directional live state | The title prop seeds state before show; the exact contextual cell handle exposes current immutable state, refuses an empty title, and updates a valid title without a cell identifier; paired Markdown content reaches state through no public setter; the provider can only observe the current state and a scope-bound snapshot stream. Multiple updates may be coalesced, no older snapshot is rendered after a newer one, and state actions do not wait for ordinary rendering | +| TG21 | One scope-owned computation | `terminalGrid()` yields one task whose result is observed directly. Its submitting expansion owns the durable grid, UI state, provider host, cell children and teardown; cancellation of that scope takes all of them down. No registry, supervisor, callback-shaped lifecycle or public close boundary participates, and retained recovery never depends on a live handle | +| TG22 | Canonical package boundary | The neutral terminal package contains the state, controllers, lifecycle, replay, action and provider contracts, process observation and reusable POSIX implementation with StarFX private; the tmux package contains rendering, IPC and worker behavior; core retains structural expansion, source-aware journal descriptions and profile integration; runtime-named Deno and compiled CLI entrypoints install the host wiring. Import and export checks find no old runtime, core or CLI terminal implementation path, compatibility re-export, terminal-to-core/runtime/CLI cycle, or StarFX type on a cross-package surface | ### 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 e40802108..93d599d88 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -441,7 +441,7 @@ Given `xmd AGENTS.md#Implementor`: 5. File reads, captures, parsing, and deterministic evaluation finish or fail. 6. `Session.Launch` takes its applicable terminal lease. At the document root this is the run's foreground-terminal lease; inside `` it is that - pane's lease through the pane-scoped native launcher. A host with no + cell's lease through the cell-scoped native launcher. A host with no applicable terminal refuses here — before an agent is resolved, so learning that this invocation cannot launch anything costs no availability probe. 7. The host flushes what that terminal has pending, so the native UI does not @@ -505,10 +505,10 @@ owner to release — what has to be settled first is which conversation this is: From there both rejoin: 13. The provider spawns the native UI as an interactive child with the selected - root or pane terminal inherited — resuming the native session ID for a - provider-returned adapter, and for a client-allocated one creating it under - the allocated identity from the private file, or resuming it by the same - name when the route already named it. + root or terminal-cell endpoint inherited — resuming the native session ID + for a provider-returned adapter, and for a client-allocated one creating it + under the allocated identity from the private file, or resuming it by the + same name when the route already named it. 14. `Session.Launch` suspends while the child runs. 15. The child handles prompts, tools, permission dialogs, rendering, and native transcript persistence directly. @@ -731,33 +731,35 @@ session coordinator ``` 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 +stable `TerminalCellUI` per authored cell, closes that handle over a fresh +live-only `cellId`, and owns the private state that admits work, observes +readiness, and stops admission at close. Core installs that exact cell UI in the +paired cell's scope. `Session.Launch` uses the launcher already in scope; it +receives no cell prop, token, identifier, or mode. Context is composition rather +than authority: a constructed lookalike cannot reach a live provider host, and +the issued cell UI admits nothing after its grid closes. + +Different terminal cells do not contend, so native launches in different cells +can hold their terminals concurrently. `TerminalCellUI.launch()` keeps one cell exclusive: a second launch cannot begin while the first is live there, and -sequential launches work after the first releases it. 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. +sequential launches work after the first releases it. An overlap refuses as +busy rather than entering a hidden queue. Release requires the child, its +observable descendants and process-group members, and every other holder of +that cell's terminal to be gone; the cell remains busy if the provider cannot +establish those facts. A root launch and a terminal grid contend for the root +foreground lease, so neither can overlap the other. None of that changes the coordinator key or acquisition. Two panes naming the same provider, agent, and logical session still ask for one natural-key owner; one succeeds and the other receives `session-busy` without waiting. Two distinct -sessions may be owned concurrently. A pane terminal grants no permission to +sessions may be owned concurrently. A terminal cell grants no permission to ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. -The pane-scoped launcher keeps the same launch request and provider authority +The cell-scoped launcher keeps the same launch request and provider authority division as the root launcher. Public middleware can route or refuse a request -but cannot settle it, replace the pane, or mint a launch. Provider-specific grid -or pane identities never enter the `AgentLaunchRequest`, terminal result, +but cannot settle it, select another cell, or mint a launch. Provider-specific +grid or pane identities never enter the `AgentLaunchRequest`, terminal result, `agent_session_launch` record, construction route, ownership key, diagnostic, or private instruction file. @@ -770,23 +772,27 @@ 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. -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 +A native launch runs through `TerminalCellUI.launch()`. The private controller +asks the provider host for a terminal activity: a resource whose acquisition +happens only once the child has actually spawned. Acquisition is 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 controller publishes `running` only after acquisition, awaits +the acquired operation, and does not complete the action until the activity's +cleanup has swept whatever the launch still holds. A root launch has no cell +activity at all. Readiness is not added to `AgentLaunchRequest`, +`AgentLaunchResult`, the public Agent Api, a retained launch phase, or a process +handle, so it changes neither the launch's authored nor its durable contract. + +The provider-neutral terminal package exports the cell UI action contract and +the host activity boundary, not readiness, busy-state, or closing authority. +Pane work receives its issued contextual cell UI; the grid lifecycle alone +waits for readiness, publishes final cell status, and closes admission. The +provider receives only the live `cellId` needed to select its physical endpoint; +that value enters no Agent request, retained record, diagnostic, or provider +identity. + +Under the tmux provider the cell-scoped launcher sends exact argv, cwd, and environment values over a private authenticated socket to the persistent pane worker. The worker, not a tmux command line, creates the native child with all three standard streams inherited from the pane terminal. It provides the @@ -918,15 +924,15 @@ session than the one this operation prepared. At the root, V1 holds the foreground-terminal lease for the CLI execution. In a terminal grid, the grid holds that root lease and a launch holds only its current -pane lease. Two launches cannot concurrently own the same root or pane terminal, -even when they name different sessions. Launches on distinct panes may run -concurrently, and sequential launches on one terminal are ordinary composition. +cell lease. Two launches cannot concurrently own the same root terminal or cell, +even when they name different sessions. Launches in distinct cells may run +concurrently, and sequential launches in one cell are ordinary composition. Cancellation interrupts the native interactive process, establishes that it can -no longer execute or hold its root or pane terminal, restores that terminal's -state, and runs every provider finalizer. A process that ignores the initial -interruption is terminated according to the host process adapter's bounded -shutdown policy. +no longer execute or hold its root or terminal-cell endpoint, restores that +terminal's state, and runs every provider finalizer. A process that ignores the +initial interruption is terminated according to the host process adapter's +bounded shutdown policy. The adapter attempts to collect the native exit status, but a runtime-retained defunct PID or a lost exit event is not live process ownership. After a fatal signal was accepted, or the process was already absent, bounded settlement may @@ -1053,10 +1059,10 @@ a replacement or reconstructs state from a transcript. When the launch is a pane child, completed replay of the enclosing completed grid claims the whole structured region before this operation is reached, so it also contacts nothing. Partial grid replay restores a completed launch as a -settled pane status. An incomplete launch is reached under a newly created live -pane terminal and follows the same phase rules above; neither the new provider -layout nor the pane ordinal changes its retained launch or logical-session -identity. +settled cell status. An incomplete launch is reached through a newly created +live terminal-cell UI and follows the same phase rules above; neither the fresh +`cellId` nor the new provider layout changes its retained launch or +logical-session identity. Those are operation/runtime replay semantics: they define how an execution behaves when an embedder, a test, or a future retained execution host supplies @@ -1196,7 +1202,7 @@ starts Claude, Codex, or a model. Terminal-grid tests additionally install a controlled provider that is not tmux. It exposes readiness, independent pane settlement, reader close, provider failure, parent cancellation, and teardown completion as test-controlled -operations while using the same core terminal authority and pane-scoped native +operations while using the same core terminal authority and cell-scoped native launchers. Separate tmux integration evidence exercises the production adapter; core semantics are not inferred from tmux identifiers or process behavior. The tmux evidence covers exact argv over private IPC, the runtime spawn boundary, @@ -1258,9 +1264,9 @@ Focused tests prove: failed releases nothing and withholds quiescence; and 23. a canonical version parse accepts exactly one matching line, and refuses zero or several without repeating the output; and -24. launches on distinct pane terminals run concurrently while launches in one - pane remain exclusive, the same logical Agent session still contends across - panes, pane readiness occurs only after successful native-child start, grid +24. launches in distinct terminal cells run concurrently while launches in one + cell remain exclusive, the same logical Agent session still contends across + cells, cell readiness occurs only after successful native-child start, grid close awaits launch cancellation and session quiescence, and completed and partial grid replay preserve the launch's existing identity rules. @@ -1341,8 +1347,8 @@ in a released unbound form and a bound one; the host-owned executable observer and the build binding it produces; ACP attachment to a bound client-native session under its exact retained identity, through runtime partitions keyed by agent command and build; -an inherited root- or pane-terminal interactive child with cancellation and -bounded reaping; composition with the terminal grid's independent pane leases +an inherited root- or terminal-cell interactive child with cancellation and +bounded reaping; composition with the terminal grid's independent cell leases without changing session ownership or durable launch identity; and the controlled TestAgent fixture that proves all of it without starting a model. From 69157e4ca3d9272b8ebbcacce50c408dc09c832d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 15:02:11 -0400 Subject: [PATCH 21/22] =?UTF-8?q?=F0=9F=93=9D=20Define=20terminal=20grid?= =?UTF-8?q?=20state=20convergence=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 335 +++++++++++++++++----- specs/executable-mdx-spec.md | 213 ++++++++++---- specs/native-agent-session-launch-spec.md | 53 ++-- 3 files changed, 454 insertions(+), 147 deletions(-) diff --git a/architecture.md b/architecture.md index 2b218101f..9b048407f 100644 --- a/architecture.md +++ b/architecture.md @@ -3492,20 +3492,49 @@ provider grid coming into existence; releasing it is the provider grid going away, exactly once, whether the grid succeeded, failed to start, was closed by the reader, was failed by the provider, or was cancelled. There is no destroy operation to call, and so no way to call one twice or forget one. The host -exposes the effects a neutral grid can require — show the prepared grid, launch -an exact native request in one live cell, and start the host's default shell in -one live cell — together with a read-only `closed` operation that settles when -the reader leaves. Reader close is lifecycle input, not an imperative close -action on the grid. +exposes the effects a neutral grid can require — converge state, show the +prepared grid, launch an exact native request in one live cell, and start the +host's default shell in one live cell — together with independent read-only +`closed` and `failed` operations. Reader close is lifecycle input, not an +imperative close action on the grid, and a background provider failure cannot +hide until another action happens to call the provider. Nothing owns a grid but the expansion that submitted it. The private `terminalGrid()` resource runs the whole grid beneath that operation: the -durable grid child, live state, provider resource, cell children, close -commitment, and complete teardown. It returns the grid's running task, so its -caller awaits the computational unit directly: +layout reconciliation, durable grid child, foreground-terminal lease and root +output flush, live state, provider resource, cell children, close commitment, +and complete teardown. It returns the grid's running task, so its caller awaits +the computational unit directly: ```ts -const grid = yield* terminalGrid(layout, cellWork); +interface TerminalCellWork { + readonly cellId: TerminalCellId; + readonly operation: Operation; +} + +interface RetainedCellOutcome { + readonly status: "succeeded" | "failed" | "closed"; + readonly reason: string; +} + +interface TerminalGridJournal { + reconcileLayout(layout: RetainedGrid["layout"]): Operation; + retainGrid(operation: Operation): Operation; + retainCell( + position: number, + operation: Operation, + ): Operation; +} + +function terminalGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, +): Resource>; + +function appendTerminalCellOutput(text: string): Operation; + +const grid = yield* terminalGrid(layout, cellWork, journal); const outcome = yield* grid; ``` @@ -3516,6 +3545,37 @@ the expansion context that authored it. The installation retains only the minimum admission state that lets one exact submitted request and its provider presentation converge. +Core constructs each cell's generator as a lazy `Operation` and mints its +fresh live `cellId`; constructing the operation performs no expansion, shell, +Agent, provider, or journal work. The terminal lifecycle validates that the +ordered cell work matches the layout, runs each operation exactly once inside +the corresponding position-derived durable child, and installs that cell's +issued `TerminalCellUI` and a closure-bound cell-output sink in the operation's +scope before interpreting it. The paired operation expands authored content; +at each completed output boundary core appends those rendered bytes through the +sink with `appendTerminalCellOutput()` and awaits the resulting private store +commit before expansion continues. Empty text is a no-op; every other call +appends in call order, so the aggregate's `content` remains the complete output +produced so far. The sink is an integration facet of `./lifecycle`, not a member +of either UI or the provider view, and it exposes no store, dispatch, title, +status, identity, or host authority. Its operation is inert outside the issued +cell scope. The self-closing operation invokes the contextual cell's shell +action. No lifecycle callback receives a scope or resource. + +Core also constructs the `TerminalGridJournal` for the grid's source expansion. +The adapter closes over core's source-aware journal descriptions and implements +layout reconciliation plus the grid and position-derived cell durable +boundaries. It receives only provider-neutral retained values and lazy +operations. The neutral package defines and calls this interface but never +imports core. `reconcileLayout()` runs before provider admission; +`retainGrid()` may return a completed retained outcome without interpreting its +live operation; and `retainCell()` does the same for one completed cell. This is +how completed replay creates no live state while incomplete replay preserves +the exact durable identities core supplied. The core adapter creates each +source-described durable grid or cell child when the terminal lifecycle invokes +the corresponding retention operation; the terminal lifecycle owns the live +tasks produced by those children and never creates a competing durable lineage. + An incomplete grid creates one live, immutable `TerminalGridState` and a `TerminalGridUI` action object. The neutral terminal package owns a private per-grid StarFX store and its blocking controllers inside the current Effection @@ -3538,7 +3598,6 @@ interface TerminalGridUI { interface TerminalCellUI { readonly state: TerminalCellState; - setTitle(title: string): Operation; launch(request: NativeLaunchRequest): Operation; shell(): Operation; } @@ -3548,40 +3607,63 @@ The provider side is separate: ```ts interface TerminalGridView { - readonly state: TerminalGridState; - readonly changes: Stream; + readonly states: Stream; +} + +interface TerminalGridProvider { + host( + request: TerminalGridRequest, + view: TerminalGridView, + ): Resource; } interface TerminalGridHost { readonly closed: Operation; - show(): Operation; + readonly failed: Operation; + show(requiredRevision: TerminalGridRevision): Operation; + converge(requiredRevision: TerminalGridRevision): Operation; launch( cellId: TerminalCellId, request: NativeLaunchRequest, ): TerminalActivity; shell(cellId: TerminalCellId): TerminalActivity; } + +type PresentTerminalGrid = ( + request: TerminalGridRequest, + provider: TerminalGridProvider, +) => Operation; ``` +The provider route hands its `TerminalGridProvider` directly to the +non-contextual presentation authority with the exact routed request. Admission +happens before `provider.host()` is called. The terminal lifecycle then creates +the state and view and acquires the returned host resource in the grid's scope. +A provider object is implementation, not authority: it cannot present another +request, choose another generation, mutate state, or settle the grid by its +return value. + Core supplies one fresh live identity per authored cell. The terminal lifecycle -creates one stable `TerminalCellUI` handle for it, and core installs that exact -handle in the paired cell's scope. The handle closes over its live -cell identity, so a component calls `setTitle()`, `launch()`, or `shell()` -without passing an index or identifier. Each `state` property reads the current -immutable snapshot; a previously read snapshot never changes. Only the cell UI, -not the grid UI, enters the paired cell's context. The canonical stable -contextual API returns it through -`useTerminalCellUI(): Operation`; absence means the -caller is outside a grid cell. The required `` prop seeds state -before presentation. `setTitle()` accepts the same non-empty string and replaces -that title until another update or grid teardown. It completes once that state -transition commits and does not wait for rendering. A title update owns no -resource and has no cleanup action. It is live presentation state, not a durable -effect: a fresh incomplete presentation starts from the retained grid's resolved -authored title, and only cell work that runs again can update it again. Rendered -paired-cell content enters the same state through a private controller; no -public `setContent()`, status setter, success action, or failure action exists. -The cell's durable child alone publishes its final outcome. +creates one stable `TerminalCellUI` handle for it and installs that exact handle +while it interprets the corresponding cell operation. The handle closes over +its live cell identity, so a component calls `launch()` or `shell()` without passing an index or +identifier. Each `state` property reads the current immutable snapshot; a +previously read snapshot never changes. Only the cell UI, not the grid UI, +enters the paired cell's context. The canonical stable contextual API returns it +through `useTerminalCellUI(): Operation`; absence means the +caller is outside a grid cell. The required non-empty `` prop is +the only title input in this Story and seeds state before presentation. Title is +not mutable through `TerminalGridUI`, `TerminalCellUI`, context, or a generic +state action. Title mutation is outside this contract. +Rendered paired-cell content enters state through a private controller; no +public title or content setter, generic dispatch, status setter, success action, +or failure action exists. The cell's durable child alone publishes its final +outcome. Core reaches that controller only through the issued cell-output sink +installed while its lazy operation is interpreted. Each append completes after +the immutable aggregate commit and before the next authored effect; it does not +wait for provider rendering. This makes every earlier completed output emission +causally prior to a later `launch()` or `shell()` controller without exposing an +imperative presentation API to authors, components, or providers. Context makes the exact issued cell handle available for composition; it is not provider authority. A replacement context can intercept or refuse work but @@ -3592,21 +3674,65 @@ the issued cell's `launch()` action rather than falling through to the root foreground launcher. This changes no native request, Agent-session identity, or session-coordinator authority. -The provider receives a separate read-only `TerminalGridView`: its current -immutable state and a scope-bound stream of subsequent snapshots. It never -receives `TerminalGridUI`, a cell action handle, the StarFX store, or mutation -authority. It may coalesce intermediate snapshots while converging on the most -recent one, but it never intentionally renders an older snapshot after a newer -one. Ordinary state actions do not await rendering. `show()` is different: it -sets the desired grid phase to visible and awaits the provider effect that -presents the complete latest grid. A rendering, setup, show, launch, shell, or -close-observation failure is fatal to that grid; the provider is never silently -restarted underneath the same live state. +The provider receives a separate read-only `TerminalGridView`. Its `states` +stream is the only provider observation surface. Starting a subscription +atomically captures and emits one current snapshot, then emits only snapshots +with strictly greater revisions. Registering that subscriber and capturing the +first snapshot happen in the same serialized store step, so no state commit can +land between a separate read and subscription. Every subscription has this +property; the provider never combines `ui.state` with a later subscription. +The view exposes neither `TerminalGridUI`, a cell action handle, the StarFX +store, nor mutation authority. + +The state is complete desired presentation, not a delta or command log. The +provider runs one scope-owned renderer lane and applies revisions serially. If +several snapshots arrive while one is being applied, it may discard the +intermediate snapshots and next apply the greatest pending revision because +that snapshot subsumes them. It never starts two renders concurrently, never +applies a revision at or below the greatest one it has completed, and advances +its private applied revision only after every provider effect for the chosen +snapshot succeeds. A request to converge through revision `r` completes when a +complete snapshot with revision at least `r` has been applied; coalescing to a +later revision therefore satisfies earlier waiters without rendering old state +after new state. + +Ordinary private state commits do not await rendering. `TerminalGridUI.show()` +is different: its blocking controller commits `visible`, captures that +resulting revision, and calls `host.show(requiredRevision)`. The host first +converges through that captured revision and only then atomically presents the +prepared grid. A later state commit may be included but cannot make `show()` +return before the captured requirement is visible. + +Before `TerminalCellUI.launch()` or `shell()` asks the host to transfer the +cell's terminal, its blocking controller admits the action, commits `launching`, +and captures the resulting revision after all causally prior cell output. It +awaits `host.converge(requiredRevision)` before calling `host.launch()` or +`host.shell()`. A later applied aggregate revision satisfies that requirement +only because it contains the complete desired state, including that cell's +earlier output. Cancellation or failure while convergence is pending invokes no +host launch or shell operation, acquires no terminal activity, and establishes +no readiness. + +The host's `failed` operation is distinct from reader `closed`. The grid +lifecycle observes it from host acquisition until release, including while no +action is awaiting the renderer. It remains pending during ordinary operation +and yields the provider error when background work fails; the lifecycle raises +that exact error. Unexpected termination of the state subscription or renderer +while the host remains acquired is failure, not successful convergence or +reader close. A background subscription, renderer, setup, show, convergence, +launch, shell, or close-observation failure is fatal to that grid, fails every +pending convergence waiter, and enters the same complete structured teardown. +Reader departure settles only `closed`; ordinary resource release settles +neither signal as a false event. The provider is never silently restarted +underneath the same live state. The live state is one fixed aggregate: ```ts +type TerminalGridRevision = number; + interface TerminalGridState { + readonly revision: TerminalGridRevision; readonly phase: "preparing" | "visible" | "closing" | "closed"; readonly columns: number; readonly rows: number; @@ -3629,14 +3755,30 @@ interface TerminalCellState { } ``` +Revision is a live-only, per-grid, non-negative safe integer. The initial +snapshot is revision zero. Each atomic state commit that changes the desired +aggregate creates a new immutable snapshot and increments the revision exactly +once; a no-op creates neither a revision nor an emission. Store commits are +serialized even while action effects in distinct cells run concurrently. A +revision is never authored, retained, replayed, diagnosed, or passed to an Agent +or native-launch request. Partial replay starts a new live sequence at zero; +completed replay creates none. An attempted increment past +`Number.MAX_SAFE_INTEGER` fails the grid before publishing an ambiguous +revision. + +`content` is the complete rendered Markdown display desired for that cell, not +an output event. A later snapshot therefore includes all earlier cell output +that remains part of the presentation. Native Agent and shell terminal bytes +never enter this state. + `cellId` is minted by core for one live grid. It keeps a cell handle, state updates, provider effects, and a provider's private pane binding together even -if a later feature changes positions. It is not authored, retained, replayed, +when positions change. It is not authored, retained, replayed, diagnosed, placed in a native or Agent request, or used as provider identity. The `cells` arrays remain in authored order; row and column say where a cell is currently presented. Incomplete replay mints fresh live cell identities. The -identity is the seam a later Story may use for live movement or resizing; this -contract adds no move, resize, reorder, or durable-layout action. +identity permits movement or resizing without changing cell identity; this +contract exposes no move, resize, reorder, or durable-layout action. A cell begins `starting`. Admitting `launch()` or `shell()` publishes `launching`; acquiring the provider's terminal activity at the actual child @@ -3647,8 +3789,8 @@ work settles. Sequential launches may therefore move `running` back through `launching` while the cell flow remains active. Distinct cells act concurrently. Overlapping terminal activities in one cell fail immediately as busy rather than entering a hidden queue, and the next activity is admitted only after the -prior cleanup and quiescence proof. `setTitle()` may run while an activity is -live. Once close commitment stops admission, every new public action refuses. +prior cleanup and quiescence proof. Once close commitment stops admission, +every new public action refuses. Readiness is not something anybody acknowledges. A terminal activity is a resource whose acquisition happens only once its child has actually spawned, and @@ -3693,36 +3835,43 @@ becomes `closed` rather than failed. The grid runs as one structured scope: -1. Core validates the whole structural layout, takes the foreground-terminal - lease, and flushes root output. +1. Core validates the whole structural layout, creates the lazy cell operations + and source-aware journal adapter, and acquires `terminalGrid()`. Its task + reconciles the layout before live work, then a non-replayed grid takes the + foreground-terminal lease and flushes root output through terminal's + contextual host boundary. 2. The terminal lifecycle admits the presentation — exact request, generation, not already used — and only then creates the live UI state and acquires the provider's host resource with its read-only view. Acquisition prepares the - entire hidden - grid: every pane endpoint and its supervision. No grid is visible yet, and a - refused presentation creates no store and acquires nothing at all. -3. Core starts the pane child operations concurrently, using deterministic - durable child identities derived from the grid expansion and authored array - position. A paired pane begins its document flow and a self-closing pane - begins its shell. + entire hidden grid: its gap-free state subscription, single renderer lane, + every pane endpoint, provider-failure observation, and supervision. No grid + is visible yet, and a refused presentation creates no store and acquires + nothing at all. +3. The terminal lifecycle starts the lazy cell operations concurrently, using + the core-supplied journal adapter to establish deterministic durable child + identities derived from the grid expansion and authored array position. It + interprets each operation under that cell's issued `TerminalCellUI`. A paired + pane begins its document flow and a self-closing pane begins its shell. 4. A pane is ready only when its terminal activity is acquired, which happens only once its child has actually spawned. Reserving an endpoint, allocating a process identifier, or receiving output is not acquisition. A child that starts and exits immediately can be both ready and settled. 5. Only after every cell reaches readiness does the grid controller run - `TerminalGridUI.show()`. The action publishes `visible` and awaits the host's - atomic presentation of the complete latest state. Any acquisition or - cell-start failure before this - barrier cancels every cell, awaits complete teardown, releases the hidden - grid, and fails without exposing a partial grid. Agent preparation or - retained route work that occurred before a failed native spawn remains + `TerminalGridUI.show()`. The action publishes `visible`, captures that + revision, and awaits the host's convergence and atomic presentation through + it. Any acquisition, convergence, background-render, or cell-start failure + before this barrier cancels every cell, awaits complete teardown, releases + the hidden grid, and fails without exposing a partial grid. Agent preparation + or retained route work that occurred before a failed native spawn remains durable; atomicity covers terminal presentation and lifecycle, not rollback of earlier provider effects. 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The grid remains present after all panes settle until the reader closes or leaves it. -7. The host's `closed` operation first crosses the private live close boundary, - then the controller publishes `closing` and begins ordered teardown: prevent +7. The lifecycle observes the host's independent `closed` and `failed` + operations for its whole acquired lifetime. `closed` first crosses the + private live close boundary, then the controller publishes `closing` and + begins ordered teardown: prevent new cell actions, ask live cell children to close, await every child and finalizer, release the provider's host resource — which is what destroys it, once — restore the root terminal, publish `closed`, and only then release the @@ -3732,10 +3881,28 @@ The grid runs as one structured scope: document never continues while an observable cell child or provider-owned process can still act through the grid. +The task returned by `terminalGrid()` settles only after the selected grid +outcome is retained, every cell and renderer task has settled, the provider host +resource has been released, the root terminal has been restored, and the +foreground lease has been released. Releasing the outer resource before that +settlement cancels the task and waits for the same complete teardown; it does +not detach the computation. + +An action cancelled while waiting for convergence never transfers a terminal. +Before reader-close acknowledgement, parent cancellation cancels the cell action +and the whole grid scope, fails its pending waiters through resource teardown, +and awaits the renderer, cell, and host finalizers before it propagates. Reader +close follows the established cooperative close handshake; it prevents new +actions and cancels any admitted cell work still waiting for convergence before +a provider child exists. After close acknowledgement, parent cancellation +remains deferred by the existing rule. A background provider failure wins as +the fatal grid result under the existing failure and cleanup precedence, even +when no foreground action is currently waiting on the renderer. + The provider host's read-only `closed` settlement proposes the live close boundary. The boundary is crossed when the grid owner has entered a -cancellation-deferred -await of the grid's durable child and acknowledges that proposal; only then may +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 @@ -3859,7 +4026,8 @@ should be refused rather than ignored belongs to a versioned root boundary that does not exist yet. Until it does, the grid's obligation is the narrower one it can actually discharge: retain the complete authored structure, and open the structure it retained rather than the one the file now shows. It creates fresh -live state and cell identities and acquires a fresh provider host: completed +live state at revision zero and fresh cell identities and acquires a fresh +provider host: completed cell children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own @@ -3873,13 +4041,16 @@ a journal. The package boundary follows the provider boundary. `@executablemd/terminal` owns the provider-neutral state, UI, view, host and cell action types; the -private StarFX store and controllers; presentation admission, terminal -activities, errors, grid lifecycle and replay; POSIX process observation and -quiescence; and the controlled evidence provider. `@executablemd/terminal-tmux` -owns tmux commands, workers, private IPC, rendering convergence, and the tmux -host resource. Core imports the neutral package and keeps structural expansion, -source-aware journal descriptions, execution-profile integration, and Agent -session behavior. The Deno and compiled CLI entrypoints install the POSIX +private StarFX store and controllers; monotonic revisions, atomic observation +and convergence contracts; presentation admission, terminal activities, errors, +grid lifecycle and replay; POSIX process observation and quiescence; and the +controlled evidence provider. `@executablemd/terminal-tmux` owns tmux commands, +workers, private IPC, the serialized renderer, applied-revision tracking, and +the tmux host resource. Core imports the neutral package and keeps structural +expansion, source-aware journal descriptions, execution-profile integration, +and Agent session behavior. It supplies the neutral `TerminalGridJournal` +adapter and lazy cell operations without the terminal package importing core. +The Deno and compiled CLI entrypoints install the POSIX observer and tmux provider for an ordinary foreground `xmd run`; Node, Bun and every workflow profile install neither. A terminal grid is run presentation, not workflow orchestration. @@ -3911,9 +4082,11 @@ The extraction applies to the stack's implementation modules as follows: | `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in a non-terminal CLI module | The neutral package root exports the provider-neutral launch, state, UI, view, -host, activity, layout, routing and error contracts together with -`useTerminalCellUI()`. Its `./lifecycle` entrypoint -exports grid execution, presentation installation and replay; `./processes` +host, activity, layout, routing, journal-adapter and error contracts together +with `useTerminalCellUI()`. Its `./lifecycle` entrypoint +exports grid execution, presentation installation, replay, and the +integration-only cell-output sink core invokes while expanding paired content; +`./processes` exports process facts, snapshots and quiescence; `./posix` exports reusable POSIX observation and foreground-child adapters; and `./test` exports only controlled providers, launchers, logs and signals. These are facets of one @@ -3937,6 +4110,14 @@ workspace members. Publication places terminal after durable streams, terminal-tmux and core after terminal, and CLI after terminal-tmux, terminal, core and runtime. +Issue #781 later changes the unshipped authored names to `` and `` +and the package names to `@executablemd/grid` and +`@executablemd/grid-tmux`. That semantic restack preserves this exact resource, +state revision, atomic observation, convergence, failure, journal-adapter, +replay, cancellation, and teardown contract. It does not restore a mutable title +action, a split current-state/changes provider API, a callback-shaped lifecycle, +or any compatibility export for the names it replaces. + ### tmux provider The first production provider uses tmux where the Deno or compiled host has a @@ -5275,7 +5456,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 grid whose statically declared direct cells begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, and tear down completely before document execution continues. One scope-owned `TerminalGrid` task owns the whole durable computation. Incomplete work creates a per-grid immutable StarFX state, stable contextual `TerminalCellUI` action handles and one resource-owned `TerminalGridHost`; the provider observes a read-only view and cannot mutate state. The title prop seeds state, later title actions update it, native and shell actions are exclusive within one cell but concurrent across cells, and `show()` presents the complete latest grid after readiness. Reader close is an implicit host lifecycle signal rather than a public action. Completed replay creates none of the live state or provider objects; partial replay mints fresh live cell identities and continues incomplete effects under their existing position-derived durable identities. `@executablemd/terminal` owns the neutral live contract, lifecycle, replay and reusable POSIX observation; `@executablemd/terminal-tmux` owns tmux rendering, IPC and workers; core owns structural syntax, source-aware journal descriptions and profile integration; and runtime-named entrypoints install host wiring with no compatibility exports from old terminal paths | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; controlled non-tmux evidence proves the provider-neutral state, action, lifecycle and replay contract; implementation unbuilt | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral grid whose statically declared direct cells begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, and tear down completely before document execution continues. One scope-owned `TerminalGrid` task owns the whole durable computation. Incomplete work creates private immutable StarFX state with monotonic live revisions, stable contextual `TerminalCellUI` action handles and one resource-owned `TerminalGridHost`; the provider observes an atomic current-and-newer stream, serializes and coalesces rendering, and cannot mutate state. The authored title is fixed for this contract. Native and shell actions await convergence of preceding cell output before transferring the terminal, remain exclusive within one cell and concurrent across cells, and `show()` awaits its captured visible revision before atomic presentation. Reader close and background provider failure are distinct observed lifecycle inputs. Core supplies lazy cell operations and a source-aware journal adapter without creating a package cycle. Completed replay creates none of the live state or provider objects; partial replay starts a fresh revision sequence and cell identities while continuing incomplete effects under their existing position-derived durable identities. `@executablemd/terminal` owns the neutral live contract, lifecycle, replay and reusable POSIX observation; `@executablemd/terminal-tmux` owns tmux rendering, IPC and workers; core owns structural syntax, source-aware journal descriptions and profile integration; and runtime-named entrypoints install host wiring with no compatibility exports from old terminal paths | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; controlled non-tmux evidence proves the provider-neutral state, convergence, lifecycle and replay contract; implementation unbuilt | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that cell's lease through its contextual `TerminalCellUI`. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 97f13424e..1249a2bc5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9276,13 +9276,10 @@ ordinary sequential document flow. A self-closing pane starts the default interactive shell configured by the host. The shell choice is live host policy, not document data. An expression may derive the label from document props, such as the cell's role and current issue. The resolved prop seeds the live cell -title before the grid is shown. A component running in that cell may replace the -title through its contextual terminal-cell UI. The replacement must also be a -non-empty string; it persists until another update or grid teardown and owns no -resource. There is no second authored title form in this version. The update is -live presentation state, not a durable effect; a fresh -partial-replay presentation begins from the retained resolved title, and only -cell work that runs again may update it again. +title before the grid is shown. It is the only title input in this Story: +neither the grid UI nor the contextual terminal-cell UI exposes a title +mutation. A fresh partial-replay presentation begins from the retained resolved +title. Title-changing components and actions are outside this contract. Neither element accepts `as`. Neither renders content into the surrounding document or returns a value. Text that a paired pane renders is displayed in @@ -9344,11 +9341,49 @@ sibling render to the root again. The private grid resource treats the entire grid as one computational unit. It returns a running task which the submitting expansion awaits directly; it does not accept pane work through a callback and exposes no `open()`, `close()`, live -grid registry, supervisor, or public close-boundary object. The resource owns -the durable grid child, live UI state, provider host, pane children, close -commitment, and teardown in the submitting expansion's scope. This scope -parentage preserves the durable identities and inherited context of the exact -authored position. +grid registry, supervisor, or public close-boundary object. Its neutral +signature accepts the resolved layout, one ordered `{ cellId, operation }` +record per cell, and a `TerminalGridJournal`, and returns +`Resource>`. Each cell operation is a lazy Effection +`Operation`; constructing it performs nothing. The resource owns the +layout reconciliation, durable grid child, foreground-terminal lease and root +output flush, live UI state, provider host, pane children, close commitment, +and teardown in the submitting expansion's scope. This scope parentage +preserves the durable identities and inherited context of the exact authored +position. + +Core mints each live-only `cellId`, constructs the paired expansion or +self-closing shell as that lazy operation, and supplies the journal adapter. The +terminal lifecycle validates the ordered work against the layout and interprets +each operation exactly once beneath its position-derived durable child, after +installing that cell's issued `TerminalCellUI` in its scope. Cell work receives +no callback argument or resource handle; it reads the contextual UI only when +it runs. + +The lifecycle also installs an issued, closure-bound cell-output sink in that +scope. At every completed paired-content output boundary, core appends the +rendered bytes through +`appendTerminalCellOutput(text: string): Operation` and waits for the +private aggregate commit before it continues expansion. Empty text is a no-op; +other calls append in call order, leaving `content` as the complete output so +far. The operation is available only from the terminal package's `./lifecycle` +integration facet; it is not a UI method or provider surface and exposes no +store, dispatch, title, status, identity, or host authority. Outside the issued +cell scope it is inert. An append does not await rendering. It establishes only +that the output belongs to the current immutable desired state before a +causally later cell action begins. + +`TerminalGridJournal` is defined by the neutral terminal package and implemented +by core with the source expansion already closed over. Its three operations +reconcile the resolved retained layout, retain or replay the whole grid +operation, and retain or replay one cell outcome by authored array position. +Only provider-neutral layouts, outcomes, and lazy operations cross this +boundary. The terminal package imports no core journal type. Completed grid +replay returns from the adapter without interpreting the live operation; partial +replay reaches only the incomplete cell operations. Core's adapter creates the +source-described durable grid or cell child when the terminal lifecycle invokes +the corresponding retention operation. The lifecycle owns the resulting live +tasks under the submitting expansion and creates no second durable lineage. For incomplete work, the neutral terminal package creates an immutable live user interface backed by a private per-grid StarFX store in that Effection @@ -9356,45 +9391,68 @@ scope. The UI exposes its current state, stable cell action handles in authored order, and `show()`. It creates no independent scope and does not use retrying resource management for the provider. The provider receives a separate read-only state view and supplies a resource-owned terminal host with `show`, -per-cell `launch` and `shell` effects, and the reader-close signal. The +state convergence, per-cell `launch` and `shell` effects, and independent +reader-close and provider-failure signals. The request's exact object and installation generation still authorize presentation before any of those live values or resources are created. Contextual UI handles carry no presentation authority. Launch, shell and show use StarFX's blocking controller execution so callers can await every state transition and non-render -effect the action owns; raw non-blocking dispatch performs none of them. +effect the action owns; raw non-blocking dispatch performs none of them. Store +commits remain serialized while effectful actions in distinct cells may run +concurrently. Opening a grid is atomic from the reader's perspective: -1. Core validates the whole layout and acquires the root foreground-terminal - lease. Another root native launch or terminal grid cannot hold it at the same - time. +1. Core validates the whole layout and supplies the lazy cell operations and + source-aware journal adapter. The grid task reconciles the layout before live + work. Unless completed replay returns without interpreting that work, the + terminal lifecycle acquires the root foreground-terminal lease and flushes + root output through terminal's contextual host boundary. Another root native + launch or terminal grid cannot hold the lease at the same time. 2. The provider validates its live prerequisites and prepares every terminal - endpoint in a hidden 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 ``. + endpoint, its gap-free state subscription, one serialized renderer, and its + failure observer in a hidden grid. It presents nothing yet. +3. The terminal lifecycle begins all lazy authored pane operations concurrently + under their exact issued cell contexts and position-derived durable children. + A self-closing pane starts its shell. A paired pane expands until it starts + its first interactive child, normally ``. 4. A pane reaches readiness only when its terminal activity is acquired, which happens only once that interactive child has spawned. A paired pane that settles without acquiring one fails startup. Merely allocating an endpoint or process identifier, or receiving the child's first output, is not acquisition; an interactive child that starts and immediately exits is both ready and settled. -5. The provider attaches the complete grid only after every pane is ready. - -An incomplete grid owns one live immutable aggregate with phase, dimensions, -and ordered cell states. Each cell state contains a live-only `cellId`, title, -row, column, rendered Markdown content, and one of `starting`, `launching`, -`running`, `succeeded`, `failed`, or `closed`. Agent and shell PTY bytes never -enter it. Core supplies one fresh identity per authored cell; the terminal layer -creates one stable `TerminalCellUI` handle over that identity, and core places it -in the paired cell's context. The handle exposes the current immutable cell -state plus `setTitle()`, `launch()`, and `shell()`; callers pass no cell index or -identity. +5. After every pane is ready, the grid commits `visible`, captures that state + revision, and asks the provider to show the grid. The provider converges + through the captured revision before it atomically attaches the complete + grid. + +An incomplete grid owns one live immutable aggregate with a revision, phase, +dimensions, and ordered cell states. Revision zero is the initial snapshot. +Each atomic state commit that changes the aggregate increments that live-only +non-negative safe integer exactly once; a no-op creates no revision or stream +emission. Each cell state contains a live-only `cellId`, the authored title, +row, column, the complete rendered Markdown content desired for that cell, and +one of `starting`, `launching`, `running`, `succeeded`, `failed`, or `closed`. +Agent and shell PTY bytes never enter it. Revisions are never authored, +retained, replayed, diagnosed, or placed in Agent or native launch data. An +attempt to increment beyond `Number.MAX_SAFE_INTEGER` fails the grid before an +ambiguous revision is published. + +Core supplies one fresh identity per authored cell. The terminal layer creates +one stable `TerminalCellUI` handle over that identity and installs it while it +interprets the corresponding cell operation. The handle exposes the current +immutable cell state plus `launch()` and `shell()`; callers pass no cell index +or identity. The authored `title` prop is the only title input and no live title +setter exists in this Story. The canonical `useTerminalCellUI()` operation returns that exact handle or `undefined` outside a cell; the grid UI itself is not contextual. There is no content setter, generic dispatch, status setter, success action, or failure action. Paired Markdown output and final pane outcomes are published by -the private pane lifecycle. +the private pane lifecycle. Core's integration-only output sink awaits each +private content commit, so completed output before a later `launch()` or +`shell()` is causally included in the revision that action captures even though +ordinary rendering remains asynchronous. `launch()` and `shell()` run a terminal activity as the cell's owner. An activity is a resource acquired only after its child has spawned, so acquisition @@ -9409,8 +9467,7 @@ The same private lifecycle state admits at most one activity in a cell and stops admitting all public actions when the grid closes. Different cells do not contend. An overlapping launch or shell in one cell refuses as busy rather than waiting in a hidden queue; sequential use is admitted only after the prior -activity has completely released the terminal. `setTitle()` remains available -while an activity is live. +activity has completely released the terminal. When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private @@ -9440,15 +9497,42 @@ pane child alone selects `succeeded` or `failed` after the whole pane flow settles, and a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -The provider receives only a read-only view of the current immutable aggregate -and a scope-bound stream of later snapshots. It receives no UI action handle, -store, or mutation authority. It may skip transient snapshots while converging -on the newest one, but never intentionally renders an older snapshot after a -newer one. Title and state actions do not wait for an ordinary render. The one -`show()` action publishes a visible grid phase and awaits the provider's atomic -presentation of the complete latest state. A provider setup, render, show, -launch, shell, or close-observation failure fails the grid and tears it down; -the provider is not restarted underneath the same state. +The provider receives only a read-only state stream. Each subscription +atomically registers itself and captures one current snapshot in the same +serialized store step. That snapshot is emitted first, followed only by +strictly greater revisions, so there is no read/subscribe gap. The provider +receives no separate current-state read, UI action handle, store, or mutation +authority. + +Each snapshot is complete desired state rather than a delta. One scope-owned +renderer applies revisions serially and advances its private applied revision +only after every provider effect for that snapshot succeeds. While one render +is blocked it may coalesce several pending snapshots into the greatest revision +because that full state subsumes them. It never renders at or below an already +applied revision, and a later applied revision satisfies every convergence +waiter for an earlier one. Thus no older state can render after newer state. + +Ordinary private state changes do not await rendering. `show()` commits +`visible`, captures that revision, and waits for the host to converge through it +before atomic attach. Before a cell `launch()` or `shell()` calls the provider +operation, its blocking controller commits `launching`, captures that revision +after all causally prior cell output, and waits for host convergence through it. +Only then may the provider transfer the cell terminal. Cancellation or failure +during that wait invokes no launch or shell, acquires no terminal activity, and +does not satisfy readiness. + +The provider exposes background failure separately from reader close. The grid +lifecycle observes both from host acquisition until release, so a renderer or +subscription failure with no foreground waiter still fails the grid. The +failure operation remains pending during ordinary work, yields the exact +provider error when background work fails, and is cancelled by normal host +release rather than falsely succeeding. Unexpected termination of the state +subscription or renderer while the host remains acquired is provider failure, +not successful convergence or reader close. Provider +setup, render, show, convergence, launch, shell, or close-observation failure +fails pending convergence, cancels the grid, and enters complete teardown; the +provider is not restarted underneath the same state. Reader departure alone +proposes the ordinary cooperative close boundary. The provider host's read-only `closed` operation proposes reader close. There is no callable grid-close action. Reader close takes effect when the grid owner has @@ -9463,12 +9547,28 @@ element settle and a later document sibling begin. There is no implicit timeout; parent cancellation and an enclosing execution deadline use the same complete teardown. +The task the resource returns settles only after the selected result is +retained, every cell and renderer task has settled, the provider host resource +has been released, the root terminal has been restored, and the foreground +lease has been released. Releasing the resource early cancels the task and +waits for that same teardown; it never detaches the grid. + Pane work and the finalizers it installs are scoped inside that pane's durable child. Reader close does not halt that durable child. It cooperatively closes the pane's live work and the child retains `closed` only after its work and finalizers have settled. A pane that had already succeeded or failed keeps that outcome. +Before reader-close acknowledgement, parent cancellation while `show()`, +`launch()`, or `shell()` waits for convergence cancels the pending action and +whole grid scope, invokes no not-yet-started provider child, and awaits the +renderer, cells, host, and terminal teardown before propagating. Reader close +stops admission and cooperatively closes cell work still waiting for +convergence. After its acknowledgement, the existing cancellation-deferred +close rule applies. A background provider failure remains the fatal grid result +under the existing failure and cleanup precedence even when cancellation or +reader close is also observed. + #### Native launch ownership inside a cell Each cell receives a cell-scoped native launcher. A `` there @@ -9563,9 +9663,11 @@ whose completed `Close` was acknowledged remains settled. Partial replay compares the **resolved** layout first — the column count and each cell's title — and refuses a change before the foreground lease is taken and before any provider is contacted. It then mints fresh live `cellId` values, -creates a new live store and UI, and acquires a new provider host. Completed cell -children appear as already-settled statuses and perform no effects; incomplete -children continue from their own durable records. +creates a new live store and UI beginning at revision zero, and acquires a new +provider host. Completed cell children appear as already-settled statuses and +perform no effects; incomplete children continue from their own durable +records. The prior attempt's live revision sequence is neither restored nor +compared. The live `cellId` keeps the cell handle and provider endpoint bound if a later contract permits position changes. This version exposes no move, resize, @@ -9624,6 +9726,13 @@ neutral terminal package. StarFX is private to the neutral package. The old runtime, core and CLI terminal modules and exports do not remain as compatibility paths; every repository import uses the canonical package surfaces. +Issue #781 later changes the unshipped surface to `` and `` and the +package names to `@executablemd/grid` and `@executablemd/grid-tmux`. Its semantic +restack preserves this resource, lazy-operation, journal-adapter, state revision, +atomic observation, convergence, background-failure, replay, cancellation, and +teardown contract. It adds no compatibility aliases and does not restore a +mutable title action or split provider read-and-subscribe API. + ## 7. Entry point @@ -11639,18 +11748,20 @@ test derives a core result from a provider identifier. | TG8 | Readiness barrier | A cell begins `starting`, admission selects `launching`, and only the runtime child-spawn event selects `running` and satisfies readiness. Endpoint or PID allocation, preparation, route publication, detach and first output do not. A paired cell that settles without a spawn fails startup, while a child that spawns then exits immediately is ready and settled | | TG9 | Atomic startup failure | Each provider-preparation position and each authored cell start can fail; the host never shows the grid, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous cell failures report the first authored position | | TG10 | Independent settlement | After show, one cell can exit successfully or fail while siblings remain live and usable; its final status stays visible. An individual launch settling does not finish a paired cell with later authored work. Closing a grid with failed cells reports the first failed authored position, while teardown cancellation itself does not create a cell failure | -| TG11 | Terminal versus session ownership | Distinct cell leases permit concurrent native launches; one cell refuses overlapping launch or shell actions rather than queueing them; and sequential work is admitted only after the previous child, its observable descendants and group members, and every other holder of that cell's terminal are gone. Title updates remain admissible while a child runs. Two cells naming one logical Agent session still contend through the unchanged non-waiting coordinator | +| TG11 | Terminal versus session ownership | Distinct cell leases permit concurrent native launches; one cell refuses overlapping launch or shell actions rather than queueing them; and sequential work is admitted only after the previous child, its observable descendants and group members, and every other holder of that cell's terminal are gone. Two cells naming one logical Agent session still contend through the unchanged non-waiting coordinator | | TG12 | Reader close | The host's read-only close signal prevents every later public cell action, cancels every live cell scope, awaits each child, shell and provider finalizer, releases the exact provider host, restores the root terminal, releases the foreground lease, and only then starts the following document sibling. No public `close()` action exists | | TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; a provider setup, render, show, launch, shell or close-observation failure cancels every cell and fails the grid without restarting the provider; cleanup is attempted for all resources under existing failure precedence | | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while creating no StarFX store, UI, cell handle, state stream, provider host or pane and contacting no shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout creates fresh live cell identities, state and UI and acquires a fresh provider host; completed cell children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Cell count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and cell outcomes contain no explicit ordinal, pane index, live `cellId`, provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | -| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19–TG21; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, consumes only the read-only state view, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, shows the grid only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before cell start with no provider installed | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19–TG24; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, consumes only the read-only state view, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, shows the grid only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before cell start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live cell enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets cell and provider teardown complete, retains the cell as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters cell work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | -| TG20 | One-directional live state | The title prop seeds state before show; the exact contextual cell handle exposes current immutable state, refuses an empty title, and updates a valid title without a cell identifier; paired Markdown content reaches state through no public setter; the provider can only observe the current state and a scope-bound snapshot stream. Multiple updates may be coalesced, no older snapshot is rendered after a newer one, and state actions do not wait for ordinary rendering | -| TG21 | One scope-owned computation | `terminalGrid()` yields one task whose result is observed directly. Its submitting expansion owns the durable grid, UI state, provider host, cell children and teardown; cancellation of that scope takes all of them down. No registry, supervisor, callback-shaped lifecycle or public close boundary participates, and retained recovery never depends on a live handle | +| TG20 | Atomic one-directional state | Revision zero is the initial immutable aggregate. A subscription started while a controlled commit crosses the former read/subscribe gap emits either the state before that commit and then the next revision, or the state after it first, but never misses it, duplicates a revision, or moves backward. No-op state work emits nothing, and overflow is refused before publication. The authored title is immutable in this Story. Paired Markdown output reaches state through the lifecycle's issued integration sink: an append is observed committed before the next authored effect but does not await rendering. No public setter exists, and neither provider nor contextual cell code receives store or generic dispatch authority | +| TG21 | One scope-owned computation | `terminalGrid(layout, cells, journal)` accepts ordered live IDs with lazy Effection cell operations and a core-supplied source-aware journal adapter, and yields one task whose result is observed directly. Constructing cell work runs nothing; the terminal lifecycle interprets each operation exactly once under its issued contextual cell UI and position-derived durable child. Its submitting expansion owns the durable grid, store, state subscription, renderer, provider host, cell children and teardown; cancellation of that scope takes all of them down. No core import, registry, supervisor, callback-shaped lifecycle or public close boundary participates, and retained recovery never depends on a live handle | | TG22 | Canonical package boundary | The neutral terminal package contains the state, controllers, lifecycle, replay, action and provider contracts, process observation and reusable POSIX implementation with StarFX private; the tmux package contains rendering, IPC and worker behavior; core retains structural expansion, source-aware journal descriptions and profile integration; runtime-named Deno and compiled CLI entrypoints install the host wiring. Import and export checks find no old runtime, core or CLI terminal implementation path, compatibility re-export, terminal-to-core/runtime/CLI cycle, or StarFX type on a cross-package surface | +| TG23 | Revision convergence | With revision 1's render blocked, commits 2 and 3 coalesce so the provider completes 1 and then 3, never renders 2 after 3, and advances its applied revision only after each complete provider effect. A waiter for revision 2 is satisfied by applied revision 3. `show()` remains blocked until its captured visible revision is applied and attached. A cell whose preceding Markdown output is pending remains `launching` with zero host launch or shell calls until convergence through its captured revision, then transfers the exact terminal request | +| TG24 | Convergence failure and cancellation | A renderer failure raised while no action is waiting, and an unexpected renderer or state-subscription termination while the host remains acquired, are each observed through the host's separate failure surface, fail every pending waiter, and tear down the grid without being mistaken for reader close. Parent cancellation and reader close are each crossed while a launch waits for convergence; neither starts a provider child or establishes readiness, and releasing the controlled renderer lets every cell, subscription, host and provider finalizer settle before the specified cancellation or close outcome. Signals establish every ordering; elapsed time establishes none | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 93d599d88..75419b318 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -444,8 +444,10 @@ Given `xmd AGENTS.md#Implementor`: cell's lease through the cell-scoped native launcher. A host with no applicable terminal refuses here — before an agent is resolved, so learning that this invocation cannot launch anything costs no availability probe. -7. The host flushes what that terminal has pending, so the native UI does not - open over half-written output. +7. At the root, the host flushes what that terminal has pending. In a grid cell, + the cell action commits `launching`, captures that live state revision after + all causally prior Markdown output, and awaits provider convergence through + it. In either case the native UI cannot open over pending document output. 8. The provider resolves the logical Agent and Session against the contextual cwd, and takes exclusive ownership of that session. @@ -733,8 +735,9 @@ session coordinator The grid owns the root foreground-terminal lease. Its lifecycle creates one stable `TerminalCellUI` per authored cell, closes that handle over a fresh live-only `cellId`, and owns the private state that admits work, observes -readiness, and stops admission at close. Core installs that exact cell UI in the -paired cell's scope. `Session.Launch` uses the launcher already in scope; it +readiness, and stops admission at close. Core supplies lazy cell operations; the +terminal lifecycle installs the exact cell UI while interpreting each one in +its position-derived durable child. `Session.Launch` uses the launcher already in scope; it receives no cell prop, token, identifier, or mode. Context is composition rather than authority: a constructed lookalike cannot reach a live provider host, and the issued cell UI admits nothing after its grid closes. @@ -773,19 +776,29 @@ the other panes become ready has nevertheless crossed readiness and retains its ordinary exit outcome. A native launch runs through `TerminalCellUI.launch()`. The private controller -asks the provider host for a terminal activity: a resource whose acquisition -happens only once the child has actually spawned. Acquisition is 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 controller publishes `running` only after acquisition, awaits -the acquired operation, and does not complete the action until the activity's -cleanup has swept whatever the launch still holds. A root launch has no cell -activity at all. Readiness is not added to `AgentLaunchRequest`, +first admits the cell, commits `launching`, captures the resulting live grid +revision after all causally prior cell output, and awaits provider convergence +through it. Only then does it ask the provider host for a terminal activity: a +resource whose acquisition happens only once the child has actually spawned. +Core publishes paired Markdown at each completed output boundary through the +issued integration-only cell-output sink and awaits that private store commit, +not rendering, before expansion continues. Therefore output written before the +launch invocation is already in the complete snapshot whose revision the +controller captures; no public content setter or provider mutation participates. +Acquisition is readiness, so nobody is handed an acknowledgement to call — +allocating a PID or observing output is not acquisition, and preparation, +convergence, reservation, or spawn failure occurs before it. Cancellation while +convergence is blocked invokes no provider launch and creates no native child. +The controller publishes `running` only after acquisition, awaits the acquired +operation, and does not complete the action until the activity's cleanup has +swept whatever the launch still holds. A root launch has no cell activity at +all. Readiness is not added to `AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, a retained launch phase, or a process handle, so it changes neither the launch's authored nor its durable contract. The provider-neutral terminal package exports the cell UI action contract and -the host activity boundary, not readiness, busy-state, or closing authority. +the host convergence and activity boundaries, not readiness, busy-state, or +closing authority. Pane work receives its issued contextual cell UI; the grid lifecycle alone waits for readiness, publishes final cell status, and closes admission. The provider receives only the live `cellId` needed to select its physical endpoint; @@ -1201,9 +1214,9 @@ starts Claude, Codex, or a model. Terminal-grid tests additionally install a controlled provider that is not tmux. It exposes readiness, independent pane settlement, reader close, provider -failure, parent cancellation, and teardown completion as test-controlled -operations while using the same core terminal authority and cell-scoped native -launchers. Separate tmux integration evidence exercises the production adapter; +failure, revision convergence, parent cancellation, and teardown completion as +test-controlled operations while using the same core terminal authority and +cell-scoped native launchers. Separate tmux integration evidence exercises the production adapter; core semantics are not inferred from tmux identifiers or process behavior. The tmux evidence covers exact argv over private IPC, the runtime spawn boundary, display that cannot become child input, real terminal job control, explicit @@ -1266,9 +1279,11 @@ Focused tests prove: zero or several without repeating the output; and 24. launches in distinct terminal cells run concurrently while launches in one cell remain exclusive, the same logical Agent session still contends across - cells, cell readiness occurs only after successful native-child start, grid - close awaits launch cancellation and session quiescence, and completed and - partial grid replay preserve the launch's existing identity rules. + cells, a launch invokes no provider child until the captured cell-output + revision has converged, cancellation during that wait launches nothing, + cell readiness occurs only after successful native-child start, grid close + awaits launch cancellation and session quiescence, and completed and partial + grid replay preserve the launch's existing identity rules. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the From 6496818c613128a85b9c80f235861690fe7380b7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 18:24:58 -0400 Subject: [PATCH 22/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Converge=20terminal?= =?UTF-8?q?=20grids=20on=20one=20immutable=20state=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal grid is now a provider-neutral package with one immutable aggregate at its centre. `@executablemd/terminal` owns the native launch seam, the resolved layout, the live state and its revisions, the domain actions, the provider's read-only view and host, routing and installation, the journal boundary, the grid lifecycle, POSIX process facts, and the controlled surfaces a suite drives it through. Core keeps structural scanning, the source-aware journal adapter, and execution-profile integration; it imports the neutral package, and the neutral package takes one thing from anywhere else — durable streams' `Json`, which constrains the retained layout, grid and cell records so a host can write them straight down. Nothing of core, the CLI, a runtime host or a multiplexer crosses that boundary. What a provider sees changed shape. It no longer receives imperative `update`, `display` and `attach` calls: it subscribes to one stream of complete desired states and converges the screen to whichever it is asked for. A subscription registers and takes the snapshot in force in the same step, so no commit can fall between a read and a subscribe. Each semantic change is one immutable snapshot and one revision; a no-op is neither, and a revision that would pass the safe-integer ceiling refuses before publishing. `show()`, `launch()` and `shell()` commit, capture the revision they committed, and wait for the provider to have applied at least it — so an action cancelled while waiting makes no child call and establishes no readiness. A paired cell publishes its output where the bytes land. Every construct that renders into the document appends into the cell's own output owner, and each completed segment at every depth is one append — so output written inside a branch, a loop iteration, a component body or projected content is already part of the desired screen before a later action beside it asks for a terminal. Ownership is the expansion's alone. `terminalGrid()` hands back the running grid as a task: releasing it cancels and joins the cells, the renderer, the provider host and the foreground lease rather than detaching any of them. Array position is the durable identity of a cell — no ordinal, index or key is retained beside it — and the live `cellId` a cell handle closes over is a symbol that is never written down. The grid registry, supervisor, callback lifecycle, public close boundary and pane objects are gone; the reader-close handshake that defers a cancellation until the durable child is finished stays, privately, inside the lifecycle. --- .github/workflows/publish-packages.yml | 11 +- bun.lock | 48 +- deno.lock | 35 + package.json | 2 + packages/acp/src/provider.ts | 2 +- packages/acp/tests/native-launch.test.ts | 6 +- packages/cli/src/agent-stack.ts | 3 +- .../tests/agent-session-coordinator.test.ts | 5 +- .../cli/tests/run-composition-deno.test.ts | 3 +- packages/core/mod.ts | 26 +- packages/core/package.json | 1 + .../core/src/agent/function-components.ts | 3 +- packages/core/src/agent/launch-owner.ts | 3 +- packages/core/src/expand.ts | 268 ++- packages/core/src/terminal-grid.ts | 69 - packages/core/src/terminal/grid.ts | 651 ------ packages/core/src/terminal/journal.ts | 293 ++- packages/core/src/terminal/pane.ts | 72 - packages/core/src/terminal/profile.ts | 4 +- .../core/tests/agent-session-launch.test.ts | 12 +- .../tests/terminal-grid-structure.test.ts | 2 +- packages/core/tests/terminal-grid.test.ts | 1795 ++++++----------- packages/runtime/mod.ts | 41 +- packages/runtime/terminal.ts | 367 ---- .../runtime/tests/terminal-provider.test.ts | 306 --- packages/terminal/deno.json | 14 + packages/terminal/lifecycle.ts | 29 + packages/terminal/mod.ts | 104 + packages/terminal/package.json | 20 + packages/terminal/posix.ts | 13 + packages/terminal/processes.ts | 20 + packages/terminal/src/errors.ts | 66 + packages/terminal/src/grid.ts | 721 +++++++ packages/terminal/src/host.ts | 115 ++ packages/terminal/src/journal.ts | 128 ++ packages/terminal/src/launch.ts | 106 + packages/terminal/src/layout.ts | 116 ++ packages/terminal/src/output.ts | 47 + .../launcher.ts => terminal/src/posix.ts} | 224 +- .../terminal => terminal/src}/presentation.ts | 57 +- packages/terminal/src/processes.ts | 72 + .../src/routing.ts} | 77 +- packages/terminal/src/state.ts | 66 + packages/terminal/src/store.ts | 288 +++ packages/terminal/src/ui.ts | 82 + packages/terminal/test/launcher.ts | 61 + packages/terminal/test/mod.ts | 27 + packages/terminal/test/provider.ts | 419 ++++ packages/terminal/test/signal.ts | 77 + .../tests/native-launcher.test.ts | 10 +- packages/terminal/tests/terminal-grid.test.ts | 1270 ++++++++++++ .../terminal/tests/terminal-processes.test.ts | 100 + .../terminal/tests/terminal-provider.test.ts | 518 +++++ .../test-agent/src/child-configuration.ts | 2 +- packages/test-agent/src/components.ts | 3 +- packages/test-agent/src/controller.ts | 2 +- .../test-agent/tests/native-launch.test.ts | 5 +- pnpm-lock.yaml | 60 + .../tests/jsr-consumer-documentation.test.ts | 2 +- specs/release-process-spec.md | 3 +- 60 files changed, 5750 insertions(+), 3202 deletions(-) delete mode 100644 packages/core/src/terminal-grid.ts delete mode 100644 packages/core/src/terminal/grid.ts delete mode 100644 packages/core/src/terminal/pane.ts delete mode 100644 packages/runtime/terminal.ts delete mode 100644 packages/runtime/tests/terminal-provider.test.ts create mode 100644 packages/terminal/deno.json create mode 100644 packages/terminal/lifecycle.ts create mode 100644 packages/terminal/mod.ts create mode 100644 packages/terminal/package.json create mode 100644 packages/terminal/posix.ts create mode 100644 packages/terminal/processes.ts create mode 100644 packages/terminal/src/errors.ts create mode 100644 packages/terminal/src/grid.ts create mode 100644 packages/terminal/src/host.ts create mode 100644 packages/terminal/src/journal.ts create mode 100644 packages/terminal/src/launch.ts create mode 100644 packages/terminal/src/layout.ts create mode 100644 packages/terminal/src/output.ts rename packages/{runtime/launcher.ts => terminal/src/posix.ts} (58%) rename packages/{core/src/terminal => terminal/src}/presentation.ts (71%) create mode 100644 packages/terminal/src/processes.ts rename packages/{core/src/terminal/provider-api.ts => terminal/src/routing.ts} (76%) create mode 100644 packages/terminal/src/state.ts create mode 100644 packages/terminal/src/store.ts create mode 100644 packages/terminal/src/ui.ts create mode 100644 packages/terminal/test/launcher.ts create mode 100644 packages/terminal/test/mod.ts create mode 100644 packages/terminal/test/provider.ts create mode 100644 packages/terminal/test/signal.ts rename packages/{runtime => terminal}/tests/native-launcher.test.ts (98%) create mode 100644 packages/terminal/tests/terminal-grid.test.ts create mode 100644 packages/terminal/tests/terminal-processes.test.ts create mode 100644 packages/terminal/tests/terminal-provider.test.ts diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 9f56a1e9e..73f2e2483 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do + for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/terminal/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -75,8 +75,15 @@ jobs: package: packages/runtime version: ${{ needs.version.outputs.value }} + terminal: + needs: [version, durable-streams] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal + version: ${{ needs.version.outputs.value }} + core: - needs: [version, durable-streams, runtime] + needs: [version, durable-streams, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/core diff --git a/bun.lock b/bun.lock index e05f3854a..f6698904f 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", "@effectionx/middleware": "0.1.1", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", @@ -26,6 +26,8 @@ "mdast-util-to-string": "^4", "remark": "15", "remend": "^1.2.2", + "semver": "^7.8.5", + "starfx": "0.16.1", "unist-util-select": "^5", "zod": "^4.3.6", }, @@ -37,11 +39,13 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/test-support": "workspace:*", "@executablemd/testing": "workspace:*", "@executablemd/workflow": "workspace:*", "@types/node": "^22.0.0", + "@types/semver": "^7.7.0", "expect": "^30.0.0", "oxfmt": "^0.41.0", "oxlint": "1.74.0", @@ -53,6 +57,7 @@ "name": "@executablemd/acp", "version": "0.12.1", "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", "acpx": "0.12.0", @@ -78,6 +83,7 @@ "@standard-schema/spec": "^1.0.0", "configliere": "^0.4.0", "effection": "4.1.0", + "semver": "^7.8.5", "zod": "^4.3.6", }, }, @@ -94,13 +100,14 @@ "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", "@effectionx/middleware": "0.1.1", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", @@ -132,17 +139,28 @@ "@effectionx/context-api": "0.6.0", "@effectionx/fetch": "0.2.1", "@effectionx/fs": "0.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "effection": "4.1.0", }, }, + "packages/terminal": { + "name": "@executablemd/terminal", + "version": "0.12.1", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/node": "0.2.5", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + "starfx": "0.16.1", + }, + }, "packages/test-agent": { "name": "@executablemd/test-agent", "version": "0.12.1", "dependencies": { "@agentclientprotocol/sdk": "1.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/scope-eval": "0.1.3", "@effectionx/stream-helpers": "0.8.3", "@executablemd/acp": "workspace:*", @@ -160,9 +178,11 @@ "name": "@executablemd/test-support", "version": "0.0.0", "dependencies": { + "@effectionx/fs": "0.3.0", "@effectionx/process": "0.8.1", "@effectionx/test-adapter": "0.7.4", "@effectionx/timebox": "0.4.3", + "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0", "expect": "^30.0.0", }, @@ -176,6 +196,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0", }, }, @@ -183,7 +204,7 @@ "name": "@executablemd/web", "version": "0.12.1", "dependencies": { - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -212,7 +233,6 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/process": "0.8.1", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -249,7 +269,7 @@ "@effectionx/middleware": ["@effectionx/middleware@0.1.1", "", {}, "sha512-ss/bZRkt/xzJNE59r8NR1+0K/xQcIyCm0y9n8FYC8jKdFn51SPe3m3t7EfPcK8zkdjCoTOU7k1UpIXRl26asYA=="], - "@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="], + "@effectionx/node": ["@effectionx/node@0.2.5", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-hL8mROda8Lx375MVS+Ubu86+yMht/I0wOZG5VR6Pel0XUA5ReObQDYvNS6ocW0cNFKnEmvlVLWGWbcjJ+VkVhA=="], "@effectionx/process": ["@effectionx/process@0.8.1", "", { "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/node": "0.2.4", "@effectionx/scope-eval": "0.1.3", "cross-spawn": "^7", "ctrlc-windows": "^2", "shellwords-ts": "^3.0.1" }, "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-xyXlFja0Ill80lQ3IYfksXtJkqVmWuUOogRn/qlHWCAGlZj+MGGF8gOFbyzk/3Kx4pj14riVGgF/cyT5XCzqDw=="], @@ -327,6 +347,8 @@ "@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"], + "@executablemd/terminal": ["@executablemd/terminal@workspace:packages/terminal"], + "@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"], "@executablemd/test-support": ["@executablemd/test-support@workspace:packages/test-support"], @@ -585,6 +607,8 @@ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -753,6 +777,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -929,6 +955,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -937,6 +965,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "2.0.1", "kind-of": "6.0.3" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -957,6 +987,8 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "starfx": ["starfx@0.16.1", "", { "dependencies": { "effection": "^4", "immer": "^11.1.3", "reselect": "^5.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18", "react-redux": "^9" }, "optionalPeers": ["react", "react-dom", "react-redux"] }, "sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ=="], + "streamx": ["streamx@2.28.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "8.0.0", "is-fullwidth-code-point": "3.0.0", "strip-ansi": "6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1049,6 +1081,8 @@ "@durable-streams/state/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], + "@effectionx/process/@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="], + "@executablemd/cli/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@executablemd/durable-streams/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], diff --git a/deno.lock b/deno.lock index 7772922ea..9b3d7f63d 100644 --- a/deno.lock +++ b/deno.lock @@ -107,6 +107,7 @@ "npm:remend@^1.2.2": "1.3.0", "npm:rollup@^4.55.1": "4.62.2", "npm:semver@^7.8.5": "7.8.5", + "npm:starfx@0.16.1": "0.16.1_react@19.2.0_react-dom@19.2.0__react@19.2.0", "npm:tailwindcss@^4.1.10": "4.3.3", "npm:tsx@^4.19.0": "4.23.1", "npm:typescript@5": "5.9.3", @@ -2755,6 +2756,9 @@ "html-void-elements@3.0.0": { "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" }, + "immer@11.1.18": { + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" + }, "immutable@5.1.5": { "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==" }, @@ -3553,6 +3557,9 @@ "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "reselect@5.3.0": { + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==" + }, "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, @@ -3656,6 +3663,20 @@ "escape-string-regexp" ] }, + "starfx@0.16.1_react@19.2.0_react-dom@19.2.0__react@19.2.0": { + "integrity": "sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ==", + "dependencies": [ + "effection", + "immer", + "react", + "react-dom", + "reselect" + ], + "optionalPeers": [ + "react", + "react-dom" + ] + }, "streamx@2.28.0": { "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dependencies": [ @@ -4025,6 +4046,7 @@ "npm:remark@15", "npm:remend@^1.2.2", "npm:semver@^7.8.5", + "npm:starfx@0.16.1", "npm:tsx@^4.19.0", "npm:typescript@5", "npm:unist-util-select@5", @@ -4129,6 +4151,19 @@ ] } }, + "packages/terminal": { + "dependencies": [ + "npm:starfx@0.16.1" + ], + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/node@0.2.5", + "npm:effection@4.1.0", + "npm:starfx@0.16.1" + ] + } + }, "packages/test-agent": { "dependencies": [ "npm:@agentclientprotocol/sdk@1.3.0", diff --git a/package.json b/package.json index d17b1b1fe..14f21370a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "remark": "15", "remend": "^1.2.2", "semver": "^7.8.5", + "starfx": "0.16.1", "zod": "^4.3.6", "unist-util-select": "^5", "mdast-util-to-string": "^4" @@ -57,6 +58,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/test-support": "workspace:*", "@executablemd/testing": "workspace:*", diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba90..c68e7bd1f 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -87,8 +87,8 @@ import { AgentSessionRecoveryRequired, cwd, ExecutableObservationError, - nativeLaunch, } from "@executablemd/runtime"; +import { nativeLaunch } from "@executablemd/terminal"; import type { AgentSessionCoordinator, AgentSessionKey, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 7c3df4976..fe9e42850 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -27,8 +27,10 @@ import type { PreparedLaunchRecord, Session, } from "@executablemd/core"; -import { flushOutput, installControlledLauncher, reserveTerminal } from "@executablemd/runtime"; -import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; +import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; import { diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 4fdb23ab3..3b1241134 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -21,7 +21,8 @@ import { registerAgentProvider, } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; -import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; +import { env as readEnv } from "@executablemd/runtime"; +import { installForegroundLauncher } from "@executablemd/terminal/posix"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 46464641c..7837bef20 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -28,8 +28,8 @@ import { API, createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, - installControlledLauncher, } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, @@ -39,7 +39,8 @@ import { createMemorySessionRouteStore, } from "@executablemd/acp"; import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; -import type { ExecutableObserver, NativeLaunchRequest } from "@executablemd/runtime"; +import type { ExecutableObserver } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5d512e000..8b47cf4e4 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -21,7 +21,8 @@ import { exists, readTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import process from "node:process"; -import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NativeLauncher } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import { Agent, diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 082c8113d..867001e90 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,29 +152,13 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; -// 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"; +// A host reaches the whole terminal-grid capability through +// `installTerminalGridProfile`; the contracts a provider composes against live +// in `@executablemd/terminal` and are imported from there. export { installTerminalGridProfile } from "./src/terminal/profile.ts"; export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; -export { 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 { createTerminalGridJournal } from "./src/terminal/journal.ts"; +export type { GridIdentity } from "./src/terminal/journal.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/package.json b/packages/core/package.json index 5962c603a..a5024ae5c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 043d02b93..c656e64f1 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -23,7 +23,8 @@ import { sessionPlacement } from "./session-request.ts"; import type { ComponentInvocation, FunctionComponent } from "../types.ts"; import type { IdentityClaimant } from "../invocation-identity.ts"; -import { cwd, flushOutput, parseDuration, reserveTerminal } from "@executablemd/runtime"; +import { cwd, parseDuration } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import type { Json, PropsSchema } from "../types.ts"; import type { Expansion } from "../expansion.ts"; import { Agent } from "./agent-api.ts"; diff --git a/packages/core/src/agent/launch-owner.ts b/packages/core/src/agent/launch-owner.ts index 7c7e85285..3be70ec66 100644 --- a/packages/core/src/agent/launch-owner.ts +++ b/packages/core/src/agent/launch-owner.ts @@ -16,7 +16,8 @@ import { createApi } from "@effectionx/context-api"; import { scoped } from "effection"; import type { Operation, Stream } from "effection"; -import { cwd, flushOutput, reserveTerminal } from "@executablemd/runtime"; +import { cwd } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import { Agent, AGENT_API } from "./agent-api.ts"; import type { AgentApi, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index ff4e5d401..52a40c4ea 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,8 +13,8 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +import { createContext, ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; +import type { Context, Operation, Result } from "effection"; import type { FunctionComponent, Segment, @@ -66,12 +66,10 @@ import { terminalTitleMissingMessage, } from "./structural-rules.ts"; import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; -import { terminalGridLayout } from "./terminal-grid.ts"; -import type { PlacedPane } from "./terminal-grid.ts"; -import { 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 { terminalGridLayout, useTerminalCellUI } from "@executablemd/terminal"; +import type { PlacedCell, TerminalCellUI, TerminalCellWork } from "@executablemd/terminal"; +import { appendTerminalCellOutput, terminalGrid } from "@executablemd/terminal/lifecycle"; +import { createTerminalGridJournal } from "./terminal/journal.ts"; import { asBindingViolation, asExpressionViolation, @@ -895,6 +893,11 @@ function* expandListSegments( // Read once: `` publishes its frame for the nested call that expands // its body, so the frame ambient here cannot change while this list runs. const loop = yield* ActiveLoop.get(); + // Whether what this list renders is a terminal cell's own output. Compared by + // identity, because that is the question: these segments reach the cell only + // if they are landing in the array the cell publishes from. + const region = yield* TerminalCellOutput.get(); + const publisher = region !== undefined && region.owner === result ? region : undefined; for (const [index, segment] of segments.entries()) { // A checked failure the document did not authorize ends the work: whatever @@ -1428,6 +1431,14 @@ function* expandListSegments( } } + // One completed output boundary, at whatever depth this list is running: + // a branch, a loop iteration, a component body and projected content all + // append into this same owner, so the cell's state has the bytes before the + // next authored effect anywhere beneath it begins. + if (publisher !== undefined) { + yield* publisher.publish(); + } + // A `` anywhere below this segment ends the iteration here: what the // list already produced stands, and nothing after it expands. if (loop?.broken) { @@ -2155,7 +2166,7 @@ function* expandTerminalGrid( return; } - const placed: PlacedPane[] = []; + const placed: PlacedCell[] = []; for (const pane of structure.panes) { const title = yield* resolvePaneTitle(pane); if (!title.ok) { @@ -2166,8 +2177,8 @@ function* expandTerminalGrid( } const layout = terminalGridLayout(columns.value, placed); - // The grid renders nothing into the document: what a pane shows belongs to - // that pane, and the sibling after `` renders to the root + // The grid renders nothing into the document: what a cell shows belongs to + // that cell, and the sibling after `` renders to the root // again only once the provider has restored it. const identity = { path: site.path, @@ -2175,20 +2186,23 @@ function* expandTerminalGrid( }; 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"); + // One fresh live identity per authored position, minted here and nowhere + // else. It is never retained, replayed, diagnosed, or copied into a native + // or Agent request: array position is what a resumed run reads. + const cells: TerminalCellWork[] = structure.panes.map((pane, index) => ({ + cellId: Symbol(`terminal-cell:${index}`), + operation: cellWork(pane, layout.cells[index]!.title, index, site), + })); + const journal = createTerminalGridJournal(identity, cells.length); + + // The grid is one computational unit: acquiring the resource starts it + // beneath this expansion, and awaiting the task it returns is how this + // expansion learns what the grid retained. Releasing it early cancels and + // joins the same work rather than detaching it. + const running = yield* terminalGrid(layout, cells, journal); + const retained = yield* running; + + const failed = retained.cells.find((cell) => cell.status === "failed"); if (failed !== undefined) { owner.push(yield* raise(terminalGridError(segment, failed.reason))); } @@ -2202,81 +2216,147 @@ function* expandTerminalGrid( } /** - * What one authored pane does once the grid has created its terminal. + * What one authored cell does once its terminal exists. + * + * Constructed lazily and interpreted exactly once, by the terminal lifecycle, + * with that cell's issued handle and output sink installed in its scope. Making + * one performs no expansion, shell, Agent, provider or journal work at all. * - * A self-closing 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. + * A self-closing cell runs the host's default shell; a paired cell expands its + * own content in a scope of its own. It inherits the bindings, providers, + * configuration and working directory visible where the grid was written, and + * everything it creates afterwards stays inside the cell. Its `` cannot + * reach a loop outside the grid, its `` cannot claim an enclosing body, + * and a checked failure settles the cell rather than poisoning the root or a + * sibling. */ -function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { +function cellWork( + pane: TerminalPane, + title: string, + position: number, + site: GridSite, +): Operation { 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 (function* (): Operation { + const cell = yield* requireCellUI(position, title); + // The shell is this cell's one terminal activity, and acquiring it is + // what makes the cell ready — the same boundary a paired cell's content + // crosses, rather than a second way in. + const outcome = yield* cell.shell(); + if (outcome.signal !== undefined) { + throw new Error(`terminal ${position} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `terminal ${position} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + })(); } + return scoped(function* () { + // A cell is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this cell makes is visible to later work in this cell and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + yield* expandCellContent(pane, site); + }); +} + +/** + * The issued handle for the cell being interpreted. + * + * Absent means this operation is running outside the cell scope that issued it, + * which is a lifecycle defect rather than something an author can write. + */ +function* requireCellUI(position: number, title: string): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error( + `terminal ${position} ("${title}") ran outside the cell scope that issued its handle`, + ); + } + return cell; +} + +/** + * Expand a paired cell's content into the cell's own output owner. + * + * The owner is what makes the appends land where the bytes do. Every construct + * that renders into the document — a branch, a loop iteration, a component + * body, projected content — appends into this same array, so installing it as + * the cell's output region is what puts a boundary at each of those rather than + * only between the cell's direct children. + */ +function* expandCellContent(pane: TerminalPane, site: GridSite): Operation { + const shown: Segment[] = []; + const region = createCellOutputRegion(shown); + yield* TerminalCellOutput.set(region); + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + // A counter of its own. Cells expand concurrently, and a shared mutable + // counter would hand two of them block identities that depend on which + // happened to run first. + createBlockCounter(), + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The cell's own ledger: a checked failure settles this cell and cannot + // reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a cell cannot claim one + // outside the grid. + undefined, + ); + yield* region.publish(); +} + +/** + * Where a paired terminal cell's rendered output goes, and how much of it has + * been published. + * + * The region is identified by the array it owns rather than by being ambient. + * A region that produces a binding, a value or a string expands into a private + * buffer that never merges into the cell, and a buffer that is not this owner + * publishes nothing — its text reaches the cell later, as one segment its + * caller appends, and is published at that boundary instead. + */ +interface TerminalCellOutputRegion { + readonly owner: Segment[]; + /** Append whatever the owner has gained since the last append. */ + publish(): Operation; +} + +const TerminalCellOutput: Context = createContext< + TerminalCellOutputRegion | undefined +>("core.terminalCellOutput", undefined); + +function createCellOutputRegion(owner: Segment[]): TerminalCellOutputRegion { + let published = 0; return { - 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); - } - }); + owner, + *publish() { + const rendered = renderSegments(owner); + if (rendered.length <= published) { + return; + } + const fresh = rendered.slice(published); + // Recorded before the append is awaited, so a boundary reached while this + // one is still committing cannot send the same bytes twice. + published = rendered.length; + yield* appendTerminalCellOutput(fresh); }, }; } diff --git a/packages/core/src/terminal-grid.ts b/packages/core/src/terminal-grid.ts deleted file mode 100644 index 59a08a920..000000000 --- a/packages/core/src/terminal-grid.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * The concrete grid an authored `` derives (spec §6.21). - * - * `structural-rules.ts` decides what the source says: which panes were written, - * in what order, and what is wrong with the way they were written. What it - * cannot decide is where each pane sits, because that also depends on `columns` - * — a value the document may compute. This module is where the two meet, once - * both are known and before anything is opened. - * - * A layout is provider-neutral data. It names no terminal, multiplexer, socket, - * process or window: it says how many columns the author asked for, how many - * rows that many panes fill, and which cell each pane occupies. - */ - -import type { TerminalPane } from "./structural-rules.ts"; - -/** One pane, placed. */ -export interface TerminalGridCell { - /** The pane's structural identity: its position among the panes, from zero. */ - readonly ordinal: number; - /** The row it occupies, from zero. */ - readonly row: number; - /** The column it occupies, from zero. */ - readonly column: number; - /** The label it displays. Two cells may carry the same one. */ - readonly title: string; - /** Whether it runs the markdown the pane holds or the host's default shell. */ - readonly form: TerminalPane["form"]; -} - -/** The complete grid one `` asked for. */ -export interface TerminalGridLayout { - readonly columns: number; - /** How many rows those columns take to hold every pane. */ - readonly rows: number; - /** Every pane, in authored order, which is also row-major order. */ - readonly cells: readonly TerminalGridCell[]; -} - -/** One pane's placeable facts, once its title has been resolved. */ -export interface PlacedPane { - readonly title: string; - readonly form: TerminalPane["form"]; -} - -/** - * Place the panes across `columns` columns in the order they were authored. - * - * Row-major: the first `columns` panes fill the first row, the next fill the - * second, and a count that does not divide leaves the positions at the end of - * the last row unused. Nothing is reordered, padded, or balanced — the author's - * order is the layout, and a pane's ordinal is its identity wherever it lands. - */ -export function terminalGridLayout( - columns: number, - panes: readonly PlacedPane[], -): TerminalGridLayout { - return { - columns, - rows: Math.ceil(panes.length / columns), - cells: panes.map((pane, ordinal) => ({ - ordinal, - row: Math.floor(ordinal / columns), - column: ordinal % columns, - title: pane.title, - form: pane.form, - })), - }; -} diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts deleted file mode 100644 index 5f9a32ec6..000000000 --- a/packages/core/src/terminal/grid.ts +++ /dev/null @@ -1,651 +0,0 @@ -/** - * 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 index 3ee4fd8d4..13608952f 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -1,36 +1,38 @@ /** - * Which grid a run opened, and how a resumed run is held to it - * (spec §6.21 Durability and replay). + * Core's answer to the neutral terminal journal boundary (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. + * The terminal package defines three durable boundaries and calls them; this + * is where they become entries in *this* document's journal, described from the + * source position that wrote the grid. Only provider-neutral layouts, outcomes + * and lazy operations cross the boundary, so the terminal package imports + * nothing of core's and core supplies nothing of a provider's. * * Provider-neutral throughout. No command, socket, path, process, session, - * window or pane identifier, no argv or environment, and no terminal byte is - * written here: none of that describes the document, it describes whichever + * window or terminal identifier, no argv or environment, and no terminal byte + * is written here: none of that describes the document, it describes whichever * provider happened to present it, and a resumed run builds a fresh one. */ -import type { Operation } from "effection"; +import { withResolvers } from "effection"; +import type { Operation, Task } from "effection"; import { createDurableOperation, DurableContext, + durableSpawn, + ephemeral, StaleInputError, } from "@executablemd/durable-streams"; import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import type { + RetainedCellOutcome, + RetainedGrid, + RetainedGridLayout, + TerminalGridJournal, +} from "@executablemd/terminal"; 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 { @@ -39,8 +41,6 @@ export interface GridIdentity { readonly position?: Readonly; } -type RetainedLayout = RetainedGrid["layout"]; - function describe(identity: GridIdentity): EffectDescription { return { type: "terminal_grid_layout", @@ -70,65 +70,100 @@ function* append(description: EffectDescription, value: Json): Workflow * 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. + * exactly — a missing member, a member of the wrong kind, an extra one, a row + * or column that does not follow from the columns it claims — makes the record + * unreadable rather than half-read. A layout is what a resumed run is held to, + * so a record that cannot be believed in full must not be believed in part. */ -function readLayout(value: unknown): RetainedLayout | undefined { +function readLayout(value: unknown): RetainedGridLayout | undefined { const record = members(value); - if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { + if (record === undefined || !onlyNames(record, ["columns", "rows", "cells"])) { return undefined; } const columns = positiveInteger(record.columns); const rows = positiveInteger(record.rows); - const list = record.panes; + const list = record.cells; if (columns === undefined || rows === undefined || !Array.isArray(list)) { return undefined; } - const panes: RetainedLayout["panes"] = []; + const cells: RetainedGridLayout["cells"][number][] = []; for (const [index, entry] of list.entries()) { - const pane = readPane(entry, index, columns); - if (pane === undefined) { + const cell = readCell(entry, index, columns); + if (cell === undefined) { return undefined; } - panes.push(pane); + cells.push(cell); } - // The rows a grid claims have to be the rows its panes need, or the record + // The rows a grid claims have to be the rows its cells need, or the record // describes a grid nothing could have derived. - if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { + if (cells.length === 0 || Math.ceil(cells.length / columns) !== rows) { return undefined; } - return { columns, rows, panes }; + return { columns, rows, cells }; } -/** One retained pane, checked against the position it claims to occupy. */ -function readPane( +/** One retained cell, checked against the position it sits at. */ +function readCell( value: unknown, index: number, columns: number, -): RetainedLayout["panes"][number] | undefined { +): RetainedGridLayout["cells"][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) { + if (record === undefined || !onlyNames(record, ["title", "form", "row", "column"])) { return undefined; } + const { title, form, row, column } = record; if (typeof title !== "string" || title.length === 0) { return undefined; } if (form !== "paired" && form !== "self-closing") { return undefined; } - // Derived, not asserted: a position that does not follow from the ordinal and - // the column count is a record that disagrees with itself. + // Derived, not asserted: a position that does not follow from the array index + // and the column count is a record that disagrees with itself. if (row !== Math.floor(index / columns) || column !== index % columns) { return undefined; } - return { ordinal, title, form, row, column }; + return { title, form, row, column }; +} + +function readCellOutcome(value: unknown): RetainedCellOutcome | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["status", "reason"])) { + return undefined; + } + const { status, reason } = record; + if (status !== "succeeded" && status !== "failed" && status !== "closed") { + return undefined; + } + if (typeof reason !== "string") { + return undefined; + } + return { status, reason }; +} + +function readGrid(value: unknown): RetainedGrid | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["layout", "close", "cells"])) { + return undefined; + } + const layout = readLayout(record.layout); + const { close } = record; + if (layout === undefined || (close !== "reader" && close !== "failed")) { + return undefined; + } + if (!Array.isArray(record.cells) || record.cells.length !== layout.cells.length) { + return undefined; + } + const cells: RetainedCellOutcome[] = []; + for (const entry of record.cells) { + const outcome = readCellOutcome(entry); + if (outcome === undefined) { + return undefined; + } + cells.push(outcome); + } + return { layout, close, cells }; } /** The members of a JSON object, or `undefined` for anything else. */ @@ -150,25 +185,25 @@ function positiveInteger(value: unknown): number | undefined { } /** How two layouts differ, in the words an author can act on. */ -function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { +function divergence(held: RetainedGridLayout, derived: RetainedGridLayout): 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}`; + if (held.cells.length !== derived.cells.length) { + return `${held.cells.length} terminals rather than ${derived.cells.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}"`; + for (const [index, cell] of derived.cells.entries()) { + const before = held.cells[index]!; + if (before.title !== cell.title) { + return `terminal ${index} titled "${before.title}" rather than "${cell.title}"`; } - if (before.form !== pane.form) { - return `pane ${index} written ${before.form} rather than ${pane.form}`; + if (before.form !== cell.form) { + return `terminal ${index} written ${before.form} rather than ${cell.form}`; } - if (before.row !== pane.row || before.column !== pane.column) { + if (before.row !== cell.row || before.column !== cell.column) { return ( - `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + - `${pane.row}, column ${pane.column}` + `terminal ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${cell.row}, column ${cell.column}` ); } } @@ -176,35 +211,131 @@ function divergence(held: RetainedLayout, derived: RetainedLayout): string | und } /** - * Record which grid this is, and refuse a resumed run whose grid changed. + * Hand out the right to allocate a durable child, in authored order. + * + * The lifecycle starts every cell concurrently, so the order its operations + * *reach* this adapter is whatever the scheduler chose. A durable child's + * identity may not depend on that, so each position waits for the one before it + * to have allocated and then releases the one after — which makes the ordinals + * the journal records the authored ones, on every run. + */ +interface OrderedTurns { + wait(position: number): Operation; + done(position: number): void; +} + +function createOrderedTurns(count: number): OrderedTurns { + const gates = Array.from({ length: count }, () => withResolvers()); + gates[0]?.resolve(); + return { + wait: (position) => gates[position]!.operation, + done: (position) => gates[position + 1]?.resolve(), + }; +} + +/** + * Build the journal one grid expansion retains through. * - * Expansion driven without a journal records nothing and behaves identically. + * The adapter closes over the source description, so the terminal lifecycle + * names nothing but an array position and the lazy work at it. */ -export function* recordGridLayout( +export function createTerminalGridJournal( 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 }, - ); + cellCount: number, +): TerminalGridJournal { + const turns = createOrderedTurns(cellCount); + + /** + * Take a turn, allocate the durable child, and hand the turn straight on. + * + * Only the allocation is ordered. The child runs beside its siblings from the + * moment it exists, so holding the turn any longer would run the grid one + * cell at a time. + */ + function* allocate( + position: number, + operation: Operation, + ): Operation> { + try { + yield* turns.wait(position); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(operation); + }); + } finally { + // Released however this leaves — allocated, refused, or cancelled — so + // the position after this one is never waiting on a turn nobody holds. + turns.done(position); + } } - const changed = divergence(held, derived); - if (changed !== undefined) { + + return { + *reconcileLayout(layout: RetainedGridLayout): Operation { + if (!(yield* durable())) { + return; + } + const description = describe(identity); + const stored = yield* append(description, layout); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run ` + + "the document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, layout); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this terminal grid as a grid with ${changed}. A grid whose ` + + "layout changed cannot be replayed onto this run. Re-run the document from the " + + "start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + }, + + retainGrid(operation: Operation): Operation { + return (function* (): Operation { + if (!(yield* durable())) { + return yield* operation; + } + // A completed grid short-circuits here: the child's workflow never + // runs, so no provider is contacted, no store is created, no cell + // content expands and no shell starts. + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(operation); + }); + return parsed(readGrid(yield* task), "terminal grid"); + })(); + }, + + retainCell( + position: number, + operation: Operation, + ): Operation { + return (function* (): Operation { + if (!(yield* durable())) { + return yield* operation; + } + const task = yield* allocate(position, operation); + return parsed(readCellOutcome(yield* task), "terminal cell outcome"); + })(); + }, + }; +} + +/** + * What a record said, or a refusal. + * + * A record that cannot be read in full is not read in part: a resumed run that + * believed half of one would present a grid nobody authored. + */ +function parsed(value: T | undefined, what: string): T { + if (value === undefined) { throw new StaleInputError( - `The journal 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 }, + `The journal's record of this ${what} is not one. Re-run the document from the start ` + + "rather than resuming from this journal.", ); } + return value; } diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts deleted file mode 100644 index f5f73f165..000000000 --- a/packages/core/src/terminal/pane.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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/profile.ts b/packages/core/src/terminal/profile.ts index ed1bd7b13..fd1fe64a9 100644 --- a/packages/core/src/terminal/profile.ts +++ b/packages/core/src/terminal/profile.ts @@ -14,10 +14,10 @@ import { scoped } from "effection"; import type { Operation } from "effection"; +import { installTerminalProvider } from "@executablemd/terminal"; +import { useTerminalInstallation } from "@executablemd/terminal/lifecycle"; import { Execution } from "../execute.ts"; -import { useTerminalInstallation } from "./presentation.ts"; -import { installTerminalProvider } from "./provider-api.ts"; export interface TerminalGridProfileOptions { /** diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index cc87cbfa1..5116ae78a 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -33,14 +33,10 @@ import { parsePrepared } from "../src/agent/launch-journal.ts"; import type { AgentLaunchRequest } from "../src/agent/launch-request.ts"; import { installAgentComponents } from "../src/agent/components.ts"; import type { AgentProviderFactory } from "../src/agent/provider-api.ts"; -import { - API, - installControlledLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - nativeLaunch, - useHostFiles, -} from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch } from "@executablemd/terminal"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 626cbf34b..532be8eea 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -23,7 +23,7 @@ import { Component } from "../src/component-api.ts"; import { expandSegments } from "../src/expand.ts"; import { renderSegments } from "../src/render.ts"; import { scanSegments } from "../src/scanner.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; +import { terminalGridLayout } from "@executablemd/terminal"; import type { Json, Segment } from "../src/types.ts"; interface GridRun { diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index ec78d0f05..27a9b587a 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,29 +1,32 @@ /** - * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Terminal grid presentation, §Atomic presentation and + * Tier TG — a terminal grid written in a document (spec §6.21, + * architecture.md §Interactive terminal grids, §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. + * These rows are about what core contributes: the authored structure it + * resolves, the lazy cell work it constructs, the journal it describes, and + * what a document sees when a grid runs, fails, closes or replays. The + * provider-neutral lifecycle those rows run on is proved in + * `packages/terminal/tests/terminal-grid.test.ts`. * - * 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. + * The provider here is controlled and is not tmux: it opens no terminal, + * starts no process, and records what it was asked to do in the order it was + * asked. Every ordering claim is read off that record. Nothing is inferred + * from timing, because a grid that showed too early and one that showed on + * time take the same wall clock. * - * 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. + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a cell becomes ready because work in it acquired a terminal + * activity, never because it got far enough. `` is what a suite + * writes to be that something, and it reaches the cell through the same + * contextual handle a real `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { ensure, + Err, race, resource, scoped, @@ -42,41 +45,35 @@ import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { - controlledTerminalGrid, - installControlledLauncher, + registerTerminalProvider, reserveTerminal, TerminalGrids, - terminalProviderLog, -} from "@executablemd/runtime"; + useTerminalCellUI, +} from "@executablemd/terminal"; import type { - ControlledTerminalGridOptions, TerminalActivity, - TerminalShellOutcome, - TerminalGrid, + TerminalCellUI, TerminalGridRequest, + TerminalGridState, + TerminalShellOutcome, +} from "@executablemd/terminal"; +import { + controlledTerminalProvider, + installControlledLauncher, + terminalProviderLog, +} from "@executablemd/terminal/test"; +import type { + ControlledProviderOptions, TerminalProviderLog, TerminalProviderResources, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; +import { useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import type { PresentTerminalGrid } from "@executablemd/terminal/lifecycle"; import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { - 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. */ @@ -86,9 +83,9 @@ interface DocumentRun { output: string; /** The grid the provider was actually asked to present. */ requests: TerminalGridRequest[]; - /** What each pane displayed. */ + /** What each cell displayed, by authored position. */ shown: Map; - /** Everything the provider's grid did, in order. */ + /** Everything the provider's host did, in order. */ events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; @@ -130,31 +127,23 @@ function done(value: T): Operation { /** * 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. + * The ordinary case a row wants when it only needs a cell to be ready: + * acquired at once, settled at once. */ -function startsAndSettles(onStart?: () => void): TerminalActivity { - return resource(function* (provide) { - onStart?.(); - yield* provide(done(undefined)); - }); -} - -/** An activity whose child spawned and stays until it is released. */ -function startsAndHolds(onStart?: () => void): TerminalActivity { +function startsAndSettles(onStart?: () => void): TerminalActivity { return resource(function* (provide) { onStart?.(); - yield* provide(suspend()); + yield* provide(done({ exitCode: 0 })); }); } /** * An activity whose child never spawned. * - * It fails during acquisition, which is before a pane could be ready — the + * It fails during acquisition, which is before a cell could be ready — the * shape of a preparation or spawn failure rather than of work that ran. */ -function neverStarts(onAttempt?: () => void): TerminalActivity { +function neverStarts(onAttempt?: () => void): TerminalActivity { return resource(function* () { onAttempt?.(); throw new Error("this activity's child never spawned"); @@ -162,27 +151,26 @@ function neverStarts(onAttempt?: () => void): TerminalActivity { } /** - * What the pane-terminal rows read. + * What the cell-handle 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. + * Each of these is driven from inside a real cell, through the same + * `TerminalCellUI` a `` reaches, and read back off an ordered + * record rather than inferred. */ -interface PaneProbe { +interface CellProbe { /** 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. */ + /** Ordered marks: which cell 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. */ + /** Cell handles kept past their grid on purpose. */ + readonly kept: TerminalCellUI[]; + /** Announce that this cell is inside its interactive body. */ entered(): void; - /** Settles once every pane this probe expects is inside one at the same time. */ + /** Settles once every cell this probe expects is inside one at the same time. */ overlapped(): Operation; } -function paneProbe(expected = 2): PaneProbe { +function cellProbe(expected = 2): CellProbe { const all = withResolvers(); let inside = 0; return { @@ -203,90 +191,13 @@ 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 cell handle the current work is running in, or a failed row. */ +function* cellHandle(name: string): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error(`<${name} /> is written inside a cell`); + } + return cell; } /** The controlled interactive child, and a tripwire. */ @@ -294,10 +205,10 @@ function useGridComponents( ran: string[], slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, - afterAttach: () => Operation = function* () {}, + afterShow: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, teardownArmed: () => void = () => {}, - probe: PaneProbe = paneProbe(), + probe: CellProbe = cellProbe(), ): Operation { return registerComponents([ { @@ -305,11 +216,8 @@ function useGridComponents( 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()); + const cell = yield* cellHandle("Interactive"); + yield* cell.shell(); return ""; }, }, @@ -330,8 +238,8 @@ function useGridComponents( }, }, { - // 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 + // Enters its cell's interactive body and stays there until every other + // cell is inside one too. Two cells that contended could never both be // inside, so the wait is the proof; the deadline only turns a regression // into a failed assertion instead of a hung suite. name: "Concurrent", @@ -343,100 +251,66 @@ function useGridComponents( additionalProperties: false, }, *fn(props) { - const pane = yield* paneTerminal(); - if (pane === undefined) { - throw new Error(" is written inside a pane"); - } + const cell = yield* cellHandle("Concurrent"); 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(`enter:${mark}`); + probe.entered(); + yield* cell.shell(); probe.marks.push(`leave:${mark}`); return ""; }, }, { - // One pane, asked for two interactive operations at once and then for a + // One cell, asked for two interactive operations at once and then for a // second one after the first settled. name: "Overlapping", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { - const 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"))); + const cell = yield* cellHandle("Overlapping"); + yield* spawn(function* () { + // Raced against the first shell deliberately: whichever of the two + // reaches admission second is the overlapping one, and it is refused + // rather than queued. + try { + yield* cell.shell(); + probe.marks.push("second entered"); + } catch (error) { + probe.refusals.push(refusalOf(error)); + } + }); + yield* cell.shell(); + // The cell is free again: one owner at a time is not one owner ever. + yield* cell.shell(); + probe.marks.push("sequential"); // Kept deliberately, so a row can ask what it grants after the grid has // closed. - probe.kept.push(pane); + probe.kept.push(cell); return ""; }, }, { - // Acquires two activities in turn. One pane started, not two. + // Acquires one activity that spawns and settles in the same breath. 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"))); + const cell = yield* cellHandle("SettlesAtOnce"); + yield* cell.shell(); + probe.marks.push("started and settled"); return ""; }, }, { - // Interactive work that never acquires an activity: doing work is not starting. + // 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"))); + const cell = yield* cellHandle("Quiet"); + probe.marks.push("tried to start"); + yield* cell.shell(); return ""; }, }, @@ -446,24 +320,15 @@ function useGridComponents( 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)); - }), - ); + const cell = yield* cellHandle("Slow"); + yield* sleep(25); + slowMarks.push("ready:slow"); + yield* cell.shell(); return ""; }, }, { - // Holds the pane open, and blocks its own teardown until released — so a + // Holds the cell open, and blocks its own teardown until released — so a // row can interrupt a run while reader-close teardown is in progress. name: "SlowTeardown", origin: "tier-tg", @@ -472,7 +337,7 @@ function useGridComponents( yield* ensure(function* () { yield* teardownHeld(); }); - // Armed: the finalizer is installed and this pane is live, which is + // Armed: the finalizer is installed and this cell is live, which is // what a row waits for before letting the reader leave. teardownArmed(); yield* suspend(); @@ -480,14 +345,14 @@ function useGridComponents( }, }, { - // Waits until the grid has attached, so a pane can fail *after* the + // Waits until the grid has been shown, so a cell can fail *after* the // barrier — which is the failure the grid contains as a status rather // than the startup failure that fails the whole region. - name: "AfterAttach", + name: "AfterShow", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { - yield* afterAttach(); + yield* afterShow(); return ""; }, }, @@ -503,27 +368,28 @@ function useGridComponents( ]); } +/** What a row asks of the controlled provider, plus the ways it may misuse one. */ +type ProviderOptions = ControlledProviderOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the presentation function for a later, unrouted use. */ + readonly capture?: (present: PresentTerminalGrid) => void; +}; + /** * Register a controlled provider that presents through the function it was * delivered. * * This is the whole handshake in miniature: the factory receives presentation - * as an argument, supplies a grid resource of its own, and presents the exact + * as an argument, supplies a provider of its own, and presents the exact * request it was routed. Nothing it returns reaches core. */ -function useControlledProvider( - options: 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; +function useControlledProvider(options: ProviderOptions = {}): Operation { return registerTerminalProvider("controlled", function* (_settings, present) { options.capture?.(present); + const provider = controlledTerminalProvider(options); yield* TerminalGrids.around( { *open([request]) { @@ -531,10 +397,7 @@ function useControlledProvider( // Answers, presents nothing. Core must not believe this. return { presented: true }; } - yield* present( - options.substitute?.(request) ?? request, - controlledTerminalGrid(request, options, generation++), - ); + yield* present(options.substitute?.(request) ?? request, provider); return undefined; }, }, @@ -543,19 +406,6 @@ function useControlledProvider( }); } -/** 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 @@ -574,13 +424,13 @@ function runDocument( options: { provider?: boolean; stream?: InMemoryStream; - grid?: ControlledTerminalGridOptions; + grid?: ProviderOptions; /** 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; + /** What the cell-handle rows record. */ + probe?: CellProbe; } = {}, ): Operation { return scoped(function* () { @@ -607,12 +457,10 @@ function runDocument( ); 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 + // The reader stays until every cell has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a cell // rendered must not race the close that cancels it. const settled = withResolvers(); - let expected = 0; - let done = 0; const supplied = options.grid ?? {}; if (options.provider !== false) { yield* useControlledProvider({ @@ -620,19 +468,17 @@ function runDocument( 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(); - } + *render(state) { + if (supplied.render) { + yield* supplied.render(state); + } + if (state.cells.every((cell) => cell.status !== "starting" && isSettled(cell.status))) { + settled.resolve(); } }, }); @@ -662,6 +508,10 @@ function runDocument( }); } +function isSettled(status: string): boolean { + return status === "succeeded" || status === "failed" || status === "closed"; +} + /** The message a run failed with, failing the test if it completed. */ function failureOf(run: DocumentRun): string { if (run.outcome.ok) { @@ -670,16 +520,11 @@ function failureOf(run: DocumentRun): string { 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 { +function heldDocument(columns: number, cells: string[]): string { return [ ``, - ...panes, + ...cells, "", "", // The sibling after the grid. It runs whether the grid ran or replayed, so @@ -704,29 +549,29 @@ function runInterrupted( stream: InMemoryStream, options: { provider?: boolean; - shell?: ControlledTerminalGridOptions["shell"]; + shell?: ControlledProviderOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; /** Props this run supplies. Props are not restored across a continuation. */ props?: Record; /** - * Keep the grid open until a pane reports a failure. + * Keep the grid open until a cell reports a failure. * - * A pane that fails *after* attachment is contained as that pane's status, + * A cell that fails *after* it is shown is contained as that cell's status, * and the grid settles as failed rather than throwing. Closing before that - * would record the pane as cancelled by the close instead. + * would record the cell as cancelled by the close instead. */ closeAfterFailure?: boolean; - /** Let the reader leave only once a `` pane is armed. */ + /** Let the reader leave only once a `` cell is armed. */ closeWhenArmed?: boolean; /** Let the reader leave only once this tripwire mark has been recorded. */ closeWhenMarked?: string; - /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ - shellFailsAfterAttach?: number; - /** Holds a `` pane's finalizer until this settles. */ + /** Position of a shell that starts, waits for the grid, then exits badly. */ + shellFailsAfterShow?: number; + /** Holds a `` cell's finalizer until this settles. */ holdTeardown?: () => Operation; /** - * Called once a pane's finalizer has been entered and is blocked, with what + * Called once a cell's finalizer has been entered and is blocked, with what * the provider is holding at that moment. * * A row reads those counters here to know they ever went up, which is what @@ -762,12 +607,10 @@ function runInterrupted( */ releaseOnInterrupt?: () => void; /** - * How many panes must have settled before the run is interrupted. + * How many cells 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. + * A cell's status is published only after its durable child has returned, + * so this is also how many cell Closes the journal is known to hold. */ settled?: number; } = {}, @@ -777,33 +620,28 @@ function runInterrupted( const log = terminalProviderLog(); const ran: string[] = []; const errors: string[] = []; - // Three signals, kept apart because they mean different things. `attached` + // Three signals, kept apart because they mean different things. `shown` // says a grid opened on this run. `pastGrid` says the document reached the - // sibling after it, which is what a *replayed* grid does 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. + // sibling after it, which is what a *replayed* grid does. `cellsSettled` + // says the cell children the row cares about have written their records. // // Every one of them is an event this run produced. Nothing here waits for a // duration, so a replay that hangs reaches none of them and hangs the row — // it can never hand back a run that looks finished but is not. - const attached = withResolvers(); + const shown = withResolvers(); const pastGrid = withResolvers(); - const panesSettled = withResolvers(); - let settledPanes = 0; + const cellsSettled = withResolvers(); if ((options.settled ?? 0) === 0) { - panesSettled.resolve(); + cellsSettled.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 cellFailed = withResolvers(); + // Resolved once a `` cell has installed its finalizer. const armed = withResolvers(); const marked = withResolvers(); yield* useGridComponents( @@ -817,7 +655,7 @@ function runInterrupted( marked.resolve(); } }, - () => attached.operation, + () => shown.operation, function* () { options.onTeardownEntered?.(log.live); if (options.holdTeardown) { @@ -833,7 +671,7 @@ function runInterrupted( log, close: options.closeAfterFailure === true - ? () => paneFailed.operation + ? () => cellFailed.operation : options.closeWhenMarked !== undefined ? () => marked.operation : options.closeWhenArmed === true @@ -841,20 +679,20 @@ function runInterrupted( : options.close === true ? immediateClose() : () => suspend(), - ...(options.shellFailsAfterAttach !== undefined + ...(options.shellFailsAfterShow !== undefined ? { - shell: (ordinal: number) => + shell: (position: number) => resource>(function* (provide) { - // Acquired, so the pane is ready and the grid attaches; the + // Acquired, so the cell is ready and the grid is shown; the // failure is in the settlement afterwards, which is the - // failure a grid contains as a pane status. - if (ordinal !== options.shellFailsAfterAttach) { + // failure a grid contains as a cell status. + if (position !== options.shellFailsAfterShow) { yield* provide(done({ exitCode: 0 })); return; } yield* provide( (function* (): Operation { - yield* attached.operation; + yield* shown.operation; return { exitCode: 1 }; })(), ); @@ -867,22 +705,21 @@ function runInterrupted( *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(); + *onShow() { + shown.resolve(); }, - onUpdate(_ordinal, state) { - if (state === "failed") { - paneFailed.resolve(); - } - if (state === "succeeded" || state === "failed" || state === "closed") { - settledPanes++; - if (settledPanes >= (options.settled ?? 0)) { - panesSettled.resolve(); + // deno-lint-ignore require-yield + *render(state) { + for (const cell of state.cells) { + if (cell.status === "failed") { + cellFailed.resolve(); } } + const settledCells = state.cells.filter((cell) => isSettled(cell.status)).length; + if (settledCells >= (options.settled ?? 0)) { + cellsSettled.resolve(); + } }, }); } @@ -902,15 +739,15 @@ function runInterrupted( // `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 + // stay open, and the run is interrupted once it has been shown and the cell // records the row reads are durable. if (options.interruptWhen !== undefined) { yield* options.interruptWhen; } else if (options.close === true || options.closeAfterFailure === true) { yield* pastGrid.operation; } else { - yield* attached.operation; - yield* panesSettled.operation; + yield* shown.operation; + yield* cellsSettled.operation; } // Cancellation is begun, then released, then awaited. A row that blocks a // finalizer has to release it after the parent is cancelled, or the @@ -931,7 +768,7 @@ function runInterrupted( }); } return { - outcome: { ok: false, error: new Error("interrupted") } as Result, + outcome: Err(new Error("interrupted")), output: "", requests, shown: log.shown, @@ -944,17 +781,17 @@ function runInterrupted( }); } -const PANES = [ +const CELLS = [ 'left', '', ]; -describe("Tier TG — presenting a grid", () => { - const GRID = ["", ...PANES, "", ""].join("\n"); +describe("Tier TG — presenting a grid from a document", () => { + const GRID = ["", ...CELLS, "", ""].join("\n"); it("TA1: a handler that answers without presenting opens nothing", function* () { const dir = yield* useDir(); - const run = yield* runDocument(dir, GRID, { grid: {} as ControlledTerminalGridOptions }); + const run = yield* runDocument(dir, GRID, {}); expect(run.outcome.ok).toBe(true); // The same document, against a provider that answers the routed request @@ -982,13 +819,9 @@ describe("Tier TG — presenting a grid", () => { 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"); + yield* writeTextFile(path, GRID); const ran: string[] = []; yield* useGridComponents(ran); yield* installControlledLauncher(); @@ -997,18 +830,21 @@ describe("Tier TG — presenting a grid", () => { substitute: (request) => ({ columns: request.columns, rows: request.rows, - panes: request.panes.map((pane) => ({ ...pane })), + cells: request.cells.map((cell) => ({ ...cell })), }), }); yield* installTerminalGridProfile({ provider: "controlled" }); const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); const outcome = yield* execution; yield* forEach(function* (_chunk: string) {}, execution.output); - return outcome; + return { outcome, ran }; }); - expect(forged.ok).toBe(false); - expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); + expect(forged.outcome.ok).toBe(false); + expect(forged.outcome.ok ? "" : forged.outcome.error.message).toContain( + "this grid request is not live", + ); + expect(forged.ran).toEqual([]); }); it("TA3: presenting a changed request authorizes nothing", function* () { @@ -1033,95 +869,11 @@ describe("Tier TG — presenting a grid", () => { 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* () { + it("TA7: two cells are interactive at the same time", function* () { const dir = yield* useDir(); - const probe = paneProbe(2); + const probe = cellProbe(2); + const gateBoth = withResolvers(); + let inside = 0; const run = yield* runDocument( dir, [ @@ -1131,21 +883,50 @@ describe("Tier TG — presenting a grid", () => { "", "", ].join("\n"), - { probe }, + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + // Acquired: this cell is holding its activity. Settlement waits + // for every other cell to be holding one too. Cells that + // contended could never all be here at once. + inside += 1; + if (inside >= 2) { + gateBoth.resolve(); + } + yield* provide( + (function* (): Operation { + const together = yield* race([ + (function* (): Operation { + yield* gateBoth.operation; + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${together}`); + return { exitCode: 0 }; + })(), + ); + }), + }, + }, ); expect(run.outcome.ok).toBe(true); - // 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"); + expect(probe.marks.filter((mark) => mark === "together:true")).toHaveLength(2); // And both were holding before either let go. expect(probe.marks.indexOf("enter:b")).toBeLessThan(probe.marks.indexOf("leave:a")); }); - it("TA8: one pane refuses overlapping work, and admits the next after it settles", function* () { + it("TA8: one cell refuses overlapping work, and admits the next after it settles", function* () { const dir = yield* useDir(); - const probe = paneProbe(1); + const probe = cellProbe(1); + const held = withResolvers(); + let first = true; const run = yield* runDocument( dir, [ @@ -1154,21 +935,51 @@ describe("Tier TG — presenting a grid", () => { "", "", ].join("\n"), - { probe }, + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + // The first activity holds the cell until the overlapping one has + // been refused, so the refusal is what the row reads rather than + // a schedule it hoped for. + if (first) { + first = false; + yield* provide( + (function* (): Operation { + yield* held.operation; + return { exitCode: 0 }; + })(), + ); + return; + } + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render(state) { + if (probe.refusals.length > 0) { + held.resolve(); + } + void state; + }, + }, + }, ); expect(run.outcome.ok).toBe(true); expect(probe.refusals).toHaveLength(1); - expect(probe.refusals[0]).toContain("one owns a pane terminal at a time"); + expect(probe.refusals[0]).toContain("one owns a cell terminal at a time"); // The refused operation never ran, and the one written after the first - // settled did: a pane has one owner at a time, not one owner ever. + // settled did: a cell has one owner at a time, not one owner ever. expect(probe.marks).not.toContain("second entered"); expect(probe.marks).toContain("sequential"); }); - it("TA9: a pane terminal kept past its grid admits nothing", function* () { + it("TA9: a cell handle kept past its grid admits nothing", function* () { const dir = yield* useDir(); - const probe = paneProbe(1); + const probe = cellProbe(1); + const held = withResolvers(); + let first = true; const run = yield* runDocument( dir, [ @@ -1177,7 +988,31 @@ describe("Tier TG — presenting a grid", () => { "", "", ].join("\n"), - { probe }, + { + probe, + grid: { + shell: () => + resource>(function* (provide) { + if (first) { + first = false; + yield* provide( + (function* (): Operation { + yield* held.operation; + return { exitCode: 0 }; + })(), + ); + return; + } + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render() { + if (probe.refusals.length > 0) { + held.resolve(); + } + }, + }, + }, ); expect(run.outcome.ok).toBe(true); @@ -1187,19 +1022,18 @@ describe("Tier TG — presenting a grid", () => { let refusal: unknown; yield* scoped(function* () { try { - yield* kept!.use(startsAndSettles()); + yield* kept!.shell(); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalGridPresentationError); - expect(refusalOf(refusal)).toContain("its grid has stopped admitting"); + expect(refusalOf(refusal)).toContain("has stopped admitting"); }); it("TA10: a child that spawns and settles at once is both ready and settled", function* () { const dir = yield* useDir(); - const probe = paneProbe(1); + const probe = cellProbe(1); const run = yield* runDocument( dir, [ @@ -1211,16 +1045,16 @@ describe("Tier TG — presenting a grid", () => { { probe }, ); - // Acquired and settled in the same breath: the grid attached rather than - // waiting for a pane that had already finished. + // Acquired and settled in the same breath: the grid was shown rather than + // waiting for a cell that had already finished. expect(run.outcome.ok).toBe(true); expect(probe.marks).toContain("started and settled"); - expect(run.events).toContain("attach:0"); + expect(run.events.some((event) => event.startsWith("show:0:"))).toBe(true); }); - it("TA11: an activity that fails before acquisition never makes a pane ready", function* () { + it("TA11: an activity that fails before acquisition never makes a cell ready", function* () { const dir = yield* useDir(); - const probe = paneProbe(1); + const probe = cellProbe(1); const run = yield* runDocument( dir, [ @@ -1229,619 +1063,33 @@ describe("Tier TG — presenting a grid", () => { "", "", ].join("\n"), - { probe }, + { probe, grid: { shell: () => neverStarts() } }, ); - // The pane owned its terminal and tried. Neither is starting. + // The cell 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 + // The cell fails with the reason its activity could not start, rather than // with the generic "never started anything" — a spawn that failed says why. expect(failureOf(run)).toContain("this activity's child never spawned"); - expect(run.events).not.toContain("attach:0"); + expect(run.events.some((event) => event.startsWith("show:"))).toBe(false); 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 — + * The provider's host is released exactly once — not zero times, and not twice — * and nothing it handed out is still held. Both halves matter: a count alone - * would pass for a run that released one grid and stranded another. + * would pass for a run that released one host and stranded another. */ function expectReleasedOnce(run: DocumentRun, generation = 0): void { expect(run.events.filter((event) => event === `destroy:${generation}`)).toEqual([ `destroy:${generation}`, ]); - expect(run.live).toEqual({ grids: 0, 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() {}, - }); - }); + expect(run.live).toEqual({ grids: 0, shown: 0, activities: 0 }); } -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(); @@ -1861,22 +1109,23 @@ describe("Tier TG — a grid written in a document", () => { expect(run.outcome.ok).toBe(true); expect(run.requests).toHaveLength(1); + // Position is identity: no ordinal, index or key duplicates it. expect(run.requests[0]).toEqual({ columns: 2, rows: 3, - 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" }, + cells: [ + { title: "One", row: 0, column: 0, form: "self-closing" }, + { title: "Two", row: 0, column: 1, form: "self-closing" }, + { title: "Three", row: 1, column: 0, form: "self-closing" }, + { title: "Four", row: 1, column: 1, form: "self-closing" }, + { title: "Five", row: 2, column: 0, form: "self-closing" }, ], }); - // A grid that succeeded released its provider's grid once, holding nothing. + // A grid that succeeded released its provider's host once, holding nothing. expectReleasedOnce(run); }); - it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + it("TG4: duplicate titles stay valid, and identity is the position", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -1891,14 +1140,14 @@ describe("Tier TG — a grid written in a document", () => { ); 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" }, + expect(run.requests[0]?.cells).toEqual([ + { title: "Agent", row: 0, column: 0, form: "paired" }, + { title: "Agent", row: 0, column: 1, form: "self-closing" }, + { title: "Agent", row: 1, column: 0, form: "paired" }, ]); }); - it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { + it("TG7: root output is flushed before the grid, and cell text stays in its cell", function* () { const dir = yield* useDir(); const flushed: string[] = []; const run = yield* runDocument( @@ -1928,10 +1177,10 @@ describe("Tier TG — a grid written in a document", () => { expect(run.outcome.ok).toBe(true); expect(flushed).toEqual(["prepared"]); - // Each pane's own text went to that pane. + // Each cell's own text went to that cell. expect(run.shown.get(0)).toContain("left text"); expect(run.shown.get(1)).toContain("right text"); - // The grid renders "": the root output holds what surrounds it and no pane + // The grid renders "": the root output holds what surrounds it and no cell // display at all. expect(run.output).toContain("before"); expect(run.output).toContain("after"); @@ -1939,7 +1188,122 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).not.toContain("right text"); }); - it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + it("TG20: a cell's output is committed before the effect written after it", function* () { + const dir = yield* useDir(); + // The shell records what the aggregate already said about its own cell at + // the moment it was asked to start. Text written before `` + // must already be there: an append that waited for the next authored effect + // would show empty content here. + const contentAtLaunch: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + "first paragraph", + "", + "", + "", + "second paragraph", + "", + "", + "", + ].join("\n"), + { + grid: { + shell: () => + resource>(function* (provide) { + yield* provide(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *render(state) { + for (const cell of state.cells) { + if (cell.status === "launching") { + contentAtLaunch.push(cell.content); + } + } + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(contentAtLaunch).toHaveLength(1); + expect(contentAtLaunch[0]).toContain("first paragraph"); + // And what came after is not there yet: the append is per boundary, not one + // dump at the end. + expect(contentAtLaunch[0]).not.toContain("second paragraph"); + // The complete output is what the cell finally displays. + expect(run.shown.get(0)).toContain("first paragraph"); + expect(run.shown.get(0)).toContain("second paragraph"); + }); + + it("TG20: output nested inside one structural child reaches state before the action beside it", function* () { + const dir = yield* useDir(); + // Every snapshot the renderer worked through, and what the newest of them + // said at the moment the provider was asked for a terminal. Read off the + // renderer rather than the store, so this is the screen the action waited + // for rather than a value beside it. + const rendered: TerminalGridState[] = []; + const convergedContent: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "before the action", + "", + "", + "", + "after the action", + "", + "", + "outside the branch", + "", + "", + "", + ].join("\n"), + { + grid: { + // deno-lint-ignore require-yield + *render(state) { + rendered.push(state); + }, + shell: () => + resource>(function* (provide) { + const applied = rendered[rendered.length - 1]; + convergedContent.push(applied?.cells[0]?.content ?? ""); + yield* provide(done({ exitCode: 0 })); + }), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + expect(convergedContent).toHaveLength(1); + // The output written before the action inside the same branch is already + // part of the desired screen the action converged through. A publication + // that waited for the whole `` to finish would have committed none of + // it by now. + expect(convergedContent[0]).toContain("before the action"); + // And nothing written after it is, at either depth. + expect(convergedContent[0]).not.toContain("after the action"); + expect(convergedContent[0]).not.toContain("outside the branch"); + // All three reach the cell in the end, in authored order. + const shown = run.shown.get(0) ?? ""; + expect(shown).toContain("before the action"); + expect(shown).toContain("after the action"); + expect(shown).toContain("outside the branch"); + expect(shown.indexOf("before the action")).toBeLessThan(shown.indexOf("after the action")); + expect(shown.indexOf("after the action")).toBeLessThan(shown.indexOf("outside the branch")); + // Once each: a boundary that published twice would repeat itself. + expect(shown.split("before the action")).toHaveLength(2); + expect(shown.split("after the action")).toHaveLength(2); + expect(shown.split("outside the branch")).toHaveLength(2); + }); + + it("TG6: a cell inherits the grid site's bindings and keeps its own", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -1972,7 +1336,7 @@ describe("Tier TG — a grid written in a document", () => { // 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. + // Created inside one cell, visible to later work in that cell. expect(run.shown.get(0)).toContain("then left"); // Invisible to the sibling and to the document after the grid: an // unresolved binding stays the literal text it was written as. @@ -1980,7 +1344,7 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).toContain("after {mine}"); }); - it("TG6: a pane's cannot claim a value body outside the grid", function* () { + it("TG6: a cell's cannot claim a value body outside the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -1990,8 +1354,8 @@ describe("Tier TG — a grid written in a document", () => { " type: string", "---", "", - '', - '', + '', + '', "", "", "", @@ -2001,7 +1365,7 @@ describe("Tier TG — a grid written in a document", () => { ].join("\n"), ); - // The pane has no enclosing value body to claim, so the written in + // The cell has no enclosing value body to claim, so the written in // it is refused where it sits rather than becoming the document's value. expect(failureOf(run)).toContain( "is not written in the flow of a body that declares `returns`", @@ -2009,7 +1373,7 @@ describe("Tier TG — a grid written in a document", () => { expect(failureOf(run)).not.toContain("from the document"); }); - it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + it("TG6: a cell's checked failure settles that cell and not its sibling", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -2017,7 +1381,7 @@ describe("Tier TG — a grid written in a document", () => { "", '', "", - '', + '', "", "", "", @@ -2030,17 +1394,17 @@ describe("Tier TG — a grid written in a document", () => { ].join("\n"), ); - // Printed inside the pane it happened in, and the sibling ran regardless. - expect(run.shown.get(0)).toContain("this pane gave up"); + // Printed inside the cell it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this cell gave up"); expect(run.ran).toEqual(["sibling"]); - expect(run.output).not.toContain("this pane gave up"); + expect(run.output).not.toContain("this cell gave up"); }); - it("TG6: a paired pane runs every component in its body, in order", function* () { + it("TG6: a paired cell runs every component in its body, in order", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - // The reader leaves only once the pane's *second* component has run, so a - // pane body that stopped after the first would never let the grid close — + // The reader leaves only once the cell's *second* component has run, so a + // cell body that stopped after the first would never let the grid close — // a hang rather than a pass. const run = yield* runInterrupted( dir, @@ -2055,14 +1419,14 @@ describe("Tier TG — a grid written in a document", () => { expect(run.ran).toContain("second component"); }); - it("TG9: with no provider installed, no pane body or shell runs", function* () { + it("TG9: with no provider installed, no cell body or shell runs", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, [ "", '', - '', + '', "", "", '', @@ -2073,21 +1437,19 @@ describe("Tier TG — a grid written in a document", () => { ); expect(failureOf(run)).toContain("no terminal provider is installed"); - // The pane held work; none of it was reached, and nothing was displayed. + // The cell held work; none of it was reached, and nothing was displayed. expect(run.ran).toEqual([]); expect(run.shown.size).toBe(0); }); }); -describe("Tier TG — startup, settlement and teardown", () => { - const TWO = ["", ...PANES, "", ""].join("\n"); +describe("Tier TG — startup, settlement and teardown in a document", () => { + const TWO = ["", ...CELLS, "", ""].join("\n"); - it("TG9: nothing attaches until every pane has acquired a terminal activity", function* () { + it("TG9: nothing is shown until every cell 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. + // One ordered record the cell and the grid both write to, so "readiness + // came first" is read rather than assumed. const timeline: string[] = []; const run = yield* runDocument( dir, @@ -2102,12 +1464,14 @@ describe("Tier TG — startup, settlement and teardown", () => { slowMarks: timeline, grid: { // deno-lint-ignore require-yield - *onAttach() { - timeline.push("attach"); + *onShow() { + timeline.push("show"); }, - shell: () => + shell: (position) => resource>(function* (provide) { - timeline.push("ready:shell"); + if (position === 1) { + timeline.push("ready:shell"); + } yield* provide(done({ exitCode: 0 })); }), }, @@ -2115,12 +1479,12 @@ describe("Tier TG — startup, settlement and teardown", () => { ); 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"); + // The slow cell started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("show"); expect(timeline).toContain("ready:slow"); }); - it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + it("TG9: a cell that never starts fails the grid, and nothing is shown", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -2134,9 +1498,9 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(failureOf(run)).toContain("finished without starting anything interactive"); - // No partial grid was ever shown, and the hidden grid was released — once, + // No partial grid was ever shown, and the hidden host was released — once, // with nothing of the provider's still held. - expect(run.events).not.toContain("attach:0"); + expect(run.events.some((event) => event.startsWith("show:"))).toBe(false); expectReleasedOnce(run); }); @@ -2147,49 +1511,39 @@ describe("Tier TG — startup, settlement and teardown", () => { ["", '', "", ""].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"), + // Ready the moment the activity was acquired, so the grid was shown; + // settled straight after, so its final status is its own. + expect(run.events.some((event) => event.startsWith("show:0:"))).toBe(true); + expect(run.events).toContain("status:0:0:succeeded"); + expect(run.events.indexOf("status:0:0:succeeded")).toBeGreaterThan( + run.events.findIndex((event) => event.startsWith("show:0:")), ); }); - it("TG9: a preparation failure starts no pane at all", function* () { + it("TG9: a preparation failure starts no cell at all", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { grid: { // deno-lint-ignore require-yield *onPrepare() { - throw new Error("no pane endpoint could be created"); + throw new Error("no cell endpoint could be created"); }, }, }); - expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(failureOf(run)).toContain("no cell endpoint could be created"); expect(run.shown.size).toBe(0); }); - it("TG9: an attach failure shows no partial grid and releases it", function* () { + it("TG9: a failure showing the grid shows no partial grid and releases it", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { grid: { // deno-lint-ignore require-yield - *onAttach() { + *onShow() { throw new Error("the grid could not be shown"); }, }, @@ -2199,7 +1553,7 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.events).toContain("destroy:0"); }); - it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + it("TG9: simultaneous startup failures report the first authored position", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -2212,13 +1566,13 @@ describe("Tier TG — startup, settlement and teardown", () => { ].join("\n"), ); - // Both panes fail to start. The one reported is the first authored, not + // Both cells 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")'); + expect(failureOf(run)).toContain('terminal 0 ("First")'); + expect(failureOf(run)).not.toContain('terminal 1 ("Second")'); }); - it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + it("TG12: close cancels a live cell as closed, then destroys and continues", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, @@ -2232,15 +1586,15 @@ describe("Tier TG — startup, settlement and teardown", () => { ].join("\n"), { grid: { - // The reader leaves while the pane is still live. + // The reader leaves while the cell 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"); + // Teardown cancellation is not a cell failure. + expect(run.events).toContain("status:0:0:closed"); const destroyed = run.events.indexOf("destroy:0"); expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); // Released once, after the reader left, with nothing still held. @@ -2249,11 +1603,10 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.ran).toEqual(["after the grid"]); }); - it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + it("TG13: an active provider failure cancels every cell and fails the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { grid: { - // 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"); @@ -2262,15 +1615,35 @@ describe("Tier TG — startup, settlement and teardown", () => { }); expect(failureOf(run)).toContain("the terminal provider lost its server"); - // A provider that failed mid-grid still had its grid released exactly once. + // A provider that failed mid-grid still had its host released exactly once. + expectReleasedOnce(run); + }); + + it("TG21: a background provider failure fails the grid with no foreground action", function* () { + const dir = yield* useDir(); + const background = withResolvers(); + const run = yield* runDocument(dir, TWO, { + grid: { + // Nothing is waiting on the renderer: the reader never leaves, and the + // failure is raised from the provider's own background observation. + close: () => suspend(), + fail: () => background.operation, + // deno-lint-ignore require-yield + *onShow() { + background.resolve(new Error("the renderer lost its channel")); + }, + }, + }); + + expect(failureOf(run)).toContain("the renderer lost its channel"); expectReleasedOnce(run); }); }); describe("Tier TG — durability and replay", () => { - const GRID = heldDocument(2, PANES); + const GRID = heldDocument(2, CELLS); /** - * A grid whose only pane never starts, with its failure contained. + * A grid whose only cell never starts, with its failure contained. * * `` keeps the document going, so the root reaches no outcome of * its own and a resumed run reaches the region rather than replaying the root @@ -2323,16 +1696,16 @@ describe("Tier TG — durability and replay", () => { 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 : []; + /** The cell outcomes the grid retained, in authored order. */ + function cellOutcomes(run: DocumentRun): unknown[] { + const cells = retainedGrid(run)?.cells; + return Array.isArray(cells) ? cells : []; } /** * How every `Close` at this coroutine depth ended, in journal order. * - * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * Depth 2 is the grid child and depth 3 its cells, so a row reads these to * say how many records each level wrote and what each one settled to — * including whether any of them settled as a cancellation. */ @@ -2358,8 +1731,9 @@ describe("Tier TG — durability and replay", () => { 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. + // No provider was asked for a host, nothing was prepared, rendered or + // shown, no cell content expanded, no shell or launcher ran, and nothing + // displayed. expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); @@ -2372,7 +1746,7 @@ describe("Tier TG — durability and replay", () => { const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { closeAfterFailure: true, - shellFailsAfterAttach: 0, + shellFailsAfterShow: 0, }); expect(first.requests).toHaveLength(1); expect(completedGrid(first)).toBe(true); @@ -2397,51 +1771,56 @@ describe("Tier TG — durability and replay", () => { expect(second.shown.size).toBe(0); }); - it("TG16: each pane is a durable child of the grid, in authored order", function* () { + it("TG16: each cell 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. + // Both cells settle, so both cell children have written their records. const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); const closes = first.journal.filter((event) => event.type === "close"); - const paneIds = closes + const cellIds = closes .map((event) => String(event.coroutineId)) .filter((id) => id.split(".").length >= 3) .sort(); - expect(paneIds).toHaveLength(2); - const [left, right] = paneIds; + expect(cellIds).toHaveLength(2); + const [left, right] = cellIds; // Authored order, not scheduling order, and both beneath one grid child. expect(left!.endsWith(".0")).toBe(true); expect(right!.endsWith(".1")).toBe(true); expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); }); - it("TG16: an interrupted grid acquires a fresh provider grid rather than hanging", function* () { + it("TG16: an interrupted grid acquires a fresh provider host rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); // Interrupted while the grid is open, so its child records a cancelled - // close. Under the repaired spawn policy the resumed run continues that - // region instead of suspending on it forever. + // close. The resumed run continues that region instead of suspending on it. const first = yield* runInterrupted(dir, GRID, stream); expect(first.requests).toHaveLength(1); const second = yield* runInterrupted(dir, GRID, stream); - // A fresh provider grid, acquired by this run. + // A fresh host, acquired by this run, with a state sequence of its own that + // starts at revision zero. expect(second.requests).toHaveLength(1); expect(second.events).toContain("prepare:0:2x1"); + expect(second.events).toContain("render:0:0"); }); - it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { + it("TG16: a completed cell is restored; an incomplete shell starts again", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const source = heldDocument(2, [ '', '', ]); - const holdingShell: ControlledTerminalGridOptions["shell"] = () => + const holdingShell: ControlledProviderOptions["shell"] = (position) => resource>(function* (provide) { + if (position === 0) { + yield* provide(done({ exitCode: 0 })); + return; + } // Started, and never finishes on its own. yield* provide( (function* (): Operation { @@ -2452,7 +1831,7 @@ describe("Tier TG — durability and replay", () => { ); }); - // The left pane settles; the shell holds, so only one pane record exists. + // The left cell settles; the shell holds, so only one cell record exists. const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell, settled: 1, @@ -2464,12 +1843,10 @@ describe("Tier TG — durability and replay", () => { settled: 1, }); - // The completed pane came back from its retained outcome: its body did not - // run again. + // The completed cell came back from its retained outcome: its body did not + // run again, and no activity was acquired for it. expect(second.ran).not.toContain("left ran"); - // 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); + expect(second.events.filter((event) => event.startsWith("shell:"))).toEqual(["shell:0:1"]); }); /** @@ -2513,17 +1890,14 @@ describe("Tier TG — durability and replay", () => { }); // Refused before the foreground lease and before the provider: nothing was - // prepared, attached or displayed. + // prepared, rendered or displayed. expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); - // A replay refusal, not a run that opened something and then failed. 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); + // A replay refusal, not a run that opened something and then failed, and it + // names the value that changed. + expect(failureOf(second)).toContain("columns 2 rather than 3"); + expect(failureOf(second)).toContain("cannot be replayed onto this run"); }); it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { @@ -2541,7 +1915,7 @@ describe("Tier TG — durability and replay", () => { }); expect(second.requests).toEqual([]); - expect(failureOf(second)).toContain("Divergence"); + expect(failureOf(second)).toContain('terminal 0 titled "Left" rather than "Elsewhere"'); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); }); @@ -2561,12 +1935,12 @@ describe("Tier TG — durability and replay", () => { 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", ['', '']], + ["cell count", [...CELLS, '']], + ["cell order", ['', ...CELLS.slice(0, 1)]], + ["cell form", ['', '']], ]; - for (const [what, panes] of structural) { + for (const [what, cells] of structural) { const dir = yield* useDir(); const stream = new InMemoryStream(); const first = yield* runInterrupted(dir, GRID, stream); @@ -2574,7 +1948,7 @@ describe("Tier TG — durability and replay", () => { // 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); + const second = yield* runInterrupted(dir, heldDocument(2, cells), stream); expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( @@ -2583,7 +1957,7 @@ describe("Tier TG — durability and replay", () => { } }); - it("TG17: the retained record holds the complete authored pane structure", function* () { + it("TG17: the retained record holds the complete authored cell structure", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); @@ -2594,13 +1968,14 @@ describe("Tier TG — durability and replay", () => { 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. + // Every authored cell, with its title, form and derived position — and no + // ordinal, index, key or live identity beside them. expect(value).toEqual({ columns: 2, rows: 1, - panes: [ - { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, - { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, ], }); }); @@ -2608,16 +1983,16 @@ describe("Tier TG — durability and replay", () => { 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: [] }], + ["a missing member", { columns: 2, cells: [] }], [ "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 }, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, ], }, ], @@ -2626,20 +2001,20 @@ describe("Tier TG — durability and replay", () => { { 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 }, + cells: [ + { title: "Left", form: "paired", row: 0, column: 0 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, ], }, ], [ - "a pane out of position", + "a retained ordinal", { 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 }, + cells: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, ], }, ], @@ -2648,9 +2023,9 @@ describe("Tier TG — durability and replay", () => { { 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 }, + cells: [ + { title: "Left", form: "paired", row: 3, column: 1 }, + { title: "Right", form: "self-closing", row: 0, column: 1 }, ], }, ], @@ -2688,7 +2063,7 @@ describe("Tier TG — durability and replay", () => { const dir = yield* useDir(); const stream = new InMemoryStream(); const source = heldDocument(2, [ - '', + '', '', ]); @@ -2704,7 +2079,7 @@ describe("Tier TG — durability and replay", () => { 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 + // 1. The live cell arms its blocking finalizer, and 2. only then does the // reader leave. closeWhenArmed: true, // 3. Entering the finalizer is observed, and it blocks there. @@ -2732,30 +2107,30 @@ describe("Tier TG — durability and replay", () => { 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"]); + expect(first.ran).toEqual(["cell body"]); // One grid child, completed, and it says what closed it. expect(closeStatuses(first, 2)).toEqual(["ok"]); expect(retainedGrid(first)?.close).toBe("reader"); - // Two pane children, both completed. Neither they nor the grid recorded a + // Two cell children, both completed. Neither they nor the grid recorded a // cancellation: a cancelled child is what a later run would have to revive, // and these have nothing left to do. expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); - expect(paneOutcomes(first)).toEqual([ + expect(cellOutcomes(first)).toEqual([ { status: "closed", reason: "" }, { status: "succeeded", reason: "" }, ]); // The provider's counters went up and came back down. Reading them only at // the end would be true of counters that never moved. - expect(heldWhenBlocked).toEqual({ grids: 1, attached: 1, shells: 0 }); - expect(first.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + expect(heldWhenBlocked).toEqual({ grids: 1, shown: 1, activities: 0 }); + expect(first.live).toEqual({ grids: 0, shown: 0, activities: 0 }); // And the foreground lease came back: it was taken and given back twice // over once the run was done. expect(leases).toBe(2); // 7. Resumed with three tripwires: no provider at all, so a replay that - // asked for a grid would refuse; a mark inside the pane body, so a pane + // asked for a host would refuse; a mark inside the cell body, so a cell // that expanded again would say so; and the finalizer, which would // report being entered a second time. let reentered = 0; @@ -2775,7 +2150,7 @@ describe("Tier TG — durability and replay", () => { expect(second.ran).toEqual([PAST_THE_GRID]); }); - it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { + it("TG17: the retained layout and cell outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); @@ -2783,7 +2158,7 @@ describe("Tier TG — durability and replay", () => { 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"]) { + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer", "ordinal"]) { expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); } }); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 9db92312b..2858e7134 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -5,7 +5,7 @@ * `API` is available for middleware (`.around()`). * For normal calls, import operations directly. * - * Seven domain APIs: + * Six domain APIs: * - `API.Process` — subprocess execution (`exec`) * - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`, * `stat`, `lstat`, `readDirectory`, `glob`, `realpath`, `ensureDir`, `rename`, @@ -17,8 +17,6 @@ * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) * - `API.Service` — scoped attached service startup (`startService`) - * - `NativeLauncher` — handing one native agent UI the foreground terminal - * (`reserveTerminal`, `flushOutput`, `nativeLaunch`) * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`, * `verbose`) * @@ -129,43 +127,6 @@ export type { FileWriteTarget, GlobInput, } from "./files.ts"; -export { - flushOutput, - installControlledLauncher, - installForegroundLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - NativeLauncher, - NativeLauncherUnavailableError, - nativeLaunch, - NO_TERMINAL, - reserveTerminal, -} from "./launcher.ts"; -export type { - ControlledLauncherOptions, - NativeLauncherHandler, - NativeLaunchOutcome, - NativeLaunchRequest, -} from "./launcher.ts"; -export { - 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 deleted file mode 100644 index 93e573e7b..000000000 --- a/packages/runtime/terminal.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * 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 deleted file mode 100644 index 9dbbe09aa..000000000 --- a/packages/runtime/tests/terminal-provider.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * 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/packages/terminal/deno.json b/packages/terminal/deno.json new file mode 100644 index 000000000..422e7c3ca --- /dev/null +++ b/packages/terminal/deno.json @@ -0,0 +1,14 @@ +{ + "name": "@executablemd/terminal", + "version": "0.12.1", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test/mod.ts" + }, + "imports": { + "starfx": "npm:starfx@0.16.1" + } +} diff --git a/packages/terminal/lifecycle.ts b/packages/terminal/lifecycle.ts new file mode 100644 index 000000000..2c6865458 --- /dev/null +++ b/packages/terminal/lifecycle.ts @@ -0,0 +1,29 @@ +/** + * Running one grid, and the integration seam the host expands content through. + * + * Everything here is the lifecycle's own: opening an installation so a + * presentation can be admitted, the resource that runs a whole grid beneath the + * expansion that submitted it, and the cell-output sink core appends rendered + * Markdown through while it interprets a paired cell. + * + * The sink is deliberately not a member of either UI or the provider's view. It + * carries no store, dispatch, identity, title, status or host authority, and + * outside an issued cell scope it is inert. + */ + +export { terminalGrid } from "./src/grid.ts"; +export { + cellBusyMessage, + cellClosedMessage, + cellNeverStartedMessage, + noProviderMessage, + outsideExecutionMessage, +} from "./src/grid.ts"; + +export { appendTerminalCellOutput } from "./src/output.ts"; + +export { terminalInstallation, useTerminalInstallation } from "./src/presentation.ts"; +export type { TerminalInstallation } from "./src/presentation.ts"; + +export { TerminalGridPresentationError } from "./src/errors.ts"; +export type { PresentTerminalGrid } from "./src/host.ts"; diff --git a/packages/terminal/mod.ts b/packages/terminal/mod.ts new file mode 100644 index 000000000..83e4be62b --- /dev/null +++ b/packages/terminal/mod.ts @@ -0,0 +1,104 @@ +/** + * Provider-neutral interactive terminal grids. + * + * A document can replace its one foreground terminal with a grid of terminals + * that stay interactive at the same time. What presents that grid — a tmux + * integration, another multiplexer, a host-native UI, or a controlled surface + * that opens no terminal at all — is a provider, and nothing in this package + * knows which one is installed. + * + * This root owns the contracts everybody shares: the native launch seam, the + * resolved layout and the request derived from it, the immutable live state, the + * domain actions, the provider's read-only view and host, routing and + * installation, the journal boundary, and the errors any of them refuse with. + * + * The grid lifecycle itself is `@executablemd/terminal/lifecycle`; process + * facts are `./processes`; the POSIX host is `./posix`; and `./test` holds the + * controlled surfaces. A value exported from more than one of them is the same + * value. + */ + +export { + NATIVE_LAUNCHER_API, + NativeLauncher, + flushOutput, + nativeLaunch, + reserveTerminal, +} from "./src/launch.ts"; +export type { + NativeLaunchOutcome, + NativeLaunchRequest, + NativeLauncherHandler, +} from "./src/launch.ts"; + +export { + NATIVE_LAUNCHER_UNAVAILABLE, + NO_TERMINAL, + NativeLauncherUnavailableError, + PROCESS_OBSERVATION_UNAVAILABLE, + ProcessObservationUnavailableError, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGridError, + TerminalGridPresentationError, + TerminalProviderInstallError, + TerminalProviderUnavailableError, +} from "./src/errors.ts"; + +export { terminalGridLayout, terminalGridRequest } from "./src/layout.ts"; +export type { + PlacedCell, + TerminalCellForm, + TerminalCellRequest, + TerminalGridCell, + TerminalGridLayout, + TerminalGridRequest, +} from "./src/layout.ts"; + +export type { + TerminalCellId, + TerminalCellState, + TerminalCellStatus, + TerminalGridPhase, + TerminalGridRevision, + TerminalGridState, +} from "./src/state.ts"; + +export type { + PresentTerminalGrid, + TerminalActivity, + TerminalGridHost, + TerminalGridProvider, + TerminalGridView, + TerminalShellOutcome, +} from "./src/host.ts"; + +export { useTerminalCellUI } from "./src/ui.ts"; +export type { TerminalCellUI, TerminalGridUI } from "./src/ui.ts"; + +export { + TERMINAL_GRIDS_API, + TERMINAL_PROVIDERS_API, + TerminalGrids, + TerminalProviders, + installTerminalProvider, + registerTerminalProvider, +} from "./src/routing.ts"; +export type { + TerminalGridApi, + TerminalProviderApi, + TerminalProviderCall, + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/routing.ts"; + +export { retainedGridLayout } from "./src/journal.ts"; +export type { + RetainedCell, + RetainedCellOutcome, + RetainedGrid, + RetainedGridLayout, + TerminalCellWork, + TerminalGridCloseKind, + TerminalGridJournal, +} from "./src/journal.ts"; diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 000000000..bf0a00e8f --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,20 @@ +{ + "name": "@executablemd/terminal", + "version": "0.12.1", + "description": "Provider-neutral interactive terminal grids for executable.md documents.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test/mod.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/node": "0.2.5", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + "starfx": "0.16.1" + } +} diff --git a/packages/terminal/posix.ts b/packages/terminal/posix.ts new file mode 100644 index 000000000..9f9111ae2 --- /dev/null +++ b/packages/terminal/posix.ts @@ -0,0 +1,13 @@ +/** + * The POSIX host adapters. + * + * A host that can hand a child its own terminal installs the foreground + * launcher from here, and a provider that needs process facts installs the + * POSIX observation beside it. Nothing else in this package reaches a + * platform primitive, so a host that is not POSIX installs something else and + * imports none of this. + */ + +export { installForegroundLauncher, installPosixProcessObservation, reap } from "./src/posix.ts"; + +export { NO_TERMINAL, NativeLauncherUnavailableError } from "./src/errors.ts"; diff --git a/packages/terminal/processes.ts b/packages/terminal/processes.ts new file mode 100644 index 000000000..dfdb6db4b --- /dev/null +++ b/packages/terminal/processes.ts @@ -0,0 +1,20 @@ +/** + * What a terminal provider must be able to establish about a process. + * + * Reusable by any host: a provider that needs to prove a child stopped, or that + * nothing still holds a cell's terminal, asks here rather than reaching for a + * platform primitive of its own. + */ + +export { + PROCESS_OBSERVATION_API, + ProcessObservation, + deliverSignal, + processReachable, +} from "./src/processes.ts"; +export type { ProcessObserver, SignalDelivery, TerminalSignal } from "./src/processes.ts"; + +export { + PROCESS_OBSERVATION_UNAVAILABLE, + ProcessObservationUnavailableError, +} from "./src/errors.ts"; diff --git a/packages/terminal/src/errors.ts b/packages/terminal/src/errors.ts new file mode 100644 index 000000000..063a86c6a --- /dev/null +++ b/packages/terminal/src/errors.ts @@ -0,0 +1,66 @@ +/** + * What a terminal grid refuses with, and what a host says when it cannot + * present one. + * + * Each constructor has exactly one definition here. Another entrypoint may + * re-export it, and the value stays object-identical, so a `catch` written + * against the root and one written against `./lifecycle` classify the same + * error. + */ + +/** + * A presentation that authorized nothing: a request that was copied, changed, + * kept past its grid, presented twice, or issued under another installation. + */ +export class TerminalGridPresentationError extends Error { + override name = "TerminalGridPresentationError"; +} + +/** A grid that could not be run: a layout that disagrees with its cell work. */ +export class TerminalGridError extends Error { + override name = "TerminalGridError"; +} + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive terminals. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +export const NATIVE_LAUNCHER_UNAVAILABLE = + "no native launcher is installed — this host does not hand a native agent UI " + + "the terminal. `xmd run` installs one; a test or embedding host installs its own."; + +export class NativeLauncherUnavailableError extends Error { + override name = "NativeLauncherUnavailableError"; + constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { + super(message); + } +} + +export const NO_TERMINAL = + " needs a terminal: a native agent UI reads keystrokes and " + + "draws on the screen, and this invocation has none. Run xmd from a terminal, " + + "or use a host that installs its own launcher."; + +/** What a process observation says when no host installed one. */ +export const PROCESS_OBSERVATION_UNAVAILABLE = + "no process observation is installed — this host cannot say whether a process " + + "is still running. A POSIX host installs `installPosixProcessObservation()`."; + +export class ProcessObservationUnavailableError extends Error { + override name = "ProcessObservationUnavailableError"; + constructor(message: string = PROCESS_OBSERVATION_UNAVAILABLE) { + super(message); + } +} diff --git a/packages/terminal/src/grid.ts b/packages/terminal/src/grid.ts new file mode 100644 index 000000000..6ed74a48b --- /dev/null +++ b/packages/terminal/src/grid.ts @@ -0,0 +1,721 @@ +/** + * One terminal grid, from the lease to the last finalizer. + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The grid is built while it is still hidden, every cell + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier releases the hidden grid + * instead of leaving half a grid on the screen. + * + * ``` + * layout reconciled → lease → flush → routed to a provider → admitted + * → store and provider host → cells start → readiness barrier → show + * → cells settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing owns a grid but the expansion that submitted it. `terminalGrid()` is + * a resource whose task is the whole grid: releasing it early cancels that + * task and waits for the same complete teardown, and there is no registry, + * supervisor or execution-wide owner that could keep one running after the + * work that asked for it has gone. + */ + +import { + all, + createScope, + Err, + ensure, + Ok, + race, + resource, + scoped, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; + +import { TerminalGridError, TerminalGridPresentationError } from "./errors.ts"; +import type { + PresentTerminalGrid, + TerminalActivity, + TerminalGridHost, + TerminalGridProvider, + TerminalShellOutcome, +} from "./host.ts"; +import { flushOutput, reserveTerminal } from "./launch.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import { terminalGridRequest } from "./layout.ts"; +import type { TerminalGridLayout, TerminalGridRequest } from "./layout.ts"; +import { retainedGridLayout } from "./journal.ts"; +import type { + RetainedCellOutcome, + RetainedGrid, + TerminalCellWork, + TerminalGridJournal, +} from "./journal.ts"; +import { installTerminalCellOutput } from "./output.ts"; +import { terminalInstallation } from "./presentation.ts"; +import type { IssuedGrid } from "./presentation.ts"; +import { TerminalGrids } from "./routing.ts"; +import { createTerminalGridStore } from "./store.ts"; +import type { TerminalGridStore } from "./store.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalCellStatus, + TerminalGridPhase, + TerminalGridState, +} from "./state.ts"; +import { installTerminalCellUI } from "./ui.ts"; +import type { TerminalCellUI, TerminalGridUI } from "./ui.ts"; + +export function noProviderMessage(): string { + return ( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider" + ); +} + +export function outsideExecutionMessage(): string { + return ( + "a terminal grid is available only inside a document execution with an installed " + + "terminal provider — a grid outside one retains nothing and could not be resumed" + ); +} + +/** + * What a cell that never acquired a terminal activity says. + * + * A cell whose work finished without ever starting something interactive has + * not started: presenting it as a running cell would be presenting a grid the + * reader cannot use. + */ +export function cellNeverStartedMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A terminal cell runs an interactive child — a , or the ` + + `default shell a self-closing terminal starts.` + ); +} + +export function cellClosedMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") is closed: its grid has stopped admitting terminal ` + + `activities` + ); +} + +export function cellBusyMessage(position: number, title: string): string { + return ( + `terminal ${position} ("${title}") already has a live terminal activity — one owns a cell ` + + `terminal at a time` + ); +} + +/** + * Run one grid beneath the operation that submitted it. + * + * The returned task is the grid. Awaiting it is how the caller learns what the + * grid retained; releasing the resource before that cancels it and waits for + * every cell, renderer, host finalizer and the lease. + */ +export function terminalGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, +): Operation> { + // Written as an operation the caller delegates into rather than with + // `resource()`, because where the deferral finalizer is *registered* decides + // whether it works. A resource body is a task of its own, and a finalizer + // registered inside one unwinds with that task rather than beside the + // detached scope it has to outlive — which lets a cancellation reach the + // grid's durable child before the owner has finished it, and turns a + // completed reader close into a cancelled record a later run would have to + // revive. Delegating puts both on the caller's own frame, in the order they + // were written. Cleanup is still scope-bound, so this is a resource in every + // sense the caller can observe. + return { + *[Symbol.iterator]() { + // Checked before anything exists. A layout that disagrees with its cell + // work is the caller's mistake rather than something a grid came to, so + // it refuses at acquisition and no scope, store or provider is reached. + validateCellWork(layout, cells); + + const close = createCloseHandshake(); + // The grid runs in a scope of its own — a child of this one, so it inherits + // every context the document runs under, and its own so that tearing this + // one down does not reach the grid first. That ordering is what makes the + // finalizer below genuinely deferred once close has been acknowledged: the + // grid and its cells finish their own teardown and append their ordinary + // completed records, and only then does a pending cancellation carry on to + // the parent. + // + // A scope rather than an ordinary spawn for a second reason: how a grid + // ended is the caller's answer to read. A spawned task that fails takes its + // host scope down with it, which would unwind the expansion before it could + // account for the failure. + const [detached, destroy] = createScope(yield* useScope()); + const held: { task?: Task; outcome?: Result } = {}; + + // Registered after the scope and before the task, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && close.acknowledged && held.outcome === undefined) { + held.outcome = yield* settle(held.task); + } + yield* until(destroy()); + }); + + held.task = detached.run(() => runGrid(layout, cells, journal, close)); + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. + yield* spawn(function* () { + yield* close.proposed(); + close.acknowledge(); + }); + + return held.task; + }, + }; +} + +/** + * The live boundary reader close crosses. + * + * The provider settling `closed` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * close admission and ask its cells to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +interface CloseHandshake { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +function createCloseHandshake(): CloseHandshake { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + +/** + * Reconcile the layout, then run the grid as one durable child of the + * submitting expansion. + */ +function runGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + close: CloseHandshake, +): Operation { + return (function* (): Operation { + // Before the lease and before any provider is contacted: a resumed run + // whose resolved layout changed refuses while nothing has been opened. + yield* journal.reconcileLayout(retainedGridLayout(layout)); + return yield* journal.retainGrid(liveGrid(layout, cells, journal, close)); + })(); +} + +function* settle(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +function validateCellWork(layout: TerminalGridLayout, cells: readonly TerminalCellWork[]): void { + if (layout.cells.length === 0) { + throw new TerminalGridError("a terminal grid layout places no cells"); + } + if (cells.length !== layout.cells.length) { + throw new TerminalGridError( + `a terminal grid layout places ${layout.cells.length} cells and was given ` + + `${cells.length} cell operations: ordered array position is a cell's identity, so the ` + + `two must agree`, + ); + } + const identities = new Set(cells.map((cell) => cell.cellId)); + if (identities.size !== cells.length) { + throw new TerminalGridError( + "a terminal grid was given the same live cell identity twice: one fresh identity is " + + "minted per authored position", + ); + } +} + +/** + * Take the lease, issue the request, and read what presentation settled. + * + * The routed answer is discarded on purpose: a handler that short-circuits or + * fabricates a return has presented nothing, and this says so rather than + * letting the document believe a grid opened. + */ +function liveGrid( + layout: TerminalGridLayout, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + close: CloseHandshake, +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalGridPresentationError(outsideExecutionMessage()); + } + + const request = terminalGridRequest(layout); + let settled: RetainedGrid | undefined; + + // Issued, not started. The lookup holds the request and this work until a + // provider presents a grid for this exact object; the grid then runs + // beneath this operation's own scope. + const issued: IssuedGrid = { + request, + generation: installation.generation, + used: false, + *run(provider) { + settled = yield* runPresented(request, cells, journal, provider, close); + }, + }; + installation.grids.add(issued); + yield* ensure(() => { + installation.grids.delete(issued); + }); + + // The one foreground-terminal lease, taken before any provider is asked + // for anything. A root native launch and a grid contend for exactly this. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before + // the grid covers it up. + yield* flushOutput(); + + yield* TerminalGrids.operations.open(request); + + if (settled === undefined) { + throw new TerminalGridPresentationError(noProviderMessage()); + } + return settled; + }); +} + +/** One cell's live bookkeeping. Private, and never a second capability model. */ +interface CellRuntime { + readonly position: number; + readonly cellId: TerminalCellId; + readonly title: string; + /** Whether this cell has ever acquired a terminal activity, or was restored. */ + ready: boolean; + /** Whether one terminal activity is live in this cell right now. */ + busy: boolean; + outcome?: RetainedCellOutcome; +} + +function runPresented( + request: TerminalGridRequest, + cells: readonly TerminalCellWork[], + journal: TerminalGridJournal, + provider: TerminalGridProvider, + close: CloseHandshake, +): Operation { + return scoped(function* (): Operation { + const runtimes: CellRuntime[] = cells.map((work, position) => ({ + position, + cellId: work.cellId, + title: request.cells[position]!.title, + ready: false, + busy: false, + })); + + const store = yield* createTerminalGridStore({ + columns: request.columns, + rows: request.rows, + cells: runtimes.map((runtime) => ({ + cellId: runtime.cellId, + title: runtime.title, + row: request.cells[runtime.position]!.row, + column: request.cells[runtime.position]!.column, + })), + }); + + // Registered before the host is acquired, so it runs after the host has + // been released and the root terminal restored — which is the only moment + // at which `closed` is the whole truth about this grid. + yield* ensure(function* () { + yield* publishClosedPhase(store); + }); + + const host = yield* provider.host(request, { states: store.states }); + + // Independent of reader close, and observed for the host's whole acquired + // lifetime: a renderer that died while no action was waiting on it may not + // stay hidden until something happens to call the provider again. Spawned + // after acquisition, so it comes down before the host is released and an + // ordinary release settles nothing as a false event. + yield* spawn(function* (): Operation { + throw yield* host.failed; + }); + + let admitting = true; + let shown = false; + const closing = withResolvers(); + const readiness = runtimes.map(() => withResolvers()); + const startupFailed = withResolvers(); + + // Nothing new is admitted once teardown begins, so a cell that was about + // to start a terminal activity is refused rather than racing the close. + yield* ensure(() => { + admitting = false; + }); + + const markReady = (runtime: CellRuntime): void => { + if (runtime.ready) { + return; + } + runtime.ready = true; + readiness[runtime.position]!.resolve(); + }; + + const performActivity = function* ( + runtime: CellRuntime, + start: () => TerminalActivity, + ): Operation { + if (!admitting) { + throw new TerminalGridPresentationError(cellClosedMessage(runtime.position, runtime.title)); + } + if (runtime.busy) { + throw new TerminalGridPresentationError(cellBusyMessage(runtime.position, runtime.title)); + } + runtime.busy = true; + try { + // Admitted, and the revision that includes every causally prior output + // of this cell captured with it. + const admitted = yield* store.commit((state) => + withCellStatus(state, runtime.cellId, "launching"), + ); + // Nothing of the provider's is called until the screen it will draw on + // is the screen this action asked for. A cancellation here therefore + // makes no child call and establishes no readiness. + yield* host.converge(admitted.revision); + return yield* scoped(function* (): Operation { + // Acquisition happens only once the child has actually spawned, and + // acquiring it *is* the cell becoming ready. + const outcome = yield* start(); + yield* store.commit((state) => withCellStatus(state, runtime.cellId, "running")); + markReady(runtime); + return yield* outcome; + }); + } finally { + // The activity's own cleanup has been awaited by the scope above, so + // the next activity is admitted only after this one is quiescent. + runtime.busy = false; + } + }; + + interface LaunchRun { + readonly runtime: CellRuntime; + readonly request: NativeLaunchRequest; + outcome?: NativeLaunchOutcome; + } + interface ShellRun { + readonly runtime: CellRuntime; + outcome?: TerminalShellOutcome; + } + + const launchController = store.controller("terminal.cell.launch", function* (run) { + run.outcome = yield* performActivity(run.runtime, () => + host.launch(run.runtime.cellId, run.request), + ); + }); + const shellController = store.controller("terminal.cell.shell", function* (run) { + run.outcome = yield* performActivity(run.runtime, () => host.shell(run.runtime.cellId)); + }); + const showController = store.controller>( + "terminal.grid.show", + function* () { + const visible = yield* store.commit((state) => withPhase(state, "visible")); + yield* host.show(visible.revision); + }, + ); + + const cellHandles: TerminalCellUI[] = runtimes.map((runtime) => ({ + get state(): TerminalCellState { + return store.state().cells[runtime.position]!; + }, + *launch(nativeRequest: NativeLaunchRequest): Operation { + const run: LaunchRun = { runtime, request: nativeRequest }; + yield* launchController(run); + return run.outcome ?? {}; + }, + *shell(): Operation { + const run: ShellRun = { runtime }; + yield* shellController(run); + return run.outcome ?? {}; + }, + })); + + const ui: TerminalGridUI = { + get state(): TerminalGridState { + return store.state(); + }, + cells: cellHandles, + show: () => showController({}), + }; + + const appendOutput = (runtime: CellRuntime) => + function* (text: string): Operation { + yield* store.commit((state) => withCellContent(state, runtime.cellId, text)); + }; + + const children: Task[] = []; + for (const [position, work] of cells.entries()) { + const runtime = runtimes[position]!; + children.push( + yield* spawn(() => + journal.retainCell( + position, + cellOutcome( + runtime, + work, + ui.cells[position]!, + appendOutput(runtime), + closing.operation, + ), + ), + ), + ); + } + + // Observing each child is what turns a cell's outcome — replayed or live — + // into a published status and a cell the barrier counts as started. + for (const [position, child] of children.entries()) { + const runtime = runtimes[position]!; + yield* spawn(function* () { + const outcome = yield* child; + runtime.outcome = outcome; + // A cell restored from its retained outcome satisfies the barrier + // without acquiring anything: it did start, on the run that recorded it. + markReady(runtime); + yield* store.commit((state) => withCellStatus(state, runtime.cellId, outcome.status)); + if (outcome.status === "failed" && !shown) { + // Before the barrier a cell failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than showing what is + // left. After it, the failure is this cell's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + }); + } + + // Every cell must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose cell + // already failed from waiting forever for an acquisition that cannot happen. + try { + yield* race([all(readiness.map((gate) => gate.operation)), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored position, not by + // whichever rejected the race first. + throw new TerminalGridError(firstReason(runtimes) ?? "a terminal grid cell failed to start"); + } + + yield* ui.show(); + shown = true; + + // The grid stays visible after its cells settle. The reader leaving is + // what finishes the grid, not the last cell exiting. + yield* host.closed; + + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* close.propose(); + + admitting = false; + yield* store.commit((state) => withPhase(state, "closing")); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a cell + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. + for (const runtime of runtimes) { + if (runtime.outcome === undefined) { + yield* store.commit((state) => withCellStatus(state, runtime.cellId, "closed")); + } + } + for (const [position, child] of children.entries()) { + // Awaited, not halted. Each cell settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* child; + runtimes[position]!.outcome ??= outcome; + } + + const outcomes = runtimes.map((runtime) => runtime.outcome ?? closedOutcome()); + const reason = firstReason(runtimes); + return { + layout: retainedGridLayout({ + columns: request.columns, + rows: request.rows, + cells: request.cells.map((cell) => ({ + title: cell.title, + form: cell.form, + row: cell.row, + column: cell.column, + })), + }), + close: reason === undefined ? "reader" : "failed", + cells: outcomes, + }; + }); +} + +/** + * Publish `closed`, once the host has gone and the root terminal is back. + * + * Cleanup enforces quiescence; it does not decide outcomes. A commit that + * cannot happen here — a grid already at its revision ceiling, a store whose + * scope is coming down — must not replace the result the grid already reached. + */ +function* publishClosedPhase(store: TerminalGridStore): Operation { + try { + yield* store.commit((state) => withPhase(state, "closed")); + } catch { + // The phase is live display state. Nothing reads it after this point, and + // the outcome this grid settled on is already decided. + } +} + +/** Run one cell's work and say what it came to. */ +function cellOutcome( + runtime: CellRuntime, + work: TerminalCellWork, + handle: TerminalCellUI, + append: (text: string) => Operation, + closing: Operation, +): Operation { + return (function* (): Operation { + try { + // The cell's work runs beside the close signal rather than under it. + // When the reader leaves, this settles as `closed` straight away and the + // work comes down in the enclosing scope's own teardown — so a cell whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => interpretCell(work, handle, append)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + // The nested work is stopped by this cell's own scope, and its + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. + yield* running.halt(); + return { status: "closed", reason: "" }; + } + if (!runtime.ready) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: cellNeverStartedMessage(runtime.position, runtime.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** Interpret one cell's lazy operation exactly once, under its issued handle. */ +function interpretCell( + work: TerminalCellWork, + handle: TerminalCellUI, + append: (text: string) => Operation, +): Operation { + return scoped(function* () { + yield* installTerminalCellUI(handle); + yield* installTerminalCellOutput(append); + yield* work.operation; + }); +} + +/** The first failed cell's sentence in authored order, which is the grid's. */ +function firstReason(runtimes: readonly CellRuntime[]): string | undefined { + return runtimes.find((runtime) => runtime.outcome?.status === "failed")?.outcome?.reason; +} + +/** What a cell the reader closed came to. */ +function closedOutcome(): RetainedCellOutcome { + return { status: "closed", reason: "" }; +} + +function withPhase(state: TerminalGridState, phase: TerminalGridPhase): TerminalGridState { + return { ...state, phase }; +} + +function withCellStatus( + state: TerminalGridState, + cellId: TerminalCellId, + status: TerminalCellStatus, +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell) => (cell.cellId === cellId ? { ...cell, status } : cell)), + }; +} + +function withCellContent( + state: TerminalGridState, + cellId: TerminalCellId, + text: string, +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell) => + cell.cellId === cellId ? { ...cell, content: cell.content + text } : cell, + ), + }; +} + +export type { PresentTerminalGrid, TerminalGridHost }; diff --git a/packages/terminal/src/host.ts b/packages/terminal/src/host.ts new file mode 100644 index 000000000..1b658cea4 --- /dev/null +++ b/packages/terminal/src/host.ts @@ -0,0 +1,115 @@ +/** + * What a provider supplies, and what the grid can ask of it. + * + * The provider draws a grid and nothing else decides: it observes the desired + * state, converges the screen to it, hands one cell's terminal to a child, and + * says when the reader left or when its own machinery failed. It cannot settle + * a grid by returning, close one by calling something, or change a state it + * was shown. + */ + +import type { Operation, Stream } from "effection"; + +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import type { TerminalGridRequest } from "./layout.ts"; +import type { TerminalCellId, TerminalGridRevision, TerminalGridState } from "./state.ts"; + +/** How a cell's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One terminal activity: something interactive a cell runs. + * + * A resource, and the acquisition is the whole point. Preparing a child and + * spawning it happen before the value exists, so a provider that could not + * start one never yields — and the cell it belongs to never becomes ready. + * Acquiring it means the child is running; the value acquired is the operation + * that settles with how that child ended; releasing it kills and reaps + * whatever is left. + * + * A child that starts and exits immediately is therefore both ready and + * settled. Allocating a process identifier and receiving first output are not + * acquisition. + */ +export type TerminalActivity = Operation>; + +/** + * The provider's only window onto the grid. + * + * Starting a subscription atomically registers it and enqueues the current + * snapshot, then delivers only strictly newer revisions. There is no separate + * "read the current state" operation to combine with a later subscription, + * because the gap between those two is exactly where a commit would be lost. + */ +export interface TerminalGridView { + readonly states: Stream; +} + +/** + * One provider's realization of one complete grid. + * + * Supplied as a resource, so acquiring it is the provider grid coming into + * existence and releasing it is the grid going away — exactly once, whether + * the grid succeeded, failed to start, was closed by the reader, was failed by + * the provider, or was cancelled. There is no destroy to call and no way to + * call one twice. + */ +export interface TerminalGridHost { + /** Settles when the reader closes or leaves the grid. */ + readonly closed: Operation; + /** + * Settles with the provider's own error when its background work fails. + * + * Independent of `closed`, and observed for the host's whole acquired + * lifetime: a renderer that died while no action was waiting on it may not + * stay hidden until something happens to call the provider again. + */ + readonly failed: Operation; + /** + * Converge through `requiredRevision` and then present the prepared grid, + * atomically. Called once, and only after every cell is ready. + */ + show(requiredRevision: TerminalGridRevision): Operation; + /** + * Settle once a complete snapshot with a revision of at least + * `requiredRevision` has been fully applied. + * + * A later revision satisfies an earlier requirement, because the aggregate + * subsumes everything before it. + */ + converge(requiredRevision: TerminalGridRevision): Operation; + /** Hand one live cell's terminal to the exact native request. */ + launch( + cellId: TerminalCellId, + request: NativeLaunchRequest, + ): TerminalActivity; + /** Start the host's default interactive shell in one live cell. */ + shell(cellId: TerminalCellId): TerminalActivity; +} + +/** + * What a registered provider is: something that can realize one request. + * + * A provider object is implementation, not authority. It cannot present + * another request, choose another installation generation, mutate state, or + * settle the grid by what it returns. + */ +export interface TerminalGridProvider { + host(request: TerminalGridRequest, view: TerminalGridView): Operation; +} + +/** + * What a registered provider is handed, and the only way to present. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else: it does not travel through a context, a request, a result, a + * prop, a binding or a durable record. Presenting the exact request core + * issued is what runs the grid; anything else authorizes nothing. + */ +export type PresentTerminalGrid = ( + request: TerminalGridRequest, + provider: TerminalGridProvider, +) => Operation; diff --git a/packages/terminal/src/journal.ts b/packages/terminal/src/journal.ts new file mode 100644 index 000000000..f02a3f754 --- /dev/null +++ b/packages/terminal/src/journal.ts @@ -0,0 +1,128 @@ +/** + * What a grid retains, and how the lifecycle reaches a journal it knows + * nothing about. + * + * The neutral package defines this interface and calls it; the host that has a + * journal implements it with its own source-aware descriptions already closed + * over. Only provider-neutral layouts, outcomes and lazy operations cross the + * boundary, so nothing here names a journal type, a coroutine, or a durable + * record shape. + * + * Array position is the durable identity of a cell. No ordinal, index or key + * duplicates that fact, because a stored one could disagree with the position + * it sits at. + */ + +import type { Operation } from "effection"; +import type { Json } from "@executablemd/durable-streams"; + +import type { TerminalCellForm } from "./layout.ts"; +import type { TerminalCellId } from "./state.ts"; + +/** + * One retained cell's placement, in authored order. + * + * Every retained shape here is `Json`, which is the durable stream's own + * vocabulary rather than a structural look-alike: a host writes these records + * straight down, and a type that only resembled JSON would let a value through + * that no journal could hold. It is the one thing this package takes from + * anywhere else, and it is a data type — nothing of core, the CLI, a runtime + * host or a multiplexer crosses this boundary. + */ +export interface RetainedCell extends Record { + readonly title: string; + readonly form: TerminalCellForm; + readonly row: number; + readonly column: number; +} + +/** The provider-neutral layout a grid retains. */ +export interface RetainedGridLayout extends Record { + readonly columns: number; + readonly rows: number; + readonly cells: RetainedCell[]; +} + +/** How a grid ended. */ +export type TerminalGridCloseKind = "reader" | "failed"; + +/** One cell's retained outcome: what it came to, and why when it failed. */ +export interface RetainedCellOutcome extends Record { + readonly status: TerminalCellStatusOutcome; + readonly reason: string; +} + +/** What a cell can have come to, as the journal records it. */ +export type TerminalCellStatusOutcome = "succeeded" | "failed" | "closed"; + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * cell's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or terminal identifier, no argv or environment, and no + * terminal byte — none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + readonly layout: RetainedGridLayout; + readonly close: TerminalGridCloseKind; + readonly cells: RetainedCellOutcome[]; +} + +/** + * One cell's work, as the submitting expansion constructed it. + * + * The operation is lazy: constructing it performs no expansion, shell, Agent, + * provider or journal work at all. The lifecycle interprets it exactly once, + * inside the durable child derived from its position, with that cell's issued + * handle and output sink installed. + */ +export interface TerminalCellWork { + readonly cellId: TerminalCellId; + readonly operation: Operation; +} + +/** + * The three durable boundaries a grid has. + * + * `reconcileLayout()` runs before provider admission, so a resumed run whose + * resolved layout changed refuses while nothing has been opened and nothing has + * started. `retainGrid()` may return a completed retained outcome without + * interpreting its live operation, which is how completed replay creates no + * live state at all; `retainCell()` does the same for one completed cell. + */ +export interface TerminalGridJournal { + reconcileLayout(layout: RetainedGridLayout): Operation; + retainGrid(operation: Operation): Operation; + retainCell( + position: number, + operation: Operation, + ): Operation; +} + +/** What a resolved layout has to say to be retained. */ +export interface PlacedGridLayout { + readonly columns: number; + readonly rows: number; + readonly cells: readonly { + readonly title: string; + readonly form: TerminalCellForm; + readonly row: number; + readonly column: number; + }[]; +} + +/** The retained shape of one resolved layout. */ +export function retainedGridLayout(layout: PlacedGridLayout): RetainedGridLayout { + return { + columns: layout.columns, + rows: layout.rows, + cells: layout.cells.map((cell) => ({ + title: cell.title, + form: cell.form, + row: cell.row, + column: cell.column, + })), + }; +} diff --git a/packages/terminal/src/launch.ts b/packages/terminal/src/launch.ts new file mode 100644 index 000000000..df9f78003 --- /dev/null +++ b/packages/terminal/src/launch.ts @@ -0,0 +1,106 @@ +/** + * The native launcher — how a host hands one child process the terminal. + * + * This is not `exec`. An ordinary command is a captured child: its stdout and + * stderr are piped so a document can display, capture and journal them, and + * its exit status is a value the document reads. A native coding-agent UI is + * the opposite of that. It draws on the terminal, reads the person's + * keystrokes, and owns the conversation it has with them. None of that may + * become an XMD process result or a journaled transcript, and a piped child + * cannot be interactive at all. + * + * So a launch asks for three things in order, and each is refusable on its + * own: + * + * 1. `reserve()` takes the one foreground-terminal lease for the run. A host + * with no terminal refuses here, which is before any session ownership has + * moved. Two launches cannot hold it at once even when they name different + * sessions, so native UIs are sequential by construction. A terminal grid + * takes the same lease for its whole visible lifetime. + * 2. `flush()` gives the reader everything the document has produced so far, + * so the native UI does not open on top of half-written output. + * 3. `launch()` spawns the child with the terminal inherited, waits for it, + * and reports its terminal status and nothing else. + * + * There is no host default. `xmd run` installs the foreground launcher from + * `@executablemd/terminal/posix`; a test or embedding host installs the + * controlled one from `@executablemd/terminal/test`. Until one is installed + * every operation refuses, which is what keeps document help and inspection + * free of any of this. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +import { NativeLauncherUnavailableError } from "./errors.ts"; + +/** + * What a provider asks the host to run. + * + * `command` is the complete argv, built by the provider's adapter from the + * provider-native session identity. Raw prepared instructions never appear in + * it, and never in `env`: a process's arguments and environment are readable + * by other processes, so the instruction layer travels through the provider's + * own session API instead. + */ +export interface NativeLaunchRequest { + command: string[]; + cwd: string; + env?: Record; +} + +/** + * How the native UI ended. A child that exited on a signal reports the signal + * and no code, which is how a signalled exit stays distinguishable from + * status 0. + */ +export interface NativeLaunchOutcome { + exitCode?: number; + signal?: string; +} + +export interface NativeLauncherHandler { + reserve(): Operation; + flush(): Operation; + launch(request: NativeLaunchRequest): Operation; +} + +/** + * The composition name is stable across loaded copies, so it does not follow + * the module between packages: a host still running the previous copy composes + * with this one through the name they share. + */ +export const NATIVE_LAUNCHER_API = "runtime.nativeLauncher"; + +export const NativeLauncher: Api = createApi( + NATIVE_LAUNCHER_API, + { + // deno-lint-ignore require-yield + *reserve(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *flush(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *launch(_request: NativeLaunchRequest): Operation { + throw new NativeLauncherUnavailableError(); + }, + }, +); + +/** Hold the foreground-terminal lease for the calling scope. */ +export function reserveTerminal(): Operation { + return NativeLauncher.operations.reserve(); +} + +/** Give the reader everything the document has produced so far. */ +export function flushOutput(): Operation { + return NativeLauncher.operations.flush(); +} + +/** Run one native UI as a foreground child and report how it ended. */ +export function nativeLaunch(request: NativeLaunchRequest): Operation { + return NativeLauncher.operations.launch(request); +} diff --git a/packages/terminal/src/layout.ts b/packages/terminal/src/layout.ts new file mode 100644 index 000000000..44b9a2f7d --- /dev/null +++ b/packages/terminal/src/layout.ts @@ -0,0 +1,116 @@ +/** + * The concrete grid an authored terminal grid derives, and the request one + * expansion submits for it. + * + * A layout is provider-neutral data. It names no terminal, multiplexer, + * socket, process or window: it says how many columns the author asked for, + * how many rows that many cells fill, and which position each cell occupies. + * + * A cell's identity is its position in the ordered array, which is why nothing + * here carries an ordinal, index or key. Two arrays in authored order say the + * same thing, and a stored ordinal could disagree with the position it sits at. + */ + +/** Whether a cell runs the markdown it holds or the host's default shell. */ +export type TerminalCellForm = "paired" | "self-closing"; + +/** One cell, placed. */ +export interface TerminalGridCell { + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** The label it displays. Two cells may carry the same one. */ + readonly title: string; + readonly form: TerminalCellForm; +} + +/** The complete grid one terminal-grid element asked for. */ +export interface TerminalGridLayout { + readonly columns: number; + /** How many rows those columns take to hold every cell. */ + readonly rows: number; + /** Every cell, in authored order, which is also row-major order. */ + readonly cells: readonly TerminalGridCell[]; +} + +/** One cell's placeable facts, once its title has been resolved. */ +export interface PlacedCell { + readonly title: string; + readonly form: TerminalCellForm; +} + +/** + * Place the cells across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` cells fill the first row, the next fill the + * second, and a count that does not divide leaves the positions at the end of + * the last row unused. Nothing is reordered, padded, or balanced — the author's + * order is the layout, and a cell's position is its identity wherever it lands. + */ +export function terminalGridLayout( + columns: number, + cells: readonly PlacedCell[], +): TerminalGridLayout { + return { + columns, + rows: Math.ceil(cells.length / columns), + cells: cells.map((cell, position) => ({ + row: Math.floor(position / columns), + column: position % columns, + title: cell.title, + form: cell.form, + })), + }; +} + +/** One cell the provider is asked to present, by its position in the array. */ +export interface TerminalCellRequest { + /** The label to display. Two cells may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this cell's work or the host's default shell + * does. A provider reads it to know which cells it must start a shell in. + */ + readonly form: TerminalCellForm; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or cell identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and presentation compares the object it is given against + * the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. + */ +export interface TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly cells: readonly TerminalCellRequest[]; +} + +/** The provider-neutral request one derived layout asks for. */ +export function terminalGridRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + cells: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} diff --git a/packages/terminal/src/output.ts b/packages/terminal/src/output.ts new file mode 100644 index 000000000..33aed93d4 --- /dev/null +++ b/packages/terminal/src/output.ts @@ -0,0 +1,47 @@ +/** + * Where a cell's rendered Markdown goes. + * + * This is an integration facet, not a capability. It carries no store, no + * dispatch, no identity, and no way to set a title or a status — the only + * thing it can do is add to the content of the one cell whose scope issued it. + * Outside such a scope it is inert, which is what makes a copy kept past its + * grid, or an import reached from ordinary document work, worth nothing. + * + * An append waits for the private aggregate commit and not for rendering. That + * is the whole causal claim: by the time it returns, the output is part of the + * desired state a later `launch()` or `shell()` will converge through. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; + +/** What one issued cell scope does with rendered bytes. */ +export type TerminalCellOutputSink = (text: string) => Operation; + +const CellOutput: Context = createContext< + TerminalCellOutputSink | undefined +>("terminal.cellOutput", undefined); + +/** + * Add rendered Markdown to the current cell's desired content. + * + * Empty text is a no-op. Every other call appends in call order, so `content` + * remains the complete output the cell has produced so far. + */ +export function appendTerminalCellOutput(text: string): Operation { + return (function* (): Operation { + if (text.length === 0) { + return; + } + const sink = yield* CellOutput.get(); + if (sink === undefined) { + return; + } + yield* sink(text); + })(); +} + +/** Install one cell's sink for the scope that interprets its work. */ +export function* installTerminalCellOutput(sink: TerminalCellOutputSink): Operation { + yield* CellOutput.set(sink); +} diff --git a/packages/runtime/launcher.ts b/packages/terminal/src/posix.ts similarity index 58% rename from packages/runtime/launcher.ts rename to packages/terminal/src/posix.ts index 2a11e374d..12ee9ac8b 100644 --- a/packages/runtime/launcher.ts +++ b/packages/terminal/src/posix.ts @@ -1,33 +1,16 @@ /** - * The native launcher — how a host hands one child process the terminal. + * The POSIX host: how this process hands a child its own terminal, and how it + * establishes that a child stopped. * - * This is not `exec`. An ordinary command is a captured child: its stdout and - * stderr are piped so a document can display, capture and journal them, and - * its exit status is a value the document reads. A native coding-agent UI is - * the opposite of that. It draws on the terminal, reads the person's - * keystrokes, and owns the conversation it has with them. None of that may - * become an XMD process result or a journaled transcript, and a piped child - * cannot be interactive at all. - * - * So a launch asks for three things in order, and each is refusable on its - * own: - * - * 1. `reserve()` takes the one foreground-terminal lease for the run. A host - * with no terminal refuses here, which is before any session ownership has - * moved. Two launches cannot hold it at once even when they name different - * sessions, so native UIs are sequential by construction. - * 2. `flush()` gives the reader everything the document has produced so far, - * so the native UI does not open on top of half-written output. - * 3. `launch()` spawns the child with the terminal inherited, waits for it, - * and reports its terminal status and nothing else. + * XMD stays the parent. It does not replace itself with the child, because a + * process that has execed away cannot cancel the document, reap the child, own + * its exit status, or continue after the UI closes. * - * There is no host default. `xmd run` installs the foreground launcher; - * a test or embedding host installs a controlled one that needs no terminal. - * Until one is installed every operation refuses, which is what keeps - * document help and inspection free of any of this. + * Everything host-specific about a native launch is here rather than beside + * the neutral contracts, so a host that is not POSIX installs something else + * and a package that only describes grids imports none of it. */ -import { type Api, createApi } from "@effectionx/context-api"; import { ensure, race, resource, scoped, until } from "effection"; import { once } from "@effectionx/node/events"; import type { Operation } from "effection"; @@ -35,85 +18,11 @@ import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import process from "node:process"; -/** - * What a provider asks the host to run. - * - * `command` is the complete argv, built by the provider's adapter from the - * provider-native session identity. Raw prepared instructions never appear in - * it, and never in `env`: a process's arguments and environment are readable - * by other processes, so the instruction layer travels through the provider's - * own session API instead. - */ -export interface NativeLaunchRequest { - command: string[]; - cwd: string; - env?: Record; -} - -/** - * How the native UI ended. A child that exited on a signal reports the signal - * and no code, which is how a signalled exit stays distinguishable from - * status 0. - */ -export interface NativeLaunchOutcome { - exitCode?: number; - signal?: string; -} - -export interface NativeLauncherHandler { - reserve(): Operation; - flush(): Operation; - launch(request: NativeLaunchRequest): Operation; -} - -export const NATIVE_LAUNCHER_UNAVAILABLE = - "no native launcher is installed — this host does not hand a native agent UI " + - "the terminal. `xmd run` installs one; a test or embedding host installs its own."; - -export class NativeLauncherUnavailableError extends Error { - override name = "NativeLauncherUnavailableError"; - constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { - super(message); - } -} - -export const NativeLauncher: Api = createApi( - "runtime.nativeLauncher", - { - // deno-lint-ignore require-yield - *reserve(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *flush(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *launch(_request: NativeLaunchRequest): Operation { - throw new NativeLauncherUnavailableError(); - }, - }, -); - -/** Hold the foreground-terminal lease for the calling scope. */ -export function reserveTerminal(): Operation { - return NativeLauncher.operations.reserve(); -} - -/** Give the reader everything the document has produced so far. */ -export function flushOutput(): Operation { - return NativeLauncher.operations.flush(); -} - -/** Run one native UI as a foreground child and report how it ended. */ -export function nativeLaunch(request: NativeLaunchRequest): Operation { - return NativeLauncher.operations.launch(request); -} - -export const NO_TERMINAL = - " needs a terminal: a native agent UI reads keystrokes and " + - "draws on the screen, and this invocation has none. Run xmd from a terminal, " + - "or use a host that installs its own launcher."; +import { NativeLauncherUnavailableError, NO_TERMINAL } from "./errors.ts"; +import { NativeLauncher } from "./launch.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import { ProcessObservation } from "./processes.ts"; +import type { SignalDelivery, TerminalSignal } from "./processes.ts"; /** * How long an interrupted child is given to leave on its own before the @@ -128,6 +37,23 @@ const REAP_POLL_MS = 25; /** How long an unanswerable kill is given before the child is called gone. */ const KILL_SETTLE_MS = 500; +/** Install the POSIX answer to "is this process still there". */ +export function* installPosixProcessObservation(): Operation { + yield* ProcessObservation.around( + { + // deno-lint-ignore require-yield + *reachable([pid]) { + return isReachable(pid); + }, + // deno-lint-ignore require-yield + *deliver([pid, name]) { + return signal(pid, name); + }, + }, + { at: "min" }, + ); +} + interface ForegroundLauncherOptions { /** * Whether this host can hand a child the terminal. Read once, when the @@ -140,10 +66,6 @@ interface ForegroundLauncherOptions { /** * Install the launcher that hands a native UI this process's own terminal. - * - * XMD stays the parent. It does not replace itself with the child, because a - * process that has execed away cannot cancel the document, reap the child, - * own its exit status, or continue after the UI closes. */ export function* installForegroundLauncher( options: ForegroundLauncherOptions = {}, @@ -244,13 +166,13 @@ function runForeground(request: NativeLaunchRequest): Operation { - const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); + const [code, signalName] = yield* once<[number | null, string | null]>(started, "exit"); const outcome: NativeLaunchOutcome = {}; if (code !== null) { outcome.exitCode = code; } - if (signal !== null) { - outcome.signal = signal; + if (signalName !== null) { + outcome.signal = signalName; } return outcome; })(), @@ -265,10 +187,10 @@ function runForeground(request: NativeLaunchRequest): Operation { // permission error — leaves a child that may still be running, and // reporting that as a successful reap would let the document continue // while a native UI still owns the terminal. - let fatal: Delivery | undefined; + let fatal: SignalDelivery | undefined; const done = (outcome?: Error) => { if (settled) { @@ -355,9 +277,6 @@ export function reap(child: ChildProcess): Promise { }); } -/** What one signal delivery established about the process it was aimed at. */ -type Delivery = "delivered" | "absent" | "refused"; - /** * Send one signal to the child by pid, and report what that established. * @@ -367,7 +286,7 @@ type Delivery = "delivered" | "absent" | "refused"; * run would keep waiting on a native UI still holding the terminal. Addressing * the process directly is what makes escalation real. */ -function signal(pid: number, name: "SIGINT" | "SIGKILL"): Delivery { +function signal(pid: number, name: TerminalSignal): SignalDelivery { try { process.kill(pid, name); return "delivered"; @@ -380,12 +299,10 @@ function signal(pid: number, name: "SIGINT" | "SIGKILL"): Delivery { } function isNoSuchProcess(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === "ESRCH" - ); + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + return Reflect.get(error, "code") === "ESRCH"; } /** @@ -400,58 +317,3 @@ function isReachable(pid: number): boolean { return false; } } - -/** - * A launcher a host installs when it has no terminal to give away, and no - * intention of starting a native UI. - * - * `record` sees each request in the order the provider made it; `outcome` - * decides what the child did; and `wait` is the operation the launch blocks - * on, so a test controls exactly how long the document stays suspended. - */ -export interface ControlledLauncherOptions { - record?: (request: NativeLaunchRequest) => void; - outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; - wait?: (request: NativeLaunchRequest) => Operation; - onReserve?: () => void; - onFlush?: () => void; -} - -export function* installControlledLauncher( - options: ControlledLauncherOptions = {}, -): Operation { - let held = false; - yield* NativeLauncher.around( - { - reserve() { - return resource(function* (provide) { - if (held) { - throw new Error( - "another already holds this run's terminal — one " + - "native UI owns the terminal at a time", - ); - } - held = true; - options.onReserve?.(); - try { - yield* provide(); - } finally { - held = false; - } - }); - }, - // deno-lint-ignore require-yield - *flush() { - options.onFlush?.(); - }, - *launch([request]) { - options.record?.(request); - if (options.wait) { - yield* options.wait(request); - } - return options.outcome?.(request) ?? { exitCode: 0 }; - }, - }, - { at: "min" }, - ); -} diff --git a/packages/core/src/terminal/presentation.ts b/packages/terminal/src/presentation.ts similarity index 71% rename from packages/core/src/terminal/presentation.ts rename to packages/terminal/src/presentation.ts index 19c335768..4146412f1 100644 --- a/packages/core/src/terminal/presentation.ts +++ b/packages/terminal/src/presentation.ts @@ -1,14 +1,13 @@ /** - * Who may present a grid, and for which request (architecture.md §Terminal - * presentation). + * Who may present a grid, and for which request. * * The provider draws a grid. This decides one thing about it: whether the - * request being presented is the exact one 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. + * request being presented is the exact one the lifecycle issued, under the + * installation that issued it, and not one that has been presented already. + * Nothing else here decides anything — and nothing here owns a grid. * * Ownership belongs to the expansion that submitted it. A grid runs beneath - * that operation, so its panes keep the durable identity and the bindings of + * that operation, so its cells keep the durable identity and the bindings of * the document position that wrote them, and structured concurrency takes the * grid down whenever that operation unwinds. * @@ -19,44 +18,27 @@ * 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. + * superseded installation, or already used — presents nothing, and the + * provider is never touched. */ 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; +import { TerminalGridPresentationError } from "./errors.ts"; +import type { PresentTerminalGrid, TerminalGridProvider } from "./host.ts"; +import type { TerminalGridRequest } from "./layout.ts"; /** One grid an expansion submitted, and what it is waiting to be given. */ -interface IssuedGrid { - /** The exact request object core issued. Compared by identity, never shape. */ +export interface IssuedGrid { + /** The exact request object the lifecycle issued. Compared by identity, never shape. */ readonly request: TerminalGridRequest; /** The installation this grid belongs to. */ readonly generation: object; /** Whether this request has already been presented. */ used: boolean; /** Run the grid, beneath the operation that submitted it. */ - run(grid: Operation): Operation; + run(provider: TerminalGridProvider): Operation; } /** @@ -70,7 +52,7 @@ export function createPresentTerminalGrid( generation: object, issued: ReadonlySet, ): PresentTerminalGrid { - return function* present(request, grid) { + return function* present(request, provider) { const found = [...issued].find((candidate) => Object.is(candidate.request, request)); if (found === undefined) { throw new TerminalGridPresentationError( @@ -88,10 +70,11 @@ export function createPresentTerminalGrid( "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. + // Admitted before anything of the provider's is touched: a refused + // presentation creates no store, acquires no host, and leaves the provider + // holding nothing. found.used = true; - yield* found.run(grid); + yield* found.run(provider); }; } @@ -112,7 +95,7 @@ export interface TerminalInstallation { const Installation: Context = createContext< TerminalInstallation | undefined ->("core.terminal.installation", undefined); +>("terminal.installation", undefined); /** * Open one terminal installation for a live document, and hand back the @@ -138,5 +121,3 @@ export function* useTerminalInstallation(): Operation { export function terminalInstallation(): Operation { return Installation.get(); } - -export type { IssuedGrid }; diff --git a/packages/terminal/src/processes.ts b/packages/terminal/src/processes.ts new file mode 100644 index 000000000..a8f28af6a --- /dev/null +++ b/packages/terminal/src/processes.ts @@ -0,0 +1,72 @@ +/** + * What a terminal provider must be able to establish about a process. + * + * A cancelled launch may not leave a child holding a terminal, and a cell may + * not admit its next activity while the previous one still owns the screen. + * Both are claims about processes, and neither can be made from a PID, an + * elapsed timeout, or a signal that was merely sent. This is the neutral + * vocabulary for making them; `@executablemd/terminal/posix` is one host's + * answer. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +import { ProcessObservationUnavailableError } from "./errors.ts"; + +/** + * What one signal delivery established about the process it was aimed at. + * + * `absent` is the outcome the caller was asking for: gone between the decision + * and the delivery is still gone. `refused` is a delivery that did not happen, + * and is evidence of nothing. + */ +export type SignalDelivery = "delivered" | "absent" | "refused"; + +/** The signals a terminal provider has cause to send. */ +export type TerminalSignal = "SIGINT" | "SIGTERM" | "SIGKILL"; + +export interface ProcessObserver { + /** + * Whether the process still exists. + * + * Reachability, not an exit event: an event is a report, and a report is not + * the fact a teardown proof needs. + */ + reachable(pid: number): Operation; + /** Send one signal, and say what that established. */ + deliver(pid: number, signal: TerminalSignal): Operation; +} + +/** The stable name every loaded copy composes through. */ +export const PROCESS_OBSERVATION_API = "terminal.processObservation"; + +/** + * The public observation surface. Its own default always refuses. + * + * A host that cannot observe processes must say so rather than answer "gone" + * for a child it never looked at. + */ +export const ProcessObservation: Api = createApi( + PROCESS_OBSERVATION_API, + { + // deno-lint-ignore require-yield + *reachable(_pid: number): Operation { + throw new ProcessObservationUnavailableError(); + }, + // deno-lint-ignore require-yield + *deliver(_pid: number, _signal: TerminalSignal): Operation { + throw new ProcessObservationUnavailableError(); + }, + }, +); + +/** Whether the process still exists. */ +export function processReachable(pid: number): Operation { + return ProcessObservation.operations.reachable(pid); +} + +/** Send one signal, and report what it established. */ +export function deliverSignal(pid: number, signal: TerminalSignal): Operation { + return ProcessObservation.operations.deliver(pid, signal); +} diff --git a/packages/core/src/terminal/provider-api.ts b/packages/terminal/src/routing.ts similarity index 76% rename from packages/core/src/terminal/provider-api.ts rename to packages/terminal/src/routing.ts index 80d2d4377..38438c111 100644 --- a/packages/core/src/terminal/provider-api.ts +++ b/packages/terminal/src/routing.ts @@ -1,35 +1,60 @@ /** - * How a terminal provider is installed, and what installing one grants. + * How a grid request reaches a provider, and how a provider is installed. * - * 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. + * **Routing is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal lease and settles a grid is the + * non-contextual presentation function delivered straight to the registered + * provider, so a handler that answers without delegating has presented nothing + * and settled nothing. * - * 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. + * Installation works the same way, and deliberately mirrors the Agent provider + * handshake. Selecting a provider *is* a presentation decision, so nothing is + * returned up the public chain: public middleware receives one frozen, one-use + * install request and may inspect it, refuse by throwing, or delegate it. The + * registered provider's handler sits at the terminal end of that chain and + * holds its own captured continuation — a parameter of its generator, carried + * by no request and no return value. Through it, and only through it, the + * invocation terminal hands the factory this execution's presentation function + * and records that the provider acknowledged installation. */ import { type Api, createApi } from "@effectionx/context-api"; import { ensure } from "effection"; import type { Operation } from "effection"; -import type { PresentTerminalGrid } from "./presentation.ts"; +import { TerminalProviderInstallError, TerminalProviderUnavailableError } from "./errors.ts"; +import type { PresentTerminalGrid } from "./host.ts"; +import type { TerminalGridRequest } from "./layout.ts"; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; + +export interface TerminalGridApi { + /** + * Route one grid request to whatever presents it. + * + * Answers `unknown`, and the answer is discarded: a return value is not + * evidence that a grid was opened, and the lifecycle reads what presentation + * settled instead of what a handler said. + */ + open(request: TerminalGridRequest): Operation; +} + +/** + * The public routing surface. Its own default always refuses. + * + * Reaching this default means no registered provider consumed the request, so + * nothing was presented — which is the honest answer for a host that installs + * no provider at all. + */ +export const TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, +}); /** What a host says about the provider it is installing. */ export interface TerminalProviderOptions { @@ -83,10 +108,6 @@ export interface TerminalProviderApi { install(call: TerminalProviderCall): Operation; } -export class TerminalProviderInstallError extends Error { - override name = "TerminalProviderInstallError"; -} - /** * The public installation surface. Its own default always refuses. * @@ -171,7 +192,7 @@ function deliveryOf(value: unknown): { } return { options: { label }, - present: (request, grid) => Reflect.apply(present, undefined, [request, grid]), + present: (request, provider) => Reflect.apply(present, undefined, [request, provider]), }; } diff --git a/packages/terminal/src/state.ts b/packages/terminal/src/state.ts new file mode 100644 index 000000000..eed5cdb99 --- /dev/null +++ b/packages/terminal/src/state.ts @@ -0,0 +1,66 @@ +/** + * What a grid currently wants shown. + * + * One immutable aggregate, not a delta or a command log. A provider reads a + * snapshot and makes the screen say that; it never replays the steps that got + * there. So a later snapshot contains every earlier cell output that is still + * part of the presentation, and a provider that skipped one has lost nothing. + * + * `revision` is live-only and per-grid. It starts at zero, increments exactly + * once per commit that changes the aggregate, and is never authored, retained, + * replayed, diagnosed, or placed in a native or Agent request. It exists so a + * caller can name the state it needs on screen and wait for exactly that. + */ + +/** A live grid's monotonic state counter. */ +export type TerminalGridRevision = number; + +/** + * One live cell's identity. + * + * A symbol, because it is live: minted fresh for each cell of each running + * grid, never written down, and unforgeable by anything that did not receive + * it. It keeps a cell handle, its state, the provider's effects and the + * provider's private endpoint binding together even when positions move. It is + * not authored, retained, replayed, diagnosed, placed in a native or Agent + * request, or used as provider identity. + */ +export type TerminalCellId = symbol; + +/** What a cell is doing, as the provider observes it. */ +export type TerminalCellStatus = + | "starting" + | "launching" + | "running" + | "succeeded" + | "failed" + | "closed"; + +/** What the grid as a whole is doing. */ +export type TerminalGridPhase = "preparing" | "visible" | "closing" | "closed"; + +export interface TerminalCellState { + readonly cellId: TerminalCellId; + /** The label to display. Fixed for the life of the grid. */ + readonly title: string; + readonly row: number; + readonly column: number; + readonly status: TerminalCellStatus; + /** + * The complete rendered Markdown display desired for this cell. + * + * Not an output event: a later snapshot carries everything earlier snapshots + * carried. Terminal bytes a native UI or shell exchanges with the reader + * never enter this state at all. + */ + readonly content: string; +} + +export interface TerminalGridState { + readonly revision: TerminalGridRevision; + readonly phase: TerminalGridPhase; + readonly columns: number; + readonly rows: number; + /** Every cell, in authored order. */ + readonly cells: readonly TerminalCellState[]; +} diff --git a/packages/terminal/src/store.ts b/packages/terminal/src/store.ts new file mode 100644 index 000000000..6a50762a0 --- /dev/null +++ b/packages/terminal/src/store.ts @@ -0,0 +1,288 @@ +/** + * One grid's private state, and the only way to change it. + * + * The store is presentation state, not durability: the journal remains the + * source of replay and recovery, and nothing here is ever written down. What + * it owns is the one immutable aggregate a provider renders, the revision that + * names it, and the serialized lane every change goes through. + * + * The lane exists because cells act concurrently. Two cells appending output + * and a third admitting a launch are three effects running at once, and a + * store that let their commits interleave would publish a revision describing + * neither. Each commit therefore runs to completion — compare, increment, + * publish, notify — before the next one begins. + * + * A subscription registers and takes its first snapshot in the same + * synchronous step. There is deliberately no "read the current state" call to + * pair with a later subscribe: the gap between those two is exactly where a + * commit would be lost, and an interface that cannot express the gap cannot + * have the bug. + */ + +import { createChannel, createQueue, ensure, resource, useScope, withResolvers } from "effection"; +import type { Operation, Queue, Stream } from "effection"; +import { createStore, createThunks, StoreUpdateContext } from "starfx"; + +import { TerminalGridError } from "./errors.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalGridRevision, + TerminalGridState, +} from "./state.ts"; + +/** + * A candidate aggregate built from the one in force. + * + * It carries no revision decision: whether this is a change at all, and what + * revision it becomes, belong to the store. + */ +export type GridChange = (current: TerminalGridState) => TerminalGridState; + +/** One cell's fixed placement, as the store is seeded with it. */ +export interface SeededCell { + readonly cellId: TerminalCellId; + readonly title: string; + readonly row: number; + readonly column: number; + /** What this cell starts at: `starting` live, or a restored outcome. */ + readonly status?: TerminalCellState["status"]; +} + +export interface TerminalGridStoreSeed { + readonly columns: number; + readonly rows: number; + readonly cells: readonly SeededCell[]; + /** + * Where this grid's revision sequence begins. + * + * A live grid always begins at zero, partial replay included. The start is a + * parameter because the refusal at the safe-integer ceiling is otherwise + * unreachable, and a ceiling nothing can reach is a ceiling nothing has + * checked. + */ + readonly startRevision?: TerminalGridRevision; +} + +export interface TerminalGridStore { + /** The aggregate in force. A snapshot read earlier never changes. */ + state(): TerminalGridState; + /** + * Every snapshot from this moment on, beginning with the one in force. + * + * Registration and that first snapshot happen in one synchronous step, and + * everything after it has a strictly greater revision. + */ + readonly states: Stream; + /** Apply one semantic change, serialized against every other. */ + commit(change: GridChange): Operation; + /** + * Mint one more blocking controller on this grid's private thunks instance. + * + * The returned operation runs the controller synchronously in its caller, so + * the caller awaits every state transition and host effect the controller + * owns. Nothing reaches one of these through dispatch. + */ + controller

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

(name: string, body: (payload: P) => Operation) { + const created = thunks.create<{ readonly input: P }>(name, function* (ctx, next) { + yield* body(ctx.payload.input); + yield* next(); + }); + return (payload: P) => + (function* (): Operation { + yield* created.run({ input: payload }); + })(); + }, + }; + })(); +} diff --git a/packages/terminal/src/ui.ts b/packages/terminal/src/ui.ts new file mode 100644 index 000000000..2cb816595 --- /dev/null +++ b/packages/terminal/src/ui.ts @@ -0,0 +1,82 @@ +/** + * The domain actions a live grid offers, and how work inside a cell reaches + * its own. + * + * These are action handles, not resource owners. A handle closes over the live + * cell it was issued for, so a component calls `launch()` or `shell()` without + * naming an index or an identifier — and holding one after its grid has closed + * grants nothing, because admission is the lifecycle's state rather than the + * handle's. + * + * Only the cell UI enters a cell's context. The grid UI does not: showing the + * grid is the lifecycle's decision, taken once every cell is ready, and a + * component that could take it would be deciding for its siblings. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; + +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launch.ts"; +import type { TerminalShellOutcome } from "./host.ts"; +import type { TerminalCellState, TerminalGridState } from "./state.ts"; + +export interface TerminalCellUI { + /** + * This cell's current immutable snapshot. + * + * Read each time: a snapshot read earlier never changes, so two reads that + * straddle a commit are two different objects rather than one that moved. + */ + readonly state: TerminalCellState; + /** + * Hand this cell's terminal to one native UI and wait for it. + * + * Refuses after close admission stops, and refuses while another activity is + * live in this cell. Sequential use is ordinary: the next one is admitted + * once the prior activity has settled and its cleanup has finished. + */ + launch(request: NativeLaunchRequest): Operation; + /** Start the host's default shell in this cell and wait for it. */ + shell(): Operation; +} + +export interface TerminalGridUI { + readonly state: TerminalGridState; + /** Every cell's handle, in authored order. */ + readonly cells: readonly TerminalCellUI[]; + /** + * Commit `visible`, and wait until the provider has presented that exact + * revision. + */ + show(): Operation; +} + +const CellUI: Context = createContext( + "terminal.cellUI", + undefined, +); + +/** + * The cell the current work is running in, or `undefined` outside a grid. + * + * Absence is the ordinary case and means "not in a cell": work outside a grid + * reads nothing here and goes on competing for the root foreground lease + * exactly as it always has. + */ +export function useTerminalCellUI(): Operation { + return CellUI.get(); +} + +/** + * Install one cell's issued handle for the scope that interprets its work. + * + * The handle is installed as it was issued. Wrapping it here would put a + * second object between the cell's work and the one the lifecycle is tracking, + * and the refusals and readiness this seam exists to carry are that object's. + * + * Set rather than composed: a cell is not a layer over an enclosing cell, + * because cells do not nest. + */ +export function* installTerminalCellUI(ui: TerminalCellUI): Operation { + yield* CellUI.set(ui); +} diff --git a/packages/terminal/test/launcher.ts b/packages/terminal/test/launcher.ts new file mode 100644 index 000000000..422b056a4 --- /dev/null +++ b/packages/terminal/test/launcher.ts @@ -0,0 +1,61 @@ +/** + * A launcher a host installs when it has no terminal to give away, and no + * intention of starting a native UI. + * + * `record` sees each request in the order the provider made it; `outcome` + * decides what the child did; and `wait` is the operation the launch blocks + * on, so a test controls exactly how long the document stays suspended. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; + +import { NativeLauncher } from "../mod.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "../mod.ts"; + +export interface ControlledLauncherOptions { + record?: (request: NativeLaunchRequest) => void; + outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; + wait?: (request: NativeLaunchRequest) => Operation; + onReserve?: () => void; + onFlush?: () => void; +} + +export function* installControlledLauncher( + options: ControlledLauncherOptions = {}, +): Operation { + let held = false; + yield* NativeLauncher.around( + { + reserve() { + return resource(function* (provide) { + if (held) { + throw new Error( + "another already holds this run's terminal — one " + + "native UI owns the terminal at a time", + ); + } + held = true; + options.onReserve?.(); + try { + yield* provide(); + } finally { + held = false; + } + }); + }, + // deno-lint-ignore require-yield + *flush() { + options.onFlush?.(); + }, + *launch([request]) { + options.record?.(request); + if (options.wait) { + yield* options.wait(request); + } + return options.outcome?.(request) ?? { exitCode: 0 }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/terminal/test/mod.ts b/packages/terminal/test/mod.ts new file mode 100644 index 000000000..5175282af --- /dev/null +++ b/packages/terminal/test/mod.ts @@ -0,0 +1,27 @@ +/** + * Controlled providers, launchers, gates and records. + * + * Nothing here presents anything, opens a terminal, looks for a multiplexer, + * or starts a process. It exists so that the grammar, authority, lifecycle, + * convergence, settlement and replay contracts can be proved without a + * production provider anywhere in the picture. + */ + +export { installControlledLauncher } from "./launcher.ts"; +export type { ControlledLauncherOptions } from "./launcher.ts"; + +export { + controlledTerminalProvider, + rendererEndedMessage, + settled, + subscriptionEndedMessage, + terminalProviderLog, +} from "./provider.ts"; +export type { + ControlledProviderOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "./provider.ts"; + +export { barrier, gate } from "./signal.ts"; +export type { Barrier, Gate } from "./signal.ts"; diff --git a/packages/terminal/test/provider.ts b/packages/terminal/test/provider.ts new file mode 100644 index 000000000..945ce57d2 --- /dev/null +++ b/packages/terminal/test/provider.ts @@ -0,0 +1,419 @@ +/** + * A terminal provider that presents nothing and records everything. + * + * It opens no terminal, looks for no multiplexer, and starts no process — and + * it answers the whole host contract, which is what makes it evidence that the + * contract does not depend on tmux. Everything a row needs to read is a record + * or a counter this keeps, so ordering claims are read rather than timed. + * + * The renderer here is the reference shape a real provider has to match: one + * scope-owned lane, coalescing forward to the newest pending snapshot, never + * applying an older revision after a newer one, and advancing what it has + * applied only once the whole render effect for that snapshot has succeeded. + */ + +import { ensure, race, resource, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; + +import type { + NativeLaunchOutcome, + NativeLaunchRequest, + TerminalActivity, + TerminalCellId, + TerminalCellStatus, + TerminalGridHost, + TerminalGridProvider, + TerminalGridRequest, + TerminalGridRevision, + TerminalGridState, + TerminalGridView, + TerminalShellOutcome, +} from "../mod.ts"; + +/** What one controlled provider holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Hosts acquired and not yet released. */ + grids: number; + /** Hosts shown and not yet released. */ + shown: number; + /** Terminal activities acquired and not yet released. */ + activities: number; +} + +/** + * Everything one controlled provider did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every cell started, that nothing was shown before the readiness + * barrier, and that release took down exactly the host it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; + /** Every snapshot the renderer fully applied, in the order it applied them. */ + readonly applied: TerminalGridState[]; + /** + * What each cell displays, by authored position, as of the last applied + * snapshot. + * + * A suite reads this to prove where a cell's output went — and reads the root + * document output to prove where it did not. + */ + readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the host takes something and down when it gives it + * back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { + events: [], + applied: [], + shown: new Map(), + live: { grids: 0, shown: 0, activities: 0 }, + }; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a host exists, `render` gates or fails one revision's render, + * `onShow` fails the barrier, `launch` and `shell` decide what a cell's child + * did and whether it started at all, `close` is the operation the host waits on + * so a suite controls exactly when the reader leaves, and `fail` is the + * background failure a suite raises while nothing is waiting on the renderer. + */ +export interface ControlledProviderOptions { + /** Appended to as the host works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onDestroy?: () => Operation; + /** + * The render effect for one chosen snapshot. + * + * A suite that blocks here holds the lane, which is how coalescing becomes + * observable: the revisions that arrive while this is blocked are subsumed by + * the newest one, and only that one is rendered next. + */ + render?: (state: TerminalGridState) => Operation; + onShow?: (revision: TerminalGridRevision) => Operation; + /** The terminal activity for one cell's native launch. */ + launch?: ( + position: number, + request: NativeLaunchRequest, + ) => TerminalActivity; + /** + * The terminal activity for one cell's shell. + * + * A suite that wants a shell which never starts supplies one that throws + * before it provides: the cell then never becomes ready, exactly as a real + * spawn failure leaves it. + */ + shell?: (position: number) => TerminalActivity; + /** Settles when the reader leaves. Never, by default. */ + close?: () => Operation; + /** + * Settles with a background provider failure. + * + * Independent of `close`: a suite uses it to fail the grid while no + * foreground action is waiting on the renderer at all. + */ + fail?: () => Operation; + /** + * Settles when the renderer should stop applying revisions. + * + * A lane that stops while the host is still acquired is a provider failure, + * and this is how a suite produces one without failing a render. + */ + stopRenderer?: () => Operation; + /** + * Settles when the state subscription should stop delivering. + * + * A view that stops while the host is still acquired is a provider failure + * too, and is reported as one. The stream's own type says it cannot close, so + * this is how a suite reaches that report without building a value the + * contract has no way to express. + */ + stopSubscription?: () => Operation; +} + +/** An outcome that is already settled, for a child that needed no waiting. */ +export function settled(outcome: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return outcome; + })(); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +export function subscriptionEndedMessage(): string { + return ( + "the terminal provider's state subscription ended while its grid was still acquired: a " + + "view that stops delivering is a provider failure, not a converged grid" + ); +} + +export function rendererEndedMessage(): string { + return ( + "the terminal provider's renderer stopped while its grid was still acquired: a lane that " + + "stops applying revisions is a provider failure, not a converged grid" + ); +} + +/** + * One controlled provider. + * + * `generation` counts the hosts it has acquired, so a suite can tell a second + * host apart from the first in the record. + */ +export function controlledTerminalProvider( + options: ControlledProviderOptions = {}, +): TerminalGridProvider { + const log = options.log ?? terminalProviderLog(); + let generation = 0; + + return { + host(request: TerminalGridRequest, view: TerminalGridView): Operation { + return resource(function* (provide) { + const mark = generation++; + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${mark}:${request.columns}x${request.rows}`); + log.live.grids++; + let isShown = false; + + // Registered before the host is provided, so every way out of the + // resource runs it once: settled, failed to start, closed, failed by + // the provider, or cancelled. + yield* ensure(function* () { + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${mark}`); + log.live.grids--; + if (isShown) { + isShown = false; + log.live.shown--; + } + }); + + const failure = withResolvers(); + const fail = (error: unknown): void => failure.resolve(toError(error)); + + // Acquired in the host's own scope and released with it, so a + // subscription cannot outlive the grid it describes. + const states = yield* view.states; + const first = yield* states.next(); + if (first.done) { + throw new Error(subscriptionEndedMessage()); + } + // Revision zero, received atomically with the registration that + // produced it. The cell order it carries is how this provider maps a + // live identity to the authored position it reports in the record. + const order: TerminalCellId[] = first.value.cells.map((cell) => cell.cellId); + const positionOf = (cellId: TerminalCellId): number => { + const position = order.indexOf(cellId); + if (position < 0) { + throw new Error("this terminal grid host was asked about a cell it never received"); + } + return position; + }; + + let pending: TerminalGridState | undefined = first.value; + let applied = -1; + const waiters = new Set<{ readonly required: number; readonly wake: () => void }>(); + let awake = withResolvers(); + const nudge = (): void => { + awake.resolve(); + }; + const statuses = new Map(); + + const satisfy = (): void => { + for (const waiter of [...waiters]) { + if (applied >= waiter.required) { + waiters.delete(waiter); + waiter.wake(); + } + } + }; + + // The subscription pump. It keeps only the newest snapshot, because a + // newer aggregate subsumes every older one — which is what makes + // coalescing forward correct rather than lossy. + function* pump(): Operation { + while (true) { + const next = yield* states.next(); + if (next.done) { + // The stream's type says this cannot happen, so reaching it means + // the view came from somewhere that does not honour the contract. + throw new Error(subscriptionEndedMessage()); + } + if (pending === undefined || next.value.revision > pending.revision) { + pending = next.value; + } + nudge(); + } + } + + yield* spawn(function* (): Operation { + try { + yield* race([ + pump(), + (function* (): Operation { + yield* options.stopSubscription ? options.stopSubscription() : suspend(); + })(), + ]); + fail(new Error(subscriptionEndedMessage())); + } catch (error) { + fail(error); + } + }); + + // One lane, and only one. It never starts two renders at once, never + // applies a revision at or below the greatest it has completed, and + // advances what it has applied only after the whole render effect for + // the chosen snapshot has succeeded. + function* renderLane(): Operation { + while (true) { + const chosen = pending; + if (chosen === undefined || chosen.revision <= applied) { + // Re-armed and re-checked before suspending, so a snapshot that + // arrived between the two is not waited for forever. + awake = withResolvers(); + if (pending !== undefined && pending.revision > applied) { + continue; + } + yield* awake.operation; + continue; + } + pending = undefined; + if (options.render) { + yield* options.render(chosen); + } + applied = chosen.revision; + log.applied.push(chosen); + log.events.push(`render:${mark}:${chosen.revision}`); + for (const [position, cell] of chosen.cells.entries()) { + log.shown.set(position, cell.content); + if (statuses.get(position) !== cell.status) { + statuses.set(position, cell.status); + log.events.push(`status:${mark}:${position}:${cell.status}`); + } + } + satisfy(); + } + } + + yield* spawn(function* (): Operation { + try { + yield* race([ + renderLane(), + (function* (): Operation { + yield* options.stopRenderer ? options.stopRenderer() : suspend(); + })(), + ]); + fail(new Error(rendererEndedMessage())); + } catch (error) { + fail(error); + } + }); + + const failHook = options.fail; + if (failHook) { + yield* spawn(function* (): Operation { + try { + fail(yield* failHook()); + } catch (error) { + fail(error); + } + }); + } + + const converge = (required: TerminalGridRevision): Operation => ({ + *[Symbol.iterator]() { + // Recorded before it is answered, so a suite reads *that* the grid + // asked for a screen, and when, rather than inferring it from what + // happened next. + log.events.push(`converge:${mark}:${required}`); + if (applied >= required) { + return; + } + const reached = withResolvers(); + const waiter = { required, wake: () => reached.resolve() }; + waiters.add(waiter); + try { + yield* reached.operation; + } finally { + waiters.delete(waiter); + } + }, + }); + + yield* provide({ + closed: { + *[Symbol.iterator]() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${mark}`); + }, + }, + failed: { + *[Symbol.iterator]() { + return yield* failure.operation; + }, + }, + converge, + *show(required: TerminalGridRevision) { + yield* converge(required); + if (options.onShow) { + yield* options.onShow(required); + } + log.events.push(`show:${mark}:${required}`); + isShown = true; + log.live.shown++; + }, + launch(cellId: TerminalCellId, nativeRequest: NativeLaunchRequest) { + const position = positionOf(cellId); + return resource>(function* (provideOutcome) { + const outcome = options.launch + ? yield* options.launch(position, nativeRequest) + : settled({ exitCode: 0 }); + log.events.push(`launch:${mark}:${position}`); + log.live.activities++; + yield* ensure(() => { + log.live.activities--; + }); + yield* provideOutcome(outcome); + }); + }, + shell(cellId: TerminalCellId) { + const position = positionOf(cellId); + return resource>(function* (provideOutcome) { + const outcome = options.shell + ? yield* options.shell(position) + : settled({ exitCode: 0 }); + log.events.push(`shell:${mark}:${position}`); + log.live.activities++; + yield* ensure(() => { + log.live.activities--; + }); + yield* provideOutcome(outcome); + }); + }, + }); + }); + }, + }; +} diff --git a/packages/terminal/test/signal.ts b/packages/terminal/test/signal.ts new file mode 100644 index 000000000..cebae2568 --- /dev/null +++ b/packages/terminal/test/signal.ts @@ -0,0 +1,77 @@ +/** + * Gates, for suites that need ordering evidence rather than elapsed time. + * + * A grid that converged too early and one that converged on time take the same + * wall clock, so nothing here measures duration. A gate is opened by something + * that happened, and a row that waits on one either gets the event it named or + * hangs — which is a failure the suite can see, not a pass it cannot trust. + */ + +import { withResolvers } from "effection"; +import type { Operation } from "effection"; + +export interface Gate { + /** Settles once the gate has been opened, however long ago. */ + readonly opened: Operation; + /** Open it. Opening a gate twice is opening it once. */ + open(): void; + /** Whether it has been opened. */ + readonly isOpen: boolean; +} + +export function gate(): Gate { + const resolvers = withResolvers(); + let open = false; + return { + opened: resolvers.operation, + open() { + if (open) { + return; + } + open = true; + resolvers.resolve(); + }, + get isOpen() { + return open; + }, + }; +} + +/** + * A gate that opens once `expected` things have arrived. + * + * Written for the rows that prove concurrency: several cells each announce + * that they are inside their interactive work, and the gate opens only when + * all of them are inside at the same time. Cells that contended could never + * open it. + */ +export interface Barrier extends Gate { + /** Announce one arrival. */ + arrive(): void; + /** How many have arrived. */ + readonly arrived: number; +} + +export function barrier(expected: number): Barrier { + const inner = gate(); + let arrived = 0; + if (expected <= 0) { + inner.open(); + } + return { + opened: inner.opened, + open: inner.open, + get isOpen() { + return inner.isOpen; + }, + arrive() { + arrived += 1; + if (arrived >= expected) { + inner.open(); + } + }, + get arrived() { + return arrived; + }, + }; +} diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts similarity index 98% rename from packages/runtime/tests/native-launcher.test.ts rename to packages/terminal/tests/native-launcher.test.ts index c39546b73..65778ae2a 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -23,14 +23,8 @@ import * as path from "node:path"; import * as os from "node:os"; import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; -import { - flushOutput, - installForegroundLauncher, - nativeLaunch, - NO_TERMINAL, - reap, - reserveTerminal, -} from "../launcher.ts"; +import { flushOutput, nativeLaunch, reserveTerminal } from "../mod.ts"; +import { installForegroundLauncher, NO_TERMINAL, reap } from "../posix.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; diff --git a/packages/terminal/tests/terminal-grid.test.ts b/packages/terminal/tests/terminal-grid.test.ts new file mode 100644 index 000000000..eec0f6fdb --- /dev/null +++ b/packages/terminal/tests/terminal-grid.test.ts @@ -0,0 +1,1270 @@ +/** + * Tier TG — the provider-neutral terminal grid lifecycle (architecture.md + * §Terminal grid presentation, §Atomic presentation and settlement, + * §Durability and replay). + * + * These rows drive `terminalGrid()` directly, with cell work written by hand + * and a journal that retains nothing. What they prove is the part of a grid + * that has no document in it: who may present one, what owns the running + * grid, how the immutable state advances, when an action is allowed to ask the + * provider for a terminal, and what teardown must have finished before any of + * it settles. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + * Every ordering claim is read off a record or a gate, because a grid that + * converged too early and one that converged on time take the same wall clock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { each, ensure, race, resource, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; + +import { + installTerminalProvider, + registerTerminalProvider, + reserveTerminal, + TerminalGridPresentationError, + TerminalGrids, + terminalGridLayout, + TerminalProviderInstallError, + TerminalProviders, + useTerminalCellUI, +} from "../mod.ts"; +import type { + PlacedCell, + RetainedCellOutcome, + RetainedGrid, + TerminalActivity, + TerminalCellUI, + TerminalGridHost, + TerminalGridJournal, + TerminalGridLayout, + TerminalGridProvider, + TerminalGridRequest, + TerminalGridState, + TerminalShellOutcome, +} from "../mod.ts"; +import { appendTerminalCellOutput, terminalGrid, useTerminalInstallation } from "../lifecycle.ts"; +import type { PresentTerminalGrid } from "../lifecycle.ts"; +import { + barrier, + controlledTerminalProvider, + gate, + installControlledLauncher, + terminalProviderLog, +} from "../test/mod.ts"; +import type { + ControlledProviderOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "../test/mod.ts"; +import { createTerminalGridStore, revisionCeilingMessage } from "../src/store.ts"; + +/** A journal with nothing behind it: every retention is its live operation. */ +function directJournal(record: string[] = []): TerminalGridJournal { + return { + // deno-lint-ignore require-yield + *reconcileLayout() { + record.push("reconcile"); + }, + retainGrid: (operation) => operation, + retainCell: (position, operation) => + (function* (): Operation { + record.push(`retain:${position}`); + return yield* operation; + })(), + }; +} + +/** A layout of `titles.length` cells across `columns`, all paired. */ +function layoutOf(columns: number, titles: readonly string[]): TerminalGridLayout { + const cells = titles.map((title): PlacedCell => ({ title, form: "paired" })); + return terminalGridLayout(columns, cells); +} + +function refusalOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Everything a controlled host installs, for an in-process grid. */ +function useHost(provider: TerminalGridProvider): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, present) { + yield* TerminalGrids.around( + { + *open([request]) { + yield* present(request, provider); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; + })(); +} + +/** Cell work that acquires one shell and settles with it. */ +function shellCell(marks?: string[], mark = ""): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("this cell work ran outside the scope that issued its handle"); + } + marks?.push(`enter:${mark}`); + yield* cell.shell(); + marks?.push(`leave:${mark}`); + })(); +} + +/** One ordered `{cellId, operation}` record per position. */ +function cellWork( + titles: readonly string[], + body: (position: number) => Operation, +): { cellId: symbol; operation: Operation }[] { + return titles.map((title, position) => ({ + cellId: Symbol(`cell:${position}:${title}`), + operation: body(position), + })); +} + +describe("Tier TG — owning one grid", () => { + it("TG22: cell work is inert until interpreted, and interpreted exactly once", function* () { + const constructed: number[] = []; + const entered: number[] = []; + const log = terminalProviderLog(); + + const retained = yield* scoped(function* (): Operation { + const settled = settledCells(2); + yield* useHost( + controlledTerminalProvider({ log, close: () => settled.opened, render: settled.render }), + ); + const titles = ["a", "b"]; + const cells = titles.map((title, position) => { + // Built here and not entered: constructing a cell's operation performs + // no expansion, shell, provider or journal work at all. + constructed.push(position); + return { + cellId: Symbol(title), + operation: (function* (): Operation { + entered.push(position); + yield* shellCell(); + })(), + }; + }); + expect(entered).toEqual([]); + + const task = yield* terminalGrid(layoutOf(2, titles), cells, directJournal()); + return yield* task; + }); + + expect(constructed).toEqual([0, 1]); + // Once each, in authored order, and never a second time. + expect(entered.slice().sort()).toEqual([0, 1]); + expect(retained.cells).toHaveLength(2); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG22: the returned task is owned by the submitting scope", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let heldWhileLive: TerminalProviderResources | undefined; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + + // The grid is live — its host acquired, its cell holding an activity — + // and the row's own branch then wins the race, cancelling the submitting + // operation and nothing else. + yield* race([ + (function* (): Operation { + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell(finalized, "cell")), + directJournal(), + ); + yield* task; + })(), + (function* (): Operation { + yield* live.opened; + heldWhileLive = { ...log.live }; + })(), + ]); + }); + + expect(heldWhileLive).toEqual({ grids: 1, shown: 0, activities: 1 }); + // The cell's activity was released and the provider's host with it: nothing + // detached, and every finalizer ran. + expect(finalized).toEqual(["cell"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + }); + + it("TG22: releasing the resource early cancels and awaits cells, renderer and host", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + + yield* scoped(function* () { + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + ...heldShell(live), + // deno-lint-ignore require-yield + *onDestroy() { + finalized.push("host"); + }, + }), + ); + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell(finalized, "cell")), + directJournal(), + ); + // Deliberately not awaited: the row releases the resource by + // cancelling the scope that holds it, which is the case the contract + // is about. + void grid; + yield* suspend(); + }); + }); + yield* live.opened; + yield* task.halt(); + }); + + // The cell's finalizer ran, the host was destroyed once, and nothing the + // provider handed out is still held. + expect(finalized).toEqual(["cell", "host"]); + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG22: a layout and its cell work must agree, and no provider is reached", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useHost(controlledTerminalProvider({ log })); + try { + const task = yield* terminalGrid( + layoutOf(2, ["a", "b"]), + cellWork(["a"], () => shellCell()), + directJournal(), + ); + yield* task; + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("places 2 cells and was given 1 cell operations"); + expect(log.events).toEqual([]); + }); + + it("TG22: one live identity per authored position, and never the same one twice", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + const shared = Symbol("shared"); + + yield* scoped(function* () { + yield* useHost(controlledTerminalProvider({ log })); + try { + const task = yield* terminalGrid( + layoutOf(2, ["a", "b"]), + [ + { cellId: shared, operation: shellCell() }, + { cellId: shared, operation: shellCell() }, + ], + directJournal(), + ); + yield* task; + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("the same live cell identity twice"); + expect(log.events).toEqual([]); + }); + + it("TG23: the foreground lease admits one grid at a time", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let refusal: unknown; + + // Caught around the region rather than around the await. A grid that fails + // raises into the scope that submitted it, which is how a document run + // learns about it; a `catch` at the await would never see it. + try { + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + yield* spawn(function* () { + const task = yield* terminalGrid( + layoutOf(1, ["first"]), + cellWork(["first"], () => holdingCell(finalized, "first")), + directJournal(), + ); + yield* task; + }); + yield* live.opened; + + // The foreground-terminal lease admits a single grid at a time, so a + // second never reaches presentation at all. + const second = yield* terminalGrid( + layoutOf(1, ["second"]), + cellWork(["second"], () => shellCell()), + directJournal(), + ); + yield* second; + }); + } catch (error) { + refusal = error; + } + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + // Only one host was ever prepared. + expect(log.events.filter((event) => event.startsWith("prepare:"))).toEqual(["prepare:0:1x1"]); + expect(finalized).toEqual(["first"]); + }); + + it("TG23: a root native launch cannot take the lease a live grid holds", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = gate(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ log, close: () => suspend(), ...heldShell(live) }), + ); + yield* spawn(function* () { + const task = yield* terminalGrid( + layoutOf(1, ["grid"]), + cellWork(["grid"], () => holdingCell(finalized, "grid")), + directJournal(), + ); + yield* task; + }); + yield* live.opened; + + try { + yield* scoped(function* () { + yield* reserveTerminal(); + }); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + }); +}); + +/** Cell work that starts something interactive and stays until it is stopped. */ +function holdingCell(finalized: string[], mark: string): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("this cell work ran outside the scope that issued its handle"); + } + yield* ensure(() => { + finalized.push(mark); + }); + yield* cell.shell(); + })(); +} + +/** + * A shell that starts and never finishes, and a gate opened once it is running. + * + * `running` is committed after the activity has been acquired, so a row that + * waits on this gate is waiting for the provider to be holding something. + */ +function heldShell(live: { open(): void }): Pick { + return { + shell: () => + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }), + // deno-lint-ignore require-yield + *render(state: TerminalGridState) { + if (state.cells.some((cell) => cell.status === "running")) { + live.open(); + } + }, + }; +} + +/** + * A render hook that opens its gate once `count` cells have settled. + * + * The gate is opened by a snapshot the renderer actually applied, so a row that + * waits on it waits for an event rather than for a duration. + */ +function settledCells(count: number): { + readonly opened: Operation; + render: (state: TerminalGridState) => Operation; +} { + const reached = gate(); + return { + opened: reached.opened, + // deno-lint-ignore require-yield + *render(state: TerminalGridState) { + const settled = state.cells.filter( + (cell) => + cell.status === "succeeded" || cell.status === "failed" || cell.status === "closed", + ).length; + if (settled >= count) { + reached.open(); + } + }, + }; +} + +describe("Tier TG — the private aggregate", () => { + const seed = () => ({ + columns: 2, + rows: 1, + cells: [ + { cellId: Symbol("a"), title: "Left", row: 0, column: 0 }, + { cellId: Symbol("b"), title: "Right", row: 0, column: 1 }, + ], + }); + + it("TG24: the first snapshot is revision zero", function* () { + const store = yield* createTerminalGridStore(seed()); + const first = store.state(); + expect(first.revision).toBe(0); + expect(first.phase).toBe("preparing"); + expect(first.cells.map((cell) => cell.status)).toEqual(["starting", "starting"]); + expect(first.cells.map((cell) => cell.content)).toEqual(["", ""]); + }); + + it("TG24: a subscription starts from the snapshot in force, with no gap after it", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + // Something has already happened before anybody subscribes. + yield* store.commit((state) => ({ ...state, phase: "visible" })); + + const states = yield* store.states; + // Commits that land after the subscription exists. + yield* store.commit((state) => withStatus(state, 0, "launching")); + yield* store.commit((state) => withStatus(state, 1, "launching")); + + for (let read = 0; read < 3; read++) { + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + } + }); + + // The revision in force when the subscription began, and then every later + // one in order: nothing between the registration and the first snapshot. + expect(seen).toEqual([1, 2, 3]); + }); + + it("TG24: no commit is lost between registering a subscription and its first snapshot", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + const subscribed = withResolvers(); + const reading = yield* spawn(function* () { + const states = yield* store.states; + subscribed.resolve(); + for (let read = 0; read < 2; read++) { + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + } + }); + // Issued while the subscription is being acquired. Registration and the + // first snapshot are one step, so this commit is either already in that + // snapshot or is the next emission — it cannot fall between them. + yield* store.commit((state) => withStatus(state, 0, "launching")); + yield* subscribed.operation; + yield* store.commit((state) => withStatus(state, 1, "launching")); + yield* reading; + }); + + expect(seen).toHaveLength(2); + // Contiguous: a gap here is a commit that happened while nobody was + // listening and nobody ever heard about. + expect(seen[1]).toBe(seen[0]! + 1); + expect(seen[1]).toBe(2); + }); + + it("TG24: a commit that changes nothing creates no revision and no emission", function* () { + const seen: number[] = []; + yield* scoped(function* () { + const store = yield* createTerminalGridStore(seed()); + const states = yield* store.states; + const first = yield* states.next(); + expect(first.done).toBe(false); + + // Same phase, same statuses, same content: a no-op. + const unchanged = yield* store.commit((state) => ({ ...state })); + expect(unchanged.revision).toBe(0); + const stillZero = yield* store.commit((state) => withStatus(state, 0, "starting")); + expect(stillZero.revision).toBe(0); + // Empty appended content is also a no-op. + const stillZeroAgain = yield* store.commit((state) => withContent(state, 0, "")); + expect(stillZeroAgain.revision).toBe(0); + + const changed = yield* store.commit((state) => withStatus(state, 0, "launching")); + expect(changed.revision).toBe(1); + + const next = yield* states.next(); + if (next.done) { + throw new Error("the state subscription ended"); + } + seen.push(next.value.revision); + }); + + // One emission, for the one commit that changed the aggregate. + expect(seen).toEqual([1]); + }); + + it("TG24: a snapshot read earlier never changes, and a title is fixed", function* () { + const store = yield* createTerminalGridStore(seed()); + const before = store.state(); + yield* store.commit((state) => withContent(state, 0, "hello")); + const after = store.state(); + + expect(before.revision).toBe(0); + expect(before.cells[0]!.content).toBe(""); + expect(after.cells[0]!.content).toBe("hello"); + expect(Object.isFrozen(after)).toBe(true); + expect(Object.isFrozen(after.cells)).toBe(true); + expect(Object.isFrozen(after.cells[0]!)).toBe(true); + // Titles come from the authored layout and nothing moves them. + expect(after.cells.map((cell) => cell.title)).toEqual(["Left", "Right"]); + expect(after.cells.map((cell) => [cell.row, cell.column])).toEqual([ + [0, 0], + [0, 1], + ]); + }); + + it("TG24: refuses before publishing past the safe-integer ceiling", function* () { + const seen: number[] = []; + let refusal: unknown; + yield* scoped(function* () { + const store = yield* createTerminalGridStore({ + ...seed(), + startRevision: Number.MAX_SAFE_INTEGER, + }); + const states = yield* store.states; + const first = yield* states.next(); + if (!first.done) { + seen.push(first.value.revision); + } + try { + yield* store.commit((state) => withStatus(state, 0, "launching")); + } catch (error) { + refusal = error; + } + // Nothing was published: the aggregate in force is the one it was. + expect(store.state().revision).toBe(Number.MAX_SAFE_INTEGER); + expect(store.state().cells[0]!.status).toBe("starting"); + }); + + expect(refusalOf(refusal)).toBe(revisionCeilingMessage(Number.MAX_SAFE_INTEGER)); + expect(seen).toEqual([Number.MAX_SAFE_INTEGER]); + }); +}); + +function withStatus( + state: TerminalGridState, + position: number, + status: TerminalGridState["cells"][number]["status"], +): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell, index) => (index === position ? { ...cell, status } : cell)), + }; +} + +function withContent(state: TerminalGridState, position: number, text: string): TerminalGridState { + return { + ...state, + cells: state.cells.map((cell, index) => + index === position ? { ...cell, content: cell.content + text } : cell, + ), + }; +} + +/** + * A provider that records every effect it could possibly have. + * + * Lazy on purpose: nothing in here runs until something acquires it. A refusal + * that happens first therefore leaves the record empty, which is the only way + * to tell "refused before the provider was touched" from "refused after". + */ +function watchedProvider(effects: string[], label: string): TerminalGridProvider { + return { + host(_request: TerminalGridRequest, view): Operation { + return resource(function* (provide) { + effects.push(`acquired:${label}`); + yield* ensure(() => { + effects.push(`released:${label}`); + }); + const states = yield* view.states; + const first = yield* states.next(); + if (first.done) { + throw new Error("the state subscription ended"); + } + const order = first.value.cells.map((cell) => cell.cellId); + yield* provide({ + closed: { *[Symbol.iterator]() {} }, + failed: { + *[Symbol.iterator]() { + yield* suspend(); + throw new Error("unreachable"); + }, + }, + // deno-lint-ignore require-yield + *converge() {}, + // deno-lint-ignore require-yield + *show() { + effects.push(`show:${label}`); + }, + launch: () => + resource>(function* (provideOutcome) { + effects.push(`launch:${label}`); + yield* provideOutcome(settledOutcome({ exitCode: 0 })); + }), + shell: (cellId) => + resource>(function* (provideOutcome) { + effects.push(`shell:${label}:${order.indexOf(cellId)}`); + yield* provideOutcome(settledOutcome({ exitCode: 0 })); + }), + }); + }); + }, + }; +} + +interface NativeLaunchOutcomeShape { + exitCode?: number; + signal?: string; +} + +function settledOutcome(value: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return value; + })(); +} + +describe("Tier TG — refusing a presentation before the provider is touched", () => { + /** Drive one grid, letting the row decide what the provider presents. */ + function underProvider( + present: (present: PresentTerminalGrid, request: TerminalGridRequest) => Operation, + opened: string[], + ): Operation { + return scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + yield* present(presentGrid, request); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + try { + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => shellCell(opened, "a")), + directJournal(), + ); + return yield* task; + } catch (error) { + return error; + } + }); + } + + it("TR1: a copied request is refused, and the copy's host is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + try { + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + // Same members, a different object. Identity is what is read. + const copy = { + columns: request.columns, + rows: request.rows, + cells: request.cells.map((cell) => ({ ...cell })), + }; + try { + yield* present(copy, watchedProvider(effects, "copy")); + } catch (error) { + refusal = error; + } + }, opened); + }); + } catch { + // The grid refuses for want of a presentation; the refusal this row reads + // is the one presentation itself produced. + } + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR2: a changed request is refused, and its host is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + try { + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + try { + yield* present( + { ...request, columns: request.columns + 1 }, + watchedProvider(effects, "changed"), + ); + } catch (error) { + refusal = error; + } + }, opened); + }); + } catch { + // As above. + } + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR3: the exact request is refused once it is stale", function* () { + const effects: string[] = []; + const opened: string[] = []; + let kept: { present: PresentTerminalGrid; request: TerminalGridRequest } | undefined; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + kept = { present, request }; + yield* present(request, watchedProvider(effects, "live")); + }, opened); + }); + + // The grid ran and finished, so its submitting operation has unwound and + // the request it issued is no longer anything to present for. This watched + // host's reader leaves the moment it is asked, so the cell is closed while + // its shell is still live and never records a departure of its own. + expect(opened).toEqual(["enter:a"]); + expect(effects).toEqual(["acquired:live", "shell:live:0", "show:live", "released:live"]); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.present(kept!.request, watchedProvider(effects, "stale")); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("is not live"); + // Nothing new: the stale host was never acquired. + expect(effects).toEqual(["acquired:live", "shell:live:0", "show:live", "released:live"]); + }); + + it("TR4: a second presentation of the exact live request is refused", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + yield* present(request, watchedProvider(effects, "first")); + try { + yield* present(request, watchedProvider(effects, "second")); + } catch (error) { + refusal = error; + } + }, opened); + }); + + expect(refusalOf(refusal)).toContain("already been presented"); + // One host acquired and released; the second was never touched. + expect(effects.filter((effect) => effect.includes("second"))).toEqual([]); + }); + + it("TR5: the exact live request is refused under another installation generation", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_options, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + // A second installation supersedes the one this grid was issued + // under. It shares the lookup, so it *finds* this request — and + // turns it away for belonging to another installation. + const superseding = yield* useTerminalInstallation(); + try { + yield* superseding(request, watchedProvider(effects, "wrong-generation")); + } catch (error) { + refusal = error; + } + // Then the right one presents, so the grid still settles. + yield* presentGrid(request, watchedProvider(effects, "right-generation")); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => shellCell(opened, "a")), + directJournal(), + ); + yield* task; + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("belongs to another terminal provider installation"); + // The refused generation's host was never acquired; only the admitted one. + expect(effects.filter((effect) => effect.includes("wrong-generation"))).toEqual([]); + expect(effects).toContain("acquired:right-generation"); + expect(effects).toContain("released:right-generation"); + }); + + it("TR6: a presentation function kept past its execution presents nothing", function* () { + const effects: string[] = []; + const opened: string[] = []; + let kept: PresentTerminalGrid | undefined; + + yield* scoped(function* () { + yield* underProvider(function* (present, request) { + kept = present; + yield* present(request, watchedProvider(effects, "live")); + }, opened); + }); + + let refusal: unknown; + yield* scoped(function* () { + const asked: TerminalGridRequest = { + columns: 1, + rows: 1, + cells: [{ title: "x", row: 0, column: 0, form: "self-closing" }], + }; + try { + yield* kept!(asked, watchedProvider(effects, "unrouted")); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects.filter((effect) => effect.includes("unrouted"))).toEqual([]); + }); + + it("TR7: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; + yield* scoped(function* () { + const present = yield* useTerminalInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerTerminalProvider("real", function* () {}); + yield* TerminalProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); + try { + yield* installTerminalProvider("real", { label: "real" }, present); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusalOf(refusal)).toContain("did not install"); + }); +}); + +describe("Tier TG — convergence before transfer", () => { + it("TG24: show stays blocked until the revision it committed is applied", function* () { + const log = terminalProviderLog(); + const held = gate(); + const order: string[] = []; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + *render(state) { + if (state.phase === "visible") { + order.push(`blocked:${state.revision}`); + // Every cell is already running, so nothing but the render is + // keeping the grid hidden. + order.push(`statuses:${state.cells.map((cell) => cell.status).join(",")}`); + order.push(`shown-before:${log.events.some((event) => event.startsWith("show:"))}`); + yield* held.opened; + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => holdingCell([], "a")), + directJournal(), + ); + yield* grid; + }); + // The renderer is blocked on the `visible` revision, so the grid cannot + // have been shown yet however long this waits. + yield* untilRecorded(order, 3); + expect(log.events.some((event) => event.startsWith("show:"))).toBe(false); + held.open(); + yield* untilEvent(log, (event) => event.startsWith("show:")); + yield* task.halt(); + }); + + expect(order[1]).toBe("statuses:running"); + expect(order[2]).toBe("shown-before:false"); + // The revision `show()` committed is the revision the host was asked for. + const blocked = order[0]!.split(":")[1]; + expect(log.events).toContain(`show:0:${blocked}`); + }); + + it("TG24: a cell action waits for convergence, and a cancelled wait transfers nothing", function* () { + const log = terminalProviderLog(); + const reached = gate(); + const observed: TerminalGridState[] = []; + + yield* scoped(function* () { + yield* useHost( + controlledTerminalProvider({ + log, + close: () => suspend(), + *render(state) { + if (state.cells.some((cell) => cell.status === "launching")) { + observed.push(state); + reached.open(); + // Held forever: convergence never completes, so the action can + // never reach the host. + yield* suspend(); + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => outputThenShell("first line\n")), + directJournal(), + ); + yield* grid; + }); + yield* reached.opened; + // Cancelled while the action is waiting for the screen it asked for. + yield* task.halt(); + }); + + // The revision the action captured already contains the output written + // before it, which is what makes convergence through it meaningful. + expect(observed).toHaveLength(1); + expect(observed[0]!.cells[0]!.content).toBe("first line\n"); + expect(observed[0]!.cells[0]!.status).toBe("launching"); + // No child call, no readiness, and nothing was shown. + expect(log.events.filter((event) => event.startsWith("shell:"))).toEqual([]); + expect(log.events.filter((event) => event.startsWith("launch:"))).toEqual([]); + expect(log.events.some((event) => event.startsWith("show:"))).toBe(false); + expect(log.applied.some((state) => state.cells.some((cell) => cell.status === "running"))).toBe( + false, + ); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TG24: the host is reached only after the revision the action captured is applied", function* () { + const log = terminalProviderLog(); + const reached = gate(); + const release = gate(); + let launching = -1; + + yield* scoped(function* () { + const settled = settledCells(1); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + *render(state) { + yield* settled.render(state); + if (state.cells.some((cell) => cell.status === "launching") && launching < 0) { + launching = state.revision; + reached.open(); + // The lane is held here, so this revision is not applied until + // the row lets it be. An action that called the host without + // waiting would have reached it while this was blocked, and its + // record would sit before this render's. + yield* release.opened; + } + }, + }), + ); + const task = yield* spawn(function* () { + const grid = yield* terminalGrid( + layoutOf(1, ["a"]), + cellWork(["a"], () => outputThenShell("first line\n")), + directJournal(), + ); + yield* grid; + }); + yield* reached.opened; + release.open(); + yield* task; + }); + + expect(launching).toBeGreaterThan(0); + // The action asked for exactly the revision it committed, and asked before + // it asked for a terminal. + const asked = log.events.indexOf(`converge:0:${launching}`); + const rendered = log.events.indexOf(`render:0:${launching}`); + const started = log.events.indexOf("shell:0:0"); + expect(asked).toBeGreaterThanOrEqual(0); + expect(rendered).toBeGreaterThanOrEqual(0); + expect(started).toBeGreaterThanOrEqual(0); + expect(asked).toBeLessThan(started); + // And the screen it asked for was applied before the provider was asked + // for a terminal. + expect(rendered).toBeLessThan(started); + }); +}); + +describe("Tier TG — concurrency across cells", () => { + for (const count of [1, 2, 3, 8]) { + it(`TG23: ${count} cell(s) run concurrently, in stable authored order`, function* () { + const log = terminalProviderLog(); + const together = barrier(count); + const titles = Array.from({ length: count }, (_unused, index) => `cell ${index}`); + const identities: string[] = []; + + const retained = yield* scoped(function* (): Operation { + const settled = settledCells(count); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + render: settled.render, + shell: () => + resource>(function* (provide) { + // Acquired: this cell is holding its activity. Settlement waits + // for every other cell to be holding one too, which cells that + // contended could never all do. + together.arrive(); + yield* provide( + (function* (): Operation { + yield* together.opened; + return { exitCode: 0 }; + })(), + ); + }), + }), + ); + const task = yield* terminalGrid( + layoutOf(3, titles), + titles.map((title, position) => ({ + cellId: Symbol(title), + operation: (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + // Each cell sees its own handle and nobody else's. + identities.push(`${position}:${cell.state.title}`); + yield* cell.shell(); + })(), + })), + directJournal(), + ); + return yield* task; + }); + + expect(together.arrived).toBe(count); + expect(identities.slice().sort()).toEqual( + titles.map((title, position) => `${position}:${title}`).sort(), + ); + // Authored order in the retained record, whatever order they ran in. + expect(retained.layout.cells.map((cell) => cell.title)).toEqual(titles); + expect(retained.cells).toHaveLength(count); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + } + + it("TG23: one cell refuses overlap, and admits the next after complete cleanup", function* () { + const log = terminalProviderLog(); + const refusals: string[] = []; + const marks: string[] = []; + const releaseFirst = gate(); + const refused = gate(); + let acquisitions = 0; + const released: number[] = []; + + yield* scoped(function* () { + const settled = settledCells(1); + yield* useHost( + controlledTerminalProvider({ + log, + close: () => settled.opened, + render: settled.render, + shell: () => + resource>(function* (provide) { + const mine = ++acquisitions; + yield* ensure(() => { + released.push(mine); + }); + yield* provide( + (function* (): Operation { + if (mine === 1) { + yield* releaseFirst.opened; + } + return { exitCode: 0 }; + })(), + ); + }), + }), + ); + const task = yield* terminalGrid( + layoutOf(1, ["a"]), + [ + { + cellId: Symbol("a"), + operation: (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + yield* spawn(function* () { + try { + yield* cell.shell(); + marks.push("overlapping admitted"); + } catch (error) { + refusals.push(refusalOf(error)); + refused.open(); + } + }); + yield* spawn(function* () { + yield* refused.opened; + releaseFirst.open(); + }); + yield* cell.shell(); + marks.push("first settled"); + // The cell is free again: one owner at a time is not one owner + // ever, and the next one is admitted only after the prior + // activity's own cleanup finished. + marks.push(`released-before-second:${released.length}`); + yield* cell.shell(); + marks.push("sequential"); + })(), + }, + ], + directJournal(), + ); + yield* task; + }); + + expect(refusals).toHaveLength(1); + expect(refusals[0]).toContain("one owns a cell terminal at a time"); + expect(marks).not.toContain("overlapping admitted"); + expect(marks).toContain("sequential"); + expect(marks).toContain("released-before-second:1"); + // Two activities acquired, two released, nothing stranded. + expect(acquisitions).toBe(2); + expect(released).toEqual([1, 2]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); +}); + +/** Cell work that writes output and then asks for its terminal. */ +function outputThenShell(text: string): Operation { + return (function* (): Operation { + const cell = yield* useTerminalCellUI(); + if (cell === undefined) { + throw new Error("no cell handle"); + } + yield* appendTerminalCellOutput(text); + yield* cell.shell(); + })(); +} + +/** Settles once `record` holds at least `count` entries. */ +function untilRecorded(record: readonly string[], count: number): Operation { + return { + *[Symbol.iterator]() { + while (record.length < count) { + yield* nextTurn(); + } + }, + }; +} + +/** Settles once the provider's record holds an event this predicate accepts. */ +function untilEvent( + log: TerminalProviderLog, + accepts: (event: string) => boolean, +): Operation { + return { + *[Symbol.iterator]() { + while (!log.events.some(accepts)) { + yield* nextTurn(); + } + }, + }; +} + +function nextTurn(): Operation { + return { + *[Symbol.iterator]() { + const settled = withResolvers(); + queueMicrotask(() => settled.resolve()); + yield* settled.operation; + }, + }; +} diff --git a/packages/terminal/tests/terminal-processes.test.ts b/packages/terminal/tests/terminal-processes.test.ts new file mode 100644 index 000000000..0086df68e --- /dev/null +++ b/packages/terminal/tests/terminal-processes.test.ts @@ -0,0 +1,100 @@ +/** + * Tier PO — process facts, and the POSIX host that establishes them + * (architecture.md §Package ownership). + * + * A cancelled launch may not leave a child holding a terminal, and a cell may + * not admit its next activity while the previous one still owns the screen. + * Both are claims about processes, and neither can be made from a PID, an + * elapsed timeout, or a signal that was merely sent. These rows are about the + * neutral vocabulary for making them and the one host that answers it. + * + * They start a real child, because a stub that agreed with the implementation + * would prove nothing about the kernel. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, sleep } from "effection"; +import type { Operation } from "effection"; +import { spawn as spawnChild } from "node:child_process"; +import process from "node:process"; +import type { ChildProcess } from "node:child_process"; + +import { TerminalGridPresentationError } from "../mod.ts"; +import { TerminalGridPresentationError as LifecyclePresentationError } from "../lifecycle.ts"; +import { + deliverSignal, + processReachable, + ProcessObservationUnavailableError, +} from "../processes.ts"; +import { installPosixProcessObservation } from "../posix.ts"; + +/** A child that stays until something stops it. */ +function useChild(): Operation { + return (function* (): Operation { + const child = spawnChild(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + yield* ensure(() => { + try { + child.kill("SIGKILL"); + } catch { + // Already gone, which is the state this was asking for. + } + }); + return child; + })(); +} + +describe("Tier PO — process observation", () => { + it("PO1: refuses when no host has installed one", function* () { + let reachable: unknown; + let delivery: unknown; + yield* scoped(function* () { + try { + yield* processReachable(1); + } catch (error) { + reachable = error; + } + try { + yield* deliverSignal(1, "SIGINT"); + } catch (error) { + delivery = error; + } + }); + + // A host that cannot observe processes says so rather than answering + // "gone" for a child it never looked at. + expect(reachable).toBeInstanceOf(ProcessObservationUnavailableError); + expect(delivery).toBeInstanceOf(ProcessObservationUnavailableError); + }); + + it("PO2: the POSIX host reports a live child reachable and a stopped one gone", function* () { + yield* installPosixProcessObservation(); + const child = yield* useChild(); + const pid = child.pid; + expect(pid).toBeDefined(); + + expect(yield* processReachable(pid!)).toBe(true); + + // A fatal signal the kernel accepted, and then the fact itself. + expect(yield* deliverSignal(pid!, "SIGKILL")).toBe("delivered"); + while (yield* processReachable(pid!)) { + yield* sleep(10); + } + expect(yield* processReachable(pid!)).toBe(false); + + // Gone between the decision and the delivery is the outcome the caller + // was asking for, and is reported as such rather than as a refusal. + expect(yield* deliverSignal(pid!, "SIGKILL")).toBe("absent"); + }); +}); + +describe("Tier TG — one package, several facets", () => { + it("TG25: a value two entrypoints publish is the same value", function* () { + // A `catch` written against the root and one written against `./lifecycle` + // classify the same error, because there is one definition of it. + expect(LifecyclePresentationError).toBe(TerminalGridPresentationError); + yield* sleep(0); + }); +}); diff --git a/packages/terminal/tests/terminal-provider.test.ts b/packages/terminal/tests/terminal-provider.test.ts new file mode 100644 index 000000000..b936bb0bc --- /dev/null +++ b/packages/terminal/tests/terminal-provider.test.ts @@ -0,0 +1,518 @@ +/** + * Tier TG — the routing surface and the provider host contract + * (architecture.md §Terminal grid presentation). + * + * Two things live here, and neither decides anything. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and the lifecycle throws + * the answer away. The host is what a provider supplies as a resource, and its + * contract is ordering — prepared hidden, converged serially, shown once, + * destroyed exactly once. + * + * Who may present a grid, and what presenting one authorizes, is proved in + * `terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. + * The state a host observes is scripted by the row, so every claim about what + * the renderer did is read off a record rather than inferred from timing. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createQueue, race, resource, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation, Queue } from "effection"; + +import { + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + TerminalProviderUnavailableError, +} from "../mod.ts"; +import type { + TerminalCellId, + TerminalCellState, + TerminalGridHost, + TerminalGridRequest, + TerminalGridState, + TerminalGridView, + TerminalShellOutcome, +} from "../mod.ts"; +import { + controlledTerminalProvider, + gate, + rendererEndedMessage, + subscriptionEndedMessage, + terminalProviderLog, +} from "../test/mod.ts"; +import type { ControlledProviderOptions, TerminalProviderLog } from "../test/mod.ts"; + +/** A two-by-one grid: the smallest request that still has two positions. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + cells: [ + { title: "Agent", row: 0, column: 0, form: "paired" }, + { title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +const IDENTITIES: TerminalCellId[] = [Symbol("agent"), Symbol("shell")]; + +/** One snapshot, at `revision`, with whatever this row wants to say. */ +function snapshot(revision: number, overrides: Partial = {}): TerminalGridState { + const cells = IDENTITIES.map((cellId, index): TerminalCellState => { + const cell: TerminalCellState = { + cellId, + title: index === 0 ? "Agent" : "Shell", + row: 0, + column: index, + status: "starting", + content: "", + }; + return Object.freeze(cell); + }); + const state: TerminalGridState = { + revision, + phase: "preparing", + columns: 2, + rows: 1, + cells: Object.freeze(cells), + ...overrides, + }; + return Object.freeze(state); +} + +/** + * A view the row writes to. + * + * The provider is the thing under test here, so the state it observes is + * scripted rather than produced by a running grid. + */ +interface ScriptedView { + readonly view: TerminalGridView; + push(state: TerminalGridState): void; +} + +function scriptedView(initial: TerminalGridState): ScriptedView { + const queue: Queue = createQueue(); + queue.add(initial); + return { + view: { + states: { + // deno-lint-ignore require-yield + *[Symbol.iterator]() { + return { next: () => queue.next() }; + }, + }, + }, + push: (state) => queue.add(state), + }; +} + +describe("Tier TG — the routing surface", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: TerminalGridRequest[] = []; + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* TerminalGrids.operations.open(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the same object reached the far end. + expect(reached[0]).toBe(seen[0]); + }); + + it("TP2: middleware narrows a request before anything below sees it", function* () { + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.cells.length }); + }, + }); + yield* TerminalGrids.operations.open(request()); + }); + + expect(reached[0]?.columns).toBe(1); + expect(reached[0]?.rows).toBe(2); + }); + + it("TP2: middleware refuses a request, and nothing below is reached", function* () { + const reached: TerminalGridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + { at: "min" }, + ); + yield* TerminalGrids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + expect(reached).toEqual([]); + }); +}); + +/** Acquire one controlled host over a scripted view. */ +function useScriptedHost( + script: ScriptedView, + options: ControlledProviderOptions, +): Operation { + return controlledTerminalProvider(options).host(request(), script.view); +} + +describe("Tier TG — the provider host contract", () => { + it("TP3: an acquired host presents nothing until it is shown", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const events = yield* scoped(function* () { + yield* useScriptedHost(script, { log }); + yield* untilApplied(log, 0); + return [...log.events]; + }); + + // A grid the reader can see before every cell is ready is the one thing + // atomic startup forbids. + expect(events).toEqual([ + "prepare:0:2x1", + "render:0:0", + "status:0:0:starting", + "status:0:1:starting", + ]); + expect(events.some((event) => event.startsWith("show:"))).toBe(false); + }); + + it("TP4: revision zero arrives with the subscription, and newer ones in order", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { log }); + yield* untilApplied(log, 0); + script.push(snapshot(1, { phase: "visible" })); + yield* host.converge(1); + script.push(snapshot(2, { phase: "closing" })); + yield* host.converge(2); + }); + + expect(log.applied.map((state) => state.revision)).toEqual([0, 1, 2]); + }); + + it("TP5: a blocked render coalesces forward, and its waiters are satisfied by the newer state", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const held = gate(); + const reachedOne = gate(); + const waiters: string[] = []; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + *render(state) { + if (state.revision === 1) { + reachedOne.open(); + yield* held.opened; + } + }, + }); + yield* untilApplied(log, 0); + + script.push(snapshot(1, { phase: "visible" })); + yield* reachedOne.opened; + // Both arrive while revision 1 is still being applied. + script.push(snapshot(2, { phase: "closing" })); + script.push(snapshot(3, { phase: "closed" })); + + const two = yield* spawn(function* () { + yield* host.converge(2); + waiters.push(`two:${log.applied[log.applied.length - 1]!.revision}`); + }); + const three = yield* spawn(function* () { + yield* host.converge(3); + waiters.push("three"); + }); + + held.open(); + yield* two; + yield* three; + }); + + // One then three: the intermediate snapshot is subsumed by the newest + // pending one, and an older revision is never applied after a newer one. + expect(log.applied.map((state) => state.revision)).toEqual([0, 1, 3]); + // The waiter for two completed from three, because a complete aggregate at + // three contains everything two described. + expect(waiters).toContain("two:3"); + expect(waiters).toContain("three"); + }); + + it("TP6: applied advances only after the whole render effect succeeds", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let converged = false; + let failure: Error | undefined; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + // deno-lint-ignore require-yield + *render(state) { + if (state.revision === 1) { + throw new Error("this renderer could not draw revision 1"); + } + }, + }); + yield* untilApplied(log, 0); + script.push(snapshot(1, { phase: "visible" })); + + failure = yield* race([ + host.failed, + (function* (): Operation { + yield* host.converge(1); + converged = true; + return new Error("unreachable"); + })(), + ]); + }); + + expect(failure?.message).toBe("this renderer could not draw revision 1"); + // Nothing was applied past the render that failed, and no waiter was told + // the screen had caught up. + expect(converged).toBe(false); + expect(log.applied.map((state) => state.revision)).toEqual([0]); + }); + + it("TP7: a state subscription that stops while acquired is a provider failure", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const stop = gate(); + let failure: Error | undefined; + let closed = false; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + stopSubscription: () => stop.opened, + }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + stop.open(); + failure = yield* host.failed; + }); + + expect(failure?.message).toBe(subscriptionEndedMessage()); + // Failure and reader close are independent observations. + expect(closed).toBe(false); + }); + + it("TP8: a renderer that stops while acquired is a provider failure", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + const stop = gate(); + let failure: Error | undefined; + let closed = false; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + stopRenderer: () => stop.opened, + }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + stop.open(); + failure = yield* host.failed; + }); + + expect(failure?.message).toBe(rendererEndedMessage()); + expect(closed).toBe(false); + }); + + it("TP9: an ordinary release settles neither closed nor failed", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let closed = false; + let failed = false; + + yield* scoped(function* () { + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { log, close: () => suspend() }); + yield* untilApplied(log, 0); + yield* spawn(function* () { + yield* host.closed; + closed = true; + }); + yield* spawn(function* () { + yield* host.failed; + failed = true; + }); + }); + }); + + expect(closed).toBe(false); + expect(failed).toBe(false); + // Released once, with nothing still held. + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TP10: a shell that never starts is never acquired, and leaves no record", function* () { + const log = terminalProviderLog(); + const script = scriptedView(snapshot(0)); + let refusal: unknown; + + yield* scoped(function* () { + const host = yield* useScriptedHost(script, { + log, + close: () => suspend(), + shell: () => + resource>(function* () { + // Fails before it provides: nothing started, so nothing is owed an + // outcome and no cell could call this ready. + throw new Error("no child could be spawned"); + }), + }); + yield* untilApplied(log, 0); + try { + yield* yield* host.shell(IDENTITIES[1]!); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe("no child could be spawned"); + expect(log.events.some((event) => event.startsWith("shell:"))).toBe(false); + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); + + it("TP11: release happens once, whatever ended the host", function* () { + const log = terminalProviderLog(); + // One provider, three hosts: the generation in the record is how a suite + // tells a second host apart from the first. + const provider = controlledTerminalProvider({ log, close: () => suspend() }); + + // Settled normally. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + yield* provider.host(request(), script.view); + yield* untilApplied(log, 0); + }); + // Cancelled while live. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + const holding = withResolvers(); + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* provider.host(request(), script.view); + holding.resolve(); + yield* suspend(); + }); + }); + yield* holding.operation; + yield* task.halt(); + }); + // Failed after acquisition. + yield* scoped(function* () { + const script = scriptedView(snapshot(0)); + try { + yield* scoped(function* () { + yield* provider.host(request(), script.view); + throw new Error("the provider failed"); + }); + } catch { + // The failure is the point; the release is what is being counted. + } + }); + + // One destroy each, and nothing left holding anything. A resource cannot be + // released twice, which is why there is no way to call one by hand. + for (const generation of [0, 1, 2]) { + expect(log.events.filter((event) => event === `destroy:${generation}`)).toEqual([ + `destroy:${generation}`, + ]); + } + expect(log.live).toEqual({ grids: 0, shown: 0, activities: 0 }); + }); +}); + +/** Settles once the renderer has applied a snapshot at least `revision`. */ +function untilApplied(log: TerminalProviderLog, revision: number): Operation { + return { + *[Symbol.iterator]() { + while (!log.applied.some((state) => state.revision >= revision)) { + const settled = withResolvers(); + queueMicrotask(() => settled.resolve()); + yield* settled.operation; + } + }, + }; +} diff --git a/packages/test-agent/src/child-configuration.ts b/packages/test-agent/src/child-configuration.ts index 321e56e5f..8447867f9 100644 --- a/packages/test-agent/src/child-configuration.ts +++ b/packages/test-agent/src/child-configuration.ts @@ -37,7 +37,7 @@ import type { AgentComponentsOptions, AgentProviderOptions, Json } from "@execut import { createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { installInvocationAgentProvider } from "@executablemd/core/host"; -import { installControlledLauncher } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { ChildDeclaration, ChildDeclarationChild, diff --git a/packages/test-agent/src/components.ts b/packages/test-agent/src/components.ts index 5340d3b11..b01a2f65a 100644 --- a/packages/test-agent/src/components.ts +++ b/packages/test-agent/src/components.ts @@ -42,7 +42,8 @@ import { import type { ErrorSegment, Json, PropsSchema, Segment } from "@executablemd/core"; import { createMemorySessionRouteStore, createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProvider, SessionRouteContext } from "@executablemd/acp"; -import { command, installControlledLauncher, readTextFile } from "@executablemd/runtime"; +import { command, readTextFile } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import { Test } from "@executablemd/testing"; import { NativeLaunchObserver, useTestAgentController } from "./controller.ts"; import type { ScenarioHandle, TestAgentControllerInternals } from "./controller.ts"; diff --git a/packages/test-agent/src/controller.ts b/packages/test-agent/src/controller.ts index f27327c79..157be43dc 100644 --- a/packages/test-agent/src/controller.ts +++ b/packages/test-agent/src/controller.ts @@ -20,7 +20,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; // node:fs/promises primitive directly. import { realpath } from "node:fs/promises"; import { readTextFile, stat } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; import type { DurableEvent } from "@executablemd/durable-streams"; import { encodeMessage, formatRoute, parseWorkerMessage, PROBE_INSTANCE } from "./protocol.ts"; import type { ControllerMessage, WorkerMessage } from "./protocol.ts"; diff --git a/packages/test-agent/tests/native-launch.test.ts b/packages/test-agent/tests/native-launch.test.ts index 3f274d040..f21356c40 100644 --- a/packages/test-agent/tests/native-launch.test.ts +++ b/packages/test-agent/tests/native-launch.test.ts @@ -25,8 +25,9 @@ import * as os from "node:os"; import { installAgentComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; -import { API, installControlledLauncher, useHostFiles } from "@executablemd/runtime"; -import type { NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea97ff75..1f60c76f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: semver: specifier: ^7.8.5 version: 7.8.5 + starfx: + specifier: 0.16.1 + version: 0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) unist-util-select: specifier: ^5 version: 5.1.0 @@ -105,6 +108,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:packages/runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:packages/terminal '@executablemd/test-agent': specifier: workspace:* version: link:packages/test-agent @@ -242,6 +248,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@secretlint/core': specifier: 13.0.4 version: 13.0.4 @@ -315,6 +324,24 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/terminal: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/node': + specifier: 0.2.5 + version: 0.2.5(effection@4.1.0) + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + effection: + specifier: 4.1.0 + version: 4.1.0 + starfx: + specifier: 0.16.1 + version: 0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + packages/test-agent: dependencies: '@agentclientprotocol/sdk': @@ -2076,6 +2103,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} @@ -2427,6 +2457,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2483,6 +2516,20 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + starfx@0.16.1: + resolution: {integrity: sha512-qYAGHJJCYkBChTu9ZSmNTdzvNMDC0Hds+ecNh3TpTSCIqCi15U2+CJ+jI37b6TZuPG76+CUxFbzP+BA+qUb1TQ==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + react-redux: ^9 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + react-redux: + optional: true + streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -3915,6 +3962,8 @@ snapshots: html-void-elements@3.0.0: {} + immer@11.1.18: {} + immutable@5.1.5: {} is-extendable@0.1.1: {} @@ -4418,6 +4467,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.3.0: {} + resolve-pkg-maps@1.0.0: {} reusify@1.1.0: {} @@ -4464,6 +4515,15 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + starfx@0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + effection: 4.1.0 + immer: 11.1.18 + reselect: 5.3.0 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + streamx@2.28.0: dependencies: events-universal: 1.0.1 diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index a10628792..3c2da021c 100644 --- a/scripts/tests/jsr-consumer-documentation.test.ts +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -33,7 +33,7 @@ const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const TIMEOUT = 180_000; /** The workspace members a consumer of core has to resolve. */ -const MEMBERS = ["core", "runtime", "durable-streams", "acp"] as const; +const MEMBERS = ["core", "runtime", "durable-streams", "terminal", "acp"] as const; /** Every documentation asset the product ships, by package-relative path. */ const ASSETS: Record = { diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 411f963a5..cf538356e 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -49,7 +49,8 @@ sequenceDiagram ## 2. Version lockstep Every publishable package (`packages/core`, `packages/cli`, -`packages/durable-streams`, `packages/runtime`, `packages/testing`, +`packages/durable-streams`, `packages/runtime`, `packages/terminal`, +`packages/testing`, `packages/code-review-agent`, `packages/test-agent`, `packages/acp`, `packages/web`, `packages/workflow`) declares the same version in its `deno.json` and `package.json`. A member marked `"private": true` is outside the lockstep