diff --git a/.gitignore b/.gitignore index 9ae85558d..a02ca7bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ packages/web/generated/ # it. The concurrent verifier runs site:build and site:check together, so a lint # that walked one would report on a file nobody wrote. site/*.timestamp-*.mjs + +# The harness in scripts/repl-study writes raw ANSI beside its committed +# captures; only the readable .txt frames are reviewed and kept. +scripts/tests/fixtures/repl-study/*.ansi diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5dd0b48a6..ff15febc9 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -4,11 +4,14 @@ // the exact indentation the rule tests assert on. // `packages/*/npm` is generated dnt output, written by a test while the // battery runs; format-checking it fails on a file nobody wrote by hand. + // The vendored snapshots are upstream's bytes: a drift verifier holds each to + // the manifest that records it, and reformatting one would break that. "ignorePatterns": [ "**/*.md", "scripts/tests/fixtures/**", "**/npm/**", "packages/workflow/vendor/cloudflare-computer-dofs/**", - "packages/acp/vendor/acpx/**" + "packages/acp/vendor/acpx/**", + "scripts/repl-study/vendor/freedom/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index e92294a55..400d1a50d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,6 +3,8 @@ "extends": ["./oxlint.shared.json"], + "ignorePatterns": ["scripts/repl-study/vendor/freedom/**"], + "plugins": ["typescript", "unicorn", "import"], "jsPlugins": [ diff --git a/deno.json b/deno.json index 292fd4016..8eed98d11 100644 --- a/deno.json +++ b/deno.json @@ -46,6 +46,7 @@ "oxlint": "npm:oxlint@1.74.0", "oxlint-tsgolint": "npm:oxlint-tsgolint@0.25.0", "semver": "npm:semver@^7.8.5", + "starfx": "npm:starfx@0.16.1", "typescript": "npm:typescript@^5.0.0", "unist-util-select": "npm:unist-util-select@^5", "zod": "npm:zod@^4.3.6", @@ -64,6 +65,12 @@ "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", "weights:measure": "deno run --allow-all scripts/measure-test-weights.ts", + "repl:study": "deno run --allow-all --frozen scripts/repl-study/main.ts", + "repl:compose": "deno run --allow-all --frozen scripts/repl-compose/main.ts", + "repl:pause": "deno run --allow-all --frozen scripts/repl-pause/main.ts", + "repl:pause:seam": "deno run --allow-all --frozen scripts/repl-pause/missing-seam.ts", + "repl:pause:xmd": "deno run --allow-all --frozen scripts/repl-pause/measure.ts", + "repl:hydration": "deno run --allow-all --frozen scripts/repl-hydration/main.ts", "test": "deno test --allow-all --frozen", "verify": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/verify.ts", "vendor:verify": "deno run --allow-read --allow-write=/tmp --allow-env --allow-run --cached-only --frozen scripts/verify-cloudflare-dofs.ts", diff --git a/deno.lock b/deno.lock index ec96c9990..4c8b23d8c 100644 --- a/deno.lock +++ b/deno.lock @@ -41,6 +41,7 @@ "npm:@agentclientprotocol/sdk@1.3.0": "1.3.0_zod@4.4.3", "npm:@babel/core@^7.28.0": "7.29.7", "npm:@babel/preset-react@^7.27.1": "7.29.7_@babel+core@7.29.7", + "npm:@bomb.sh/tty@0.9.0": "0.9.0", "npm:@durable-streams/client@~0.2.2": "0.2.6", "npm:@durable-streams/server@~0.3.8": "0.3.8", "npm:@effectionx/context-api@0.6.0": "0.6.0_effection@4.1.0", @@ -107,6 +108,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", @@ -467,6 +469,9 @@ "@babel/helper-validator-identifier" ] }, + "@bomb.sh/tty@0.9.0": { + "integrity": "sha512-1fX9lgwdc+kGRQeVEYwv7cJb5i855ysvB/TMCC3/wnnjMiKLZjWew6LLnsAMq4pLzkweivYw+zwYNZRUVhjoBA==" + }, "@clack/core@1.4.3": { "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", "dependencies": [ @@ -2755,6 +2760,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 +3561,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 +3667,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": [ @@ -3990,12 +4015,14 @@ "npm:oxlint-tsgolint@0.25.0", "npm:oxlint@1.74.0", "npm:semver@^7.8.5", + "npm:starfx@0.16.1", "npm:typescript@5", "npm:unist-util-select@5", "npm:zod@^4.3.6" ], "packageJson": { "dependencies": [ + "npm:@bomb.sh/tty@0.9.0", "npm:@durable-streams/client@~0.2.2", "npm:@durable-streams/server@~0.3.8", "npm:@effectionx/context-api@0.6.0", @@ -4025,6 +4052,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", diff --git a/package.json b/package.json index 7941dd3d4..772d61369 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "mdast-util-to-string": "^4" }, "devDependencies": { + "@bomb.sh/tty": "0.9.0", "@durable-streams/server": "^0.3.8", "@executablemd/acp": "workspace:*", "@executablemd/cli": "workspace:*", @@ -67,6 +68,7 @@ "expect": "^30.0.0", "oxfmt": "^0.41.0", "oxlint": "1.74.0", + "starfx": "0.16.1", "tsx": "^4.19.0", "typescript": "^5.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc5691b9..121420857 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@bomb.sh/tty': + specifier: 0.9.0 + version: 0.9.0 '@durable-streams/server': specifier: ^0.3.8 version: 0.3.8 @@ -135,6 +138,9 @@ importers: oxlint: specifier: 1.74.0 version: 1.74.0 + starfx: + specifier: 0.16.1 + version: 0.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -552,6 +558,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@bomb.sh/tty@0.9.0': + resolution: {integrity: sha512-1fX9lgwdc+kGRQeVEYwv7cJb5i855ysvB/TMCC3/wnnjMiKLZjWew6LLnsAMq4pLzkweivYw+zwYNZRUVhjoBA==} + engines: {node: '>= 22'} + '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} @@ -2126,6 +2136,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==} @@ -2477,6 +2490,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==} @@ -2533,6 +2549,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==} @@ -2723,6 +2753,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@bomb.sh/tty@0.9.0': {} + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 @@ -3965,6 +3997,8 @@ snapshots: html-void-elements@3.0.0: {} + immer@11.1.18: {} + immutable@5.1.5: {} is-extendable@0.1.1: {} @@ -4468,6 +4502,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.3.0: {} + resolve-pkg-maps@1.0.0: {} reusify@1.1.0: {} @@ -4514,6 +4550,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/repl-compose/README.md b/scripts/repl-compose/README.md new file mode 100644 index 000000000..b9c20554b --- /dev/null +++ b/scripts/repl-compose/README.md @@ -0,0 +1,347 @@ +# Composing the REPL, take two + +A bounded architecture experiment for +[#840](https://github.com/taras/executable.md/issues/840), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). + +The first #840 experiment is reverted. It established individual behaviours but +no cohesive layering: route resolution, view projection, responsive layout, +manual Freedom mounting and host playback each made overlapping topology +decisions, so a working example depended on keeping several representations in +step. None of its code is here, and none of it will be promoted. + +This directory is the replacement, built to one layering: + +```text +history fixture → ReplModel → (URL + model) → ResolvedLocation + → keyed component descriptions → one mounted Freedom tree + → parent-owned layout → pure render walk → terminal renderer +``` + +**Journal and history are different things here.** *Journal* stays XMD's +existing durable append-only execution mechanism. This experiment implements no +Journal and fixtures none: production Journal access happens above it and is not +represented. What the REPL consumes is *history* — the immutable ordered +execution records read from a Journal — which projects into `ReplModel`. +*History* is also the name of the UI surface that presents that read model, and +a route can name it (`xmd://repl/e1/history/...`); a surface is not a Journal +either. + +It is disposable evidence. `scripts/repl-study/` stays beside it as #838/#839 +laboratory apparatus — terminal hosting, fixtures, captures and Freedom focus +evidence — and nothing here reuses its router, components, layout or rendering. + +## What is here + +| File | What it owns | +| --- | --- | +| `history.ts` | the hand-authored history fixtures, and the projection that ends history-record access | +| `model.ts` | `ReplModel` and the frozen values a checkpoint holds | +| `router.ts` | `decodeRoute`, `encodeRoute`, `resolveRoute`, and nothing else | +| `component.ts` | what a parent says its children are: keyed descriptions over immutable input | +| `reconcile.ts` | descriptions in, one mounted Freedom tree out — and the walks that read it | +| `handoff.ts` | deliver a value to every live receiver, and know when they have applied it | +| `frames.ts` | the host's clock, and the demand the mounted branches place on it | +| `input.ts` | a key down the live ancestry, a typed action back up | +| `shell.ts` | the small set of components the reconciliation evidence drives | +| `screen.ts` | a resolved location, described as an interface — and the refusal when it is not one | +| `render.ts` | cells, and nothing else: two renderers, so the seam is a seam | +| `host.ts` | the terminal, the viewport, the frames, the raw input — and none of their names | +| `trace.ts` | one location followed through every layer | +| `main.ts` | the documented command | + +Evidence: `scripts/tests/repl-compose-router.test.ts`, +`scripts/tests/repl-compose-reconcile.test.ts`, +`scripts/tests/repl-compose-screen.test.ts` and +`scripts/tests/repl-compose-command.test.ts`. + +Conclusions — what to retain, revise and discard, and the production +sequencing — are in [`RESULT.md`](RESULT.md). + +## Run it + +```bash +deno task repl:compose --journey # the representative journey, unattended +deno task repl:compose --journey --narrow # the same tree, laid out for less width +deno task repl:compose --trace '' # one location, through every layer +``` + +The journey opens a drawer, stacks another on it, closes the top one, and then +asks for a location the execution never went to. Nothing needs pressing, and no +terminal is attached: the host measures its viewport from a flag rather than a +device, which is the whole point of it being the host. + +`--trace` prints the seven things #840 asks for — the decoded route, the model +identities it resolved to, the keyed description, the mounted tree and its focus +chain, where an activation went and what it meant, what a closing branch took +with it, and the output drawn from that same tree. Every line is read from the +one place that answers it, so a wrong line means a wrong thing rather than a +stale report. + +## One mounted tree, and nothing beside it + +A parent declares its direct children as keyed descriptions over immutable +input. It does not reach into a registry, ask what is mounted, or hand a child a +way to register itself — so the tree is decided before anything exists. +Reconciliation is then the only thing that mounts anything, and Freedom is the +only thing it mounts into. + +Matching is by key, the way Crank matches keyed children. A description whose +key *and component* both match the node already there keeps that node, and with +it the node's Effection scope and everything the branch's lifecycle holds inside +it. A key that stops being described is removed with its whole subtree, and the +removal is awaited rather than started. + +That is what makes teardown structural rather than remembered. A drawer stack is +described as a branch — the second drawer is a child of the first — so closing +the top one removes exactly one subtree. What goes with it goes because its +scope is gone: its frame subscription, its input middleware, its focus target +and its presentation. Nothing is notified, and there is nothing to keep in step. + +Every question about the interface is answered by walking those same nodes: +`paint` for what is drawn, `focusTargets` for what can be focused, `press` for +where a key goes, and the clock's own `demand` for who is asking for frames. + +**A key names one child.** Uniqueness among a parent's direct children is +checked over the whole description tree before a single node is created, +removed or updated, so a refusal leaves the mounted tree exactly as it was. Two +branches under one key is a tree that cannot be addressed: the reconciler finds +children by key, so the second shadows the first, and the first is then never +matched for an update and never counted as undescribed for removal — mounted for +as long as its parent lives. + +**A retained branch is told what changed rather than rebuilt.** Its new input +travels the same direct parent-child boundary the first one did — nothing +ambient, nothing looked up, nothing polled — and the delivery completes only +once the branch has taken it. So when a reconcile returns, every branch it kept +is acting on the input it was just given. Presentation, children and `onPress` +read that same current input. + +**One description carries one input.** A description does not hold its input +where anything can reach it: everything the input decides is a closure over one +captured value, made in one call, and a description is a class with a private +field, so nothing assembled from its parts is one. An earlier version exposed +`input: unknown` beside those closures and delivered through a sink whose +parameter was `unknown`. A spread could then replace the payload while the +closures kept the original — `{ ...describe(Probe, "probe", 2), input: 3 }` +type-checked and mounted a component whose lifecycle acted on 3 while it drew 2. +Matching the component identity did not catch it, because identity only says who +made the *original* description. + +The typed channel that replaces it needs no cast, no bivariance and no table: a +component carries its own `NodeDataKey>`, minted when the +component is built with `component()`, and a branch keeps its update channel on +its own node under that key. Reading it back is +`node.data.get(component.updates)`, which the compiler already knows is a +`Handoff`. The reconciler holds no input of its own and could not +substitute one. + +**Delivery completes; it does not merely send.** One primitive in `handoff.ts` +carries both frames and input: `deliver()` finishes once every receiver that was +live when it started has come back for the next value, which is the moment it +has finished applying this one. Asking for the next value *is* the +acknowledgement, so there is no `ack()` to forget and a slow receiver holds the +producer rather than being overtaken. When `advance(timestamp)` returns, a +render walk sees that frame. Nothing sleeps to find out. + +A receiver that goes away mid-delivery does not strand the producer: its slot is +removed and whatever was outstanding on it released in the same synchronous +teardown, so closing a drawer while a frame is in flight leaves the other +receivers to finish it and the clock to return. + +**A value with state is acquired, not constructed.** A handoff holds the set of +live receivers and what each of them still owes, and a clock holds a handoff, so +both are resources: `useHandoff()` and `useFrameClock()`, owned by the scope that +asked for them and ended by it. A branch's update channel is acquired inside the +branch's own lifecycle, so it belongs to that branch's scope and goes when the +branch does. A factory would have made that state belong to whoever happened to +hold the reference — which is how a set of receivers outlives the thing they were +receiving from and goes on being counted. + +The exception is a component's `NodeDataKey`. It is immutable metadata an author +declares at module evaluation about a value they own, which is what +`component()` mints and carries — not state, and nothing to tear down. + +**The URL reconstructs focus, structurally.** Every surface a route can name is +a focus-owning branch, and a description says where focus belongs: `"none"`, +`"here"` — a surface the URL names, where focus starts without stopping you +going elsewhere — or `"alone"`, which is what an open drawer means. + +Those claims have to describe *one* place, so they are required to lie on one +ancestry. A branch may claim inside a branch that also claims, and the deeper +one wins, because that is the same place at more depth. Two claims in unrelated +subtrees name two places at once and are refused in the same preflight that +checks keys, before a single node is created, removed or updated. + +"Deepest" here means ancestry, not rendering order. The active branch is found +by descending — at each level at most one subtree can hold a claim, which is +what the preflight guarantees — so no sibling's position is part of the answer. +An earlier version flattened every claimant in tree order and took the last, +which made moving an unrelated sibling move focus. + +Focus moves only when it is not already inside the branch being asked for: on a +cold mount, when the location names somewhere else, and when the branch that +held it was removed. Focus already there stays where the person put it. + +**An open drawer owns interaction.** Because its claim is `"alone"`, nothing +outside it can be reached: the drawer beneath it and the surfaces behind it stay +mounted, keep drawing and keep their lifecycles, and none of them is a focus or +pointer target. Input still travels their scopes, so a key the top drawer does +not claim — `Escape` — still bubbles out through the live ancestry. Closing the +top drawer makes the one beneath it the active branch; closing the last one +gives the route-named surface focus and makes the other surfaces reachable +again. Nothing outside `screen.ts` knows what a drawer or a surface is. + +**A pointer names what it was on.** The target survives normalization as an +opaque node identity, and the host resolves it against the live tree: a +reachable target takes focus and is then dispatched to exactly as a keypress +there would have been, and a covered, background, container or absent one takes +no focus and receives no input. "Covered" is not a flag or a second set of +components — it is the focus boundary, and there is one mechanism for it. + +**Delivery addresses a position in the tree, never a retained reference.** +Removing a node detaches it from its parent but leaves the node object, and a +disposed Effection scope still carries the interceptors installed on it — so a +kept reference to a closed drawer's control would otherwise still run that +drawer's middleware and answer with an action. + +**A component declares every member except `onPress`.** A member is optional +when its absence is the neutral element of a composition and required when its +absence would substitute a claim. Absent `onPress` means the component says +nothing about a key, so it carries on to the branch that does understand it — +which is what would have happened anyway. Absent `children` would instead be the +reconciler deciding the component has no subtree, and a `children` misspelled or +lost in a merge would mount a tree missing a branch with nothing to report. +`lifecycle` is written `null` rather than left out, because whether a branch +holds anything disposable decides whether a task is started for it at all. + +## The representative execution + +One entry, a nested scope tree, and two live suspensions: + +```text +entry-1 "Add a README to the project" +└── document + ├── plan (settled — opened `review`, answered it, exited) + ├── write (waiting on `project`) + └── publish (waiting on `confirm`) +``` + +`document` runs `write` and `publish` as concurrent branches. `write` opens a +`project` elicitation and waits; `publish` then opens `confirm` on top of it. +Both are unanswered at the head, so the suspension stack at `cp-10` is ordered +`project, confirm` and the top one is the interactive drawer. That is what makes +the location #840 names resolvable: + +```text +xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect +``` + +and what makes closing `+confirm` expose `+project`, its real parent, rather +than an empty screen. + +The `plan` scope exists to be a *settled* nested scope. Leaving a scope closes +it rather than erasing it, so a settled scope stays in the tree and a URL can +still name it — which is how bindings a finished scope published stay reachable. + +**Entries in a session are sequential**, so the representative execution has +exactly one and the projection refuses to describe two running at once. A second +entry appears only in `SERIAL_HISTORY`, a separate fixture where `entry-1` opens +a `project` wait in its `document` scope, answers it, leaves the scope and +settles — and only *then* is `entry-2` submitted, opening the same kind at the +same path. An entry cannot settle while it is waiting, and cannot settle while +any scope it opened has not exited, so one moment never holds two live scope +trees. `Scope.settled` records that a scope exited; nothing marks one settled to +let an entry finish. + +Every name in that fixture is the same; only the owner differs. That is what +suspension ownership has to be proven against: each `Suspension` names the entry +*and* the scope path that own it, a drawer path under `entry-1` indexes +entry-1's own stack, and an answer consumes a wait only when the entry, the +exact scope path and the kind all match. Without the entry in that comparison, a +stale answer for the finished `entry-1` removed `entry-2`'s live wait and left +the head with an empty stack — a moment that never happened, which routing would +then accept or refuse the wrong drawer against. + +## Three decisions this slice makes + +**The entry is separate from the scopes it owns.** #839 spelled the entry as the +first scope segment. It is a different kind of thing: an entry is something you +submitted, and a scope is something the execution opened while running it. +Separating them is what lets a refusal say `"plan" is not a scope of entry-1` +rather than reporting a scope miss one level from where it happened. + +**One location has one spelling, and the encoder is what gives it.** Decoding +answers the structure a URL names rather than the bytes it was written with, so +`?inspect&at=cp-10`, `%65ntry-1` and `?draft=` are equivalent spellings that +decode to the same `Route`; `encodeRoute()` then supplies the one spelling a +location is stored and generated with. Canonicalizing is +`encodeRoute(decodeRoute(url))`, not a rule that turns an equivalent spelling +away — rejecting equivalent input would be public behaviour #840 never settled, +and it buys nothing the encoder does not already guarantee. + +What decoding still refuses is a URL that is malformed, or one that names two +locations at once: an unknown query key, a repeated one, a value on the valueless +`inspect`, an empty `at=`, `inspect` without the marker it reconstructs, an empty +path segment (which is what a trailing slash is), an unnamed drawer (`/+`), a +scope written below a drawer, and a drawer written outside any entry. Accepting a +*spelling* is not accepting a *structure*, and the evidence carries a named +control for exactly that. + +A URL is untrusted text, and `decodeURIComponent` throws on a lone `%`. Every +percent-decode in the router therefore goes through one guarded helper that +answers `undefined`, and the refusal names the part the text came from — +`execution`, `entry`, `scope`, `drawer`, `at` or `draft`. `decodeRoute()` +promises `Result`, and a syntax failure leaving as a thrown `URIError` +would be that promise broken. + +**A `Route` cannot hold a structure its encoder would change.** `Route` is a +closed union: a `SurfaceRoute` names a region and has no member a scope path +could occupy, and an `EntryRoute` carries the entry that owns its scopes and +drawers. Both are minted by checked constructors — `surfaceRoute()` and +`entryRoute()` return `Result` and refuse an empty execution, entry, scope, +drawer or marker — and a module-local symbol on the type means a hand-written +look-alike is not a `Route` and never reaches `encodeRoute()`. The defect this +closes: a route carrying `scopes: ["document"]` and no entry used to encode to +`xmd://repl/e1/transcript/document`, which decoded back with `document` as the +*entry*. The round trip now holds for every route that can be built. + +## `path-to-regexp` was evaluated and is not adopted + +#840 permits it "only as a syntactic recognition primitive", and only if the +comparison shows it materially simplifies strict decoding and canonical encoding +without weakening `Result` failures or semantic resolution. It does not, for +four reasons, and it is not a dependency of this repository today — adding one +is an explicit act that moves `deno.lock`. + +**The path has two adjacent unbounded runs, not one.** A location is +`////*/+*`. `path-to-regexp`'s +wildcard captures one run of segments as a flat array, so the most it can match +is `/:execution/:surface{/*rest}` — after which `rest` still has to be split on +the `+` sigil and classified into entry, scopes and drawers by hand. That +classification *is* the work; the primitive would contribute the first two +segments. + +**Most of the strictness lives in the query string, which it excludes.** Its own +documentation is explicit: *"`path-to-regexp` is intended for ordered data (e.g. +paths, hosts). It can not handle arbitrarily ordered data (e.g. query strings, +URL fragments, JSON, etc)."* The canonical ordering of `at`, `inspect` and +`draft`, the valueless spelling of `inspect`, the refusal of a repeated key and +of an empty `at=`, and `inspect` without a marker are all outside what it sees — +as is the equivalence that lets those same keys arrive in any order. + +**`compile()` cannot produce the canonical encoding.** It encodes each parameter +with `encodeURIComponent`, which turns the `+` that marks a drawer into `%2B` +and destroys the one distinction the path grammar carries. Passing +`encode: false` moves the encoding back here, which is where it already is. + +**Its failures are not `Result` failures.** `match()` answers `false`, which +carries no segment and no explanation, and `compile()` throws on a missing +parameter. #840 requires a failure that names the first unresolved segment and +says what exists there, returned through Effection's `Result`. Both would have +to be wrapped, and the wrapper is longer than the matching it replaces. + +Revolution's `route()` is still the model for the *control flow* — request in, +result out, no ambient navigation state, rendering kept on the other side of the +boundary. What is not carried over is its choice of matcher, because its paths +are fixed patterns over one kind of segment and a REPL location is not. diff --git a/scripts/repl-compose/RESULT.md b/scripts/repl-compose/RESULT.md new file mode 100644 index 000000000..a644f3685 --- /dev/null +++ b/scripts/repl-compose/RESULT.md @@ -0,0 +1,201 @@ +# What the replacement experiment found + +[#840](https://github.com/taras/executable.md/issues/840), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). This is the +conclusion: what to keep, what to change, what to drop, and in what order to +build it. None of the code under `scripts/repl-compose/` is a starting point — +production begins from current `main`, written afresh. + +The question was whether one small routing and component model can carry the +REPL. It can. The layering held from a URL to bytes without anything needing to +be kept in step with anything else, and every defect the reviews found was a +*boundary* defect — a value reachable where it should not have been — rather +than a layering one. + +## Retain + +**The URL is the location, and the router is three pure functions.** +`decodeRoute`, `encodeRoute` and `resolveRoute` own no current route, no +history, no subscription and no callback. Asking where you are means handing the +router a URL again. That is what made the same question answerable of two +different models without either input moving, and it is why a refusal could be +rendered as a whole screen rather than a banner: there was no retained "current +route" to be half-updated. + +**History-record access ends at `ReplModel`.** Routing, composition, layout and +rendering never saw a record. The projection is the only module that reads one, +and the evidence holds the router to importing `effection` and the model's types +and nothing else. + +**A resolved location holds the model's own values.** Not a copy, not a second +projection — the identity checks (`toBe`, not `toEqual`) are what stopped a +display-shaped duplicate growing between the model and the screen, which is the +failure the first experiment had. + +**Keyed descriptions reconciled into Freedom, with Freedom the only mounted +tree.** A parent declares its direct children and their immutable inputs; +reconciliation mounts them and nothing else. Every question — what is drawn, +what can be focused, where a key goes, who wants frames — is a walk of those +same nodes. The three negative controls (positional matching, a hidden-but-live +drawer, a parallel registry) each *pass* the check the real design fails them +on, which is what makes the checks checks. + +**Structural teardown.** A drawer stack is a branch, so closing the top removes +one subtree and closing the bottom removes both — and what goes with it goes +because its Effection scope is gone, not because anything was notified. This is +the single strongest result of the experiment: the properties #840 asks for +("no focus target, input path, frame demand or presentation") are not +maintained, they are unavailable. + +**Acknowledged delivery.** `advance(timestamp)` completes once every subscriber +has applied that frame, and a retained branch's new input completes once the +branch has taken it. Asking for the next value *is* the acknowledgement, so +there is no `ack()` to forget. This replaced a `sleep(0)` barrier that was a +guess at how long a receiver needed. + +**Layout as presentation only.** The viewport reaches components through their +input and decides how a parent arranges what its children drew. The same +location describes the same tree at every width — proven by composing once at +each viewport and comparing topology and focus order. + +**Normalizing input at the host, with the pointer's target preserved.** The +keypress is one value either way, and what the pointer was on rides alongside it +rather than inside it — so a control cannot tell a click from a keypress, and +"equivalent activations emit the same action" is not a property anything has to +maintain. The target is an opaque node identity the host resolves against the +live tree: a focusable one takes focus and is then dispatched to exactly as a +keypress there would have been, and a covered, background, container or +absent one takes no focus and receives no input. The equivalence is proven +*targeted* — focus on one control, pointer on another — not merely for whatever +happened to hold focus. + +**The URL reconstructs focus, and it does so structurally.** Every surface a +route can name is a focus-owning branch, and a description says where focus +belongs: nowhere, *here*, or *here alone*. Claims are required to lie on one +ancestry — a claim inside a claim is the same place deeper, and the deeper one +wins — and two claims in unrelated subtrees are refused in the same preflight +that checks keys, before anything is mounted, removed or moved. + +"Deepest" is ancestry, not rendering order. The active branch is found by +descending, so no sibling's position is part of the answer; an earlier version +flattened claimants in tree order and took the last, which made reordering an +unrelated sibling move focus. Cold-mounting the same URL in a fresh root +reconstructs the same focus identity. + +An `alone` claim is what makes a drawer modal: everything outside it stays +mounted and drawing and keeps its lifecycle, and none of it is a focus or +pointer target — while input still travels those scopes, so an unclaimed key +bubbles out through the live ancestry. Closing the top drawer makes the one +beneath it active; closing the last restores the route-named surface and the +reachability of the others. One mechanism, and nothing outside the screen module +knows what a drawer is. + +## Revise + +**`Description` had to become opaque.** It first carried `input: unknown` +beside closures over the input it was made with, so a spread could replace the +payload and leave one mounted component acting on two inputs. The fix — capture +the input in closures, make the description a class with a private field, and +give each component its own `NodeDataKey>` — is what production +should start from. Do not ship a description with a reachable input. + +**Key uniqueness needs a preflight, not a check while mounting.** Two siblings +under one key produce a tree that cannot be addressed: the reconciler finds +children by key, so the second shadows the first, and the first is never matched +again and never removed. The whole description tree is checked before anything +is created, so a refusal changes nothing. + +**Entries and scopes are different kinds of thing.** #839 spelled the entry as +the first scope segment. Separating them is what lets a refusal say `"plan" is +not a scope of entry-1` rather than reporting a miss one level from where it +happened. + +**Decoding should accept equivalent spellings.** Refusing a reordered query or +an over-encoded segment was public behaviour #840 never settled, and it bought +nothing: `encodeRoute()` already gives every persisted and generated location +one spelling. Canonicalizing is `encodeRoute(decodeRoute(url))`. + +**Values with state are resources.** A handoff and a clock hold live receivers, +so they are acquired, not constructed, and the scope that asked for them ends +them. A factory made that state belong to whoever held the reference. + +**A serial-entry invariant belongs in the projection.** Entries in a session are +sequential, and an entry cannot settle while it waits or while a scope it opened +has not exited. Enforcing it where history becomes the model is what stops a +fixture — or a real journal read — describing two live scope trees at one +moment. + +## Discard + +**`InputSink.accept(input: unknown)` and the bivariant-method bridge.** It +type-checked and it was wrong. Nothing should cross the typed boundary by +variance. + +**`settle()` / `sleep(0)` as a barrier.** A guess, not a barrier. + +**A slot-level acknowledgement.** The release has to travel with the value, or a +queued value is acknowledged when it is fetched rather than when it is applied. + +**`path-to-regexp`.** Evaluated against #840's own condition and not adopted: +the path has two adjacent unbounded runs rather than one, its documentation +excludes query strings where this grammar's query rules live, `compile()` +percent-encodes the `+` that marks a drawer, and `match()` answers `false` +rather than a `Result` naming a segment. The README records the comparison. + +**Crank and Revolution as dependencies.** Both were the right *models* — Crank +for keyed reconciliation and generator-local lifetimes, Revolution for +request-in/result-out matching with no ambient navigation state. Neither is +needed as code, and a Crank runtime would have put a second mounted +component-context tree beside Freedom, which is the duplication this experiment +existed to remove. + +## Known limits of this evidence + +- **Pointer input is proven at the dispatch boundary, not at a terminal.** Mouse + reporting is deliberately never enabled, following #838's decision, and the + pointer's target arrives as a node identity rather than as coordinates. + Resolving that identity against the live tree, moving focus to it and + dispatching there is proven; turning a *screen position* into that identity is + not, and needs geometry the render walk does not yet keep. +- **Presentation is lines of text.** Cells, widths, wrapping and the responsive + long tail are out of scope here; `render.ts` exists to show the seam is real, + not to draw well. +- **The session snapshot is a shape, not a store.** StarFX was not added. What + is proven is that per-surface state kept outside the tree survives a branch + being unmounted — not any particular store's semantics. +- **No real journal, no XMD execution, no Agent providers, no subtree pause.** + +## Production sequencing + +Written afresh from current `main`, in dependency order. Each step is +independently reviewable and each one has evidence before the next begins. + +1. **`ReplModel` and the history projection.** The immutable model, the + serial-entry and scope-exit invariants, and the checkpoint snapshot. No + routing. Evidence: projection refusals and per-checkpoint isolation. +2. **The router.** `decodeRoute`, `encodeRoute`, `resolveRoute` over that model, + returning `Result`. Evidence: canonical encoding, equivalent-spelling + decoding, and refusal at the first unresolved segment. This is the step that + most benefits from being alone — it is pure, and its evidence is cheap. +3. **The handoff primitive.** Acknowledged delivery with per-value release and + scope-owned state, on its own, before anything depends on it. +4. **The component boundary and reconciler.** Opaque keyed descriptions, + component-owned typed update channels, a preflight that checks both key + uniqueness and single-ancestry focus claims, and reconciliation into Freedom. + Evidence: retention, teardown, focus reconstruction, ambiguity refusal, and + the three negative controls. +5. **The frame clock and input normalization**, on the primitive from step 3. +6. **The screen.** `describeScreen` over a resolved location, with one + focus-owning branch per route surface, the refusal as a whole screen, and + layout as presentation only. +7. **The host and a renderer.** Viewport, frames, raw input with the pointer's + target preserved, renderer replacement — and a source-level control that the + host names nothing it shows. Screen-position hit-testing belongs here, and is + the one thing this experiment left unproven. +8. **The component catalog and the responsive long tail**, which is where the + #838 study's content belongs, and which this experiment deliberately did not + rebuild. + +Steps 1–4 are the contract. Steps 5–8 are work that gets easier because of them, +and none of them can put a second representation back without deleting a +control that is already written. diff --git a/scripts/repl-compose/component.ts b/scripts/repl-compose/component.ts new file mode 100644 index 000000000..27cd1167f --- /dev/null +++ b/scripts/repl-compose/component.ts @@ -0,0 +1,273 @@ +/** + * What a parent says its children are. + * + * A component description is a value, not a mounted thing: a key, the component + * to run, and the immutable input to run it on. A parent produces the + * descriptions of its *direct* children from its own input and nothing else — + * it does not reach into a registry, ask what is mounted, or hand a child a way + * to register itself. That is the whole boundary, and it is what lets the tree + * be decided before anything exists. + * + * Crank is the model. A component is a function of its input; a keyed child + * matched across an update keeps its identity and its local state; a child that + * stops being described is unmounted with everything below it. What is not + * borrowed is Crank's runtime, because a second mounted component-context tree + * beside Freedom is exactly the duplication this experiment exists to remove. + * Here the description is reconciled *into* Freedom, and a Freedom node is the + * only thing that gets mounted. + * + * **One description, one input.** A description does not carry its input where + * anything can reach it. Everything the input decides — the children, what is + * drawn, what a key means, and what a retained branch is told next — is a + * closure over the same captured value, made in one call, and a description is + * a class with a private field so no object can be assembled that looks like + * one. An earlier version exposed `input: unknown` beside those closures, and a + * spread could then replace it: `{ ...describe(Probe, "probe", 2), input: 3 }` + * type-checked, and produced a mounted component whose lifecycle acted on 3 + * while it drew 2. One component, two inputs, and nothing to detect it. + * + * The typed channel that replaces it is reached without a cast, without + * bivariance and without any table: a component carries its own + * `NodeDataKey>`, minted when the component is built, and a + * branch keeps its update channel on its own node under that key. Reading it + * back is `node.data.get(component.updates)`, which the compiler already knows + * is a `Handoff`. + */ + +import type { Operation } from "effection"; +import { createNodeData } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node, NodeDataKey } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import type { Frames } from "./frames.ts"; +import { useHandoff } from "./handoff.ts"; +import type { Handoff, Receiver } from "./handoff.ts"; +import type { Action, KeyPress } from "./input.ts"; + +/** + * What a description says about where focus belongs. + * + * `"none"` says nothing, and is what a component that never asks answers. + * `"here"` says focus belongs in this branch — a surface the URL names, which + * is where you start without stopping you going elsewhere. `"alone"` says focus + * belongs in this branch *and nowhere else*, which is what an open drawer + * means: everything outside it stays mounted, keeps drawing and keeps its + * lifecycle, and none of it can be reached. + */ +export type FocusClaim = "none" | "here" | "alone"; + +/** + * A component's identity is the component value itself. + * + * Nothing mints an id and nothing remembers one: reconciliation compares the + * component a description names by reference. A table of components keyed by + * name would be a registry beside the tree, and it would answer a question — + * "which component is this?" — that the description already answers. + */ +export type ComponentIdentity = object; + +/** Where a branch waits for the input its parent hands it next. */ +export interface Updates { + receive(): Operation>; +} + +/** What a mounted branch is given: its own node, its input, and the operational APIs. */ +export interface Mounted { + /** This branch's Freedom node. Its scope owns everything the branch holds. */ + readonly node: Node; + /** The input this branch was mounted on. Later input arrives on `updates`. */ + readonly input: Input; + /** The one host frame stream, and the demand this branch may place on it. */ + readonly frames: Frames; + /** + * Later input for this same branch, handed down by its parent. + * + * A retained branch keeps its node and its local state, so it has to be told + * what changed rather than rebuilt. This is that channel, and it crosses the + * direct parent-child boundary like the first input did: nothing ambient, + * nothing to look up, and nothing to poll. Taking the next input reports that + * the previous one was applied, so the reconcile that delivered it does not + * return until this branch has acted on it. + */ + readonly updates: Updates; + /** + * Say that this branch's local state exists. + * + * The reconcile that mounted the branch waits here before it returns, so a + * caller never observes a tree whose new branches have not finished setting + * themselves up. A lifecycle that ends without calling it does not hang the + * mount — finishing releases the same gate — but then nothing it did was + * guaranteed to be visible. + */ + ready(): Operation; +} + +/** + * What one component is, before it is given the channel key its branches use. + * + * A member is optional when its absence is the neutral element of a + * composition, and required when its absence would substitute a claim. Absent + * `onPress` means this component says nothing about a key, so the key carries + * on to the branch that does understand it — which is what would have happened + * anyway, and is why writing `onPress: () => undefined` on every leaf is noise. + * Absent `children` would instead be the reconciler deciding this component has + * no subtree, and a `children` misspelled, renamed or lost in a merge would + * mount a tree missing a branch with nothing to report. That one costs a line. + * + * `lifecycle` is written `null` rather than left out, because whether a branch + * holds anything disposable decides whether a task is started for it at all. + * Absence there is a value the author chose, the same distinction the engine's + * own prop boundary draws between an omitted prop and one written `null`. + */ +export interface ComponentSpec { + readonly name: string; + /** The direct children this input describes, in the order they appear. */ + children(input: Input): readonly Description[]; + /** Disposable local state and subscriptions, or `null` for a branch with none. */ + readonly lifecycle: ((mounted: Mounted) => Operation) | null; + /** Whether this component is a focus target of its own. */ + readonly focusable: boolean; + /** The semantic action this input gives a key. Absent means: pass it on. */ + onPress?(input: Input, key: KeyPress): Action | undefined; + /** + * Where this input says focus belongs. + * + * It is a property of the input rather than of the component, because which + * surface a URL names changes while the component does not. Absent means this + * component never asks, which is the neutral answer: something else will. + */ + claimsFocus?(input: Input): FocusClaim; + /** What this component draws, around what its children drew. */ + present(input: Input, children: readonly string[]): readonly string[]; +} + +/** One component, with the typed channel its branches are updated through. */ +export interface Component extends ComponentSpec { + /** + * Where a branch of this component keeps the channel its parent updates it + * through. + * + * Minted with the component and carried on it — metadata an author declares + * about a value they own, rather than an entry in a collection somebody has + * to keep. It is what makes delivering later input a typed read of this + * branch's own node instead of an erased payload the reconciler carries. + */ + readonly updates: NodeDataKey>; +} + +/** Build one component, minting the channel key its branches will use. */ +export function component(spec: ComponentSpec): Component { + return { + ...spec, + updates: createNodeData>(`xmd:repl-compose:${spec.name}:updates`), + }; +} + +interface DescribedParts { + children(): readonly Description[]; + claimsFocus(): FocusClaim; + start(node: Node, frames: Frames, ready: () => Operation): Operation | undefined; + update(node: Node): Operation; + onPress(key: KeyPress): Action | undefined; + present(children: readonly string[]): readonly string[]; +} + +/** + * A parent's statement that one keyed child exists, on the input it runs on. + * + * Not exported as a value, and holding a private field, so the only thing that + * can make one is `describe()`. Its input is captured, never carried: there is + * no member to replace, and so no way to leave a mounted component acting on + * one input while it draws another. + */ +class DescribedChild { + readonly #parts: DescribedParts; + + constructor( + readonly key: string, + readonly name: string, + readonly identity: ComponentIdentity, + readonly focusable: boolean, + parts: DescribedParts, + ) { + this.#parts = parts; + } + + /** The children this description's own input describes. */ + children(): readonly Description[] { + return this.#parts.children(); + } + + /** Whether the location is asking for this branch to hold focus. */ + claimsFocus(): FocusClaim { + return this.#parts.claimsFocus(); + } + + /** Start this branch, or nothing when the component holds nothing disposable. */ + start(node: Node, frames: Frames, ready: () => Operation): Operation | undefined { + return this.#parts.start(node, frames, ready); + } + + /** + * Hand a retained branch the input this description was made with. + * + * The reconciler calls it only after the node's identity matched this + * description's component, and what it reaches is that component's own typed + * channel on that node — so the value delivered is the one this description + * captured, and it can be no other. + */ + update(node: Node): Operation { + return this.#parts.update(node); + } + + onPress(key: KeyPress): Action | undefined { + return this.#parts.onPress(key); + } + + present(children: readonly string[]): readonly string[] { + return this.#parts.present(children); + } +} + +export type Description = DescribedChild; + +/** + * Declare one keyed child. + * + * The input is captured here and read nowhere else, so a component can only + * ever see what its parent handed it — and every behaviour that input decides + * is a closure made in this one call. + */ +export function describe( + component: Component, + key: string, + input: Input, +): Description { + const lifecycle = component.lifecycle; + return new DescribedChild(key, component.name, component, component.focusable, { + children: () => component.children(input), + claimsFocus: () => component.claimsFocus?.(input) ?? "none", + + start: + lifecycle === null + ? () => undefined + : (node, frames, ready) => + (function* start(): Operation { + // Acquired here, so the channel belongs to this branch's own scope and + // ends when the branch does — rather than being a value the branch + // happens to hold. + const updates = yield* useHandoff(); + node.data.set(component.updates, updates); + yield* lifecycle({ node, input, frames, ready, updates }); + })(), + + *update(node: Node): Operation { + const updates = node.data.get(component.updates); + if (updates !== undefined) { + yield* updates.deliver(input); + } + }, + + onPress: (key) => component.onPress?.(input, key), + present: (children) => component.present(input, children), + }); +} diff --git a/scripts/repl-compose/frames.ts b/scripts/repl-compose/frames.ts new file mode 100644 index 000000000..111a7ab10 --- /dev/null +++ b/scripts/repl-compose/frames.ts @@ -0,0 +1,58 @@ +/** + * One clock, and the branches that are asking it for frames. + * + * The host owns the clock. A branch that animates subscribes to it for exactly + * as long as that branch is mounted, and the number of live subscriptions *is* + * the demand — there is nothing else to consult and nothing to keep in step. + * A closed drawer stops demanding frames because its scope is gone, not because + * something remembered to say so. + * + * Advancing the clock is an operation that completes, not a send that returns + * at once. When `advance()` returns, every branch that was subscribed has + * applied that timestamp, so whatever reads the tree next — a render walk, an + * assertion — sees that frame rather than the one before it. A host that cannot + * tell when a frame has landed can only guess, and drawing on a guess is how a + * frame comes out half old. + */ + +import type { Operation } from "effection"; + +import { useHandoff } from "./handoff.ts"; +import type { Receiver } from "./handoff.ts"; + +/** What a mounted branch may do with the clock. */ +export interface Frames { + /** + * Subscribe for this branch's lifetime. + * + * It is a resource, so the subscription and the demand it represents both end + * when the scope that acquired them does. + */ + subscribe(): Operation>; + /** How many mounted branches are asking for frames right now. */ + readonly demand: number; +} + +/** The host's side: the same clock, plus the ability to advance it. */ +export interface FrameClock extends Frames { + /** Advance to one timestamp, completing once every subscriber has applied it. */ + advance(timestamp: number): Operation; +} + +/** + * One clock, owned by the scope that acquires it. + * + * The receivers it is holding are state, so the clock is acquired rather than + * constructed: when the scope that asked for it ends, so does what it was + * keeping. + */ +export function* useFrameClock(): Operation { + const frames = yield* useHandoff(); + return { + get demand(): number { + return frames.demand; + }, + subscribe: () => frames.receive(), + advance: (timestamp: number) => frames.deliver(timestamp), + }; +} diff --git a/scripts/repl-compose/handoff.ts b/scripts/repl-compose/handoff.ts new file mode 100644 index 000000000..8bf7bfc91 --- /dev/null +++ b/scripts/repl-compose/handoff.ts @@ -0,0 +1,169 @@ +/** + * Hand a value to every live receiver, and know when they have all taken it. + * + * A producer that sends and moves on cannot tell you whether anything acted on + * what it sent, so a caller that wants to read the result has to guess how long + * to wait. That guess is what `sleep(0)` was doing here, and a guess is not a + * barrier: a receiver that needed two turns would be read before it ran. + * + * So delivery completes instead. `deliver()` finishes only once every receiver + * that was live when it started has come back for the next value, which is the + * moment it has finished applying this one. Asking for the next value is the + * acknowledgement — there is no separate `ack()` to forget, and a receiver that + * is slow holds the producer rather than being overtaken. + * + * A receiver that goes away does not strand the producer. Its slot is removed + * and whatever was outstanding on it is released in the same synchronous + * teardown, so closing a drawer mid-frame leaves the frame's other receivers to + * finish it and the producer to return. + * + * The handoff itself is scope-owned for the same reason. It is a value with + * state — the set of live receivers and what each of them still owes — so it is + * acquired rather than constructed, and the scope that acquired it is what ends + * it. A factory would have made that state belong to whoever happened to hold + * the reference, which is how a set of receivers outlives the thing they were + * receiving from and goes on being counted. + */ + +import { resource, withResolvers } from "effection"; +import type { Operation } from "effection"; + +/** One live receiver's side of a handoff. */ +export interface Receiver { + /** + * The next value, once there is one. + * + * Calling it acknowledges the value returned by the previous call, so a loop + * that takes a value, applies it and comes back is already reporting. + */ + next(): Operation; +} + +export interface Handoff { + /** Receive for as long as the acquiring scope lives. */ + receive(): Operation>; + /** Deliver one value, completing when every live receiver has applied it. */ + deliver(value: T): Operation; + /** How many receivers are live right now. */ + readonly demand: number; +} + +/** + * One value on its way to one receiver, and the producer it will release. + * + * The release travels *with* the value rather than living on the slot, because + * a value that waited in the queue must be acknowledged when it is applied and + * not when it is handed over. Keeping one release per slot acknowledged a + * queued value on the call that fetched it — before the receiver had done + * anything with it — which is a producer told "applied" about work that had not + * started. + */ +interface Parcel { + readonly value: T; + readonly release: () => void; +} + +interface Slot { + /** Values delivered before this receiver asked for one, oldest first. */ + queued: Parcel[]; + /** Resumes a receiver that is waiting for a value. */ + resume?: (parcel: Parcel) => void; + /** The release owed for the value this receiver is applying right now. */ + applying?: () => void; +} + +/** Release the producer of the value this receiver has now finished applying. */ +function acknowledge(slot: Slot): void { + const applying = slot.applying; + slot.applying = undefined; + applying?.(); +} + +/** Release everything this slot still owes, because it can owe nothing now. */ +function abandon(slot: Slot): void { + acknowledge(slot); + for (const parcel of slot.queued) { + parcel.release(); + } + slot.queued = []; +} + +export function useHandoff(): Operation> { + return resource(function* (provide) { + const slots = new Set>(); + try { + yield* provide(handoffOver(slots)); + } finally { + // Nothing waiting on a receiver here can still be answered, and no + // receiver still counts, so the state ends with the scope that owns it. + for (const slot of slots) { + abandon(slot); + } + slots.clear(); + } + }); +} + +function handoffOver(slots: Set>): Handoff { + return { + get demand(): number { + return slots.size; + }, + + receive(): Operation> { + return resource(function* (provide) { + const slot: Slot = { queued: [] }; + slots.add(slot); + try { + yield* provide({ + *next(): Operation { + // Coming back for another value is how the last one is reported. + acknowledge(slot); + const queued = slot.queued.shift(); + if (queued !== undefined) { + slot.applying = queued.release; + return queued.value; + } + const waiting = withResolvers>(); + slot.resume = waiting.resolve; + let parcel: Parcel; + try { + parcel = yield* waiting.operation; + } finally { + slot.resume = undefined; + } + slot.applying = parcel.release; + return parcel.value; + }, + }); + } finally { + // Leaving releases everything this receiver still owes, so one that + // goes away cannot hold delivery open. + slots.delete(slot); + abandon(slot); + } + }); + }, + + *deliver(value: T): Operation { + // The receivers live at the moment delivery starts. One that subscribes + // during it joins the next value rather than this one. + const outstanding: Operation[] = []; + for (const slot of [...slots]) { + const applied = withResolvers(); + const parcel: Parcel = { value, release: applied.resolve }; + outstanding.push(applied.operation); + const resume = slot.resume; + if (resume === undefined) { + slot.queued.push(parcel); + } else { + slot.resume = undefined; + resume(parcel); + } + } + for (const applied of outstanding) { + yield* applied; + } + }, + }; +} diff --git a/scripts/repl-compose/history.ts b/scripts/repl-compose/history.ts new file mode 100644 index 000000000..bc4580605 --- /dev/null +++ b/scripts/repl-compose/history.ts @@ -0,0 +1,453 @@ +/** + * One execution's history, and the projection that ends history-record access. + * + * `ReplHistory` is the immutable ordered execution records the REPL reads. In + * production those records are read from the durable Journal — XMD's + * append-only execution mechanism — and that read happens *above* this + * experiment. Nothing here is a Journal: this implements no append, no replay, + * no durability and no transaction, and #842 owns the real read. What is here + * is a history fixture, hand-authored so that folding it is the only way to + * learn what was open, what had settled and what was waiting at any recorded + * moment. Deriving it from a screen would make the routing evidence compare a + * projection with itself. + * + * `projectModel()` is the boundary. Everything above it reads history records; + * everything below it reads a `ReplModel` and cannot reach a record at all. + */ + +import { deepFreeze } from "./model.ts"; +import type { Checkpoint, Entry, ReplModel, Scope, Suspension } from "./model.ts"; + +export type HistoryRecordKind = + | "entry.submitted" + | "scope.enter" + | "scope.exit" + | "suspension.opened" + | "suspension.answered" + | "entry.settled"; + +export interface HistoryRecord { + /** The marker a URL names this moment by. */ + readonly marker: string; + /** Recorded seconds, which is what the Execution History band measures. */ + readonly at: number; + readonly kind: HistoryRecordKind; + /** The entry the record was made in. */ + readonly entry: string; + /** + * Where inside that entry, outermost first. For `scope.enter` and + * `scope.exit` this is the parent of the scope named by `detail`; for a + * suspension it is the scope that owns the wait. + */ + readonly scope: readonly string[]; + /** The entry's title, the scope's name, or the suspension's kind. */ + readonly detail: string; + /** What the wait is asking. Only a `suspension.opened` carries one. */ + readonly prompt?: string; +} + +export type ReplHistory = readonly HistoryRecord[]; + +/** The execution this history fixture records. A route naming another one refuses. */ +export const EXECUTION = "e1"; + +/** + * The representative execution: one entry, a nested scope tree, and two live + * suspensions. + * + * The two-drawer stack is truthful rather than arranged. `document` runs `write` + * and `publish` as concurrent branches; `write` opens a `project` elicitation + * and waits, and `publish` then opens a `confirm` on top of it. Both are + * unanswered at the head, so the stack is ordered by when each opened and the + * top one is the interactive drawer — which is what makes closing `confirm` + * expose `project`, its real parent, rather than an empty screen. + * + * The `plan` scope exists to be a *settled* nested scope: it opened a `review` + * suspension, that suspension was answered, and the scope exited. It stays in + * the tree, because leaving a scope closes it rather than erasing it. + * + * Entries in a session are sequential, so this execution has exactly one. The + * evidence for suspension ownership uses `SERIAL_HISTORY` below, where a second + * entry begins only after the first has settled. + */ +export const HISTORY: ReplHistory = [ + { + marker: "cp-01", + at: 2, + kind: "entry.submitted", + entry: "entry-1", + scope: [], + detail: "Add a README to the project", + }, + { + marker: "cp-02", + at: 5, + kind: "scope.enter", + entry: "entry-1", + scope: [], + detail: "document", + }, + { + marker: "cp-03", + at: 12, + kind: "scope.enter", + entry: "entry-1", + scope: ["document"], + detail: "plan", + }, + { + marker: "cp-04", + at: 18, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document", "plan"], + detail: "review", + prompt: "Approve this plan before it runs?", + }, + { + marker: "cp-05", + at: 24, + kind: "suspension.answered", + entry: "entry-1", + scope: ["document", "plan"], + detail: "review", + }, + { + marker: "cp-06", + at: 30, + kind: "scope.exit", + entry: "entry-1", + scope: ["document"], + detail: "plan", + }, + { + marker: "cp-07", + at: 34, + kind: "scope.enter", + entry: "entry-1", + scope: ["document"], + detail: "write", + }, + { + marker: "cp-08", + at: 41, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document", "write"], + detail: "project", + prompt: "Which project should the README describe?", + }, + { + marker: "cp-09", + at: 46, + kind: "scope.enter", + entry: "entry-1", + scope: ["document"], + detail: "publish", + }, + { + marker: "cp-10", + at: 50, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document", "publish"], + detail: "confirm", + prompt: "Commit and push the README now?", + }, +]; + +/** + * Two entries, one after the other, for proving who owns a suspension. + * + * `entry-1` opens a `project` wait in its `document` scope, answers it, leaves + * the scope and settles. Only then is `entry-2` submitted, and it opens a + * `project` wait at the same scope path. Every name is the same; only the owner + * differs — which is the one thing a drawer path can be answered by. + * + * The scope exit is not bookkeeping. A settled entry with a scope still open is + * two live scope trees at one moment, which is a shape the product never + * reaches and which would carry a false active scope into everything that reads + * the model. + * + * It is deliberately a separate fixture. The representative execution stays one + * entry, because concurrent entry lifecycles are a state the product does not + * create and routing must not be shown resolving against one. + */ +export const SERIAL_HISTORY: ReplHistory = [ + { + marker: "sp-01", + at: 2, + kind: "entry.submitted", + entry: "entry-1", + scope: [], + detail: "Add a README to the project", + }, + { + marker: "sp-02", + at: 5, + kind: "scope.enter", + entry: "entry-1", + scope: [], + detail: "document", + }, + { + marker: "sp-03", + at: 9, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document"], + detail: "project", + prompt: "Which project should the README describe?", + }, + { + marker: "sp-04", + at: 14, + kind: "suspension.answered", + entry: "entry-1", + scope: ["document"], + detail: "project", + }, + { + marker: "sp-05", + at: 17, + kind: "scope.exit", + entry: "entry-1", + scope: [], + detail: "document", + }, + { + marker: "sp-06", + at: 18, + kind: "entry.settled", + entry: "entry-1", + scope: [], + detail: "Add a README to the project", + }, + { + marker: "sp-07", + at: 22, + kind: "entry.submitted", + entry: "entry-2", + scope: [], + detail: "Update the changelog", + }, + { + marker: "sp-08", + at: 26, + kind: "scope.enter", + entry: "entry-2", + scope: [], + detail: "document", + }, + { + marker: "sp-09", + at: 31, + kind: "suspension.opened", + entry: "entry-2", + scope: ["document"], + detail: "project", + prompt: "Which project's changelog is this?", + }, +]; + +/** The records up to and including one marker, which is the history as it stood. */ +export function historyThrough(marker: string, history: ReplHistory = HISTORY): ReplHistory { + const at = history.findIndex((record) => record.marker === marker); + if (at === -1) { + throw new Error(`no such history marker: ${marker}`); + } + return history.slice(0, at + 1); +} + +interface DraftScope { + name: string; + settled: boolean; + children: DraftScope[]; +} + +interface DraftEntry { + id: string; + title: string; + settled: boolean; + scopes: DraftScope[]; +} + +function findScope(entry: DraftEntry, path: readonly string[]): DraftScope | undefined { + let level = entry.scopes; + let found: DraftScope | undefined; + for (const name of path) { + found = level.find((scope) => scope.name === name); + if (found === undefined) { + return undefined; + } + level = found.children; + } + return found; +} + +function scopesAt(entry: DraftEntry, path: readonly string[]): DraftScope[] | undefined { + if (path.length === 0) { + return entry.scopes; + } + return findScope(entry, path)?.children; +} + +function snapshotScope(scope: DraftScope): Scope { + return { + name: scope.name, + settled: scope.settled, + children: scope.children.map(snapshotScope), + }; +} + +function snapshot( + marker: string, + at: number, + entries: readonly DraftEntry[], + suspensions: readonly Suspension[], +): Checkpoint { + const copied: Entry[] = entries.map((entry) => ({ + id: entry.id, + title: entry.title, + settled: entry.settled, + scopes: entry.scopes.map(snapshotScope), + })); + const stack: Suspension[] = suspensions.map((suspension) => ({ + kind: suspension.kind, + entry: suspension.entry, + scope: [...suspension.scope], + prompt: suspension.prompt, + })); + return deepFreeze({ marker, at, entries: copied, suspensions: stack }); +} + +/** The first scope anywhere in a tree that has not exited. */ +function unsettledScope(scopes: readonly DraftScope[]): DraftScope | undefined { + for (const scope of scopes) { + if (!scope.settled) { + return scope; + } + const deeper = unsettledScope(scope.children); + if (deeper !== undefined) { + return deeper; + } + } + return undefined; +} + +/** Whether one open suspension is the exact wait a record names. */ +function owns(suspension: Suspension, record: HistoryRecord): boolean { + return ( + suspension.entry === record.entry && + suspension.kind === record.detail && + suspension.scope.length === record.scope.length && + suspension.scope.every((name, at) => name === record.scope[at]) + ); +} + +function where(record: HistoryRecord): string { + const path = + record.scope.length === 0 ? record.entry : `${record.entry}/${record.scope.join("/")}`; + return `${record.marker} (${record.kind} ${JSON.stringify(record.detail)} in ${path})`; +} + +/** + * Fold a history into the model it describes. + * + * Each record produces one checkpoint holding the complete moment that followed + * it, so reconstructing a moment needs that checkpoint and nothing else. The + * head is the newest one. + */ +export function projectModel(execution: string, history: ReplHistory = HISTORY): ReplModel { + const entries: DraftEntry[] = []; + const suspensions: Suspension[] = []; + const checkpoints: Checkpoint[] = []; + + for (const record of history) { + if (record.kind === "entry.submitted") { + if (entries.some((entry) => entry.id === record.entry)) { + throw new Error(`${where(record)} submits an entry that is already open`); + } + // Entries in a session are sequential. Two live at once is a state the + // product does not create, so the projection refuses to describe one + // rather than letting a fixture drift into proving routing against it. + const running = entries.find((entry) => !entry.settled); + if (running !== undefined) { + throw new Error(`${where(record)} submits an entry while ${running.id} is still running`); + } + entries.push({ id: record.entry, title: record.detail, settled: false, scopes: [] }); + } else { + const entry = entries.find((candidate) => candidate.id === record.entry); + if (entry === undefined) { + throw new Error(`${where(record)} names an entry no record submitted`); + } + if (record.kind === "scope.enter") { + const level = scopesAt(entry, record.scope); + if (level === undefined) { + throw new Error(`${where(record)} names a parent scope no record entered`); + } + if (level.some((scope) => scope.name === record.detail)) { + throw new Error(`${where(record)} enters a scope that is already open there`); + } + level.push({ name: record.detail, settled: false, children: [] }); + } + if (record.kind === "scope.exit") { + const leaving = findScope(entry, [...record.scope, record.detail]); + if (leaving === undefined) { + throw new Error(`${where(record)} exits a scope no record entered`); + } + leaving.settled = true; + } + if (record.kind === "suspension.opened") { + if (findScope(entry, record.scope) === undefined && record.scope.length > 0) { + throw new Error(`${where(record)} suspends in a scope no record entered`); + } + suspensions.push({ + kind: record.detail, + entry: record.entry, + scope: [...record.scope], + prompt: record.prompt ?? "", + }); + } + if (record.kind === "suspension.answered") { + // An answer names one wait completely: the entry, the exact scope path + // inside it, and the kind. Matching on anything less lets an answer for + // a finished entry consume a live wait belonging to the next one, which + // reconstructs a moment that never happened. + let answered = -1; + for (let index = suspensions.length - 1; index >= 0 && answered === -1; index -= 1) { + if (owns(suspensions[index], record)) { + answered = index; + } + } + if (answered === -1) { + throw new Error(`${where(record)} answers no suspension this entry has open`); + } + suspensions.splice(answered, 1); + } + if (record.kind === "entry.settled") { + const waiting = suspensions.find((suspension) => suspension.entry === record.entry); + if (waiting !== undefined) { + throw new Error(`${where(record)} settles an entry still waiting on ${waiting.kind}`); + } + // An entry that settled while a scope it opened had not exited would + // leave a second live scope tree beside the next entry's. `settled` + // records that a scope exited, so this refuses rather than marking one. + const open = unsettledScope(entry.scopes); + if (open !== undefined) { + throw new Error( + `${where(record)} settles an entry whose ${open.name} scope has not exited`, + ); + } + entry.settled = true; + } + } + checkpoints.push(snapshot(record.marker, record.at, entries, suspensions)); + } + + const head = checkpoints[checkpoints.length - 1]; + if (head === undefined) { + throw new Error("an execution with no records has no head to project"); + } + return deepFreeze({ execution, head: head.marker, checkpoints }); +} diff --git a/scripts/repl-compose/host.ts b/scripts/repl-compose/host.ts new file mode 100644 index 000000000..38f315cb5 --- /dev/null +++ b/scripts/repl-compose/host.ts @@ -0,0 +1,179 @@ +/** + * The part that owns the terminal, and knows nothing about what is on it. + * + * The host measures the viewport, produces frames, normalizes raw input, hands + * a location to be composed, and decides which renderer draws. What it never + * does is name anything the application is made of: there is no route segment, + * no drawer kind, no component and no fixture transition in this file, and the + * evidence reads it to check. A host that knew a drawer was a drawer would be + * the application wearing the host's clothes, and every new screen would have + * to be taught to it. + * + * **Equivalent activations are the same activation.** A key arrives as bytes + * and a pointer arrives as a button on something, and both are normalized + * *here*, before anything is dispatched. What reaches the tree is one value + * with no trace of how it was produced — so a control cannot tell a click from + * a keypress, and "the same semantic action" is not a property anything has to + * maintain. + * + * A pointer names *what* it was on, and that survives normalization. The name + * is an opaque node identity the host resolves against the live tree: a target + * the tree no longer holds, or one that was never a place focus could be — + * a container, something drawn but not made focusable, or nothing at all — + * takes no focus and receives no input. A target that is focusable takes focus + * first, and then the activation is dispatched exactly as a keypress there + * would have been. + */ + +import { until } from "effection"; +import type { Operation, Result } from "effection"; +import { current, focus } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node, Root } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import type { Description } from "./component.ts"; +import type { FrameClock } from "./frames.ts"; +import { press } from "./input.ts"; +import type { Delivery, KeyPress } from "./input.ts"; +import { compose, focusTargets, paint } from "./reconcile.ts"; +import type { Renderer } from "./render.ts"; +import type { Viewport } from "./screen.ts"; + +/** What a terminal actually delivers, before anything has interpreted it. */ +export type RawInput = + | { readonly kind: "bytes"; readonly bytes: Uint8Array } + | { + readonly kind: "pointer"; + readonly button: "primary" | "secondary"; + /** Which node it was on, as an opaque identity the host does not read. */ + readonly on: string; + }; + +/** + * One activation: what happened, and what it happened on. + * + * The target survives normalization and the keypress does not carry it, so the + * tree is handed the same value either way and a component cannot answer one + * differently from the other. + */ +export interface Activation { + readonly key: KeyPress; + /** The node the raw input named, when it named one. */ + readonly on?: string; +} + +/** The one normalized form. Nothing downstream can tell which raw input made it. */ +export function normalize(raw: RawInput): Activation | undefined { + if (raw.kind === "pointer") { + return { key: raw.button === "primary" ? { key: "Enter" } : { key: "Escape" }, on: raw.on }; + } + const [first, ...rest] = raw.bytes; + if (first === 13 || first === 10) { + return { key: { key: "Enter" } }; + } + if (first === 27 && rest.length === 0) { + return { key: { key: "Escape" } }; + } + if (first === 9) { + return { key: { key: "Tab" } }; + } + return undefined; +} + +/** The node one opaque identity names, searched in the tree that exists now. */ +function held(root: Node, id: string): Node | undefined { + if (root.id === id) { + return root; + } + for (const child of root.children) { + const found = held(child, id); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +export interface Host { + /** Show whatever these descriptions describe, mounting and removing as needed. */ + show(descriptions: readonly Description[]): Operation>; + /** Advance the clock, completing once every branch has applied the frame. */ + advance(timestamp: number): Operation; + /** Deliver one raw input to whatever currently has focus. */ + deliver(raw: RawInput): Delivery; + /** The bytes this viewport receives, drawn from the mounted tree. */ + draw(): string; + /** Swap the renderer. Nothing about the mounted tree changes. */ + use(renderer: Renderer): void; + /** Re-measure. Nothing about which components exist changes. */ + resize(viewport: Viewport): void; + readonly viewport: Viewport; + readonly renderer: Renderer; +} + +export interface HostOptions { + readonly root: Root; + readonly clock: FrameClock; + readonly renderer: Renderer; + readonly viewport: Viewport; +} + +export function createHost(options: HostOptions): Host { + const { root, clock } = options; + let renderer = options.renderer; + let viewport = options.viewport; + + return { + get viewport(): Viewport { + return viewport; + }, + get renderer(): Renderer { + return renderer; + }, + + show: (descriptions: readonly Description[]) => compose(root.node, descriptions, clock), + + advance: (timestamp: number) => clock.advance(timestamp), + + deliver(raw: RawInput): Delivery { + const nothing: Delivery = { target: "", path: [], action: undefined }; + const activation = normalize(raw); + if (activation === undefined) { + return nothing; + } + if (activation.on === undefined) { + return press(root.node, current(root.node), activation.key); + } + const on = held(root.node, activation.on); + if (on === undefined || !focusTargets(root.node).includes(on)) { + // Removed, a container, drawn but never made focusable, or nothing at + // all: none of those is a place input can go, so none of them takes + // focus on the way to finding that out. + return nothing; + } + focus(on); + return press(root.node, on, activation.key); + }, + + draw(): string { + return renderer.draw(paint(root.node), viewport.columns); + }, + + use(next: Renderer): void { + renderer = next; + }, + + resize(next: Viewport): void { + viewport = next; + }, + }; +} + +/** Tear the mounted tree down, which is the only thing a host owns of it. */ +export function* close(root: Root): Operation { + yield* until(root.destroy()); +} + +/** Whichever node has focus right now, derived from the tree. */ +export function focused(root: Root): Node { + return current(root.node); +} diff --git a/scripts/repl-compose/input.ts b/scripts/repl-compose/input.ts new file mode 100644 index 000000000..436e98b93 --- /dev/null +++ b/scripts/repl-compose/input.ts @@ -0,0 +1,119 @@ +/** + * A key goes to the node that has focus, and an action comes back up. + * + * The key is invoked on the focused node's scope, so Effection walks that + * scope's ancestors and every branch between the root and the control runs its + * middleware. Ancestors run outermost first, which is why the recorded path + * reads from the root down to the target. + * + * The *answer* travels the other way. Each branch lets the levels below it + * answer first and claims the key only if none did, so the innermost component + * that understands a key decides what it means and an ancestor is a fallback + * rather than an interceptor. What comes back is a typed semantic action, not + * the keystroke — which is what lets a pointer and a key produce the same + * action without the component knowing which arrived. + * + * A branch that is not mounted has no scope, so it has no middleware on this + * path at all. That is the whole of why a closed drawer cannot receive input: + * not a check, an absence. + */ + +import { createContext } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; + +/** One key, normalized by the host before it reaches the tree. */ +export interface KeyPress { + readonly key: string; +} + +/** What a key meant, said semantically. */ +export interface Action { + readonly kind: string; + /** The key of the component that claimed it. */ + readonly from: string; +} + +/** The branches one delivery passed through, outermost first. */ +const PathContext = createContext("xmd:repl-compose:key-path"); + +/** + * One key, delivered to a node. + * + * The default answers nothing: a key no mounted branch claimed produced no + * action, which is a different outcome from a branch claiming it and deciding + * it means nothing. + */ +export const KeysApi = createApi("xmd:repl-compose:keys", { + press(node: Node, key: KeyPress): Action | undefined { + void node; + void key; + return undefined; + }, +}); + +/** + * Put one mounted branch on the input path. + * + * `claim` is read at delivery rather than captured, so a branch reconciled with + * new input answers from that input. + */ +export function installBranch( + node: Node, + name: string, + claim: (key: KeyPress) => Action | undefined, +): void { + node.scope.around(KeysApi, { + press([target, key], next): Action | undefined { + node.scope.get(PathContext)?.push(name); + return next(target, key) ?? claim(key); + }, + }); +} + +export interface Delivery { + /** The node the key was delivered to. */ + readonly target: string; + /** The branches it passed through, outermost first. */ + readonly path: readonly string[]; + /** What it meant, when a mounted branch claimed it. */ + readonly action: Action | undefined; +} + +/** Whether the tree rooted at `root` still holds `target`. */ +function attached(root: Node, target: Node): boolean { + if (root === target) { + return true; + } + for (const child of root.children) { + if (attached(child, target)) { + return true; + } + } + return false; +} + +/** + * Send one key to a node of the tree. + * + * Delivery addresses a *position in the tree*, never a reference somebody kept. + * That distinction is load-bearing: removing a node detaches it from its parent + * but leaves the node object, and a disposed Effection scope still carries the + * interceptors that were installed on it — so a retained reference to a closed + * drawer's control would otherwise still run that drawer's middleware and + * answer with an action. A node the tree no longer holds is not a place input + * can go, and this says so rather than discovering it later. + * + * The path is collected on the root's scope rather than returned by the + * middleware, because middleware that had to return the path could not also use + * its return value for the action. + */ +export function press(root: Node, target: Node, key: KeyPress): Delivery { + if (!attached(root, target)) { + return { target: target.name, path: [], action: undefined }; + } + const path: string[] = []; + root.scope.set(PathContext, path); + const action = KeysApi.invoke(target.scope, "press", [target, key]); + return { target: target.name, path, action: action ?? undefined }; +} diff --git a/scripts/repl-compose/main.ts b/scripts/repl-compose/main.ts new file mode 100644 index 000000000..f433932b0 --- /dev/null +++ b/scripts/repl-compose/main.ts @@ -0,0 +1,117 @@ +/** + * The documented command. + * + * deno task repl:compose --journey + * deno task repl:compose --trace 'xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect' + * + * `--journey` runs the representative journey with nobody watching: it walks a + * sequence of locations, advances the clock between them, and prints what the + * mounted tree drew at each one. `--trace` follows a single location through + * every layer and prints the seven things #840 asks for. + * + * Neither attaches a terminal. The host here measures a viewport from flags + * rather than from a device, which is the point: the host boundary is the same + * either way, and none of what it drives knows the difference. + */ + +import { main } from "effection"; +import type { Operation } from "effection"; + +import { useRoot } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import { useFrameClock } from "./frames.ts"; +import { EXECUTION, projectModel } from "./history.ts"; +import { createHost } from "./host.ts"; +import { framedRenderer, plainRenderer } from "./render.ts"; +import { decodeRoute, resolveRoute } from "./router.ts"; +import { describeScreen } from "./screen.ts"; +import type { SessionSnapshot, Viewport } from "./screen.ts"; +import { printTrace, traceLocation } from "./trace.ts"; + +/** The journey: open a drawer, stack one on it, close it, and go nowhere real. */ +const JOURNEY: readonly { readonly at: string; readonly url: string }[] = [ + { at: "the entry, no drawer", url: "xmd://repl/e1/transcript/entry-1/document?at=cp-10&inspect" }, + { + at: "the project drawer", + url: "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-10&inspect", + }, + { + at: "confirm stacked on it", + url: "xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect", + }, + { + at: "the top drawer closed", + url: "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-10&inspect", + }, + { + at: "a location the execution never went to", + url: "xmd://repl/e1/transcript/entry-1/document/+review?at=cp-10&inspect", + }, +]; + +const SESSION: SessionSnapshot = { scroll: {} }; +const WIDE: Viewport = { columns: 120, rows: 30 }; +const NARROW: Viewport = { columns: 72, rows: 20 }; + +function* run(argv: readonly string[]): Operation { + const model = projectModel(EXECUTION); + const root = yield* useRoot(); + const clock = yield* useFrameClock(); + const narrow = argv.includes("--narrow"); + const host = createHost({ + root, + clock, + renderer: argv.includes("--framed") ? framedRenderer : plainRenderer, + viewport: narrow ? NARROW : WIDE, + }); + + const traceAt = argv.indexOf("--trace"); + if (traceAt !== -1) { + const url = argv[traceAt + 1]; + if (url === undefined) { + console.error("--trace needs the location to follow"); + return; + } + const trace = yield* traceLocation( + url, + "xmd://repl/e1/transcript/entry-1/document?at=cp-10&inspect", + model, + host, + root, + clock, + SESSION, + host.viewport, + ); + for (const line of printTrace(trace)) { + console.log(line); + } + return; + } + + let elapsed = 0; + for (const step of JOURNEY) { + const decoded = decodeRoute(step.url); + const outcome = decoded.ok ? resolveRoute(decoded.value, model) : decoded; + const shown = yield* host.show(describeScreen(outcome, SESSION, host.viewport)); + if (!shown.ok) { + console.error(`refused to compose: ${shown.error.message}`); + return; + } + elapsed += 16; + yield* host.advance(elapsed); + + console.log(`— ${step.at} —`); + console.log(host.draw()); + console.log(`frames wanted: ${clock.demand}`); + console.log(""); + } + + // The renderer is replaced while the same tree stays mounted. + host.use(framedRenderer); + console.log("— the same tree, another renderer —"); + console.log(host.draw()); +} + +if (import.meta.main) { + await main(() => run(Deno.args)); +} diff --git a/scripts/repl-compose/model.ts b/scripts/repl-compose/model.ts new file mode 100644 index 000000000..da755542b --- /dev/null +++ b/scripts/repl-compose/model.ts @@ -0,0 +1,110 @@ +/** + * What the execution recorded, said as immutable values. + * + * This is where history-record access ends. Nothing downstream of here — + * routing, composition, layout or rendering — reads a history record, which is + * why the projection in `history.ts` is the only module that imports one. The + * durable Journal those records are read from sits above this experiment + * entirely and is not represented here. A consumer + * that wants to know what was open at a moment asks a `Checkpoint`, and a + * consumer that wants to know where the execution got to asks for the head. + * + * Every value here is frozen, and frozen deeply. A model handed to a router, a + * component or a renderer is a value those layers may read and may not edit, so + * the freeze is the contract rather than a convention: assigning through one + * throws in a module, which is what the evidence checks. + */ + +/** + * One durable wait, and the scope that owns it. + * + * Ownership is the entry *and* the path inside it, because a scope path alone + * is only meaningful within one entry: two entries can each run a `document` + * scope, and a wait belonging to one of them is not a drawer the other can + * open. `scope` is that path, outermost first, in the same spelling a route + * segment uses; an empty path means the entry's own body owns the wait. + */ +export interface Suspension { + readonly kind: string; + readonly entry: string; + readonly scope: readonly string[]; + readonly prompt: string; +} + +/** + * One scope the execution entered, and the scopes it opened inside itself. + * + * A scope that exited stays in the tree and becomes `settled`. An entry's + * transcript keeps its whole structure — leaving a scope closes it, it does not + * erase it — so a settled scope is still a place a URL can name, which is how + * bindings a finished scope published stay reachable. + */ +export interface Scope { + readonly name: string; + readonly settled: boolean; + readonly children: readonly Scope[]; +} + +/** + * One transcript entry, which owns its scope tree. + * + * Entries in a session are sequential: one settles before the next is + * submitted, so at most one is unsettled at any recorded moment. + */ +export interface Entry { + readonly id: string; + readonly title: string; + readonly settled: boolean; + readonly scopes: readonly Scope[]; +} + +/** + * The complete recorded moment at one history boundary. + * + * A checkpoint is self-contained on purpose: reconstructing the moment it names + * needs nothing but this value. That is what lets the same URL resolve against + * one checkpoint and refuse against another, and it is why the entries here are + * this checkpoint's own snapshot rather than a reference to a mutable head. + */ +export interface Checkpoint { + readonly marker: string; + readonly at: number; + readonly entries: readonly Entry[]; + /** The nested suspension stack, outermost first. The last one is the top. */ + readonly suspensions: readonly Suspension[]; +} + +/** One execution, its recorded checkpoints, and where its head reached. */ +export interface ReplModel { + readonly execution: string; + /** The marker of the newest recorded checkpoint. */ + readonly head: string; + readonly checkpoints: readonly Checkpoint[]; +} + +/** The checkpoint one marker names, or `undefined` when nothing recorded it. */ +export function checkpointAt(model: ReplModel, marker: string): Checkpoint | undefined { + return model.checkpoints.find((checkpoint) => checkpoint.marker === marker); +} + +/** The head checkpoint, which is the newest moment the execution recorded. */ +export function headCheckpoint(model: ReplModel): Checkpoint | undefined { + return checkpointAt(model, model.head); +} + +/** + * `value`, frozen through every array and object it reaches. + * + * The projection builds each checkpoint by copying, so nothing here is shared + * with a later moment and freezing one cannot freeze a value another moment is + * still assembling. + */ +export function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + for (const member of Object.values(value)) { + deepFreeze(member); + } + return Object.freeze(value); +} diff --git a/scripts/repl-compose/reconcile.ts b/scripts/repl-compose/reconcile.ts new file mode 100644 index 000000000..d8b0920ab --- /dev/null +++ b/scripts/repl-compose/reconcile.ts @@ -0,0 +1,395 @@ +/** + * Descriptions in, one mounted Freedom tree out. + * + * Reconciliation is the only thing that mounts anything, and Freedom is the + * only thing it mounts into. There is no second tree, no ownership map and no + * collection of live components: what exists is exactly the nodes reconciliation + * created and has not removed, and every question about the interface — what is + * drawn, what can be focused, where a key goes, who is asking for frames — is + * answered by walking those nodes. + * + * Matching is by key, the way Crank matches keyed children. A description whose + * key and component both match the node already there keeps that node, and with + * it the node's Effection scope and everything the branch's lifecycle is holding + * inside it. A description that names a different component at the same key is a + * different child, so the old one is unmounted first. A key that stops being + * described is removed with its whole subtree, and removal is awaited rather + * than started, because a branch that is merely on its way out is still there. + * + * A retained branch is *told* what changed rather than rebuilt. Its new input + * goes down the same parent-child boundary the first one did, and the delivery + * completes only once the branch has taken it — so when a reconcile returns, + * every branch it kept is acting on the input it was just given, not the one + * before. + * + * Nothing is mutated until the whole description tree has been checked. A key + * has to be unique among one parent's direct children, because two branches + * answering to one key is a tree that cannot be addressed: the second shadows + * the first, and the first is then unreachable by the only name anything has + * for it — never matched again, never removed, mounted for as long as its + * parent lives. + */ + +import { Err, Ok, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { current, focus, focusable } from "../repl-study/vendor/freedom/upstream/index.ts"; +import { createNodeData } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import type { ComponentIdentity, Description } from "./component.ts"; +import type { Frames } from "./frames.ts"; +import { installBranch } from "./input.ts"; + +/** The key its parent described this node by. */ +const KeyOf = createNodeData("xmd:repl-compose:key"); + +/** The component this node is mounted for, compared by reference. */ +const IdentityOf = createNodeData("xmd:repl-compose:identity"); + +/** The description this node is currently reconciled to. */ +const DescriptionOf = createNodeData("xmd:repl-compose:description"); + +/** Two of one parent's direct children answering to one key. */ +export class DuplicateKey extends Error { + readonly key: string; + /** The component whose children collided, by name. */ + readonly parent: string; + + constructor(parent: string, key: string) { + super(`${parent} describes two children keyed ${JSON.stringify(key)}; a key names one child`); + this.name = "DuplicateKey"; + this.key = key; + this.parent = parent; + } +} + +/** + * Two branches in unrelated subtrees both asking to hold focus. + * + * A location names one place. Claims that lie on one ancestry describe the same + * place at different depths, which is a drawer inside the surface it opened + * over; claims that do not describe two, and there is no answer to give. + */ +export class AmbiguousFocus extends Error { + /** The two claims that are not in each other's ancestry. */ + readonly claims: readonly [string, string]; + + constructor(first: string, second: string) { + super( + `${JSON.stringify(first)} and ${JSON.stringify(second)} both ask to hold focus, and neither contains the other`, + ); + this.name = "AmbiguousFocus"; + this.claims = [first, second]; + } +} + +/** The key one mounted node was described by, when it was described at all. */ +export function keyOf(node: Node): string | undefined { + return node.data.get(KeyOf); +} + +/** One checked description, with its children already checked too. */ +interface Planned { + readonly description: Description; + readonly children: readonly Planned[]; +} + +function plan(parent: string, descriptions: readonly Description[]): Result { + const seen = new Set(); + const planned: Planned[] = []; + for (const description of descriptions) { + if (seen.has(description.key)) { + return Err(new DuplicateKey(parent, description.key)); + } + seen.add(description.key); + const children = plan(description.key, description.children()); + if (!children.ok) { + return children; + } + planned.push({ description, children: children.value }); + } + return Ok(planned); +} + +/** + * Reconcile one root's children to `descriptions`. + * + * The whole description tree is checked before a single node is created, + * removed or handed new input, so a refusal leaves the mounted tree exactly as + * it was. It returns once every branch it mounted has said its local state + * exists and every branch it kept has taken its new input, so what the caller + * then observes is the whole tree and not a half-built one. + */ +export function* compose( + root: Node, + descriptions: readonly Description[], + frames: Frames, +): Operation> { + const planned = plan(root.name === "" ? "the root" : root.name, descriptions); + if (!planned.ok) { + return planned; + } + const focus = claimedBy(planned.value); + if (!focus.ok) { + return focus; + } + const mounted: Operation[] = []; + yield* reconcile(root, planned.value, frames, mounted); + for (const ready of mounted) { + yield* ready; + } + establishFocus(root); + return Ok(); +} + +/** + * Put focus inside the one branch the location is asking for. + * + * A description may say that its branch is the one the location wants. Those + * claims have to describe *one* place, so they are required to lie on a single + * ancestry: a branch may claim inside a branch that also claims, and the + * deepest one wins, but two claims in unrelated subtrees name two places at + * once and are refused before a single node is touched. + * + * That is the difference between structure and rendering order. An earlier + * version flattened every claimant in tree order and took the last, so moving + * an unrelated sibling moved focus. Here the active branch is found by + * descending — at each level at most one subtree can hold a claim, which is + * what the preflight guarantees — so no sibling's position is part of the + * answer. + * + * The active branch is also the *only* place focus can be. Everything outside + * it stops being a focus target for as long as it is covered: a drawer under + * another drawer, and the surfaces behind them, remain mounted and keep + * drawing and keep their lifecycles, and none of them can be reached by Tab or + * by a pointer. Input still travels their scopes, so a key the top drawer does + * not claim still bubbles out through them. + */ +function establishFocus(root: Node): void { + const active = activeBranch(root); + const branch = active ?? root; + // A drawer says focus belongs to it *alone*, so nothing outside it can be + // reached. A surface says only where focus starts and leaves the rest of the + // interface reachable — trapping traversal inside one region would be a + // different product, and moving focus across a region boundary is what moves + // the URL in the first place. + const alone = active !== undefined && active.data.get(DescriptionOf)?.claimsFocus() === "alone"; + reachable(root, branch, !alone || root === branch); + + const within = focusTargets(branch); + const anywhere = focusTargets(root); + const settled = current(root); + + if (within.length === 0) { + if (anywhere.length > 0 && !anywhere.includes(settled)) { + focus(anywhere[0]); + } + return; + } + + // Focus moves when it is not already inside the branch being asked for: on a + // cold mount, when the location names somewhere else, and when the branch + // that held it was removed. Focus that is already there stays where the + // person put it. + if (!within.includes(settled)) { + focus(within[0]); + } +} + +/** + * The deepest branch asking for focus, found by descending rather than sorting. + * + * At most one child subtree can hold a claim, so which child is visited first + * cannot change the answer. + */ +function activeBranch(node: Node): Node | undefined { + let deeper: Node | undefined; + for (const child of node.children) { + deeper = activeBranch(child) ?? deeper; + } + if (deeper !== undefined) { + return deeper; + } + return node.data.get(DescriptionOf)?.claimsFocus() === "none" ? undefined : node; +} + +/** Make exactly the focusable nodes inside the active branch reachable. */ +function reachable(node: Node, active: Node, inside: boolean): void { + const within = inside || node === active; + const description = node.data.get(DescriptionOf); + if (description?.focusable === true) { + if (within) { + focusable(node); + } else if ("focused" in node.props) { + node.unset("focused"); + } + } + for (const child of node.children) { + reachable(child, active, within); + } +} + +/** + * The deepest claim in a described forest, or the reason there is no one claim. + * + * Checked over the whole description tree before anything is created, removed + * or updated, so a location that names two places at once changes nothing at + * all rather than mounting half of itself and then discovering the problem. + */ +function claimedBy(planned: readonly Planned[]): Result { + let found: Description | undefined; + for (const { description, children } of planned) { + const deeper = claimedBy(children); + if (!deeper.ok) { + return deeper; + } + // A claim inside a claiming branch is the same place, deeper. A claim + // beside one is a different place. + const here = deeper.value ?? (description.claimsFocus() === "none" ? undefined : description); + if (here !== undefined) { + if (found !== undefined) { + return Err(new AmbiguousFocus(found.key, here.key)); + } + found = here; + } + } + return Ok(found); +} + +function* reconcile( + parent: Node, + planned: readonly Planned[], + frames: Frames, + mounted: Operation[], +): Operation { + const existing = new Map(); + for (const child of parent.children) { + const key = child.data.get(KeyOf); + if (key !== undefined) { + existing.set(key, child); + } + } + + const described = new Set(); + for (const [order, { description, children }] of planned.entries()) { + described.add(description.key); + const found = existing.get(description.key); + if (found !== undefined && found.data.get(IdentityOf) === description.identity) { + // The same child. It keeps its node, so it keeps its scope, so it keeps + // whatever its lifecycle is holding — and is told what changed. + found.data.set(DescriptionOf, description); + found.set("order", order); + // The identity above matched, so this description was made by the very + // component whose branch is mounted here — and `update` reaches that + // component's own typed channel on this node. The reconciler carries no + // input of its own and could not substitute one. A branch that never + // subscribed for updates has no receiver, and this returns at once. + yield* description.update(found); + yield* reconcile(found, children, frames, mounted); + continue; + } + if (found !== undefined) { + // Same key, different component: a different child, and the old one goes + // before the new one arrives. + yield* until(found.remove()); + } + const child = mount(parent, description, order, frames, mounted); + yield* reconcile(child, children, frames, mounted); + } + + for (const [key, child] of existing) { + if (!described.has(key)) { + yield* until(child.remove()); + } + } + + parent.sort(byOrder); +} + +function mount( + parent: Node, + description: Description, + order: number, + frames: Frames, + mounted: Operation[], +): Node { + const child = parent.createChild(description.name); + child.data.set(KeyOf, description.key); + child.data.set(IdentityOf, description.identity); + child.data.set(DescriptionOf, description); + child.set("key", description.key); + child.set("order", order); + + if (description.focusable) { + focusable(child); + } + + // Read the description from the node rather than closing over this one, so a + // branch reconciled to new input answers keys from that input. + installBranch(child, description.key, (key) => child.data.get(DescriptionOf)?.onPress(key)); + + const gate = withResolvers(); + const body = description.start(child, frames, function* ready() { + gate.resolve(); + }); + if (body === undefined) { + gate.resolve(); + } else { + child.scope.run(function* () { + try { + yield* body; + } finally { + // A lifecycle that returned or was halted without readying releases the + // gate here, so a forgotten `ready()` is a branch nothing waited for + // rather than a mount that never finishes. + gate.resolve(); + } + }); + } + mounted.push(gate.operation); + return child; +} + +function byOrder(left: Node, right: Node): number { + return Number(left.props.order ?? 0) - Number(right.props.order ?? 0); +} + +/** + * What the interface draws, walked out of the same tree that owns it. + * + * A parent wraps what its children drew, so presentation is a parent-to-child + * decision over the mounted tree and a node that is not there contributes + * nothing — not an empty string, nothing. + */ +export function paint(node: Node): readonly string[] { + const children: string[] = []; + for (const child of node.children) { + children.push(...paint(child)); + } + return node.data.get(DescriptionOf)?.present(children) ?? children; +} + +/** Every focus target in the tree, in tree order, derived rather than remembered. */ +export function focusTargets(node: Node): readonly Node[] { + const found: Node[] = []; + if ("focused" in node.props) { + found.push(node); + } + for (const child of node.children) { + found.push(...focusTargets(child)); + } + return found; +} + +/** Every mounted branch's key, in tree order. */ +export function topology(node: Node): readonly string[] { + const found: string[] = []; + const key = node.data.get(KeyOf); + if (key !== undefined) { + found.push(key); + } + for (const child of node.children) { + found.push(...topology(child)); + } + return found; +} diff --git a/scripts/repl-compose/render.ts b/scripts/repl-compose/render.ts new file mode 100644 index 000000000..650b4cce7 --- /dev/null +++ b/scripts/repl-compose/render.ts @@ -0,0 +1,42 @@ +/** + * Cells, and nothing else. + * + * A renderer is handed the lines the mounted tree drew and a viewport, and it + * answers bytes. It holds no state about the application, knows no component, + * and is replaceable while a run is going: swapping one for another changes + * what the terminal receives and changes nothing about where you are, what is + * mounted, or how far a drawer has opened — because none of that is here. + * + * Two are provided, which is one more than the experiment needs and exactly + * enough to show the seam is real. A single renderer is not a boundary; it is + * an implementation with a hopeful name. + */ + +export interface Renderer { + readonly name: string; + /** The lines the tree drew, as the bytes a terminal of this size receives. */ + draw(lines: readonly string[], columns: number): string; +} + +/** The lines as they are, clipped to the width. */ +export const plainRenderer: Renderer = { + name: "plain", + draw(lines, columns) { + return lines.map((line) => clip(line, columns)).join("\n"); + }, +}; + +/** The same lines inside a rule, to show the seam changes bytes and nothing else. */ +export const framedRenderer: Renderer = { + name: "framed", + draw(lines, columns) { + const width = Math.max(2, columns); + const rule = "─".repeat(width - 2); + const body = lines.map((line) => `│${clip(line, width - 2)}`); + return [`┌${rule}`, ...body, `└${rule}`].join("\n"); + }, +}; + +function clip(line: string, columns: number): string { + return line.length <= columns ? line : `${line.slice(0, Math.max(0, columns - 1))}…`; +} diff --git a/scripts/repl-compose/router.ts b/scripts/repl-compose/router.ts new file mode 100644 index 000000000..3f65b0b13 --- /dev/null +++ b/scripts/repl-compose/router.ts @@ -0,0 +1,612 @@ +/** + * A URL in, a location or a refusal out. + * + * The router is three pure functions and no state. It holds no current route, + * no history, no subscription and no callback, and it mounts nothing: the URL + * *is* the location, so asking where you are means handing the router a URL + * again. That is what lets the same question be asked of two different models + * and get two different answers without either input moving. + * + * Revolution is the model for the control flow. `route()` there matches a + * request and hands the result to whatever comes next; it owns no ambient + * navigation state and knows nothing about rendering. The two halves are kept + * apart the same way here, with one difference that matters: matching a path + * and resolving it are separate steps. `decodeRoute()` decides whether a URL is + * *spelled* like a location, and `resolveRoute()` decides whether the execution + * ever went there. A router that conflated them would happily describe a scope + * nothing entered. + * + * xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect + * + * Encoding is canonical and decoding is structural. One location has one + * spelling, and `encodeRoute()` is what gives it — so a URL that means the + * right thing but is written another way is accepted and answered with the + * structure it names, leaving the canonical spelling to the encoder. Reordered + * query parameters and over-percent-encoded segments are equivalent spellings, + * not errors. What decoding refuses is a URL that is malformed or that names + * two locations at once: an unknown query key, a repeated one, a value on the + * valueless `inspect`, an empty `at=`, `inspect` without the marker it + * reconstructs, an empty path segment, a scope written below a drawer, and a + * drawer written outside any entry. + * + * This module imports `effection` and the model's own types. It reaches no + * history record, no renderer, no Freedom node, no layout and no host, and the + * evidence holds it to that by reading its imports. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import { checkpointAt, headCheckpoint } from "./model.ts"; +import type { Checkpoint, Entry, ReplModel, Scope, Suspension } from "./model.ts"; + +/** + * The five regions a location can name. + * + * The surface says which region owns focus, so moving focus across a region + * boundary moves the URL with it. + */ +export const ROUTE_SURFACES = ["sessions", "transcript", "bindings", "input", "history"] as const; + +export type RouteSurface = (typeof ROUTE_SURFACES)[number]; + +export function isRouteSurface(value: string): value is RouteSurface { + return (ROUTE_SURFACES as readonly string[]).includes(value); +} + +/** + * Only this module mints a `Route`. + * + * A route is checked when it is built, and the check is worth nothing if a + * caller can write an object that satisfies the type without passing through + * it. This key is module-local, so a look-alike literal is not a `Route` and + * `encodeRoute()` never receives one. It is a construction boundary and not a + * secrecy one: a holder can read it, and reading it grants nothing. + */ +const MINTED: unique symbol = Symbol("repl-compose.route"); + +interface Minted { + readonly [MINTED]: true; +} + +/** What a location selects on the recorded timeline, and what is typed. */ +export interface RouteSelection { + /** The recorded marker the scrubber has selected. Absent means the head. */ + readonly at?: string; + /** True while the reconstruction at `at` is open rather than merely selected. */ + readonly inspect: boolean; + /** What has been typed and not run. Empty is the same as nothing typed. */ + readonly draft: string; +} + +/** A location that names a region of the REPL and nothing inside an entry. */ +export interface SurfaceRoute extends Minted, RouteSelection { + readonly kind: "surface"; + readonly execution: string; + readonly surface: RouteSurface; +} + +/** + * A location inside one transcript entry. + * + * The entry is separate from the scopes it owns because they are different + * kinds of thing: an entry is something you submitted, and a scope is something + * the execution opened while running it. + */ +export interface EntryRoute extends Minted, RouteSelection { + readonly kind: "entry"; + readonly execution: string; + readonly surface: RouteSurface; + readonly entry: string; + /** The scope path inside that entry, outermost first. */ + readonly scopes: readonly string[]; + /** The drawer stack, outermost first. The last one is the top. */ + readonly drawers: readonly string[]; +} + +/** + * One location. + * + * Scopes and drawers live on the arm that has an entry to own them, so a route + * carrying a scope path and no entry is not a value this type can hold — which + * is what stops `encodeRoute()` writing a scope segment that decodes back as an + * entry. + */ +export type Route = SurfaceRoute | EntryRoute; + +function named(part: string, value: string): Error | undefined { + if (value === "") { + return new RouteSyntaxError("", `a ${part} cannot be empty`); + } + return undefined; +} + +function checkSelection(selection: RouteSelection): Error | undefined { + if (selection.at !== undefined && selection.at === "") { + return new RouteSyntaxError("", "a marker cannot be empty; leave `at` out to select none"); + } + if (selection.inspect && selection.at === undefined) { + return new RouteSyntaxError("", "inspect needs the marker it reconstructs; add at="); + } + return undefined; +} + +/** A location naming a region, checked. */ +export function surfaceRoute( + parts: { execution: string; surface: RouteSurface } & RouteSelection, +): Result { + const refusal = named("execution", parts.execution) ?? checkSelection(parts); + if (refusal !== undefined) { + return Err(refusal); + } + return Ok({ + [MINTED]: true, + kind: "surface", + execution: parts.execution, + surface: parts.surface, + at: parts.at, + inspect: parts.inspect, + draft: parts.draft, + }); +} + +/** A location inside one entry, checked. */ +export function entryRoute( + parts: { + execution: string; + surface: RouteSurface; + entry: string; + scopes: readonly string[]; + drawers: readonly string[]; + } & RouteSelection, +): Result { + const refusal = + named("execution", parts.execution) ?? + named("entry", parts.entry) ?? + parts.scopes.map((scope) => named("scope", scope)).find((one) => one !== undefined) ?? + parts.drawers.map((drawer) => named("drawer", drawer)).find((one) => one !== undefined) ?? + checkSelection(parts); + if (refusal !== undefined) { + return Err(refusal); + } + return Ok({ + [MINTED]: true, + kind: "entry", + execution: parts.execution, + surface: parts.surface, + entry: parts.entry, + scopes: [...parts.scopes], + drawers: [...parts.drawers], + at: parts.at, + inspect: parts.inspect, + draft: parts.draft, + }); +} + +/** + * Where a route resolved to, as the exact model values it resolved against. + * + * Every member here is a value out of `model`, compared by identity rather than + * copied. A resolved location is not a second projection of the execution: the + * layers above it read the model through this, so there is nothing to keep in + * step with anything. + */ +export interface ResolvedLocation { + readonly route: Route; + readonly surface: RouteSurface; + readonly checkpoint: Checkpoint; + readonly entry?: Entry; + /** The scopes the path named, outermost first. The last one is selected. */ + readonly scopes: readonly Scope[]; + /** The suspensions the drawer path named, outermost first. */ + readonly drawers: readonly Suspension[]; + readonly inspecting: boolean; + readonly draft: string; +} + +/** A URL that is not spelled like a location. */ +export class RouteSyntaxError extends Error { + readonly url: string; + /** Which part of the URL was refused: `execution`, `surface`, `entry`, … */ + readonly part: string; + + constructor(url: string, message: string, part = "url") { + super(message); + this.name = "RouteSyntaxError"; + this.url = url; + this.part = part; + } +} + +/** + * A location the execution never went to. + * + * It names the first segment that did not resolve, where that segment sits, and + * what exists in its place — so a refusal says what to write instead rather + * than only that something was wrong. + */ +export class RouteRefusal extends Error { + /** Which part refused: `execution`, `at`, `entry`, `scope[n]` or `drawer[n]`. */ + readonly position: string; + /** The segment as the URL wrote it. */ + readonly segment: string; + /** What the model has in that place. */ + readonly found: readonly string[]; + + constructor(position: string, segment: string, found: readonly string[], message: string) { + super(message); + this.name = "RouteRefusal"; + this.position = position; + this.segment = segment; + this.found = found; + } +} + +/** The authority is `repl`, because this URL addresses a REPL and not a document. */ +const PREFIX = "xmd://repl/"; + +/** A drawer segment wears this, so a drawer is never mistaken for a scope. */ +const DRAWER_PREFIX = "+"; + +/** The query keys, in the one order a canonical URL writes them. */ +const QUERY_KEYS = ["at", "inspect", "draft"] as const; + +function list(names: readonly string[]): string { + return names.length === 0 ? "none" : names.join(", "); +} + +/** One location, in the one spelling it has. */ +export function encodeRoute(route: Route): string { + const inside = + route.kind === "entry" + ? [ + encodeURIComponent(route.entry), + ...route.scopes.map((scope) => encodeURIComponent(scope)), + ...route.drawers.map((drawer) => `${DRAWER_PREFIX}${encodeURIComponent(drawer)}`), + ] + : []; + const path = [encodeURIComponent(route.execution), route.surface, ...inside].join("/"); + + const query: string[] = []; + if (route.at !== undefined) { + query.push(`at=${encodeURIComponent(route.at)}`); + } + if (route.inspect) { + // Valueless, which is the only spelling the encoder writes. + query.push("inspect"); + } + if (route.draft !== "") { + query.push(`draft=${encodeURIComponent(route.draft)}`); + } + + return query.length === 0 ? `${PREFIX}${path}` : `${PREFIX}${path}?${query.join("&")}`; +} + +/** + * One percent-encoded piece of a URL, decoded, or `undefined` when it is not + * valid percent-encoding. + * + * `decodeURIComponent` throws on a lone `%`, and a URL is untrusted text, so + * every decode in this module goes through here. A syntax failure that escaped + * as a thrown `URIError` would leave `decodeRoute()`'s `Result` a + * promise it does not keep. + */ +function percentDecode(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +function malformed(url: string, part: string, value: string): RouteSyntaxError { + return new RouteSyntaxError( + url, + `the ${part} ${JSON.stringify(value)} is not valid percent-encoding`, + part, + ); +} + +function decodeQuery(url: string, query: string): Result { + let at: string | undefined; + let inspect = false; + let draft = ""; + const seen: string[] = []; + + for (const pair of query === "" ? [] : query.split("&")) { + const equals = pair.indexOf("="); + const key = equals === -1 ? pair : pair.slice(0, equals); + const written = equals === -1 ? "" : pair.slice(equals + 1); + if (!(QUERY_KEYS as readonly string[]).includes(key)) { + return Err( + new RouteSyntaxError(url, `${JSON.stringify(key)} is not part of a REPL route`, "query"), + ); + } + if (seen.includes(key)) { + return Err( + new RouteSyntaxError(url, `${key} is written twice, and names two locations`, key), + ); + } + seen.push(key); + const value = percentDecode(written); + if (value === undefined) { + return Err(malformed(url, key, written)); + } + if (key === "at") { + if (value === "") { + return Err( + new RouteSyntaxError(url, "at= names no marker; leave it out to select none", "at"), + ); + } + at = value; + } + if (key === "inspect") { + if (equals !== -1) { + return Err( + new RouteSyntaxError( + url, + "inspect takes no value; it is present or it is not", + "inspect", + ), + ); + } + inspect = true; + } + if (key === "draft") { + // An empty draft is the same as no draft, so `draft=` is an equivalent + // spelling rather than a malformed one. The encoder leaves it out. + draft = value; + } + } + + if (inspect && at === undefined) { + return Err( + new RouteSyntaxError( + url, + "inspect needs the marker it reconstructs; add at=", + "inspect", + ), + ); + } + + return Ok({ at, inspect, draft }); +} + +/** + * One URL, parsed, or the reason it was refused. + * + * The answer is the structure the URL names, not the bytes it was written with. + * Two spellings of one location decode to the same `Route`, and + * `encodeRoute()` gives that route the one spelling it is stored and generated + * with — so canonicalizing is `encodeRoute(decodeRoute(url))` rather than a + * rule that turns an equivalent spelling away. + * + * Percent-decoding is `decodeURIComponent` alone: `+` is a literal plus here, + * which is what lets a drawer segment wear one. Text it cannot decode is a + * refusal naming the part it came from, never a throw. + */ +export function decodeRoute(url: string): Result { + if (!url.startsWith(PREFIX)) { + return Err( + new RouteSyntaxError( + url, + `a REPL route starts with ${PREFIX}, and ${JSON.stringify(url)} does not`, + ), + ); + } + + const rest = url.slice(PREFIX.length); + const split = rest.indexOf("?"); + const path = split === -1 ? rest : rest.slice(0, split); + const segments = path.split("/"); + if (segments.length < 2) { + return Err(new RouteSyntaxError(url, `${JSON.stringify(url)} names no surface`)); + } + + const execution = percentDecode(segments[0]); + if (execution === undefined) { + return Err(malformed(url, "execution", segments[0])); + } + if (execution === "") { + return Err(new RouteSyntaxError(url, `${JSON.stringify(url)} names no execution`, "execution")); + } + + const surface = segments[1]; + if (!isRouteSurface(surface)) { + return Err( + new RouteSyntaxError( + url, + `${JSON.stringify(surface)} is not a surface; the surfaces are ${list([...ROUTE_SURFACES])}`, + "surface", + ), + ); + } + + let entry: string | undefined; + const scopes: string[] = []; + const drawers: string[] = []; + for (const segment of segments.slice(2)) { + if (segment === "") { + return Err(new RouteSyntaxError(url, `${JSON.stringify(url)} has an empty path segment`)); + } + if (segment.startsWith(DRAWER_PREFIX)) { + const written = segment.slice(DRAWER_PREFIX.length); + const drawer = percentDecode(written); + if (drawer === undefined) { + return Err(malformed(url, "drawer", written)); + } + if (drawer === "") { + return Err(new RouteSyntaxError(url, "a drawer segment names no drawer", "drawer")); + } + drawers.push(drawer); + continue; + } + if (drawers.length > 0) { + return Err( + new RouteSyntaxError( + url, + `${JSON.stringify(segment)} is a scope below a drawer, which cannot be reopened`, + "scope", + ), + ); + } + const part = entry === undefined ? "entry" : "scope"; + const name = percentDecode(segment); + if (name === undefined) { + return Err(malformed(url, part, segment)); + } + if (entry === undefined) { + entry = name; + continue; + } + scopes.push(name); + } + if (entry === undefined && drawers.length > 0) { + return Err( + new RouteSyntaxError( + url, + `a drawer is opened inside an entry, and ${JSON.stringify(url)} names none`, + "drawer", + ), + ); + } + + const selection = decodeQuery(url, split === -1 ? "" : rest.slice(split + 1)); + if (!selection.ok) { + return selection; + } + + // Building through the same constructors any other caller uses is what keeps + // one definition of a well-formed location rather than two. + const built = + entry === undefined + ? surfaceRoute({ execution, surface, ...selection.value }) + : entryRoute({ execution, surface, entry, scopes, drawers, ...selection.value }); + if (!built.ok) { + return Err(new RouteSyntaxError(url, built.error.message, "url")); + } + return built; +} + +/** + * Where a route resolves to against one model, or the first segment it could + * not resolve. + * + * Nothing here manufactures a value from the URL. Every entry, scope, + * checkpoint and drawer in the answer came out of `model`, so a location that + * resolves is a location the execution actually reached. + */ +export function resolveRoute(route: Route, model: ReplModel): Result { + if (route.execution !== model.execution) { + return Err( + new RouteRefusal( + "execution", + route.execution, + [model.execution], + `${JSON.stringify(route.execution)} is not this execution; these records are ${JSON.stringify(model.execution)}`, + ), + ); + } + + const markers = model.checkpoints.map((checkpoint) => checkpoint.marker); + const checkpoint = route.at === undefined ? headCheckpoint(model) : checkpointAt(model, route.at); + if (checkpoint === undefined) { + const named = route.at ?? model.head; + return Err( + new RouteRefusal( + "at", + named, + markers, + `${JSON.stringify(named)} names no recorded checkpoint; the markers are ${list(markers)}`, + ), + ); + } + + if (route.kind === "surface") { + return Ok({ + route, + surface: route.surface, + checkpoint, + entry: undefined, + scopes: [], + drawers: [], + inspecting: route.inspect, + draft: route.draft, + }); + } + + const entry = checkpoint.entries.find((candidate) => candidate.id === route.entry); + if (entry === undefined) { + const ids = checkpoint.entries.map((candidate) => candidate.id); + return Err( + new RouteRefusal( + "entry", + route.entry, + ids, + `${JSON.stringify(route.entry)} is not an entry at ${checkpoint.marker}; the entries there are ${list(ids)}`, + ), + ); + } + + const scopes: Scope[] = []; + let level: readonly Scope[] = entry.scopes; + let holder = entry.id; + for (const [index, name] of route.scopes.entries()) { + const found = level.find((scope) => scope.name === name); + if (found === undefined) { + const names = level.map((scope) => scope.name); + return Err( + new RouteRefusal( + `scope[${index}]`, + name, + names, + `${JSON.stringify(name)} is not a scope of ${holder} at ${checkpoint.marker}; the scopes there are ${list(names)}`, + ), + ); + } + scopes.push(found); + level = found.children; + holder = found.name; + } + + // One entry's drawer stack, in recorded order. A checkpoint holds every + // suspension the moment was waiting on, across every entry; the drawer path + // under `entry-1` indexes entry-1's, so a wait `entry-2` owns is not a drawer + // `entry-1` can open even when both spell the kind the same way. + const stack = checkpoint.suspensions.filter((suspension) => suspension.entry === entry.id); + const open = stack.map((suspension) => suspension.kind); + for (const [index, kind] of route.drawers.entries()) { + const suspension = stack[index]; + if (suspension === undefined) { + return Err( + new RouteRefusal( + `drawer[${index}]`, + kind, + open, + `there is no drawer ${index + 1} of ${entry.id} at ${checkpoint.marker}; its suspension stack is ${list(open)}`, + ), + ); + } + if (suspension.kind !== kind) { + return Err( + new RouteRefusal( + `drawer[${index}]`, + kind, + [suspension.kind], + `${JSON.stringify(kind)} is not drawer ${index + 1} of ${entry.id} at ${checkpoint.marker}; its suspension stack is ${list(open)}`, + ), + ); + } + } + + return Ok({ + route, + surface: route.surface, + checkpoint, + entry, + scopes, + drawers: stack.slice(0, route.drawers.length), + inspecting: route.inspect, + draft: route.draft, + }); +} diff --git a/scripts/repl-compose/screen.ts b/scripts/repl-compose/screen.ts new file mode 100644 index 000000000..21dfc6734 --- /dev/null +++ b/scripts/repl-compose/screen.ts @@ -0,0 +1,346 @@ +/** + * One resolved location, described as an interface. + * + * This is where the two halves meet. A `Result`, a session + * snapshot and a viewport go in; keyed component descriptions come out. Nothing + * here mounts anything, and nothing here reads history records: every value it + * reads came out of `ReplModel` through the router, and every value it hands a + * component is one of those or a plain viewport number. + * + * **A refusal is a whole screen, not a banner.** When the location did not + * resolve, the description is the refusal and nothing else — so reconciliation + * removes whatever was mounted for the last good location, and there is no + * half-resolved interface still sitting behind it holding focus, input and + * frames. That falls out of describing rather than being arranged: a screen the + * parent does not describe is a screen that stops existing. + * + * **Layout is presentation, never existence.** The viewport reaches components + * through their input and decides how a parent arranges what its children drew. + * It decides nothing about which children there are: the same location + * describes the same tree at every width, and only `present` reads the + * viewport. The evidence holds that by composing one location at two viewports + * and comparing the topology. + */ + +import { spawn } from "effection"; +import type { Operation, Result } from "effection"; + +import { component, describe } from "./component.ts"; +import type { Component, Description } from "./component.ts"; +import type { Entry, Scope, Suspension } from "./model.ts"; +import { ROUTE_SURFACES } from "./router.ts"; +import type { ResolvedLocation, RouteSurface } from "./router.ts"; + +/** How much terminal there is. The only thing layout is allowed to read. */ +export interface Viewport { + readonly columns: number; + readonly rows: number; +} + +/** + * What the session remembers that the URL deliberately does not. + * + * Scroll offsets survive a component being unmounted and remounted, which is + * why they are here rather than in a component: the tree is derived from the + * location, and anything it owns goes when its branch does. + */ +export interface SessionSnapshot { + readonly scroll: Readonly>; +} + +/** What each surface has to say about this location. */ +function linesFor(surface: RouteSurface, location: ResolvedLocation): readonly string[] { + if (surface === "input") { + return [location.draft === "" ? "(nothing typed)" : location.draft]; + } + if (surface === "history") { + return [`${location.checkpoint.marker} @ ${location.checkpoint.at}s`]; + } + if (surface === "transcript") { + return []; + } + return [location.checkpoint.marker]; +} + +/** Side by side while there is room for it, stacked when there is not. */ +function columnsFit(viewport: Viewport): boolean { + return viewport.columns >= 100; +} + +interface ControlInput { + readonly label: string; + readonly action: string; +} + +const Control: Component = component({ + name: "control", + focusable: true, + children: () => [], + lifecycle: null, + onPress: (input, key) => + key.key === "Enter" ? { kind: input.action, from: input.label } : undefined, + present: (input) => [`[ ${input.label} ]`], +}); + +interface ScopeInput { + readonly scope: Scope; + /** The scopes below this one on the route's path, outermost first. */ + readonly below: readonly Scope[]; + readonly depth: number; +} + +const ScopeView: Component = component({ + name: "scope", + focusable: true, + lifecycle: null, + + children({ below, depth }): readonly Description[] { + const [next, ...rest] = below; + if (next === undefined) { + return []; + } + // A nested scope is a child branch, because that is what it is. + return [describe(ScopeView, next.name, { scope: next, below: rest, depth: depth + 1 })]; + }, + + present({ scope, depth }, children) { + const indent = " ".repeat(depth); + const state = scope.settled ? "settled" : "open"; + return [`${indent}${scope.name} (${state})`, ...children]; + }, +}); + +interface EntryInput { + readonly entry: Entry; + readonly scopes: readonly Scope[]; + readonly scroll: number; +} + +const EntryView: Component = component({ + name: "entry", + focusable: true, + lifecycle: null, + + children({ scopes }): readonly Description[] { + const [first, ...rest] = scopes; + if (first === undefined) { + return []; + } + return [describe(ScopeView, first.name, { scope: first, below: rest, depth: 1 })]; + }, + + present({ entry, scroll }, children) { + const settled = entry.settled ? "settled" : "running"; + return [`${entry.id} — ${entry.title} (${settled})`, ...children.slice(scroll)]; + }, +}); + +interface DrawerInput { + readonly suspension: Suspension; + /** The drawers stacked on top of this one, outermost first. */ + readonly above: readonly Suspension[]; +} + +const DrawerView: Component = component({ + name: "drawer", + focusable: true, + + children({ suspension, above }): readonly Description[] { + const [next, ...rest] = above; + const controls = [ + describe(Control, `${suspension.kind}.answer`, { + label: "Answer", + action: "suspension.answer", + }), + describe(Control, `${suspension.kind}.back`, { label: "Back", action: "drawer.close" }), + ]; + if (next === undefined) { + return controls; + } + // Stacked, not adjacent: the drawer above is a child branch of this one. + return [...controls, describe(DrawerView, next.kind, { suspension: next, above: rest })]; + }, + + *lifecycle({ node, input, frames, ready, updates }): Operation { + // Both subscriptions belong to this body's scope, and both are acquired + // before the child that reads one is spawned. + const later = yield* updates.receive(); + const clock = yield* frames.subscribe(); + let opened = 0; + let current = input; + node.set("opened", opened); + node.set("prompt", current.suspension.prompt); + + yield* spawn(function* () { + while (true) { + const at = yield* clock.next(); + opened += 1; + node.set("opened", opened); + node.set("at", at); + } + }); + + yield* ready(); + + while (true) { + current = yield* later.next(); + node.set("prompt", current.suspension.prompt); + } + }, + + // The top drawer is what the location is asking for, so it owns interaction + // while it is open: its controls are reachable and the drawer beneath it and + // the surfaces behind it are not, even though all of them stay mounted and + // keep drawing. A drawer with something stacked on it asks for nothing, + // because the one above it is asking. Nothing outside this file says the + // word "drawer" to make any of that happen. + claimsFocus: ({ above }) => (above.length === 0 ? "alone" : "none"), + + onPress({ suspension }, key) { + return key.key === "Escape" ? { kind: "drawer.close", from: suspension.kind } : undefined; + }, + + present({ suspension }, children) { + return [ + `▸ ${suspension.kind} — ${suspension.prompt}`, + ` owner: ${suspension.scope.join("/")}`, + ...children.map((line) => ` ${line}`), + ]; + }, +}); + +interface SurfaceInput { + readonly surface: RouteSurface; + /** True when this is the surface the URL names. */ + readonly named: boolean; + /** True when this surface is also where the location is asking focus to be. */ + readonly holdsFocus: boolean; + readonly lines: readonly string[]; + /** Whatever this surface shows, described by the parent that placed it. */ + readonly content: readonly Description[]; +} + +/** + * One region of the interface, and a place focus can be. + * + * Every surface a route can name has one of these, which is what makes the + * surface segment of a URL reconstructable: the branch the location names is + * the branch that asks for focus, and Freedom is what puts it there. + */ +const SurfaceView: Component = component({ + name: "surface", + focusable: true, + lifecycle: null, + children: ({ content }) => content, + claimsFocus: ({ holdsFocus }) => (holdsFocus ? "here" : "none"), + present({ surface, named, lines }, children) { + return [ + `${named ? "*" : " "} ${surface}`, + ...lines.map((line) => ` ${line}`), + ...children.map((line) => ` ${line}`), + ]; + }, +}); + +interface WorkbenchInput { + readonly location: ResolvedLocation; + readonly session: SessionSnapshot; + readonly viewport: Viewport; +} + +const Workbench: Component = component({ + name: "workbench", + focusable: false, + lifecycle: null, + + children({ location, session }): readonly Description[] { + const entry = location.entry; + const transcript = + entry === undefined + ? [] + : [ + describe(EntryView, entry.id, { + entry, + scopes: location.scopes, + scroll: session.scroll[entry.id] ?? 0, + }), + ]; + + // Every surface a route can name is a branch, so every surface a route can + // name is somewhere focus can be reconstructed to. + const described: Description[] = ROUTE_SURFACES.map((surface) => + describe(SurfaceView, surface, { + surface, + named: location.surface === surface, + // A surface asks for focus only while nothing is open over it. With a + // drawer up, the drawer is the one place the location names — two + // claims side by side would be two places, and are refused. + holdsFocus: location.surface === surface && location.drawers.length === 0, + lines: linesFor(surface, location), + content: surface === "transcript" ? transcript : [], + }), + ); + + const [first, ...above] = location.drawers; + if (first !== undefined) { + described.push(describe(DrawerView, first.kind, { suspension: first, above })); + } + + return described; + }, + + present({ location, viewport }, children) { + // The viewport decides how this parent arranges what its children drew. It + // decides nothing about which children exist — those came from the location. + const heading = `${location.checkpoint.marker}${location.inspecting ? " (inspecting)" : ""}`; + if (columnsFit(viewport)) { + return [heading, ...children]; + } + return [heading, "— narrow —", ...children.map((line) => line.trimStart())]; + }, +}); + +interface RefusalInput { + readonly message: string; +} + +const RefusalView: Component = component({ + name: "refusal", + focusable: true, + children: () => [], + lifecycle: null, + claimsFocus: () => "alone", + present: (input) => ["This location does not exist in this execution.", ` ${input.message}`], +}); + +interface ScreenInput { + readonly outcome: Result; + readonly session: SessionSnapshot; + readonly viewport: Viewport; +} + +const Screen: Component = component({ + name: "screen", + focusable: false, + lifecycle: null, + + children({ outcome, session, viewport }): readonly Description[] { + if (!outcome.ok) { + // The refusal is the screen. Nothing else is described, so nothing else + // stays mounted. + return [describe(RefusalView, "refusal", { message: outcome.error.message })]; + } + return [describe(Workbench, "workbench", { location: outcome.value, session, viewport })]; + }, + + present: (_input, children) => children, +}); + +/** The whole interface one outcome describes. */ +export function describeScreen( + outcome: Result, + session: SessionSnapshot, + viewport: Viewport, +): readonly Description[] { + return [describe(Screen, "screen", { outcome, session, viewport })]; +} diff --git a/scripts/repl-compose/shell.ts b/scripts/repl-compose/shell.ts new file mode 100644 index 000000000..a794ac261 --- /dev/null +++ b/scripts/repl-compose/shell.ts @@ -0,0 +1,156 @@ +/** + * A small interface, described rather than built. + * + * These are the components the reconciliation evidence drives: enough shape to + * have a nested drawer over some panels, and nothing more. Each one is a plain + * value with a pure `children` and an optional lifecycle, and none of them + * reaches anything but the input its parent handed it. + * + * The drawer is the interesting one. A stack of drawers is described as a + * *branch* — the second drawer is a child of the first, not a sibling — so + * closing the top one removes exactly one subtree, and closing the bottom one + * removes both. It also animates, which is how a removed branch can be shown to + * stop asking the host for frames. + */ + +import { spawn } from "effection"; +import type { Operation } from "effection"; + +import { component, describe } from "./component.ts"; +import type { Component, Description, Mounted } from "./component.ts"; + +export interface ControlInput { + readonly label: string; + /** The action pressing this control means. */ + readonly action: string; +} + +export interface PanelInput { + readonly key: string; + readonly title: string; + readonly lines: readonly string[]; +} + +export interface DrawerInput { + readonly key: string; + readonly kind: string; + readonly prompt: string; + readonly controls: readonly ControlInput[]; +} + +export interface WorkspaceInput { + readonly panels: readonly PanelInput[]; + /** The drawer stack, outermost first. Each one is mounted inside the last. */ + readonly drawers: readonly DrawerInput[]; +} + +/** One drawer, and whatever is stacked on top of it. */ +interface StackedDrawer { + readonly drawer: DrawerInput; + readonly above: readonly DrawerInput[]; +} + +export const Control: Component = component({ + name: "control", + focusable: true, + lifecycle: null, + children: () => [], + onPress(input, key) { + if (key.key === "Enter") { + return { kind: input.action, from: input.label }; + } + return undefined; + }, + present(input) { + return [`[ ${input.label} ]`]; + }, +}); + +export const Panel: Component = component({ + name: "panel", + focusable: true, + lifecycle: null, + children: () => [], + present(input, children) { + return [`${input.title}:`, ...input.lines.map((line) => ` ${line}`), ...children]; + }, +}); + +export const Drawer: Component = component({ + name: "drawer", + focusable: true, + + children({ drawer, above }): readonly Description[] { + const controls = drawer.controls.map((control) => + describe(Control, `${drawer.key}.${control.label}`, control), + ); + const [next, ...rest] = above; + if (next === undefined) { + return controls; + } + // Stacked, not adjacent: the drawer above is a child branch of this one. + return [...controls, describe(Drawer, next.key, { drawer: next, above: rest })]; + }, + + *lifecycle({ node, input, frames, ready, updates }: Mounted): Operation { + // A drawer opens over time, so it asks the host for frames — and stops + // asking when this scope ends, which is when the branch is removed. + const clock = yield* frames.subscribe(); + const later = yield* updates.receive(); + let opened = 0; + let current = input; + node.set("opened", opened); + node.set("prompt", current.drawer.prompt); + + // Both subscriptions belong to this body's scope, and are acquired before + // the child that reads one is spawned. + yield* spawn(function* () { + while (true) { + const elapsed = yield* clock.next(); + opened += 1; + node.set("opened", opened); + node.set("at", elapsed); + } + }); + + yield* ready(); + + // Its parent tells it what changed. Taking the next input is what reports + // the previous one applied, so a reconcile does not return until the prompt + // on screen is the prompt that was just described. + while (true) { + current = yield* later.next(); + node.set("prompt", current.drawer.prompt); + } + }, + + onPress({ drawer }, key) { + if (key.key === "Escape") { + return { kind: "drawer.close", from: drawer.key }; + } + return undefined; + }, + + present({ drawer }, children) { + return [`— ${drawer.kind}: ${drawer.prompt}`, ...children.map((line) => ` ${line}`)]; + }, +}); + +export const Workspace: Component = component({ + name: "workspace", + focusable: false, + lifecycle: null, + + children(input): readonly Description[] { + const panels = input.panels.map((panel) => describe(Panel, panel.key, panel)); + const [first, ...above] = input.drawers; + if (first === undefined) { + return panels; + } + return [...panels, describe(Drawer, first.key, { drawer: first, above })]; + }, + + present(_input, children) { + return children; + }, +}); diff --git a/scripts/repl-compose/trace.ts b/scripts/repl-compose/trace.ts new file mode 100644 index 000000000..671b9d919 --- /dev/null +++ b/scripts/repl-compose/trace.ts @@ -0,0 +1,217 @@ +/** + * One location, followed all the way down. + * + * #840 asks for a trace that shows the same thing at every layer rather than + * seven reports that happen to agree, so every line below is read from the one + * place that answers it: the route from the URL, the identities from the model, + * the topology and the focus chain from the mounted tree, the delivery from a + * real dispatch through that tree, and the output from a render walk of it. + * + * Nothing here is a second representation. If a line of this trace is wrong, + * what it describes is wrong. + */ + +import type { Operation } from "effection"; +import { current } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node, Root } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import type { Description } from "./component.ts"; +import type { FrameClock } from "./frames.ts"; +import type { Host } from "./host.ts"; +import type { ReplModel } from "./model.ts"; +import { focusTargets, keyOf, topology } from "./reconcile.ts"; +import { decodeRoute, encodeRoute, resolveRoute } from "./router.ts"; +import { describeScreen } from "./screen.ts"; +import type { SessionSnapshot, Viewport } from "./screen.ts"; + +export interface Trace { + /** 1. what the URL decoded to, written back canonically. */ + readonly decoded: string; + /** 2. the exact model values it resolved to, or the refusal. */ + readonly resolved: readonly string[]; + /** 3. the keyed component description, as the tree it asks for. */ + readonly described: readonly string[]; + /** 4. the mounted Freedom ancestry, and what can take focus in it. */ + readonly mounted: readonly string[]; + readonly focus: readonly string[]; + /** 5. where an activation went, and what it meant. */ + readonly delivery: readonly string[]; + /** 6. what a closing branch took with it, and what still wants frames. */ + readonly teardown: readonly string[]; + /** 7. the terminal output, drawn from that same mounted tree. */ + readonly output: readonly string[]; +} + +/** The ancestry of one node, outermost first, by the keys it was described by. */ +function ancestry(node: Node): string[] { + const path: string[] = []; + for (let at: Node | undefined = node; at; at = at.parent) { + const key = keyOf(at); + if (key !== undefined) { + path.unshift(key); + } + } + return path; +} + +/** + * Follow one URL through every layer, and report what each one said. + * + * `closing` is the URL the same run moves to afterwards, so the teardown line + * is a real branch removal rather than a description of one. + */ +export function* traceLocation( + url: string, + closing: string, + model: ReplModel, + host: Host, + root: Root, + clock: FrameClock, + session: SessionSnapshot, + viewport: Viewport, +): Operation { + const decoded = decodeRoute(url); + if (!decoded.ok) { + return { + decoded: `refused: ${decoded.error.message}`, + resolved: [], + described: [], + mounted: [], + focus: [], + delivery: [], + teardown: [], + output: [], + }; + } + + const outcome = resolveRoute(decoded.value, model); + const resolved = outcome.ok + ? [ + `checkpoint ${outcome.value.checkpoint.marker} @ ${outcome.value.checkpoint.at}s`, + `entry ${outcome.value.entry?.id ?? "none"}`, + `scopes ${outcome.value.scopes.map((scope) => scope.name).join("/") || "none"}`, + `drawers ${outcome.value.drawers.map((one) => `${one.entry}:${one.kind}`).join(" → ") || "none"}`, + `identities ${outcome.value.checkpoint === modelCheckpoint(model, outcome.value.checkpoint.marker) ? "are the model's own values" : "were copied"}`, + ] + : [`refused at ${refusalPosition(outcome.error)}: ${outcome.error.message}`]; + + const descriptions = describeScreen(outcome, session, viewport); + const described = descriptionTree(descriptions, 0); + + const shown = yield* host.show(descriptions); + if (!shown.ok) { + return { + decoded: encodeRoute(decoded.value), + resolved, + described, + mounted: [`refused: ${shown.error.message}`], + focus: [], + delivery: [], + teardown: [], + output: [], + }; + } + + yield* host.advance(16); + + // Where the URL put focus, before anything is interacted with. + const reconstructed = ancestry(current(root.node)); + + // Then one explicit, generic interaction to reach a control: a pointer on it. + // The host resolves that target against the live tree and moves focus there, + // which is the same path a real click takes — nothing here calls `focus()`. + const targets = focusTargets(root.node); + const innermost = targets[targets.length - 1]; + const pointed = + innermost === undefined + ? { target: "", path: [], action: undefined } + : host.deliver({ kind: "pointer", button: "primary", on: innermost.id }); + + // And the same activation by keyboard, now that focus is there. + const typed = host.deliver({ kind: "bytes", bytes: Uint8Array.from([13]) }); + + const delivery = [ + `focus reconstructed from the URL at ${reconstructed.join(" › ")}`, + `pointer target ${innermost === undefined ? "none" : ancestry(innermost).join(" › ")}`, + `pointer path ${pointed.path.join(" › ")}`, + `pointer action ${pointed.action?.kind ?? "none"}`, + `focus now at ${ancestry(current(root.node)).join(" › ")}`, + `keyboard path ${typed.path.join(" › ")}`, + `keyboard action ${typed.action?.kind ?? "none"}`, + `equivalent ${ + typed.action?.kind === pointed.action?.kind && typed.path.join() === pointed.path.join() + ? "yes" + : "no" + }`, + ]; + + const before = topology(root.node); + const demandBefore = clock.demand; + + const closed = decodeRoute(closing); + const nextOutcome = closed.ok ? resolveRoute(closed.value, model) : closed; + yield* host.show(describeScreen(nextOutcome, session, viewport)); + + const after = topology(root.node); + const teardown = [ + `closing to ${closing}`, + `removed ${before.filter((key) => !after.includes(key)).join(", ") || "nothing"}`, + `remaining ${after.join(", ")}`, + `frame demand ${demandBefore} → ${clock.demand}`, + ]; + + // Put the traced location back so the output line describes the location the + // trace is about. + yield* host.show(descriptions); + yield* host.advance(32); + + return { + decoded: encodeRoute(decoded.value), + resolved, + described, + mounted: topology(root.node), + focus: focusTargets(root.node).map((node) => keyOf(node) ?? node.name), + delivery, + teardown, + output: host.draw().split("\n"), + }; +} + +function modelCheckpoint(model: ReplModel, marker: string): unknown { + return model.checkpoints.find((checkpoint) => checkpoint.marker === marker); +} + +function refusalPosition(error: Error): string { + return "position" in error && typeof error.position === "string" ? error.position : "the URL"; +} + +function descriptionTree(descriptions: readonly Description[], depth: number): string[] { + const lines: string[] = []; + for (const description of descriptions) { + lines.push(`${" ".repeat(depth)}${description.key}`); + lines.push(...descriptionTree(description.children(), depth + 1)); + } + return lines; +} + +/** The trace, as the lines a person reads. */ +export function printTrace(trace: Trace): readonly string[] { + return [ + "1. decoded route", + ` ${trace.decoded}`, + "2. resolved against the model", + ...trace.resolved.map((line) => ` ${line}`), + "3. keyed component description", + ...trace.described.map((line) => ` ${line}`), + "4. mounted Freedom tree", + ...trace.mounted.map((line) => ` ${line}`), + " focus chain", + ...trace.focus.map((line) => ` ${line}`), + "5. action delivery", + ...trace.delivery.map((line) => ` ${line}`), + "6. branch teardown", + ...trace.teardown.map((line) => ` ${line}`), + "7. terminal output", + ...trace.output.map((line) => ` ${line}`), + ]; +} diff --git a/scripts/repl-hydration/README.md b/scripts/repl-hydration/README.md new file mode 100644 index 000000000..7b0b312cf --- /dev/null +++ b/scripts/repl-hydration/README.md @@ -0,0 +1,393 @@ +# Rebuilding a REPL view from its Journal and URL + +[#842](https://github.com/taras/executable.md/issues/842), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). An experiment. None +of this merges, and none of it is a starting point for production. + +The question is whether one durable XMD Journal plus one canonical URL can +reconstruct every durable semantic REPL view, while StarFX stays a discardable +cache and process-local continuations stay honestly unreconstructable. + +**Slice 1 establishes the pure boundary, before StarFX exists.** Records in, one +immutable semantic model out, one URL resolved against it, and a refusal for +everything else. + +**Slice 2 puts that model in the actual StarFX store** and shows the store adds +nothing to it. + +**Slice 3 takes the process away** and asks what is left: a draft that is not a +record, an Agent result without the stream that produced it, a replay that +consumes what is written instead of doing it again, and a secret that has to be +asked for a second time. + +**Slice 4 forks the execution**, completes the refusal matrix, and concludes. +[RESULT.md](RESULT.md) is the conclusion: the exact vocabulary, URL schema, +hydration boundary, snapshot policy, replay frontier and verdict. + +```bash +deno task repl:hydration # the observable trace +deno task test scripts/tests/repl-hydration-projection.test.ts # slice 1 +deno task test scripts/tests/repl-hydration-store.test.ts # slice 2 +deno task test scripts/tests/repl-hydration-replay.test.ts # slice 3 +deno task test scripts/tests/repl-hydration-fork.test.ts # slice 4 +``` + +## The layers + +| module | what it owns | +| --- | --- | +| `journal.ts` | the closed semantic vocabulary, parsed out of untrusted durable records | +| `fixture.ts` | two truthful append-only journals, and the one-thing-wrong variants of the first | +| `model.ts` | the semantic model's types: frozen plain data, no optional member | +| `project.ts` | the fold — one prefix in, one model out — and the inconsistency refusals | +| `location.ts` | #840's URL grammar, resolved against a selected prefix | +| `purity.ts` | the walk that names anything in a model that is not frozen plain data | +| `overlay.ts` | the live process's pause state, where nothing durable can reach it | +| `store.ts` | the actual StarFX store, hydrated from records plus a URL | +| `layout.ts` | presentation, computed outside the store, and the topology that must not move | +| `ephemeral.ts` | partial Agent output and the secret seam — the other half process loss takes | +| `replay.ts` | the deterministic document, and one function that both runs and replays it | +| `fork.ts` | taking a fork: the one moment a fork reads its parent | +| `provenance.ts` | following a fork back to its parent, when this process can reach one | + +`journal.ts`, `model.ts`, `project.ts` and `purity.ts` import `effection` and +each other. None of them imports `overlay.ts`, and the evidence reads their +imports to say so, and `ephemeral.ts` is held out of the same set. `store.ts` is +the only module that imports `starfx`. + +## The event vocabulary + +Ten kinds, and a record naming anything else is refused rather than carried: + +``` +entry.submitted entry.settled entry.failed entry.interrupted +scope.opened scope.completed +binding.published +suspension.opened suspension.answered +outcome.recorded +``` + +Each kind declares its fields, and a record carrying a field its kind does not +declare is refused too. That is what stops a continuation, a callback, a +renderer handle or a terminal cell being written into the durable stream and +read back as an execution fact — not a rule about what to look for, but a +vocabulary with nowhere to put one. + +A record's marker is its own opaque `id`. Its `seq` is append position: it +orders replay, names nothing, and a gap in it is how a short stream is +recognized. + +## The marker policy + +Every record mints a marker except the two *closing* kinds. Weight is how +prominent the position is, and it is a value on the marker rather than a +convention: + +| kind | marker | +| --- | --- | +| `entry.submitted` | major entry boundary | +| `entry.settled` | terminal | +| `entry.failed` | terminal | +| `entry.interrupted` | terminal | +| `scope.opened` | opening | +| `suspension.opened` | opening | +| `binding.published` | small semantic checkpoint | +| `outcome.recorded` | small semantic checkpoint | +| `scope.completed` | none — updates the scope the opening minted | +| `suspension.answered` | none — updates the suspension the opening minted | + +An entry's end is a place to stand, not a property of the marker before it, so +settlement, failure and interruption are each directly navigable. The +representative journal is 23 records and 20 markers; the terminal journal is +18 records and 15. + +## How an entry ends + +There are four lifecycle statuses and no fifth: `running`, `settled`, `failed` +and `interrupted`. + +One terminal record ends an entry and interrupts whatever it still had open. +The entry carries what the record said; each still-running descendant scope +becomes `interrupted` and carries the same reason, because the Journal +recorded one ending and not one per scope. A scope that completed earlier +stays completed, open waits close, and published bindings are untouched. + +``` +t-13 failed weight=terminal + entry-2 failed (the registry rejected the tarball) + document: interrupted (the registry rejected the tarball) + verify: settled + upload: interrupted (the registry rejected the tarball) + +t-18 interrupted weight=terminal + entry-3 interrupted (the operator stopped the run) + document: interrupted (the operator stopped the run) + watch: interrupted (the operator stopped the run) + +before the failure (t-12): 0 ended entries +release survives the failure: release=0.14.0 +``` + +`fixture.ts` holds two journals. The representative one is the pause/head +subject and keeps that shape; the terminal one is three entries and nothing +else, one for each way an entry can end. + +## The URL + +There is one REPL URL grammar and this is not a second one. `decodeRoute()` and +`encodeRoute()` are imported unchanged from #840's `../repl-compose/router.ts`: +the same five surfaces, the same `+drawer` segments, the same +`at` / `inspect` / `draft` query, the same canonical spelling, the same +equivalent-spelling decoding, the same `RouteRefusal` shape. + +``` +xmd://repl/e1/transcript/entry-3/document/publish/+source/+confirm?at=r-22&inspect +``` + +What is adapted, and adapted here rather than there, is **resolution**. #840 +resolves against a model holding a snapshot of every checkpoint; that table is +exactly the accelerator #842 must not depend on. So resolution here projects +the prefix the URL named and answers against that single model. Nothing in +`repl-compose` changed, and its evidence is green. + +## The two positions + +The expansion pause point and the live History head are independent, which #841 +measured on a real execution. In the fixture `r-22` is the marker expansion is +held at and `r-23` is a durable outcome background work appended afterwards: + +``` +expansion pause point (live overlay): r-22 +live History head (durable) : r-23 + + at r-22 : 22 records, outcomes none + at r-23 : 23 records, outcomes "remote tags fetched" +``` + +Which marker expansion is held at is not written down in the Journal, because +the Journal has no field for it. `overlay.ts` is the only place it exists, and +a reconstruction from records and a URL gets `cold()` — not an overlay +reporting "not paused", which would still be a claim about a pause. + +## Named negative controls + +Each structural claim carries one, written in the test as the weaker +implementation it rules out. + +| control | what it accepts that the real boundary refuses | +| --- | --- | +| `open-vocabulary` | a record whose kind is `pause.held` | +| `skip-malformed` | a 22-record journal in which `project` was silently never published | +| `leaky-prefix` | a "historical" view built by filtering the head, carrying every later binding, drawer and outcome | +| `pause-truncates-head` | a head that stops at the expansion pause point and loses the background outcome | +| `append-order-siblings` | `publish, write` — the order the coroutines opened in, not the document's | +| `decorated-model` | a renderer handle, a `Uint8Array` of cells, or an unfrozen scroll offset on the model | +| `snapshot-dependent` | a projector that answers from a cache and answers nothing without one | +| `permissive-ownership` | two top-level entries running at once | +| `permissive-closure` | an entry settling over a scope that never completed | +| `restore-abandoned` | a fifth lifecycle status renaming an interruption | +| `omit-terminal-kinds` | a policy whose nearest position to a failure is `t-12`, where the entry is still running | +| `closing-marker` | `t-04`, a second position for a scope that has one | +| `interrupt-completed-scope` | `verify:interrupted`, rewriting a scope that finished | +| `persisted-snapshots` | a cache that outlived its records, answering a 3-record moment where 22 belong | +| `head-memoized` | a frozen live head still reporting 22 records after the 23rd arrived | +| `layout-in-the-store` | a viewport in the state, making two terminals two executions | +| `overlay-in-the-store` | a pause flag in the state, surviving a restart that cannot know it | +| `replay-reperforms` | a run that ignores the record and does both durable effects again | +| `recoverable-secret` | a secret treated as ordinary: nobody is asked, so the value had to be somewhere | +| `streamed-into-the-record` | a partial chunk under the right request, indistinguishable from the result | +| `draft-as-a-visit` | four navigation entries for one place, burying where the person came from | +| `kind-only-replay` | another document's submission, binding and Agent occurrence, all matching on kind alone | +| `discarded-result` | a replay that recognizes the record and then publishes the script's own literal | +| `parent-backed-fork` | an inheritance read from the parent, which empties when the parent goes | +| `provenance-at-hydration` | resolving the link while hydrating, making the parent a dependency | +| `plausible-partial` | the readable prefix of a damaged journal, describing a binding that was never published | +| `marker-without-a-record` | a prefix ending at a record that minted no marker | +| `multi-record-inheritance` | an environment copied record by record, hydrating after half of it | + +## The StarFX store + +`starfx@0.16.1`, added through the repository's frozen-lock procedure. It +depends on `effection: ^4`, so it sits beside this repository's `effection` +rather than beside a second copy of it, and its root export is React-free. It +runs under Deno, Node and Bun unchanged, and no production package moved. + +The store takes the caller's Effection scope through `useScope()`, so its +lifetime is the session's. Its slices are `execution`, `url`, `records`, +`model`, `history`, `location` and `snapshots`, plus the `cache` and `loaders` +slices StarFX's schema requires and this REPL never writes. + +Everything in it is derived from the records and the URL, and every transition +re-derives rather than patching — a patch would be a second way to arrive at a +state, and the claim is that there is one. + +**Snapshots accelerate and never testify.** `snapshots` memoizes the model of a +*marker* prefix, because a prefix ending at a record can never change. The live +head is never memoized: it is exactly the prefix that grows. A new store starts +with an empty cache and cannot be handed a populated one, so a snapshot cannot +outlive the process that derived it. + +## The journey + +``` +— the journey, accumulated live against a cold rebuild — + empty prefix none records 0 future markers 0 rebuild identical true + the first entry prefix r-01 records 1 future markers 0 rebuild identical true + nested scopes prefix r-03 records 3 future markers 0 rebuild identical true + a published binding prefix r-07 records 7 future markers 0 rebuild identical true + the expansion pause marker prefix r-22 records 22 future markers 0 rebuild identical true + a background append, expansion held prefix r-22 records 22 future markers 1 rebuild identical true + historical inspection prefix r-03 records 3 future markers 17 rebuild identical true + the live head prefix r-23 records 23 future markers 0 rebuild identical true + back to the pause marker prefix r-22 records 22 future markers 1 rebuild identical true + +— Continue is the live process's to offer — + at r-22, holding : true + at r-22, released : false + at r-22, after restart: false + the reconstruction is unchanged : true + +— the cache accelerates and decides nothing — + memoized markers : r-03 r-22 + after discarding them : true + +— one state, two terminals — + lines at 120 columns : 27 + lines at 28 columns : 34 + topology identical : true + nothing foreign in the store: clean +``` + +A future marker is listed as navigation context and carries no fact: while +expansion is held at `r-22` and the Journal advances to `r-23`, the selected +model does not move and the string `remote tags fetched` appears in neither +the model nor the History. + +## Restart + +A live run and a replay are one function. `resume()` walks the document's steps +beside the Journal in append order, and each step either *consumes* the records +it already produced or *performs* itself for the first time. A consumed step +never reaches the performer, which is the whole no-repeat claim. Where it stops +is the **replay frontier**: the first elicitation with no answer recorded. + +**A record is consumed only when it is that step's own record.** The kind alone +says far too little — every scope opening is a `scope.opened` — so every +replayable occurrence has an identity: operation, owning entry, owning scope, +and the durable name of the occurrence there. That identity is separate from +the result: replay *matches* the request and *restores* what came back, which +is why `outcome.recorded` carries `request` beside `label`. An outcome +recognized by its own result could only be recognized by a replay that already +knew the answer. + +Alignment happens first and completely: the prior Journal is parsed, projected, +and walked against the script before a single effect runs. A divergence +therefore costs nothing — no effect, no appended record, and the Journal handed +in comes back untouched. A retained record still unclaimed when the document +has finished is a divergence too, because it describes work this document does +not do. + +``` +another document's journal : record 0: expected entry.submitted "entry-1" in entry-1, + found entry.submitted "other-entry" in other-entry +another binding, right kind: record 7: expected binding.published "notes" in entry-1, + found binding.published "other" in entry-1 +``` + +**A matched operation returns its recorded result.** Consuming is not merely +declining to perform: the value the live performer would have produced has to +arrive at the same place, or the document carries on with whatever its source +happened to say and the replay only looked correct. Every producing step names +what it puts into execution state, every consuming step derives from that +state, and both the performer's value and the matched record's are written to +the same one. The script holds no second copy of a result it did not compute — +`publish` names a value, it does not carry one. + +``` +— a matched operation returns its recorded result — + the Agent was : agent entry-1/document/draft + the next step performed: publish notes + and published : "RESTORED AGENT RESULT" +``` + +The same holds for a recovered answer: `channel` lands in execution state and +the step after it publishes what the person said, so a recovery that reached +only the report array would be visible as a missing binding. + +Two elicitations differ on the way there, and that difference is the secret +rule. An ordinary answer is in the record, so replay recovers it and asks +nobody. A secret answer is not in the record and never was, so replay knows +only that it was asked — and asks again. With nobody to ask, that is a frontier +of its own. + +`suspension.opened` carries `secret` and `suspension.answered` carries +`answer`, which is what makes the two cases distinguishable at all. The +projection refuses a `suspension.answered` that carries a value for a wait the +Journal opened as secret, so a leak cannot be written down and then merely left +unread. + +``` +— a first run, live — + performed : agent entry-1/document/draft, publish notes + frontier : awaiting channel + +— the same document after process loss — + performed again : nothing + consumed : agent entry-1/document/draft, publish notes + recovered : channel=#releases + re-prompted for : token + partial output : none + frontier : complete + with nobody to ask: unrevealed token + +— what the restart shows — + admitted result : Release notes for 0.14.0 + its scopes : draft > review + journal records : 13 (typing appended none) + navigation stack: 1 + Continue offered: false + +— the secret — + asked for again in : token + present in journal, store, navigation, audit or run: nowhere +``` + +## Drafts + +Typing moves the URL and nothing else. No record is appended, and the ordinary +navigation history — which is process-local, like the snapshot cache, and so +lives beside the store rather than in it — is replaced in place rather than +grown. Three keystrokes are one place, so Back goes where the person came from +instead of walking backwards through their typing. + +## Forks + +A fork owns a new Journal whose first record is `entry.inherited`. It carries +the synthetic entry, the parent and source marker, and **the whole inherited +environment in that one record** — so a Journal containing nothing but it +already hydrates into the complete environment. Spread over a run of ordinary +publications the copy could be read half-finished, and nothing downstream +could tell. `inherit()` builds the payload from the parent's projection at +exactly the source marker, which is the only moment a fork needs its parent. + +``` +— a fork stands on its own — + records : 7 + inherited entry : entry-0 (settled) + environment : project=executable.md release=0.13.1 + published by : entry-0 entry-1 + with the parent : resolvable xmd://repl/e1/transcript?at=r-07 + without the parent : unavailable (e1@r-07) + hydrates unaided : e1-fork + the parent's later work is absent: true +``` + +Removing the parent costs the link and nothing else. Resolving provenance is a +separate function over whatever journals the process can reach, never part of +hydration — a hydration that insisted on a resolvable link would have made the +parent a dependency. + +## What this POC does not do + +No renderer, no terminal, no real XMD execution and no model provider. The +limits of the evidence are recorded in [RESULT.md](RESULT.md). diff --git a/scripts/repl-hydration/RESULT.md b/scripts/repl-hydration/RESULT.md new file mode 100644 index 000000000..25baa4e69 --- /dev/null +++ b/scripts/repl-hydration/RESULT.md @@ -0,0 +1,309 @@ +# What the hydration experiment found + +[#842](https://github.com/taras/executable.md/issues/842), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). This is the +conclusion. None of the code under `scripts/repl-hydration/` is a starting +point — production begins from current `main`, written afresh. + +The question: + +> Can one durable XMD Journal plus one canonical URL reconstruct every durable +> semantic REPL view, while StarFX remains a discardable cache and +> process-local continuations remain honestly unreconstructable? + +**Yes.** + +## Decision: RETAIN + +| | | +| --- | --- | +| **Retain** | Records and a URL determine the view. The projector takes records and a marker and has no parameter a snapshot could go in. StarFX holds only what those two produce. The pause controller, partial Agent output and the navigation stack live where process loss takes them. A fork publishes its inheritance into its own Journal and points at its parent by name. | +| **Revise** | Two vocabulary facts the experiment had to settle and one it had to repair: which records mint markers, that an unfinished scope is `interrupted` rather than a fifth status, and that replay must match a record's *identity* and restore its *result*. All three are below. | +| **Reject** | Nothing. | + +## The event vocabulary + +Eleven kinds. A record naming anything else is refused, and so is a record +carrying a field its kind does not declare — which is what stops a +continuation, a callback, a renderer handle or a terminal cell being written +into the durable stream and read back as an execution fact. + +| kind | fields beyond the envelope | marker | +| --- | --- | --- | +| `entry.submitted` | `entry`, `title` | boundary | +| `entry.inherited` | `entry`, `title`, `parent`, `source`, `bindings` | boundary | +| `entry.settled` | `entry` | terminal | +| `entry.failed` | `entry`, `reason` | terminal | +| `entry.interrupted` | `entry`, `reason` | terminal | +| `scope.opened` | `entry`, `scope`, `name`, `source` | opening | +| `scope.completed` | `entry`, `scope`, `name` | none | +| `suspension.opened` | `entry`, `scope`, `wait`, `prompt`, `secret` | opening | +| `suspension.answered` | `entry`, `scope`, `wait`, `answer` | none | +| `binding.published` | `entry`, `name`, `value` | checkpoint | +| `outcome.recorded` | `entry`, `scope`, `request`, `label` | checkpoint | + +The envelope is `id`, `seq`, `at`, `kind`. **A record's marker is its own +opaque `id`; `seq` is append position, which orders replay, names nothing, and +whose gap is how a short stream is recognized.** + +Three fields exist because something would otherwise be unprovable: + +- **`secret` and `answer`.** Without them a recoverable answer and a redacted + one are the same record, and "replay re-prompts for a secret" is vacuous. + The projection refuses a `suspension.answered` carrying a value for a wait + the Journal opened as secret, so a leak cannot be written and then merely + left unread. +- **`request` on `outcome.recorded`.** Identity and result must be separate: + an outcome recognized by its own result could only be recognized by a replay + that already knew the answer. +- **`source` on `scope.opened`.** Concurrent siblings open in dispatch order + and the transcript is a reading of the document, so the position has to be + recorded rather than inferred. +- **`bindings` on `entry.inherited`.** A fork's inheritance is complete in its + first record — see below. + +### Marker policy + +Every record mints a semantic History marker except the two *closing* kinds. +An entry's end is a place to stand, not a property of the marker before it, so +settlement, failure and interruption are each directly navigable. A scope +completion and a suspension answer update the state the opening already +minted. Weight — boundary, terminal, opening, checkpoint — is a value on the +marker, not a convention. + +### Lifecycle + +Four statuses: `running`, `settled`, `failed`, `interrupted`. One terminal +record ends an entry and interrupts whatever it still had open; each +still-running descendant scope carries the entry's reason, because the Journal +recorded one ending and not one per scope. A scope that completed earlier +stays completed. There is no `abandoned`. + +## The URL schema + +There is one REPL URL grammar and this experiment did not write a second one. +`decodeRoute()` and `encodeRoute()` come from #840's router unchanged. + +``` +xmd://repl//[/[/…][/+…]][?at=&inspect&draft=] +``` + +Surfaces are `sessions`, `transcript`, `bindings`, `input`, `history`. +Decoding is structural and encoding is canonical: equivalent spellings decode +to one route, and one route has one spelling. What is refused is a URL that is +malformed or names two locations at once. + +**Only resolution was adapted, and adapted in a module of its own.** #840 +resolves against a model holding every checkpoint of the execution; that table +is exactly the accelerator #842 must not depend on, so resolution here projects +the prefix the URL named and answers against that one model. `repl-compose` is +untouched and its four suites are green. + +The grammar named every state the experiment needed. No product decision about +the URL was required. + +## The hydration boundary + +``` +records ──parse──▶ events ──project(prefix)──▶ model ──resolveIn(route)──▶ location + │ │ + └──────────────── URL ────────────────────────┘ ▼ + StarFX store +``` + +The StarFX store — `starfx@0.16.1`, real, running unchanged on Deno, Node and +Bun — holds `execution`, `url`, `records`, `model`, `history`, `location` and +`snapshots`, plus the `cache` and `loaders` slices its schema requires and +this REPL never writes. It takes the caller's Effection scope through +`useScope()`, so its lifetime is the session's. Every transition re-derives +from the records and the URL rather than patching: a patch would be a second +way to arrive at a state, and the claim is that there is one. + +**Outside the store, and unreconstructable:** + +| state | where it lives | +| --- | --- | +| the pause controller, and whether Continue is offered | `overlay.ts` | +| partial Agent output, and the secret seam | `ephemeral.ts` | +| the ordinary navigation history | a private field on the session | +| marker snapshots | the store, but see below | +| the viewport, and everything drawn | `layout.ts`, computed from a model | + +None of `journal.ts`, `model.ts`, `project.ts`, `purity.ts`, `location.ts` or +`store.ts` imports `overlay.ts` or `ephemeral.ts`, and the evidence reads their +imports to say so. + +## The snapshot policy + +**Snapshots accelerate and never testify.** Only a *marker* prefix is +memoized, because a prefix that ends at a record can never change. The live +head is never memoized: it is exactly the prefix that grows. A new store +starts with an empty cache and cannot be handed a populated one, so a snapshot +cannot outlive the process that derived it — which is the only reason reading +one is safe at all. + +Discarding every memoized marker and navigating the same tour again produces +deeply equal semantic state at every step. + +## The replay frontier + +A live run and a replay are one function. `resume()` walks the document's steps +beside the Journal in append order and each step either *consumes* the records +it already produced or *performs* itself for the first time. Where it stops is +the frontier: the first elicitation with no answer recorded. + +Three rules make that sound, and each was found by a defect: + +1. **Alignment happens first and completely.** The prior Journal is parsed, + projected and walked against the whole script before a single effect runs, + so a divergence performs nothing, appends nothing and leaves the supplied + Journal untouched. A retained record left unclaimed once the document has + finished is a divergence too. +2. **A record is consumed only when it is that step's own.** Every replayable + occurrence has an identity — operation, owning entry, owning scope, durable + occurrence name. Matching on the record kind alone let one document's + records stand in for another's. +3. **A matched operation returns its recorded result.** Producing steps name + what they put into execution state; consuming steps derive from it; the + live performer's value and the matched record's are written to the same + place. An entry in `consumed` or `recovered` is an audit line, not a + restoration — and a script that keeps its own copy of a result it did not + compute hides the difference. + +Restart is not Continue. Continue resolves a suspended routine in a process +that still exists (#841); replay rebuilds a position from what was written +down. Cold hydration offers a frontier, never Continue, and never claims +EXPANSION PAUSED. + +**Secrets.** An ordinary answer is in the record, so replay recovers it and +asks nobody. A secret answer is not in the record and never was, so replay +knows only that it was asked — and asks again. With nobody to ask, that is a +frontier of its own. + +## Forks + +A fork owns a new Journal whose first record is `entry.inherited`: + +``` +{ id, seq, at, kind: "entry.inherited", + entry, title, // the synthetic entry it begins with + parent, source, // where it was taken from + bindings: [{ name, value }, …] } // the whole inherited environment, ordered +``` + +**Inheritance becomes self-contained in that one record.** The projection +publishes the environment from `entry.inherited` itself, so a Journal +containing nothing but that record already hydrates into the complete +environment — an unsettled synthetic entry with everything it carried over. +Spread across a run of ordinary `binding.published` records the copy could be +read half-finished, and a prefix ending in the middle would hydrate into an +environment that existed in neither execution; nothing downstream could tell. +One record cannot be half-read. `entry.settled` stays separate, because +settling is a later thing that happened, not part of the payload. + +**The parent is needed only while that record is created.** `inherit()` +projects the parent at exactly the source marker and writes what it finds +into the payload — not the parent's head, and not a hand-transcribed copy +that could only agree with itself. After the record exists the parent is a +name. + +``` +with the parent : resolvable xmd://repl/e1/transcript?at=r-07 +without the parent : unavailable (e1@r-07) +hydrates unaided : e1-fork +``` + +Removing the parent costs the link and nothing else. The fork still +reconstructs, settles and carries on, still says where it came from, and +merely has nowhere to send someone who follows it. Resolving provenance is a +separate function over whatever journals the process can reach, never part of +hydration — a hydration that insisted on a resolvable link would have made the +parent a dependency. A parent that is missing, short, malformed, or +inconsistent at the source marker dims the link and nothing else; an +inconsistency the parent reaches only *after* that marker leaves it alone. + +## Refusals + +Every layer that reads untrusted input refuses rather than answering +partially: the record parser (malformed, truncated, repeated identity, foreign +field, unknown kind), the projection (overlapping entries, impossible scope +closure, malformed ownership, two siblings at one source, a recorded secret), +the URL codec (thirteen malformed spellings), the resolver (execution, marker, +entry, scope, drawer) and hydration itself, which builds no half-session and +leaves a live session's state intact when a move is refused. + +## Named negative controls + +Twenty-eight, each a weaker implementation written in the evidence that accepts +what the real boundary refuses. + +`open-vocabulary` · `skip-malformed` · `leaky-prefix` · `pause-truncates-head` +· `append-order-siblings` · `decorated-model` · `snapshot-dependent` · +`permissive-ownership` · `permissive-closure` · `restore-abandoned` · +`omit-terminal-kinds` · `closing-marker` · `interrupt-completed-scope` · +`persisted-snapshots` · `head-memoized` · `layout-in-the-store` · +`overlay-in-the-store` · `replay-reperforms` · `recoverable-secret` · +`streamed-into-the-record` · `draft-as-a-visit` · `kind-only-replay` · +`discarded-result` · `parent-backed-fork` · `provenance-at-hydration` · +`plausible-partial` · `marker-without-a-record` · `multi-record-inheritance` + +## Evidence + +```bash +deno task repl:hydration # the observable trace +deno task test scripts/tests/repl-hydration-projection.test.ts # vocabulary, projection, location +deno task test scripts/tests/repl-hydration-store.test.ts # the StarFX store +deno task test scripts/tests/repl-hydration-replay.test.ts # restart, drafts, Agent, secrets +deno task test scripts/tests/repl-hydration-fork.test.ts # forks and the refusal matrix +``` + +Green on Deno, Node and Bun. + +## What this evidence does not cover + +- **No real XMD execution.** Slice 1's Journal is a hand-authored fixture and + the replay document is a script of steps. #841 proved pause against a real + execution; this proved reconstruction against records, and the two have not + been run together. +- **No model provider**, by #842's own instruction. The Agent is a deterministic + fixture that streams three chunks and admits one result. +- **Admission is producer-owned and unprovable from a record.** A partial chunk + written as an `outcome.recorded` under the right request is indistinguishable + from an admitted result, because both are text under one durable name. The + guarantee is that `admit()` discards the buffer, not that a reader could + catch a producer that did not. +- **Presentation is lines of text.** `layout.ts` exists to show the seam is + real, not to draw well; cells, widths and wrapping are #838's subject. +- **One fork, one parent.** Fork chains, and what a provenance link means two + generations back, were not exercised. Nor was a large environment: the + payload is one record however big it gets, and nothing here says where that + stops being reasonable. +- **Nothing was measured.** No snapshot was shown to make navigation faster, + only to be unnecessary. + +## Production sequencing + +Written afresh from current `main`, in dependency order. + +1. **The durable vocabulary and the projector.** The eleven kinds as parsed + records, the marker policy, the lifecycle, prefix projection, and the + refusals. Pure, and the cheapest step to get right alone. +2. **Resolution against a prefix.** #840's codec with the adaptation this + experiment isolated. +3. **The StarFX store**, hydrated from the two, with the snapshot rule as a + test rather than a comment. +4. **The process-local boundary.** The pause overlay, the Agent stream and the + navigation stack, each with the import guard that keeps it out of the + durable path. +5. **Restart replay.** Alignment before execution, identity matching, result + restoration, and the frontier — in that order, because each of the three + was a defect found after the one before it looked finished. +6. **Forks**, which need nothing above them but the vocabulary. Inheritance + is atomic: one record carrying the ordered environment, built from the + parent's projection at the source marker, and a provenance link resolved + separately from hydration. + +Steps 1–3 are the contract. Step 5 is the one that repaid adversarial review +three times, and its evidence must include a downstream data dependency: a +replay whose restored values nothing consumes looks correct however wrong it +is. diff --git a/scripts/repl-hydration/ephemeral.ts b/scripts/repl-hydration/ephemeral.ts new file mode 100644 index 000000000..a3065a376 --- /dev/null +++ b/scripts/repl-hydration/ephemeral.ts @@ -0,0 +1,105 @@ +/** + * What the running process holds and the record does not. + * + * Two things live here, and they are here because neither can survive a + * restart and neither may be written down. + * + * **Partial Agent output.** An Agent streams before it has said anything + * final. Those chunks are how the person watches it think; they are not what + * happened. Admission is the durable event, and the admitted result is what a + * record holds — so `admit()` discards the buffer rather than flushing it + * anywhere. A cold reconstruction shows the admitted result and no partial + * text, because there is no partial text to show. + * + * **Secrets.** A secret is asked for, used, and never kept: not by this module + * either. `Secrets` is a seam a live process fills and a cold one does not, + * and the only thing it remembers is *which* waits it was asked about. That + * list is what the evidence reads, because a list of questions is safe and a + * list of answers would defeat the whole claim. + * + * Nothing here is imported by `journal.ts`, `model.ts`, `project.ts`, + * `location.ts`, `purity.ts` or `store.ts`, and the evidence reads their + * imports to say so. Like `overlay.ts`, this is the half of the REPL that + * process loss is supposed to take. + */ + +/** Partial Agent output, per scope, for as long as this process lives. */ +export interface Streaming { + /** What has arrived for one scope and not yet been admitted. */ + partial(scope: string): readonly string[]; + /** One more chunk of output nobody has committed to. */ + receive(scope: string, chunk: string): void; + /** The result was admitted; the chunks that led to it are not evidence of it. */ + admit(scope: string): void; + /** Every scope currently mid-stream. */ + streaming(): readonly string[]; +} + +export function createStreaming(): Streaming { + const chunks = new Map(); + return { + partial(scope) { + return [...(chunks.get(scope) ?? [])]; + }, + receive(scope, chunk) { + const held = chunks.get(scope) ?? []; + held.push(chunk); + chunks.set(scope, held); + }, + admit(scope) { + chunks.delete(scope); + }, + streaming() { + return [...chunks.keys()].toSorted(); + }, + }; +} + +/** A secret, if someone is there to give it. */ +export type Revealed = { readonly known: true; readonly value: string } | { readonly known: false }; + +/** + * Where a secret comes from, which is always a person and never a record. + * + * `asked` is the audit trail: the waits this process had to put in front of + * someone. It never holds a value, so printing it, logging it or attaching it + * to an error is safe by construction rather than by care. + */ +export interface Secrets { + reveal(wait: string, prompt: string): Revealed; + readonly asked: readonly string[]; +} + +/** A live process with someone at the keyboard. */ +export function scriptedSecrets(answers: Readonly>): Secrets { + const asked: string[] = []; + return { + reveal(wait) { + asked.push(wait); + const value = answers[wait]; + return value === undefined ? { known: false } : { known: true, value }; + }, + get asked() { + return [...asked]; + }, + }; +} + +/** + * A process with nobody to ask. + * + * It still records the question, because reaching a secret frontier with no + * one there is a thing that happened and the run has to say where it stopped. + */ +export function noSecrets(): Secrets { + const asked: string[] = []; + return { + reveal(wait) { + asked.push(wait); + return { known: false }; + }, + get asked() { + return [...asked]; + }, + }; +} diff --git a/scripts/repl-hydration/fixture.ts b/scripts/repl-hydration/fixture.ts new file mode 100644 index 000000000..b79114342 --- /dev/null +++ b/scripts/repl-hydration/fixture.ts @@ -0,0 +1,612 @@ +/** + * Three truthful append-only Journals, written as the durable records would + * arrive. + * + * The first is representative: the pause point, the live head, and everything + * a prefix has to isolate. The second, at the bottom of this file, is minimal + * and its only subject is how an entry ends. The third is a fork, and its + * first record is computed from the first rather than written out. + * + * Truthful means the fold is the only way to learn what this execution + * reached. Nothing here is a snapshot of an answer, nothing is derived from a + * screen, and the awkward parts are awkward on purpose: + * + * - `entry-1` settles, `entry-2` fails, `entry-3` is still running. Top-level + * entries are serial, so each begins only after the one before it closed. + * - `entry-1` nests `plan` inside `document` and completes both, so a + * historical marker exists on either side of a scope's settlement. + * - `entry-2` publishes `release` and *then* fails, with both its scopes open. + * The binding survives, and its open scopes become interrupted rather than + * settled. + * - `entry-3` opens `publish` before `write` although `write` comes first in + * the document. The records carry each scope's source position, and the + * projection reads them in the document's order rather than the coroutines'. + * - `entry-3` republishes `project`, so an earlier marker reconstructs the + * earlier value rather than merely missing a name. + * - `r-22` is the expansion pause point, and `r-23` is a durable outcome that + * background work appended *after* it while expansion stayed held. The two + * positions are independent (#841), and only the second is the live head. + * + * The pause point is not written down here. `r-22` is an ordinary + * `suspension.opened` record, and which marker expansion is held at is a fact + * the live process owns — `overlay.ts` is where a test says it, and nothing in + * the durable stream can. + */ + +import { inherit } from "./fork.ts"; +import { parseJournal } from "./journal.ts"; + +/** The execution these records belong to. A route naming another one refuses. */ +export const EXECUTION = "e1"; + +/** + * The marker the live process is holding expansion at. + * + * Declared beside the fixture because the evidence needs to name it, and + * nowhere near the records, because the Journal has no field for it. + */ +export const PAUSE_MARKER = "r-22"; + +/** The newest marker: the background outcome appended while expansion was held. */ +export const LIVE_HEAD = "r-23"; + +/** + * Records as the durable stream holds them: plain JSON, of unknown shape until + * `parseJournal()` reads them. + */ +const RECORDS: readonly Record[] = [ + { id: "r-01", seq: 1, at: 2, kind: "entry.submitted", entry: "entry-1", title: "Add a README" }, + { + id: "r-02", + seq: 2, + at: 4, + kind: "scope.opened", + entry: "entry-1", + scope: [], + name: "document", + source: 0, + }, + { + id: "r-03", + seq: 3, + at: 7, + kind: "scope.opened", + entry: "entry-1", + scope: ["document"], + name: "plan", + source: 0, + }, + { + id: "r-04", + seq: 4, + at: 11, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document", "plan"], + wait: "review", + prompt: "Approve this plan before it runs?", + secret: false, + }, + { + id: "r-05", + seq: 5, + at: 16, + kind: "suspension.answered", + entry: "entry-1", + scope: ["document", "plan"], + wait: "review", + answer: "approved", + }, + { + id: "r-06", + seq: 6, + at: 18, + kind: "scope.completed", + entry: "entry-1", + scope: ["document"], + name: "plan", + }, + { + id: "r-07", + seq: 7, + at: 21, + kind: "binding.published", + entry: "entry-1", + name: "project", + value: "executable.md", + }, + { + id: "r-08", + seq: 8, + at: 24, + kind: "scope.completed", + entry: "entry-1", + scope: [], + name: "document", + }, + { id: "r-09", seq: 9, at: 25, kind: "entry.settled", entry: "entry-1" }, + + { + id: "r-10", + seq: 10, + at: 30, + kind: "entry.submitted", + entry: "entry-2", + title: "Tag the release", + }, + { + id: "r-11", + seq: 11, + at: 32, + kind: "scope.opened", + entry: "entry-2", + scope: [], + name: "document", + source: 0, + }, + { + id: "r-12", + seq: 12, + at: 35, + kind: "scope.opened", + entry: "entry-2", + scope: ["document"], + name: "tag", + source: 0, + }, + { + id: "r-13", + seq: 13, + at: 38, + kind: "binding.published", + entry: "entry-2", + name: "release", + value: "0.13.0", + }, + { + id: "r-14", + seq: 14, + at: 41, + kind: "entry.failed", + entry: "entry-2", + reason: "tag 0.13.0 already exists on the remote", + }, + + { + id: "r-15", + seq: 15, + at: 48, + kind: "entry.submitted", + entry: "entry-3", + title: "Write the changelog", + }, + { + id: "r-16", + seq: 16, + at: 50, + kind: "scope.opened", + entry: "entry-3", + scope: [], + name: "document", + source: 0, + }, + { + id: "r-17", + seq: 17, + at: 53, + kind: "scope.opened", + entry: "entry-3", + scope: ["document"], + name: "publish", + source: 1, + }, + { + id: "r-18", + seq: 18, + at: 54, + kind: "scope.opened", + entry: "entry-3", + scope: ["document"], + name: "write", + source: 0, + }, + { + id: "r-19", + seq: 19, + at: 57, + kind: "binding.published", + entry: "entry-3", + name: "changelog", + value: "CHANGELOG.md", + }, + { + id: "r-20", + seq: 20, + at: 59, + kind: "binding.published", + entry: "entry-3", + name: "project", + value: "executable.md@0.13.1", + }, + { + id: "r-21", + seq: 21, + at: 62, + kind: "suspension.opened", + entry: "entry-3", + scope: ["document", "write"], + wait: "source", + prompt: "Which project should the changelog describe?", + secret: false, + }, + { + id: "r-22", + seq: 22, + at: 66, + kind: "suspension.opened", + entry: "entry-3", + scope: ["document", "publish"], + wait: "confirm", + prompt: "Commit and push the changelog now?", + secret: false, + }, + { + id: "r-23", + seq: 23, + at: 74, + kind: "outcome.recorded", + entry: "entry-3", + scope: ["document", "publish"], + request: "fetch remote tags", + label: "remote tags fetched", + }, +]; + +/** + * The durable stream as a reader receives it: plain JSON of unknown shape, + * until `parseJournal()` reads it. + */ +export const JOURNAL: readonly unknown[] = RECORDS; + +function renumber(records: readonly Record[]): readonly unknown[] { + return records.map((record, index) => ({ ...record, seq: index + 1 })); +} + +/** + * The representative journal with one record's fields changed. + * + * Every refusal case is this journal with one thing wrong, so what fails is + * the one thing named rather than a fixture written to fail. + */ +export function journalChanging(at: number, changes: Record): readonly unknown[] { + return RECORDS.map((record, index) => (index === at ? { ...record, ...changes } : record)); +} + +/** The representative journal with one record replaced outright. */ +export function journalWith(at: number, record: unknown): readonly unknown[] { + return RECORDS.map((one, index) => (index === at ? record : one)); +} + +/** The representative journal with one field cut out of one record. */ +export function journalDropping(at: number, field: string): readonly unknown[] { + return RECORDS.map((record, index) => { + if (index !== at) { + return record; + } + const kept: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key !== field) { + kept[key] = value; + } + } + return kept; + }); +} + +/** The representative journal with one record removed and its position left as a gap. */ +export function journalMissing(at: number): readonly unknown[] { + return RECORDS.filter((_, index) => index !== at); +} + +/** The representative journal with one record removed and positions closed up. */ +export function journalWithout(at: number): readonly unknown[] { + return renumber(RECORDS.filter((_, index) => index !== at)); +} + +/** The representative journal cut short after `count` records. */ +export function truncatedAfter(count: number): readonly unknown[] { + return RECORDS.slice(0, count); +} + +/** Where one record sits in the representative journal. */ +export function positionOf(id: string): number { + const at = RECORDS.findIndex((record) => record.id === id); + if (at === -1) { + throw new Error(`no such record: ${id}`); + } + return at; +} + +/** + * A second, minimal journal whose only subject is how an entry ends. + * + * The representative journal above is the pause/head fixture and stays that + * shape; bending it to also carry three terminal entries would have made both + * subjects harder to read. This one is three entries and nothing else: + * + * - `entry-1` completes its scopes and settles. + * - `entry-2` completes `verify` and publishes `release`, then opens `upload` + * and fails with it still running. The completed scope stays completed, the + * binding survives, and `upload` and `document` are interrupted. + * - `entry-3` is waiting on an elicitation when the run is interrupted. The + * wait closes and its scopes are interrupted, and the entry itself is + * `interrupted` rather than `failed`. + * + * Every one of its three endings is a terminal marker, so each end state is a + * place a URL can stand. + */ +const TERMINAL_RECORDS: readonly Record[] = [ + { id: "t-01", seq: 1, at: 1, kind: "entry.submitted", entry: "entry-1", title: "Check the tree" }, + { + id: "t-02", + seq: 2, + at: 2, + kind: "scope.opened", + entry: "entry-1", + scope: [], + name: "document", + source: 0, + }, + { + id: "t-03", + seq: 3, + at: 3, + kind: "scope.opened", + entry: "entry-1", + scope: ["document"], + name: "check", + source: 0, + }, + { + id: "t-04", + seq: 4, + at: 5, + kind: "scope.completed", + entry: "entry-1", + scope: ["document"], + name: "check", + }, + { + id: "t-05", + seq: 5, + at: 6, + kind: "scope.completed", + entry: "entry-1", + scope: [], + name: "document", + }, + { id: "t-06", seq: 6, at: 7, kind: "entry.settled", entry: "entry-1" }, + + { + id: "t-07", + seq: 7, + at: 10, + kind: "entry.submitted", + entry: "entry-2", + title: "Publish the release", + }, + { + id: "t-08", + seq: 8, + at: 11, + kind: "scope.opened", + entry: "entry-2", + scope: [], + name: "document", + source: 0, + }, + { + id: "t-09", + seq: 9, + at: 12, + kind: "scope.opened", + entry: "entry-2", + scope: ["document"], + name: "verify", + source: 0, + }, + { + id: "t-10", + seq: 10, + at: 15, + kind: "scope.completed", + entry: "entry-2", + scope: ["document"], + name: "verify", + }, + { + id: "t-11", + seq: 11, + at: 16, + kind: "binding.published", + entry: "entry-2", + name: "release", + value: "0.14.0", + }, + { + id: "t-12", + seq: 12, + at: 18, + kind: "scope.opened", + entry: "entry-2", + scope: ["document"], + name: "upload", + source: 1, + }, + { + id: "t-13", + seq: 13, + at: 22, + kind: "entry.failed", + entry: "entry-2", + reason: "the registry rejected the tarball", + }, + + { + id: "t-14", + seq: 14, + at: 26, + kind: "entry.submitted", + entry: "entry-3", + title: "Watch the deploy", + }, + { + id: "t-15", + seq: 15, + at: 27, + kind: "scope.opened", + entry: "entry-3", + scope: [], + name: "document", + source: 0, + }, + { + id: "t-16", + seq: 16, + at: 28, + kind: "scope.opened", + entry: "entry-3", + scope: ["document"], + name: "watch", + source: 0, + }, + { + id: "t-17", + seq: 17, + at: 31, + kind: "suspension.opened", + entry: "entry-3", + scope: ["document", "watch"], + wait: "approve", + prompt: "Approve the deploy?", + secret: false, + }, + { + id: "t-18", + seq: 18, + at: 35, + kind: "entry.interrupted", + entry: "entry-3", + reason: "the operator stopped the run", + }, +]; + +/** The execution the terminal journal records. */ +export const TERMINAL_EXECUTION = "e2"; + +/** The terminal journal, as a reader receives it. */ +export const TERMINAL_JOURNAL: readonly unknown[] = TERMINAL_RECORDS; + +/** The three markers a reader can stand on to see an entry's exact end state. */ +export const TERMINAL_MARKERS = { settled: "t-06", failed: "t-13", interrupted: "t-18" } as const; + +/** The last marker before `entry-2` failed. */ +export const BEFORE_FAILURE = "t-12"; + +/** + * A fork of the representative execution, taken at `r-07`. + * + * Its first record is built by `inherit()` from the parent's projection at + * exactly that marker — not from the parent's head, and not by writing the + * values out again here, which would only prove this file agrees with + * itself. Everything after it is the fork's own work. + * + * `e1`'s later `release` and `changelog` bindings are therefore absent, and + * absent because the projection at `r-07` did not have them rather than + * because nobody typed them. + */ +const INHERITED: unknown = (() => { + const events = parseJournal(RECORDS); + if (!events.ok) { + throw events.error; + } + const record = inherit(events.value, { + parent: "e1", + source: "r-07", + entry: "entry-0", + title: "Forked from the README run", + id: "f-01", + }); + if (!record.ok) { + throw record.error; + } + return record.value; +})(); + +const FORK_RECORDS: readonly unknown[] = [ + INHERITED, + { id: "f-02", seq: 2, at: 3, kind: "entry.settled", entry: "entry-0" }, + + { + id: "f-03", + seq: 3, + at: 8, + kind: "entry.submitted", + entry: "entry-1", + title: "Try the release again", + }, + { + id: "f-04", + seq: 4, + at: 9, + kind: "scope.opened", + entry: "entry-1", + scope: [], + name: "document", + source: 0, + }, + { + id: "f-05", + seq: 5, + at: 12, + kind: "binding.published", + entry: "entry-1", + name: "release", + value: "0.13.1", + }, + { + id: "f-06", + seq: 6, + at: 15, + kind: "suspension.opened", + entry: "entry-1", + scope: ["document"], + wait: "confirm", + prompt: "Tag 0.13.1 now?", + secret: false, + }, +]; + +/** The execution the fork records. */ +export const FORK_EXECUTION = "e1-fork"; + +/** The marker in the parent this fork was taken at. */ +export const FORK_SOURCE = "r-07"; + +/** The fork's own Journal, as a reader receives it. */ +export const FORK_JOURNAL: readonly unknown[] = FORK_RECORDS; + +/** The fork's first record alone: an inheritance nobody has settled yet. */ +export const FORK_INHERITED: readonly unknown[] = [INHERITED]; + +/** The fork's journal with one record's fields changed. */ +export function forkChanging(at: number, changes: Record): readonly unknown[] { + return FORK_RECORDS.map((record, index) => + index === at ? { ...Object(record), ...changes } : record, + ); +} + +/** The fork's journal with records appended after it. */ +export function forkWith(...records: readonly unknown[]): readonly unknown[] { + return [...FORK_RECORDS, ...records]; +} diff --git a/scripts/repl-hydration/fork.ts b/scripts/repl-hydration/fork.ts new file mode 100644 index 000000000..75c175330 --- /dev/null +++ b/scripts/repl-hydration/fork.ts @@ -0,0 +1,67 @@ +/** + * Taking a fork, which is the one moment a fork needs its parent. + * + * `inherit()` projects the parent at the source marker and writes what it + * finds into a single `entry.inherited` record. After that record exists the + * parent is only a name: reconstructing the fork reads the fork's own + * Journal, and the parent's presence decides nothing but whether the + * provenance link can be followed. + * + * The environment is read from the projection *at the marker*, never from the + * parent's head and never transcribed by hand. Copying it by hand is how a + * fixture comes to agree with itself and with nothing else, and taking it + * from the head is how a fork silently inherits work done after it was taken. + * + * It is one record on purpose. Spread across a run of ordinary publications + * the environment could be read half-copied — a prefix ending in the middle + * would hydrate into an environment that never existed anywhere — and a + * reader cannot tell a partial copy from a complete one. One record cannot be + * half-read. + */ + +import { Ok } from "effection"; +import type { Result } from "effection"; + +import type { SemanticEvent } from "./journal.ts"; +import { projectPrefix } from "./project.ts"; + +export interface Inheritance { + /** The execution being forked from. */ + readonly parent: string; + /** The marker in that execution to fork at. */ + readonly source: string; + /** The synthetic entry the fork begins with. */ + readonly entry: string; + readonly title: string; + /** The record's own durable identity in the fork's Journal. */ + readonly id: string; +} + +/** + * The fork's first record, built from the parent as it stood at `source`. + * + * A marker the parent never minted, or a parent whose records describe a run + * that cannot have happened, is a refusal: there is no environment to + * inherit, and inventing an empty one would make a fork of nothing look like + * a fork of something. + */ +export function inherit(events: readonly SemanticEvent[], at: Inheritance): Result { + const parent = projectPrefix(at.parent, events, at.source); + if (!parent.ok) { + return parent; + } + return Ok({ + id: at.id, + seq: 1, + at: 0, + kind: "entry.inherited", + entry: at.entry, + title: at.title, + parent: at.parent, + source: at.source, + bindings: parent.value.bindings.map((binding) => ({ + name: binding.name, + value: binding.value, + })), + }); +} diff --git a/scripts/repl-hydration/journal.ts b/scripts/repl-hydration/journal.ts new file mode 100644 index 000000000..524e741fc --- /dev/null +++ b/scripts/repl-hydration/journal.ts @@ -0,0 +1,469 @@ +/** + * Durable records in, a closed semantic vocabulary out. + * + * The Journal is the append-only durable record of what one REPL execution + * did. It is not the Execution History: History is the UI projection built + * from these records, and the two names are kept apart everywhere here. + * + * A record arrives as untrusted text — architecture.md's rule is that the + * journal is parsed, never trusted, and an unreadable record is refused rather + * than coerced. So this module takes `unknown` and answers a `Result`. Nothing + * downstream ever sees a record: the projector reads `SemanticEvent`, which is + * a closed union of nine kinds and carries only strings, numbers and arrays of + * strings. + * + * That closure is the mechanism, not a convention. A record naming a kind this + * vocabulary does not list is refused, and so is a record carrying a field the + * kind does not declare — which is what stops a pause controller, a held + * continuation, a callback, a renderer handle or a terminal cell being written + * into the durable stream and read back out as though it were an execution + * fact. The pause controller is process-local (#841) and has nothing to say + * here. + * + * A record's marker is its own opaque identity, never its position. Position + * orders replay; `id` is what a URL names. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +/** The eleven things one REPL execution durably records. */ +export const SEMANTIC_KINDS = [ + "entry.submitted", + "entry.inherited", + "entry.settled", + "entry.failed", + "entry.interrupted", + "scope.opened", + "scope.completed", + "binding.published", + "suspension.opened", + "suspension.answered", + "outcome.recorded", +] as const; + +export type SemanticKind = (typeof SEMANTIC_KINDS)[number]; + +/** + * How prominent the marker a record mints is, or that it mints none. + * + * A submission is where a reader enters the transcript, and a terminal record + * is where one run of it ends, so both are navigable in their own right — a + * settlement, a failure and an interruption are each a place to stand, not a + * property of the marker before them. An opening is where something visible + * began. A published binding and a recorded outcome are point facts with no + * closing half, so each is its own small checkpoint. + * + * Only the two *closing* kinds mint nothing: a scope completion and a + * suspension answer update the state the opening already minted. + */ +export type MarkerWeight = "boundary" | "terminal" | "opening" | "checkpoint"; + +export const MARKER_POLICY: Record = { + "entry.submitted": "boundary", + "entry.inherited": "boundary", + "entry.settled": "terminal", + "entry.failed": "terminal", + "entry.interrupted": "terminal", + "scope.opened": "opening", + "suspension.opened": "opening", + "binding.published": "checkpoint", + "outcome.recorded": "checkpoint", + "scope.completed": "none", + "suspension.answered": "none", +}; + +/** The kinds that mint a semantic History marker, in vocabulary order. */ +export const MARKER_KINDS: readonly SemanticKind[] = SEMANTIC_KINDS.filter( + (kind) => MARKER_POLICY[kind] !== "none", +); + +export function mintsMarker(kind: SemanticKind): boolean { + return MARKER_POLICY[kind] !== "none"; +} + +/** + * The weight of the marker a record mints. + * + * Asking this of a closing kind is a mistake the caller has already made, so + * it answers `"none"` rather than inventing a rank for a marker that does not + * exist. + */ +export function markerWeight(kind: SemanticKind): MarkerWeight | "none" { + return MARKER_POLICY[kind]; +} + +/** One name and value a fork carries over from its parent. */ +export interface InheritedBinding { + readonly name: string; + readonly value: string; +} + +/** What every record carries: its opaque identity, its position, its time. */ +interface Recorded { + /** The record's opaque durable identity. A marker is one of these. */ + readonly id: string; + /** Append position, starting at 1. It orders replay and names nothing. */ + readonly seq: number; + /** Recorded seconds, which is what the Execution History band measures. */ + readonly at: number; +} + +export type SemanticEvent = + | (Recorded & { + readonly kind: "entry.submitted"; + readonly entry: string; + readonly title: string; + }) + | (Recorded & { + /** + * The first entry of a fork: what it carries over, and where from. + * + * It submits an entry like any other, so the values it inherits are + * published into this Journal rather than read out of the parent's. The + * parent is named so the transcript can point at where it came from, + * and naming is all it is — nothing here needs the parent to exist. + */ + readonly kind: "entry.inherited"; + readonly entry: string; + readonly title: string; + /** The execution this fork was taken from. */ + readonly parent: string; + /** The marker in that execution the fork was taken at. */ + readonly source: string; + /** + * The whole inherited environment, in order, in this one record. + * + * Spread across a run of ordinary publications it could be read + * half-copied: a prefix ending in the middle would hydrate into an + * environment that never existed anywhere. One record cannot be + * half-read. + */ + readonly bindings: readonly InheritedBinding[]; + }) + | (Recorded & { readonly kind: "entry.settled"; readonly entry: string }) + | (Recorded & { readonly kind: "entry.failed"; readonly entry: string; readonly reason: string }) + | (Recorded & { + readonly kind: "entry.interrupted"; + readonly entry: string; + readonly reason: string; + }) + | (Recorded & { + readonly kind: "scope.opened"; + readonly entry: string; + /** The parent path inside the entry, outermost first. */ + readonly scope: readonly string[]; + readonly name: string; + /** The scope's ordinal in its parent's body. Concurrent siblings open out of it. */ + readonly source: number; + }) + | (Recorded & { + readonly kind: "scope.completed"; + readonly entry: string; + readonly scope: readonly string[]; + readonly name: string; + }) + | (Recorded & { + readonly kind: "binding.published"; + readonly entry: string; + readonly name: string; + readonly value: string; + }) + | (Recorded & { + readonly kind: "suspension.opened"; + readonly entry: string; + readonly scope: readonly string[]; + readonly wait: string; + readonly prompt: string; + /** Whether the answer is a secret. A secret one records no answer, ever. */ + readonly secret: boolean; + }) + | (Recorded & { + readonly kind: "suspension.answered"; + readonly entry: string; + readonly scope: readonly string[]; + readonly wait: string; + /** What was answered, so replay recovers it instead of asking again. Empty for a secret. */ + readonly answer: string; + }) + | (Recorded & { + readonly kind: "outcome.recorded"; + readonly entry: string; + readonly scope: readonly string[]; + /** The durable name of the work this is the outcome of, which is not the outcome. */ + readonly request: string; + readonly label: string; + }); + +/** A record the durable stream holds that this vocabulary cannot read. */ +export class JournalParseError extends Error { + /** The record's append position in the supplied journal, starting at 0. */ + readonly index: number; + /** The field that refused, or `record` when the record itself did. */ + readonly field: string; + + constructor(index: number, field: string, message: string) { + super(`record ${index}: ${message}`); + this.name = "JournalParseError"; + this.index = index; + this.field = field; + } +} + +/** The fields each kind declares, beyond the envelope. A record carries these and no others. */ +const FIELDS: Record = { + "entry.submitted": ["entry", "title"], + "entry.inherited": ["entry", "title", "parent", "source", "bindings"], + "entry.settled": ["entry"], + "entry.failed": ["entry", "reason"], + "entry.interrupted": ["entry", "reason"], + "scope.opened": ["entry", "scope", "name", "source"], + "scope.completed": ["entry", "scope", "name"], + "binding.published": ["entry", "name", "value"], + "suspension.opened": ["entry", "scope", "wait", "prompt", "secret"], + "suspension.answered": ["entry", "scope", "wait", "answer"], + "outcome.recorded": ["entry", "scope", "request", "label"], +}; + +/** The fields that are a path inside an entry rather than a name. */ +const PATHS: readonly string[] = ["scope"]; + +/** + * The fields that are a number rather than text, by the kind that declares + * them. + * + * `source` is an ordinal on a scope opening and a marker on an inherited + * entry. One table of field names could not tell those apart, and reading a + * marker as an ordinal would have refused every fork. + */ +const NUMBERS: Readonly> = { + "scope.opened": ["source"], +}; + +/** The fields that are a flag rather than text. */ +const FLAGS: readonly string[] = ["secret"]; + +/** The fields that are a whole environment rather than one value. */ +const ENVIRONMENTS: readonly string[] = ["bindings"]; + +/** + * The fields whose text may be empty, because empty is a value they can hold. + * + * `answer` is here because an empty one is the whole point: a secret + * elicitation records that it was answered and records nothing of what was + * said. The projection is what refuses a secret wait answered with a value; + * the parser cannot know which wait this record closes. + */ +const MAY_BE_EMPTY: readonly string[] = ["reason", "prompt", "value", "answer"]; + +const ENVELOPE: readonly string[] = ["id", "seq", "at", "kind"]; + +function isRecordObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isSemanticKind(value: unknown): value is SemanticKind { + return typeof value === "string" && SEMANTIC_KINDS.some((kind) => kind === value); +} + +function text(index: number, field: string, value: unknown): Result { + if (typeof value !== "string") { + return Err(new JournalParseError(index, field, `${field} is not text`)); + } + if (value === "" && !MAY_BE_EMPTY.includes(field)) { + return Err(new JournalParseError(index, field, `${field} is empty`)); + } + return Ok(value); +} + +function path(index: number, field: string, value: unknown): Result { + if (!Array.isArray(value)) { + return Err(new JournalParseError(index, field, `${field} is not a path`)); + } + const segments: string[] = []; + for (const segment of value) { + if (typeof segment !== "string" || segment === "") { + return Err(new JournalParseError(index, field, `${field} has a segment that is not a name`)); + } + segments.push(segment); + } + return Ok(segments); +} + +function ordinal(index: number, field: string, value: unknown): Result { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + return Err(new JournalParseError(index, field, `${field} is not a source ordinal`)); + } + return Ok(value); +} + +function flag(index: number, field: string, value: unknown): Result { + if (typeof value !== "boolean") { + return Err(new JournalParseError(index, field, `${field} is not a flag`)); + } + return Ok(value); +} + +/** + * An ordered environment, read. + * + * A member is a name and a value and nothing else, and a name appears once. + * Two members under one name would make the environment depend on which the + * reader kept, which is not a question a durable record may leave open. + */ +function environment( + index: number, + field: string, + value: unknown, +): Result { + if (!Array.isArray(value)) { + return Err(new JournalParseError(index, field, `${field} is not an environment`)); + } + const members: InheritedBinding[] = []; + for (const member of value) { + if (!isRecordObject(member)) { + return Err( + new JournalParseError(index, field, `${field} has a member that is not a binding`), + ); + } + const extra = Object.keys(member).find((key) => key !== "name" && key !== "value"); + if (extra !== undefined) { + return Err( + new JournalParseError(index, field, `a binding carries no ${JSON.stringify(extra)}`), + ); + } + if (typeof member.name !== "string" || member.name === "") { + return Err(new JournalParseError(index, field, `${field} has a member with no name`)); + } + if (typeof member.value !== "string") { + return Err( + new JournalParseError(index, field, `the binding ${member.name} has no text value`), + ); + } + if (members.some((one) => one.name === member.name)) { + return Err(new JournalParseError(index, field, `${field} names ${member.name} twice`)); + } + members.push({ name: member.name, value: member.value }); + } + return Ok(members); +} + +/** + * One durable record, read. + * + * Every declared field is required and every undeclared one refuses, so a + * record is complete or it is a refusal. A record cut short — the truncated + * case — is missing a field it declares, and says which. + */ +function parseRecord(index: number, raw: unknown, seen: Set): Result { + if (!isRecordObject(raw)) { + return Err(new JournalParseError(index, "record", "a journal record is a plain object")); + } + if (!isSemanticKind(raw.kind)) { + return Err( + new JournalParseError( + index, + "kind", + `${JSON.stringify(raw.kind)} is not a semantic kind; the kinds are ${SEMANTIC_KINDS.join(", ")}`, + ), + ); + } + const kind = raw.kind; + + const declared = [...ENVELOPE, ...FIELDS[kind]]; + const extra = Object.keys(raw).find((key) => !declared.includes(key)); + if (extra !== undefined) { + return Err( + new JournalParseError( + index, + extra, + `${JSON.stringify(extra)} is not part of a ${kind} record`, + ), + ); + } + + const id = text(index, "id", raw.id); + if (!id.ok) { + return id; + } + if (seen.has(id.value)) { + return Err(new JournalParseError(index, "id", `${JSON.stringify(id.value)} is recorded twice`)); + } + seen.add(id.value); + + if (typeof raw.seq !== "number" || raw.seq !== index + 1) { + return Err( + new JournalParseError( + index, + "seq", + `seq ${JSON.stringify(raw.seq)} is not append position ${index + 1}; the journal is truncated or reordered`, + ), + ); + } + if (typeof raw.at !== "number" || !Number.isFinite(raw.at) || raw.at < 0) { + return Err(new JournalParseError(index, "at", "at is not a recorded time")); + } + + const fields: Record< + string, + string | number | boolean | readonly string[] | readonly InheritedBinding[] + > = {}; + for (const field of FIELDS[kind]) { + if (!(field in raw)) { + return Err(new JournalParseError(index, field, `a ${kind} record declares ${field}`)); + } + const value = raw[field]; + const read = PATHS.includes(field) + ? path(index, field, value) + : (NUMBERS[kind] ?? []).includes(field) + ? ordinal(index, field, value) + : FLAGS.includes(field) + ? flag(index, field, value) + : ENVIRONMENTS.includes(field) + ? environment(index, field, value) + : text(index, field, value); + if (!read.ok) { + return read; + } + fields[field] = read.value; + } + + // Building the event by spreading the parsed fields over the parsed envelope + // keeps one definition of what each kind carries: the field table above. + const event: unknown = { id: id.value, seq: raw.seq, at: raw.at, kind, ...fields }; + if (!isSemanticEvent(event)) { + return Err(new JournalParseError(index, "record", `a ${kind} record did not read as one`)); + } + return Ok(event); +} + +function isSemanticEvent(value: unknown): value is SemanticEvent { + if (!isRecordObject(value) || !isSemanticKind(value.kind)) { + return false; + } + return FIELDS[value.kind].every((field) => field in value); +} + +/** + * A whole journal, read in append order, or the first record that refused. + * + * Nothing partial comes back. A journal with one unreadable record is a + * journal this vocabulary cannot describe, and answering with the records + * before it would be exactly the plausible partial view #842 refuses. + */ +export function parseJournal(records: readonly unknown[]): Result { + const events: SemanticEvent[] = []; + const seen = new Set(); + for (const [index, raw] of records.entries()) { + const event = parseRecord(index, raw, seen); + if (!event.ok) { + return event; + } + events.push(event.value); + } + return Ok(events); +} diff --git a/scripts/repl-hydration/layout.ts b/scripts/repl-hydration/layout.ts new file mode 100644 index 000000000..325646459 --- /dev/null +++ b/scripts/repl-hydration/layout.ts @@ -0,0 +1,82 @@ +/** + * Presentation, computed outside the store. + * + * Nothing here is ever written back. The store holds what a Journal and a URL + * say; a viewport is a property of the terminal the person happens to be + * looking at, and reflowing for a narrower one is a different reading of the + * same facts rather than a different set of them. + * + * `topology()` is what must not move: the entries, the scope tree, the drawer + * stack and the names in the environment, with no width, no wrapping and no + * value in it. `layout()` is what may: lines of text, wrapped and clipped to a + * viewport. The evidence lays one hydrated state out at two sizes and checks + * that exactly one of those two answers changed. + */ + +import type { Scope, SemanticModel } from "./model.ts"; +import type { SemanticState } from "./store.ts"; + +export interface Viewport { + readonly columns: number; + readonly rows: number; +} + +function paths(scopes: readonly Scope[], prefix: string): readonly string[] { + return scopes.flatMap((scope) => [ + `${prefix}/${scope.name}:${scope.outcome.status}`, + ...paths(scope.children, `${prefix}/${scope.name}`), + ]); +} + +/** + * The structure of one reconstructed moment, with nothing presentational in + * it. + * + * Two viewports describe the same execution, so this is the answer that has to + * be identical between them — and it is derived from the model rather than + * from anything a renderer produced, so a layout that quietly dropped a scope + * could not make it agree. + */ +export function topology(model: SemanticModel): readonly string[] { + return [ + `execution ${model.execution}@${model.marker}`, + ...model.entries.flatMap((entry) => [ + `entry ${entry.id}:${entry.outcome.status}`, + ...paths(entry.scopes, entry.id), + ]), + ...model.bindings.map((binding) => `binding ${binding.name}`), + ...model.suspensions.map((wait) => `drawer ${wait.entry}/${wait.wait}`), + ...model.outcomes.map((outcome) => `outcome ${outcome.label}`), + ]; +} + +function wrap(text: string, columns: number): readonly string[] { + if (text.length <= columns) { + return [text]; + } + const lines: string[] = []; + for (let at = 0; at < text.length; at += columns) { + lines.push(text.slice(at, at + columns)); + } + return lines; +} + +/** + * One hydrated state, drawn for one terminal. + * + * Plain text and no escape sequence anywhere: this experiment is about what + * the store may hold, and a renderer that emitted styling bytes would put the + * question the evidence asks — whether any of this reaches StarFX — one step + * further away rather than answering it. + */ +export function layout(semantic: SemanticState, viewport: Viewport): readonly string[] { + const body = [ + semantic.url, + ...semantic.model.entries.map( + (entry) => `${entry.id} ${entry.title} (${entry.outcome.status})`, + ), + ...semantic.model.bindings.map((binding) => `${binding.name} = ${binding.value}`), + ...semantic.history.map((marker) => `${marker.position} ${marker.id} ${marker.kind}`), + ]; + return body.flatMap((line) => wrap(line, viewport.columns)).slice(0, viewport.rows); +} diff --git a/scripts/repl-hydration/location.ts b/scripts/repl-hydration/location.ts new file mode 100644 index 000000000..4f0b96449 --- /dev/null +++ b/scripts/repl-hydration/location.ts @@ -0,0 +1,244 @@ +/** + * The #840 URL grammar, resolved against a Journal prefix. + * + * There is one REPL URL grammar and this is not a second one. `decodeRoute()` + * and `encodeRoute()` are imported from the accepted #840 router and used + * unchanged: the same five surfaces, the same `+drawer` segments, the same + * `at` / `inspect` / `draft` query, the same canonical spelling, the same + * equivalent-spelling decoding and the same refusals. A URL that #840 accepts + * is a URL this accepts, spelled the same way. + * + * What is adapted, and adapted here rather than there, is *resolution*. #840 + * resolves against a `ReplModel` holding every checkpoint of the execution, + * which is exactly the table #842 must not depend on: a resolver that looks a + * marker up in a snapshot table needs the table, and the table is supposed to + * be an accelerator. So resolution here projects the prefix the URL named and + * answers against that one model. Nothing in `repl-compose` changes, and its + * evidence is untouched. + * + * `RouteRefusal` is reused too, including its `position` / `segment` / `found` + * shape, so a refusal from this resolver reads exactly like a refusal from + * that one. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import { entryRoute, RouteRefusal, surfaceRoute } from "../repl-compose/router.ts"; +import type { Route, RouteSurface } from "../repl-compose/router.ts"; + +import type { SemanticEvent } from "./journal.ts"; +import type { Entry, Scope, SemanticModel, Suspension } from "./model.ts"; +import { markersOf, projectPrefix } from "./project.ts"; + +export { + decodeRoute, + encodeRoute, + entryRoute, + RouteRefusal, + RouteSyntaxError, + surfaceRoute, +} from "../repl-compose/router.ts"; +export type { Route, RouteSurface } from "../repl-compose/router.ts"; + +/** + * The same location with another draft. + * + * Typing moves the draft and nothing else, so the new route is built from the + * old one's own parts through the same constructors any other caller uses. + * The draft lives in the URL because the URL is where the selected location + * lives, and it reaches no Journal: there is no record kind that could carry + * it. + */ +export function withDraft(route: Route, draft: string): Result { + if (route.kind === "surface") { + return surfaceRoute({ + execution: route.execution, + surface: route.surface, + at: route.at, + inspect: route.inspect, + draft, + }); + } + return entryRoute({ + execution: route.execution, + surface: route.surface, + entry: route.entry, + scopes: route.scopes, + drawers: route.drawers, + at: route.at, + inspect: route.inspect, + draft, + }); +} + +/** What a location names, and the prefix it named it in. */ +interface Located { + readonly route: Route; + readonly surface: RouteSurface; + /** The model the selected prefix projects to. Every value below comes out of it. */ + readonly model: SemanticModel; + readonly inspecting: boolean; + readonly draft: string; +} + +/** A location naming a region and nothing inside an entry. */ +export interface SurfaceLocation extends Located { + readonly kind: "surface"; +} + +/** A location inside one entry of the selected prefix. */ +export interface EntryLocation extends Located { + readonly kind: "entry"; + readonly entry: Entry; + /** The scopes the path named, outermost first. The last one is selected. */ + readonly scopes: readonly Scope[]; + /** The suspensions the drawer path named, outermost first. */ + readonly drawers: readonly Suspension[]; +} + +export type SemanticLocation = SurfaceLocation | EntryLocation; + +function list(names: readonly string[]): string { + return names.length === 0 ? "none" : names.join(", "); +} + +/** + * Where a route resolves against one journal, or the first segment it could + * not resolve. + * + * The prefix is decided first and everything else is asked of it. That + * ordering is what makes historical inspection honest: an entry that had not + * been submitted at the selected marker is not an entry this location can + * name, and the refusal says which entries were there instead. + */ +export function resolveLocation( + route: Route, + execution: string, + events: readonly SemanticEvent[], +): Result { + if (route.execution !== execution) { + return Err( + new RouteRefusal( + "execution", + route.execution, + [execution], + `${JSON.stringify(route.execution)} is not this execution; these records are ${JSON.stringify(execution)}`, + ), + ); + } + + const projected = projectPrefix(execution, events, route.at); + if (!projected.ok) { + const markers = markersOf(events); + return Err( + new RouteRefusal( + "at", + route.at === undefined ? "" : route.at, + markers, + projected.error.message, + ), + ); + } + return resolveIn(route, projected.value); +} + +/** + * The same resolution, against a model that has already been projected. + * + * A caller that holds the prefix — because it kept one, or because it has + * just built one — resolves through here rather than projecting a second + * time. It is the same function `resolveLocation()` finishes with, so an + * accelerated answer and a cold one cannot diverge by taking different code. + * + * The model decides; a caller that hands over the wrong prefix gets a correct + * answer about the wrong moment, which is why a snapshot must never outlive + * the process that derived it. + */ +export function resolveIn(route: Route, model: SemanticModel): Result { + if (route.kind === "surface") { + return Ok({ + kind: "surface", + route, + surface: route.surface, + model, + inspecting: route.inspect, + draft: route.draft, + }); + } + + const entry = model.entries.find((candidate) => candidate.id === route.entry); + if (entry === undefined) { + const ids = model.entries.map((candidate) => candidate.id); + return Err( + new RouteRefusal( + "entry", + route.entry, + ids, + `${JSON.stringify(route.entry)} is not an entry at ${model.marker}; the entries there are ${list(ids)}`, + ), + ); + } + + const scopes: Scope[] = []; + let level: readonly Scope[] = entry.scopes; + let holder = entry.id; + for (const [index, name] of route.scopes.entries()) { + const found = level.find((scope) => scope.name === name); + if (found === undefined) { + const names = level.map((scope) => scope.name); + return Err( + new RouteRefusal( + `scope[${index}]`, + name, + names, + `${JSON.stringify(name)} is not a scope of ${holder} at ${model.marker}; the scopes there are ${list(names)}`, + ), + ); + } + scopes.push(found); + level = found.children; + holder = found.name; + } + + // One entry's drawer stack, in the order each wait opened. A prefix of it is + // a location; anything else is not, which is what stops a reordered stack + // resolving to a moment the execution never had. + const stack = model.suspensions.filter((suspension) => suspension.entry === entry.id); + const open = stack.map((suspension) => suspension.wait); + for (const [index, wait] of route.drawers.entries()) { + const suspension = stack[index]; + if (suspension === undefined) { + return Err( + new RouteRefusal( + `drawer[${index}]`, + wait, + open, + `there is no drawer ${index + 1} of ${entry.id} at ${model.marker}; its suspension stack is ${list(open)}`, + ), + ); + } + if (suspension.wait !== wait) { + return Err( + new RouteRefusal( + `drawer[${index}]`, + wait, + [suspension.wait], + `${JSON.stringify(wait)} is not drawer ${index + 1} of ${entry.id} at ${model.marker}; its suspension stack is ${list(open)}`, + ), + ); + } + } + + return Ok({ + kind: "entry", + route, + surface: route.surface, + model, + entry, + scopes, + drawers: stack.slice(0, route.drawers.length), + inspecting: route.inspect, + draft: route.draft, + }); +} diff --git a/scripts/repl-hydration/main.ts b/scripts/repl-hydration/main.ts new file mode 100644 index 000000000..0d0175a32 --- /dev/null +++ b/scripts/repl-hydration/main.ts @@ -0,0 +1,601 @@ +/** + * The documented command. + * + * deno task repl:hydration + * + * It reads the fixture journal once and prints what Slice 1 claims: the + * Execution History the records mint, the three prefixes #842 navigates + * between, what each one can and cannot see, the purity walk over a projected + * model, and one refusal of each kind. + * + * It renders nothing and mounts nothing. There is no store here yet and no + * process holding expansion — the overlay is printed beside the projection + * precisely to show that the projection does not consult it. + */ + +import { main } from "effection"; +import type { Operation } from "effection"; + +import { + BEFORE_FAILURE, + EXECUTION, + FORK_EXECUTION, + FORK_INHERITED, + FORK_JOURNAL, + JOURNAL, + journalChanging, + journalDropping, + journalWithout, + LIVE_HEAD, + PAUSE_MARKER, + positionOf, + TERMINAL_JOURNAL, + TERMINAL_MARKERS, +} from "./fixture.ts"; +import { MARKER_POLICY, parseJournal, SEMANTIC_KINDS } from "./journal.ts"; +import type { SemanticEvent } from "./journal.ts"; +import { decodeRoute, encodeRoute, resolveLocation } from "./location.ts"; +import type { Outcome, Scope, SemanticModel } from "./model.ts"; +import * as overlay from "./overlay.ts"; +import { markersOf, projectPrefix } from "./project.ts"; +import { foreignValues } from "./purity.ts"; +import { layout, topology } from "./layout.ts"; +import type { Viewport } from "./layout.ts"; +import { createStreaming, noSecrets, scriptedSecrets } from "./ephemeral.ts"; +import { answered, elicitation, resume, SCRIPT } from "./replay.ts"; +import { alone, library, provenanceLink } from "./provenance.ts"; +import { hydrate } from "./store.ts"; +import type { ReplSession, SemanticState } from "./store.ts"; + +const AT_PAUSE = `xmd://repl/e1/transcript/entry-3/document/publish/+source/+confirm?at=${PAUSE_MARKER}&inspect`; +const AT_HEAD = "xmd://repl/e1/transcript/entry-3/document/write"; +const HISTORICAL = "xmd://repl/e1/bindings?at=r-16"; + +function events(records: readonly unknown[]): readonly SemanticEvent[] { + const parsed = parseJournal(records); + if (!parsed.ok) { + throw parsed.error; + } + return parsed.value; +} + +function model(parsed: readonly SemanticEvent[], through?: string): SemanticModel { + const projected = projectPrefix(EXECUTION, parsed, through); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +function describe(one: SemanticModel): string { + const bindings = one.bindings.map((binding) => `${binding.name}=${binding.value}`).join(" "); + const waits = one.suspensions.map((suspension) => suspension.wait).join(" "); + const outcomes = one.outcomes.map((outcome) => outcome.label).join(" "); + return [ + ` records : ${one.records}`, + ` marker : ${one.marker} @${one.at}`, + ` entries : ${one.entries.map((entry) => `${entry.id}:${entry.outcome.status}`).join(" ")}`, + ` bindings : ${bindings === "" ? "none" : bindings}`, + ` drawers : ${waits === "" ? "none" : waits}`, + ` outcomes : ${outcomes === "" ? "none" : outcomes}`, + ].join("\n"); +} + +function say(outcome: Outcome): string { + return outcome.status === "failed" || outcome.status === "interrupted" + ? `${outcome.status} (${outcome.reason})` + : outcome.status; +} + +function lines(scopes: readonly Scope[], indent: string): readonly string[] { + return scopes.flatMap((scope) => [ + `${indent}${scope.name}: ${say(scope.outcome)}`, + ...lines(scope.children, `${indent} `), + ]); +} + +function refusal(what: string, thrown: unknown): string { + return thrown instanceof Error ? ` ${what}: ${thrown.name} — ${thrown.message}` : ` ${what}: ?`; +} + +function refused(what: string, records: readonly unknown[]): string { + const parsed = parseJournal(records); + if (parsed.ok) { + return ` ${what}: ACCEPTED, which is a defect`; + } + return refusal(what, parsed.error); +} + +/** The journey #842 names, as the locations it visits and what has arrived. */ +const JOURNEY: readonly { + readonly name: string; + readonly url: string; + readonly records: number; +}[] = [ + { name: "empty", url: "xmd://repl/e1/transcript", records: 0 }, + { name: "the first entry", url: "xmd://repl/e1/transcript/entry-1", records: 1 }, + { name: "nested scopes", url: "xmd://repl/e1/transcript/entry-1/document/plan", records: 3 }, + { name: "a published binding", url: "xmd://repl/e1/bindings", records: 7 }, + { name: "the expansion pause marker", url: AT_PAUSE, records: 22 }, + { name: "a background append, expansion held", url: AT_PAUSE, records: 23 }, + { + name: "historical inspection", + url: "xmd://repl/e1/transcript/entry-1/document?at=r-03", + records: 23, + }, + { name: "the live head", url: AT_HEAD, records: 23 }, + { name: "back to the pause marker", url: AT_PAUSE, records: 23 }, +]; + +const WIDE: Viewport = { columns: 120, rows: 40 }; +const NARROW: Viewport = { columns: 28, rows: 40 }; + +function* session(url: string, records: readonly unknown[]): Operation { + const opened = yield* hydrate(EXECUTION, url, records); + if (!opened.ok) { + throw opened.error; + } + return opened.value; +} + +function same(one: SemanticState, other: SemanticState): boolean { + return JSON.stringify(one) === JSON.stringify(other); +} + +function* walkJourney(): Operation { + const live = yield* session("xmd://repl/e1/transcript", []); + console.log("— the journey, accumulated live against a cold rebuild —"); + for (const step of JOURNEY) { + for (const record of JOURNAL.slice(live.state().records.length, step.records)) { + const applied = yield* live.append(record); + if (!applied.ok) { + throw applied.error; + } + } + const moved = yield* live.navigate(step.url); + if (!moved.ok) { + throw moved.error; + } + const cold = yield* session(step.url, JOURNAL.slice(0, step.records)); + const state = live.semantic(); + const future = state.history.filter((one) => one.position === "future").length; + console.log( + ` ${step.name.padEnd(38)} prefix ${(state.model.marker || "none").padEnd(5)} records ${String( + state.model.records, + ).padStart(2)} future markers ${future} rebuild identical ${same(state, cold.semantic())}`, + ); + } + console.log(""); + + const held = overlay.live(PAUSE_MARKER); + const paused = live.semantic(); + console.log("— Continue is the live process's to offer —"); + console.log( + ` at ${paused.model.marker}, holding : ${overlay.canContinueAt(held, paused.model.marker)}`, + ); + console.log( + ` at ${paused.model.marker}, released : ${overlay.canContinueAt(overlay.released(held), paused.model.marker)}`, + ); + console.log( + ` at ${paused.model.marker}, after restart: ${overlay.canContinueAt(overlay.cold(), paused.model.marker)}`, + ); + console.log(` the reconstruction is unchanged : ${same(live.semantic(), paused)}`); + console.log(""); + + console.log("— the cache accelerates and decides nothing —"); + console.log(` memoized markers : ${live.cached().join(" ")}`); + const warm = live.semantic(); + yield* live.discardSnapshots(); + const again = yield* live.navigate(AT_PAUSE); + if (!again.ok) { + throw again.error; + } + console.log(` after discarding them : ${same(live.semantic(), warm)}`); + console.log(""); + + console.log("— one state, two terminals —"); + const wide = layout(warm, WIDE); + const narrow = layout(warm, NARROW); + console.log(` lines at ${WIDE.columns} columns : ${wide.length}`); + console.log(` lines at ${NARROW.columns} columns : ${narrow.length}`); + console.log( + ` topology identical : ${ + JSON.stringify(topology(warm.model)) === JSON.stringify(topology(live.semantic().model)) + }`, + ); + console.log( + ` nothing foreign in the store: ${ + foreignValues(live.state(), "state").length === 0 ? "clean" : "FOUND" + }`, + ); +} + +/** The secret this trace uses, so the sweep below has something to look for. */ +const TOKEN = "npm_Ie4Xz9QqSECRETvalue"; + +function* walkRestart(): Operation { + const channel = elicitation("channel"); + const token = elicitation("token"); + + const first = resume({ + script: SCRIPT, + prior: [], + streaming: createStreaming(), + secrets: noSecrets(), + }); + if (!first.ok) { + throw first.error; + } + console.log("— a first run, live —"); + console.log(` performed : ${first.value.performed.join(", ")}`); + console.log( + ` frontier : ${first.value.frontier.kind} ${ + first.value.frontier.kind === "complete" ? "" : first.value.frontier.wait + }`, + ); + console.log(""); + + const withChannel = answered(first.value.records, channel, "#releases"); + if (!withChannel.ok) { + throw withChannel.error; + } + const second = resume({ + script: SCRIPT, + prior: withChannel.value, + streaming: createStreaming(), + secrets: noSecrets(), + }); + if (!second.ok) { + throw second.error; + } + const withToken = answered(second.value.records, token, ""); + if (!withToken.ok) { + throw withToken.error; + } + const records = withToken.value; + + const secrets = scriptedSecrets({ token: TOKEN }); + const streaming = createStreaming(); + const replayed = resume({ script: SCRIPT, prior: records, secrets, streaming }); + if (!replayed.ok) { + throw replayed.error; + } + console.log("— the same document after process loss —"); + console.log( + ` performed again : ${replayed.value.performed.length === 0 ? "nothing" : replayed.value.performed.join(", ")}`, + ); + console.log(` consumed : ${replayed.value.consumed.join(", ")}`); + console.log(` recovered : ${replayed.value.recovered.join(", ")}`); + console.log(` re-prompted for : ${replayed.value.asked.join(", ")}`); + console.log( + ` partial output : ${replayed.value.streaming.length === 0 ? "none" : replayed.value.streaming.join(", ")}`, + ); + console.log(` frontier : ${replayed.value.frontier.kind}`); + + const headless = resume({ + script: SCRIPT, + prior: records, + secrets: noSecrets(), + streaming: createStreaming(), + }); + if (!headless.ok) { + throw headless.error; + } + console.log( + ` with nobody to ask: ${headless.value.frontier.kind} ${ + headless.value.frontier.kind === "complete" ? "" : headless.value.frontier.wait + }`, + ); + console.log(""); + + const restarted = yield* session("xmd://repl/e3/transcript/entry-1/document/draft", records); + const typed = yield* restarted.type(TOKEN.slice(0, 4)); + if (!typed.ok) { + throw typed.error; + } + const draft = restarted.semantic().model.entries[0].scopes[0].children[0]; + console.log("— what the restart shows —"); + console.log( + ` admitted result : ${restarted + .semantic() + .model.outcomes.map((one) => one.label) + .join(", ")}`, + ); + console.log( + ` its scopes : ${draft.name} > ${draft.children.map((one) => one.name).join(" ")}`, + ); + console.log(` journal records : ${restarted.state().records.length} (typing appended none)`); + console.log(` navigation stack: ${restarted.visits().length}`); + console.log( + ` Continue offered: ${overlay.canContinueAt(overlay.cold(), restarted.semantic().model.marker)}`, + ); + console.log(""); + + const swept = [ + JSON.stringify(records), + JSON.stringify(restarted.state()), + JSON.stringify(restarted.visits()), + JSON.stringify(secrets.asked), + JSON.stringify(replayed.value), + ]; + const foreign = records.map((record) => ({ ...Object(record), entry: "other-entry" })); + const diverged = resume({ + script: SCRIPT, + prior: foreign, + secrets: noSecrets(), + streaming: createStreaming(), + }); + const swapped = records.map((record) => { + const one = Object(record); + return one.kind === "binding.published" ? { ...one, name: "other", value: "wrong" } : one; + }); + const wrongBinding = resume({ + script: SCRIPT, + prior: swapped, + secrets: noSecrets(), + streaming: createStreaming(), + }); + const at = records.findIndex((record) => Object(record).kind === "outcome.recorded"); + const restored = records + .slice(0, at + 1) + .map((record, index) => + index === at ? { ...Object(record), label: "RESTORED AGENT RESULT" } : record, + ); + const derived = resume({ + script: SCRIPT, + prior: restored, + secrets: noSecrets(), + streaming: createStreaming(), + }); + if (!derived.ok) { + throw derived.error; + } + const notes = derived.value.records.find( + (record) => Object(record).kind === "binding.published" && Object(record).name === "notes", + ); + console.log("— a matched operation returns its recorded result —"); + console.log(` the Agent was : ${derived.value.consumed.join(", ")}`); + console.log(` the next step performed: ${derived.value.performed.join(", ")}`); + console.log(` and published : ${JSON.stringify(Object(notes).value)}`); + console.log(""); + + console.log("— a record is consumed only when it is this step's own —"); + console.log( + ` another document's journal: ${diverged.ok ? "ACCEPTED, which is a defect" : diverged.error.message}`, + ); + console.log( + ` another binding, right kind: ${wrongBinding.ok ? "ACCEPTED, which is a defect" : wrongBinding.error.message}`, + ); + console.log(` the supplied journal is unchanged: ${foreign.length === records.length}`); + console.log(""); + + console.log("— the secret —"); + console.log(` asked for again in : ${secrets.asked.join(", ")}`); + console.log( + ` present in journal, store, navigation, audit or run: ${ + swept.some((surface) => surface.includes(TOKEN)) ? "FOUND, which is a defect" : "nowhere" + }`, + ); +} + +function* walkFork(): Operation { + const parsed = events(FORK_JOURNAL); + const projected = projectPrefix(FORK_EXECUTION, parsed, undefined); + if (!projected.ok) { + throw projected.error; + } + const fork = projected.value; + + const alone1 = events(FORK_INHERITED); + const first = projectPrefix(FORK_EXECUTION, alone1, undefined); + if (!first.ok) { + throw first.error; + } + + console.log("— a fork stands on its own —"); + console.log(` records : ${parsed.length}`); + console.log( + ` its first alone : ${first.value.entries[0].id} (${first.value.entries[0].outcome.status}), environment ${first.value.bindings + .map((one) => `${one.name}=${one.value}`) + .join(" ")}`, + ); + console.log(` inherited entry : ${fork.entries[0].id} (${fork.entries[0].outcome.status})`); + console.log( + ` environment : ${fork.bindings.map((one) => `${one.name}=${one.value}`).join(" ")}`, + ); + console.log(` published by : ${fork.bindings.map((one) => one.entry).join(" ")}`); + + const withParent = provenanceLink(fork, library({ [EXECUTION]: JOURNAL })); + const withoutParent = provenanceLink(fork, alone()); + console.log( + ` with the parent : ${withParent.kind} ${ + withParent.kind === "resolvable" ? withParent.url : "" + }`, + ); + console.log( + ` without the parent : ${withoutParent.kind} ${ + withoutParent.kind === "unavailable" + ? `(${withoutParent.parent}@${withoutParent.source})` + : "" + }`, + ); + + const unaided = yield* hydrate( + FORK_EXECUTION, + "xmd://repl/e1-fork/transcript/entry-1/document", + FORK_JOURNAL, + ); + if (!unaided.ok) { + throw unaided.error; + } + console.log(` hydrates unaided : ${unaided.value.semantic().model.execution}`); + console.log( + ` the parent's later work is absent: ${ + JSON.stringify(unaided.value.state()).includes("changelog") + ? "FOUND, which is a defect" + : "true" + }`, + ); +} + +function* run(): Operation { + const parsed = events(JOURNAL); + + console.log("— the marker policy —"); + for (const kind of SEMANTIC_KINDS) { + console.log(` ${kind.padEnd(20)} ${MARKER_POLICY[kind]}`); + } + console.log(""); + + console.log("— the Journal, and the Execution History it mints —"); + console.log(` durable records : ${parsed.length}`); + console.log(` semantic markers: ${markersOf(parsed).length}`); + console.log(` ${markersOf(parsed).join(" ")}`); + console.log(""); + + const held = overlay.live(PAUSE_MARKER); + console.log("— two independent positions —"); + console.log(` expansion pause point (live overlay): ${held.pauseMarker}`); + console.log(` live History head (durable) : ${model(parsed).marker}`); + console.log(""); + + console.log(`— the prefix at the expansion pause point, ${PAUSE_MARKER} —`); + console.log(describe(model(parsed, PAUSE_MARKER))); + console.log(""); + + console.log(`— the prefix at the live head, ${LIVE_HEAD} —`); + console.log(describe(model(parsed, LIVE_HEAD))); + console.log(""); + + console.log("— an earlier historical marker, r-16 —"); + console.log(describe(model(parsed, "r-16"))); + console.log(""); + + console.log("— source order, not dispatch order —"); + const document = model(parsed).entries[2].scopes[0]; + console.log(` opened : publish (r-17) then write (r-18)`); + console.log(` projects: ${document.children.map((scope) => scope.name).join(" then ")}`); + console.log(""); + + console.log("— what each entry inherited —"); + for (const entry of model(parsed).entries) { + const inherited = entry.inherited.map((binding) => `${binding.name}=${binding.value}`); + console.log(` ${entry.id}: ${inherited.length === 0 ? "nothing" : inherited.join(" ")}`); + } + console.log(""); + + console.log("— how an entry ends —"); + const terminal = events(TERMINAL_JOURNAL); + console.log(` durable records : ${terminal.length}`); + console.log(` semantic markers: ${markersOf(terminal).length}`); + for (const [name, marker] of Object.entries(TERMINAL_MARKERS)) { + const ended = model(terminal, marker); + const last = ended.markers[ended.markers.length - 1]; + console.log(` ${marker} ${name.padEnd(12)} weight=${last.weight}`); + for (const entry of ended.entries) { + console.log(` ${entry.id} ${say(entry.outcome)}`); + for (const line of lines(entry.scopes, " ")) { + console.log(line); + } + } + } + const before = model(terminal, BEFORE_FAILURE); + const endings = before.entries.filter( + (entry) => entry.outcome.status === "failed" || entry.outcome.status === "interrupted", + ); + console.log(` before the failure (${BEFORE_FAILURE}): ${endings.length} ended entries`); + console.log( + ` release survives the failure: ${model(terminal) + .bindings.map((binding) => `${binding.name}=${binding.value}`) + .join(" ")}`, + ); + console.log(""); + + console.log("— three locations —"); + for (const url of [AT_PAUSE, AT_HEAD, HISTORICAL]) { + const route = decodeRoute(url); + if (!route.ok) { + console.log(refusal(url, route.error)); + continue; + } + const located = resolveLocation(route.value, EXECUTION, parsed); + if (!located.ok) { + console.log(refusal(url, located.error)); + continue; + } + const where = located.value; + const inside = + where.kind === "entry" + ? `${where.entry.id} ${where.scopes.map((scope) => scope.name).join("/")} drawers=${where.drawers.length}` + : "—"; + console.log(` ${encodeRoute(where.route)}`); + console.log(` surface ${where.surface}, prefix ${where.model.marker}, ${inside}`); + } + console.log(""); + + console.log("— nothing foreign reached the model —"); + const foreign = foreignValues(model(parsed)); + console.log(` ${foreign.length === 0 ? "clean" : foreign.join("\n ")}`); + console.log(` overlay is a separate value: ${JSON.stringify(overlay.cold())}`); + console.log(""); + + console.log("— refusals —"); + console.log( + refused( + "a record naming the pause controller", + journalChanging(positionOf("r-22"), { kind: "pause.held" }), + ), + ); + console.log( + refused( + "a record carrying a continuation ", + journalChanging(positionOf("r-22"), { continuation: "held" }), + ), + ); + console.log( + refused("a truncated record ", journalDropping(positionOf("r-07"), "value")), + ); + console.log( + refused( + "a reordered journal ", + journalChanging(positionOf("r-07"), { seq: 99 }), + ), + ); + + const overlapping = parseJournal(journalWithout(positionOf("r-09"))); + if (overlapping.ok) { + const projected = projectPrefix(EXECUTION, overlapping.value); + console.log( + ` overlapping entries : ${projected.ok ? "ACCEPTED, which is a defect" : projected.error.message}`, + ); + } + + const impossible = parseJournal(journalWithout(positionOf("r-08"))); + if (impossible.ok) { + const projected = projectPrefix(EXECUTION, impossible.value); + console.log( + ` impossible scope closure : ${projected.ok ? "ACCEPTED, which is a defect" : projected.error.message}`, + ); + } + + const unresolved = decodeRoute("xmd://repl/e1/transcript/entry-3/document/plan"); + if (unresolved.ok) { + const answer = resolveLocation(unresolved.value, EXECUTION, parsed); + console.log( + ` a URL the execution never went to : ${answer.ok ? "ACCEPTED, which is a defect" : answer.error.message}`, + ); + } + console.log(""); + + yield* walkJourney(); + console.log(""); + + yield* walkRestart(); + console.log(""); + + yield* walkFork(); +} + +if (import.meta.main) { + await main(run); +} diff --git a/scripts/repl-hydration/model.ts b/scripts/repl-hydration/model.ts new file mode 100644 index 000000000..761cfd5c2 --- /dev/null +++ b/scripts/repl-hydration/model.ts @@ -0,0 +1,191 @@ +/** + * What one Journal prefix means, said as immutable values. + * + * This is the semantic model: the state of one REPL execution as of one + * selected marker. It is not the Journal, and it is not a store. A StarFX + * store is hydrated *from* this in Slice 2, and if the two ever disagree the + * Journal is right — which is only true while this value can be rebuilt from + * records alone. + * + * So a model holds one prefix, not a table of every prefix. Asking what an + * earlier marker looked like means projecting that prefix again, never reading + * a snapshot kept beside this one. A model that carried its own history of + * moments would make a cached checkpoint the cheapest way to answer, and then + * the accelerator would quietly have become the evidence. + * + * Every value here is frozen deeply, and every value here is plain data: + * strings, numbers, booleans and frozen containers of them. There is no + * optional member anywhere — a state that has a reason is a different shape + * from one that does not — so `undefined` reaching this model is a defect and + * `purity.ts` reports it as one. + */ + +import { deepFreeze } from "../repl-compose/model.ts"; + +export { deepFreeze }; + +/** A root binding as it stood at one marker. */ +export interface Binding { + readonly name: string; + readonly value: string; + /** The entry whose execution published this version. */ + readonly entry: string; + /** The marker that published this version. */ + readonly marker: string; +} + +/** + * What became of something that was opened. + * + * An entry or a scope is running until a record closes it. Settling carries + * nothing. A failure and an interruption each carry their reason, and an + * interrupted scope carries its entry's, because the Journal recorded one + * terminal record and not one per scope. + * + * There are four statuses and no fifth. A scope that was still open when its + * entry ended is `interrupted` — never `settled`, which would claim an + * outcome the execution never reached, and never `failed`, which would invent + * a failure for each scope out of the one the entry recorded. + */ +export type Outcome = + | { readonly status: "running" } + | { readonly status: "settled" } + | { readonly status: "failed"; readonly reason: string } + | { readonly status: "interrupted"; readonly reason: string }; + +/** + * One user-visible scope, and the scopes opened inside it. + * + * Children are held in source order — the ordinal each scope has in its + * parent's body — because concurrent siblings open in whatever order their + * coroutines are dispatched, and the transcript is a reading of the document. + * + * A completed scope stays in the tree. Leaving a scope closes it rather than + * erasing it, which is how a finished scope remains a place a URL can name. + */ +export interface Scope { + readonly name: string; + readonly source: number; + readonly outcome: Outcome; + /** The marker the scope's opening minted. */ + readonly marker: string; + readonly children: readonly Scope[]; +} + +/** + * One durable wait, and the scope inside one entry that owns it. + * + * `secret` travels with the request because a drawer for a secret must never + * echo what is typed into it, and because replay has to know that answering + * this one again is the only way to get past it. The answer itself is not + * here and is not anywhere: a secret elicitation records that it was asked + * and that it was answered, and nothing else. + */ +export interface Suspension { + readonly wait: string; + readonly entry: string; + /** The owning scope path, outermost first. Empty means the entry's own body. */ + readonly scope: readonly string[]; + readonly prompt: string; + readonly secret: boolean; + readonly marker: string; +} + +/** + * One durable outcome a coroutine recorded. + * + * `request` names the work and `label` is what came back. They are separate + * because restart replay matches on the first and restores the second: an + * outcome identified by its own result could only ever be recognized by + * already knowing the answer. + */ +export interface Recorded { + readonly entry: string; + readonly scope: readonly string[]; + readonly request: string; + readonly label: string; + readonly marker: string; +} + +/** + * One submitted top-level entry. + * + * `inherited` is the root environment the entry was submitted into: the + * bindings published before it, as they stood then. It is what makes + * "sequential entries inherit the latest published root bindings" a value + * rather than a claim about ordering, and it is why a binding published by an + * entry that later failed is still visible to the entry after it. + */ +export interface Entry { + readonly id: string; + readonly title: string; + readonly outcome: Outcome; + /** The marker the submission minted. */ + readonly marker: string; + readonly scopes: readonly Scope[]; + readonly inherited: readonly Binding[]; +} + +/** + * One semantic History marker: a navigable position, not a durable record. + * + * Execution History is the UI projection of the Journal, and this is its unit. + * `weight` is how prominent the position is, which is the marker policy said + * as a value: a submission is a boundary, an entry's end is terminal, an + * opening is an opening, and a point fact is a small checkpoint. Only a + * closing record mints nothing at all. + */ +export interface Marker { + readonly id: string; + readonly at: number; + readonly kind: string; + readonly weight: string; + readonly entry: string; +} + +/** + * Where an execution came from. + * + * A fork carries its parent's name and the marker it was taken at, and + * carries them in its *own* Journal. That is what lets the transcript point + * back without needing the parent to be there: the pointer is a fact this + * execution recorded, and only following it needs the other one. + */ +export type Provenance = + | { readonly kind: "root" } + | { + readonly kind: "forked"; + readonly parent: string; + /** The marker in the parent this fork was taken at. */ + readonly source: string; + /** The entry in this execution that carries what was inherited. */ + readonly entry: string; + }; + +/** One execution as of one selected marker. */ +export interface SemanticModel { + readonly execution: string; + /** Where this execution came from, said by this execution's own records. */ + readonly provenance: Provenance; + /** The newest marker in this prefix, which is this prefix's History head. */ + readonly marker: string; + readonly at: number; + /** + * How many durable records this prefix applied. + * + * A marker names a record, and records after it that mint no marker are + * still part of the live head. Holding the count is what lets the evidence + * say whether selecting the newest marker and selecting the live head are + * the same prefix, rather than assuming it. + */ + readonly records: number; + readonly entries: readonly Entry[]; + /** The root environment at this prefix, in first-publication order. */ + readonly bindings: readonly Binding[]; + /** Every unanswered wait at this prefix, in the order each opened. */ + readonly suspensions: readonly Suspension[]; + /** Every durable outcome recorded at this prefix, in append order. */ + readonly outcomes: readonly Recorded[]; + /** The Execution History of this prefix, in append order. */ + readonly markers: readonly Marker[]; +} diff --git a/scripts/repl-hydration/overlay.ts b/scripts/repl-hydration/overlay.ts new file mode 100644 index 000000000..2499ed0e6 --- /dev/null +++ b/scripts/repl-hydration/overlay.ts @@ -0,0 +1,85 @@ +/** + * What only a live process knows, kept where it cannot be mistaken for + * evidence. + * + * #841 established that the pause controller is generator state in a scope + * outside the subtree it holds: ephemeral, never journaled, and gone with the + * process. #842 has to show that nothing durable depends on it, which needs + * the thing to exist somewhere — so it exists here, in a module the pure + * boundary does not import and cannot be handed. + * + * The overlay answers two questions and owns no state of its own: + * + * - which marker expansion is currently held at, if any; + * - whether Continue is offered, which is true only while this process still + * holds that exact continuation. + * + * Both are `live`. After process loss there is no overlay at all — not an + * overlay reporting `false`, which would still be a claim about a pause — and + * `cold()` is what that absence is spelled as. A reconstruction from Journal + * and URL alone gets `cold()`, and a UI reading it has nothing to say about + * EXPANSION PAUSED because there is nothing there that could say it. + * + * Nothing in this module may be passed to `parseJournal()`, `projectPrefix()` + * or `resolveLocation()`. None of them has a parameter it would fit, and the + * import evidence holds them to never acquiring one. + */ + +/** + * A process that is holding XMD expansion. + * + * The expansion pause point is fixed while this exists; the live History head + * is free to move past it, because background work keeps recording. + */ +export interface LiveOverlay { + readonly held: true; + /** The marker whose record expansion is held at. */ + readonly pauseMarker: string; + /** Offered only while this process owns the original held continuation. */ + readonly canContinue: boolean; +} + +/** No process is holding anything: either it never was, or it is gone. */ +export interface ColdOverlay { + readonly held: false; +} + +export type Overlay = LiveOverlay | ColdOverlay; + +/** The overlay of a process holding expansion at one marker. */ +export function live(pauseMarker: string): LiveOverlay { + return { held: true, pauseMarker, canContinue: true }; +} + +/** + * The same process after the continuation is discarded. + * + * Expansion is still held at the same marker — a held routine does not resume + * because its continuation was dropped — but Continue is no longer something + * this process can offer. + */ +export function released(overlay: LiveOverlay): LiveOverlay { + return { held: true, pauseMarker: overlay.pauseMarker, canContinue: false }; +} + +/** What survives process loss, which is nothing. */ +export function cold(): ColdOverlay { + return { held: false }; +} + +/** + * Whether Continue may be offered while standing at `marker`. + * + * Three things have to hold at once: a process is holding expansion, it still + * owns the original continuation, and the marker being looked at is the one + * it is holding at. Returning to the expansion pause point and continuing are + * separate actions, so standing at the live head offers nothing even while the + * hold is intact. + * + * This takes an overlay and a marker and touches no store. A reconstruction + * that has only a Journal and a URL has no overlay to pass, and `cold()` + * answers `false` everywhere without a claim about whether anything is paused. + */ +export function canContinueAt(overlay: Overlay, marker: string): boolean { + return overlay.held && overlay.canContinue && overlay.pauseMarker === marker; +} diff --git a/scripts/repl-hydration/project.ts b/scripts/repl-hydration/project.ts new file mode 100644 index 000000000..8d2ec212f --- /dev/null +++ b/scripts/repl-hydration/project.ts @@ -0,0 +1,553 @@ +/** + * A Journal prefix in, one semantic model out. + * + * Two functions, and the relationship between them is the point. + * `projectPrefix()` folds a prefix from nothing, every time it is asked. + * `foldMarkers()` folds once and snapshots at each marker, which is what a + * live session accumulating records does. They must agree at every marker, and + * they agree because neither can read the other: `projectPrefix()` takes + * records and a marker, and there is no snapshot argument it could be handed. + * + * That is the whole hydration claim in one signature. A projector that could + * accept a cached checkpoint would make the cache load-bearing the first time + * someone passed one, and no test written afterwards would find it. + * + * Nothing here knows the pause controller exists. Expansion may be held (#841) + * while background work appends durable outcomes, and that is not a fact about + * this fold: the prefix ending at the pause marker is the same prefix whether + * or not a process is currently holding a continuation there. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import { markerWeight, mintsMarker } from "./journal.ts"; +import type { SemanticEvent } from "./journal.ts"; +import { deepFreeze } from "./model.ts"; +import type { + Binding, + Entry, + Marker, + Outcome, + Provenance, + Recorded, + Scope, + SemanticModel, + Suspension, +} from "./model.ts"; + +/** A journal that is internally inconsistent: readable records describing an impossible run. */ +export class ProjectionError extends Error { + /** The record that could not be applied. */ + readonly record: string; + readonly seq: number; + readonly kind: string; + + constructor(event: SemanticEvent, message: string) { + super(`${event.id} (${event.kind} in ${event.entry}): ${message}`); + this.name = "ProjectionError"; + this.record = event.id; + this.seq = event.seq; + this.kind = event.kind; + } +} + +/** A marker no record in this journal minted. */ +export class UnknownMarkerError extends Error { + readonly marker: string; + readonly markers: readonly string[]; + + constructor(marker: string, markers: readonly string[]) { + super( + `${JSON.stringify(marker)} names no semantic marker; the markers are ${ + markers.length === 0 ? "none" : markers.join(", ") + }`, + ); + this.name = "UnknownMarkerError"; + this.marker = marker; + this.markers = [...markers]; + } +} + +interface DraftScope { + readonly name: string; + readonly source: number; + outcome: Outcome; + readonly marker: string; + readonly children: DraftScope[]; +} + +interface DraftEntry { + readonly id: string; + readonly title: string; + outcome: Outcome; + readonly marker: string; + readonly scopes: DraftScope[]; + readonly inherited: readonly Binding[]; +} + +interface Draft { + provenance: Provenance; + readonly entries: DraftEntry[]; + readonly bindings: Binding[]; + readonly suspensions: Suspension[]; + readonly outcomes: Recorded[]; + readonly markers: Marker[]; +} + +function draft(): Draft { + return { + provenance: { kind: "root" }, + entries: [], + bindings: [], + suspensions: [], + outcomes: [], + markers: [], + }; +} + +function findScope(scopes: readonly DraftScope[], path: readonly string[]): DraftScope | undefined { + let level = scopes; + let found: DraftScope | undefined; + for (const name of path) { + found = level.find((scope) => scope.name === name); + if (found === undefined) { + return undefined; + } + level = found.children; + } + return found; +} + +function running(scopes: readonly DraftScope[]): DraftScope | undefined { + for (const scope of scopes) { + if (scope.outcome.status === "running") { + return scope; + } + const deeper = running(scope.children); + if (deeper !== undefined) { + return deeper; + } + } + return undefined; +} + +/** + * Every descendant scope still running becomes interrupted; the rest are left + * exactly as they are. + * + * The status check is what keeps a scope that completed before the terminal + * record completed. Walking a whole subtree and stamping it would rewrite + * history the Journal already recorded. + */ +function interrupt(scopes: readonly DraftScope[], reason: string): void { + for (const scope of scopes) { + if (scope.outcome.status === "running") { + scope.outcome = { status: "interrupted", reason }; + } + interrupt(scope.children, reason); + } +} + +/** Close every wait the entry still had open when it ended. */ +function closeWaits(state: Draft, entry: string): void { + for (let index = state.suspensions.length - 1; index >= 0; index -= 1) { + if (state.suspensions[index].entry === entry) { + state.suspensions.splice(index, 1); + } + } +} + +/** Whether `path` names `scope` itself or something inside it. */ +function within(path: readonly string[], scope: readonly string[]): boolean { + return scope.length <= path.length && scope.every((name, at) => name === path[at]); +} + +function openEntry(state: Draft, event: SemanticEvent): Result { + const entry = state.entries.find((candidate) => candidate.id === event.entry); + if (entry === undefined) { + return Err(new ProjectionError(event, "names an entry no record submitted")); + } + if (entry.outcome.status !== "running") { + return Err( + new ProjectionError(event, `names ${entry.id}, which is already ${entry.outcome.status}`), + ); + } + return Ok(entry); +} + +function apply(state: Draft, event: SemanticEvent): Result { + if (event.kind === "entry.submitted" || event.kind === "entry.inherited") { + if (state.entries.some((entry) => entry.id === event.entry)) { + return Err(new ProjectionError(event, "submits an entry that is already recorded")); + } + // What this entry was submitted into, read before it publishes anything. + // A fork's synthetic entry inherits nothing: it *is* the inheritance, and + // reporting the environment it just introduced as its own inheritance + // would make the fork look like it started from itself. + const before = state.bindings.map((binding) => ({ ...binding })); + + if (event.kind === "entry.inherited") { + // One fork, one inheritance. A second would describe an execution with + // two pasts, and nothing could say which environment it started in. + if (state.provenance.kind === "forked") { + return Err( + new ProjectionError( + event, + `inherits again; this execution already forked from ${state.provenance.parent}`, + ), + ); + } + if (state.entries.length > 0) { + return Err(new ProjectionError(event, "inherits after this execution already began")); + } + state.provenance = { + kind: "forked", + parent: event.parent, + source: event.source, + entry: event.entry, + }; + // The environment arrives with the entry, from this one record. There + // is no prefix of a fork that has the entry and only some of it. + for (const inherited of event.bindings) { + state.bindings.push({ + name: inherited.name, + value: inherited.value, + entry: event.entry, + marker: event.id, + }); + } + } + // Top-level entries run serially. Two overlapping is a shape the product + // never reaches, and describing one would carry a second live scope tree + // into everything that reads the model. + const live = state.entries.find((entry) => entry.outcome.status === "running"); + if (live !== undefined) { + return Err(new ProjectionError(event, `overlaps ${live.id}, which is still running`)); + } + state.entries.push({ + id: event.entry, + title: event.title, + outcome: { status: "running" }, + marker: event.id, + scopes: [], + inherited: before, + }); + return Ok(); + } + + const owner = openEntry(state, event); + if (!owner.ok) { + return owner; + } + const entry = owner.value; + + if (event.kind === "entry.settled") { + const waiting = state.suspensions.find((suspension) => suspension.entry === entry.id); + if (waiting !== undefined) { + return Err(new ProjectionError(event, `settles an entry still waiting on ${waiting.wait}`)); + } + const open = running(entry.scopes); + if (open !== undefined) { + return Err( + new ProjectionError(event, `settles an entry whose ${open.name} scope has not completed`), + ); + } + entry.outcome = { status: "settled" }; + return Ok(); + } + + if (event.kind === "entry.failed" || event.kind === "entry.interrupted") { + // One terminal record ends the entry and interrupts whatever it still had + // open. The entry carries what the record said; each still-running scope + // carries the same reason, because the Journal recorded one ending and + // not one per scope. Published bindings are untouched. + interrupt(entry.scopes, event.reason); + closeWaits(state, entry.id); + entry.outcome = + event.kind === "entry.failed" + ? { status: "failed", reason: event.reason } + : { status: "interrupted", reason: event.reason }; + return Ok(); + } + + if (event.kind === "binding.published") { + const published: Binding = { + name: event.name, + value: event.value, + entry: entry.id, + marker: event.id, + }; + const at = state.bindings.findIndex((binding) => binding.name === event.name); + if (at === -1) { + state.bindings.push(published); + } else { + state.bindings[at] = published; + } + return Ok(); + } + + const parent = event.scope.length === 0 ? undefined : findScope(entry.scopes, event.scope); + if (event.scope.length > 0 && parent === undefined) { + return Err(new ProjectionError(event, `names a scope path no record opened`)); + } + const level = parent === undefined ? entry.scopes : parent.children; + + if (event.kind === "scope.opened") { + if (parent !== undefined && parent.outcome.status !== "running") { + return Err( + new ProjectionError( + event, + `opens inside ${parent.name}, which is already ${parent.outcome.status}`, + ), + ); + } + if (level.some((scope) => scope.name === event.name)) { + return Err(new ProjectionError(event, "opens a scope that is already open there")); + } + // Two siblings claiming one position in their parent's body cannot be put + // in source order, and the transcript is a reading of the document. + const taken = level.find((scope) => scope.source === event.source); + if (taken !== undefined) { + return Err( + new ProjectionError( + event, + `claims source position ${event.source}, which ${taken.name} holds`, + ), + ); + } + level.push({ + name: event.name, + source: event.source, + outcome: { status: "running" }, + marker: event.id, + children: [], + }); + return Ok(); + } + + if (event.kind === "scope.completed") { + const leaving = level.find((scope) => scope.name === event.name); + if (leaving === undefined) { + return Err(new ProjectionError(event, "completes a scope no record opened")); + } + if (leaving.outcome.status !== "running") { + return Err( + new ProjectionError( + event, + `completes ${leaving.name}, which is already ${leaving.outcome.status}`, + ), + ); + } + const inside = running(leaving.children); + if (inside !== undefined) { + return Err( + new ProjectionError( + event, + `completes ${leaving.name} while ${inside.name} is still open inside it`, + ), + ); + } + const path = [...event.scope, event.name]; + const waiting = state.suspensions.find( + (suspension) => suspension.entry === entry.id && within(suspension.scope, path), + ); + if (waiting !== undefined) { + return Err( + new ProjectionError(event, `completes ${leaving.name} while it waits on ${waiting.wait}`), + ); + } + leaving.outcome = { status: "settled" }; + return Ok(); + } + + if (parent !== undefined && parent.outcome.status !== "running") { + return Err( + new ProjectionError(event, `names ${parent.name}, which is already ${parent.outcome.status}`), + ); + } + + if (event.kind === "suspension.opened") { + const already = state.suspensions.find( + (suspension) => + suspension.entry === entry.id && + suspension.wait === event.wait && + suspension.scope.length === event.scope.length && + suspension.scope.every((name, at) => name === event.scope[at]), + ); + if (already !== undefined) { + return Err( + new ProjectionError(event, `opens a ${event.wait} wait that is already open there`), + ); + } + state.suspensions.push({ + wait: event.wait, + entry: entry.id, + scope: [...event.scope], + prompt: event.prompt, + secret: event.secret, + marker: event.id, + }); + return Ok(); + } + + if (event.kind === "suspension.answered") { + // An answer names one wait completely: the entry, the exact scope path + // inside it, and the kind. Matching on less lets an answer consume a wait + // that belongs somewhere else and reconstruct a moment that never was. + let answered = -1; + for (let index = state.suspensions.length - 1; index >= 0 && answered === -1; index -= 1) { + const suspension = state.suspensions[index]; + const same = + suspension.entry === entry.id && + suspension.wait === event.wait && + suspension.scope.length === event.scope.length && + suspension.scope.every((name, at) => name === event.scope[at]); + if (same) { + answered = index; + } + } + if (answered === -1) { + return Err(new ProjectionError(event, "answers no wait this entry has open there")); + } + // A secret answer is not stored anywhere, so a record carrying one is a + // journal that already leaked. Refusing it is the last place that can be + // said, and saying it by dropping the value instead would leave the leak + // written down and merely unread. + if (state.suspensions[answered].secret && event.answer !== "") { + return Err( + new ProjectionError(event, `answers the secret wait ${event.wait} with a recorded value`), + ); + } + state.suspensions.splice(answered, 1); + return Ok(); + } + + state.outcomes.push({ + entry: entry.id, + scope: [...event.scope], + request: event.request, + label: event.label, + marker: event.id, + }); + return Ok(); +} + +function copyScope(scope: DraftScope): Scope { + return { + name: scope.name, + source: scope.source, + outcome: { ...scope.outcome }, + marker: scope.marker, + // Concurrent siblings open in dispatch order; the transcript reads them in + // the order the document writes them. + children: scope.children.toSorted((one, other) => one.source - other.source).map(copyScope), + }; +} + +function copyEntry(entry: DraftEntry): Entry { + return { + id: entry.id, + title: entry.title, + outcome: { ...entry.outcome }, + marker: entry.marker, + scopes: entry.scopes.toSorted((one, other) => one.source - other.source).map(copyScope), + inherited: entry.inherited.map((binding) => ({ ...binding })), + }; +} + +function snapshot(execution: string, state: Draft, records: number): SemanticModel { + const head = state.markers[state.markers.length - 1]; + return deepFreeze({ + execution, + provenance: { ...state.provenance }, + marker: head === undefined ? "" : head.id, + at: head === undefined ? 0 : head.at, + records, + entries: state.entries.map(copyEntry), + bindings: state.bindings.map((binding) => ({ ...binding })), + suspensions: state.suspensions.map((suspension) => ({ + ...suspension, + scope: [...suspension.scope], + })), + outcomes: state.outcomes.map((outcome) => ({ ...outcome, scope: [...outcome.scope] })), + markers: state.markers.map((marker) => ({ ...marker })), + }); +} + +function mintMarker(event: SemanticEvent): Marker { + return { + id: event.id, + at: event.at, + kind: event.kind, + weight: markerWeight(event.kind), + entry: event.entry, + }; +} + +/** Every semantic marker this journal mints, in append order. */ +export function markersOf(events: readonly SemanticEvent[]): readonly string[] { + return events.filter((event) => mintsMarker(event.kind)).map((event) => event.id); +} + +/** + * The model at one marker, or at the live head when no marker is named. + * + * The prefix ends at the record the marker identifies, so a later record is + * not merely hidden — it is never applied, and there is nothing in the answer + * for it to have touched. Naming the live head applies every record, including + * any that arrived while expansion was held somewhere earlier. + */ +export function projectPrefix( + execution: string, + events: readonly SemanticEvent[], + through?: string, +): Result { + let end = events.length; + if (through !== undefined) { + const at = events.findIndex((event) => event.id === through && mintsMarker(event.kind)); + if (at === -1) { + return Err(new UnknownMarkerError(through, markersOf(events))); + } + end = at + 1; + } + + const state = draft(); + for (let index = 0; index < end; index += 1) { + const event = events[index]; + const applied = apply(state, event); + if (!applied.ok) { + return applied; + } + if (mintsMarker(event.kind)) { + state.markers.push(mintMarker(event)); + } + } + return Ok(snapshot(execution, state, end)); +} + +/** + * One fold, snapshotting at every marker: what a live session accumulates. + * + * The answer is keyed by marker so the evidence can compare it against a + * from-scratch projection of the same marker. It is an accelerator's shape, + * and it is never an input to anything. + */ +export function foldMarkers( + execution: string, + events: readonly SemanticEvent[], +): Result> { + const state = draft(); + const accumulated = new Map(); + for (const [index, event] of events.entries()) { + const applied = apply(state, event); + if (!applied.ok) { + return applied; + } + if (mintsMarker(event.kind)) { + state.markers.push(mintMarker(event)); + accumulated.set(event.id, snapshot(execution, state, index + 1)); + } + } + return Ok(accumulated); +} diff --git a/scripts/repl-hydration/provenance.ts b/scripts/repl-hydration/provenance.ts new file mode 100644 index 000000000..55f2e072a --- /dev/null +++ b/scripts/repl-hydration/provenance.ts @@ -0,0 +1,122 @@ +/** + * Following a fork back to where it came from, when that is possible. + * + * A fork records its parent's name and the marker it was taken at, so what it + * came from is a fact in its own Journal and survives whatever happens to the + * parent. Whether that fact can be *followed* is a different question, asked + * of whatever journals this process can reach, and answered here. + * + * The distinction is the whole point of the module. Removing the parent must + * cost the link and nothing else: the fork still reconstructs, still says + * where it came from, and merely has nowhere to send someone who clicks. A + * design that resolved provenance while hydrating would have made the parent + * a dependency of the fork, which is exactly what #842 forbids. + * + * Nothing here is imported by the projector or the store. A link is computed + * from a model that is already built. + */ + +import { encodeRoute, surfaceRoute } from "./location.ts"; +import { parseJournal } from "./journal.ts"; +import type { SemanticModel } from "./model.ts"; +import { markersOf, projectPrefix } from "./project.ts"; + +/** One execution's records, if this process can reach them. */ +export type Found = + | { readonly found: true; readonly records: readonly unknown[] } + | { readonly found: false }; + +/** The journals this process can reach. A cold one reaches none. */ +export interface Library { + journalOf(execution: string): Found; +} + +/** A library holding the executions it was given. */ +export function library(journals: Readonly>): Library { + return { + journalOf(execution) { + const records = journals[execution]; + return records === undefined ? { found: false } : { found: true, records }; + }, + }; +} + +/** A process that can reach nothing but the execution in front of it. */ +export function alone(): Library { + return { journalOf: () => ({ found: false }) }; +} + +/** + * What the transcript shows above a fork. + * + * `none` is a root execution. `unavailable` still names the parent and the + * marker, because that is what the fork recorded and it stays true; it simply + * cannot be opened from here. `resolvable` carries the one canonical URL that + * opens the parent at the marker this fork was taken from. + */ +export type ProvenanceLink = + | { readonly kind: "none" } + | { + readonly kind: "unavailable"; + readonly parent: string; + readonly source: string; + readonly why: string; + } + | { + readonly kind: "resolvable"; + readonly parent: string; + readonly source: string; + readonly url: string; + }; + +/** + * Where this execution came from, and whether it can be opened from here. + * + * Unreadable parent records make the link unavailable rather than making this + * a failure: a fork whose parent's Journal is corrupt is still a fork that + * reconstructs, and refusing here would let the parent's condition decide + * whether the fork works. + */ +export function provenanceLink(model: SemanticModel, reachable: Library): ProvenanceLink { + if (model.provenance.kind === "root") { + return { kind: "none" }; + } + const { parent, source } = model.provenance; + + const found = reachable.journalOf(parent); + if (!found.found) { + return { kind: "unavailable", parent, source, why: `${parent} is not available here` }; + } + + const events = parseJournal(found.records); + if (!events.ok) { + return { kind: "unavailable", parent, source, why: `${parent} cannot be read` }; + } + if (!markersOf(events.value).includes(source)) { + return { + kind: "unavailable", + parent, + source, + why: `${parent} has no marker ${source}`, + }; + } + // A parent that reads but cannot have happened has nothing to open at that + // marker either. Every one of these is the link going dark, never the fork + // failing: whether the parent is well is not the fork's business. + const at = projectPrefix(parent, events.value, source); + if (!at.ok) { + return { kind: "unavailable", parent, source, why: `${parent} cannot be reconstructed` }; + } + + const route = surfaceRoute({ + execution: parent, + surface: "transcript", + at: source, + inspect: false, + draft: "", + }); + if (!route.ok) { + return { kind: "unavailable", parent, source, why: route.error.message }; + } + return { kind: "resolvable", parent, source, url: encodeRoute(route.value) }; +} diff --git a/scripts/repl-hydration/purity.ts b/scripts/repl-hydration/purity.ts new file mode 100644 index 000000000..ab2777060 --- /dev/null +++ b/scripts/repl-hydration/purity.ts @@ -0,0 +1,98 @@ +/** + * What is allowed to be in a semantic model, checked by walking one. + * + * "No continuation, callback, StarFX handle, renderer object, terminal cell or + * generator-local value in the model" is not a property a type can hold: every + * one of those satisfies `unknown`, and a structural interface accepts an + * object carrying extra members. So it is checked by walking the value and + * naming everything that is not plain frozen data. + * + * The rule is a whitelist rather than a list of things to reject, because a + * list of rejections only finds what someone thought of. A string, a finite + * number, a boolean and `null` are values; a frozen array and a frozen plain + * object are containers of values; everything else — a function, a generator, + * a promise, a symbol, a class instance, a `Map`, a `Uint8Array`, a proxy over + * any of them, and `undefined` — is named with the path it was found at. + * + * `undefined` is refused deliberately. The model has no optional member, so a + * member that is absent is a shape defect and not a spelling of "nothing". + */ + +function label(value: unknown): string { + if (value === undefined) { + return "undefined"; + } + if (typeof value === "function") { + return "a function"; + } + if (typeof value === "symbol") { + return "a symbol"; + } + if (typeof value === "bigint") { + return "a bigint"; + } + if (typeof value === "number") { + return "a non-finite number"; + } + if (value === null || typeof value !== "object") { + return `a ${typeof value}`; + } + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null || Array.isArray(value)) { + return "unfrozen"; + } + const name = value.constructor === undefined ? "an exotic object" : value.constructor.name; + return `a ${name}`; +} + +function walk(value: unknown, at: string, found: string[]): void { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + found.push(`${at}: ${label(value)}`); + } + return; + } + if (typeof value !== "object") { + found.push(`${at}: ${label(value)}`); + return; + } + const prototype = Object.getPrototypeOf(value); + if (Array.isArray(value)) { + if (!Object.isFrozen(value)) { + found.push(`${at}: an unfrozen array`); + } + for (const [index, member] of value.entries()) { + walk(member, `${at}[${index}]`, found); + } + return; + } + if (prototype !== Object.prototype && prototype !== null) { + found.push(`${at}: ${label(value)}`); + return; + } + if (!Object.isFrozen(value)) { + found.push(`${at}: an unfrozen object`); + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key === "symbol") { + found.push(`${at}: a symbol key ${String(key)}`); + continue; + } + walk(Reflect.get(value, key), `${at}.${key}`, found); + } +} + +/** + * Everything reachable from `value` that is not plain frozen data, as paths. + * + * An empty answer is the claim. A non-empty one names where the foreign value + * sits, so a control that smuggles one in says what it smuggled and where. + */ +export function foreignValues(value: unknown, at = "model"): readonly string[] { + const found: string[] = []; + walk(value, at, found); + return found; +} diff --git a/scripts/repl-hydration/replay.ts b/scripts/repl-hydration/replay.ts new file mode 100644 index 000000000..db2e05ad5 --- /dev/null +++ b/scripts/repl-hydration/replay.ts @@ -0,0 +1,612 @@ +/** + * Restart resumption: the same document, run against a record that already + * exists. + * + * A live run and a replay are one function. `resume()` walks the document's + * steps beside the Journal in append order, and for each step it either + * *consumes* the records that step already produced or *performs* the step for + * the first time. That is the whole no-repeat claim: a consumed step never + * reaches the performer, so a durable effect that is already written down + * cannot happen twice. It is not ordinary Continue — Continue resolves a + * suspended routine in a process that still exists (#841), and this rebuilds + * a position from what was recorded. + * + * **A record is consumed only when it is that step's own record.** The kind + * alone says far too little: every scope opening is a `scope.opened`, and a + * replay that matched on kind would let one document's records stand in for + * another's — suppressing an effect that never happened and reporting it as + * done. So every replayable occurrence has an identity — operation, owning + * entry, owning scope, and the durable name of the occurrence — and the + * identity is separate from the result: replay *matches* the request and + * *restores* what came back. An `outcome.recorded` carries both, which is why + * `request` is a field and not the label. + * + * Alignment happens first and completely. The prior Journal is parsed, + * projected, and then walked against the script before a single effect runs, + * so a divergence refuses with nothing performed, nothing appended and the + * supplied Journal untouched. A retained record left unclaimed when the + * document has finished is a divergence too: it describes work this document + * does not do. + * + * Where it stops is the **replay frontier**: the first elicitation with no + * answer recorded. Live execution belongs after that point and nowhere else. + * + * **A matched operation returns its recorded result.** Consuming is not + * merely declining to perform: the value the live performer would have + * produced has to arrive at the same place, or the document carries on with + * whatever its source happened to say and the replay only looked correct. + * So every producing step names what it puts into execution state, every + * consuming step derives from that state, and the two paths — the performer's + * value and the matched record's — write to the same one. The script holds no + * second copy of a result it did not compute. + * + * Two elicitations behave differently on the way there, and the difference is + * the secret rule. An ordinary answer is in the record, so replay recovers it + * and asks nobody. A secret answer is not in the record and never was, so + * replay knows only that it was asked — and asks again. If there is nobody to + * ask, that is a frontier too. + * + * The document here is a fixture, deliberately: #842 forbids a real model + * provider, and a script of steps is the smallest thing that can be run twice + * and compared. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +import type { Secrets, Streaming } from "./ephemeral.ts"; +import { parseJournal } from "./journal.ts"; +import type { SemanticEvent, SemanticKind } from "./journal.ts"; +import { projectPrefix } from "./project.ts"; + +/** One thing the document does. */ +export type Step = + | { readonly kind: "submit"; readonly entry: string; readonly title: string } + | { + readonly kind: "open"; + readonly entry: string; + readonly scope: readonly string[]; + readonly name: string; + readonly source: number; + } + | { + readonly kind: "complete"; + readonly entry: string; + readonly scope: readonly string[]; + readonly name: string; + } + | { + readonly kind: "agent"; + readonly entry: string; + readonly scope: readonly string[]; + /** What the Agent streamed on the way to its answer. Never durable. */ + readonly chunks: readonly string[]; + /** What it finally says when it runs. A replay never reads this. */ + readonly admitted: string; + /** Where the admitted result lands, whoever produced it. */ + readonly produces: string; + } + | { + readonly kind: "publish"; + readonly entry: string; + readonly name: string; + /** The execution-state value to publish. There is no literal to fall back to. */ + readonly from: string; + } + | { + readonly kind: "elicit"; + readonly entry: string; + readonly scope: readonly string[]; + readonly wait: string; + readonly prompt: string; + readonly secret: boolean; + /** Where the answer lands, whether a person gave it or the record did. */ + readonly produces: string; + } + | { readonly kind: "settle"; readonly entry: string }; + +/** + * The representative document. + * + * It drafts release notes with an Agent, admits the result, opens the scope + * that result creates, and publishes **what the Agent produced** — read out of + * execution state, not out of this list, which is why changing the recorded + * result changes what the replay goes on to publish. It then needs two + * answers: an ordinary one, whose value the next step publishes, and a secret + * one, whose value nothing records. Everything after the secret exists so + * that a replay which stopped there can be told apart from one that got past + * it. + */ +export const SCRIPT: readonly Step[] = [ + { kind: "submit", entry: "entry-1", title: "Publish the release notes" }, + { kind: "open", entry: "entry-1", scope: [], name: "document", source: 0 }, + { kind: "open", entry: "entry-1", scope: ["document"], name: "draft", source: 0 }, + { + kind: "agent", + entry: "entry-1", + scope: ["document", "draft"], + chunks: ["Rele", "Release notes for ", "Release notes for 0.14.0"], + admitted: "Release notes for 0.14.0", + produces: "notes", + }, + { kind: "open", entry: "entry-1", scope: ["document", "draft"], name: "review", source: 0 }, + { kind: "complete", entry: "entry-1", scope: ["document", "draft"], name: "review" }, + { kind: "complete", entry: "entry-1", scope: ["document"], name: "draft" }, + { kind: "publish", entry: "entry-1", name: "notes", from: "notes" }, + { kind: "open", entry: "entry-1", scope: ["document"], name: "publish", source: 1 }, + { + kind: "elicit", + entry: "entry-1", + scope: ["document", "publish"], + wait: "channel", + prompt: "Which channel should this be announced on?", + secret: false, + produces: "channel", + }, + { kind: "publish", entry: "entry-1", name: "announced", from: "channel" }, + { + kind: "elicit", + entry: "entry-1", + scope: ["document", "publish"], + wait: "token", + prompt: "Registry token?", + secret: true, + produces: "token", + }, + { kind: "complete", entry: "entry-1", scope: ["document"], name: "publish" }, + { kind: "complete", entry: "entry-1", scope: [], name: "document" }, + { kind: "settle", entry: "entry-1" }, +]; + +/** Where a run stopped. */ +export type Frontier = + | { readonly kind: "complete" } + /** Waiting on a person. Live execution resumes when the answer is recorded. */ + | { + readonly kind: "awaiting"; + readonly wait: string; + readonly prompt: string; + readonly secret: boolean; + } + /** A secret whose value is gone and whom nobody is here to ask again. */ + | { readonly kind: "unrevealed"; readonly wait: string; readonly prompt: string }; + +export interface Run { + /** The journal after this run: what it found, plus what it appended. */ + readonly records: readonly unknown[]; + /** Durable effects this run actually carried out. */ + readonly performed: readonly string[]; + /** Durable effects it read out of the record instead of carrying out. */ + readonly consumed: readonly string[]; + /** Answers it recovered from the record without asking anyone. */ + readonly recovered: readonly string[]; + /** Waits it had to put in front of a person. Never what they said. */ + readonly asked: readonly string[]; + /** Scopes still mid-stream when it stopped. Empty after a replay. */ + readonly streaming: readonly string[]; + readonly frontier: Frontier; +} + +/** + * What makes one replayable occurrence that occurrence and no other. + * + * Four things, and none of them is the result: the operation, the entry that + * owns it, the scope path inside that entry, and the durable name of the + * occurrence there. A step and a record agree when all four agree. + */ +interface Identity { + readonly kind: SemanticKind; + readonly entry: string; + readonly scope: readonly string[]; + readonly name: string; +} + +function says(identity: Identity): string { + const where = + identity.scope.length === 0 ? identity.entry : `${identity.entry}/${identity.scope.join("/")}`; + return `${identity.kind} ${JSON.stringify(identity.name)} in ${where}`; +} + +function same(one: Identity, other: Identity): boolean { + return ( + one.kind === other.kind && + one.entry === other.entry && + one.name === other.name && + one.scope.length === other.scope.length && + one.scope.every((segment, at) => segment === other.scope[at]) + ); +} + +/** The identity of a record that is already durable. */ +function identityOf(event: SemanticEvent): Identity { + if (event.kind === "scope.opened" || event.kind === "scope.completed") { + return { kind: event.kind, entry: event.entry, scope: event.scope, name: event.name }; + } + if (event.kind === "binding.published") { + return { kind: event.kind, entry: event.entry, scope: [], name: event.name }; + } + if (event.kind === "suspension.opened" || event.kind === "suspension.answered") { + return { kind: event.kind, entry: event.entry, scope: event.scope, name: event.wait }; + } + if (event.kind === "outcome.recorded") { + // The request, never the label: an outcome recognized by its own result + // could only be recognized by a replay that already knew the answer. + return { kind: event.kind, entry: event.entry, scope: event.scope, name: event.request }; + } + return { kind: event.kind, entry: event.entry, scope: [], name: event.entry }; +} + +/** The durable name this fixture gives one Agent occurrence. */ +function agentRequest(step: Extract): string { + return [step.entry, ...step.scope].join("/"); +} + +/** The records one step writes, in order, as the identities they will have. */ +function expectations(step: Step): readonly Identity[] { + if (step.kind === "submit") { + return [{ kind: "entry.submitted", entry: step.entry, scope: [], name: step.entry }]; + } + if (step.kind === "settle") { + return [{ kind: "entry.settled", entry: step.entry, scope: [], name: step.entry }]; + } + if (step.kind === "open") { + return [{ kind: "scope.opened", entry: step.entry, scope: step.scope, name: step.name }]; + } + if (step.kind === "complete") { + return [{ kind: "scope.completed", entry: step.entry, scope: step.scope, name: step.name }]; + } + if (step.kind === "publish") { + return [{ kind: "binding.published", entry: step.entry, scope: [], name: step.name }]; + } + if (step.kind === "agent") { + return [ + { + kind: "outcome.recorded", + entry: step.entry, + scope: step.scope, + name: agentRequest(step), + }, + ]; + } + return [ + { kind: "suspension.opened", entry: step.entry, scope: step.scope, name: step.wait }, + { kind: "suspension.answered", entry: step.entry, scope: step.scope, name: step.wait }, + ]; +} + +/** A step that needs a value nothing before it produced. */ +export class ExecutionGap extends Error { + /** The step that could not proceed. */ + readonly step: string; + /** The execution-state value it wanted. */ + readonly needed: string; + + constructor(step: string, needed: string) { + super(`${step} needs ${JSON.stringify(needed)}, and nothing before it produced one`); + this.name = "ExecutionGap"; + this.step = step; + this.needed = needed; + } +} + +/** A retained Journal that is not this document's. */ +export class ReplayDivergence extends Error { + /** The append position that diverged, or the record count when one is left over. */ + readonly position: number; + readonly expected: string; + readonly found: string; + + constructor(position: number, expected: string, found: string) { + super(`record ${position}: expected ${expected}, found ${found}`); + this.name = "ReplayDivergence"; + this.position = position; + this.expected = expected; + this.found = found; + } +} + +/** Which retained records each step claimed, in script order. */ +type Alignment = readonly (readonly number[])[]; + +/** + * Pair the script against what is already durable, or refuse. + * + * This runs to completion before anything is performed, so a divergence costs + * nothing: no effect happens, no record is written, and the Journal handed in + * is the Journal handed back to the caller untouched. + * + * Running out of retained records is not a divergence — it is where the live + * frontier is. Having some left over when the document is finished *is* one. + */ +function align(script: readonly Step[], events: readonly SemanticEvent[]): Result { + const claimed: number[][] = []; + let at = 0; + for (const step of script) { + const mine: number[] = []; + for (const expected of expectations(step)) { + if (at >= events.length) { + break; + } + const found = identityOf(events[at]); + if (!same(expected, found)) { + return Err(new ReplayDivergence(at, says(expected), says(found))); + } + mine.push(at); + at += 1; + } + claimed.push(mine); + } + if (at < events.length) { + return Err( + new ReplayDivergence(at, "the document to be finished", says(identityOf(events[at]))), + ); + } + return Ok(claimed); +} + +interface Appending { + readonly records: unknown[]; + seq: number; +} + +function append(into: Appending, kind: SemanticKind, fields: Record): void { + into.records.push({ + id: `s-${String(into.seq).padStart(2, "0")}`, + seq: into.seq, + // Recorded time advances with the record, which is all this fixture needs + // of a clock and all a replay can know about one. + at: into.seq, + kind, + ...fields, + }); + into.seq += 1; +} + +export interface Resume { + /** The document to run. */ + readonly script: readonly Step[]; + /** What is already durable. Empty is a first run. */ + readonly prior: readonly unknown[]; + /** + * Who to ask for a secret. + * + * Required rather than defaulted: whether anyone is at the keyboard decides + * whether this run can get past a secret frontier, and a default would let + * a caller be headless without saying so. A process with nobody there + * passes `noSecrets()`. + */ + readonly secrets: Secrets; + /** Where partial Agent output goes while it is in flight. */ + readonly streaming: Streaming; +} + +/** + * Run the document against what is already recorded. + * + * The answer is a `Result` because the journal it was handed may not be one + * this vocabulary can read, and a run that cannot read its own past has + * nothing to say about where to continue from. + */ +export function resume(options: Resume): Result { + const parsed = parseJournal(options.prior); + if (!parsed.ok) { + return parsed; + } + // A Journal that reads but cannot have happened is not something to resume + // from, and finding that out after performing half the document would be + // finding out too late. + const projected = projectPrefix("", parsed.value, undefined); + if (!projected.ok) { + return projected; + } + const aligned = align(options.script, parsed.value); + if (!aligned.ok) { + return aligned; + } + + const { secrets, streaming } = options; + const events = parsed.value; + const plan = aligned.value; + const into: Appending = { + records: [...options.prior], + seq: options.prior.length + 1, + }; + const performed: string[] = []; + const consumed: string[] = []; + const recovered: string[] = []; + /** + * What the document has produced so far. + * + * One map, written by the live performer and by the matched record alike, + * so a consumed operation and a performed one hand the same thing to + * whatever comes next. It never leaves this function: a secret is in here. + */ + const state = new Map(); + + function stopped(frontier: Frontier): Result { + return Ok({ + records: into.records, + performed, + consumed, + recovered, + asked: secrets.asked, + streaming: streaming.streaming(), + frontier, + }); + } + + for (const [index, step] of options.script.entries()) { + const claimed = plan[index]; + + if (step.kind === "submit") { + if (claimed.length === 0) { + append(into, "entry.submitted", { entry: step.entry, title: step.title }); + } + continue; + } + + if (step.kind === "open") { + if (claimed.length === 0) { + append(into, "scope.opened", { + entry: step.entry, + scope: step.scope, + name: step.name, + source: step.source, + }); + } + continue; + } + + if (step.kind === "complete") { + if (claimed.length === 0) { + append(into, "scope.completed", { + entry: step.entry, + scope: step.scope, + name: step.name, + }); + } + continue; + } + + if (step.kind === "settle") { + if (claimed.length === 0) { + append(into, "entry.settled", { entry: step.entry }); + } + continue; + } + + if (step.kind === "publish") { + if (claimed.length === 1) { + // The recorded value is the one that was published, and it lands in + // execution state exactly where a live publication would have put it. + const record = events[claimed[0]]; + const value = record.kind === "binding.published" ? record.value : ""; + state.set(step.name, value); + consumed.push(`publish ${step.name}`); + continue; + } + const value = state.get(step.from); + if (value === undefined) { + return Err(new ExecutionGap(`publish ${step.name}`, step.from)); + } + state.set(step.name, value); + performed.push(`publish ${step.name}`); + append(into, "binding.published", { + entry: step.entry, + name: step.name, + value, + }); + continue; + } + + if (step.kind === "agent") { + const request = agentRequest(step); + if (claimed.length === 1) { + // The result is written down against this request, so the Agent does + // not run and nothing streams. There is no partial output after a + // restart because none was produced, not because it was hidden — and + // the recorded result goes where the live one would have gone, which + // is what makes the rest of the document follow it. + const record = events[claimed[0]]; + state.set(step.produces, record.kind === "outcome.recorded" ? record.label : ""); + consumed.push(`agent ${request}`); + continue; + } + for (const chunk of step.chunks) { + streaming.receive(request, chunk); + } + state.set(step.produces, step.admitted); + performed.push(`agent ${request}`); + streaming.admit(request); + append(into, "outcome.recorded", { + entry: step.entry, + scope: step.scope, + request, + label: step.admitted, + }); + continue; + } + + if (claimed.length === 0) { + append(into, "suspension.opened", { + entry: step.entry, + scope: step.scope, + wait: step.wait, + prompt: step.prompt, + secret: step.secret, + }); + } + + if (claimed.length < 2) { + // Nobody has answered yet. This is the replay frontier: everything + // before it is reconstructed and everything after it is live. + return stopped({ + kind: "awaiting", + wait: step.wait, + prompt: step.prompt, + secret: step.secret, + }); + } + + if (!step.secret) { + // Read out of the matched record and nowhere else, and put where the + // person's answer would have gone, so the steps after it see it. + const record = events[claimed[1]]; + const answer = record.kind === "suspension.answered" ? record.answer : ""; + state.set(step.produces, answer); + recovered.push(`${step.wait}=${answer}`); + continue; + } + + // The record says this was answered and says nothing about what with, + // which is the point. Replay reconstructs the question, not the answer. + const revealed = secrets.reveal(step.wait, step.prompt); + if (!revealed.known) { + return stopped({ kind: "unrevealed", wait: step.wait, prompt: step.prompt }); + } + // Used, and used only here: nothing downstream records it. + state.set(step.produces, revealed.value); + } + + return stopped({ kind: "complete" }); +} + +/** + * The record an operator's answer makes. + * + * A secret's answer is recorded as the empty string, because what is durable + * is that the question was answered. Handing a value here for a wait the + * Journal opened as secret is refused by the projection, so the leak cannot + * be written and then merely ignored. + */ +export function answered( + records: readonly unknown[], + step: Extract, + value: string, +): Result { + if (step.secret && value !== "") { + return Err(new Error(`the answer to the secret wait ${step.wait} is not recorded`)); + } + return Ok([ + ...records, + { + id: `s-${String(records.length + 1).padStart(2, "0")}`, + seq: records.length + 1, + at: records.length + 1, + kind: "suspension.answered", + entry: step.entry, + scope: step.scope, + wait: step.wait, + answer: value, + }, + ]); +} + +/** The elicitation step one wait belongs to. */ +export function elicitation(wait: string): Extract { + const step = SCRIPT.find((one) => one.kind === "elicit" && one.wait === wait); + if (step === undefined || step.kind !== "elicit") { + throw new Error(`the document has no ${wait} elicitation`); + } + return step; +} diff --git a/scripts/repl-hydration/store.ts b/scripts/repl-hydration/store.ts new file mode 100644 index 000000000..647467989 --- /dev/null +++ b/scripts/repl-hydration/store.ts @@ -0,0 +1,401 @@ +/** + * The actual StarFX store, hydrated from Journal plus URL. + * + * This is `starfx` itself — `createSchema`, `createStore`, `slice` — and not a + * stand-in. The store is bound to the enclosing Effection scope through + * `useScope()`, so it ends when the session that asked for it ends rather than + * owning a scope nobody can reach. + * + * Everything in it is derived. Records and a URL go in; the semantic model, + * the Execution History and the resolved location come out. Nothing is written + * here that was not computed from those two inputs, which is what makes + * discarding the whole store and building another one at the same URL a + * no-op that the evidence can check by deep comparison. + * + * **Snapshots accelerate and never testify.** `snapshots` memoizes the model + * of a *marker* prefix, because a prefix that ends at a record can never + * change. The live head is never memoized: it is exactly the prefix that grows. + * A new store starts with an empty cache and cannot be handed a populated one, + * so a snapshot cannot outlive the process that derived it — which is the only + * reason reading one is safe. + * + * The pause controller is not here. A store holds what a Journal and a URL can + * say, and neither can say that this process is holding a continuation. + */ + +import { Ok, useScope } from "effection"; +import type { Operation, Result } from "effection"; +import { createSchema, createStore, slice } from "starfx"; +import type { FxStore } from "starfx"; + +import { parseJournal } from "./journal.ts"; +import type { SemanticEvent } from "./journal.ts"; +import { decodeRoute, encodeRoute, resolveIn, withDraft } from "./location.ts"; +import type { Route, SemanticLocation } from "./location.ts"; +import type { Marker, SemanticModel } from "./model.ts"; +import { projectPrefix } from "./project.ts"; + +/** + * One Execution History row. + * + * Every marker the Journal has minted appears, including those after the + * selected one: a future marker is navigation context, and the scrubber needs + * to know it is there. What a future marker must never do is carry a fact into + * the model, and it cannot — `position` is the only thing said about it here. + */ +export interface HistoryEntry { + readonly id: string; + readonly at: number; + readonly kind: string; + readonly weight: string; + readonly entry: string; + /** `past`, `selected` or `future`, relative to what the URL named. */ + readonly position: string; +} + +/** What the URL selected, as identifiers rather than as model values. */ +export type LocationState = + | { + readonly kind: "surface"; + readonly surface: string; + readonly inspecting: boolean; + readonly draft: string; + } + | { + readonly kind: "entry"; + readonly surface: string; + readonly entry: string; + readonly scopes: readonly string[]; + readonly drawers: readonly string[]; + readonly inspecting: boolean; + readonly draft: string; + }; + +/** + * Everything a reconstruction has to reproduce. + * + * The snapshot cache is deliberately absent. Two sessions that agree here + * agree about the execution, whatever either one has memoized, which is what + * makes "the cache is an accelerator" a statement the comparison can hold. + */ +export interface SemanticState { + readonly execution: string; + /** The one canonical spelling of the selected location. */ + readonly url: string; + readonly model: SemanticModel; + readonly history: readonly HistoryEntry[]; + readonly location: LocationState; +} + +interface StoreShape { + execution: string; + url: string; + records: readonly unknown[]; + model: SemanticModel; + history: readonly HistoryEntry[]; + location: LocationState; + snapshots: Readonly>; +} + +function emptyModel(): SemanticModel { + const projected = projectPrefix("", [], undefined); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +const EMPTY_MODEL = emptyModel(); + +const EMPTY_LOCATION: LocationState = { + kind: "surface", + surface: "transcript", + inspecting: false, + draft: "", +}; + +function schemaFor() { + return createSchema({ + // `cache` and `loaders` are StarFX's own and this REPL uses neither. They + // are required by the schema, and staying empty is something the evidence + // reads rather than assumes. + cache: slice.table(), + loaders: slice.loaders(), + execution: slice.str(""), + url: slice.str(""), + records: slice.any([]), + model: slice.any(EMPTY_MODEL), + history: slice.any([]), + location: slice.any(EMPTY_LOCATION), + snapshots: slice.any>>({}), + }); +} + +type Schema = ReturnType[0]; +type State = ReturnType[1]; + +function historyOf( + events: readonly SemanticEvent[], + selected: string, + markers: readonly Marker[], +): readonly HistoryEntry[] { + const within = new Set(markers.map((marker) => marker.id)); + const all = projectPrefix("", events, undefined); + if (!all.ok) { + return []; + } + return all.value.markers.map((marker) => ({ + id: marker.id, + at: marker.at, + kind: marker.kind, + weight: marker.weight, + entry: marker.entry, + position: marker.id === selected ? "selected" : within.has(marker.id) ? "past" : "future", + })); +} + +function locationOf(where: SemanticLocation): LocationState { + if (where.kind === "surface") { + return { + kind: "surface", + surface: where.surface, + inspecting: where.inspecting, + draft: where.draft, + }; + } + return { + kind: "entry", + surface: where.surface, + entry: where.entry.id, + scopes: where.scopes.map((scope) => scope.name), + drawers: where.drawers.map((drawer) => drawer.wait), + inspecting: where.inspecting, + draft: where.draft, + }; +} + +/** Everything one hydration produces, and the memo it produced on the way. */ +type Derived = Omit & { + readonly memo: Readonly>; +}; + +/** One hydration: records and a URL in, everything the store holds out. */ +function derive( + execution: string, + url: string, + records: readonly unknown[], + snapshots: Readonly>, +): Result { + const parsed = parseJournal(records); + if (!parsed.ok) { + return parsed; + } + const route = decodeRoute(url); + if (!route.ok) { + return route; + } + return deriveFrom(execution, route.value, parsed.value, records, snapshots); +} + +function deriveFrom( + execution: string, + route: Route, + events: readonly SemanticEvent[], + records: readonly unknown[], + snapshots: Readonly>, +): Result { + // Only a marker prefix is memoized. The live head is the one prefix that + // grows, so caching it would cache an answer that stops being true. + const cached = route.at === undefined ? undefined : snapshots[route.at]; + let memo = snapshots; + let model = cached; + if (model === undefined) { + const projected = projectPrefix(execution, events, route.at); + if (!projected.ok) { + return projected; + } + model = projected.value; + if (route.at !== undefined) { + memo = { ...snapshots, [route.at]: model }; + } + } + + const where = resolveIn(route, model); + if (!where.ok) { + return where; + } + + return Ok({ + execution, + url: encodeRoute(route), + records, + model, + history: historyOf(events, model.marker, model.markers), + location: locationOf(where.value), + memo, + }); +} + +/** + * One REPL session over one StarFX store. + * + * Every method that changes anything re-derives from the records and the URL. + * There is no incremental patch of the semantic model, because a patch would + * be a second way to arrive at a state and the whole claim is that there is + * one. + */ +export interface ReplSession { + /** What a reconstruction must reproduce. */ + semantic(): SemanticState; + /** The whole StarFX state, for evidence that walks it. */ + state(): State; + /** Apply one newly appended durable record, keeping the selected location. */ + append(record: unknown): Operation>; + /** Select another location. The URL is authoritative. */ + navigate(url: string): Operation>; + /** + * Replace the draft, which moves the URL and nothing else. + * + * Typing is not navigating and it is not executing: no Journal record is + * appended, and the ordinary navigation history is replaced in place rather + * than grown, so Back goes where the person came from instead of walking + * backwards through their keystrokes. + */ + type(draft: string): Operation>; + /** Throw away every memoized marker model. */ + discardSnapshots(): Operation; + /** Which markers the accelerator is currently holding. */ + cached(): readonly string[]; + /** + * The ordinary navigation history, oldest first. + * + * It is process-local, like the snapshot cache: where this person has been + * is not a fact about the execution, and a reconstruction that invented one + * would be claiming to know something the Journal never recorded. So it + * lives beside the store rather than in it. + */ + visits(): readonly string[]; +} + +class Session implements ReplSession { + readonly #store: FxStore; + readonly #schema: Schema; + readonly #execution: string; + readonly #visits: string[]; + + constructor(store: FxStore, schema: Schema, execution: string, url: string) { + this.#store = store; + this.#schema = schema; + this.#execution = execution; + this.#visits = [url]; + } + + semantic(): SemanticState { + const state = this.#store.getState(); + // Frozen like everything it holds: a caller comparing two of these is + // comparing values, and a caller that could edit one would be editing a + // reading of the Journal. + return Object.freeze({ + execution: state.execution, + url: state.url, + model: state.model, + history: state.history, + location: state.location, + }); + } + + state(): State { + return this.#store.getState(); + } + + cached(): readonly string[] { + return Object.keys(this.#store.getState().snapshots).toSorted(); + } + + visits(): readonly string[] { + return [...this.#visits]; + } + + *append(record: unknown): Operation> { + const state = this.#store.getState(); + return yield* this.#settle(state.url, [...state.records, record]); + } + + *navigate(url: string): Operation> { + const moved = yield* this.#settle(url, this.#store.getState().records); + if (moved.ok) { + this.#visits.push(this.#store.getState().url); + } + return moved; + } + + *type(draft: string): Operation> { + const route = decodeRoute(this.#store.getState().url); + if (!route.ok) { + return route; + } + const typed = withDraft(route.value, draft); + if (!typed.ok) { + return typed; + } + const settled = yield* this.#settle(encodeRoute(typed.value), this.#store.getState().records); + if (settled.ok) { + this.#visits[this.#visits.length - 1] = this.#store.getState().url; + } + return settled; + } + + *discardSnapshots(): Operation { + yield* this.#store.update(this.#schema.snapshots.set({})); + } + + *#settle(url: string, records: readonly unknown[]): Operation> { + const derived = derive(this.#execution, url, records, this.#store.getState().snapshots); + if (!derived.ok) { + return derived; + } + yield* this.#write(derived.value); + return Ok(); + } + + *#write(derived: Derived): Operation { + yield* write(this.#store, this.#schema, derived); + } +} + +function* write(store: FxStore, schema: Schema, derived: Derived): Operation { + yield* store.update([ + schema.execution.set(derived.execution), + schema.url.set(derived.url), + schema.records.set(derived.records), + schema.model.set(derived.model), + schema.history.set(derived.history), + schema.location.set(derived.location), + schema.snapshots.set(derived.memo), + ]); +} + +/** + * Build a store and hydrate it, or refuse. + * + * The store takes the caller's Effection scope, so its lifetime is the + * caller's. Hydration is the whole construction: a session that exists has + * already read its records and resolved its URL, and there is no half-built + * state for anything to observe. + */ +export function* hydrate( + execution: string, + url: string, + records: readonly unknown[], +): Operation> { + const derived = derive(execution, url, records, {}); + if (!derived.ok) { + return derived; + } + + const [schema, initialState] = schemaFor(); + const scope = yield* useScope(); + const store = createStore({ initialState, scope }); + yield* write(store, schema, derived.value); + return Ok(new Session(store, schema, execution, derived.value.url)); +} diff --git a/scripts/repl-pause/README.md b/scripts/repl-pause/README.md new file mode 100644 index 000000000..45471ae4e --- /dev/null +++ b/scripts/repl-pause/README.md @@ -0,0 +1,157 @@ +# Pausing XMD expansion + +[#841](https://github.com/taras/executable.md/issues/841), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). + +**The conclusion is [`RESULT.md`](RESULT.md): RETAIN.** A REPL-installed +pass-through controller pauses and resumes the expansion of one real XMD +execution, over surfaces that already exist, with no new core XMD API and without +touching Effection's scheduler. + +This file is the working record behind it, including what was got wrong on the +way. Run the evidence: + +```bash +deno task test scripts/tests/repl-pause-gate.test.ts # 16 cases, two suites +deno task repl:pause # the synthetic unit gate +deno task repl:pause:seam # what Effection publishes +deno task repl:pause:xmd # inventory + expansion-pause trace +``` + +## The correction that mattered + +The first attempts used the wrong completeness boundary. They asked whether every +descendant **Effection scope** was suspended, concluded that a real execution could +therefore never report `paused`, and proposed depending on a future Effection +scheduler-decoration or quiescence API. + +That was the wrong question. The REPL pauses **XMD expansion**. Effection is the +runtime and keeps running throughout: tasks stay live, timers fire, external work +finishes, and background work records its outcomes. Those conclusions are withdrawn +and are not repeated in `RESULT.md`. + +The obligation set is **expansion walks**. `api.Scope` and scope counts remain as +**diagnostics** that `inspect()` reports and that decide nothing. + +## EXPANSION PAUSED, not "paused at head" + +Two positions, and they must not be conflated: + +- **The expansion pause point is fixed** — no further element or output expands. +- **The durable Journal, and so the live History head, may advance**, because + already-running work records its outcomes normally. + +Measured on a real execution: 25 live Effection scopes, ordinary component children +advancing 80 → 140, the Journal head moving 7 → 8 — with expansion stopped +throughout and the same continuation held in the same place. + +A future scrubber can show a stationary expansion-pause marker while the live +History head advances. This POC does not implement that UI; the distinction is why +it has to exist. + +## What a walk is, and how `paused` is computed + +A walk is one **bracket instance**. Four existing operations delimit one: +`Execution.document`, `Component.content`, `Component.tryContent`, and the REPL's +own `expand` handler — including each region it expands. Everything else the +middleware wraps is a **step gate** inside a walk. + +``` +satisfied(walk) ⟺ (walk holds a continuation OR walk has active children) + AND every active child walk is satisfied + +paused ⟺ at least one walk is active + AND every active walk is satisfied +``` + +Both clauses were found by a failing case, and `RESULT.md` records which. Walk +identity travels on a REPL-owned context for the duration of the bracket, so a +walk that crosses gates in nineteen different coroutines is one obligation. + +## The boundary inventory + +Measured by running the document, not read off the Api declarations — `raise()` in +particular looks ubiquitous in `expand.ts` but fires only on error paths. + +| role | surfaces | +| --- | --- | +| **brackets one expansion walk** | `document`, `content`, `expand`, `region` | +| **step gate inside a walk** | `importComponent`, `applyModifiers`, `applyBoundModifiers`, `codeBlock`, `retain`, `output`, `replCheckpoint` | + +Gating `DocumentOutput.output` is what covers prose: it makes "the next element *or +output* does not appear until Continue" a claim about output, not only about +elements. + +Two measured facts about the shape of expansion: + +- **The root walk crosses gates in nineteen coroutines, strictly sequentially.** + Each output emission is a fresh short-lived coroutine. Concurrency *inside* one + walk did not occur. +- **Genuine concurrency appears as separate walks.** Two regions expanded in + parallel are two walks, and both must be held or settled. + +## Surfaces used, and how + +- **`Component.around(...)`, `Execution.around(...)`, `DocumentOutput.around(...)`** + — real contextual Api middleware, installed at the default `max` in the scope that + will own the execution, **before** it starts, never when Pause is pressed. `min` + is the implementation slot the runtime providers occupy. +- **The REPL's own captured `expand` handler.** `ExecutionInstallation.expand` is + read once and bound at profile capture, so a host holds its own handler and may + decorate it while assembling its profile. This is REPL-owned code, **not** Api + middleware. The per-region bracket and the per-chunk checkpoint live in that same + REPL-owned loop. +- **Stable `api.Scope`** — creation and destruction, diagnostic only. Its `create` + returns a tuple rather than an `Operation`, so it could not suspend anything even + if asked, and nothing treats it as scheduler control. +- **Canonical component identity is untouched.** No imported component definition + is wrapped or replaced; the middleware observes `importComponent` and delegates. + +## What the synthetic gate still contributes + +`gate.ts`, `fixture.ts`, `execution.ts`, `journal.ts` and `main.ts` are the first +slice: a gate over an Api invented for the experiment, kept in the suite as focused +unit evidence for the hold-and-release mechanism. It substitutes for nothing — every +product claim is made against the real execution. + +Its mechanism findings still stand and are the reason the design works: + +- Middleware installed on one scope is inherited by descendants and absent from + siblings, because that is how Api dispatch resolves a handle. +- A hold is a suspended `action()`, so teardown unwinds *through* it rather than + releasing it — which is what makes interruption and shutdown safe. +- Continue cannot replay: it resolves a suspended action rather than re-running + anything. + +`missing-seam.ts` (`deno task repl:pause:seam`) records what Effection 4.1.0 +publishes and what it does not. **The design does not depend on any of it** — it is +kept only as that record, and as the reason no private reducer, deep import, +scheduler substitution or runtime-name reconstruction appears anywhere here. + +## Two measurement errors this work corrected in itself + +- **An interval that measured nothing.** Earlier slices timed a paused interval by + consuming N advances from a subscription taken much earlier. The advance signal + buffers from the moment a subscription is taken, so those reads drained a backlog + in ~0 ms and no real time passed. Every interval now starts from a **fresh** + subscription. This is what first made ordinary component children look as though + they had stopped; measured properly they run 80 → 140. +- **A no-replay assertion that sampled once.** Duplicate durable outcomes are now + counted at append time, so one landing at any moment is caught. + +## What is deliberately not done + +- No `Execution.advance` and no other public or core XMD pause API. +- No edits to any production package. +- No private Effection reducer, deep import, scheduler substitution, or + runtime-name reconstruction. +- No claim about whole-runtime quiescence, and no inference that a live Effection + scope means expansion can advance. +- No classification of engine-owned scopes: they are counted as a diagnostic and + nothing is concluded from them. + +`RESULT.md` records the remaining limits of the evidence, including eval blocks, +which need a platform compiler and so do not run under the Node and Bun suites. + +None of this code is a starting point for production. It is a disposable POC on a +branch that never merges. diff --git a/scripts/repl-pause/RESULT.md b/scripts/repl-pause/RESULT.md new file mode 100644 index 000000000..f9f5084c0 --- /dev/null +++ b/scripts/repl-pause/RESULT.md @@ -0,0 +1,257 @@ +# What the expansion-pause experiment found + +[#841](https://github.com/taras/executable.md/issues/841), under the REPL quest +[#827](https://github.com/taras/executable.md/issues/827). This is the +conclusion. None of the code under `scripts/repl-pause/` is a starting point — +production begins from current `main`, written afresh. + +The question: + +> Can the REPL pause and resume XMD expansion using existing XMD surfaces without +> controlling Effection scheduling? + +**Yes.** + +## Decision: RETAIN + +A REPL-installed, pass-through middleware controller pauses and resumes the +expansion of one real XMD execution, over surfaces that already exist, with no +new core XMD API and without touching Effection's scheduler. + +| | | +| --- | --- | +| **Retain** | The REPL installs middleware before starting one execution; it is transparent while playing; it holds expansion at existing boundaries; completeness is decided by expansion walks; Continue releases the same continuations exactly once. | +| **Revise** | Nothing structural. Only the vocabulary: the mode is **EXPANSION PAUSED**, never "paused at head", because the durable Journal is free to move while expansion is held. | +| **Reject** | Nothing. | + +Effection is the runtime and it keeps running throughout: tasks stay live, timers +fire, external work finishes, and background work records what it produced. +Pausing expansion does not require, and must not wait for, runtime quiescence. + +## What an active expansion walk is + +XMD expansion is already bracketed. Four existing operations delimit one walk: + +| bracket | what it delimits | +| --- | --- | +| `Execution.document` | the root document's expansion | +| `Component.content` | content a component projects | +| `Component.tryContent` | the same, reporting failure instead of replacing | +| the REPL profile's own `expand` handler | structural syntax this REPL declared, and each of its regions | + +**A walk is one bracket instance**: it becomes active on entry and settles on +return. Everything else the middleware wraps is a **step gate** *inside* a walk — +a place the walk can be held, not a walk of its own: +`importComponent`, `applyModifiers`, `applyBoundModifiers`, `codeBlock`, +`retain`, `capture`, `raise`, `handleFailure`, `DocumentOutput.output`, and the +REPL's own per-region checkpoint. + +A walk publishes its identity on a REPL-owned context for the duration of its +bracket (`Context.with`, so the enclosing identity is restored on the way out). +Every gate crossed underneath attributes to that walk, in whatever coroutine the +engine dispatches from. **That is what stops one walk being counted as several**: +the root document crosses gates in nineteen different coroutines and is one +obligation, not nineteen. + +Measured, on the representative document: the nineteen coroutines of the root +walk are **strictly sequential** — each output emission is a fresh short-lived +coroutine, created and finished one at a time. Concurrency inside one walk did +not occur; genuine concurrency appears as *separate* walks, which is what the +region case exercises. + +## How `paused` is computed + +``` +satisfied(walk) ⟺ (walk holds a continuation OR walk has active children) + AND every active child walk is satisfied + +paused ⟺ at least one walk is active + AND every active walk is satisfied +``` + +Both halves of `satisfied` are load-bearing, and each was found by a failing case: + +- **Without the delegation clause**, a parent suspended inside `next()` while its + children are held blocks `paused` for ever. That is what two concurrent region + walks do to `document` and `expand`. +- **Without "and every active child"**, a parent held at its own gate reports + `paused` while a concurrent child walk is still expanding. + +Delegation is recorded, not inferred: a bracket reads the enclosing walk id +*before* publishing its own, so the parent link is a fact the REPL wrote. Both +sides of every such relationship are middleware this REPL owns. + +**What is deliberately not an obligation:** ordinary Effection scopes and tasks. A +component body that spawns children, a provider that sleeps, a subprocess being +awaited — none of these expand XMD, so none can prevent `paused`. `api.Scope` +observation is kept as a **diagnostic** that `inspect()` reports and that decides +nothing. + +## EXPANSION PAUSED, not paused at head + +Two positions must not be conflated, and the mode name carries the distinction: + +- **The expansion pause point is fixed.** No further element or output expands. +- **The durable Journal — and therefore the live History head — may advance**, + because already-running work records its outcomes normally. + +The real trace, with Pause requested while a component body was running ordinary +Effection: + +``` +state the moment Pause is requested : pausing +reached() : EXPANSION PAUSED +active walks : w1:document(root) held@output:\n\n +live Effection scopes (diagnostic) : 25 + +across 30 sibling advances: expansion STOPPED, holds unchanged +ordinary Effection children of a component: 80 -> 140 (still running) + +journal records before external work completes : 7 +journal records after it recorded : 8 +the appended record : yield:background +expansion pause point : still fixed +controller state : paused + +after Continue: completed, released 1, twice 0 +the background record appears exactly once : true +``` + +Twenty-five live Effection scopes, ordinary component children advancing 80 → 140, +the Journal head moving 7 → 8 — and expansion stopped the whole time, with the +same continuation held in the same place. + +A future scrubber can therefore show a **stationary expansion-pause marker while +the live History head advances**. This POC does not implement that UI, and the +distinction is why it must exist. + +## External work + +- **Never frozen.** Nothing the controller does reaches it. +- **May finish while pausing or paused.** It does. +- **Its durable outcome is appended normally** while expansion is paused. +- **If its continuation then attempts more XMD expansion, it is held** at the next + controlled expansion boundary, like any other walk. +- **Continue does not replay it.** Counted at append time, so a duplicate landing + at any moment would be caught. Structurally, Continue cannot replay: it resolves + a suspended `action()` rather than re-running anything. + +## Cancellation and teardown + +| terminal path | outcome | held continuations | retained resource | +| --- | --- | --- | --- | +| completion | the document's value | released by Continue | released | +| failure during coordination | raises to the owner on Continue | unwound | released | +| explicit interruption from `paused` | `halted` | **unwound, not released** | released | +| owner shutdown from `paused` | `halted`, never a success | **unwound, not released** | released | + +Interruption and shutdown do **not** release holds into ordinary execution: the +gate's release counter stays at **zero** on both paths, because a hold is a +suspended `action()` whose discard runs when the routine unwinds. The element +after the hold never expands and the Journal does not move on the way out. + +A failure during coordination is retained like any other expansion work and +reaches the owner when Continue releases it — not swallowed, not deferred. + +## What #842 may rely on + +- **Expansion can be paused and resumed in memory**, on a real execution, with no + core XMD pause API. +- **The controller is ephemeral and never journaled.** It is generator state in a + scope outside the subtree it holds. A UI may report its status; it does not own + or reconstruct it. +- **A paused interval has a fixed *expansion* position, not a fixed Journal + head.** Anything reconstructing a view must treat those as two positions. +- **Continue is exactly-once and replay-free.** +- **An installed-but-idle controller changes nothing** — output and journal are + byte-identical to an unmediated run. +- **Restart is not Continue.** The retained continuations are in memory only; + after a process restart they are gone and journal replay owns resumption, which + is #842's subject. +- **Coverage is a standing obligation.** Pause is sound only while every expansion + path crosses a controlled boundary. A new core operation that becomes a way for + a document to advance, and that is not one of these surfaces, silently widens + the gap — which is why the boundary inventory belongs in production as a test. + +## Evidence + +```bash +deno task test scripts/tests/repl-pause-gate.test.ts # 16 cases, two suites +deno task repl:pause # the synthetic unit gate +deno task repl:pause:seam # Effection's published surface +deno task repl:pause:xmd # inventory + expansion-pause trace +``` + +Green on Deno, Node and Bun. The real-execution suite carries the product claims; +the synthetic gate remains as focused unit evidence and substitutes for nothing. + +The boundary inventory, measured by running the document: eleven surfaces crossed, +four of them walk brackets. Prose is covered because `DocumentOutput.output` is +gated — which is what makes "the next element *or output* does not appear until +Continue" a claim about output and not only about elements. + +### Break-it controls + +| defect | result | +| --- | --- | +| descendant-Effection-scope completeness restored as the decision | **9 of 10 rows fail** — the real execution is stuck in `pausing`, which is exactly the error this correction removed | +| `paused` reported without requiring every walk held or settled | 7 of 10 rows fail | +| durable appends suppressed while paused | the background-recording row fails | +| the same durable outcome recorded twice | the background-recording row fails | +| an expansion path allowed to bypass the gate | the concurrent-walk and exactly-once rows fail | +| holds released into ordinary execution on unwind | exactly the three release/unwind rows fail | + +And one **positive** control, required and in the suite permanently: ordinary +Effection children of a component keep running throughout the paused interval and +do **not** fail expansion completeness. A raw task continuing is not a bypass. + +### Corrections this slice made to its own earlier evidence + +- **An interval that measured nothing.** Earlier slices timed a paused interval by + consuming N advances from a subscription taken much earlier. The advance signal + buffers from the moment a subscription is taken, so those reads drained a + backlog in ~0 ms and the "interval" passed no real time. Every interval now + starts from a **fresh** subscription. This is what first made ordinary component + children look as though they had stopped; measured properly they run 80 → 140. +- **A no-replay assertion that sampled once.** Duplicates are now counted at + append time, so one landing at any moment is caught. +- The conclusions withdrawn from the previous commit — that a real execution + cannot report `paused`, that production should expose only `playing` and + `pausing`, that an Effection scheduler or quiescence API is required — were + artefacts of using Effection-scope quiescence as the completeness rule. They are + not repeated here. + +## Production sequencing + +Written afresh from current `main`. + +1. **The boundary inventory as a test.** Assert which surfaces a representative + execution crosses and which of them bracket a walk. This is the standing guard + on the soundness condition and the most durable artefact here. +2. **The walk registry and the controller.** Walk brackets, step gates, + context-carried walk identity, the `satisfied` rule with both clauses, and + `playing` / `pausing` / `paused`. +3. **EXPANSION PAUSED in the interface**, with the expansion pause marker and the + live History head shown as two positions. +4. **Hand over to #842** for durable resumption after restart. + +## Known limits of this evidence + +- **One document, one host profile.** No Agent providers, no real subprocess + beyond the echo stub, no nested ``, no plugins. +- **Concurrency inside a single walk was not observed**, so the rule is proven + against sequential activity within a walk and against concurrency *between* + walks. A future engine that expands two elements of one walk in parallel would + need the obligation refined below walk granularity. +- **Eval blocks are not exercised.** They need a platform compiler installed + through `API.Env.around({ compile })` and are Deno-data-URI shaped, so they do + not run under the Node and Bun suites. They route through the same + `applyModifiers` boundary as `exec`, which is exercised. +- **The background recorder is a POC producer on a real channel.** It appends to + the same durable stream the engine journals through; it is not a core durable + operation. +- **`Scope.around()` has no removal**, so the controller is installed for the + target scope's lifetime. +- Nothing in `#841`'s out-of-scope list was touched: no terminal rendering, no + routing, no StarFX, no durable restart replay, and no public or core XMD pause + API. diff --git a/scripts/repl-pause/execution.ts b/scripts/repl-pause/execution.ts new file mode 100644 index 000000000..bfe90b3cf --- /dev/null +++ b/scripts/repl-pause/execution.ts @@ -0,0 +1,70 @@ +/** + * An XMD-owned execution Api — the boundary a pause would mediate. + * + * #841 asks whether middleware around an execution Api can withhold a running + * subtree's continuations. That question only means something if the Api is the + * one XMD would actually own, so this is modelled on what an engine does rather + * than on what is convenient to gate: it advances an execution by one journaled + * step, it forks a child execution, and it performs one external operation whose + * work happens outside Effection. + * + * Nothing here knows about pausing. A gate is installed by decorating this Api + * on one scope, and the fixtures below run identically with no gate at all — + * which is what makes "the middleware is what stopped it" a claim the evidence + * can separate from "the fixture cooperated". + */ + +import { createContext, until, useScope } from "effection"; +import type { Operation, Task } from "effection"; +import { createApi } from "effection/experimental"; + +import type { Journal } from "./journal.ts"; + +/** The session journal every execution appends to. */ +export const ExecutionJournal = createContext("xmd.execution.journal"); + +/** Which execution the current scope's records belong to. */ +export const ExecutionOwner = createContext("xmd.execution.owner"); + +export interface ExecutionApi { + /** + * Advance this execution by one journaled step. + * + * @returns the journal's length after the record was appended + */ + step(label: string): Operation; + /** + * Run `body` as a child execution owned by the calling execution. + */ + fork(label: string, body: () => Operation): Operation>; + /** + * Perform one operation whose work happens outside Effection, then journal + * its result. The promise is already in flight when this is invoked, so the + * external system is never frozen by anything that happens here. + */ + external(label: string, work: Promise): Operation; +} + +export const Execution = createApi("xmd.execution", { + *step(label) { + const journal = yield* ExecutionJournal.expect(); + const owner = yield* ExecutionOwner.expect(); + return journal.append(owner, label); + }, + *fork(label, body) { + const scope = yield* useScope(); + return yield* scope.spawn(function* () { + yield* ExecutionOwner.set(label); + yield* body(); + }); + }, + *external(label, work) { + const value = yield* until(work); + const journal = yield* ExecutionJournal.expect(); + const owner = yield* ExecutionOwner.expect(); + journal.append(owner, `${label}=${value}`); + return value; + }, +}); + +export const { step, fork, external } = Execution.operations; diff --git a/scripts/repl-pause/fixture.ts b/scripts/repl-pause/fixture.ts new file mode 100644 index 000000000..76dda1a07 --- /dev/null +++ b/scripts/repl-pause/fixture.ts @@ -0,0 +1,169 @@ +/** + * The topology #841's first slice asks for, and nothing else. + * + * session owner + * ├── controller sibling the scope that acquires the gate + * ├── unrelated live sibling same mediated loop, no middleware over it + * └── target execution scope the one scope the middleware decorates + * ├── nested child A + * └── nested child B + * + * The unrelated sibling runs the *identical* mediated loop as the target's + * children. It is not a different kind of work that happens to keep going — it + * is the same work with the decoration absent, which is what makes "the + * middleware is what stopped the target" separable from "that loop was going to + * stop anyway". + * + * Child B is where the negative control lives. Mediated, it advances through the + * Api like its sibling. Raw, it makes the same progress with ordinary Effection + * and never invokes the Api at all — the case that must fail. + * + * Two things keep the evidence off the clock. Every execution announces each + * advance on `advances`, so a test waits for an advance that happened instead of + * assuming an interval was long enough for one; and every step is numbered, so a + * continuation released twice would leave a duplicate in the history rather than + * being invisible among identical labels. + */ + +import { createScope, createSignal, sleep, spawn, useScope } from "effection"; +import type { Operation, Scope, Signal, Subscription, Task } from "effection"; + +import { ExecutionJournal, ExecutionOwner, external, fork, step } from "./execution.ts"; +import { useGate } from "./gate.ts"; +import type { Gate } from "./gate.ts"; +import { createJournal } from "./journal.ts"; +import type { Journal } from "./journal.ts"; + +/** Long enough that a loop yields to its siblings, short enough to stay quick. */ +const TICK = 1; + +export interface Advance { + readonly owner: string; + readonly count: number; +} + +export interface FixtureOptions { + /** How child B advances: through the Api, or in raw Effection. */ + readonly childB: "mediated" | "raw"; + /** + * An external operation child A performs through the Api. The promise is + * already in flight when the fixture starts, so whatever it represents keeps + * running no matter what the gate does. + */ + readonly pending?: Promise; +} + +export interface Fixture { + readonly gate: Gate; + readonly journal: Journal; + readonly targetScope: Scope; + readonly entry: Task; + /** One value per completed loop, from every execution in the topology. */ + readonly advances: Signal; + /** How many times child A's continuation ran past its external operation. */ + readonly pastExternal: () => number; + /** + * Destroy the scope that owns the target subtree. + * + * Used by the lifecycle matrix for the two rows that need the `paused` state, + * which only this synthetic gate reaches. + */ + shutdown(): Operation; +} + +export function* startFixture(options: FixtureOptions): Operation { + const session = yield* useScope(); + const journal = createJournal(); + session.set(ExecutionJournal, journal); + + const advances = createSignal(); + const counts = { pastExternal: 0 }; + + function announce(owner: string, count: number) { + advances.send({ owner, count }); + } + + // Destructured so the matrix can tear the owner down explicitly. Reading the + // tuple creates no scope, so the live set is the same as before. + const [targetScope, disposeTarget] = createScope(session); + targetScope.set(ExecutionOwner, "entry"); + + const gate = yield* useGate({ + target: targetScope, + journal, + rootOwner: "entry", + }); + + yield* spawn(function* unrelatedSibling() { + yield* ExecutionOwner.set("sibling"); + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + yield* step(`sibling#${count}`); + announce("sibling", count); + } + }); + + const entry = targetScope.run(function* targetExecution() { + yield* fork("childA", function* childA() { + if (options.pending) { + yield* external("await", options.pending); + counts.pastExternal += 1; + } + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + yield* step(`childA#${count}`); + announce("childA", count); + } + }); + + yield* fork("childB", function* childB() { + if (options.childB === "raw") { + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + announce("childB", count); + } + } + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + yield* step(`childB#${count}`); + announce("childB", count); + } + }); + + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + yield* step(`entry#${count}`); + announce("entry", count); + } + }); + + return { + gate, + journal, + targetScope, + entry, + advances, + pastExternal: () => counts.pastExternal, + *shutdown() { + yield* disposeTarget(); + }, + }; +} + +/** + * Wait until `owner` announces its next advance. + * + * The subscription must already exist, so an advance that happens while a test + * is deciding what to assert is queued rather than missed. + */ +export function* advanceOf( + advancing: Subscription, + owner: string, +): Operation { + while (true) { + const next = yield* advancing.next(); + if (!next.done && next.value.owner === owner) { + return next.value; + } + } +} diff --git a/scripts/repl-pause/gate.ts b/scripts/repl-pause/gate.ts new file mode 100644 index 000000000..33e0bdaa8 --- /dev/null +++ b/scripts/repl-pause/gate.ts @@ -0,0 +1,304 @@ +/** + * A pause gate built entirely from middleware around an XMD-owned Api. + * + * The gate is installed by decorating `Execution` on **one** scope — the target + * execution scope — with `Scope.around()`. Effection stores that decoration in a + * context on that scope, so every descendant resolves the decorated handle and + * every independent sibling resolves the undecorated core. Inheritance and + * absence are therefore not properties this module maintains; they are how the + * decoration is looked up. + * + * Two things are deliberately *not* here. There is no registry a component can + * find, and no `pausePoint()` for fixture code to call: a continuation is held + * only because the middleware that wraps its Api invocation suspends. And the + * gate's own state lives in the scope that acquired it, which is outside the + * subtree it holds, so the controller stays live while the target does not. + * + * The state a pause settles into is **structural, and fails closed.** `paused` + * is reported only when every live descendant scope — enumerated from + * `api.Scope` creation and destruction, which is execution ownership and + * nothing else — is one the gate is holding. A descendant that progresses + * without invoking the Api is live and unheld, so it can never be mistaken for + * a suspended one; `pausing` simply never settles, and `inspect()` names it. + */ + +import { action, createSignal, resource, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import { api } from "effection/experimental"; + +import { Execution } from "./execution.ts"; +import type { Journal } from "./journal.ts"; + +export type PauseState = "running" | "pausing" | "paused"; + +interface Park { + readonly at: string; + released: number; + release(): void; +} + +export interface Inspection { + readonly state: PauseState; + /** Every live descendant scope of the target, by execution ownership. */ + readonly live: readonly string[]; + /** The descendants the gate is holding, and where each is held. */ + readonly held: readonly string[]; + /** Live descendants the gate does not hold — why `pausing` has not settled. */ + readonly unaccounted: readonly string[]; + /** + * Descendants whose Api invocations have actually dispatched through this + * middleware. This is the inheritance claim read at the boundary itself: a + * scope appears here only because the decoration installed on the target was + * the handle *its* invocation resolved. + */ + readonly mediated: readonly string[]; + /** + * Scopes outside the target subtree that dispatched through this middleware. + * Always empty: a decoration installed on one scope is unreachable from an + * independent sibling, so a name here would mean the gate had leaked. + */ + readonly strangers: readonly string[]; + /** The target subtree's own history position. */ + readonly targetHead: number; +} + +export interface PauseReport { + readonly held: readonly string[]; + readonly targetHead: number; +} + +export interface Gate { + readonly state: PauseState; + /** Enter `pausing`. Returns at once; nothing is held yet. */ + request(): void; + /** + * Settle once every live descendant is held at the middleware boundary, and + * only then enter `paused`. + */ + reached(): Operation; + /** + * Continue. Releases the same held continuations, exactly once each. Called + * before `paused` is reached, it abandons the request instead. + */ + release(): void; + inspect(): Inspection; + /** How many held continuations the gate has resolved. */ + readonly releases: number; + /** A continuation resolved more than once would be counted here. */ + readonly doubleReleases: number; +} + +export interface GateOptions { + /** The scope that owns the target execution. Middleware is installed here. */ + readonly target: Scope; + readonly journal: Journal; + /** The journal owner the target execution itself appends under. */ + readonly rootOwner: string; +} + +export function useGate(options: GateOptions): Operation { + const { target, journal, rootOwner } = options; + + return resource(function* (provide) { + let state: PauseState = "running"; + let releases = 0; + let doubleReleases = 0; + let counter = 0; + + const parks = new Map(); + const children = new Map>([[target, new Set()]]); + const parents = new Map(); + const ids = new Map(); + const labels = new Map(); + const owners = new Set([rootOwner]); + const mediations = new Map(); + /** + * Every scope ever created under the target, kept after it is destroyed. + * Membership has to outlive the scope, or a settled descendant would look + * like a foreign one the moment it exited. + */ + const known = new Set(); + + /** Anything that changes the live set or the held set wakes `reached()`. */ + const changes = createSignal(); + + function describe(scope: Scope): string { + const id = ids.get(scope) ?? "s?"; + const label = labels.get(scope); + const park = parks.get(scope); + const name = label ? `${id}(${label})` : id; + return park ? `${name}@${park.at}` : name; + } + + function descendants(): Scope[] { + const found: Scope[] = []; + const walk = (at: Scope) => { + for (const child of children.get(at) ?? []) { + found.push(child); + walk(child); + } + }; + walk(target); + return found; + } + + function unaccounted(): Scope[] { + return descendants().filter((scope) => !parks.has(scope)); + } + + function targetHead(): number { + return journal.snapshot().filter((record) => owners.has(record.owner)).length; + } + + /** + * Record that `scope`'s invocation dispatched through this middleware, then + * hold it if a pause is in effect. + */ + function* mediate(at: string): Operation { + const scope = yield* useScope(); + mediations.set(scope, (mediations.get(scope) ?? 0) + 1); + yield* checkpoint(scope, at); + } + + function* checkpoint(scope: Scope, at: string): Operation { + if (state === "running") { + return; + } + yield* action((resolve) => { + const park: Park = { + at, + released: 0, + release() { + park.released += 1; + if (park.released === 1) { + releases += 1; + resolve(); + } else { + doubleReleases += 1; + } + }, + }; + parks.set(scope, park); + changes.send(`held ${describe(scope)}`); + return () => { + parks.delete(scope); + changes.send(`freed ${describe(scope)}`); + }; + }, `xmd.pause.checkpoint(${at})`); + } + + target.around(api.Scope, { + create: (args, next) => { + const made = next(...args); + const [child] = made; + const [parent] = args; + counter += 1; + ids.set(child, `s${counter}`); + parents.set(child, parent); + children.set(child, new Set()); + children.get(parent)?.add(child); + known.add(child); + changes.send(`created ${describe(child)}`); + return made; + }, + *destroy(args, next) { + const [scope] = args; + try { + return yield* next(...args); + } finally { + const parent = parents.get(scope); + if (parent) { + children.get(parent)?.delete(scope); + } + const gone = describe(scope); + parents.delete(scope); + children.delete(scope); + labels.delete(scope); + changes.send(`destroyed ${gone}`); + } + }, + }); + + target.around(Execution, { + *step(args, next) { + yield* mediate(`step:${args[0]}`); + const head = yield* next(...args); + yield* mediate(`stepped:${args[0]}`); + return head; + }, + *fork(args, next) { + const [label, body] = args; + yield* mediate(`fork:${label}`); + return yield* next(label, function* () { + const scope = yield* useScope(); + labels.set(scope, label); + owners.add(label); + yield* mediate(`entry:${label}`); + yield* body(); + }); + }, + *external(args, next) { + yield* mediate(`external:${args[0]}`); + const value = yield* next(...args); + yield* mediate(`returned:${args[0]}`); + return value; + }, + }); + + const gate: Gate = { + get state() { + return state; + }, + get releases() { + return releases; + }, + get doubleReleases() { + return doubleReleases; + }, + request() { + if (state === "running") { + state = "pausing"; + changes.send("requested"); + } + }, + *reached() { + const arriving = yield* changes; + while (true) { + if (unaccounted().length === 0) { + state = "paused"; + return { + held: descendants().map(describe), + targetHead: targetHead(), + }; + } + yield* arriving.next(); + } + }, + release() { + state = "running"; + // The copy is load-bearing. `resolve()` reaches the held routine's + // `resume()`, which runs the action's discard synchronously, and that + // discard deletes the park — so releasing straight from `parks.values()` + // would mutate the map it is iterating. + for (const park of [...parks.values()]) { + park.release(); + } + changes.send("released"); + }, + inspect() { + const live = descendants(); + return { + state, + live: live.map(describe), + held: live.filter((scope) => parks.has(scope)).map(describe), + unaccounted: unaccounted().map(describe), + mediated: live.filter((scope) => mediations.has(scope)).map(describe), + strangers: [...mediations.keys()].filter((scope) => !known.has(scope)).map(describe), + targetHead: targetHead(), + }; + }, + }; + + yield* provide(gate); + }); +} diff --git a/scripts/repl-pause/journal.ts b/scripts/repl-pause/journal.ts new file mode 100644 index 000000000..be6ad9ce2 --- /dev/null +++ b/scripts/repl-pause/journal.ts @@ -0,0 +1,48 @@ +/** + * The history a paused execution must stop appending to. + * + * One journal per session, with each record naming the execution that appended + * it, because "the target history stops" and "the sibling keeps going" are the + * same question asked of one ordered log. Two logs could not answer whether a + * paused subtree slipped a record in between two of the sibling's. + */ + +export interface JournalRecord { + readonly owner: string; + readonly label: string; +} + +export interface Journal { + /** Append one record and return the journal's new length. */ + append(owner: string, label: string): number; + /** The whole log's length. */ + readonly head: number; + /** How many records `owner` has appended — its own history position. */ + headOf(owner: string): number; + /** The labels `owner` appended, in order. */ + labelsOf(owner: string): readonly string[]; + snapshot(): readonly JournalRecord[]; +} + +export function createJournal(): Journal { + const records: JournalRecord[] = []; + + return { + append(owner, label) { + records.push({ owner, label }); + return records.length; + }, + get head() { + return records.length; + }, + headOf(owner) { + return records.filter((record) => record.owner === owner).length; + }, + labelsOf(owner) { + return records.filter((record) => record.owner === owner).map((record) => record.label); + }, + snapshot() { + return [...records]; + }, + }; +} diff --git a/scripts/repl-pause/main.ts b/scripts/repl-pause/main.ts new file mode 100644 index 000000000..3f6dc19c9 --- /dev/null +++ b/scripts/repl-pause/main.ts @@ -0,0 +1,268 @@ +/** + * The representative lifecycle trace for #841's first slice. + * + * deno task repl:pause + * + * Two traces are printed, because the finding is a boundary rather than a + * capability. The first follows a subtree whose every advance is an invocation + * of the XMD execution Api: it reaches `paused`, holds its history, and releases + * each continuation once. The second changes one thing — a child that advances in + * ordinary Effection — and the same controller can never report `paused` at all. + * + * Every row is read from the gate at the moment the phase ends, and every phase + * boundary is an announced advance rather than an elapsed interval. Where a + * number depends on how many times a loop got to run, the row reports the + * relation the contract cares about ("fixed", "advanced") instead, so the trace + * is the same on every machine. + */ + +import { main, race, scoped, sleep } from "effection"; +import type { Operation } from "effection"; + +import { advanceOf, startFixture } from "./fixture.ts"; +import type { Fixture } from "./fixture.ts"; + +interface Row { + readonly phase: string; + readonly state: string; + readonly live: number; + readonly held: number; + readonly unaccounted: string; + readonly history: string; + readonly sibling: string; + readonly resumes: string; +} + +function render(rows: readonly Row[]): string[] { + const columns: Array<[string, (row: Row) => string]> = [ + ["phase", (row) => row.phase], + ["controller", (row) => row.state], + ["live", (row) => String(row.live)], + ["held", (row) => String(row.held)], + ["live & unheld", (row) => row.unaccounted], + ["target history", (row) => row.history], + ["sibling", (row) => row.sibling], + ["resumes", (row) => row.resumes], + ]; + + const widths = columns.map(([heading, read]) => + Math.max(heading.length, ...rows.map((row) => read(row).length)), + ); + + const line = (cells: string[]) => + cells + .map((cell, at) => cell.padEnd(widths[at])) + .join(" ") + .trimEnd(); + + return [ + line(columns.map(([heading]) => heading)), + line(widths.map((width) => "-".repeat(width))), + ...rows.map((row) => line(columns.map(([, read]) => read(row)))), + ]; +} + +/** Wait for the handshake, but report rather than hang if it cannot settle. */ +function* settleOrReport(fixture: Fixture): Operation { + const outcome = yield* race([ + (function* () { + yield* fixture.gate.reached(); + return true; + })(), + (function* () { + yield* sleep(250); + return false; + })(), + ]); + return outcome; +} + +function* traceMediated(): Operation { + const fixture = yield* startFixture({ childB: "mediated" }); + const advancing = yield* fixture.advances; + const rows: Row[] = []; + + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + yield* advanceOf(advancing, "entry"); + + const running = fixture.gate.inspect(); + const siblingAtStart = fixture.journal.headOf("sibling"); + rows.push({ + phase: "1 every execution advancing", + state: running.state, + live: running.live.length, + held: running.held.length, + unaccounted: `${running.unaccounted.length}`, + history: `${running.targetHead}`, + sibling: `${siblingAtStart}`, + resumes: `${fixture.gate.releases}`, + }); + + fixture.gate.request(); + const requested = fixture.gate.inspect(); + rows.push({ + phase: "2 pause requested", + state: requested.state, + live: requested.live.length, + held: requested.held.length, + unaccounted: `${requested.unaccounted.length} still advancing`, + history: `${requested.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases}`, + }); + + const settled = yield* settleOrReport(fixture); + const paused = fixture.gate.inspect(); + rows.push({ + phase: "3 every descendant held", + state: settled ? paused.state : `${paused.state} (unsettled)`, + live: paused.live.length, + held: paused.held.length, + unaccounted: `${paused.unaccounted.length}`, + history: `${paused.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases}`, + }); + + const heldAt = paused.held; + const headAt = paused.targetHead; + const siblingAt = fixture.journal.headOf("sibling"); + for (let advance = 0; advance < 5; advance += 1) { + yield* advanceOf(advancing, "sibling"); + } + const during = fixture.gate.inspect(); + rows.push({ + phase: "4 five sibling advances later", + state: during.state, + live: during.live.length, + held: during.held.length, + unaccounted: `${during.unaccounted.length}`, + history: during.targetHead === headAt ? "fixed" : "MOVED", + sibling: fixture.journal.headOf("sibling") > siblingAt ? "advanced" : "STALLED", + resumes: `${fixture.gate.releases}`, + }); + + const sameContinuations = during.held.join("|") === heldAt.join("|") ? "same" : "CHANGED"; + + fixture.gate.release(); + const released = fixture.gate.inspect(); + rows.push({ + phase: "5 continue", + state: released.state, + live: released.live.length, + held: released.held.length, + unaccounted: `${released.unaccounted.length}`, + history: `${released.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases} (${fixture.gate.doubleReleases} twice)`, + }); + + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + yield* advanceOf(advancing, "entry"); + const after = fixture.gate.inspect(); + rows.push({ + phase: "6 every execution advancing", + state: after.state, + live: after.live.length, + held: after.held.length, + unaccounted: `${after.unaccounted.length}`, + history: after.targetHead > headAt ? "advanced" : "STALLED", + sibling: "advanced", + resumes: `${fixture.gate.releases}`, + }); + + const labels = fixture.journal.snapshot().map((record) => `${record.owner}:${record.label}`); + + return [ + "Trace A — every advance mediated by the XMD execution Api", + "", + ...render(rows), + "", + `held continuations at pause : ${heldAt.join(", ")}`, + `the same ones five sibling advances later : ${sameContinuations}`, + `history duplicated any label after continue : ${ + new Set(labels).size === labels.length ? "no" : "YES" + }`, + ]; +} + +function* traceUnmediated(): Operation { + const fixture = yield* startFixture({ childB: "raw" }); + const advancing = yield* fixture.advances; + const rows: Row[] = []; + + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + + fixture.gate.request(); + const requested = fixture.gate.inspect(); + rows.push({ + phase: "1 pause requested", + state: requested.state, + live: requested.live.length, + held: requested.held.length, + unaccounted: `${requested.unaccounted.length}`, + history: `${requested.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases}`, + }); + + const settled = yield* settleOrReport(fixture); + const stuck = fixture.gate.inspect(); + rows.push({ + phase: "2 handshake given up on", + state: settled ? stuck.state : `${stuck.state} (never settles)`, + live: stuck.live.length, + held: stuck.held.length, + unaccounted: stuck.unaccounted.join(", "), + history: `${stuck.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases}`, + }); + + const before = yield* advanceOf(advancing, "childB"); + const after = yield* advanceOf(advancing, "childB"); + const moving = fixture.gate.inspect(); + rows.push({ + phase: "3 child B advances twice more", + state: moving.state, + live: moving.live.length, + held: moving.held.length, + unaccounted: moving.unaccounted.join(", "), + history: `${moving.targetHead}`, + sibling: `${fixture.journal.headOf("sibling")}`, + resumes: `${fixture.gate.releases}`, + }); + + fixture.gate.release(); + + return [ + "Trace B — one descendant advancing in ordinary Effection", + "", + ...render(rows), + "", + `child B advanced ${before.count} -> ${after.count} while the controller was pausing`, + `child B journal records : ${fixture.journal.headOf("childB")}`, + "", + "Child B is dispatched through the middleware exactly once, when it is forked.", + "Being mediated at creation is not being pausable: every advance after that is", + "raw Effection, so the gate never holds it and `paused` is never reported. The", + "controller fails closed — it does not mistake an unheld descendant for a", + "suspended one — but it also cannot pause the subtree.", + ]; +} + +await main(function* () { + // Each trace gets its own scope. The executions of the first one are live + // until it ends, and they append to whichever journal the session context + // holds — so running them in one scope would let trace A write trace B's + // history. + const mediated = yield* scoped(traceMediated); + const unmediated = yield* scoped(traceUnmediated); + + for (const line of [...mediated, "", "", ...unmediated]) { + console.log(line); + } +}); diff --git a/scripts/repl-pause/measure.ts b/scripts/repl-pause/measure.ts new file mode 100644 index 000000000..c5996cd82 --- /dev/null +++ b/scripts/repl-pause/measure.ts @@ -0,0 +1,274 @@ +/** + * The expansion-pause evidence, printed. + * + * deno task repl:pause:xmd + * + * Five sections, in the order the claim is built. + * + * 1. **Coverage.** Which existing surfaces a real representative XMD execution + * crosses, and which of them bracket a walk rather than sit inside one — + * measured by running the document, not read off the Api declarations. + * 2. **Playing pass-through.** The same document with and without the REPL + * decoration, compared on output and on the journal. + * 3. **EXPANSION PAUSED.** What the controller reports when Pause arrives while + * a component body is running ordinary Effection, and what the document and + * the runtime each do across a long hold. + * 4. **Background recording.** The Journal head advancing past a fixed expansion + * pause point, and not being replayed on Continue. + * 5. **Controls.** Concurrent region walks, and no middleware at all. + */ + +import { main, race, scoped, sleep } from "effection"; +import type { Operation } from "effection"; + +import { advanceOf, runSiblingExecution, startXmdFixture } from "./xmd-fixture.ts"; + +function say(line: string) { + console.log(line); +} + +function heading(line: string) { + console.log(`\n${line}\n${"-".repeat(line.length)}`); +} + +function* bounded(op: Operation, ms: number): Operation { + return yield* race([ + op, + (function* () { + yield* sleep(ms); + return "timeout" as const; + })(), + ]); +} + +/** A promise settled from outside Effection. */ +function deferred(): { promise: Promise; settle: (v: string) => void } { + let settle: (value: string) => void = () => {}; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, settle }; +} + +function* coverage(): Operation { + const control = yield* scoped(function* () { + const fixture = yield* startXmdFixture({ withoutMiddleware: true }); + const output = yield* fixture.execution; + return { + output: String(output), + journal: (yield* fixture.journalKinds()).join(" "), + }; + }); + + yield* scoped(function* () { + const fixture = yield* startXmdFixture({}); + const output = yield* fixture.execution; + const gate = fixture.gate; + if (!gate) { + return; + } + + heading("1. coverage — the existing surfaces a real execution crosses"); + const bySurface = new Map(); + for (const crossing of gate.crossings) { + const entry = bySurface.get(crossing.surface) ?? { count: 0, kind: crossing.kind }; + entry.count += 1; + bySurface.set(crossing.surface, entry); + } + say(`${"surface".padEnd(22)} ${"crossings".padEnd(10)} role`); + say(`${"-".repeat(22)} ${"-".repeat(10)} ${"-".repeat(34)}`); + for (const [surface, entry] of [...bySurface].toSorted()) { + say( + `${surface.padEnd(22)} ${String(entry.count).padEnd(10)} ${ + entry.kind === "walk" ? "brackets one expansion walk" : "step gate inside a walk" + }`, + ); + } + const walkKinds = [ + ...new Set(gate.crossings.filter((c) => c.kind === "walk").map((c) => c.surface)), + ].toSorted(); + say(""); + say(`walk brackets seen : ${walkKinds.join(", ")}`); + say(`distinct walks : ${new Set(gate.crossings.map((c) => c.walk)).size}`); + + heading("2. playing pass-through"); + say(`output identical to the no-middleware control : ${String(output) === control.output}`); + say( + `journal identical : ${ + (yield* fixture.journalKinds()).join(" ") === control.journal + }`, + ); + say(`walks left active : ${gate.inspect().walks.length}`); + say(`continuations held : ${gate.inspect().held.length}`); + say(`controller state : ${gate.state}`); + }); +} + +function* expansionPaused(): Operation { + yield* scoped(function* () { + heading("3. EXPANSION PAUSED — a real execution"); + const external = deferred(); + const fixture = yield* startXmdFixture({ background: external.promise }); + const gate = fixture.gate; + if (!gate) { + return; + } + const advancing = yield* fixture.advances; + + // Pause while a component body is running ordinary Effection, so this is + // work already in flight rather than work not yet begun. + yield* advanceOf(advancing, "slow"); + + gate.request(); + say(`state the moment Pause is requested : ${gate.state}`); + const requested = gate.inspect(); + say(`active walks : ${requested.walks.length}`); + say(`walks still advancing : ${requested.advancing.length}`); + + const settled = yield* bounded(gate.reached(), 800); + const resting = gate.inspect(); + say( + `reached() : ${ + settled === "timeout" ? "NEVER SETTLED" : "EXPANSION PAUSED" + }`, + ); + say(`state : ${resting.state}`); + say(`active walks : ${resting.walks.join(" | ")}`); + say(`held continuations : ${resting.held.join(", ")}`); + say(`live Effection scopes (diagnostic) : ${resting.liveScopes}`); + + const journalAtRest = yield* fixture.journalKinds(); + const fanoutAtRest = fixture.fanoutSteps(); + const laterAtRest = fixture.laterRan(); + const heldAtRest = resting.held.join("|"); + + // A long hold, measured by an unrelated sibling's own announced advances on a + // FRESH subscription. The signal buffers from the moment a subscription is + // taken, so reusing the earlier one would drain a backlog in no time at all + // and the interval would measure nothing. + const interval = yield* fixture.advances; + for (let advance = 0; advance < 30; advance += 1) { + yield* advanceOf(interval, "sibling"); + } + + say(""); + say( + `across 30 sibling advances: expansion ${ + fixture.laterRan() === laterAtRest ? "STOPPED" : "ADVANCED" + }, holds ${heldAtRest === gate.inspect().held.join("|") ? "unchanged" : "MOVED"}`, + ); + say( + `ordinary Effection children of a component: ${fanoutAtRest} -> ${fixture.fanoutSteps()} ` + + `(${fixture.fanoutSteps() > fanoutAtRest ? "still running" : "stopped"})`, + ); + say(`state after that interval : ${gate.state}`); + + heading("4. background recording while expansion is paused"); + const beforeExternal = (yield* fixture.journalKinds()).length; + say(`journal records before external work completes : ${beforeExternal}`); + + // The external system completes while expansion is paused. Its durable + // outcome is appended normally. + external.settle("external-done"); + yield* advanceOf(interval, "recorded"); + + const afterExternal = yield* fixture.journalKinds(); + say(`journal records after it recorded : ${afterExternal.length}`); + say(`the appended record : ${afterExternal.at(-1)}`); + say( + `expansion pause point : ${ + fixture.laterRan() === laterAtRest ? "still fixed" : "MOVED" + }`, + ); + say(`controller state : ${gate.state}`); + say(""); + say("This is the distinction the mode name carries: EXPANSION PAUSED, not paused at head."); + + const appendsBeforeContinue = fixture.appendCount(); + gate.release(); + const finished = yield* bounded(fixture.execution, 6000); + const final = yield* fixture.journalKinds(); + say(""); + say( + `after Continue: ${ + finished === "timeout" ? "TIMEOUT" : "completed" + }, released ${gate.releases}, twice ${gate.doubleReleases}`, + ); + say( + `the background record appears exactly once : ${ + final.filter((kind) => kind === "yield:background").length === 1 + }`, + ); + say( + `records written before the hold unchanged : ${ + final.slice(0, journalAtRest.length).join(" ") === journalAtRest.join(" ") + }`, + ); + say(`appends before Continue ${appendsBeforeContinue}, after ${fixture.appendCount()}`); + say(`terminal record : ${final.at(-1)}`); + }); +} + +function* controls(): Operation { + yield* scoped(function* () { + heading("5a. concurrent expansion walks"); + const fixture = yield* startXmdFixture({ concurrentRegions: true }); + const gate = fixture.gate; + if (!gate) { + return; + } + const advancing = yield* fixture.advances; + // Pause while both regions are still expanding, not after they finished. + yield* advanceOf(advancing, "region"); + gate.request(); + yield* bounded(gate.reached(), 800); + const resting = gate.inspect(); + say(`state : ${resting.state}`); + say(`active walks : ${resting.walks.length}`); + for (const walk of resting.walks) { + say(` ${walk}`); + } + say(`still advancing: ${resting.advancing.length}`); + gate.release(); + yield* bounded(fixture.execution, 6000); + }); + + yield* scoped(function* () { + heading("5b. a sibling execution, run while the target is paused"); + const fixture = yield* startXmdFixture({}); + const gate = fixture.gate; + if (!gate) { + return; + } + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "slow"); + gate.request(); + yield* bounded(gate.reached(), 800); + say(`target state: ${gate.state}`); + + const sibling = yield* bounded(runSiblingExecution(), 6000); + if (sibling !== "timeout") { + say(`sibling completed : ${sibling.output.includes("Hello from declared Markdown.")}`); + say(`sibling journal : ${sibling.journal.join(" ")}`); + } + say(`target still : ${gate.state}`); + gate.release(); + yield* bounded(fixture.execution, 6000); + }); + + yield* scoped(function* () { + heading("5c. control — no REPL middleware at all"); + const fixture = yield* startXmdFixture({ withoutMiddleware: true }); + say(`controller present : ${fixture.gate !== undefined}`); + const outcome = yield* bounded(fixture.execution, 6000); + say(`outcome : ${outcome === "timeout" ? "TIMEOUT" : "completed"}`); + say(`terminal record : ${(yield* fixture.journalKinds()).at(-1)}`); + }); +} + +await main(function* () { + yield* coverage(); + yield* expansionPaused(); + yield* controls(); + say(""); +}); diff --git a/scripts/repl-pause/missing-seam.ts b/scripts/repl-pause/missing-seam.ts new file mode 100644 index 000000000..cf55d8796 --- /dev/null +++ b/scripts/repl-pause/missing-seam.ts @@ -0,0 +1,267 @@ +/** + * The smallest proof of the seam Effection 4.1.0 does not publish. + * + * `scripts/tests/repl-pause-gate.test.ts` shows what middleware around an + * XMD-owned Api *can* do: hold every descendant whose progress is an invocation + * of that Api. This script is about the gap that leaves — a descendant advancing + * in ordinary Effection — and about what it would take to close it. + * + * Read the printed report, not this comment, for the result. Run it with: + * + * deno task repl:pause:seam + * + * Nothing here is a proposal. The one mechanism that does gate every + * continuation is reached by writing a context Effection neither exports nor + * documents, and the last section demonstrates the specific reason that is + * unsafe rather than merely unsupported: the write fails *open*. A gate built on + * it would report `paused` for a subtree that is still running the moment the + * internal name changed, because writing an unknown context name is not an + * error. + */ + +import { createContext, createScope, Err, main, Ok, sleep, spawn, until } from "effection"; +import type { Operation, Result, Scope } from "effection"; +import { api } from "effection/experimental"; + +function say(line: string) { + console.log(line); +} + +function heading(line: string) { + console.log(`\n${line}\n${"-".repeat(line.length)}`); +} + +/** + * The runtime's own contexts, addressed by the names it gives them. + * + * `createContext` is public; these three names are not. A scope resolves a + * context by `context.name` against a prototype chain of plain records, so a + * context rebuilt under an internal name reads and writes the internal slot. + */ +const ReducerByName = createContext("@effection/reducer"); +const PriorityByName = createContext("@effection/scope.generation", 0); +const SettleByName = createContext("@effection/coroutine.settle"); + +interface Coroutine { + scope: Scope; + data: { enqueued: boolean }; + step(): IteratorResult; + perform(effect: unknown): void; + settle(outcome: unknown): void; +} + +type Settleware = (outcome: unknown, next: (o: unknown) => void) => void; + +/** + * Effection's own reducer loop, with one difference: who decides when it runs. + * + * Every continuation in Effection advances through exactly one funnel — + * `Coroutine.resume()` calls `reducer.schedule(routine)` on the reducer its + * scope resolved when the coroutine was *created*. A subtree whose scope + * resolves this object instead therefore cannot take a step that this object + * does not take for it, whatever the operation was and whoever wrote it. + */ +class Gate { + reducing = false; + paused = false; + private tiers: Coroutine[][] = []; + + schedule = (routine: Coroutine) => { + if (!routine.data.enqueued) { + routine.data.enqueued = true; + (this.tiers[routine.scope.expect(PriorityByName)] ??= []).push(routine); + } + this.drain(); + }; + + drain() { + if (this.reducing) { + return; + } + try { + this.reducing = true; + for (let routine = this.dequeue(); routine; routine = this.dequeue()) { + const settle = routine.scope.expect(SettleByName); + try { + const next = routine.step(); + if (next.done) { + settle({ exists: true, value: { ok: true, value: next.value } }, (outcome) => + routine.settle(outcome), + ); + } else { + routine.perform(next.value); + } + } catch (error) { + settle({ exists: true, value: { ok: false, error } }, (outcome) => + routine.settle(outcome), + ); + } + } + } finally { + this.reducing = false; + } + } + + private dequeue(): Coroutine | undefined { + if (this.paused) { + return undefined; + } + for (let priority = 0; priority < this.tiers.length; priority++) { + const tier = this.tiers[priority]; + if (tier && tier.length > 0) { + const routine = tier.shift(); + if (routine) { + routine.data.enqueued = false; + return routine; + } + } + } + return undefined; + } + + get queued(): number { + return this.tiers.reduce((sum, tier) => sum + (tier?.length ?? 0), 0); + } +} + +/** What the published surface offers for scheduling. Nothing. */ +function reportPublishedSurface(): void { + heading("1. what Effection 4.1.0 publishes"); + + const members = Object.keys(api).toSorted(); + say(`effection/experimental exports api with: ${members.join(", ")}`); + say( + `api.Main (the Inspector's 4.1.0-alpha.7 gate around the program body) is ${ + "Main" in api ? "present" : "ABSENT" + }`, + ); + say( + "api.Scope's members are create/destroy/set/delete. `create` returns a tuple, not an Operation,", + ); + say("so middleware over it cannot suspend — it can observe a scope appearing, and nothing more."); +} + +/** Whether the internals can be imported at all. */ +function* reportDeepImport(): Operation { + heading("2. can the scheduler be imported directly?"); + + for (const specifier of [ + "effection/esm/lib/reducer.js", + "npm:effection@4.1.0/esm/lib/reducer.js", + ]) { + const outcome: Result = yield* until( + import(specifier).then( + (value: unknown) => Ok(value), + (error: unknown) => Err(error), + ), + ); + say(`import("${specifier}") => ${outcome.ok ? "RESOLVED" : `refused: ${outcome.error.name}`}`); + } + say("The package's `exports` map publishes `.` and `./experimental` only, so the reducer,"); + say("ReducerContext, SettleContext, Priority, Children, callcc and trap are all unreachable."); +} + +/** The one mechanism that gates every continuation, and what it costs. */ +function* reportSubstitution(): Operation { + heading("3. substituting the scheduler for one subtree"); + + const gate = new Gate(); + const progress = { entry: 0, childA: 0, childB: 0, sibling: 0 }; + + const target = createScope(); + target.set(ReducerByName, gate); + + target.run(function* () { + yield* spawn(function* () { + while (true) { + yield* sleep(1); + progress.childA += 1; + } + }); + yield* spawn(function* () { + while (true) { + yield* sleep(1); + progress.childB += 1; + } + }); + while (true) { + yield* sleep(1); + progress.entry += 1; + } + }); + + yield* spawn(function* () { + while (true) { + yield* sleep(1); + progress.sibling += 1; + } + }); + + yield* sleep(30); + const before = { ...progress }; + gate.paused = true; + yield* sleep(30); + const during = { ...progress }; + // Read before draining: this is the count of continuations being *held*, and + // after a release there is nothing left to count. + const retained = gate.queued; + gate.paused = false; + gate.drain(); + yield* sleep(30); + const after = { ...progress }; + + say(`before pause: ${JSON.stringify(before)}`); + say(`while paused: ${JSON.stringify(during)} (retained: ${retained})`); + say(`after release: ${JSON.stringify(after)}`); + + const frozen = + during.entry === before.entry && + during.childA === before.childA && + during.childB === before.childB; + say(""); + say(`Every descendant froze without invoking any Api: ${frozen}. The loops here are raw`); + say( + `Effection — no checkpoint, no cooperation — and the sibling advanced ${ + during.sibling - before.sibling + } times`, + ); + say("across the same interval. This is the capability the middleware seam cannot reach."); +} + +/** Why the above is a named gap and not a design. */ +function* reportFailsOpen(): Operation { + heading("4. why that substitution is not a proposal"); + + const target = createScope(); + const MisspelledByName = createContext("@effection/reducer-v2"); + const gate = new Gate(); + gate.paused = true; + target.set(MisspelledByName, gate); + + const progress = { work: 0 }; + target.run(function* () { + while (true) { + yield* sleep(1); + progress.work += 1; + } + }); + + yield* sleep(20); + + say(`Writing a context name the runtime does not read threw nothing, enqueued nothing`); + say(`(gate.queued = ${gate.queued}), and the subtree advanced ${progress.work} times while the`); + say("gate believed it was holding everything. A pause built this way reports `paused` for a"); + say("live subtree the moment the internal name moves, because there is no binding to fail."); + say(""); + say("The missing seam, stated as a request: a *public*, per-scope way to mediate continuation"); + say("scheduling — an `api.Reducer`/`api.Coroutine` that `Scope.around()` can decorate, or an"); + say("exported ReducerContext — so that a gate can be installed by ownership and *proven* bound."); +} + +await main(function* () { + reportPublishedSurface(); + yield* reportDeepImport(); + yield* reportSubstitution(); + yield* reportFailsOpen(); + say(""); +}); diff --git a/scripts/repl-pause/repl-gate.ts b/scripts/repl-pause/repl-gate.ts new file mode 100644 index 000000000..981993623 --- /dev/null +++ b/scripts/repl-pause/repl-gate.ts @@ -0,0 +1,500 @@ +/** + * A REPL controller that pauses **XMD expansion**, not Effection. + * + * Effection is the runtime. It keeps running: tasks stay live, timers fire, + * subprocesses finish, and background work records what it produced. What this + * controller stops is the expansion of the selected XMD execution subtree, and + * the completeness question is therefore about *expansion walks* — never about + * whether some descendant Effection scope happens to be suspended. + * + * ## What an expansion walk is + * + * XMD expansion is bracketed. Four existing operations delimit one walk: + * `Execution.document` (the root document), `Component.content` and + * `Component.tryContent` (content a component projects), and the REPL profile's + * own `expand` handler (structural syntax this REPL declared). Each entry starts + * a walk; each return settles it. + * + * Everything else the middleware wraps is a **step gate** *inside* a walk: + * `importComponent`, `applyModifiers`, `applyBoundModifiers`, `codeBlock`, + * `retain`, `capture`, `raise`, `handleFailure`, `DocumentOutput.output`, and the + * REPL's own per-region checkpoint. A step gate is a place a walk can be held, + * not a walk of its own. + * + * That distinction is what stops one walk being counted as several. A walk + * publishes its identity on a REPL-owned context for the duration of the bracket + * (`Context.with`, so the previous identity is restored on the way out), and + * every gate crossed underneath — in whatever scope the engine happens to + * dispatch from — attributes to *that* walk. So the root document's fourteen step + * gates are one obligation, not fourteen. + * + * ## How `paused` is computed + * + * paused ⟺ at least one walk is active + * and every active walk is holding at least one continuation + * + * A walk that returns from its bracket has settled and is no longer an + * obligation. A walk that is delegating — suspended inside `next()` while a + * nested walk or a core operation runs — is satisfied by the hold underneath it, + * because its own continuation is a single `yield* next()` that cannot advance + * until that hold is released. Nothing is inferred about opaque runtime state: + * both sides of that relationship are middleware this REPL wrote. + * + * ## What is deliberately not an obligation + * + * Ordinary Effection scopes and tasks. A component body that spawns a child, a + * provider that sleeps, a subprocess being awaited — none of these expand XMD, so + * none of them can prevent `paused`. `api.Scope` observation is kept below purely + * as a **diagnostic**: `inspect()` reports it and it decides nothing. + * + * ## What `paused` does not mean + * + * Not that the runtime is quiescent, not that external systems are frozen, and + * **not that the durable Journal head is stationary** — already-running work that + * produces a durable outcome records it normally while expansion is paused. The + * expansion pause point is fixed; the History head may advance past it. + */ + +import { action, createContext, createSignal, resource, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import { api } from "effection/experimental"; + +import { Component } from "../../packages/core/src/component-api.ts"; +import { DocumentOutput } from "../../packages/core/src/api.ts"; +import { Execution } from "../../packages/core/src/execute.ts"; +import type { ExpansionRequest } from "../../packages/core/host.ts"; + +export type ExpansionState = "playing" | "pausing" | "paused"; + +/** + * Which expansion walk the current operation belongs to. + * + * REPL-owned, established before execution begins, and scoped to each bracket + * with `Context.with` so a nested walk does not leak its identity into whatever + * the enclosing walk does next. + */ +const ActiveWalk = createContext("repl.pause.expansion-walk"); + +interface Hold { + readonly walk: string; + readonly at: string; + released: number; + release(): void; +} + +interface Walk { + readonly id: string; + readonly kind: string; + readonly detail: string; + /** The walk this one was started from, if any. */ + readonly parent: string | undefined; + /** Scopes currently held on this walk's behalf. */ + readonly holds: Set; +} + +/** One boundary crossing, for the inventory and the trace. */ +export interface Crossing { + readonly surface: string; + readonly detail: string; + readonly walk: string; + readonly kind: "walk" | "step"; + readonly phase: "enter" | "exit"; + /** Which coroutine scope crossed it, so concurrency within a walk is visible. */ + readonly scope: string; +} + +export interface ExpansionInspection { + readonly state: ExpansionState; + /** Active expansion walks, and where each is held. */ + readonly walks: readonly string[]; + /** Active walks holding nothing — why `pausing` has not settled. */ + readonly advancing: readonly string[]; + /** Every held continuation, by the walk it belongs to. */ + readonly held: readonly string[]; + /** + * Live descendant Effection scopes of the execution. + * + * **Diagnostic only.** Ordinary runtime activity is not a pause obligation, so + * this decides nothing; it is reported because knowing the runtime is still + * busy while expansion is paused is the point. + */ + readonly liveScopes: number; +} + +export interface ReplGate { + readonly state: ExpansionState; + /** Enter `pausing`. Returns at once; nothing is held yet. */ + request(): void; + /** Settle once every active expansion walk is held or settled. */ + reached(): Operation; + /** Continue. Releases the same held continuations, exactly once each. */ + release(): void; + inspect(): ExpansionInspection; + readonly crossings: readonly Crossing[]; + readonly releases: number; + readonly doubleReleases: number; + /** + * Install the middleware for this execution, before it starts. + * + * Never installed when Pause is pressed: the decoration is what makes a later + * Pause possible, so it has to predate the work it will hold. + */ + installBoundaries(): Operation; + /** + * Wrap the REPL's own expansion handler. + * + * `ExecutionInstallation.expand` is a function this REPL holds while assembling + * its profile, so what comes back is still the REPL's handler. It is not Api + * middleware. + */ + decorateExpand( + handler: (request: ExpansionRequest) => Operation, + ): (request: ExpansionRequest) => Operation; + /** A pause point inside the REPL's own region loop. */ + checkpoint(label: string): Operation; + /** + * Bracket one expansion walk around REPL-owned expansion code. + * + * The REPL's expansion handler drives its own region loop, so a region is an + * expansion unit this REPL delimits itself. Bracketing each one makes two + * regions expanded concurrently two concurrent walks, both of which must be + * held or settled before `paused`. + */ + walk(kind: string, detail: string, body: () => Operation): Operation; +} + +export interface ReplGateOptions { + /** The scope that will own the REPL's execution. */ + readonly target: Scope; +} + +export function useReplGate(options: ReplGateOptions): Operation { + const { target } = options; + + return resource(function* (provide) { + let state: ExpansionState = "playing"; + let releases = 0; + let doubleReleases = 0; + let walkCounter = 0; + + const walks = new Map(); + const holds = new Map(); + const crossings: Crossing[] = []; + + /** Diagnostic scope tree. Decides nothing. */ + const liveScopes = new Set(); + /** Stable names for scopes, so a trace can show who crossed what. */ + const scopeNames = new Map(); + let scopeCounter = 0; + + function nameOf(scope: Scope): string { + const existing = scopeNames.get(scope); + if (existing !== undefined) { + return existing; + } + scopeCounter += 1; + const name = `c${scopeCounter}`; + scopeNames.set(scope, name); + return name; + } + + /** Anything that changes the walk set or the held set wakes `reached()`. */ + const changes = createSignal(); + + function describeWalk(walk: Walk): string { + const where = [...walk.holds].map((scope) => holds.get(scope)?.at ?? "?").join(","); + const name = `${walk.id}:${walk.kind}(${walk.detail})`; + if (where !== "") { + return `${name} held@${where}`; + } + const children = childrenOf(walk.id); + return children.length === 0 + ? `${name} advancing` + : `${name} delegating->${children.map((child) => child.id).join("+")}`; + } + + function childrenOf(id: string): Walk[] { + return [...walks.values()].filter((walk) => walk.parent === id); + } + + /** + * Whether one walk can no longer advance expansion. + * + * A walk is satisfied when it is holding a continuation of its own, or when + * it is delegating to child walks — and, either way, only when every active + * child is satisfied too. Both halves are needed. Without the delegation + * clause a parent suspended inside `next()` while its children are held would + * block `paused` forever; without the "and every child" clause a parent held + * at its own gate would report `paused` while a concurrent child walk was + * still expanding. + */ + function satisfied(walk: Walk): boolean { + const children = childrenOf(walk.id); + if (walk.holds.size === 0 && children.length === 0) { + return false; + } + return children.every(satisfied); + } + + function advancingWalks(): Walk[] { + return [...walks.values()].filter((walk) => !satisfied(walk)); + } + + /** The corrected completeness rule: expansion walks, and nothing else. */ + function expansionSettled(): boolean { + return walks.size > 0 && advancingWalks().length === 0; + } + + /** Park the current continuation until Continue, if a pause is in effect. */ + function* hold(walkId: string, at: string): Operation { + if (state === "playing") { + return; + } + const walk = walks.get(walkId); + if (walk === undefined) { + return; + } + const scope = yield* useScope(); + yield* action((resolve) => { + const parked: Hold = { + walk: walkId, + at, + released: 0, + release() { + parked.released += 1; + if (parked.released === 1) { + releases += 1; + resolve(); + } else { + doubleReleases += 1; + } + }, + }; + holds.set(scope, parked); + walk.holds.add(scope); + changes.send(`held ${walkId}@${at}`); + return () => { + holds.delete(scope); + walk.holds.delete(scope); + changes.send(`freed ${walkId}@${at}`); + }; + }, `repl.expansion.hold(${at})`); + } + + /** A step gate inside a walk: a place that walk can be held. */ + function* step(surface: string, detail: string): Operation { + const walk = (yield* ActiveWalk.get()) ?? "w0"; + const scope = yield* useScope(); + crossings.push({ + surface, + detail, + walk, + kind: "step", + phase: "enter", + scope: nameOf(scope), + }); + yield* hold(walk, `${surface}:${detail}`); + } + + /** + * Bracket one expansion walk around `body`. + * + * The identity is published for the duration and restored afterwards, so a + * gate crossed after a nested walk returns attributes to the enclosing walk + * again rather than to the one that just settled. + */ + function bracket(kind: string, detail: string, body: () => Operation): Operation { + walkCounter += 1; + const id = `w${walkCounter}`; + return (function* () { + // Read before publishing this walk's own identity, so the enclosing walk + // becomes this one's parent and delegation is a fact the REPL recorded + // rather than something inferred later. + const parent = yield* ActiveWalk.get(); + const own = nameOf(yield* useScope()); + return yield* ActiveWalk.with(id, function* () { + const walk: Walk = { id, kind, detail, parent, holds: new Set() }; + walks.set(id, walk); + crossings.push({ + surface: kind, + detail, + walk: id, + kind: "walk", + phase: "enter", + scope: own, + }); + changes.send(`walk ${id} started`); + try { + yield* hold(id, `${kind}:enter`); + const result = yield* body(); + yield* hold(id, `${kind}:exit`); + return result; + } finally { + walks.delete(id); + crossings.push({ + surface: kind, + detail, + walk: id, + kind: "walk", + phase: "exit", + scope: own, + }); + changes.send(`walk ${id} settled`); + } + }); + })(); + } + + // Diagnostic only: ordinary runtime activity is not a pause obligation. + target.around(api.Scope, { + create: (args, next) => { + const made = next(...args); + liveScopes.add(made[0]); + return made; + }, + *destroy(args, next) { + try { + return yield* next(...args); + } finally { + liveScopes.delete(args[0]); + } + }, + }); + + const gate: ReplGate = { + get state() { + return state; + }, + get crossings() { + return crossings; + }, + get releases() { + return releases; + }, + get doubleReleases() { + return doubleReleases; + }, + + *installBoundaries(): Operation { + // Ordinary instrumentation wraps at the default `max`; `min` is the + // implementation slot the runtime providers occupy. + yield* Component.around({ + // Walk brackets: content a component projects is its own expansion. + *content(args, next) { + return yield* bracket("content", args[0] ?? "(default)", () => next(...args)); + }, + *tryContent(args, next) { + return yield* bracket("tryContent", args[0] ?? "(default)", () => next(...args)); + }, + + // Step gates, inside whichever walk is current. + *importComponent(args, next) { + yield* step("importComponent", String(args[0])); + return yield* next(...args); + }, + *applyModifiers(args, next) { + yield* step("applyModifiers", args[0].map((modifier) => modifier.name).join(",")); + return yield* next(...args); + }, + *applyBoundModifiers(args, next) { + yield* step("applyBoundModifiers", args[1].blockId); + yield* next(...args); + }, + *codeBlock(args, next) { + yield* step("codeBlock", ""); + return yield* next(...args); + }, + *retain(args, next) { + yield* step("retain", ""); + return yield* next(...args); + }, + *capture(args, next) { + yield* step("capture", args[0]); + return yield* next(...args); + }, + *raise(args, next) { + yield* step("raise", args[0].source ?? ""); + return yield* next(...args); + }, + *handleFailure(args, next) { + yield* step("handleFailure", ""); + return yield* next(...args); + }, + }); + + // Output is expansion reaching the reader, so it is gated too. This is + // what makes "the next element or output does not appear until Continue" a + // claim about output rather than only about elements. + yield* DocumentOutput.around({ + *output(args, next) { + yield* step("output", String(args[0]).slice(0, 24).replace(/\n/g, "\\n")); + return yield* next(...args); + }, + }); + + // The root document expansion: the outermost walk. + yield* Execution.around({ + *document(args, next) { + return yield* bracket("document", "root", () => next(...args)); + }, + }); + }, + + decorateExpand(handler) { + return function* replExpand(request: ExpansionRequest): Operation { + yield* bracket("expand", request.name, () => handler(request)); + }; + }, + + *checkpoint(label) { + yield* step("replCheckpoint", label); + }, + + walk(kind, detail, body) { + return bracket(kind, detail, body); + }, + + request() { + if (state === "playing") { + state = "pausing"; + changes.send("requested"); + } + }, + + *reached(): Operation { + const arriving = yield* changes; + while (true) { + if (expansionSettled()) { + state = "paused"; + return gate.inspect(); + } + yield* arriving.next(); + } + }, + + release() { + state = "playing"; + // Copied first: `resolve()` reaches the held routine's `resume()`, which + // runs the action's discard synchronously, and that discard deletes the + // hold — so releasing straight from `holds.values()` would mutate the map + // it is iterating. + for (const parked of [...holds.values()]) { + parked.release(); + } + changes.send("released"); + }, + + inspect(): ExpansionInspection { + return { + state, + walks: [...walks.values()].map(describeWalk), + advancing: advancingWalks().map(describeWalk), + held: [...holds.values()].map((parked) => `${parked.walk}@${parked.at}`), + liveScopes: liveScopes.size, + }; + }, + }; + + yield* provide(gate); + }); +} diff --git a/scripts/repl-pause/xmd-fixture.ts b/scripts/repl-pause/xmd-fixture.ts new file mode 100644 index 000000000..643abc183 --- /dev/null +++ b/scripts/repl-pause/xmd-fixture.ts @@ -0,0 +1,498 @@ +/** + * One real XMD execution, in the topology the corrected contract asks for. + * + * session owner + * ├── controller sibling acquires the gate; outside the target + * ├── unrelated live sibling ordinary Effection, never an obligation + * └── execution scope REPL middleware installed here, before the run + * └── executeInstalled(...) the document and every descendant + * + * The document is representative rather than convenient. It exercises the root + * document, structural syntax the REPL's own profile declares and expands, + * declared Markdown, projected content, a component-retained resource, a + * code-block modifier, a bound `exec`, and document output. + * + * Three things here exist to make the *corrected* claims provable: + * + * - **`` spawns ordinary Effection children.** They are not expansion, so + * they must never prevent `paused`. That is the runtime-independence case. + * - **`` starts work that outlives its own invocation** and records a + * durable outcome to the same stream the engine journals through. That is how + * the Journal head can advance while expansion is held. + * - **`concurrentRegions` expands the two `` regions in parallel**, each + * bracketed as its own walk by the REPL's own handler, so "all concurrent walks + * held or settled" has something real to be true of. + */ + +import { + createScope, + createSignal, + resource, + scoped, + sleep, + spawn, + until, + useScope, +} from "effection"; +import type { Operation, Signal, Subscription, Task } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { useEchoExec } from "@executablemd/runtime/test"; + +import { collect } from "../../packages/core/src/collect.ts"; +import { registerComponents } from "../../packages/core/src/components/registration.ts"; +import { content, retain } from "../../packages/core/src/component-api.ts"; +import { inlineSource } from "../../packages/core/src/root-source.ts"; +import { executeInstalled, Markdown, sourceDigest, Structural } from "../../packages/core/host.ts"; +import type { ExecutionInstallation, ExpansionRequest } from "../../packages/core/host.ts"; +import type { Json } from "../../packages/core/src/types.ts"; + +import { useReplGate } from "./repl-gate.ts"; +import type { ReplGate } from "./repl-gate.ts"; + +const REPL_ORIGIN = "repl-pause/profile"; + +/** Long enough that a loop yields to its siblings, short enough to stay quick. */ +const TICK = 1; + +export interface Advance { + readonly owner: string; + readonly count: number; +} + +export interface XmdFixtureOptions { + /** Install no REPL middleware at all. */ + readonly withoutMiddleware?: boolean; + /** Expand the two `` regions as concurrent walks. */ + readonly concurrentRegions?: boolean; + /** External work a component body awaits, already in flight. */ + readonly pending?: Promise; + /** External work a background recorder awaits, already in flight. */ + readonly background?: Promise; + /** `` fails after its ordinary work instead of returning. */ + readonly failing?: boolean; + /** Let the structural expansion path skip the gate — the bypass control. */ + readonly bypassGate?: boolean; +} + +export interface XmdFixture { + readonly gate: ReplGate | undefined; + readonly stream: InMemoryStream; + readonly execution: Task; + readonly advances: Signal; + journalKinds(): Operation; + /** Durable appends so far, as the stream itself counts them. */ + appendCount: () => number; + /** How many times a record of this kind was appended, counted at append time. */ + appendsOf: (kind: string) => number; + /** Steps of ordinary Effection work `` completed between boundaries. */ + slowSteps: () => number; + /** Steps the ordinary spawned children of `` completed. */ + fanoutSteps: () => number; + /** Whether `` expanded — the element after the usual pause point. */ + laterRan: () => number; + /** Whether ``'s continuation ran past its external operation. */ + pastExternal: () => number; + /** Retained resources, in the order they were acquired and released. */ + lifecycle: () => readonly string[]; + /** Destroy the scope that owns the execution — CLI-style owner shutdown. */ + shutdown(): Operation; +} + +const GREETING_SOURCE = `Hello from declared Markdown.\n`; + +const DOCUMENT = `# REPL target + + + +Panel one body. + + +Panel two body. + + + + + + +Projected content the component asks for. + + + + + + + + + + + + +\`\`\`sh exec +echo plain +\`\`\` + +\`\`\`sh exec as="bound" +echo bound +\`\`\` +`; + +function replProfile( + expand: (request: ExpansionRequest) => Operation, +): ExecutionInstallation { + return { + declarations: [ + Structural({ + name: "Panels", + origin: REPL_ORIGIN, + forms: ["paired"], + props: { type: "object", properties: {}, additionalProperties: false }, + syntax: ["…"], + description: "Lay out the panels written inside it.", + context: "The panels this construct lays out.", + parent: null, + }), + Structural({ + name: "Panel", + origin: REPL_ORIGIN, + forms: ["paired"], + props: { + type: "object", + properties: { title: { type: "string" } }, + additionalProperties: false, + }, + syntax: ['…'], + description: "One panel.", + context: "Markdown the panel holds.", + parent: "Panels", + }), + Markdown({ + name: "Greeting", + origin: `${REPL_ORIGIN}/Greeting`, + source: GREETING_SOURCE, + digest: sourceDigest(GREETING_SOURCE), + }), + ], + expand, + }; +} + +/** Read one region to completion, as the REPL's handler does. */ +function* readRegion( + region: ExpansionRequest["regions"][number], + checkpoint: (label: string) => Operation, + announce: (owner: string) => void, + pace: number, +): Operation { + const subscription = yield* yield* region.expand(); + while (true) { + yield* checkpoint(`region:${region.name}`); + if (pace > 0) { + // Paced only so that a pause can arrive while a region is genuinely + // mid-expansion rather than already finished. An ungated region is paced + // harder, because the whole point of that control is a path that is *still + // expanding* when the controller is asked to settle. + yield* sleep(TICK * pace); + } + announce("region"); + const next = yield* subscription.next(); + if (next.done) { + return; + } + } +} + +/** + * The REPL's own expansion handler. + * + * A region is an expansion unit this REPL delimits itself, so each one is + * bracketed as its own walk. Expanded in parallel that makes two concurrent + * walks, which is the only honest way to have concurrent expansion to test. + */ +function replExpansion( + gate: ReplGate | undefined, + concurrent: boolean, + announce: (owner: string) => void = () => {}, + pace = 0, +): (request: ExpansionRequest) => Operation { + const checkpoint = gate + ? (label: string) => gate.checkpoint(label) + : // deno-lint-ignore require-yield + function* (): Operation { + return; + }; + const walk = gate + ? (detail: string, body: () => Operation) => gate.walk("region", detail, body) + : (_detail: string, body: () => Operation) => body(); + + return function* expand(request: ExpansionRequest): Operation { + if (concurrent) { + const running: Task[] = []; + for (const region of request.regions) { + running.push( + yield* spawn(() => + walk(region.name, () => readRegion(region, checkpoint, announce, pace)), + ), + ); + } + for (const task of running) { + yield* task; + } + return; + } + for (const region of request.regions) { + yield* walk(region.name, () => readRegion(region, checkpoint, announce, pace)); + } + }; +} + +export function* startXmdFixture(options: XmdFixtureOptions = {}): Operation { + const session = yield* useScope(); + const advances = createSignal(); + const counts = { sibling: 0, slow: 0, fanout: 0, later: 0, pastExternal: 0 }; + const lifecycle: string[] = []; + + yield* spawn(function* unrelatedSibling() { + for (let count = 1; ; count += 1) { + yield* sleep(TICK); + counts.sibling += 1; + advances.send({ owner: "sibling", count }); + } + }); + + // Destructured so the owner can be torn down explicitly, which is what a + // CLI-style shutdown does to a running execution. + const [executionScope, disposeExecution] = createScope(session); + const stream = new InMemoryStream(); + // Counted as each append happens, so a duplicate is caught whenever it lands + // rather than only if a test samples the journal at the right moment. + const appendsByKind = new Map(); + stream.onAppend = (event) => { + const kind = event.type === "yield" ? `yield:${String(event.description.type)}` : event.type; + appendsByKind.set(kind, (appendsByKind.get(kind) ?? 0) + 1); + }; + + const gate = options.withoutMiddleware + ? undefined + : yield* useReplGate({ target: executionScope }); + + /** A component that retains a resource at its invocation site. */ + const holder = { + name: "Holder", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + return yield* retain(() => + resource(function* (provide) { + lifecycle.push("acquired:held"); + try { + yield* provide("held-token"); + } finally { + lifecycle.push("released:held"); + } + }), + ); + }, + }; + + /** + * Work that outlives its own invocation and records durably when it finishes. + * + * Retained, so the recorder belongs to the document rather than to the element + * that started it, and it appends to the same durable stream the engine + * journals through. This is the "already-running work records its outcome" + * case, and it is what lets the Journal head move while expansion is held at a + * boundary. + */ + const background = { + name: "Background", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + return yield* retain(() => + resource(function* (provide) { + yield* spawn(function* recorder() { + const value = options.background ? yield* until(options.background) : "none"; + const event: DurableEvent = { + type: "yield", + coroutineId: "root.background", + description: { type: "background", name: "repl-pause.background", label: value }, + result: { status: "ok", value }, + }; + yield* stream.append(event); + advances.send({ owner: "recorded", count: 1 }); + }); + yield* provide("background-started"); + }), + ); + }, + }; + + const slow = { + name: "Slow", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + for (let step = 1; step <= 40; step += 1) { + yield* sleep(TICK); + counts.slow += 1; + advances.send({ owner: "slow", count: step }); + } + if (options.pending) { + const value = yield* until(options.pending); + counts.pastExternal += 1; + return value; + } + if (options.failing) { + throw new Error("Slow failed while the controller was coordinating"); + } + return "slow-done"; + }, + }; + + /** + * Ordinary Effection descendants of one component invocation. + * + * They expand nothing, so they are not pause obligations. Their liveness is + * exactly what must *not* prevent `paused`. + */ + const fanout = { + name: "Fanout", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + return yield* retain(() => + resource(function* (provide) { + for (const branch of ["a", "b"]) { + yield* spawn(function* ordinaryChild() { + for (let step = 1; ; step += 1) { + yield* sleep(TICK); + counts.fanout += 1; + advances.send({ owner: `fanout:${branch}`, count: step }); + } + }); + } + yield* provide("fanout-live"); + }), + ); + }, + }; + + /** The element the walk reaches after the usual pause point. */ + const later = { + name: "Later", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + counts.later += 1; + return "later-ran"; + }, + }; + + const projecting = { + name: "Projecting", + origin: REPL_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + const projected = yield* content(); + return projected.trim(); + }, + }; + + const execution = executionScope.run(function* replExecution() { + yield* useEchoExec(); + yield* registerComponents([slow, fanout, projecting, holder, background, later]); + + if (gate) { + // Before the execution starts. Never when Pause is pressed. + yield* gate.installBoundaries(); + } + + // `bypassGate` hands the profile an undecorated handler, so the structural + // expansion path reaches no gate at all. That is the bypass control. + const expansion = replExpansion( + options.bypassGate ? undefined : gate, + options.concurrentRegions ?? false, + (owner) => advances.send({ owner, count: 1 }), + options.bypassGate ? 25 : options.concurrentRegions ? 1 : 0, + ); + + return yield* collect( + yield* executeInstalled({ ...inlineSource(DOCUMENT), stream }, [ + replProfile(gate && !options.bypassGate ? gate.decorateExpand(expansion) : expansion), + ]), + ); + }); + + return { + gate, + stream, + execution, + advances, + *journalKinds() { + const events = yield* stream.readAll(); + return events.map((event) => + event.type === "yield" ? `yield:${String(event.description.type)}` : event.type, + ); + }, + appendCount: () => stream.appendCount, + appendsOf: (kind: string) => appendsByKind.get(kind) ?? 0, + slowSteps: () => counts.slow, + fanoutSteps: () => counts.fanout, + laterRan: () => counts.later, + pastExternal: () => counts.pastExternal, + lifecycle: () => [...lifecycle], + *shutdown() { + yield* disposeExecution(); + }, + }; +} + +/** + * One execution outside the selected subtree, run to completion. + * + * Its own scope, its own stream, its own profile. Used to show that pausing one + * execution does not touch another: it expands and records normally while the + * target is held. + */ +export function* runSiblingExecution(): Operation<{ output: string; journal: string[] }> { + return yield* scoped(function* () { + yield* useEchoExec(); + const stream = new InMemoryStream(); + const output = yield* collect( + yield* executeInstalled( + { + ...inlineSource("# Sibling\n\n\n\n```sh exec\necho sibling\n```\n"), + stream, + }, + [replProfile(replExpansion(undefined, false))], + ), + ); + const events = yield* stream.readAll(); + return { + output: String(output), + journal: events.map((event) => + event.type === "yield" ? `yield:${String(event.description.type)}` : event.type, + ), + }; + }); +} + +/** Wait until `owner` announces its next advance. */ +export function* advanceOf( + advancing: Subscription, + owner: string, +): Operation { + while (true) { + const next = yield* advancing.next(); + if (!next.done && next.value.owner === owner) { + return next.value; + } + } +} + +/** Run one operation in its own scope so its executions cannot outlive it. */ +export function isolated(body: () => Operation): Operation { + return scoped(body); +} diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md new file mode 100644 index 000000000..e63129c15 --- /dev/null +++ b/scripts/repl-study/README.md @@ -0,0 +1,193 @@ +# REPL interaction study, in a real terminal + +A bounded experiment for [#838](https://github.com/taras/executable.md/issues/838) +and [#839](https://github.com/taras/executable.md/issues/839), under the REPL +quest [#827](https://github.com/taras/executable.md/issues/827). +It renders the Product Owner's approved `XMD REPL Terminal Interface` study from +fixture data through `@bomb.sh/tty` 0.9.0, to answer whether that renderer can +carry the design in terminal cells. + +#839 added the half #838 did not answer: one location said as a URL, and a focus +model derived from it. `RESULT-focus.md` records what that found. + +It executes no XMD, opens no Agent session, reads no real journal and writes +nothing but its captures. The implementation may be discarded; `RESULT.md` and +`RESULT-focus.md` record what it found. + +## Run it + +```bash +deno task repl:study --play # the whole story, start to finish +deno task repl:study # sitting on one moment, keys below +deno task repl:study --fixture drawer # opening on a different one +deno task repl:study --play generated drawer # one transition, as a diagnostic +deno task repl:study --capture captures/ # every fixture at every profile +deno task repl:study --print nested wide # one frame, as text + +deno task repl:study --frame 07 # one frame of the focus study +deno task repl:study --route 'xmd://repl/e1/transcript/entry-1/plan/+project' +deno task repl:study --frame 12 --focus-map # with the numbered overlay on +deno task repl:study --capture-focus captures/ # the focus study's frames, as text +``` + +**`--frame` and `--route` are the same door.** A frame is a location the +Product Owner's focus study names, and `--frame 07` is shorthand for its URL +plus how far the execution had recorded when it was taken. `--route` takes any +location at all: + +```text +xmd://repl//[/]*[/+]*[?at=][&inspect][&draft=] +``` + +The surface is one of `sessions`, `transcript`, `bindings`, `input`, `history`, +and it says which region owns focus — so moving focus across a region boundary +moves the URL with it. A `+` marks a drawer, so a drawer is never mistaken for a +scope of the same name, and the last drawer in the path is the top, the only one +that is visible and interactive. `at` names the recorded marker the scrubber has +selected; `inspect` says the reconstruction at it is open, takes no value, and +is refused without a marker. Everything else — where the transcript is scrolled +to, whether the overlay is drawn, which target inside a region is focused right +now — is disposable and deliberately not in the URL. + +**`--play` is the demonstration.** It begins at the empty REPL and goes all the +way to the settled entry — empty → nested → generated → drawer → paused → +settled — holding each moment long enough to read and animating every transition +between them. Nothing needs pressing. When it reaches the settled entry it stays +there until you leave with `q`. Sixteen seconds, wide terminal, no keyboard. + +Keys, while it is running: + +| Key | What it does | +| --- | --- | +| `Tab` / `Shift+Tab` | move focus around the ring, forward and in reverse | +| `1`–`5` | jump straight to a region | +| `F1` | show or hide the numbered focus map | +| `Enter` | activate the focused target | +| `Esc` | Back, and never destructive — see below | +| `↑` `↓` `PgUp` `PgDn` | move the transcript window | +| `←` `→` | move the selected marker, one at a time | +| `Ctrl+↑` / `Ctrl+↓` | move the locus out to the parent scope, or in to the first child | +| `Ctrl+←` / `Ctrl+→` | move to the previous or next sibling scope, wrapping | +| `d` | open or close the suspension that is waiting | +| `p` | play the transition out of this moment into the next | +| `q` | leave, restoring the terminal | +| `Ctrl+C` | interrupt the entry if one is running, paused or reconstructed; else clear the draft; else leave | + +**Focus belongs to the tree.** The interface is a Freedom node tree — a surface +is a node, a scope panel is a branch inside it, a drawer is a branch pushed as +the active focus root, a control is a leaf — and traversal order is tree order, +worked out on demand. A key is delivered to the focused node, so every branch +between the root and it runs its middleware. Closing a drawer removes its +branch, and its controls and their middleware go with it. + +The numbers the overlay draws are assigned separately from traversal: regions +take 1–5 and controls 6 upward, which is why `Run` is numbered after the footer +and traversed before it. While a drawer is open the ring is the drawer's own +controls and the Execution History region, and nothing else: the footer is +mounted inside the pushed branch deliberately, because it is the one way out of +it. A control that is visible but disabled is a node that was never made +focusable — drawn, numbered, and unreachable by Tab. + +**`Esc` is Back.** It closes the top drawer, restoring whatever opened it; then +leaves a reconstruction for the paused head; then returns from a control to the +region that owns it; then pops one navigation entry. It never discards the draft +and never answers a suspension — which is one deliberate divergence from the +study, recorded in `RESULT-focus.md`. + +## Moving between moments + +The six fixtures are stable states — reconstructable, capturable, and what a +journal would restore. A journey is the sequence through all of them: holds on +each moment, transitions between. Both exist only while they run — segment, +phase and elapsed time live in the frame loop, never in a fixture — so what a +journal restores is a moment, never a point halfway through a transition. + +A hold is not dead time. The study's screens are dense, and a demonstration that +cut between them as fast as it could render would show everything and let a +person read nothing. Holds run from 1.2 to 2.6 seconds, in proportion to how +much there is to take in. + +Two kinds of motion run during a transition: + +- **the renderer's own.** The contextual band declares a transition, so when a + suspension opens the drawer `@bomb.sh/tty` interpolates its height and top edge + and reports `animating` until it arrives. The harness supplies the time and + nothing else — in seconds, which is the unit the renderer measures transitions + in, converted once at that boundary from the milliseconds everything else here + counts in. +- **the application's own.** The recorded head travels along the track and the + target's transcript arrives a few rows at a time, both interpolated here from + elapsed milliseconds. + +A frame clock — `sleep(16)` in a child of the terminal session — is spawned only +while one of those two is still moving, and halted the moment both have settled, +so an idle REPL schedules nothing. Cancelling the session halts the clock with +it, which is why an interruption cannot leave a frame being drawn into a terminal +that has already been restored. + +Three flags exist for running a journey or a playback without a person watching: +`--frames ` leaves once the playback settles or that many frames have been +drawn, `--interrupt-after-frames ` raises a real `SIGINT` at the harness mid +transition, and `--trace ` records what every frame did — elapsed +milliseconds, the seconds it advanced the renderer by, whether the renderer was +animating, and how many bytes it emitted. + +## What it shows + +Six fixtures, each a moment from the study: an empty REPL; a `Plan` running +inside the document scope with three sections settled; the Plan's returned +program replacing the expression that produced it; a project `Elicit` drawer +with three Agent sessions in flight; a paused head with an earlier checkpoint +under inspection; and a settled Entry 1. + +Four layout profiles, chosen from the measured terminal size alone: + +| Profile | From | Composition | +| --- | --- | --- | +| `wide` | 160 × 36 | Sessions, transcript and bindings side by side, above one full-width Execution History footer | +| `medium` | 120 × 30 | the same composition at its floor, with secondary detail dropped | +| `narrow` | 72 × 20 | one surface at a time, full screen, under a bar naming it | +| `too-small` | below 72 × 20 | an explicit refusal that recovers on the next resize | + +## The captures + +`--capture ` writes every fixture at every profile as two files: a `.txt` +frame, which is the interface as a person reads it, and a `.ansi` file, which is +the exact byte stream. The `.txt` frames under +`scripts/tests/fixtures/repl-study/` are committed and are also the goldens +`scripts/tests/repl-study.test.ts` checks, so a rendering change shows up in a +diff as the picture it changed. The `.ansi` files are not committed. + +`--capture-focus ` does the same for the focus study's frames, with the +numbered overlay on, under `scripts/tests/fixtures/repl-focus/`. The study +states its frames as numbered target lists, so numbering them on screen is what +makes a capture legible as evidence against the frame it reproduces. + +## How it is put together + +| File | What it owns | +| --- | --- | +| `model.ts` | the semantic vocabulary — scopes, phases, sections, sessions, bindings, checkpoints, drawers. No cells. | +| `playback.ts` | the journey, the path between two fixtures, and the motion at one instant of it | +| `fixtures.ts` | the six moments and the three drawers, from the study's own content | +| `route.ts` | the URL schema, parsing, formatting, and push versus replace | +| `vendor/freedom/` | `@bomb.sh/freedom`, vendored and pinned — the node tree that owns focus | +| `tree.ts` | the interface as Freedom nodes: surfaces, panels, drawers, controls | +| `keys.ts` | a key delivered to the focused node, through its ancestors' middleware | +| `drive.ts` | one event, carried through the tree and the store — the harness and the evidence share it | +| `surfaces.ts` | which controls a drawer carries, and in what order | +| `journal.ts` | the hand-authored journal fixture, and the fold that reconstructs a moment from it | +| `store.ts` | `ReplState`, its reducer, `hydrate()` and `projection()` | +| `frames.ts` | the focus study's fourteen frames, as addressable states | +| `layout.ts` | the profile, and every region's rectangle in cells | +| `render.ts` | those rectangles and that fixture, as `@bomb.sh/tty` operations | +| `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | +| `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | +| `capture.ts` | one frame, away from a terminal, in bytes and in cells | +| `mutations.ts` | the twenty-six ways the evidence breaks this on purpose | +| `main.ts` | the documented command | + +`--replay` runs the same lifecycle with no terminal attached, writing its byte +stream to an ordinary pipe. That is how the evidence checks that the modes the +harness turned on are turned back off — on an ordinary exit, on a signal, and +when a frame throws. diff --git a/scripts/repl-study/RESULT-focus.md b/scripts/repl-study/RESULT-focus.md new file mode 100644 index 000000000..3b2ab7b82 --- /dev/null +++ b/scripts/repl-study/RESULT-focus.md @@ -0,0 +1,324 @@ +# What the routing and focus experiment found + +Issue [#839](https://github.com/taras/executable.md/issues/839) asked whether a +REPL can have one coherent location that survives resizing, drawer nesting, +historical inspection and the loss of its in-memory store — and whether focus +can be derived rather than remembered, so that background work never moves +somebody somewhere else. + +**Decision: retain the model.** One URL carries location. Freedom's node tree +carries focus, traversal, input targeting and branch lifetime. Between them +they answer all fourteen frames of the Product Owner's approved focus study, +forward and in reverse, at the wide and the narrow profile, and a state built by +a long interaction rebuilds from its URL and a journal alone. Two defects were +found on the way, and both were only findable by driving bytes. + +## The two defects, and why the suite could not see them + +**A lone Escape never arrived.** `@bomb.sh/tty` buffers a solitary `ESC` — it +cannot yet know whether an escape sequence is following — and returns +`pending: { delay: 25 }` with an empty event list, asking the caller to re-scan +after that delay. #838's reader took `scanned.events` and dropped +`scanned.pending`, so Escape was swallowed until some other key was pressed +behind it. The key was documented in the README, handled in the reducer, and +covered by a test that handed the reducer a synthetic `{ code: "Escape" }` no +terminal had produced. + +**A real Shift+Tab never arrived either.** It is `ESC [ Z`, which the decoder +reports as key code `Backtab` with **no** shift flag. The reducer tested +`code === "Tab" && shift`, which is an event only a test had ever constructed. + +Both are the same mistake: a keyboard claim checked against invented events. +Everything this slice asserts about Escape and about reverse traversal is +therefore driven as bytes through `input.scan()`, pending flush included, and +two controls — `swallow-pending-escape` and `ignore-backtab` — reproduce the +pre-repair behaviour exactly so the repair cannot regress into a synthetic test +again. + +The general lesson is narrow and worth keeping: **a decoder's contract includes +what it does not hand you yet.** A harness that reads only the events out of a +scan has not finished reading the scan. + +## What location turned out to be + +```text +xmd://repl//[/]*[/+]*[?at=][&inspect][&draft=] +``` + +The REPL has exactly three kinds of state, and telling them apart is what made +every acceptance criterion reachable: + +1. **Execution truth** belongs to the journal. Which scope is open, what has been + published, what is waiting for an answer, whether the run is live or paused — + none of that is location, and none of it is in the URL. This experiment folds + a hand-authored journal fixture; #842 owns the real one. +2. **Location** is the URL, and nothing else is location. +3. **Everything else is disposable** — the scroll anchor, whether the overlay is + drawn, which target is focused within a region. + +The scrubber's selected marker is **not** in that third group, and putting it +there was this experiment's first real mistake. A selection that lived only in +memory rendered a state its own URL could not reopen: the band showed a marker, +and a cold start came back with none. It is location, so it is in the URL. + +Four decisions inside the schema earned their keep: + +- **A drawer segment wears `+`.** Drawers are nested routes, and a path is how + nesting is said; the prefix is what stops `.../project/+project` being + ambiguous. The suite checks exactly that URL. +- **Pause is not in the URL.** Whether the runtime is live or paused is + execution truth. A URL that could describe "paused" independently of the + execution it names would be able to describe pausing a finished run. +- **`at` absent is the live head.** There is no `at=head` sentinel, so two URLs + cannot render the same screen and hydrate into different states. +- **Selecting a marker and reconstructing it are two parts, not one.** `at` is + the marker the scrubber has selected; `inspect` says the reconstruction at it + is open. They are genuinely different states — study frame 11 has a marker + selected with the run merely paused, and frame 12 has the reconstruction open + at another — and one field could not tell them apart. `inspect` is valueless + and refused without `at`, so each state has exactly one spelling. + +**Scrubbing replaces and entering inspection pushes**, and that has a visible +consequence the study does not state: Back from an inspected marker returns to +the head, not back through every marker the scrubber passed. Draft editing +replaces for the same reason — both are continuous adjustments rather than +places somebody went. Closing a reconstruction is not the same act as +deselecting a marker, so returning to the head leaves `at` where it was. + +**Moving focus across a region boundary is moving the route.** The surface +segment says which region owns focus, so the two cannot be updated in different +transitions — a reducer that changed only focus left the URL describing the +region somebody had already tabbed away from, and at narrow widths would have +gone on rendering one surface full-screen while focus named another. A control +belongs to the surface of the region that owns it, which is why focusing `Pause` +reads as `history` and focusing `Run` reads as `input`. + +## What focus turned out to be + +**Freedom's node tree owns it.** The tree replaces the DOM in the terminal: a +surface is a node, a scope panel mounted inside it is a branch, a drawer is a +branch pushed as the active focus root, and a control is a leaf. Traversal order +is tree order, computed on demand from the active subtree. There is no registry, +no ordered list, no owner strings and no identifier parsing. + +This experiment's first attempt did keep such a registry — a flat +`FocusTarget[]` with hand-written traversal order, a hand-written owner chain +and hand-written restoration. It passed every case written against it, because a +list compared with itself always agrees. What it could not do was answer a +question about where a control actually *is*, and three of this slice's cases +are exactly those questions. + +**Input goes to the focused node, and stops where it is consumed.** A key is +invoked on `current(root).scope`, so Effection walks that scope's ancestors and +every branch between the root and the control runs its middleware in order. The +evidence reads the path rather than inferring it: + +```text +target field:drawer.project.name +path drawer:project → panel:project.body +``` + +A flat registry has no way to produce that: the path is the tree's. + +**A branch that consumes a key ends the dispatch.** `keydown` returns whether it +was handled; middleware that handles one returns `true` without calling `next`, +and the harness's own fallback does not run. An earlier round recorded the path +and then reduced the same event globally regardless — so a drawer could +intercept Escape and watch the drawer close anyway. That is the difference +between a hierarchy that *annotates* a dispatch and one that *governs* it, and +only the second is worth having. + +## What identities may and may not be used for + +Nodes carry semantic names — `region:transcript`, `control:transport.pause`, +`field:drawer.project.name` — and the line between a legitimate use and a +forbidden one is worth stating exactly, because this experiment crossed it twice +before getting it right. + +**Permitted: annotating routing.** The route's surface segment is one of five +names, and a digit key naming the region to jump to, or a frame table declaring +which node it focuses, are addresses. They say *what to look for*; the tree is +what says whether it is there and where. + +**Forbidden: deriving ancestry or input targeting.** Which region owns a +control, which branch a key passes through, and what focus falls back to when a +node disappears are all questions about where a node *is*. They are answered by +walking the live tree — `surfaceOwning()` climbs parents, the dispatch path is +Effection's own scope chain — never by parsing a prefix out of a name. An +identity string cannot be wrong about its own spelling but can easily be wrong +about the tree, and a second answer is precisely what this architecture removes. + +The earlier `ownerOf()` and `ownerRegion()` helpers, which read ownership out of +the identity, are gone. + +**Closing a branch destroys it.** A drawer closes by removing its node; its +controls, its body panel and their middleware go with it through structured +teardown. Afterwards nothing in the tree can be focused, and no dispatch reaches +what used to be there. There is no second list to update, because there is no +second list. + +**A drawer is a pushed focus root.** `focusPush()` traps cycling inside the +branch and remembers what to restore; nested drawers nest, and popping restores +first to the outer drawer and finally to the invoking control. The footer is +mounted *inside* the pushed branch deliberately — the study calls it "the one +way out" — which is how #827's "keeps the fixed history footer reachable" +survives a suspension. + +**A disabled control is a node that was never made focusable.** It is mounted, +the renderer draws it and the `F1` map numbers it; it simply carries no +`focused` prop, so it cannot enter the chain. That is Freedom's own distinction +rather than one this harness invents, and it is what study frame 12 means by +numbering a dimmed `Continue` and saying Tab skips it. + +**The overlay is the tree, walked.** Numbering is assigned as the study assigns +it — regions first, then controls — but the list it numbers is the live tree, +which is why the overlay follows focus into a drawer instead of going on +numbering the panes behind it. + +**Focus is not in the application model at all.** `ReplState` has no focus +field. The store decides what an event *means* and names what should happen to +focus; the tree carries it out, because the tree is the thing that knows what +exists. That is the strongest form the "background updates never steal focus" +claim can take: the honest path does not write focus, and the control that +breaks it has to reach past the store into the tree. + +The URL still records the **surface**, because the surface segment is what says +which region owns focus — so a focus move that crosses a region boundary is a +move the route makes in the same transition. What the URL never records is the +focus identity. + +## Two gaps found in Freedom, and what was done about them + +`@bomb.sh/freedom` is private and unpublished, so its source is vendored here +from the public playground repository, pinned and manifested. Two patches are +recorded against it; `vendor/freedom/PROVENANCE.md` has the detail. + +1. **A root was parented to Effection `global`.** `createRoot()` alone means + host context does not reach the tree and a failure in node work raises into + a boundary nobody observes. `useRoot()` acquires the tree as a resource owned + by the acquiring scope. +2. **Removal asked about identity, not containment.** `useFocus()`'s middleware + moved focus to a successor when the *removed node* was the focused one — but + a drawer or panel is closed by removing the branch *above* the focused + control, so the common case left focus on a node that had just been + destroyed while a perfectly good sibling survived. + +Two more are this harness's own, and both are the same mistake in miniature — +letting the order things happened to happen in stand in for the order that was +meant. + +**A reconciler must add before it removes.** Removing the focused control +before its replacement exists leaves the region with nothing to move focus to, +and focus lands outside it — which is how a resumed run first lost its transport +slot. + +**And it must then restore the canonical order.** A replacement is appended +wherever there is room, so a control that changed from enabled to disabled ended +up last. The tree a live interaction arrived at and the tree a cold start +rebuilt from the same URL and journal then disagreed — `Return → Fork → +Continue` against `Continue → Return → Fork` — which breaks the reconstruction +boundary even though every node was present in both. Sorting the region's +children by their canonical index after reconciling settles it, and the evidence +drives frame 11 into inspection, throws the store and the tree away, and rebuilds +to compare the ordered topology. + +## Divergences from the study, named rather than hidden + +1. **Escape closes the top drawer without answering it.** Study frame 09 gives + the confirmation drawer `esc declines` — Escape as an *answer*. This + experiment owns navigation and answers no `Elicit` request; answering is + #840's and #842's. Escape here is Back, and Back is non-destructive: the + suspension is still waiting afterwards, which the suite asserts. +2. **"Entered" is being focused within.** The study says `history` exposes its + controls once the region is entered with Enter. The rule this harness + implements is that the controls are in the sequence while focus is within the + region *and an execution has been recorded*. All fourteen frames agree with + it: frame 03 focuses region 5 and lists no controls because the REPL is + empty, not because it was not entered. A state with a recorded execution, + focus in the footer and no controls exposed does not appear in the study, so + nothing here distinguishes the two readings and the simpler one was taken. +3. **The overlay is a legend, not floating callouts.** The study numbers its + targets on top of the interface, which a browser can do because it measured + them. In cells the honest equivalent is a right-anchored legend carrying the + same numbers in the same order, dimming a target that is visible but + disabled. +4. **Focus is a glyph, not a colour.** The focused region wears `▌` at its + top-left and a focused control wears `▸` beside its label — including inside + the footer's `[▸Continue ]`, where the marker replaces the space inside the + bracket rather than widening it, because the track's room is computed from + that string and a focused control that shortened the track would make focus a + layout decision. The study's own "glyph + word, never colour alone" rule + applies here too, and a committed `.txt` capture records glyphs. +5. **The harness's own moment keys changed.** #838 used `1`–`6` to switch + fixture. The moment is now a function of the route and the journal, so the + digits do what study frame 02 says they do — jump straight to a region — and + `--frame` and `--route` are how a particular moment is opened. + +## What `SURFACES` is, and what it is not + +#838 has **four** routing surfaces and the study has **five** focus regions. +These are different things and `layout.ts` was not changed. Narrow routing +promotes one region to a whole screen; the REPL input is never one of those, +because `layout.ts` already renders it inside the transcript. It is still +somewhere focus can be, so it is a route surface and not a layout surface, and +the one line of reconciliation lives in `store.ts`. No #838 golden moved. + +## Structural navigation, and where the sibling list comes from + +`Ctrl+↑` moves the locus out to the parent scope, `Ctrl+↓` in to the first +child, and `Ctrl+←`/`Ctrl+→` along the siblings, wrapping at both ends. All four +push, because each is a place somebody went, and all four act only outside an +editable target so a modified arrow is never stolen out of a draft. + +**The sibling list is derived from the journal, never declared.** Siblings are a +fact about what the execution actually opened, which is why a scope that has not +been entered yet is not one. The fixture journal opens `plan`, `preview` and +`write` inside `document`, in that source order, so the arrows have something +real to walk. + +## Ctrl+C, and what counts as active + +An entry that is paused, or that is being read through a reconstruction, is +still running. Ctrl+C interrupts it and the REPL stays open; only an idle REPL +clears its draft or leaves. Treating a live transport as the test for "active" +exited from a paused entry instead of interrupting it, which hands that entry's +lifecycle to whoever closed the terminal. + +## Scoped limits + +- **Three regions expose no controls.** `sessions`, `transcript` and `bindings` + are declared explicit and none of the fourteen frames gives any of them a + control, so nothing here says what their controls would be. +- **The journal is a fixture.** Pausing and resuming extend it to the record the + story already contains rather than recording anything, and no state is durable + past the process. #842 owns a real journal, journal storage and replay. +- **The six #838 fixtures are the available content.** The route and the journal + choose which one a moment shows and override its transport, its badge and its + open drawer, so `--route` and `--frame` genuinely drive the picture. They do + not synthesise content the fixture set does not have: there is no "paused at + the live head" transcript distinct from the reconstruction's, and a frame that + wants three sessions borrows the fixture that has three. +- **The journey is a projector.** While `--play` runs it supplies the moment on + screen; the store still reduces every keystroke, and the two meet again the + moment the journey ends. +- **Nothing here answers an `Elicit`, executes XMD or opens an Agent session.** + Every claim is about navigation. + +## What the evidence rests on + +Twenty-six controls, each breaking exactly one claim and each rejected by name +by the same oracle that admits the honest run. Four of them exist only because +the tree does: `rebuild-tree-each-sync` destroys focus by rebuilding rather than +reconciling, `keep-closed-branch` closes a drawer without removing it, +`flat-overlay` numbers a list kept beside the interface instead of the tree, and +`focus-hidden-target` makes a disabled control focusable. The two +that matter most are the two that reproduce the decoder defects, because they +are the only reason to believe the byte-driven cases would notice if the repair +were undone. + +**Transitions are driven through the real path**, forward and in reverse, from +each of the fourteen frames — the same `drive()` the interactive harness uses, +so a case cannot prove a path the running harness does not take. An earlier +round proved them by constructing each destination from its own URL, which is a +check a reducer that moved focus and left the route behind passes without +trouble — and did. diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md new file mode 100644 index 000000000..3b771268f --- /dev/null +++ b/scripts/repl-study/RESULT.md @@ -0,0 +1,185 @@ +# What this experiment found + +Issue [#838](https://github.com/taras/executable.md/issues/838) asked whether +`@bomb.sh/tty` can render the approved `XMD REPL Terminal Interface` study in a +real terminal, and what the design owes a terminal that is not 2560 × 1440. + +**Decision: retain the harness, and revise two design states.** The renderer +carried every fixture at every profile without a single renderer error, animated +both its own transitions and the application's, and gave the terminal back after +an ordinary exit, an interruption mid-animation and a failure. The seam between +semantic fixtures, layout, rendering and the terminal host held. Two states +needed an adaptation the study does not describe, named below. + +## Dimensions tested + +| Where | Size | Profile | +| --- | --- | --- | +| rendered and captured | 200 × 50 | wide | +| rendered and captured | 140 × 38 | medium | +| rendered and captured | 90 × 28 | narrow | +| rendered and captured | 64 × 18 | too-small | +| a real pseudo-terminal, interactively, macOS `script` | 80 × 24 | narrow | +| a real pseudo-terminal, animating with no input | 80 × 24 | narrow | +| a real pseudo-terminal, the whole story with no input | 80 × 24 | narrow | + +The interactive run opened, showed the `nested` fixture, accepted `4`, `Tab` and +`q` as keystrokes, and left the terminal in the modes it found. Every other +dimension was exercised through the captures and the suite. + +## Animation + +The renderer animates, and the frame loop that drives it is small. `--play` runs +the whole approved story — empty, nested, generated, drawer, paused, settled — +holding each moment for between 1.2 and 2.6 seconds and animating every +transition, about sixteen seconds end to end with nobody at the keyboard. When it +arrives at the settled entry the clock stops and that is what stays on screen. + +- **A declared transition is interpolated by the renderer.** Giving the + contextual band `transition: { duration: 0.26, easing: "easeInOut", properties: + ["height", "y"] }` is the whole of what the harness does about the drawer's + movement: `render()` then reports `animating: true` and interpolated cell + bounds until it arrives. In one measured playback, seventeen of forty-one + frames were still interpolating. +- **The renderer's unit is seconds, for both `duration` and `deltaTime`.** This + harness counts milliseconds everywhere else, because that is what `sleep()` + and the playback clock speak, and converts once at the boundary. Getting this + wrong is quiet: scaling both sides by the same thousand produces the same + frame count and the same picture, so the mistake survives every test that only + compares the harness with itself. What catches it is the library's own + arithmetic — a 0.2 transition is halfway after 0.1 — and a case asserting that + one frame of seconds does *not* finish one. +- **The renderer times itself unless you tell it not to.** With no `deltaTime` + option it advances by the monotonic time since the previous render, and it + passes 0 after a frame that reported `animating: false`. This harness always + supplies the value: ticks get their own elapsed, and a keystroke or a resize + gets `0`, so typing during a transition does not skip it forward — and a + capture of a transition is reproducible rather than a race with the clock. +- **A transition's flag clears one frame after it arrives.** The library's own + test spends 0.3 seconds on a 0.2-second transition for this reason. A loop + that stopped at the first frame reporting the final geometry would leave the + last frame undrawn. +- **Some interpolated frames emit nothing.** Sub-cell movement changes no cell, + so a loop that stopped when a frame produced zero bytes would freeze halfway. + `animating` is the condition to schedule on, never the byte count. +- **The clock belongs to the session.** It is a child task running `sleep(16)`, + spawned when either the renderer or the application's own transition is moving, + and halted as soon as both settle — an idle REPL schedules nothing at all. An + interruption mid-transition halted it with the session: the trace ends at the + frame the signal arrived on, and the terminal's modes were restored after it. +- **A long run exhausts the renderer, and the answer is a new one.** Clay keeps + a cache of measured words — 16 384 by default — and one `Term` rendering this + interface at 200 × 50 fills it after **86 frames**, reporting + `TEXT_MEASUREMENT_CAPACITY_EXCEEDED` and rendering nothing. The same journey at + 80 × 24 completed 207 frames without reaching the limit, so it is a function of + how much text each frame measures rather than of time. The harness now catches + that one error, builds a fresh `Term` and repaints; a viewer sees a frame, not + a fault. Any production REPL will meet this, and a `Term` that lives for a + session needs the same recovery or a raised cache. +- **One wake-up at a time beats a loop that schedules itself.** The clock is + armed by the frame loop after each frame, because only the loop knows whether + the next wait is a frame or the rest of a hold. A timer deciding that for + itself read state the loop had not finished updating, and jumped whole + segments of the story. +- **Application-timed motion stays a pure function of elapsed time.** The head's + travel along the track and the transcript's arrival are computed from + milliseconds, so the same instant renders identically from a test, a capture + and the live loop — which is what makes a midpoint capture possible at all. + +What a playback never becomes is state. The six fixtures remain the +reconstructable moments; a playback's phase and elapsed time live in the frame +loop and are gone when it settles. Reconstruction lands on a fixture, and a +control that makes it land halfway through a transition is rejected by the +goldens. + +## What the renderer gave us + +- **Layout arrives back in cells.** `render().info.get(id).bounds` reports + `{x, y, width, height}` in terminal cells, so "the Execution History footer is + never covered" is a question that can be asked of the renderer directly + instead of inferred from bytes. This is the single most useful thing the + library does for evidence. +- **The byte vocabulary is tiny.** Frames are made of `ESC[0m`, + `ESC[48;2;r;g;bm`, `ESC[row;colH` and text. Sixty lines reconstruct a terminal + from them, which is how the stale-cell check is possible at all. +- **Diffs really are minimal.** One changed character emits `ESC[0m ESC[1;23H2`. +- **Clay's layout is enough.** Floating regions at exact cell rectangles, fitted + and grown axes, padding, clipping and borders composed the whole study — + three panes, a bottom-anchored contextual band, a full-width footer, and a + drawer above it — with no arithmetic beyond `layout.ts`. +- **Glyphs render.** `▶ ● ◆ ✓ ◀ × ≈ · ↳ ▸ │ ├ ╭ ─` all appear, so the study's + "glyph + word, never colour alone" rule survives into the terminal. + +## Limitations, as observed + +1. **The renderer clips; it does not scroll.** `clip` truncates a region's + overflow and there is no scroll offset, so a window over a long transcript is + the application's to own. That is not a defect — it is a boundary, and it + means every consumer of this renderer will write the same windowing code. +2. **Resize is not in the input stream.** `Input.scan()` decodes keys and mouse + reports; feeding it `ESC[8;24;80t` produced nine ordinary keydowns. Size + changes must come from `SIGWINCH` plus `Deno.consoleSize()` and be handed to + `term.update()`. A renderer never told is not merely stale: it goes on + addressing cells the terminal no longer has. +3. **A pseudo-terminal may report `0 × 0`.** macOS `script` does, and a renderer + handed those dimensions draws nothing at all — which looks exactly like a + crash. The harness now assumes 80 × 24 when the terminal will not say. +4. **Ambiguous-width glyphs are assumed to be one cell.** `●` and `◆` are East + Asian Ambiguous; a terminal configured to render them double-width would + misalign every rail and notch that uses them. Nothing here detects that, and + no such terminal was tested. +5. **The output view expires.** `render().output` must be copied immediately — + `Uint8Array.from(...)` — or the next frame invalidates it. + +## The two design states that required adaptation + +1. **The band is five rows, not the study's four.** Notch height carries scope + depth, which is the settled meaning, and four depths need four rows of their + own: depth 0 fills them, depth 3 takes the track row alone, and anything + deeper shares the shortest notch and says so with `·`. The selection's label + then has nowhere to go — a depth-0 notch and the label want the same cell — so + the band takes one more row than the study's 92 pixels divide into. Everything + else about a marker is said some way that is not height: the playhead is its + own heavier stem with its own label, a selection is gold with `▲` and a label, + and an entry boundary is `◆` where an ordinary event is `●`. +2. **A narrow track is mostly transport.** The study's rule that the track + yields room to the visible controls is faithful and expensive: at 90 columns + while inspecting history, `INSPECTING [ Continue ] [ Return ] [ Fork ]` + leaves the track about nine columns for fourteen checkpoints. Markers that + would collide are gathered into one notch carrying their count, `←`/`→` still + steps through every checkpoint behind it, and the full-screen history surface + lists them all. A design that wants the track legible at narrow widths has to + decide what the transport gives up first. + The band's own labels feel the same squeeze: `EXECUTION HISTORY` and + `recorded · 00:53` cost eighteen columns the track needs more, so the narrow + band says `HISTORY` and `00:53` and lets the surface bar above it carry the + name. The same pressure drops the transcript's eight-column phase word below + 56 columns, and drops session notes, binding notes and the drawer's schema + column at medium. + +## What was not answered + +- Focus, the ring, the drawer's trap and where focus returns after a suspension + are #839's, and nothing here establishes them. They are answered now, in + [`RESULT-focus.md`](./RESULT-focus.md). + +**One correction this experiment owes.** #838 documented `Esc` as "return to the +head" and its suite exercised that path by handing the reducer a synthetic +`{ code: "Escape" }`. No terminal ever produced one: the decoder buffers a lone +`ESC` and asks to be re-scanned, and this harness read `scanned.events` and +dropped `scanned.pending`, so pressing Escape did nothing at all. Shift+Tab was +the same shape of mistake — a real one arrives as `Backtab` carrying no shift +flag. #839 found both by driving bytes rather than events, and repaired them. +- Only one native transition is exercised — the drawer's opening. A drawer + *closing* would need the moment being left to stay renderable through the + transition, which is a question about what a playback holds, and #842's + journal reconstruction is the place to answer it. +- No reusable component boundary is proposed; #840 owns that, and the region + functions in `render.ts` are deliberately private. +- Restoration is proved at the byte boundary and by construction — cleanup + registered before the modes are applied — rather than by inspecting a + pseudo-terminal's mode flags. Allocating a PTY and emulating a child terminal + is the territory #801 withdrew. +- Nothing here executes XMD, journals anything, or opens an Agent session, so + every fixture is a statement about rendering and none is a statement about + execution. diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts new file mode 100644 index 000000000..29dd3fb9b --- /dev/null +++ b/scripts/repl-study/capture.ts @@ -0,0 +1,395 @@ +/** + * One frame, rendered away from a terminal, in bytes and in cells. + * + * `@bomb.sh/tty` does no I/O, so the same frame the interactive harness writes + * to a real terminal can be produced here and read back as text. That is what + * makes the captures reviewable: the `.txt` files are the interface as a person + * would see it, and they are also what the evidence compares against. + */ + +import { createTerm } from "@bomb.sh/tty"; +import type { BoundingBox, Term } from "@bomb.sh/tty"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, writeTextFile } from "@effectionx/fs"; +import { join } from "node:path"; + +import { fixture, fixtures } from "./fixtures.ts"; +import { JOURNEY, journeyPlan, motionAt, PLAYBACKS } from "./playback.ts"; +import type { Motion, Playback } from "./playback.ts"; +import type { Fixture } from "./model.ts"; +import type { Profile, SurfaceName } from "./layout.ts"; +import { layoutFor } from "./layout.ts"; +import { renderScreen } from "./render.ts"; +import type { FocusView } from "./render.ts"; +import { applyAnsi, createGrid, gridText } from "./screen.ts"; +import { initialView } from "./store.ts"; +import type { View } from "./store.ts"; +import { fixtureFor, viewOf } from "./store.ts"; +import { FRAMES, useFrame } from "./frames.ts"; +import { overlayOf } from "./tree.ts"; +import type { Mutation } from "./mutations.ts"; + +export interface Size { + readonly cols: number; + readonly rows: number; +} + +/** + * The dimensions each profile is captured at. + * + * These are representative terminals, not thresholds: `layout.ts` owns where one + * profile ends and the next begins, and these sit inside each range. + */ +export const PROFILE_SIZES: Record = { + wide: { cols: 200, rows: 50 }, + medium: { cols: 140, rows: 38 }, + narrow: { cols: 90, rows: 28 }, + "too-small": { cols: 64, rows: 18 }, +}; + +export interface Frame { + readonly ansi: Uint8Array; + readonly text: string; + /** True while the renderer is still interpolating a declared transition. */ + readonly animating: boolean; + /** Where each region landed, in cells, as the renderer reports it. */ + readonly bounds: Readonly>; +} + +/** The regions whose geometry the evidence asks about. */ +const MEASURED = [ + "root", + "header", + "sidebar", + "transcript", + "bindings", + "contextual", + "footer", + "surface-bar", + "too-small", +]; + +export interface FrameRequest { + readonly fixture: Fixture; + readonly view: View; + readonly size: Size; + readonly mutation?: Mutation; + readonly surface?: SurfaceName; + /** Present only while a playback is running between two fixtures. */ + readonly motion?: Motion; + /** Where focus is. Left out, the frame says nothing about focus at all. */ + readonly focus?: FocusView; + /** + * Seconds since the previous frame, which is the unit the renderer measures + * transitions in. Leaving it out hands the renderer its own monotonic clock; + * supplying it — including `0` — overrides that, which is what makes a + * captured transition reproducible. + */ + readonly deltaSeconds?: number; +} + +export function* useTerm(size: Size): Operation { + return yield* until(createTerm({ width: size.cols, height: size.rows })); +} + +/** Render one frame into a fresh terminal, which is always a complete repaint. */ +export function* renderFrame(request: FrameRequest): Operation { + const term = yield* useTerm(request.size); + return renderInto(term, request); +} + +/** + * The renderer ran out of room to measure text. + * + * Clay keeps a cache of measured words — 16 384 of them — and a long-lived Term + * rendering an interface this wordy exhausts it. It is not a failure of the + * frame: the answer is a new Term, which starts the cache again. + */ +export class RendererCapacityError extends Error { + constructor(message: string) { + super(message); + this.name = "RendererCapacityError"; + } +} + +export function renderInto(term: Term, request: FrameRequest): Frame { + const { view, size, mutation } = request; + // A frame drawn from state the harness has already left behind. The renderer + // cannot tell the difference — only a reader, or a golden, can. + const subject = mutation === "stale-frame" ? fixture("empty") : request.fixture; + const motion = + mutation === "restore-mid-animation" && request.motion === undefined + ? // Reconstruction must land on a state, never halfway through a transition. + // This control makes it land halfway. + { progress: 0.5, headAt: subject.history.headAt / 2, reveal: 0.5, done: false } + : request.motion; + // A playback's first frame still shows the moment it is leaving, so a drawer + // about to open is not open yet: that is what gives the renderer two + // geometries to interpolate between rather than one it has already arrived at. + const opening = motion === undefined || motion.progress > 0; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: subject.drawer !== undefined && view.drawerOpen && opening, + surface: request.surface ?? view.surface, + mutation, + }); + const result = term.render( + renderScreen({ fixture: subject, view, layout, mutation, motion, focus: request.focus }), + request.deltaSeconds === undefined ? {} : { deltaTime: request.deltaSeconds }, + ); + if (result.errors.length > 0) { + const capacity = result.errors.find( + (error) => error.type === "TEXT_MEASUREMENT_CAPACITY_EXCEEDED", + ); + if (capacity !== undefined) { + throw new RendererCapacityError(capacity.message); + } + throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); + } + const ansi = Uint8Array.from(result.output); + const grid = applyAnsi(createGrid(size.cols, size.rows), ansi); + const bounds: Record = {}; + for (const id of MEASURED) { + bounds[id] = result.info.get(id)?.bounds; + } + return { ansi, text: gridText(grid), animating: result.animating, bounds }; +} + +/** + * One playback, rendered frame by frame with an explicit delta. + * + * Nothing here waits: time is supplied rather than measured, so the same + * sequence comes out of a test, a capture and a review identically. The run + * ends when the application's motion has finished *and* the renderer has + * stopped interpolating, which is the same condition the frame loop uses to + * stop scheduling. + */ +export function* playFrames( + playback: Playback, + size: Size, + options: { readonly frameMs?: number; readonly limit?: number } = {}, +): Operation { + const frameMs = options.frameMs ?? 16; + const limit = options.limit ?? 200; + const subject = fixture(playback.to); + const view = initialView(subject); + const term = yield* useTerm(size); + const frames: Frame[] = []; + // A frame in the middle of a transition is a handful of changed cells, not a + // screen. The screen is what those changes have added up to, so the grid + // carries across frames exactly as a terminal's does. + const screen = createGrid(size.cols, size.rows); + let elapsed = 0; + for (let index = 0; index < limit; index += 1) { + const motion = motionAt(playback, elapsed); + const frame = renderInto(term, { + fixture: subject, + view, + size, + motion, + deltaSeconds: index === 0 ? 0 : frameMs / 1000, + }); + applyAnsi(screen, frame.ansi); + frames.push({ ...frame, text: gridText(screen) }); + if (motion.done && !frame.animating) { + return frames; + } + elapsed += frameMs; + } + return frames; +} + +/** + * Every frame of the whole demonstration, rendered deterministically. + * + * The screen carries across frames the way a terminal's does, so what comes + * back is what a person would have seen at each moment rather than the handful + * of cells that changed. + */ +export interface JourneyFrame extends Frame { + readonly label: string; + readonly fixture: string; +} + +export interface JourneyRun { + readonly frames: JourneyFrame[]; + /** How many times the renderer had to be rebuilt to get to the end. */ + readonly rebuilds: number; +} + +export function* journeyFrames( + size: Size, + options: { readonly frameMs?: number } = {}, +): Operation { + const frameMs = options.frameMs ?? 16; + let term = yield* useTerm(size); + let rebuilds = 0; + const screen = createGrid(size.cols, size.rows); + const frames: JourneyFrame[] = []; + for (const planned of journeyPlan(JOURNEY, frameMs)) { + const subject = fixture(planned.fixture); + const request: FrameRequest = { + fixture: subject, + view: initialView(subject), + size, + motion: planned.motion, + deltaSeconds: planned.deltaMs / 1000, + }; + let frame: Frame; + try { + frame = renderInto(term, request); + } catch (error) { + if (!(error instanceof RendererCapacityError)) { + throw error; + } + // A fresh Term starts with an empty measurement cache and repaints + // everything, so the screen this frame lands on is complete. + term = yield* useTerm(size); + rebuilds += 1; + frame = renderInto(term, { ...request, deltaSeconds: 0 }); + } + applyAnsi(screen, frame.ansi); + frames.push({ + ...frame, + text: gridText(screen), + label: planned.label, + fixture: planned.fixture, + }); + } + return { frames, rebuilds }; +} + +export function captureName(fixtureName: string, profile: Profile): string { + return `${fixtureName}.${profile}`; +} + +export interface Capture { + readonly name: string; + readonly profile: Profile; + readonly size: Size; + readonly frame: Frame; +} + +/** + * Every fixture at every profile. + * + * `too-small` is captured for two fixtures only: the refusal does not vary with + * what is behind it, and capturing six identical screens would say six times + * less than capturing two and saying so. + */ +export function* captureAll(): Operation { + const captures: Capture[] = []; + const profiles: Profile[] = ["wide", "medium", "narrow"]; + for (const subject of fixtures()) { + for (const profile of profiles) { + const size = PROFILE_SIZES[profile]; + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + captures.push({ name: captureName(subject.name, profile), profile, size, frame }); + } + } + for (const name of ["drawer", "paused"]) { + const subject = fixture(name); + const size = PROFILE_SIZES["too-small"]; + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + captures.push({ name: captureName(name, "too-small"), profile: "too-small", size, frame }); + } + + // The promoted surfaces only exist in narrow, so the wide captures never show + // them. These are the routed views themselves. + const routed: readonly { readonly fixture: string; readonly surface: SurfaceName }[] = [ + { fixture: "paused", surface: "history" }, + { fixture: "drawer", surface: "sessions" }, + { fixture: "nested", surface: "bindings" }, + ]; + for (const route of routed) { + const subject = fixture(route.fixture); + const size = PROFILE_SIZES.narrow; + const frame = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), surface: route.surface, drawerOpen: false }, + size, + surface: route.surface, + }); + captures.push({ + name: `${route.fixture}.narrow.${route.surface}`, + profile: "narrow", + size, + frame, + }); + } + // Three moments of one playback, so a reader can see a transition without + // running it: where it starts, where it is halfway, and where it settles. + const playback = PLAYBACKS.find((one) => one.from === "generated" && one.to === "drawer")!; + const frames = yield* playFrames(playback, PROFILE_SIZES.wide); + const moments: readonly { readonly label: string; readonly at: number }[] = [ + { label: "start", at: 0 }, + { label: "midpoint", at: Math.floor((frames.length - 1) / 2) }, + { label: "settled", at: frames.length - 1 }, + ]; + for (const moment of moments) { + captures.push({ + name: `play.${playback.from}-${playback.to}.${moment.label}`, + profile: "wide", + size: PROFILE_SIZES.wide, + frame: frames[moment.at], + }); + } + + return captures; +} + +/** + * One capture as a file: what it is, how big the terminal was, and the screen. + * + * Trailing blank rows are dropped, because the header already says how tall the + * terminal was and a file that ends in empty lines is a file git complains + * about. Both the writer and the evidence read the screen through here, so a + * golden cannot disagree with what `--capture` writes. + */ +export function captureText(capture: Capture): string { + const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}`; + return `${header}\n${capture.frame.text.replace(/\n+$/, "")}\n`; +} + +export function* writeCaptures(directory: string, captures: readonly Capture[]): Operation { + yield* ensureDir(directory); + for (const capture of captures) { + yield* writeTextFile(join(directory, `${capture.name}.txt`), captureText(capture)); + yield* writeTextFile( + join(directory, `${capture.name}.ansi`), + new TextDecoder().decode(capture.frame.ansi), + ); + } +} + +/** + * The study's frames, at the profiles a reader can check them at. + * + * Every frame is drawn with the numbered overlay on, because the study states + * its frames as numbered targets: numbering them on screen is what makes a + * capture legible as evidence against the frame it reproduces. The narrow set + * is representative rather than exhaustive — the suite checks all fourteen at + * both profiles, and a golden's job here is to be read. + */ +const NARROW_FRAMES = ["01", "05", "07", "12", "14"]; + +export function* captureFocus(): Operation { + const captures: Capture[] = []; + for (const subject of FRAMES) { + const { state, tree } = yield* useFrame(subject); + const profiles: Profile[] = NARROW_FRAMES.includes(subject.id) ? ["wide", "narrow"] : ["wide"]; + for (const profile of profiles) { + const size = PROFILE_SIZES[profile]; + const frame = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size, + focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, + }); + captures.push({ name: `frame-${subject.id}.${profile}`, profile, size, frame }); + } + } + return captures; +} diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts new file mode 100644 index 000000000..dfd28538a --- /dev/null +++ b/scripts/repl-study/drive.ts @@ -0,0 +1,147 @@ +/** + * One event, carried through the tree and the store. + * + * The harness and the evidence both go through here, so a case cannot prove a + * path the interactive run does not take. The order is the whole of the + * architecture, in five lines: + * + * 1. the key is delivered to the focused node, so every branch between the root + * and it runs its middleware — and if one of them **consumes** it, that is + * the end: no fallback runs a key the hierarchy already answered; + * 2. otherwise the store decides what the event means, told where focus is + * rather than keeping its own answer; + * 3. the tree carries out whatever the store decided about focus; + * 4. the tree is brought into line with the new state, mounting and removing + * branches; + * 5. the route follows focus, because the surface segment is what says which + * region owns it. + */ + +import type { Operation } from "effection"; + +import { asKey, followFocus, reduce } from "./store.ts"; +import type { HarnessEvent, ReduceContext, ReplState } from "./store.ts"; +import type { FocusIntent } from "./store.ts"; +import { focus as focusNode, surfaceOwning } from "./tree.ts"; +import type { ReplTree } from "./tree.ts"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; +import { sendKey } from "./keys.ts"; +import type { Delivery } from "./keys.ts"; +import type { Mutation } from "./mutations.ts"; + +/** + * The nearest focusable ancestor of a node, read off the live tree. + * + * Ownership is a question about where a node *is*, so it is asked of the tree. + * Reconstructing it by parsing the identity string would be a second answer, + * and a second answer is what this architecture exists to remove. + */ +function ownerOf(tree: ReplTree, node: Node): Node | undefined { + const reachable = tree.chain(); + for (let at = node.parent; at; at = at.parent) { + const found = reachable.find((candidate) => candidate === at); + if (found) { + return found; + } + } + return undefined; +} + +/** Carry out what the store decided about focus. The tree performs it. */ +export function applyFocus(tree: ReplTree, intent: FocusIntent | undefined): void { + if (intent === undefined) { + return; + } + if (intent.kind === "advance") { + tree.advance(); + return; + } + if (intent.kind === "retreat") { + tree.retreat(); + return; + } + if (intent.kind === "owner") { + const owner = ownerOf(tree, tree.focused()); + if (owner) { + focusNode(owner); + } + return; + } + const target = tree.chain().find((node) => node.name === intent.identity); + if (target) { + focusNode(target); + } +} + +export interface Driven { + readonly state: ReplState; + /** Where the key went, and through what. Absent for a non-key event. */ + readonly delivery?: Delivery; +} + +export function drive( + tree: ReplTree, + state: ReplState, + event: HarnessEvent, + context: Omit, +): Operation { + return { + *[Symbol.iterator]() { + const delivery = + event.kind === "key" + ? sendKey(tree.root.node, tree.focused(), asKey(event.event)) + : undefined; + if (delivery?.handled === true) { + // A branch on the live ancestor path claimed it. Running the fallback + // anyway is exactly the defect this ordering exists to prevent: the + // hierarchy would be annotating the dispatch instead of governing it. + return { state, delivery }; + } + const reduced = reduce(state, event, { ...context, focused: tree.focused().name }); + applyFocus(tree, reduced.focus); + yield* tree.sync(reduced.state, context.mutation); + // Which surface owns focus is read off the tree, not parsed out of the + // focused node's name. + const followed = followFocus( + reduced.state, + surfaceOwning(tree.focused()), + "focus", + context.mutation, + ); + yield* tree.sync(followed, context.mutation); + return { state: followed, delivery }; + }, + }; +} + +export type { Mutation }; + +/** + * Put focus where a run is opening. + * + * The route's surface says which region owns focus, and a named study frame + * additionally says which node inside it. Both are addresses; the tree decides + * whether they are there. + * + * The interactive harness and the evidence both enter through here. When they + * did not, `--frame 12` opened at the frame's location but not its focus, so + * the footer — an explicit region, whose controls exist only once focus is + * inside it — drew none of the transport controls that frame is about, and + * nothing noticed because the evidence entered a different way. + */ +export function enterRoute(tree: ReplTree, state: ReplState, wanted?: string): Operation { + return { + *[Symbol.iterator]() { + const region = tree.chain().find((node) => node.name === `region:${state.route.surface}`); + if (region) { + focusNode(region); + } + yield* tree.sync(state); + const target = + wanted === undefined ? undefined : tree.chain().find((node) => node.name === wanted); + if (target) { + focusNode(target); + } + }, + }; +} diff --git a/scripts/repl-study/fixtures.ts b/scripts/repl-study/fixtures.ts new file mode 100644 index 000000000..d3b46917a --- /dev/null +++ b/scripts/repl-study/fixtures.ts @@ -0,0 +1,756 @@ +/** + * The six moments the harness can show, taken from the approved study. + * + * Between them they carry every transcript and history trait #838 asks for: + * nested visible scopes with lifecycle rails, collapsed completed work, prose + * long enough to wrap, generated XMD replacing the expression that produced it, + * concurrent session activity, an Elicit drawer, a paused head with a historical + * selection, marker runs dense enough to collide, nesting deeper than the band's + * rows, and a settled entry. + * + * The content is the study's own: the `Create a project README` program, its + * `Plan` component, the three Agent sessions, and the twelve recorded + * checkpoints of the Journal Time Travel animation. + */ + +import type { Checkpoint, Drawer, Fixture, Session, TranscriptRow } from "./model.ts"; +import { FIXTURE_NAMES } from "./model.ts"; + +const RETURNED_PROGRAM = [ + "# Create a project README", + "", + "Provide the project name and a one-sentence description.", + "", + '', + " Enter the project details.", + "", + "", + "This is the README that will be created:", + "", + '', +]; + +const README = ["# Northstar", "", "A lightweight workspace for coordinating coding agents."]; + +const SESSIONS: readonly Session[] = [ + { + id: "plan-a91f7c", + agent: "planner", + state: "completed", + label: "✓ completed", + turn: "turn 1 · returned 59 lines", + selected: true, + }, + { + id: "review-b72e1d", + agent: "reviewer", + state: "active", + label: "● responding", + turn: "turn 1 · streaming", + note: "streaming · background update · selection unchanged", + }, + { + id: "implement-c31d2e", + agent: "implementer", + state: "queued", + label: "· queued", + turn: "no turn yet", + }, +]; + +/** + * The recorded timeline of the study's animation. + * + * Two of these share a second with a neighbour, and three sit deeper than the + * band has rows for. Both are deliberate: a band that cannot show them has to + * summarize rather than overprint, and the scrubber still has to reach them. + */ +const CHECKPOINTS: readonly Checkpoint[] = [ + { + at: 2, + kind: "entry", + label: "Entry 1 submitted", + scope: "REPL", + depth: 0, + records: ["repl.entry.submitted", "source.frozen 8 lines"], + }, + { + at: 5, + kind: "event", + label: "document scope entered", + scope: "Entry 1 › document", + depth: 1, + records: ["scope.enter document", "capability.granted write"], + }, + { + at: 12, + kind: "event", + label: "Plan entered", + scope: "Entry 1 › document › Plan", + depth: 2, + records: ["scope.enter Plan", "inputs.bound content", "component.resolved Plan.md"], + }, + { + at: 18, + kind: "event", + label: "planning inputs prepared", + scope: "… › Plan › PlanInputs", + depth: 3, + records: ["binding.published syntax", "binding.published inputs"], + }, + { + at: 29, + kind: "event", + label: "planning Agent response admitted", + scope: "… › Plan › Prompt", + depth: 3, + records: ["agent.turn.admitted plan-a91f7c", "binding.published draft"], + }, + { + at: 30, + kind: "event", + label: "draft checked", + scope: "… › Plan › Check", + depth: 4, + records: ["binding.published responseKind", "binding.published check"], + }, + { + at: 41, + kind: "event", + label: "review returned Approve", + scope: "… › Plan › Elicit", + depth: 3, + records: ["elicit.answered review"], + }, + { + at: 47, + kind: "event", + label: "Plan replaced by returned program", + scope: "Entry 1 › document", + depth: 1, + records: ["node.replaced Plan → 59 lines", "scope.exit Plan"], + }, + { + at: 49, + kind: "event", + label: "project Elicit requested", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.requested project", "drawer.opened"], + }, + { + at: 52, + kind: "event", + label: "project Elicit answered", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.answered project", "binding.published project"], + }, + { + at: 53, + kind: "event", + label: "confirmation Elicit requested", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.requested confirmation"], + }, + { + at: 57, + kind: "event", + label: "README.md written", + scope: "Entry 1 › document", + depth: 1, + records: ["effect.file.write README.md", "63 bytes · +3 lines"], + }, + { + at: 60, + kind: "event", + label: "Evaluate exited", + scope: "Entry 1 › document", + depth: 1, + records: ["scope.exit Evaluate", "teardown.complete"], + }, + { + at: 61, + kind: "entry", + label: "Entry 1 completed", + scope: "REPL", + depth: 0, + records: ["repl.entry.completed", "bindings.published 0"], + }, +]; + +function upTo(seconds: number): readonly Checkpoint[] { + return CHECKPOINTS.filter((checkpoint) => checkpoint.at <= seconds); +} + +const NESTED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "prose", + text: "repl:entry-1 · submitted source is immutable while running", + depth: 0, + emphasis: "dim", + }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { + kind: "prose", + depth: 1, + text: "Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, reviews its own draft, and returns it for admission into this document scope.", + }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "active", + pair: "plan", + }, + { kind: "section", name: "Read the Prompt", published: "prompt", state: "collapsed", depth: 2 }, + { + kind: "section", + name: "Prepare the planning inputs", + published: "syntax, inputs", + state: "collapsed", + depth: 2, + }, + { + kind: "section", + name: "Create the first draft", + published: "draft", + state: "collapsed", + depth: 2, + }, + { + kind: "section", + name: "Check the draft", + published: "responseKind, check", + state: "expanded", + depth: 2, + }, + { kind: "lifecycle", source: '', depth: 3, phase: "active", pair: "check" }, + { + kind: "lifecycle", + source: " ", + depth: 4, + phase: "settled", + }, + { + kind: "lifecycle", + source: '', + depth: 3, + phase: "waiting", + pair: "ask", + }, + { + kind: "prose", + text: "Review the generated Plan and choose Approve, Request changes or Stop.", + depth: 4, + }, + { + kind: "prose", + depth: 4, + text: "The reviewer has the draft, the schema it was checked against, and the capabilities the document would be granted if the Plan is admitted. Nothing it returns runs until this scope admits it.", + }, + { kind: "lifecycle", source: '', depth: 4, phase: "settled" }, + { + kind: "lifecycle", + source: '', + depth: 4, + phase: "settled", + }, + { + kind: "lifecycle", + source: "", + depth: 3, + phase: "waiting", + pair: "ask", + close: true, + }, + { kind: "lifecycle", source: "", depth: 3, phase: "active", pair: "check", close: true }, + { kind: "section", name: "Review the draft", published: "review", state: "collapsed", depth: 2 }, + { + kind: "section", + name: "Admit the approved Plan", + published: "admitted", + state: "collapsed", + depth: 2, + }, +]; + +const GENERATED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "exit", + pair: "plan", + }, + { + kind: "section", + name: "Admit the approved Plan", + published: "admitted", + state: "collapsed", + depth: 2, + }, + { kind: "lifecycle", source: "", depth: 1, phase: "exit", pair: "plan", close: true }, + { + kind: "fence", + label: "XMD", + lines: RETURNED_PROGRAM, + caption: "returned program · 59 lines · replaces the Plan expression, then evaluates here", + depth: 1, + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { kind: "prose", text: "Provide the project name and a one-sentence description.", depth: 1 }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "enter", + pair: "elicit", + }, + { kind: "prose", text: "Enter the project details.", depth: 2 }, + { kind: "lifecycle", source: "", depth: 1, phase: "enter", pair: "elicit", close: true }, +]; + +const DRAWER_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { + kind: "section", + name: "Ask for the project details", + published: "project", + state: "expanded", + depth: 1, + }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "waiting", + pair: "elicit", + }, + { kind: "prose", text: "Enter the project details.", depth: 2 }, + { + kind: "lifecycle", + source: "", + depth: 1, + phase: "waiting", + pair: "elicit", + close: true, + }, + { kind: "prose", text: "▲ suspended · answer in the drawer below", depth: 1, emphasis: "strong" }, +]; + +const INSPECTED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "reconstructed from the journal · no live action is possible here", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "Plan", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "active", + pair: "plan", + }, + { kind: "section", name: "Read the Prompt", published: "prompt", state: "collapsed", depth: 1 }, + { + kind: "section", + name: "Prepare the planning inputs", + published: "syntax, inputs", + state: "expanded", + depth: 1, + }, + { kind: "lifecycle", source: '', depth: 2, phase: "settled" }, + { + kind: "lifecycle", + source: '', + depth: 2, + phase: "active", + }, + { + kind: "prose", + text: "XMD catalog · 47 symbols · component, control, agent, io", + depth: 2, + emphasis: "dim", + }, +]; + +const SETTLED_ROWS: readonly TranscriptRow[] = [ + { kind: "prose", text: "Create a project README", depth: 0, emphasis: "title" }, + { kind: "prose", text: "Provide the project name and a one-sentence description.", depth: 0 }, + { + kind: "fence", + label: "MARKDOWN", + lines: README, + depth: 0, + caption: "README.md · 63 bytes · +3 lines", + }, + { kind: "prose", text: "README.md was created for Northstar.", depth: 0, emphasis: "strong" }, + { kind: "prose", text: "no REPL bindings published · 1 file written", depth: 0, emphasis: "dim" }, +]; + +/** The three suspensions the approved story opens. */ +export const DRAWER_KINDS = ["project", "review", "confirm"] as const; + +export type DrawerKind = (typeof DRAWER_KINDS)[number]; + +export function isDrawerKind(value: string): value is DrawerKind { + return (DRAWER_KINDS as readonly string[]).includes(value); +} + +/** + * The three drawers, keyed by the name a route opens them with. + * + * `model.ts` has carried all three shapes since #838, but only the project form + * had content. Study frames 08 and 09 are the other two, and a drawer a route + * can name has to be a drawer the harness can draw. + */ +const DRAWERS: Record = { + project: { + kind: "project", + heading: "INPUT REQUIRED", + origin: + 'suspended at \u00b7 document scope \u00b7 validated against the Elicit schema', + prompt: "Enter the project details.", + fields: [ + { label: "Project name", value: "Northstar" }, + { label: "Description", value: "A lightweight workspace for coordinating coding agents." }, + ], + schema: ["{", " name: string (required),", " description: string (required)", "}"], + validation: "both fields valid", + submit: "Submit \u2318\u21b5", + }, + review: { + kind: "review", + heading: "REVIEW REQUIRED", + origin: 'suspended at \u00b7 Plan scope \u00b7 59 lines returned', + plan: RETURNED_PROGRAM.slice(0, 6), + more: "\u25b8 53 more lines \u00b7 \u2325\u2193 scrolls the Plan", + decisions: [ + { label: "Approve", chosen: true }, + { label: "Request changes", chosen: false, note: "adds a required feedback field" }, + { label: "Stop", chosen: false }, + ], + submit: "Submit \u2318\u21b5", + }, + confirm: { + kind: "confirm", + heading: "CONFIRMATION REQUIRED", + origin: 'suspended at \u00b7 document scope', + prompt: "Create README.md with the content shown above?", + preview: README, + actions: [ + { label: "Approve", primary: true }, + { label: "Decline", primary: false }, + ], + hint: "\u2318\u21b5 approves \u00b7 Esc closes the drawer without answering it", + }, +}; + +export function drawerOf(kind: DrawerKind): Drawer { + return DRAWERS[kind]; +} + +const FIXTURES: Record = { + empty: { + name: "empty", + moment: "a fresh REPL, before anything has run", + crumb: "REPL", + sidebar: { + tab: "sessions", + heading: "No sessions yet", + placeholder: [ + "Agent sessions appear here as executions open them.", + "They persist after an entry settles.", + ], + }, + sessions: [], + bindings: { + scopeName: "REPL scope", + bindings: [], + placeholder: [ + "No REPL bindings yet", + "Values named with as appear here for the active scope.", + ], + }, + input: { + label: "REPL INPUT", + hint: "⇧⏎ newline", + placeholder: "Enter XMD or invoke a document…", + runEnabled: true, + }, + history: { elapsed: "00:00", headAt: 0, checkpoints: [], transport: "idle" }, + }, + + nested: { + name: "nested", + moment: "Plan running inside the document scope, three sections settled", + crumb: "REPL › Entry 1 › document › Plan · active", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 1", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "31.4", + sourceLines: 8, + scopeNote: "↳ Plan scope open", + rows: NESTED_ROWS, + }, + sessions: SESSIONS.slice(0, 1), + bindings: { + scopeName: "Plan scope", + bindings: [ + { + name: "prompt", + lines: ['"Create an XMD program that asks me for a', 'project name and a description…"'], + }, + { + name: "syntax", + note: "prose", + lines: ["XMD catalog · 47 symbols", "component, control, agent, io"], + }, + { + name: "inputs", + note: "json", + lines: ["{", ' surface: "component",', ' session: "plan-a91f7c",', " budget: 3", "}"], + }, + { + name: "draft", + note: "XMD source · 59 lines", + lines: ["# Create a project README", ''], + }, + ], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:31", + headAt: 31, + checkpoints: upTo(31), + transport: "live", + }, + }, + + generated: { + name: "generated", + moment: "the Plan's returned program replacing the expression that produced it", + crumb: "REPL › Entry 1 › document · active", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 2", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "48.1", + sourceLines: 8, + scopeNote: "↳ document scope open", + rows: GENERATED_ROWS, + }, + sessions: SESSIONS.slice(0, 2), + bindings: { + scopeName: "document scope", + bindings: [ + { + name: "admitted", + note: "XMD source · 59 lines · sealed", + lines: ["# Create a project README"], + }, + ], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:48", + headAt: 48, + checkpoints: upTo(48), + transport: "live", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + drawer: { + name: "drawer", + moment: "suspended at the project Elicit while three sessions are in flight", + crumb: "REPL › Entry 1 › document · suspended", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 3", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "48.9", + sourceLines: 8, + scopeNote: "↳ document scope suspended", + rows: DRAWER_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "document scope", + bindings: [{ name: "readme", note: "markdown · 3 lines", lines: ["# Northstar"] }], + }, + drawer: DRAWERS.project, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:49", + headAt: 49, + checkpoints: upTo(49), + transport: "live", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + paused: { + name: "paused", + moment: "paused at the head, inspecting the recorded Plan scope", + crumb: "REPL › Entry 1 › document › Plan", + badge: "RECONSTRUCTED AT 00:12 · READ-ONLY", + readOnly: true, + sidebar: { + tab: "journal", + heading: "ENTRY 1 · CREATE PROJECT README", + subheading: "inspecting recorded history · read-only", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "53.0", + sourceLines: 8, + scopeNote: "↳ reconstructed · read-only", + rows: INSPECTED_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "Plan scope · as recorded", + bindings: [{ name: "prompt", lines: ['"Create an XMD program that asks…"'] }], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "suspended · inspecting recorded history", + runEnabled: false, + }, + history: { + elapsed: "00:53", + headAt: 53, + selectedAt: 12, + checkpoints: upTo(53), + transport: "inspecting", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + settled: { + name: "settled", + moment: "Entry 1 settled, the input ready for Entry 2", + crumb: "REPL · Entry 1 settled", + sidebar: { tab: "sessions", heading: "SESSIONS · 3", subheading: "persist after settling" }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "completed", + elapsed: "41.2", + sourceLines: 8, + scopeNote: "▸ source · 8 lines", + rows: SETTLED_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "REPL scope", + bindings: [], + placeholder: [ + "Entry 1 published none", + "Values named with as appear here for the active scope.", + ], + }, + input: { + label: "REPL INPUT", + hint: "ready for Entry 2", + placeholder: "Enter XMD or invoke a document…", + runEnabled: true, + }, + history: { + elapsed: "01:01", + headAt: 61, + checkpoints: CHECKPOINTS, + transport: "idle", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, +}; + +export function fixture(name: string): Fixture { + const found = FIXTURES[name]; + if (!found) { + throw new Error(`no such fixture: ${name}`); + } + return found; +} + +export function fixtures(): readonly Fixture[] { + return FIXTURE_NAMES.map((name) => fixture(name)); +} diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts new file mode 100644 index 000000000..46e99d25f --- /dev/null +++ b/scripts/repl-study/frames.ts @@ -0,0 +1,377 @@ +/** + * The Product Owner's focus study, as fourteen addressable states. + * + * Each frame of the approved study carries a numbered target list, a focused + * number, and a `meta` record naming what Tab and Shift+Tab do from there. That + * is the acceptance source for #839, so it is transcribed here rather than + * paraphrased: `study` is the label the study prints, `id` is the identity this + * harness answers with, and `tab` and `shift` are the identities the study's + * prose names. + * + * `url` is what makes a frame reachable — `deno task repl:study --frame 07` + * opens it — and `head` is how far the execution had got when it was taken, + * which is journal truth and deliberately not in the URL. + */ + +import { journalThrough } from "./journal.ts"; +import type { FixtureName } from "./model.ts"; +import { hydrate } from "./store.ts"; +import type { ReplState } from "./store.ts"; +import { useReplTree } from "./tree.ts"; +import { enterRoute } from "./drive.ts"; +import type { ReplTree } from "./tree.ts"; +import type { Operation } from "effection"; + +export interface StudyTarget { + /** The number the study's overlay writes beside this target. */ + readonly n: number; + readonly id: string; + readonly kind: "region" | "control" | "field"; + /** The label the study prints. The harness writes its own, from state. */ + readonly study: string; +} + +export interface StudyFrame { + readonly id: string; + readonly title: string; + /** What the study says produced this frame. */ + readonly key: string; + readonly url: string; + /** The journal marker the execution had recorded. Absent is a fresh REPL. */ + readonly head?: string; + readonly focus: string; + readonly fixture: FixtureName; + /** Whether the study drew the numbered overlay in this frame. */ + readonly overlay: boolean; + readonly targets: readonly StudyTarget[]; + /** The identity Tab lands on, and the study's own wording for it. */ + readonly tab: string; + readonly shift: string; + readonly meta: { readonly tab: string; readonly shift: string; readonly trap: boolean }; +} + +const REGIONS: readonly StudyTarget[] = [ + { n: 1, id: "region:sessions", kind: "region", study: "Sessions / Journal / State" }, + { n: 2, id: "region:transcript", kind: "region", study: "Transcript" }, + { n: 3, id: "region:bindings", kind: "region", study: "Bindings" }, + { n: 4, id: "region:input", kind: "region", study: "REPL input" }, + { n: 5, id: "region:history", kind: "region", study: "Execution History" }, +]; + +const PAUSE: StudyTarget = { n: 6, id: "control:transport.pause", kind: "control", study: "Pause" }; +const CONTINUE: StudyTarget = { + n: 6, + id: "control:transport.continue", + kind: "control", + study: "Continue", +}; +const RETURN_HEAD: StudyTarget = { + n: 7, + id: "control:transport.return-head", + kind: "control", + study: "Return to paused head", +}; +const FORK: StudyTarget = { + n: 8, + id: "control:transport.fork", + kind: "control", + study: "Fork from here", +}; + +export const FRAMES: readonly StudyFrame[] = [ + { + id: "01", + title: "Empty REPL · focus in the input", + key: "initial focus on load", + url: "xmd://repl/e1/input", + focus: "region:input", + fixture: "empty", + overlay: false, + targets: [REGIONS[3]], + tab: "region:history", + shift: "region:bindings", + meta: { tab: "region 5 · Execution History", shift: "region 3 · Bindings", trap: false }, + }, + { + id: "02", + title: "Focus map overlay activated", + key: "F1 · toggle focus map", + url: "xmd://repl/e1/input", + focus: "region:input", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:history", + shift: "region:bindings", + meta: { tab: "5 · Execution History", shift: "3 · Bindings", trap: false }, + }, + { + id: "03", + title: "Forward Tab · into Execution History", + key: "Tab", + url: "xmd://repl/e1/history", + focus: "region:history", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:sessions", + shift: "region:input", + meta: { tab: "1 · Sessions — the ring wraps", shift: "4 · REPL input", trap: false }, + }, + { + id: "04", + title: "Reverse Shift+Tab · back to Bindings", + key: "Shift+Tab ×2 from region 5", + url: "xmd://repl/e1/bindings", + focus: "region:bindings", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:input", + shift: "region:transcript", + meta: { tab: "4 · REPL input", shift: "2 · Transcript", trap: false }, + }, + { + id: "05", + title: "Running transcript · footer controls reachable", + key: "Tab ×2 from the transcript", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-06", + focus: "control:transport.pause", + fixture: "nested", + overlay: true, + targets: [...REGIONS, PAUSE], + tab: "region:sessions", + shift: "region:history", + meta: { + tab: "1 · Sessions — leaves the footer", + shift: "5 · Execution History region", + trap: false, + }, + }, + { + id: "06", + title: "Three Agent sessions · background activity does not steal focus", + key: "no keypress — reviewer session starts streaming", + url: "xmd://repl/e1/transcript/entry-1/document", + head: "cp-13", + focus: "region:transcript", + fixture: "drawer", + overlay: true, + targets: REGIONS, + tab: "region:bindings", + shift: "region:sessions", + meta: { tab: "3 · Bindings", shift: "1 · Sessions", trap: false }, + }, + { + id: "07", + title: "Project-details Elicit · focus trapped in the drawer", + key: 'execution suspends at ', + url: "xmd://repl/e1/transcript/entry-1/document/+project", + head: "cp-14", + focus: "field:drawer.project.name", + fixture: "drawer", + overlay: true, + targets: [ + { n: 1, id: "field:drawer.project.name", kind: "field", study: "Project name" }, + { n: 2, id: "field:drawer.project.description", kind: "field", study: "Description" }, + { + n: 3, + id: "control:drawer.project.schema", + kind: "control", + study: "Schema disclosure · ⌥S", + }, + { n: 4, id: "control:drawer.project.submit", kind: "control", study: "Submit" }, + { + n: 5, + id: "region:history", + kind: "region", + study: "Execution History · still reachable", + }, + ], + tab: "field:drawer.project.description", + shift: "region:history", + meta: { + tab: "2 · Description", + shift: "5 · Execution History — the one way out of the trap", + trap: true, + }, + }, + { + id: "08", + title: "Plan-review Elicit · scroll region then decisions", + key: "Tab ×1 from the review scroll region", + url: "xmd://repl/e1/transcript/entry-1/document/plan/+review", + head: "cp-08", + focus: "control:drawer.review.approve", + fixture: "nested", + overlay: true, + targets: [ + { + n: 1, + id: "control:drawer.review.scroll", + kind: "control", + study: "Plan review · scroll region", + }, + { n: 2, id: "control:drawer.review.approve", kind: "control", study: "Approve" }, + { n: 3, id: "control:drawer.review.request", kind: "control", study: "Request changes" }, + { n: 4, id: "control:drawer.review.stop", kind: "control", study: "Stop" }, + { n: 5, id: "control:drawer.review.submit", kind: "control", study: "Submit" }, + { n: 6, id: "region:history", kind: "region", study: "Execution History" }, + ], + tab: "control:drawer.review.request", + shift: "control:drawer.review.scroll", + meta: { tab: "3 · Request changes", shift: "1 · review scroll region", trap: true }, + }, + { + id: "09", + title: "README-confirmation Elicit · Approve and Decline", + key: "Tab ×1 from the preview region", + url: "xmd://repl/e1/transcript/entry-1/document/+confirm", + head: "cp-16", + focus: "control:drawer.confirm.approve", + fixture: "drawer", + overlay: true, + targets: [ + { + n: 1, + id: "control:drawer.confirm.preview", + kind: "control", + study: "README preview · scroll region", + }, + { n: 2, id: "control:drawer.confirm.approve", kind: "control", study: "Approve" }, + { n: 3, id: "control:drawer.confirm.decline", kind: "control", study: "Decline" }, + { n: 4, id: "region:history", kind: "region", study: "Execution History" }, + ], + tab: "control:drawer.confirm.decline", + shift: "control:drawer.confirm.preview", + meta: { tab: "3 · Decline", shift: "1 · README preview", trap: true }, + }, + { + id: "10", + title: "Paused at the live head", + key: "Enter on Pause, from frame 05", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-18", + focus: "control:transport.continue", + fixture: "paused", + overlay: true, + targets: [...REGIONS, CONTINUE, RETURN_HEAD], + tab: "control:transport.return-head", + shift: "region:history", + meta: { tab: "7 · Return to paused head", shift: "5 · Execution History region", trap: false }, + }, + { + id: "11", + title: "Execution History navigation · checkpoint selected", + key: "← ← · step back two checkpoints", + url: "xmd://repl/e1/history/entry-1/document?at=cp-16", + head: "cp-18", + focus: "region:history", + fixture: "paused", + overlay: true, + targets: [...REGIONS, CONTINUE, RETURN_HEAD], + tab: "control:transport.continue", + shift: "region:input", + meta: { tab: "6 · Continue", shift: "4 · REPL input", trap: false }, + }, + { + id: "12", + title: "Historical inspection · reconstructed, read-only", + key: "Enter on the selected checkpoint", + url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", + head: "cp-18", + focus: "control:transport.fork", + fixture: "paused", + overlay: true, + targets: [ + ...REGIONS, + { + n: 6, + id: "control:transport.continue", + kind: "control", + study: "Continue · disabled while inspecting", + }, + RETURN_HEAD, + FORK, + ], + tab: "region:sessions", + shift: "control:transport.return-head", + meta: { tab: "1 · Journal — the ring wraps", shift: "7 · Return to paused head", trap: false }, + }, + { + id: "13", + title: "Return to live execution", + key: "Enter on Return to paused head, then Continue", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-19", + focus: "control:transport.pause", + fixture: "drawer", + overlay: true, + targets: [...REGIONS, PAUSE], + tab: "region:sessions", + shift: "region:history", + meta: { tab: "1 · Sessions", shift: "5 · Execution History region", trap: false }, + }, + { + id: "14", + title: "Settled entry · REPL input ready for Entry 2", + key: "no keypress — Entry 1 completes", + url: "xmd://repl/e1/input?draft=%3CPlan%3E", + head: "cp-22", + focus: "region:input", + fixture: "settled", + overlay: true, + targets: [ + ...REGIONS, + { n: 6, id: "control:input.run", kind: "control", study: "Run · enabled again" }, + ], + tab: "control:input.run", + shift: "region:bindings", + meta: { tab: "6 · Run", shift: "3 · Bindings", trap: false }, + }, +]; + +export function frame(id: string): StudyFrame | undefined { + return FRAMES.find((one) => one.id === id); +} + +/** + * One frame, as a state. + * + * The URL and the journal do all of the rebuilding. The frame then declares + * where focus was, because focus is disposable and no URL claims to carry it — + * which is exactly why the evidence has to check that the declared identity is + * still a live target in the state the URL rebuilt. + */ +export function stateFor(subject: StudyFrame): ReplState { + const state = hydrate(subject.url, journalThrough(subject.head)); + return { ...state, overlay: subject.overlay }; +} + +export interface Frame { + readonly state: ReplState; + readonly tree: ReplTree; +} + +/** + * One frame, as a state and the tree that renders it. + * + * The URL and the journal rebuild the state; the tree is then built from it and + * the frame's declared focus placed on the node that carries it. Focus is not + * in the URL and never was — placing it here is what the person did before the + * frame was taken, and the evidence's job is to check that the node the study + * names is one the tree actually offers. + */ +export function useFrame(subject: StudyFrame): Operation { + return { + *[Symbol.iterator]() { + const state = stateFor(subject); + const tree = yield* useReplTree(state); + // The same entry the interactive harness uses, so a frame opened by + // `--frame` and a frame built here cannot come out different. + yield* enterRoute(tree, state, subject.focus); + return { state, tree }; + }, + }; +} diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts new file mode 100644 index 000000000..4f25de2fa --- /dev/null +++ b/scripts/repl-study/host.ts @@ -0,0 +1,753 @@ +/** + * The only module that touches the terminal. + * + * Everything it turns on, it turns back off — on an ordinary exit, on a signal, + * and when a frame throws. The ordering is the point: each `ensure()` is + * registered *before* the thing it undoes exists, because a run halted while it + * is still acquiring has nothing registered to unwind, and a terminal left in + * the alternate buffer with its cursor hidden is a shell the person has to fix + * by hand. + * + * Mouse reporting is deliberately never enabled. Nothing here needs a pointer, + * and a terminal left reporting mouse movement is the loudest way this + * experiment could damage the thing it is borrowing. + */ + +import { alternateBuffer, createInput, cursor, settings } from "@bomb.sh/tty"; +import type { Input, InputEvent, ScanResult, Setting, Term } from "@bomb.sh/tty"; +import { createSignal, ensure, resource, sleep, spawn, until } from "effection"; +import type { Operation, Signal, Task } from "effection"; + +import { fixture, fixtures } from "./fixtures.ts"; +import type { Fixture, FixtureName } from "./model.ts"; +import { SURFACES } from "./layout.ts"; +import { transcriptLines } from "./render.ts"; +import type { FocusView } from "./render.ts"; +import { initialView } from "./store.ts"; +import { asKey, fixtureFor, hydrate, reduce, viewOf } from "./store.ts"; +import { overlayOf, useReplTree } from "./tree.ts"; +import { drive, enterRoute } from "./drive.ts"; +import type { HarnessEvent, ReplState, View } from "./store.ts"; +import { journalThrough, markerShowing } from "./journal.ts"; +import { formatRoute } from "./route.ts"; +import { RendererCapacityError, useTerm } from "./capture.ts"; +import { renderInto } from "./capture.ts"; +import type { Mutation } from "./mutations.ts"; +import { + motionAt, + playbackFrom, + segmentDurationMs, + segmentFixture, + segmentLabel, +} from "./playback.ts"; +import type { Motion, Playback, Segment } from "./playback.ts"; + +/** The modes the harness changes, as one reversible pair. */ +export function terminalModes(): Setting { + return settings(alternateBuffer({ clear: true }), cursor(false)); +} + +/** + * Apply the terminal modes, and restore them however this ends. + * + * The cleanup is registered before the first byte is written, and it only + * reverts what it actually applied. + */ +export function useTerminalModes( + write: (bytes: Uint8Array) => void, + mutation?: Mutation, +): Operation { + return resource(function* (provide) { + const modes = terminalModes(); + let applied = false; + yield* ensure(() => { + if (applied && mutation !== "leak-terminal-modes") { + write(modes.revert); + } + }); + write(modes.apply); + applied = true; + yield* provide(); + }); +} + +export function useRawMode(mutation?: Mutation): Operation { + return resource(function* (provide) { + let raw = false; + yield* ensure(() => { + if (raw && mutation !== "leak-terminal-modes") { + Deno.stdin.setRaw(false); + } + }); + Deno.stdin.setRaw(true); + raw = true; + yield* provide(); + }); +} + +/** + * One operating-system signal, for as long as the enclosing scope lives. + * + * The handler is a stable reference, added after its own removal is registered + * and removed in the same scope's teardown. + */ +export function useSignalListener(signal: Deno.Signal, handler: () => void): Operation { + return resource(function* (provide) { + let added = false; + yield* ensure(() => { + if (added) { + Deno.removeSignalListener(signal, handler); + } + }); + Deno.addSignalListener(signal, handler); + added = true; + yield* provide(); + }); +} + +/** + * Raw keystrokes. + * + * The reader is cancelled in teardown, which is what releases a read that is + * still waiting for a key that will never come. + */ +export function useStdinReader(): Operation> { + return resource(function* (provide) { + let reader: ReadableStreamDefaultReader | undefined; + yield* ensure(function* () { + if (reader) { + yield* until(reader.cancel()); + } + }); + reader = Deno.stdin.readable.getReader(); + yield* provide(reader); + }); +} + +/** + * What to assume when the terminal will not say how big it is. + * + * Some pseudo-terminals report `0 × 0` — macOS `script` does — and a renderer + * handed those dimensions draws nothing at all, which looks exactly like a + * harness that crashed. Eighty by twenty-four is the oldest safe answer to that + * question. + */ +export const ASSUMED_SIZE = { cols: 80, rows: 24 } as const; + +export function measureTerminal(): { cols: number; rows: number } { + try { + const size = Deno.consoleSize(); + if (size.columns > 0 && size.rows > 0) { + return { cols: size.columns, rows: size.rows }; + } + } catch { + // No terminal is attached to this process; the assumed size is the answer. + } + return { cols: ASSUMED_SIZE.cols, rows: ASSUMED_SIZE.rows }; +} + +export type { HarnessEvent }; + +export interface HarnessState { + readonly view: View; + readonly fixture: Fixture; + readonly cols: number; + readonly rows: number; + readonly quit: boolean; +} + +/** + * One chunk of raw keystrokes, decoded completely. + * + * A lone `ESC` is ambiguous until the terminal has had its say, so the decoder + * buffers it and asks to be re-scanned with an empty buffer after its own + * latency. A harness that reads `scanned.events` and drops `scanned.pending` + * swallows every Escape the user presses — the key is documented, the reducer + * handles it, and pressing it does nothing. Honouring `pending` here is what + * makes Escape arrive at all. + * + * The flush is bounded: the decoder reports `pending` again when re-scanned + * before its latency has elapsed, and a loop that trusted it without a ceiling + * would spin on a terminal whose clock disagreed. + */ +const FLUSH_ATTEMPTS = 4; + +export function* scanKeys( + input: Input, + chunk: Uint8Array | undefined, + deliver: (event: InputEvent) => void, + mutation?: Mutation, +): Operation { + const dispatch = (scanned: ScanResult): ScanResult["pending"] => { + for (const event of scanned.events) { + deliver(event); + } + return scanned.pending; + }; + let pending = dispatch(input.scan(chunk)); + for (let attempt = 0; attempt < FLUSH_ATTEMPTS; attempt += 1) { + if (pending === undefined || mutation === "swallow-pending-escape") { + return; + } + yield* sleep(pending.delay); + pending = dispatch(input.scan()); + } +} + +/** One frame drawn, and whether the renderer is still moving. */ +interface Painted { + readonly animating: boolean; + readonly bytes: number; +} + +function draw( + term: Term, + state: HarnessState, + write: (bytes: Uint8Array) => void, + mutation?: Mutation, + motion?: Motion, + deltaMs = 0, + focus?: FocusView, +): Painted { + // One render path for the harness and for the captures, so what a person sees + // in a terminal and what a golden records cannot drift apart. + const frame = renderInto(term, { + fixture: state.fixture, + view: state.view, + size: { cols: state.cols, rows: state.rows }, + mutation, + motion, + focus, + // The harness counts in milliseconds and the renderer in seconds. The + // conversion happens here, once, at the only place the two meet. + deltaSeconds: deltaMs / 1000, + }); + write(frame.ansi); + return { animating: frame.animating, bytes: frame.ansi.length }; +} + +/** A frame every sixteen milliseconds, which is the rate the study was made at. */ +export const FRAME_MS = 16; + +/** The same frame, in the seconds the renderer measures transitions in. */ +export const FRAME_SECONDS = FRAME_MS / 1000; + +/** + * One wake-up, armed by the frame loop after each frame it draws. + * + * It is a child of the terminal session, so a cancelled session takes it with + * it and no timer is left drawing into a terminal that has been restored. It is + * armed one frame at a time rather than looping on its own, because only the + * loop knows how long the next wait should be — sixteen milliseconds while a + * transition runs, and the remainder of a hold while a moment is being read. + * A timer that decided that for itself would be deciding it from state the loop + * had not finished updating. + */ +function* ticker(events: Signal, delayMs: number): Operation { + yield* sleep(delayMs); + events.send({ kind: "tick", advanceMs: delayMs }); +} + +/** One line of what the frame loop did, for evidence that cannot watch a screen. */ +export interface TraceEntry { + readonly frame: number; + readonly elapsedMs: number; + /** What the renderer was advanced by, in its own unit: seconds. */ + readonly deltaSeconds: number; + readonly animating: boolean; + readonly motionDone: boolean | null; + readonly bytes: number; + /** `hold:nested`, `play:nested→generated`, or `settled` once it is over. */ + readonly segment: string; + /** The moment this frame is showing, which is always one of the fixtures. */ + readonly fixture: FixtureName; +} + +/** + * The state a run opens at. + * + * `--route` says it outright. A fixture name says it indirectly: the journal + * knows which marker reconstructs that moment, and a moment with a suspension + * waiting opens the drawer that is waiting, because that is what an execution + * suspending does. + */ +export function openingState(options: { + readonly fixture: FixtureName; + readonly route?: string; + readonly head?: string; + readonly focusMap?: boolean; +}): ReplState { + const head = options.head ?? markerShowing(options.fixture); + const journal = journalThrough(head); + if (options.route !== undefined) { + const opened = hydrate(options.route, journal); + return { ...opened, overlay: options.focusMap === true }; + } + const start = { + execution: "e1", + surface: "transcript" as const, + scopes: [], + drawers: [], + inspect: false, + draft: "", + }; + const opened = hydrate(formatRoute(start), journal); + const waiting = opened.moment.suspension; + const routed = + waiting === undefined + ? opened + : hydrate(formatRoute({ ...start, drawers: [waiting] }), journal); + return { ...routed, overlay: options.focusMap === true }; +} + +export interface InteractiveOptions { + readonly fixture: FixtureName; + readonly mutation?: Mutation; + /** Open at this URL instead of at a fixture's own moment. */ + readonly route?: string; + /** How far the execution has recorded, which a URL never carries. */ + readonly head?: string; + /** The node to put focus on, for a run that opens at a named study frame. */ + readonly focus?: string; + /** Start with the numbered focus map drawn. */ + readonly focusMap?: boolean; + /** Start this playback immediately, rather than waiting for `p`. */ + readonly play?: Playback; + /** Play the whole approved story, holds and all, with no keystrokes. */ + readonly journey?: readonly Segment[]; + /** Leave after this many frames, so a run can end without a keystroke. */ + readonly maxFrames?: number; + /** Raise SIGINT at this harness once this many frames have been drawn. */ + readonly interruptAfterFrames?: number; + readonly trace?: TraceEntry[]; +} + +/** + * The harness, in a real terminal. + * + * A resize is read from the operating system rather than from the input stream: + * `Input.scan()` decodes keys and mouse reports, and a terminal's size change is + * neither. + */ +export function* runInteractive(options: InteractiveOptions): Operation { + const write = (bytes: Uint8Array) => { + // Every byte this harness sends the terminal goes through one synchronous + // write, because the last of them is sent from teardown: an asynchronous + // write there can be cut short by the very shutdown that scheduled it, and + // a terminal left in the alternate buffer with a hidden cursor is a shell + // the person has to repair by hand. + // oxlint-disable-next-line local/no-sync-filesystem + Deno.stdout.writeSync(bytes); + }; + const size = measureTerminal(); + // One owner for where the person is. The journey below is a projector rather + // than a place: while it runs it supplies the moment on screen, and the store + // is what every keystroke acts on. + let repl = openingState(options); + let state: HarnessState = { + view: viewOf(repl), + fixture: fixtureFor(repl), + cols: size.cols, + rows: size.rows, + quit: false, + }; + + // The tree is acquired before the terminal is touched, so its teardown runs + // after the terminal has been given back rather than into a restored one. + const tree = yield* useReplTree(repl); + // Entering the region the route names comes first, because the footer is an + // explicit region: its controls exist only once focus is inside it. Without + // this the interactive harness opened at a frame's *location* but not its + // focus, so `--frame 12` drew none of the transport controls it is about. + yield* enterRoute(tree, repl, options.focus); + + let term = yield* useTerm({ cols: state.cols, rows: state.rows }); + const input: Input = yield* until(createInput({})); + + yield* useTerminalModes(write, options.mutation); + yield* useRawMode(options.mutation); + + const events = createSignal(); + const subscription = yield* events; + + yield* useSignalListener("SIGWINCH", () => events.send({ kind: "resize", ...measureTerminal() })); + yield* useSignalListener("SIGINT", () => events.send({ kind: "quit" })); + yield* useSignalListener("SIGTERM", () => events.send({ kind: "quit" })); + + const reader = yield* useStdinReader(); + yield* spawn(function* () { + while (true) { + const chunk = yield* until(reader.read()); + if (chunk.done) { + events.send({ kind: "quit" }); + return; + } + // The flush runs inside the reader task, so a cancelled session takes it + // along and nothing re-scans into a terminal that has been restored. + yield* scanKeys( + input, + chunk.value, + (event) => events.send({ kind: "key", event }), + options.mutation, + ); + } + }); + + // Everything about where the demonstration has got to lives here, in this + // invocation, and is gone when it returns. No fixture knows about it, nothing + // durable records it, and reconstruction lands on a fixture rather than on a + // moment between two of them. + const journey = options.journey; + let segmentIndex = 0; + let playback = options.play; + let elapsed = 0; + let frames = 0; + let clock: Task | undefined; + let interrupted = false; + let settled = false; + let held: string | undefined; + let lastPainted = false; + + const currentSegment = (): Segment | undefined => + journey === undefined ? undefined : journey[segmentIndex]; + + const show = (name: FixtureName) => { + const target = fixture(name); + state = { ...state, fixture: target, view: initialView(target) }; + }; + + /** Where the store says we are, once the projector is not overriding it. */ + const follow = () => { + state = { ...state, fixture: fixtureFor(repl), view: viewOf(repl), quit: repl.quit }; + }; + + if (journey !== undefined) { + const first = journey[0]; + show(segmentFixture(first)); + if (first.kind === "play") { + playback = first.playback; + } + } else if (playback !== undefined) { + show(playback.to); + } + + /** + * How long the clock should wait before the next frame. + * + * A transition wants one every sixteen milliseconds. A held moment wants + * exactly one, when the hold is over. + */ + const nextDelayMs = (): number => { + const segment = currentSegment(); + if (segment?.kind === "hold") { + return Math.max(1, segment.durationMs - elapsed); + } + return FRAME_MS; + }; + + /** + * Move the journey on by the time that just passed. + * + * A wake-up can cross a segment boundary — the end of a hold is exactly such + * a wake-up — so this consumes segments until the elapsed time fits inside + * the current one, and reports when the story has run out. + */ + const advance = (byMs: number): "running" | "finished" => { + if (journey === undefined) { + elapsed += byMs; + return "running"; + } + elapsed += byMs; + while (segmentIndex < journey.length) { + const segment = journey[segmentIndex]; + const duration = segmentDurationMs(segment); + if (elapsed < duration) { + break; + } + elapsed -= duration; + segmentIndex += 1; + const entered = journey[segmentIndex]; + if (entered === undefined) { + // The last hold ended. What is on screen is the settled entry, and it + // stays there until the person leaves. + show(segmentFixture(segment)); + playback = undefined; + return "finished"; + } + show(segmentFixture(entered)); + playback = entered.kind === "play" ? entered.playback : undefined; + } + return "running"; + }; + + /** + * Draw one frame, then decide whether anything is still moving. + * + * The clock is started only when the renderer says it is animating or the + * application's own transition has not finished, and halted as soon as both + * have settled — so an idle REPL schedules nothing at all. + */ + const paint = function* (deltaMs: number, finished = false): Operation { + const segment = currentSegment(); + const motion = playback === undefined ? undefined : motionAt(playback, elapsed); + const label = + finished || segment === undefined + ? journey === undefined + ? "focused" + : "settled" + : segmentLabel(segment); + + // A held moment is one picture. Drawing it again on the way past would cost + // a render and change nothing, so the hold is drawn once and then waited + // out. + const repeated = journey !== undefined && segment?.kind === "hold" && held === label; + if (!repeated) { + const measured = { cols: state.cols, rows: state.rows }; + const focus: FocusView = { + here: tree.focused().name, + map: overlayOf(tree), + overlay: repl.overlay, + }; + let painted: Painted; + try { + painted = draw(term, state, write, options.mutation, motion, deltaMs, focus); + } catch (error) { + if (!(error instanceof RendererCapacityError)) { + throw error; + } + // The renderer ran out of room to measure text, which a long run in a + // wide terminal will do. A new one starts that cache again and repaints + // the whole screen, so the person watching sees nothing but a frame. + term = yield* useTerm(measured); + painted = draw(term, state, write, options.mutation, motion, 0, focus); + } + frames += 1; + options.trace?.push({ + frame: frames, + elapsedMs: elapsed, + deltaSeconds: deltaMs / 1000, + animating: painted.animating, + motionDone: motion === undefined ? null : motion.done, + bytes: painted.bytes, + segment: label, + fixture: state.fixture.name, + }); + lastPainted = painted.animating; + } + held = segment?.kind === "hold" ? label : undefined; + + const journeyRunning = journey !== undefined && !finished; + const moving = lastPainted || (motion !== undefined && !motion.done) || journeyRunning; + const active = options.mutation === "never-tick" ? false : moving; + if (clock !== undefined) { + const running = clock; + clock = undefined; + yield* running.halt(); + } + if (active) { + const delay = Math.max(1, Math.round(nextDelayMs())); + clock = yield* spawn(() => ticker(events, delay)); + } + if (journey === undefined && motion !== undefined && motion.done && !lastPainted) { + // The transition has arrived. What remains is the fixture itself, which + // is what a journal or a URL would restore. + playback = undefined; + settled = true; + } + if (finished) { + settled = true; + } + if (options.maxFrames !== undefined && !active) { + // A run with a frame budget has nobody at the keyboard, so when nothing + // is moving there is nothing left for it to do. A harness that scheduled + // no frame at all ends here too, after exactly one. + settled = true; + } + }; + + yield* paint(0); + + while (true) { + // `--frames` is a ceiling for a run nobody is watching: it leaves when the + // playback has settled, or when that many frames have been drawn, whichever + // comes first. Without it the harness waits for a keystroke, as it should. + if (options.maxFrames !== undefined && (settled || frames >= options.maxFrames)) { + return; + } + if ( + options.interruptAfterFrames !== undefined && + frames >= options.interruptAfterFrames && + !interrupted + ) { + interrupted = true; + Deno.kill(Deno.pid, "SIGINT"); + } + + const next = yield* subscription.next(); + if (next.done) { + return; + } + + if (next.value.kind === "tick") { + // The one-shot has fired and finished; the frame it produces arms the next. + clock = undefined; + const advanced = advance(next.value.advanceMs); + yield* paint(next.value.advanceMs, advanced === "finished"); + continue; + } + + // A keystroke or a resize is not time passing, so the renderer is told no + // time has passed: a transition in flight keeps its own pace instead of + // jumping forward because somebody typed. + const pressed = next.value.kind === "key" ? asKey(next.value.event) : undefined; + if (pressed !== undefined && pressed.type === "keydown") { + const code = pressed.code; + if (code === "p") { + const starting = playbackFrom(state.fixture.name); + if (starting !== undefined) { + playback = starting; + elapsed = 0; + const target = fixture(starting.to); + state = { ...state, fixture: target, view: initialView(target) }; + yield* paint(0); + continue; + } + } + } + + const before = { cols: state.cols, rows: state.rows }; + // Ignoring a resize means ignoring it completely — the renderer keeps the + // dimensions it had, and goes on addressing cells the terminal no longer + // has. + if (next.value.kind === "resize") { + if (options.mutation !== "skip-resize-update") { + const measured = { cols: next.value.cols, rows: next.value.rows }; + state = { ...state, cols: measured.cols, rows: measured.rows }; + const resized = yield* drive(tree, repl, next.value, { + size: measured, + mutation: options.mutation, + scrollLimit: 0, + }); + repl = resized.state; + if (journey === undefined && playback === undefined) { + follow(); + } + } + } else { + const lines = state.fixture.entry + ? transcriptLines(state.fixture.entry, Math.max(1, state.cols - 2)).length + : 0; + const driven = yield* drive(tree, repl, next.value, { + size: { cols: state.cols, rows: state.rows }, + mutation: options.mutation, + scrollLimit: Math.max(0, lines - Math.max(1, state.rows - 8)), + }); + repl = driven.state; + if (journey === undefined && playback === undefined) { + follow(); + } else { + state = { ...state, quit: repl.quit }; + } + } + if (state.quit) { + return; + } + if (state.cols !== before.cols || state.rows !== before.rows) { + term.update({ width: state.cols, height: state.rows }); + } + yield* paint(0); + } +} + +export interface ReplayOptions { + readonly mutation?: Mutation; + /** Raise SIGINT at the harness itself once this many frames have been drawn. */ + readonly interruptAfter?: number; + /** Throw from the frame loop, to prove restoration survives a failure. */ + readonly failAfter?: number; +} + +/** + * The same lifecycle, with no terminal attached. + * + * A capture cannot show that the modes were restored, because restoration is a + * sequence of bytes rather than a picture. This writes those bytes to an + * ordinary pipe so the evidence can read them, and drives the same setup, + * frames, resize and teardown path the interactive harness uses. + */ +export function* runReplay(options: ReplayOptions): Operation { + const chunks: Uint8Array[] = []; + const write = (bytes: Uint8Array) => { + chunks.push(bytes); + // The same rule as the interactive host: the restoring bytes are written + // from teardown, where an asynchronous write is not guaranteed to finish. + // oxlint-disable-next-line local/no-sync-filesystem + Deno.stdout.writeSync(bytes); + }; + + let state: HarnessState = { + view: initialView(fixture("nested")), + fixture: fixture("nested"), + cols: 200, + rows: 50, + quit: false, + }; + const term = yield* useTerm({ cols: state.cols, rows: state.rows }); + + yield* useTerminalModes(write, options.mutation); + + const events = createSignal(); + const subscription = yield* events; + yield* useSignalListener("SIGINT", () => events.send({ kind: "quit" })); + + const script: readonly { + readonly cols: number; + readonly rows: number; + readonly fixture: FixtureName; + }[] = [ + { cols: 200, rows: 50, fixture: "nested" }, + { cols: 140, rows: 38, fixture: "drawer" }, + { cols: 90, rows: 28, fixture: "paused" }, + { cols: 64, rows: 18, fixture: "paused" }, + { cols: 200, rows: 50, fixture: "settled" }, + ]; + + let drawn = 0; + for (const step of script) { + const next = fixture(step.fixture); + state = { ...state, fixture: next, view: initialView(next), cols: step.cols, rows: step.rows }; + if (options.mutation !== "skip-resize-update") { + term.update({ width: state.cols, height: state.rows }); + } + draw(term, state, write, options.mutation); + drawn += 1; + if (options.failAfter !== undefined && drawn >= options.failAfter) { + throw new Error("the harness failed while drawing a frame"); + } + if (options.interruptAfter !== undefined && drawn >= options.interruptAfter) { + // The signal is delivered by the operating system to the listener + // installed above; waiting for it here is what proves the listener, and + // the restoration behind it, are reached by a real interruption. + Deno.kill(Deno.pid, "SIGINT"); + const interrupted = yield* subscription.next(); + if (!interrupted.done && interrupted.value.kind === "quit") { + return; + } + return; + } + } + yield* chunksDrawn(chunks); +} + +/** Frames written, kept so a caller can assert on them without a terminal. */ +function* chunksDrawn(chunks: readonly Uint8Array[]): Operation { + if (chunks.length === 0) { + throw new Error("the replay drew no frames"); + } +} + +export function fixtureNames(): readonly FixtureName[] { + return fixtures().map((one) => one.name); +} + +export { SURFACES }; diff --git a/scripts/repl-study/journal.ts b/scripts/repl-study/journal.ts new file mode 100644 index 000000000..959b6a49f --- /dev/null +++ b/scripts/repl-study/journal.ts @@ -0,0 +1,408 @@ +/** + * Execution truth, as a record list. + * + * #842 owns the real journal. This is a fixture of one: the approved story's + * twenty recorded moments, written out by hand so that folding them is the only + * way to learn what was open, what had been published and what was waiting at + * any of them. + * + * It is authored from the study rather than generated from `fixtures.ts` on + * purpose. A journal derived from the fixtures would make the hydration case + * compare the fixtures with themselves, and pass while proving nothing about + * rebuilding a state from a URL. + */ + +import type { FixtureName, TransportMode } from "./model.ts"; +import type { DrawerKind } from "./fixtures.ts"; + +export type JournalKind = + | "entry.submitted" + | "scope.enter" + | "scope.exit" + | "binding.published" + | "session.started" + | "suspension.opened" + | "suspension.answered" + | "paused" + | "resumed" + | "entry.settled"; + +export interface JournalRecord { + /** The marker a URL names this moment by. */ + readonly marker: string; + /** Recorded seconds, which is what the Execution History band measures. */ + readonly at: number; + readonly kind: JournalKind; + /** The scope the record was made in, by the name a route segment uses. */ + readonly scope: string; + /** The binding, session, drawer or entry the record is about. */ + readonly detail: string; + /** The moment following the head here shows, which is one of the six. */ + readonly shows: FixtureName; + /** + * The moment *inspecting* this marker shows, when that is a different one. + * + * Following the head at 00:12 is the Plan opening live; reconstructing 00:12 + * is the read-only Plan scope with the head still out at the end. Same + * marker, two pictures, and only a fold that is told which question it is + * answering can tell them apart. + */ + readonly reconstructs?: FixtureName; +} + +export type JournalFixture = readonly JournalRecord[]; + +export const JOURNAL: JournalFixture = [ + { + marker: "cp-01", + at: 2, + kind: "entry.submitted", + scope: "repl", + detail: "Entry 1", + shows: "nested", + }, + { + marker: "cp-02", + at: 5, + kind: "scope.enter", + scope: "document", + detail: "document", + shows: "nested", + }, + { + marker: "cp-03", + at: 6, + kind: "session.started", + scope: "document", + detail: "plan-a91f7c", + shows: "nested", + }, + { + marker: "cp-04", + at: 12, + kind: "scope.enter", + scope: "plan", + detail: "plan", + shows: "nested", + reconstructs: "paused", + }, + { + marker: "cp-05", + at: 18, + kind: "binding.published", + scope: "plan", + detail: "inputs", + shows: "nested", + }, + { + marker: "cp-06", + at: 29, + kind: "binding.published", + scope: "plan", + detail: "draft", + shows: "nested", + }, + { + marker: "cp-07", + at: 30, + kind: "session.started", + scope: "plan", + detail: "review-b72e1d", + shows: "nested", + }, + { + marker: "cp-08", + at: 35, + kind: "suspension.opened", + scope: "plan", + detail: "review", + shows: "nested", + }, + { + marker: "cp-09", + at: 41, + kind: "suspension.answered", + scope: "plan", + detail: "review", + shows: "nested", + }, + { + marker: "cp-10", + at: 45, + kind: "scope.exit", + scope: "plan", + detail: "plan", + shows: "generated", + }, + { + marker: "cp-11", + at: 46, + kind: "scope.enter", + scope: "preview", + detail: "preview", + shows: "generated", + }, + { + marker: "cp-12", + at: 47, + kind: "scope.exit", + scope: "preview", + detail: "preview", + shows: "generated", + }, + { + marker: "cp-13", + at: 48, + kind: "session.started", + scope: "document", + detail: "implement-c31d2e", + shows: "drawer", + }, + { + marker: "cp-14", + at: 49, + kind: "suspension.opened", + scope: "document", + detail: "project", + shows: "drawer", + }, + { + marker: "cp-15", + at: 52, + kind: "suspension.answered", + scope: "document", + detail: "project", + shows: "drawer", + }, + { + marker: "cp-16", + at: 53, + kind: "suspension.opened", + scope: "document", + detail: "confirm", + shows: "drawer", + }, + { + marker: "cp-17", + at: 54, + kind: "suspension.answered", + scope: "document", + detail: "confirm", + shows: "drawer", + }, + { + marker: "cp-18", + at: 55, + kind: "paused", + scope: "document", + detail: "Entry 1", + shows: "paused", + }, + { + marker: "cp-19", + at: 57, + kind: "resumed", + scope: "document", + detail: "Entry 1", + shows: "drawer", + }, + { + marker: "cp-20", + at: 58, + kind: "scope.enter", + scope: "write", + detail: "write", + shows: "drawer", + }, + { + marker: "cp-21", + at: 60, + kind: "binding.published", + scope: "write", + detail: "readme", + shows: "drawer", + }, + { + marker: "cp-22", + at: 61, + kind: "entry.settled", + scope: "repl", + detail: "Entry 1", + shows: "settled", + }, +]; + +/** What the execution had got to, and what was true there. */ +export interface Moment { + /** The marker this moment sits at, absent when nothing has been recorded. */ + readonly marker?: string; + readonly at: number; + readonly transport: TransportMode; + /** The innermost scope that was open, by its route segment. */ + readonly scope: string; + /** The bindings that scope had published by then, in the order they arrived. */ + readonly published: readonly string[]; + /** The suspension waiting for an answer, when one was. */ + readonly suspension?: DrawerKind; + readonly sessions: number; + readonly entry: "none" | "running" | "settled"; + readonly shows: FixtureName; +} + +/** Every scope is opened inside this one, which the route spells as the entry. */ +export const ROOT_SCOPE = "repl"; + +function isDrawerKind(value: string): value is DrawerKind { + return value === "project" || value === "review" || value === "confirm"; +} + +/** The records up to and including one marker, which is the journal as it stood. */ +export function journalThrough( + marker: string | undefined, + journal: JournalFixture = JOURNAL, +): JournalFixture { + if (marker === undefined) { + return []; + } + const at = journal.findIndex((record) => record.marker === marker); + if (at === -1) { + throw new Error(`no such journal marker: ${marker}`); + } + return journal.slice(0, at + 1); +} + +/** + * Fold a journal into the moment it describes. + * + * With `upTo` the fold stops at that marker and the result is a reconstruction: + * the transport says `inspecting`, because what is on screen is a recorded + * moment rather than the head. Without it the fold runs to the end of whatever + * journal it was handed, which is the head by definition. + */ +export function fold(journal: JournalFixture, upTo?: string): Moment { + const scopes: string[] = [ROOT_SCOPE]; + const published = new Map([[ROOT_SCOPE, []]]); + let sessions = 0; + let suspension: DrawerKind | undefined; + let entry: Moment["entry"] = "none"; + let transport: TransportMode = "idle"; + let shows: FixtureName = "empty"; + let marker: string | undefined; + let at = 0; + let reached = upTo === undefined; + + for (const record of journal) { + if (record.kind === "entry.submitted") { + entry = "running"; + transport = "live"; + } + if (record.kind === "scope.enter") { + scopes.push(record.detail); + published.set(record.detail, []); + } + if (record.kind === "scope.exit") { + const left = scopes.lastIndexOf(record.detail); + if (left > 0) { + scopes.splice(left, 1); + } + published.delete(record.detail); + } + if (record.kind === "binding.published") { + published.get(scopes[scopes.length - 1])?.push(record.detail); + } + if (record.kind === "session.started") { + sessions += 1; + } + if (record.kind === "suspension.opened" && isDrawerKind(record.detail)) { + suspension = record.detail; + } + if (record.kind === "suspension.answered") { + suspension = undefined; + } + if (record.kind === "paused") { + transport = "paused"; + } + if (record.kind === "resumed") { + transport = "live"; + } + if (record.kind === "entry.settled") { + entry = "settled"; + transport = "idle"; + // A settled entry closes everything it opened, so what is left is the + // REPL scope the next entry will be submitted into. + scopes.splice(1); + } + marker = record.marker; + at = record.at; + shows = upTo === undefined ? record.shows : (record.reconstructs ?? record.shows); + if (upTo !== undefined && record.marker === upTo) { + reached = true; + break; + } + } + + if (!reached) { + throw new Error(`the journal handed to this fold never reaches ${upTo}`); + } + + const scope = scopes[scopes.length - 1]; + return { + marker, + at, + transport: upTo === undefined ? transport : "inspecting", + scope, + published: published.get(scope) ?? [], + suspension, + sessions, + entry, + shows, + }; +} + +/** The markers a scrubber steps through, which is every recorded moment. */ +export function markers(journal: JournalFixture): readonly string[] { + return journal.map((record) => record.marker); +} + +/** The first marker whose moment reconstructs to one of the six fixtures. */ +export function markerShowing( + name: FixtureName, + journal: JournalFixture = JOURNAL, +): string | undefined { + return journal.find((record) => record.shows === name)?.marker; +} + +/** + * The scopes opened directly inside one parent path, in the order the execution + * opened them. + * + * This is the sibling list structural navigation moves along. It is derived from + * the journal rather than declared, because siblings are a fact about what the + * execution did — which is why a scope that has not been entered yet is not one. + */ +export function siblingsOf(journal: JournalFixture, parents: readonly string[]): readonly string[] { + const stack: string[] = [ROOT_SCOPE]; + const found: string[] = []; + const inside = (): boolean => + stack.length === parents.length + 1 && parents.every((name, at) => stack[at + 1] === name); + for (const record of journal) { + if (record.kind === "scope.enter") { + if (inside() && !found.includes(record.detail)) { + found.push(record.detail); + } + stack.push(record.detail); + continue; + } + if (record.kind === "scope.exit") { + const left = stack.lastIndexOf(record.detail); + if (left > 0) { + stack.splice(left, 1); + } + continue; + } + if (record.kind === "entry.settled") { + stack.splice(1); + } + } + return found; +} diff --git a/scripts/repl-study/keys.ts b/scripts/repl-study/keys.ts new file mode 100644 index 000000000..455ea370c --- /dev/null +++ b/scripts/repl-study/keys.ts @@ -0,0 +1,84 @@ +/** + * A keystroke goes to the node that has focus, and stops where it is consumed. + * + * The key is invoked on the focused node's scope, so Effection walks that + * scope's ancestors and every branch between the root and the control — the + * drawer, the panel, the surface — runs its middleware in order. Any of them may + * **consume** the key by not calling `next`, and a consumed key goes no further: + * no fallback runs it afterwards. + * + * That last sentence is the whole point, and an earlier round of this + * experiment got it wrong. The path was recorded and then the same event was + * reduced globally regardless, so a branch could intercept a key and watch the + * global behavior happen anyway — the hierarchy annotated the dispatch instead + * of governing it. + * + * `packages/input/src/lib/input.ts` at the pinned Bombshell commit is the + * reference this follows. + */ + +import { createContext } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; + +import type { Key } from "./store.ts"; + +/** The branches a dispatch passed through, innermost last. */ +const PathContext = createContext("xmd:repl:key-path"); + +/** + * One keystroke, delivered to a node. + * + * The return value is whether the key was **handled**. The default is `false`: + * nothing between the root and the node claimed it, so the harness's own + * fallback may run it. Middleware that handles a key returns `true` without + * calling `next`. + */ +export const KeyboardApi = createApi("xmd:repl:keyboard", { + keydown(node: Node, key: Key): boolean { + void node; + void key; + return false; + }, +}); + +/** + * Record this branch on the path of every key that passes through it. + * + * Installed by `tree.ts` when a branch is mounted, and gone when the branch is + * removed — which is the whole of why a closed panel cannot receive input. It + * passes every key on: recording is not handling. + */ +export function recordPath(node: Node, name: string): void { + node.scope.around(KeyboardApi, { + keydown([target, key], next): boolean { + node.scope.get(PathContext)?.push(name); + return next(target, key); + }, + }); +} + +export interface Delivery { + /** The node the key was delivered to. */ + readonly target: string; + /** The branches it passed through, outermost first. */ + readonly path: readonly string[]; + /** True when something on that path claimed the key. Nothing else may run it. */ + readonly handled: boolean; +} + +/** + * Send one key to whichever node has focus. + * + * The path is collected on the root's scope rather than returned by the + * middleware, because a middleware that had to return it could not also use its + * return value to say whether it handled the key. + */ +export function sendKey(root: Node, focused: Node, key: Key): Delivery { + const path: string[] = []; + root.scope.set(PathContext, path); + const handled = KeyboardApi.invoke(focused.scope, "keydown", [focused, key]); + // Ancestors run outermost first, so the recorded order is already the path + // from the root down to the node. + return { target: focused.name, path, handled: handled === true }; +} diff --git a/scripts/repl-study/layout.ts b/scripts/repl-study/layout.ts new file mode 100644 index 000000000..fe2944f56 --- /dev/null +++ b/scripts/repl-study/layout.ts @@ -0,0 +1,220 @@ +/** + * Where each region goes, in terminal cells. + * + * The study composes one screen at 2560×1440 — a sidebar at `0..620`, the + * transcript centred in `620..2100`, a bindings pane at `2130..2530`, the + * contextual surface bottom-anchored above a full-width 92px Execution History + * footer. Those proportions are kept here and the pixels are not: a terminal is + * measured in cells, and the same composition has to hold at 240 columns and at + * 120. + * + * Below the wide composition's floor the interface is not shrunk further. It is + * routed: one surface at a time, full screen, which is the policy #827 asks for + * instead of scaling an interface until its text is unreadable. + */ + +import type { Mutation } from "./mutations.ts"; + +export type Profile = "wide" | "medium" | "narrow" | "too-small"; + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** The surfaces narrow routing moves between, in ring order. */ +export const SURFACES = ["sessions", "transcript", "bindings", "history"] as const; + +export type SurfaceName = (typeof SURFACES)[number]; + +/** Below this the interface refuses rather than lies. */ +export const MINIMUM = { cols: 72, rows: 20 } as const; + +/** What a pane must have to be worth composing beside another one. */ +export const PANE_MINIMUMS = { sidebar: 28, bindings: 26, transcript: 40 } as const; + +/** Rows the study's 92px bands become. */ +/** + * The Execution History band is five rows, not the study's four. + * + * Notch height carries scope depth, and four depths need four rows of their + * own. The selection's label needs a row the notches are not using, or a + * depth-0 notch and the label fight for the same cell — so the band takes one + * more row than the study's 92 pixels divide into. + */ +const FOOTER_ROWS = 5; +const INPUT_ROWS = 4; +const HEADER_ROWS = 2; + +export interface Layout { + readonly profile: Profile; + readonly cols: number; + readonly rows: number; + readonly screen: Rect; + /** True where a pane is at its floor and secondary detail is dropped. */ + readonly dense: boolean; + readonly sidebar?: Rect; + readonly transcript?: Rect; + readonly bindings?: Rect; + readonly header?: Rect; + /** The REPL input or the Elicit drawer, bottom-anchored above the footer. */ + readonly contextual?: Rect; + readonly footer?: Rect; + readonly separators: readonly Rect[]; + /** Narrow only: the one row naming the surface you are on. */ + readonly surfaceBar?: Rect; + readonly surface?: SurfaceName; +} + +export function profileFor(cols: number, rows: number): Profile { + if (cols < MINIMUM.cols || rows < MINIMUM.rows) { + return "too-small"; + } + if (cols >= 160 && rows >= 36) { + return "wide"; + } + if (cols >= 120 && rows >= 30) { + return "medium"; + } + return "narrow"; +} + +function clamp(value: number, low: number, high: number): number { + return Math.max(low, Math.min(high, value)); +} + +export interface LayoutRequest { + readonly cols: number; + readonly rows: number; + /** An open drawer takes the contextual band in wide, the screen in narrow. */ + readonly drawer: boolean; + /** Which surface narrow routing is showing. */ + readonly surface: SurfaceName; + /** A deliberate break, for the evidence that would otherwise check nothing. */ + readonly mutation?: Mutation; +} + +/** + * The profile a request is composed as. + * + * Two mutations live here rather than in the renderer, because both are + * decisions about which composition to use at all: one keeps the wide + * composition where routing was required, and the other composes an interface + * on a terminal that is too small to carry one. + */ +function composedProfile(cols: number, rows: number, mutation?: Mutation): Profile { + const measured = profileFor(cols, rows); + if (mutation === "ignore-minimum" && measured === "too-small") { + return "narrow"; + } + if (mutation === "shrink-wide-at-narrow" && measured === "narrow") { + return "medium"; + } + return measured; +} + +export function layoutFor(request: LayoutRequest): Layout { + const { cols, rows, drawer, surface } = request; + const screen = { x: 0, y: 0, width: cols, height: rows }; + const profile = composedProfile(cols, rows, request.mutation); + + if (profile === "too-small") { + return { profile, cols, rows, screen, dense: false, separators: [] }; + } + + if (profile === "narrow") { + if (drawer) { + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surface, + contextual: screen, + }; + } + const surfaceBar = { x: 0, y: 0, width: cols, height: 1 }; + const body = { x: 0, y: 1, width: cols, height: rows - 1 }; + if (surface === "transcript") { + const input = { x: 0, y: rows - 3, width: cols, height: 3 }; + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surfaceBar, + surface, + transcript: { ...body, height: body.height - input.height }, + contextual: input, + }; + } + const region = { ...body }; + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surfaceBar, + surface, + sidebar: surface === "sessions" ? region : undefined, + bindings: surface === "bindings" ? region : undefined, + footer: surface === "history" ? region : undefined, + }; + } + + const sidebarWidth = clamp(Math.round(cols * 0.24), PANE_MINIMUMS.sidebar, 52); + const bindingsWidth = clamp(Math.round(cols * 0.16), PANE_MINIMUMS.bindings, 40); + const footer = { x: 0, y: rows - FOOTER_ROWS, width: cols, height: FOOTER_ROWS }; + const contextualHeight = drawer ? Math.min(14, rows - FOOTER_ROWS - 8) : INPUT_ROWS; + const contextual = { + x: sidebarWidth + 1, + y: footer.y - contextualHeight, + width: cols - sidebarWidth - 1, + height: contextualHeight, + }; + const header = { x: sidebarWidth + 1, y: 0, width: contextual.width, height: HEADER_ROWS }; + const paneTop = HEADER_ROWS + 1; + const paneHeight = contextual.y - paneTop; + + return { + profile, + cols, + rows, + screen, + dense: profile === "medium", + sidebar: { x: 0, y: 0, width: sidebarWidth, height: footer.y }, + header, + transcript: { + x: sidebarWidth + 1, + y: paneTop, + width: cols - sidebarWidth - bindingsWidth - 2, + height: paneHeight, + }, + bindings: { x: cols - bindingsWidth, y: paneTop, width: bindingsWidth, height: paneHeight }, + contextual, + footer, + separators: [ + { x: sidebarWidth, y: 0, width: 1, height: footer.y }, + { x: cols - bindingsWidth - 1, y: paneTop, width: 1, height: paneHeight }, + { x: sidebarWidth + 1, y: HEADER_ROWS, width: contextual.width, height: 1 }, + ], + }; +} + +/** True when two rectangles share at least one cell. */ +export function intersects(one: Rect, other: Rect): boolean { + return ( + one.x < other.x + other.width && + other.x < one.x + one.width && + one.y < other.y + other.height && + other.y < one.y + one.height + ); +} diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts new file mode 100644 index 000000000..973d44cda --- /dev/null +++ b/scripts/repl-study/main.ts @@ -0,0 +1,323 @@ +/** + * The documented command. + * + * deno task repl:study --play the whole story, start to finish + * deno task repl:study one moment, in this terminal + * deno task repl:study --frame 07 one frame of the focus study + * deno task repl:study --route any location, said as a URL + * deno task repl:study --focus-map open with the numbered overlay on + * deno task repl:study --capture every fixture at every profile + * deno task repl:study --print nested wide one frame, as text + * deno task repl:study --replay the lifecycle, with no terminal + * + * `scripts/repl-study/README.md` explains the keys and what each mode is for. + */ + +import { ensure, exit, main } from "effection"; +import type { Operation } from "effection"; + +import { captureAll, captureFocus, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; +import { frame as studyFrame, FRAMES } from "./frames.ts"; +import { parseRoute } from "./route.ts"; +import { fixture } from "./fixtures.ts"; +import { runInteractive, runReplay } from "./host.ts"; +import type { TraceEntry } from "./host.ts"; +import { JOURNEY, playbackBetween } from "./playback.ts"; +import type { Playback } from "./playback.ts"; +import { writeTextFile } from "@effectionx/fs"; +import { isFixtureName } from "./model.ts"; +import type { FixtureName } from "./model.ts"; +import type { Profile } from "./layout.ts"; +import { isMutation } from "./mutations.ts"; +import type { Mutation } from "./mutations.ts"; +import { initialView } from "./store.ts"; + +const USAGE = [ + "usage:", + " repl-study [--fixture ] [--mutation ]", + " repl-study --play the whole story, start to finish", + " repl-study --play one transition, as a diagnostic", + " [--frames ] [--interrupt-after-frames ] [--trace ]", + " repl-study --frame one frame of the approved focus study", + " repl-study --route one location, said as a URL", + " repl-study [--frame ] --focus-map with the numbered focus map drawn", + " repl-study --capture [--mutation ]", + " repl-study --capture-focus the focus study's frames, as text", + " repl-study --print [--mutation ]", + " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", + "", + "fixtures: empty, nested, generated, drawer, paused, settled", + "profiles: wide, medium, narrow, too-small", + "playbacks: empty→nested, nested→generated, generated→drawer, drawer→paused, paused→settled", + `frames: ${FRAMES.map((one) => one.id).join(", ")}`, +].join("\n"); + +type Mode = + | { + readonly kind: "interactive"; + readonly fixture: FixtureName; + readonly play?: Playback; + readonly journey?: boolean; + readonly maxFrames?: number; + readonly interruptAfterFrames?: number; + readonly trace?: string; + readonly route?: string; + readonly head?: string; + readonly focus?: string; + readonly focusMap?: boolean; + } + | { readonly kind: "capture"; readonly directory: string; readonly focus?: boolean } + | { readonly kind: "print"; readonly fixture: FixtureName; readonly profile: Profile } + | { readonly kind: "replay"; readonly interruptAfter?: number; readonly failAfter?: number }; + +interface Invocation { + readonly mode: Mode; + readonly mutation?: Mutation; +} + +function isFrameCount(value: string | undefined): value is string { + return value !== undefined && Number.isInteger(Number(value)) && Number(value) >= 1; +} + +function isProfile(value: string): value is Profile { + return value === "wide" || value === "medium" || value === "narrow" || value === "too-small"; +} + +/** + * Read the command line, refusing anything it does not understand. + * + * An unknown option is a refusal rather than a default, because a harness that + * silently ignored `--mutation stale-frme` would report a passing run for a + * control that never ran. + */ +export function parse(argv: readonly string[]): Invocation | string { + let mutation: Mutation | undefined; + let fixtureName: FixtureName = "nested"; + let mode: Mode | undefined; + let play: Playback | undefined; + let journey = false; + let maxFrames: number | undefined; + let interruptAfterFrames: number | undefined; + let trace: string | undefined; + let route: string | undefined; + let head: string | undefined; + let focus: string | undefined; + let focusMap = false; + let at = 0; + + const value = (): string | undefined => { + at += 1; + return argv[at]; + }; + + while (at < argv.length) { + const argument = argv[at]; + if (argument === "--mutation") { + const name = value(); + if (name === undefined || !isMutation(name)) { + return `--mutation needs one of the declared controls, not ${JSON.stringify(name)}`; + } + mutation = name; + } else if (argument === "--fixture") { + const name = value(); + if (name === undefined || !isFixtureName(name)) { + return `--fixture needs a fixture name, not ${JSON.stringify(name)}`; + } + fixtureName = name; + } else if (argument === "--capture") { + const directory = value(); + if (directory === undefined) { + return "--capture needs a directory to write into"; + } + mode = { kind: "capture", directory }; + } else if (argument === "--print") { + const name = value(); + const profile = value(); + if (name === undefined || !isFixtureName(name)) { + return `--print needs a fixture name, not ${JSON.stringify(name)}`; + } + if (profile === undefined || !isProfile(profile)) { + return `--print needs a profile, not ${JSON.stringify(profile)}`; + } + mode = { kind: "print", fixture: name, profile }; + } else if (argument === "--play") { + // Bare `--play` is the demonstration: the whole story, in order, with + // nobody at the keyboard. Two fixture names narrow it to one transition, + // which is a diagnostic rather than the thing to show somebody. + const from = argv[at + 1]; + const to = argv[at + 2]; + if (from === undefined || from.startsWith("--")) { + journey = true; + } else { + at += 2; + if (!isFixtureName(from) || to === undefined || !isFixtureName(to)) { + return `--play takes no arguments, or two fixture names — not ${JSON.stringify([ + from, + to, + ])}`; + } + const found = playbackBetween(from, to); + if (found === undefined) { + return `there is no playback from ${from} to ${to}`; + } + play = found; + } + } else if (argument === "--frames") { + const count = value(); + if (!isFrameCount(count)) { + return "--frames needs a frame count"; + } + maxFrames = Number(count); + } else if (argument === "--interrupt-after-frames") { + const count = value(); + if (!isFrameCount(count)) { + return "--interrupt-after-frames needs a frame count"; + } + interruptAfterFrames = Number(count); + } else if (argument === "--trace") { + const path = value(); + if (path === undefined) { + return "--trace needs a file to write"; + } + trace = path; + } else if (argument === "--frame") { + const id = value(); + const found = id === undefined ? undefined : studyFrame(id); + if (found === undefined) { + return `--frame needs one of ${FRAMES.map((one) => one.id).join(", ")}, not ${JSON.stringify(id)}`; + } + route = found.url; + head = found.head; + focus = found.focus; + fixtureName = found.fixture; + } else if (argument === "--route") { + const url = value(); + const parsed = url === undefined ? undefined : parseRoute(url); + if (parsed === undefined) { + return "--route needs a REPL URL"; + } + if (!parsed.ok) { + return parsed.error.message; + } + route = url; + } else if (argument === "--focus-map") { + focusMap = true; + } else if (argument === "--capture-focus") { + const directory = value(); + if (directory === undefined) { + return "--capture-focus needs a directory to write into"; + } + mode = { kind: "capture", directory, focus: true }; + } else if (argument === "--replay") { + mode = { kind: "replay" }; + } else if (argument === "--interrupt-after" || argument === "--fail-after") { + const count = Number(value()); + if (!Number.isInteger(count) || count < 1) { + return `${argument} needs a frame count`; + } + const replay = mode?.kind === "replay" ? mode : { kind: "replay" as const }; + mode = + argument === "--interrupt-after" + ? { ...replay, interruptAfter: count } + : { ...replay, failAfter: count }; + } else if (argument === "--help" || argument === "-h") { + return USAGE; + } else { + return `unknown option ${JSON.stringify(argument)}\n\n${USAGE}`; + } + at += 1; + } + + return { + mode: mode ?? { + kind: "interactive", + fixture: journey ? "empty" : fixtureName, + play, + journey, + maxFrames, + interruptAfterFrames, + trace, + route, + head, + focus, + focusMap, + }, + mutation, + }; +} + +function* run(invocation: Invocation): Operation { + const { mode, mutation } = invocation; + + if (mode.kind === "capture") { + const captures = mode.focus === true ? yield* captureFocus() : yield* captureAll(); + yield* writeCaptures(mode.directory, captures); + console.log(`wrote ${captures.length} captures to ${mode.directory}`); + return; + } + + if (mode.kind === "print") { + const subject = fixture(mode.fixture); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES[mode.profile], + mutation, + }); + console.log(frame.text); + return; + } + + if (mode.kind === "replay") { + yield* runReplay({ mutation, interruptAfter: mode.interruptAfter, failAfter: mode.failAfter }); + return; + } + + if (!Deno.stdout.isTerminal() || !Deno.stdin.isTerminal()) { + yield* exit( + 2, + "repl-study needs a real terminal. Use --capture to write every frame to files, --print for one, or --replay for the lifecycle.", + ); + return; + } + + const trace: TraceEntry[] = []; + const tracePath = mode.trace; + if (tracePath !== undefined) { + // Written from teardown, not after the loop: an interruption ends this run + // through the same shutdown that restores the terminal, and a trace that + // only survived an ordinary exit could not testify about an interrupted + // one. Registered before the harness starts, so it runs after it stops. + yield* ensure(function* () { + yield* writeTextFile( + tracePath, + trace.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + ); + }); + } + yield* runInteractive({ + fixture: mode.fixture, + route: mode.route, + head: mode.head, + focus: mode.focus, + focusMap: mode.focusMap, + mutation, + play: mode.play, + journey: mode.journey === true ? JOURNEY : undefined, + maxFrames: mode.maxFrames, + interruptAfterFrames: mode.interruptAfterFrames, + trace: tracePath === undefined ? undefined : trace, + }); +} + +if (import.meta.main) { + await main(function* () { + const invocation = parse(Deno.args); + if (typeof invocation === "string") { + console.log(invocation); + yield* exit(invocation === USAGE ? 0 : 2); + return; + } + yield* run(invocation); + }); +} diff --git a/scripts/repl-study/model.ts b/scripts/repl-study/model.ts new file mode 100644 index 000000000..16323eb98 --- /dev/null +++ b/scripts/repl-study/model.ts @@ -0,0 +1,193 @@ +/** + * What the REPL is showing, said semantically. + * + * A fixture describes execution — which scopes are open, what phase each one is + * in, which sections have settled, what the sessions are doing, which values a + * scope published, where the recorded head is — and nothing about a terminal. No + * row, column, width or byte appears in this file or in any value built from it. + * `layout.ts` decides where things go and `render.ts` decides what cells they + * become, so a rendering change cannot quietly become application state. + */ + +/** The lifecycle phase of one component, as the study's study names it. */ +export type Phase = "enter" | "active" | "waiting" | "exit" | "settled" | "failed" | "pending"; + +/** One line of the execution projection. */ +export type TranscriptRow = + /** A component boundary or expression, carrying its lifecycle phase. */ + | { + readonly kind: "lifecycle"; + readonly source: string; + readonly depth: number; + readonly phase: Phase; + /** Rows sharing a pair name are one scope's opening and closing boundary. */ + readonly pair?: string; + readonly close?: boolean; + } + /** Rendered prose the execution produced. Long text wraps. */ + | { + readonly kind: "prose"; + readonly text: string; + readonly depth: number; + readonly emphasis?: "title" | "strong" | "dim"; + } + /** Generated Markdown or XMD, shown before and after it is admitted. */ + | { + readonly kind: "fence"; + readonly label: string; + readonly lines: readonly string[]; + readonly caption?: string; + readonly depth: number; + } + /** Completed work, collapsed to the bindings it published. */ + | { + readonly kind: "section"; + readonly name: string; + readonly published: string; + readonly state: "collapsed" | "expanded"; + readonly depth: number; + }; + +/** One transcript entry: immutable source, and the execution it opened. */ +export interface Entry { + readonly id: string; + readonly title: string; + readonly state: "running" | "completed"; + readonly elapsed: string; + readonly sourceLines: number; + readonly scopeNote: string; + readonly rows: readonly TranscriptRow[]; +} + +export interface Session { + readonly id: string; + readonly agent: string; + readonly state: "queued" | "active" | "completed"; + readonly label: string; + readonly turn: string; + readonly selected?: boolean; + readonly note?: string; +} + +export interface Binding { + readonly name: string; + readonly note?: string; + readonly lines: readonly string[]; +} + +/** + * One semantic checkpoint on the recorded timeline. + * + * `kind` is the study's major/minor distinction: an entry boundary is major, + * every other recorded moment is minor. `depth` is how deeply nested the scope + * that produced it was, which is what the band runs out of room for first. + */ +export interface Checkpoint { + readonly at: number; + readonly kind: "entry" | "event"; + readonly label: string; + readonly scope: string; + readonly depth: number; + readonly records: readonly string[]; +} + +export type TransportMode = "idle" | "live" | "paused" | "inspecting"; + +export interface History { + readonly elapsed: string; + /** Recorded seconds at the head. The head is the newest recorded moment. */ + readonly headAt: number; + /** Where a historical selection sits, when the fixture has one. */ + readonly selectedAt?: number; + readonly checkpoints: readonly Checkpoint[]; + readonly transport: TransportMode; + /** A long wait the band compresses rather than drawing to scale. */ + readonly compressed?: { readonly at: number; readonly note: string }; +} + +export type Drawer = + | { + readonly kind: "project"; + readonly heading: string; + readonly origin: string; + readonly prompt: string; + readonly fields: readonly { readonly label: string; readonly value: string }[]; + readonly schema: readonly string[]; + readonly validation: string; + readonly submit: string; + } + | { + readonly kind: "review"; + readonly heading: string; + readonly origin: string; + readonly plan: readonly string[]; + readonly more: string; + readonly decisions: readonly { + readonly label: string; + readonly chosen: boolean; + readonly note?: string; + }[]; + readonly submit: string; + } + | { + readonly kind: "confirm"; + readonly heading: string; + readonly origin: string; + readonly prompt: string; + readonly preview: readonly string[]; + readonly actions: readonly { readonly label: string; readonly primary: boolean }[]; + readonly hint: string; + }; + +export interface SidebarState { + readonly tab: "sessions" | "journal" | "state"; + /** Shown instead of a list when there is nothing to list. */ + readonly placeholder?: readonly string[]; + readonly heading?: string; + readonly subheading?: string; +} + +export interface BindingsPane { + readonly scopeName: string; + readonly bindings: readonly Binding[]; + readonly placeholder?: readonly string[]; +} + +export interface InputBand { + readonly label: string; + readonly hint: string; + readonly placeholder?: string; + readonly runEnabled: boolean; +} + +export const FIXTURE_NAMES = [ + "empty", + "nested", + "generated", + "drawer", + "paused", + "settled", +] as const; + +export type FixtureName = (typeof FIXTURE_NAMES)[number]; + +export interface Fixture { + readonly name: FixtureName; + /** One line naming the moment, shown by the harness itself, not the REPL. */ + readonly moment: string; + readonly crumb: string; + /** The study's `RECONSTRUCTED AT … · READ-ONLY` or `PAUSED AT HEAD`. */ + readonly badge?: string; + readonly readOnly?: boolean; + readonly sidebar: SidebarState; + readonly entry?: Entry; + readonly sessions: readonly Session[]; + readonly bindings: BindingsPane; + readonly drawer?: Drawer; + readonly input: InputBand; + readonly history: History; +} + +export function isFixtureName(value: string): value is FixtureName { + return (FIXTURE_NAMES as readonly string[]).includes(value); +} diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts new file mode 100644 index 000000000..4a0142f37 --- /dev/null +++ b/scripts/repl-study/mutations.ts @@ -0,0 +1,71 @@ +/** + * The ways this harness can be broken on purpose. + * + * Every claim the evidence makes has one of these behind it, because a claim + * nobody can break is a claim nobody is checking. Each value is passed to one + * run, changes exactly one behavior, and has to be rejected by the same oracle + * that admits the honest run — not merely crash it. + */ + +export const MUTATIONS = [ + /** Draw the previous frame's operations after the state changed. */ + "stale-frame", + /** Keep the three-pane composition at narrow dimensions instead of routing. */ + "shrink-wide-at-narrow", + /** Let the drawer take the rows the Execution History footer owns. */ + "drawer-covers-footer", + /** Compose the interface below the supported minimum instead of refusing. */ + "ignore-minimum", + /** Resize the terminal without telling the renderer. */ + "skip-resize-update", + /** Drop the transcript window and the scrubber's coalescing. */ + "clip-long-transcript", + /** Leave the terminal in the modes the harness turned on. */ + "leak-terminal-modes", + /** Render one notch height for every kind of marker. */ + "flatten-notches", + /** Never schedule a frame, so an animation renders once and stops. */ + "never-tick", + /** Reconstruct a moment as a half-finished animation rather than a state. */ + "restore-mid-animation", + /** Move focus when a background update arrives. */ + "steal-focus-on-background", + /** Rebuild the whole tree on every sync instead of reconciling it. */ + "rebuild-tree-each-sync", + /** Leave a replaced control wherever it was appended, losing canonical order. */ + "append-replacements", + /** Close a drawer without removing its branch, so its controls survive. */ + "keep-closed-branch", + /** Number the overlay from a static list instead of walking the tree. */ + "flat-overlay", + /** Let Tab escape an open drawer into the panes behind it. */ + "leak-drawer-trap", + /** Make a visible-but-disabled control focusable. */ + "focus-hidden-target", + /** Push a navigation entry for every keystroke in the draft. */ + "push-draft-edits", + /** Rebuild the route from the profile instead of preserving it. */ + "drop-route-on-resize", + /** Leave focus where it was when a drawer closes. */ + "forget-drawer-invoker", + /** Permit a mutation while a recorded moment is under inspection. */ + "mutate-while-inspecting", + /** Drop `ScanResult.pending`, so a lone Escape is never delivered. */ + "swallow-pending-escape", + /** Accept only a synthetic Tab+shift as reverse traversal. */ + "ignore-backtab", + /** Move focus across a region boundary without moving the URL's surface. */ + "keep-route-on-focus", + /** Forget the selected marker when a state is rebuilt from its URL. */ + "drop-selection-on-hydrate", + /** Exit on Ctrl+C while a paused entry is still active. */ + "exit-on-paused-interrupt", + /** Leave the sibling arrows inert, as if the locus had no siblings. */ + "inert-sibling-arrows", +] as const; + +export type Mutation = (typeof MUTATIONS)[number]; + +export function isMutation(value: string): value is Mutation { + return (MUTATIONS as readonly string[]).includes(value); +} diff --git a/scripts/repl-study/playback.ts b/scripts/repl-study/playback.ts new file mode 100644 index 000000000..f4732bba1 --- /dev/null +++ b/scripts/repl-study/playback.ts @@ -0,0 +1,184 @@ +/** + * Moving between two moments, rather than cutting between them. + * + * The six fixtures stay what they are: stable, reconstructable states that the + * captures and the journal both describe. A playback is the path between two of + * them, and it exists only while it is running — its phase and its elapsed time + * live in the frame loop's own local state, never in a fixture and never in + * anything a journal would restore. Reconstruction lands on a fixture; it never + * lands halfway through a transition. + * + * Two kinds of motion run here. The renderer owns one — a drawer that grows out + * of the contextual band, interpolated by `@bomb.sh/tty` from a declared + * transition — and the application owns the other: the recorded head travelling + * along the track, and the target's transcript arriving a few rows at a time. + */ + +import { fixture } from "./fixtures.ts"; +import type { FixtureName } from "./model.ts"; + +export interface Playback { + readonly from: FixtureName; + readonly to: FixtureName; + readonly durationMs: number; +} + +/** The path the harness plays, in the order the study tells its story. */ +export const PLAYBACKS: readonly Playback[] = [ + { from: "empty", to: "nested", durationMs: 640 }, + { from: "nested", to: "generated", durationMs: 640 }, + { from: "generated", to: "drawer", durationMs: 640 }, + { from: "drawer", to: "paused", durationMs: 640 }, + { from: "paused", to: "settled", durationMs: 640 }, +]; + +/** + * One stretch of the demonstration: a moment held, or a transition played. + * + * A hold is not dead time. The study's moments are dense — a nested transcript, + * a form, a band with fourteen checkpoints on it — and a demonstration that cut + * between them as fast as it could render would show everything and let a + * person read nothing. + */ +export type Segment = + | { readonly kind: "hold"; readonly fixture: FixtureName; readonly durationMs: number } + | { readonly kind: "play"; readonly playback: Playback }; + +/** + * The whole approved story, start to finish, with nobody at the keyboard. + * + * Holds are proportional to how much there is to take in: the empty REPL is one + * sentence, the nested transcript and the paused band are the densest screens in + * the study. The last hold ends the journey, and what remains on screen is the + * settled entry. + */ +export const JOURNEY: readonly Segment[] = [ + { kind: "hold", fixture: "empty", durationMs: 1200 }, + { kind: "play", playback: PLAYBACKS[0] }, + { kind: "hold", fixture: "nested", durationMs: 2600 }, + { kind: "play", playback: PLAYBACKS[1] }, + { kind: "hold", fixture: "generated", durationMs: 2400 }, + { kind: "play", playback: PLAYBACKS[2] }, + { kind: "hold", fixture: "drawer", durationMs: 2400 }, + { kind: "play", playback: PLAYBACKS[3] }, + { kind: "hold", fixture: "paused", durationMs: 2600 }, + { kind: "play", playback: PLAYBACKS[4] }, + { kind: "hold", fixture: "settled", durationMs: 1600 }, +]; + +export function segmentDurationMs(segment: Segment): number { + return segment.kind === "hold" ? segment.durationMs : segment.playback.durationMs; +} + +/** The moment a segment is showing, which is a fixture either way. */ +export function segmentFixture(segment: Segment): FixtureName { + return segment.kind === "hold" ? segment.fixture : segment.playback.to; +} + +/** What a trace calls this segment, so a run can be read back as a journey. */ +export function segmentLabel(segment: Segment): string { + return segment.kind === "hold" + ? `hold:${segment.fixture}` + : `play:${segment.playback.from}→${segment.playback.to}`; +} + +/** How long the whole demonstration takes, holds included. */ +export function journeyDurationMs(journey: readonly Segment[] = JOURNEY): number { + return journey.reduce((total, segment) => total + segmentDurationMs(segment), 0); +} + +export function playbackFrom(from: FixtureName): Playback | undefined { + return PLAYBACKS.find((playback) => playback.from === from); +} + +export function playbackBetween(from: FixtureName, to: FixtureName): Playback | undefined { + return PLAYBACKS.find((playback) => playback.from === from && playback.to === to); +} + +export interface Motion { + /** Eased 0…1. */ + readonly progress: number; + /** Where the recorded head sits while it travels between the two moments. */ + readonly headAt: number; + /** How much of the target's transcript has arrived, as a share of its rows. */ + readonly reveal: number; + readonly done: boolean; +} + +function easeInOutCubic(fraction: number): number { + return fraction < 0.5 + ? 4 * fraction * fraction * fraction + : 1 - Math.pow(-2 * fraction + 2, 3) / 2; +} + +/** + * The motion of one playback at one moment. + * + * Pure, and a function of elapsed time alone, so the same instant can be + * rendered from a capture, from a test, or from the frame loop and come out + * identical. + */ +export function motionAt(playback: Playback, elapsedMs: number): Motion { + const fraction = + playback.durationMs <= 0 ? 1 : Math.max(0, Math.min(1, elapsedMs / playback.durationMs)); + const progress = easeInOutCubic(fraction); + const from = fixture(playback.from).history.headAt; + const to = fixture(playback.to).history.headAt; + return { + progress, + headAt: from + (to - from) * progress, + reveal: progress, + done: fraction >= 1, + }; +} + +/** The moment a playback settles on, which is the state anything restores to. */ +export function settledFixture(playback: Playback): FixtureName { + return playback.to; +} + +/** One frame of a journey, described before anything is rendered. */ +export interface PlannedFrame { + readonly label: string; + readonly fixture: FixtureName; + readonly motion?: Motion; + readonly deltaMs: number; + readonly elapsedMs: number; +} + +/** + * The whole demonstration as a list of frames, at a fixed step. + * + * The live loop is driven by a real clock and draws a held moment once; this + * walks the same journey with time supplied instead of measured, so a test or a + * capture sees exactly the frames a viewer would, in the same order, without + * waiting sixteen seconds for them. + */ +export function journeyPlan(journey: readonly Segment[] = JOURNEY, frameMs = 16): PlannedFrame[] { + const planned: PlannedFrame[] = []; + let elapsed = 0; + for (const segment of journey) { + const duration = segmentDurationMs(segment); + if (segment.kind === "hold") { + planned.push({ + label: segmentLabel(segment), + fixture: segment.fixture, + deltaMs: planned.length === 0 ? 0 : frameMs, + elapsedMs: elapsed, + }); + elapsed += duration; + continue; + } + for (let within = 0; within <= duration; within += frameMs) { + planned.push({ + label: segmentLabel(segment), + fixture: segment.playback.to, + motion: motionAt(segment.playback, within), + deltaMs: planned.length === 0 ? 0 : frameMs, + elapsedMs: elapsed + within, + }); + } + elapsed += duration; + } + return planned; +} diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts new file mode 100644 index 000000000..31b6b652c --- /dev/null +++ b/scripts/repl-study/render.ts @@ -0,0 +1,1278 @@ +/** + * The approved interface, in terminal cells. + * + * Every region is a floating box placed at the rectangle `layout.ts` computed, + * so what the renderer reports through `render().info` can be checked against + * what the layout intended. Lines are pre-wrapped and padded here rather than + * left to wrap themselves, because a window over a long transcript has to know + * how many rows each line will take before it can decide which lines to show. + * + * Colours, glyphs and wording come from the Product Owner's study. The study is + * explicit that a lifecycle phase is "glyph + word, never colour alone", so a + * phase is always legible on a monochrome terminal too. + */ + +import { close, grow, fixed, open, rgba, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; + +import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow } from "./model.ts"; +import type { Layout, Rect } from "./layout.ts"; +import { MINIMUM } from "./layout.ts"; +import type { View } from "./store.ts"; +import type { Mutation } from "./mutations.ts"; +import type { OverlayEntry } from "./tree.ts"; +import type { Motion } from "./playback.ts"; + +const C = { + src: rgba(0xc8, 0xd2, 0xd9), + active: rgba(0x7f, 0xd3, 0xe8), + tick: rgba(0x5a, 0xa8, 0x7c), + settledText: rgba(0x7c, 0x86, 0x8d), + hold: rgba(0xc9, 0x9a, 0x3f), + intro: rgba(0xcf, 0xe0, 0xea), + out: rgba(0xe6, 0xec, 0xf1), + label: rgba(0x8b, 0x95, 0x9c), + dim: rgba(0x7b, 0x85, 0x8d), + name: rgba(0xb8, 0xc4, 0xcc), + exit: rgba(0xc2, 0x76, 0x6e), + gold: rgba(0xc9, 0xa8, 0x6a), + fail: rgba(0xd2, 0x4b, 0x3f), + rule: rgba(0x16, 0x1c, 0x21), + focus: rgba(0x9a, 0xe0, 0xa8), +}; + +const BG = { + app: rgba(0x0b, 0x0d, 0x0f), + side: rgba(0x09, 0x0b, 0x0c), + center: rgba(0x0c, 0x0e, 0x11), + bind: rgba(0x0a, 0x0c, 0x0e), + drawer: rgba(0x0e, 0x13, 0x16), + input: rgba(0x0a, 0x0d, 0x0f), + footer: rgba(0x08, 0x09, 0x0b), + rule: rgba(0x16, 0x1c, 0x21), +}; + +interface PhaseStyle { + readonly glyph: string; + readonly word: string; + readonly color: number; +} + +const PHASE: Record = { + enter: { glyph: "▶", word: "ENTER", color: C.tick }, + active: { glyph: "●", word: "ACTIVE", color: C.active }, + waiting: { glyph: "●", word: "WAITING", color: C.hold }, + exit: { glyph: "◀", word: "EXIT", color: C.exit }, + settled: { glyph: "✓", word: "SETTLED", color: C.tick }, + failed: { glyph: "×", word: "FAILED", color: C.fail }, + pending: { glyph: " ", word: "", color: C.dim }, +}; + +/** One piece of a line, with the width it is padded or truncated to. */ +export interface Segment { + readonly text: string; + readonly color?: number; + /** Omit on exactly one segment to let it take the remaining width. */ + readonly width?: number; +} + +export interface VisualLine { + readonly segments: readonly Segment[]; +} + +function fit(value: string, width: number): string { + if (width <= 0) { + return ""; + } + const glyphs = [...value]; + if (glyphs.length > width) { + return width === 1 ? "…" : glyphs.slice(0, width - 1).join("") + "…"; + } + return value + " ".repeat(width - glyphs.length); +} + +export function wrapText(value: string, width: number): string[] { + if (width <= 0) { + return []; + } + const lines: string[] = []; + let current = ""; + for (const word of value.split(" ")) { + if (current === "") { + current = word; + continue; + } + if ([...current].length + 1 + [...word].length <= width) { + current = `${current} ${word}`; + continue; + } + lines.push(current); + current = word; + } + if (current !== "") { + lines.push(current); + } + return lines.length === 0 ? [""] : lines; +} + +/** Lay one row of segments out across `width` columns. */ +function lineOps(id: string, width: number, line: VisualLine): Op[] { + const fixedWidth = line.segments.reduce((total, segment) => total + (segment.width ?? 0), 0); + const flexible = line.segments.filter((segment) => segment.width === undefined).length; + const remaining = Math.max(0, width - fixedWidth); + const share = flexible === 0 ? 0 : Math.floor(remaining / flexible); + const ops: Op[] = [ + open(id, { layout: { width: fixed(width), height: fixed(1), direction: "ltr" } }), + ]; + let used = 0; + line.segments.forEach((segment, index) => { + const isLastFlexible = + segment.width === undefined && + line.segments.slice(index + 1).every((later) => later.width !== undefined); + const segmentWidth = segment.width ?? (isLastFlexible ? Math.max(0, remaining - used) : share); + if (segment.width === undefined) { + used += segmentWidth; + } + ops.push( + open(`${id}.${index}`, { layout: { width: fixed(segmentWidth), height: fixed(1) } }), + text(fit(segment.text, segmentWidth), { color: segment.color ?? C.src }), + close(), + ); + }); + ops.push(close()); + return ops; +} + +interface RegionOptions { + readonly bg?: number; + readonly padding?: { readonly left?: number; readonly right?: number; readonly top?: number }; + /** + * A transition the renderer owns. + * + * Declaring it makes `@bomb.sh/tty` interpolate this region between the + * geometry of one frame and the next, and report `animating` until it + * settles. The harness supplies the time; it does not do the interpolation. + */ + readonly transition?: { + readonly duration: number; + readonly easing?: "linear" | "easeIn" | "easeOut" | "easeInOut"; + readonly properties: readonly ("x" | "y" | "position" | "width" | "height" | "size" | "bg")[]; + }; +} + +function region( + id: string, + rect: Rect, + lines: readonly VisualLine[], + options: RegionOptions = {}, +): Op[] { + const padLeft = options.padding?.left ?? 1; + const padRight = options.padding?.right ?? 1; + const padTop = options.padding?.top ?? 0; + const innerWidth = Math.max(0, rect.width - padLeft - padRight); + const capacity = Math.max(0, rect.height - padTop); + const ops: Op[] = [ + open(id, { + layout: { + width: fixed(rect.width), + height: fixed(rect.height), + direction: "ttb", + padding: { left: padLeft, right: padRight, top: padTop }, + }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + bg: options.bg ?? BG.app, + clip: { horizontal: true, vertical: true }, + ...(options.transition === undefined + ? {} + : { + transition: { + duration: options.transition.duration, + easing: options.transition.easing, + properties: [...options.transition.properties], + }, + }), + }), + ]; + lines.slice(0, capacity).forEach((line, index) => { + ops.push(...lineOps(`${id}.line.${index}`, innerWidth, line)); + }); + ops.push(close()); + return ops; +} + +function rule(id: string, rect: Rect, glyph: string): Op[] { + const ops: Op[] = [ + open(id, { + layout: { width: fixed(rect.width), height: fixed(rect.height), direction: "ttb" }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + bg: BG.rule, + clip: { horizontal: true, vertical: true }, + }), + ]; + for (let row = 0; row < rect.height; row += 1) { + ops.push( + open(`${id}.${row}`, { layout: { width: fixed(rect.width), height: fixed(1) } }), + text(glyph.repeat(Math.max(0, rect.width)), { color: C.rule }), + close(), + ); + } + ops.push(close()); + return ops; +} + +/** + * How long the drawer takes to arrive, in the renderer's own unit. + * + * `@bomb.sh/tty` measures transitions in **seconds** — both this duration and + * the `deltaTime` a frame is advanced by. The harness thinks in milliseconds + * everywhere else, because that is what `sleep()` and the playback clock speak, + * and converts once at the renderer's boundary. + */ +export const DRAWER_TRANSITION_SECONDS = 0.26; + +/** + * The drawer's own movement, which the renderer performs. + * + * The contextual band is four rows as an input and fourteen as a drawer, and it + * is bottom-anchored, so both its height and its top edge change when a + * suspension opens. Declaring the transition is all the harness does; Clay + * interpolates the geometry and reports `animating` until it arrives. + */ +const DRAWER_TRANSITION = { + duration: DRAWER_TRANSITION_SECONDS, + easing: "easeInOut", + properties: ["height", "y"], +} as const; + +/** + * What the renderer is told about focus. + * + * It is handed the answer rather than asked to work one out: `focus.ts` derives + * the registry and the map every frame, and drawing is not a place where a + * second opinion about where focus is may be formed. + */ +export interface FocusView { + /** The name of the node the tree reports as focused. */ + readonly here: string; + /** The live tree, walked and numbered. Nothing here is a second registry. */ + readonly map: readonly OverlayEntry[]; + readonly overlay: boolean; +} + +/** The glyph a focused region wears, so focus survives a monochrome terminal. */ +const FOCUS_GLYPH = "\u258c"; + +/** The glyph beside a focused control or field. */ +const FOCUS_MARK = "\u25b8"; + +/** Where the region carrying one identity was composed, if it is on screen. */ +function regionRect(layout: Layout, identity: string): Rect | undefined { + if (identity === "region:sessions") { + return layout.sidebar; + } + if (identity === "region:transcript") { + return layout.transcript; + } + if (identity === "region:bindings") { + return layout.bindings; + } + if (identity === "region:input") { + return layout.contextual; + } + if (identity === "region:history") { + return layout.footer; + } + return undefined; +} + +function focusMarkerOps(layout: Layout, focus: FocusView | undefined): Op[] { + if (focus === undefined) { + return []; + } + const rect = regionRect(layout, focus.here); + if (rect === undefined) { + return []; + } + return [ + open("focus-marker", { + layout: { width: fixed(1), height: fixed(1) }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + }), + text(FOCUS_GLYPH, { color: C.focus }), + close(), + ]; +} + +/** The word the footer draws for a transport control, keyed by its identity. */ +const TRANSPORT_WORDS: Record = { + "control:transport.pause": ["Pause"], + "control:transport.continue": ["Continue"], + "control:transport.return-head": ["Return to paused head", "Return"], + "control:transport.fork": ["Fork from here", "Fork"], +}; + +/** + * The numbered focus map, as a legend rather than as floating callouts. + * + * The study numbers its targets on top of the interface, which a browser can do + * because it measured them. In cells the honest equivalent is a legend: the + * same numbers, in the same order, with a disabled target dimmed and present — + * study frame 12 numbers a dimmed `Continue` and says Tab skips it, so the map + * has to show what the ring does not. + */ +function focusMapRegion(layout: Layout, focus: FocusView): Op[] { + const ordered = focus.map; + const width = Math.min(34, Math.max(18, Math.round(layout.cols * 0.24))); + const height = Math.min(layout.rows, ordered.length + 2); + const rect = { x: Math.max(0, layout.cols - width - 1), y: 1, width, height }; + const lines: VisualLine[] = [label("FOCUS MAP · F1")]; + for (const target of ordered) { + const on = target.id === focus.here; + lines.push({ + segments: [ + { text: on ? `${FOCUS_MARK} ` : " ", color: C.focus, width: 2 }, + { text: `${target.number}`, color: target.enabled ? C.out : C.dim, width: 3 }, + { text: target.label, color: target.enabled ? C.src : C.dim }, + ], + }); + } + return region("focus-map", rect, lines, { bg: BG.drawer }); +} + +function blank(): VisualLine { + return { segments: [{ text: "" }] }; +} + +function plain(value: string, color = C.src): VisualLine { + return { segments: [{ text: value, color }] }; +} + +function label(value: string): VisualLine { + return plain(value, C.label); +} + +/** + * The transcript's rows, already wrapped and railed. + * + * A rail runs from a component's opening boundary to its matching close, so a + * reader can see which nested scope a line belongs to. That is why this returns + * visual lines rather than the semantic rows: the window that scrolls them has + * to count what the terminal will actually show. + */ +export function transcriptLines(entry: Entry, width: number, collapse = true): VisualLine[] { + const lines: VisualLine[] = []; + const openDepths: number[] = []; + const wordColumn = width >= 56 ? 8 : 0; + + const prefixFor = (depth: number, exclude?: number): string => { + let prefix = ""; + for (let level = 0; level < depth; level += 1) { + const railed = openDepths.includes(level) && level !== exclude; + prefix += railed ? "│ " : " "; + } + return prefix; + }; + + const push = (row: TranscriptRow) => { + if (row.kind === "lifecycle") { + const style = PHASE[row.phase]; + const prefix = prefixFor(row.depth, row.close ? row.depth : undefined); + const head = `${prefix}${style.glyph} `; + const body = width - head.length - wordColumn; + lines.push({ + segments: [ + { text: head, color: style.color, width: head.length }, + // Depth already indents the line, so the source's own leading spaces + // would indent it twice. + { text: fit(row.source.trimStart(), Math.max(0, body)), color: C.src }, + ...(wordColumn > 0 ? [{ text: style.word, color: style.color, width: wordColumn }] : []), + ], + }); + if (row.pair !== undefined && !row.close) { + openDepths.push(row.depth); + } + if (row.pair !== undefined && row.close) { + const at = openDepths.lastIndexOf(row.depth); + if (at >= 0) { + openDepths.splice(at, 1); + } + } + return; + } + if (row.kind === "section") { + const prefix = prefixFor(row.depth); + const glyph = row.state === "collapsed" ? "✓" : "▾"; + const color = row.state === "collapsed" ? C.settledText : C.intro; + const summary = + collapse && row.state === "collapsed" ? `${row.name} · ${row.published}` : row.name; + lines.push({ + segments: [ + { + text: `${prefix}${glyph} `, + color: row.state === "collapsed" ? C.tick : C.intro, + width: prefix.length + 2, + }, + { text: summary, color }, + ], + }); + return; + } + if (row.kind === "fence") { + const prefix = prefixFor(row.depth); + const inner = width - prefix.length - 2; + const border = (content: string, color: number) => { + lines.push({ + segments: [ + { text: `${prefix}│ `, color: C.rule, width: prefix.length + 2 }, + { text: fit(content, Math.max(0, inner)), color }, + ], + }); + }; + border(row.label, C.label); + for (const fenced of row.lines) { + border(fenced, fenced.startsWith("#") ? C.intro : C.src); + } + if (row.caption !== undefined) { + border(row.caption, C.dim); + } + return; + } + const prefix = prefixFor(row.depth); + const color = + row.emphasis === "dim" + ? C.dim + : row.emphasis === "title" + ? C.out + : row.emphasis === "strong" + ? C.hold + : C.src; + for (const wrapped of wrapText(row.text, Math.max(1, width - prefix.length))) { + lines.push({ + segments: [ + { text: prefix, width: prefix.length }, + { text: wrapped, color }, + ], + }); + } + }; + + for (const row of entry.rows) { + push(row); + } + return lines; +} + +function entryHeader(entry: Entry): VisualLine { + const running = entry.state === "running"; + return { + segments: [ + { text: entry.id, color: C.out, width: entry.id.length + 2 }, + { + text: running ? `● running · ${entry.elapsed}s` : `✓ completed · ${entry.elapsed}s`, + color: running ? C.active : C.tick, + width: 24, + }, + { text: entry.scopeNote, color: C.dim }, + ], + }; +} + +function transcriptRegion( + fixture: Fixture, + view: View, + rect: Rect, + mutation?: Mutation, + motion?: Motion, +): Op[] { + const width = Math.max(0, rect.width - 2); + const lines: VisualLine[] = []; + if (!fixture.entry) { + lines.push(label("TRANSCRIPT"), blank(), plain("No executions yet.", C.dim), blank()); + for (const wrapped of wrapText( + "Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the bindings it published.", + width, + )) { + lines.push(plain(wrapped, C.dim)); + } + return region("transcript", rect, lines, { bg: BG.center }); + } + + lines.push(entryHeader(fixture.entry), blank()); + const body = transcriptLines(fixture.entry, width); + const capacity = Math.max(0, rect.height - lines.length); + const windowed = + mutation === "clip-long-transcript" + ? body + : body.slice(view.anchor, view.anchor + Math.max(0, capacity - 1)); + + // While a playback runs, the target's transcript arrives a few rows at a + // time. This is the application's own interpolation: the renderer is not + // animating anything here, and when the motion settles every row is present. + const arriving = motion !== undefined && !motion.done; + if (arriving) { + const shown = Math.max(1, Math.ceil(motion.reveal * windowed.length)); + lines.push(...windowed.slice(0, shown)); + lines.push(plain("…", C.dim)); + return region("transcript", rect, lines, { bg: BG.center }); + } + + lines.push(...windowed); + if (mutation !== "clip-long-transcript") { + const remaining = body.length - view.anchor - windowed.length; + if (remaining > 0) { + lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); + } else if (view.anchor > 0) { + lines.push(plain(`▴ ${view.anchor} earlier lines · ↑ scrolls back`, C.dim)); + } + } + return region("transcript", rect, lines, { bg: BG.center }); +} + +function sidebarRegion(fixture: Fixture, view: View, layout: Layout, rect: Rect): Op[] { + const lines: VisualLine[] = []; + const tabs = fixture.sidebar.tab; + if (!layout.dense && layout.profile === "wide") { + lines.push(plain("XMD REPL", C.out), blank()); + } + lines.push( + { + segments: [ + { text: "SESSION", color: tabs === "sessions" ? C.intro : C.label, width: 10 }, + { text: "JOURNAL", color: tabs === "journal" ? C.intro : C.label, width: 10 }, + { text: "STATE", color: tabs === "state" ? C.intro : C.label, width: 8 }, + ], + }, + blank(), + ); + + if (fixture.sidebar.heading !== undefined) { + lines.push(label(fixture.sidebar.heading)); + } + if (fixture.sidebar.subheading !== undefined && !layout.dense) { + lines.push(plain(fixture.sidebar.subheading, C.dim)); + } + lines.push(blank()); + + if (tabs === "journal") { + const selected = view.checkpoint; + fixture.history.checkpoints.forEach((point, index) => { + const on = index === selected; + const later = selected >= 0 && index > selected; + lines.push({ + segments: [ + { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, + { + text: point.kind === "entry" ? "◆" : "●", + color: on ? C.gold : later ? C.dim : C.active, + width: 2, + }, + { text: point.label, color: on ? C.out : later ? C.dim : C.src }, + ], + }); + }); + lines.push(blank(), plain("▸ 26 internal records", C.dim)); + const chosen = fixture.history.checkpoints[selected]; + if (chosen) { + lines.push( + blank(), + label("SELECTED CHECKPOINT"), + plain(chosen.label, C.out), + plain(`${clock(chosen.at)} elapsed · ${chosen.scope}`, C.dim), + ); + for (const record of chosen.records) { + lines.push(plain(`· ${record}`, C.dim)); + } + } + return region("sidebar", rect, lines, { bg: BG.side }); + } + + if (fixture.sessions.length === 0) { + for (const placeholder of fixture.sidebar.placeholder ?? []) { + for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region("sidebar", rect, lines, { bg: BG.side }); + } + + for (const session of fixture.sessions) { + lines.push({ + segments: [ + { text: session.selected ? "│ " : " ", color: C.active, width: 2 }, + { text: session.id, color: session.selected ? C.out : C.name }, + ], + }); + lines.push({ + segments: [ + { text: " ", width: 2 }, + { + text: session.label, + color: + session.state === "active" ? C.active : session.state === "completed" ? C.tick : C.dim, + width: 14, + }, + { text: layout.dense ? session.agent : `${session.agent} · ${session.turn}`, color: C.dim }, + ], + }); + if (session.note !== undefined && !layout.dense) { + lines.push(plain(` ${session.note}`, C.dim)); + } + lines.push(blank()); + } + return region("sidebar", rect, lines, { bg: BG.side }); +} + +function bindingsRegion(fixture: Fixture, layout: Layout, rect: Rect): Op[] { + const lines: VisualLine[] = [ + label("BINDINGS"), + plain(fixture.bindings.scopeName, C.dim), + blank(), + ]; + if (fixture.bindings.bindings.length === 0) { + for (const placeholder of fixture.bindings.placeholder ?? []) { + for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region("bindings", rect, lines, { bg: BG.bind }); + } + for (const binding of fixture.bindings.bindings) { + lines.push(plain(binding.name, C.name)); + if (binding.note !== undefined && !layout.dense) { + lines.push(plain(binding.note, C.dim)); + } + for (const value of binding.lines) { + lines.push(plain(value, C.settledText)); + } + lines.push(blank()); + } + return region("bindings", rect, lines, { bg: BG.bind }); +} + +function contextualRegion( + fixture: Fixture, + view: View, + layout: Layout, + rect: Rect, + focus?: FocusView, +): Op[] { + const width = Math.max(0, rect.width - 2); + // With nothing to say about focus the drawer is drawn exactly as #838 drew + // it, which is what keeps a frame that is not about focus byte-identical. + const mark = (id: string): string => + focus === undefined ? "" : focus.here === id ? `${FOCUS_MARK} ` : " "; + if (fixture.drawer && view.drawerOpen) { + const drawer = fixture.drawer; + const lines: VisualLine[] = [plain(drawer.heading, C.hold)]; + // Which suspended request this is answering is never dropped: a drawer + // without its origin is a form with no idea what it belongs to. + for (const wrapped of wrapText(drawer.origin, width)) { + lines.push(plain(wrapped, C.dim)); + } + lines.push(blank()); + if (drawer.kind === "project") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); + } + lines.push(blank()); + for (const field of drawer.fields) { + const id = `field:drawer.project.${field.label === "Project name" ? "name" : "description"}`; + lines.push(label(`${mark(id)}${field.label}`)); + lines.push({ + segments: [ + { text: "┃ ", color: C.rule, width: 2 }, + { text: field.value, color: C.out }, + ], + }); + } + lines.push(blank(), { + segments: [ + { text: drawer.validation, color: C.dim, width: Math.min(width, 20) }, + { text: `${mark("control:drawer.project.submit")}${drawer.submit}`, color: C.tick }, + ], + }); + if (!layout.dense) { + lines.push(blank(), label(`${mark("control:drawer.project.schema")}schema`)); + for (const schema of drawer.schema) { + lines.push(plain(schema, C.settledText)); + } + } + } + if (drawer.kind === "review") { + lines.push(plain(`${mark("control:drawer.review.scroll")}${drawer.plan[0] ?? ""}`, C.src)); + for (const planLine of drawer.plan.slice(1)) { + lines.push(plain(planLine, C.src)); + } + lines.push(plain(drawer.more, C.dim), blank()); + const decided = ["approve", "request", "stop"]; + drawer.decisions.forEach((decision, index) => { + lines.push({ + segments: [ + { + text: decision.chosen ? "(•) " : "( ) ", + color: decision.chosen ? C.tick : C.label, + width: 4, + }, + { + text: `${mark(`control:drawer.review.${decided[index] ?? index}`)}${decision.label}`, + color: decision.chosen ? C.out : C.src, + }, + ], + }); + }); + lines.push(blank(), plain(`${mark("control:drawer.review.submit")}${drawer.submit}`, C.tick)); + } + if (drawer.kind === "confirm") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); + } + drawer.preview.forEach((preview, index) => { + lines.push({ + segments: [ + { text: "│ ", color: C.rule, width: 2 }, + { + text: index === 0 ? `${mark("control:drawer.confirm.preview")}${preview}` : preview, + color: C.src, + }, + ], + }); + }); + lines.push(blank(), { + segments: drawer.actions.map((action) => ({ + text: `[ ${mark(`control:drawer.confirm.${action.label.toLowerCase()}`)}${action.label} ]`, + color: action.primary ? C.tick : C.label, + width: action.label.length + 6 + (focus === undefined ? 0 : 2), + })), + }); + lines.push(plain(drawer.hint, C.dim)); + } + return region("contextual", rect, lines, { bg: BG.drawer, transition: DRAWER_TRANSITION }); + } + + const input = fixture.input; + const lines: VisualLine[] = [ + { + segments: [ + { text: input.label, color: C.label, width: Math.min(width, 18) }, + { text: input.hint, color: input.runEnabled ? C.dim : C.hold }, + { + text: input.runEnabled ? "[ Run ⌘⏎ ]" : "[ Run ]", + color: input.runEnabled ? C.tick : C.dim, + width: 12, + }, + ], + }, + plain(input.placeholder ?? "", C.settledText), + ]; + return region("contextual", rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); +} + +function clock(seconds: number): string { + const minutes = Math.floor(Math.max(0, seconds) / 60); + const rest = Math.floor(Math.max(0, seconds) % 60); + return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; +} + +export interface Transport { + readonly word: string; + readonly color: number; + readonly controls: readonly string[]; +} + +function transportFor(fixture: Fixture, dense: boolean): Transport { + const mode = fixture.history.transport; + if (mode === "live") { + return { word: "LIVE", color: C.active, controls: ["Pause"] }; + } + if (mode === "paused") { + return { + word: "PAUSED", + color: C.dim, + controls: ["Continue", dense ? "Return" : "Return to paused head"], + }; + } + if (mode === "inspecting") { + return { + word: dense ? "INSPECTING" : "INSPECTING HISTORY", + color: C.gold, + controls: [ + "Continue", + dense ? "Return" : "Return to paused head", + dense ? "Fork" : "Fork from here", + ], + }; + } + return { word: "IDLE", color: C.dim, controls: ["Pause"] }; +} + +/** What one column of the band is carrying. */ +export interface Notch { + readonly column: number; + readonly checkpoints: readonly Checkpoint[]; +} + +/** + * Which checkpoints share which column. + * + * Markers that would land on the same column are gathered rather than drawn + * over one another, because a marker drawn over its neighbour is a checkpoint + * the band is silently not showing. The gathered count is what the band draws, + * and the scrubber still steps through every checkpoint behind it. + */ +export function notchLayout( + history: Fixture["history"], + trackLeft: number, + trackWidth: number, + mutation?: Mutation, +): Notch[] { + if (mutation === "clip-long-transcript") { + return history.checkpoints.map((checkpoint) => ({ + column: columnFor(checkpoint.at, history, trackLeft, trackWidth), + checkpoints: [checkpoint], + })); + } + const columns = new Map(); + for (const checkpoint of history.checkpoints) { + const column = columnFor(checkpoint.at, history, trackLeft, trackWidth); + const bucket = columns.get(column) ?? []; + bucket.push(checkpoint); + columns.set(column, bucket); + } + return [...columns.entries()] + .map(([column, checkpoints]) => ({ column, checkpoints })) + .toSorted((one, other) => one.column - other.column); +} + +export function columnFor( + at: number, + history: Fixture["history"], + trackLeft: number, + trackWidth: number, +): number { + const span = 72; + const end = Math.max(history.headAt, 6); + const start = Math.max(0, end - span); + const ratio = (at - start) / Math.max(1, end - start); + return trackLeft + Math.min(trackWidth - 1, Math.max(0, Math.round(ratio * (trackWidth - 1)))); +} + +export interface BandGeometry { + readonly transport: Transport; + readonly right: string; + readonly inner: number; + readonly labelWidth: number; + readonly trackLeft: number; + readonly trackWidth: number; +} + +/** + * How much of the band the track actually gets. + * + * The study's own rule is that the track yields room to exactly the transport + * controls that are visible in that mode, so inspecting history — which shows + * three controls — leaves a much shorter track than running does. The head's + * label sits just right of the head, so it is reserved too. + */ +export function bandGeometry(fixture: Fixture, layout: Layout, rect: Rect): BandGeometry { + const transport = transportFor(fixture, layout.dense || layout.profile === "narrow"); + const controls = transport.controls.map((control) => `[ ${control} ]`).join(" "); + const right = `${transport.word} ${controls}`; + const inner = Math.max(0, rect.width - 2); + // A narrow band spends its columns on the track instead of on a label the + // surface bar above it already carries. + const labelWidth = + layout.profile === "narrow" ? 10 : Math.min(Math.max(18, Math.round(rect.width * 0.12)), 26); + const headLabelRoom = fixture.history.transport === "live" ? 8 : 15; + const rightReserve = Math.min(inner - labelWidth - 4, [...right].length + 2 + headLabelRoom); + return { + transport, + right, + inner, + labelWidth, + trackLeft: labelWidth, + trackWidth: Math.max(1, inner - labelWidth - rightReserve), + }; +} + +/** + * How tall the notch for one scope depth is. + * + * Height carries depth and nothing else: the shallowest scope gets the whole + * band and each level in takes one row less. Four depths are what four rows can + * spell, so anything deeper shares the shortest notch and says so with a glyph. + */ +export function notchHeightForDepth(depth: number): number { + return Math.max(1, 4 - Math.min(depth, 3)); +} + +/** The band's rows: four a notch can reach, and the label row below them. */ +export const BAND_ROWS = [0, 1, 2, 3, 4] as const; + +/** Where the track runs, and where a notch of depth 3 sits. */ +export const TRACK_ROW = 3; + +/** The row the selection's label owns, which no notch reaches. */ +export const NOTE_ROW = 4; + +/** True where a depth is deeper than the band has heights for. */ +export function isDeeperThanBand(depth: number): boolean { + return depth > 3; +} + +/** + * The Execution History band. + * + * A notch's height is its scope depth — depth 0 fills all four rows, depth 3 + * takes the track row alone — because that is the settled meaning of notch + * height. Everything else about a marker is said some other way: the playhead is + * its own full-height stem with a label, a selection is gold with a caret under + * it, an entry boundary is `◆` where an ordinary event is `●`, and a column + * holding several checkpoints shows how many. + */ +function footerRegion( + fixture: Fixture, + view: View, + layout: Layout, + rect: Rect, + mutation?: Mutation, + motion?: Motion, + focus?: FocusView, +): Op[] { + const history = fixture.history; + // While a playback runs, the head is where the application says it is; the + // recorded head is where it will be when the motion settles. + const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; + const flat = mutation === "flatten-notches"; + const geometry = bandGeometry(fixture, layout, rect); + const { transport, inner, labelWidth, trackLeft, trackWidth } = geometry; + // The marker replaces the space inside the bracket rather than widening it: + // the track's room is computed from this string, and a focused control that + // shortened the track would make focus a layout decision. + const right = markTransport(geometry.right, focus); + + const grid: string[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => " ")); + const colors: number[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => C.dim)); + + const put = (row: number, column: number, glyph: string, color: number) => { + if (row < 0 || row >= BAND_ROWS.length || column < 0 || column >= inner) { + return; + } + grid[row][column] = glyph; + colors[row][column] = color; + }; + + const putText = (row: number, column: number, value: string, color: number) => { + [...value].forEach((glyph, offset) => { + put(row, column + offset, glyph, color); + }); + }; + + const hasHistory = history.checkpoints.length > 0; + const selectedCheckpoint = history.checkpoints[view.checkpoint]; + + // Text goes down before the markers do, so a notch or a caret always wins the + // column it belongs in rather than being written over by a label. + const compact = labelWidth < 18; + putText(0, 0, fit(compact ? "HISTORY" : "EXECUTION HISTORY", labelWidth - 1), C.label); + putText( + 1, + 0, + fit( + hasHistory + ? compact + ? history.elapsed + : `recorded · ${history.elapsed}` + : "No recorded execution yet", + labelWidth - 1, + ), + C.dim, + ); + putText(2, 0, fit(fixture.entry ? fixture.entry.id : "", labelWidth - 1), C.dim); + putText(0, Math.max(0, inner - [...right].length), right, transport.color); + + if (hasHistory) { + const headColumn = columnFor(headAt, history, trackLeft, trackWidth); + const note = + selectedCheckpoint !== undefined + ? `▲ ${clock(selectedCheckpoint.at)} · snapped · ${( + history.headAt - selectedCheckpoint.at + ).toFixed(1)}s before head` + : history.compressed + ? history.compressed.note + : "notch height is scope depth · digits mark coalesced checkpoints"; + const anchor = + selectedCheckpoint !== undefined + ? columnFor(selectedCheckpoint.at, history, trackLeft, trackWidth) + : history.compressed + ? columnFor(history.compressed.at, history, trackLeft, trackWidth) + : trackLeft; + // The label row is the band's fifth, which no notch reaches, so the note + // can sit under the marker it describes without shortening it. + const noteColumn = Math.max(0, Math.min(anchor, inner - [...note].length)); + putText(NOTE_ROW, noteColumn, note, selectedCheckpoint !== undefined ? C.gold : C.dim); + } + + if (hasHistory) { + const headColumn = columnFor(headAt, history, trackLeft, trackWidth); + for (let column = trackLeft; column <= headColumn && column < inner; column += 1) { + put(TRACK_ROW, column, "─", C.rule); + } + + const selected = selectedCheckpoint; + const notches = notchLayout(history, trackLeft, trackWidth, mutation); + + for (const notch of notches) { + const boundary = notch.checkpoints.some((checkpoint) => checkpoint.kind === "entry"); + const deepest = Math.max(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const later = + selected !== undefined && + notch.checkpoints.every((checkpoint) => checkpoint.at > selected.at); + const color = later ? C.dim : boundary ? C.out : C.active; + const coalesced = notch.checkpoints.length > 1; + // The shallowest scope in the column owns the notch's height, so a + // coalesced column never hides the outermost thing that happened there. + const shallowest = Math.min(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const chosen = + selected !== undefined && + notch.checkpoints.some((checkpoint) => checkpoint.at === selected.at); + const glyph = coalesced + ? notch.checkpoints.length < 10 + ? String(notch.checkpoints.length) + : "+" + : boundary + ? "◆" + : isDeeperThanBand(deepest) + ? "·" + : "●"; + const notchColor = chosen ? C.gold : color; + put(TRACK_ROW, notch.column, glyph, notchColor); + // The notch rises from the track row, one row per level out, so its + // height is the depth and nothing else about it is. + const height = flat ? 1 : notchHeightForDepth(shallowest); + for (let row = TRACK_ROW - 1; row > TRACK_ROW - height; row -= 1) { + put(row, notch.column, "│", notchColor); + } + } + + if (history.compressed) { + put(TRACK_ROW, columnFor(history.compressed.at, history, trackLeft, trackWidth), "≈", C.hold); + } + + // The playhead is not a notch and does not borrow a notch's meaning: it is + // a heavier stem over the notch rows, and it carries its own label. + const headColor = history.transport === "live" ? C.active : C.dim; + for (const row of flat ? [TRACK_ROW] : [0, 1, 2, 3]) { + put(row, headColumn, "┃", headColor); + } + + // The head's label goes down last. A moving head passes over notches, and + // what a person needs to read there is where the head is, not the stem of + // a marker it happens to be beside. + const headLabel = + history.transport === "live" + ? "LIVE" + : history.transport === "idle" + ? "SETTLED" + : "PAUSED HEAD"; + if (headColumn + 2 + headLabel.length < inner - [...right].length) { + putText(0, headColumn + 2, headLabel, headColor); + putText(1, headColumn + 2, clock(headAt), C.dim); + } + } + + const lines: VisualLine[] = BAND_ROWS.map((row) => ({ + segments: runsOf(grid[row], colors[row]), + })); + + // Narrow routing gives the band a whole screen. The extra rows carry the + // checkpoint list, so a marker the band had to coalesce is still readable — + // summarizing the track is only honest if the detail is somewhere. + if (rect.height > 6) { + lines.push(blank(), label("CHECKPOINTS")); + const room = rect.height - lines.length; + const listed = history.checkpoints.slice(0, Math.max(0, room - 1)); + listed.forEach((point, index) => { + const on = index === view.checkpoint; + lines.push({ + segments: [ + { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, + { + text: point.kind === "entry" ? "◆" : isDeeperThanBand(point.depth) ? "·" : "●", + color: on ? C.gold : C.active, + width: 2, + }, + { text: point.label, color: on ? C.out : C.src }, + { text: point.scope, color: C.dim, width: Math.min(28, Math.max(0, rect.width - 40)) }, + ], + }); + }); + const hidden = history.checkpoints.length - listed.length; + if (hidden > 0) { + lines.push(plain(`▸ ${hidden} more checkpoints · ←/→ moves through every one`, C.dim)); + } + } + + return region("footer", rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); +} + +/** `[ Continue ]` becomes `[▸Continue ]` — the same width, one glyph louder. */ +function markTransport(right: string, focus: FocusView | undefined): string { + if (focus === undefined) { + return right; + } + for (const word of TRANSPORT_WORDS[focus.here] ?? []) { + const bracketed = `[ ${word} ]`; + if (right.includes(bracketed)) { + return right.replace(bracketed, `[${FOCUS_MARK}${word} ]`); + } + } + return right; +} + +/** Keep each cell's colour when a grid row becomes segments. */ +function runsOf(glyphs: readonly string[], colors: readonly number[]): Segment[] { + const segments: Segment[] = []; + let run = ""; + let color = colors[0] ?? C.dim; + glyphs.forEach((glyph, index) => { + const at = colors[index] ?? C.dim; + if (at !== color && run !== "") { + segments.push({ text: run, color, width: [...run].length }); + run = ""; + } + color = at; + run += glyph; + }); + if (run !== "") { + segments.push({ text: run, color, width: [...run].length }); + } + return segments; +} + +function headerRegion(fixture: Fixture, rect: Rect): Op[] { + const lines: VisualLine[] = [ + { + segments: [ + { text: fixture.crumb, color: C.label }, + ...(fixture.badge === undefined + ? [] + : [{ text: fixture.badge, color: C.gold, width: [...fixture.badge].length + 2 }]), + ], + }, + blank(), + ]; + return region("header", rect, lines, { bg: BG.center }); +} + +function surfaceBarRegion(fixture: Fixture, view: View, rect: Rect): Op[] { + const names = { + sessions: "SESSIONS", + transcript: "TRANSCRIPT", + bindings: "BINDINGS", + history: "EXECUTION HISTORY", + }; + const at = ["sessions", "transcript", "bindings", "history"].indexOf(view.surface) + 1; + return region( + "surface-bar", + rect, + [ + { + segments: [ + { text: `${names[view.surface]} · ${at} / 4`, color: C.intro, width: 27 }, + { + text: fixture.badge ?? fixture.crumb, + color: fixture.badge === undefined ? C.dim : C.gold, + }, + { text: "Tab ▸", color: C.label, width: 7 }, + ], + }, + ], + { bg: BG.side }, + ); +} + +function tooSmallRegion(layout: Layout): Op[] { + const lines: VisualLine[] = [ + plain("Terminal too small", C.out), + plain( + `${MINIMUM.cols} × ${MINIMUM.rows} required · ${layout.cols} × ${layout.rows} now`, + C.hold, + ), + plain("resize to continue", C.dim), + ]; + return region("too-small", layout.screen, lines, { + bg: BG.app, + padding: { left: 1, right: 1, top: 1 }, + }); +} + +export interface ScreenRequest { + readonly fixture: Fixture; + readonly view: View; + readonly layout: Layout; + readonly mutation?: Mutation; + /** Present only while a playback is running between two fixtures. */ + readonly motion?: Motion; + /** Where focus is, and what the overlay would number. */ + readonly focus?: FocusView; +} + +export function renderScreen(request: ScreenRequest): Op[] { + const { fixture, view, layout, mutation, motion, focus } = request; + const ops: Op[] = [ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: BG.app }), + ]; + + if (layout.profile === "too-small") { + ops.push(...tooSmallRegion(layout), close()); + return ops; + } + + if (layout.surfaceBar) { + ops.push(...surfaceBarRegion(fixture, view, layout.surfaceBar)); + } + if (layout.header) { + ops.push(...headerRegion(fixture, layout.header)); + } + if (layout.sidebar) { + ops.push(...sidebarRegion(fixture, view, layout, layout.sidebar)); + } + if (layout.transcript) { + ops.push(...transcriptRegion(fixture, view, layout.transcript, mutation, motion)); + } + if (layout.bindings) { + ops.push(...bindingsRegion(fixture, layout, layout.bindings)); + } + const covering = + mutation === "drawer-covers-footer" && layout.footer !== undefined && view.drawerOpen; + if (layout.contextual && !covering) { + ops.push(...contextualRegion(fixture, view, layout, layout.contextual, focus)); + } + if (layout.footer) { + ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation, motion, focus)); + } + if (layout.contextual && covering) { + // Drawn last, so it lands on top of the band the study says is never + // covered — which is the point of this control. + ops.push( + ...contextualRegion( + fixture, + view, + layout, + { ...layout.contextual, height: layout.contextual.height + layout.footer!.height }, + focus, + ), + ); + } + for (const [index, separator] of layout.separators.entries()) { + ops.push(...rule(`rule.${index}`, separator, separator.width === 1 ? "│" : "─")); + } + // Focus is drawn last, over the regions it describes, because a marker under + // the thing it marks is a marker nobody sees. + ops.push(...focusMarkerOps(layout, focus)); + if (focus?.overlay === true) { + ops.push(...focusMapRegion(layout, focus)); + } + ops.push(close()); + return ops; +} diff --git a/scripts/repl-study/route.ts b/scripts/repl-study/route.ts new file mode 100644 index 000000000..78da80296 --- /dev/null +++ b/scripts/repl-study/route.ts @@ -0,0 +1,226 @@ +/** + * Where you are, said as one URL. + * + * A REPL that cannot be reopened has no location, only a pile of fields. This + * module is the whole of what "location" means here: the execution, the surface + * that owns focus, the entry and scopes you have opened inside it, the drawers + * stacked on top, the recorded marker you are inspecting, and the draft you have + * typed but not run. Scroll offsets, the phase of an animation and which target + * is focused right now are deliberately not in it — they can be thrown away + * without changing what the REPL means. + * + * xmd://repl/e1/transcript/entry-1/document/+project?at=cp-07&inspect&draft=%3CPlan%3E + * + * Selecting a recorded marker and opening the reconstruction at it are two + * different things, so they are two different parts of the URL. `at` is the + * marker the scrubber has selected; `inspect` says the reconstruction is open. + * A selection that lived only in memory could not be reopened, and a URL that + * could not tell the two apart would render one state and hydrate into another. + * + * Parsing refuses rather than guesses, because a URL that quietly lost a drawer + * would reopen a suspended execution as if nothing were waiting. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +/** + * The five regions a route can name. + * + * These are not `layout.ts`'s `SURFACES`. That list is the four regions narrow + * routing promotes to a whole screen; the REPL input is never one of those + * because narrow already renders it inside the transcript. It is still a place + * focus can be, so it is a route surface and not a layout surface. + */ +export const ROUTE_SURFACES = ["sessions", "transcript", "bindings", "input", "history"] as const; + +export type RouteSurface = (typeof ROUTE_SURFACES)[number]; + +export function isRouteSurface(value: string): value is RouteSurface { + return (ROUTE_SURFACES as readonly string[]).includes(value); +} + +export interface Route { + readonly execution: string; + readonly surface: RouteSurface; + /** The entry, then the visible scopes opened inside it. */ + readonly scopes: readonly string[]; + /** The drawer stack. The last one is the top, and only the top is interactive. */ + readonly drawers: readonly string[]; + /** The recorded marker the scrubber has selected. Absent means none is. */ + readonly at?: string; + /** True while the reconstruction at `at` is open rather than merely selected. */ + readonly inspect: boolean; + /** What has been typed and not run. Empty is the same as nothing typed. */ + readonly draft: string; +} + +/** The authority is `repl`, because this URL addresses a REPL and not a document. */ +const PREFIX = "xmd://repl/"; + +/** A drawer segment wears this, so a drawer is never mistaken for a scope. */ +const DRAWER_PREFIX = "+"; + +export function formatRoute(route: Route): string { + const path = [ + encodeURIComponent(route.execution), + route.surface, + ...route.scopes.map((scope) => encodeURIComponent(scope)), + ...route.drawers.map((drawer) => `${DRAWER_PREFIX}${encodeURIComponent(drawer)}`), + ].join("/"); + const query: string[] = []; + if (route.at !== undefined) { + query.push(`at=${encodeURIComponent(route.at)}`); + } + if (route.inspect) { + // Valueless, and the only spelling of it, so `inspect` cannot arrive in two + // forms that render the same screen. + query.push("inspect"); + } + if (route.draft !== "") { + query.push(`draft=${encodeURIComponent(route.draft)}`); + } + return query.length === 0 ? `${PREFIX}${path}` : `${PREFIX}${path}?${query.join("&")}`; +} + +/** + * One URL, parsed, or the reason it was refused. + * + * Percent-decoding is `decodeURIComponent` alone: `+` is a literal plus here, + * which is what lets a drawer segment wear one. + */ +export function parseRoute(url: string): Result { + if (!url.startsWith(PREFIX)) { + return Err( + new Error(`a REPL route starts with ${PREFIX}, and ${JSON.stringify(url)} does not`), + ); + } + const rest = url.slice(PREFIX.length); + const split = rest.indexOf("?"); + const path = split === -1 ? rest : rest.slice(0, split); + const query = split === -1 ? "" : rest.slice(split + 1); + const segments = path.split("/"); + if (segments.length < 2) { + return Err(new Error(`${JSON.stringify(url)} names no surface`)); + } + const execution = decodeURIComponent(segments[0]); + if (execution === "") { + return Err(new Error(`${JSON.stringify(url)} names no execution`)); + } + const surface = segments[1]; + if (!isRouteSurface(surface)) { + return Err( + new Error( + `${JSON.stringify(surface)} is not a surface; the surfaces are ${ROUTE_SURFACES.join(", ")}`, + ), + ); + } + const scopes: string[] = []; + const drawers: string[] = []; + for (const segment of segments.slice(2)) { + if (segment === "") { + return Err(new Error(`${JSON.stringify(url)} has an empty path segment`)); + } + if (segment.startsWith(DRAWER_PREFIX)) { + drawers.push(decodeURIComponent(segment.slice(DRAWER_PREFIX.length))); + continue; + } + if (drawers.length > 0) { + return Err( + new Error(`${JSON.stringify(segment)} is a scope below a drawer, which cannot be reopened`), + ); + } + scopes.push(decodeURIComponent(segment)); + } + + let at: string | undefined; + let inspect = false; + let draft = ""; + for (const pair of query === "" ? [] : query.split("&")) { + const equals = pair.indexOf("="); + const key = equals === -1 ? pair : pair.slice(0, equals); + const value = equals === -1 ? "" : decodeURIComponent(pair.slice(equals + 1)); + if (key === "at") { + if (value === "") { + return Err(new Error("at= names no marker; leave it out to select none")); + } + at = value; + continue; + } + if (key === "inspect") { + if (equals !== -1) { + return Err(new Error("inspect takes no value; it is present or it is not")); + } + inspect = true; + continue; + } + if (key === "draft") { + draft = value; + continue; + } + return Err(new Error(`${JSON.stringify(key)} is not part of a REPL route`)); + } + if (inspect && at === undefined) { + return Err(new Error("inspect needs the marker it reconstructs; add at=")); + } + + return Ok({ execution, surface, scopes, drawers, at, inspect, draft }); +} + +/** The top drawer, which is the only one that is visible and interactive. */ +export function topDrawer(route: Route): string | undefined { + return route.drawers[route.drawers.length - 1]; +} + +/** True while a recorded moment is reconstructed rather than merely selected. */ +export function inspecting(route: Route): boolean { + return route.inspect; +} + +/** + * The kinds of move a route can make. + * + * Naming them is what lets push and replace be a decision rather than a habit. + */ +export type RouteChange = + | "surface" + | "focus" + | "locus" + | "drawer" + | "inspection" + | "scrub" + | "draft"; + +export type Navigation = "push" | "replace"; + +/** + * Whether a change adds a navigation entry or overwrites the current one. + * + * Scrubbing and draft editing replace, and both are continuous adjustments + * rather than places you went: Back from an inspected marker returns to the + * head rather than walking back through every marker the scrubber passed. + */ +export function navigationFor(change: RouteChange): Navigation { + return change === "scrub" || change === "draft" ? "replace" : "push"; +} + +/** + * The surface a focus identity belongs to. + * + * The surface segment says which region owns focus, so moving focus across a + * region boundary *is* moving the route. A control belongs to the surface of + * the region that owns it, which is why focusing `Pause` reads as `history` + * and focusing `Run` reads as `input`. + */ +export function surfaceFor(identity: string): RouteSurface | undefined { + const region = identity.startsWith("region:") + ? identity.slice("region:".length) + : identity.startsWith("control:transport.") + ? "history" + : identity.startsWith("control:input.") + ? "input" + : identity.startsWith("control:drawer.") || identity.startsWith("field:drawer.") + ? "transcript" + : undefined; + return region !== undefined && isRouteSurface(region) ? region : undefined; +} diff --git a/scripts/repl-study/screen.ts b/scripts/repl-study/screen.ts new file mode 100644 index 000000000..b1a0bd241 --- /dev/null +++ b/scripts/repl-study/screen.ts @@ -0,0 +1,138 @@ +/** + * The terminal's own memory, so a frame can be read back. + * + * `@bomb.sh/tty` emits only the cells that changed since the previous frame, + * which is what makes it cheap and also what makes staleness invisible: a + * renderer that forgot to redraw something emits nothing for it, and nothing is + * indistinguishable from correct until you look at the screen. Applying the + * bytes to a grid here is how the evidence looks at the screen. + * + * The vocabulary is deliberately tiny — cursor addressing, colour, and text are + * everything 0.9.0 emits — and anything else is refused rather than ignored, so + * a future version that starts scrolling or erasing cannot slip past unnoticed. + */ + +export class UnsupportedSequenceError extends Error { + readonly sequence: string; + + constructor(sequence: string) { + super(`the harness does not model the terminal sequence ${JSON.stringify(sequence)}`); + this.name = "UnsupportedSequenceError"; + this.sequence = sequence; + } +} + +export interface Grid { + readonly cols: number; + readonly rows: number; + readonly cells: string[][]; + row: number; + column: number; + /** + * Glyphs addressed at cells this terminal does not have. + * + * A real terminal does not discard them: it clamps or wraps them, and what + * the person sees is corruption. Counting them is how a renderer still + * drawing at the previous size is caught. + */ + overflow: number; +} + +export function createGrid(cols: number, rows: number): Grid { + return { + cols, + rows, + cells: Array.from({ length: rows }, () => Array.from({ length: cols }, () => " ")), + row: 0, + column: 0, + overflow: 0, + }; +} + +// The escape byte is the thing being parsed here, so matching a control +// character is the point rather than an accident. +// oxlint-disable-next-line no-control-regex +const CSI = /^\u001b\[([0-9;]*)([A-Za-z])/; + +export function applyAnsi(grid: Grid, bytes: Uint8Array): Grid { + const stream = new TextDecoder().decode(bytes); + let at = 0; + while (at < stream.length) { + const glyph = stream[at]; + if (glyph === "\u001b") { + const match = CSI.exec(stream.slice(at)); + if (!match) { + throw new UnsupportedSequenceError(stream.slice(at, at + 8)); + } + const [sequence, parameters, final] = match; + if (final === "H") { + const [row, column] = parameters.split(";"); + grid.row = (Number(row) || 1) - 1; + grid.column = (Number(column) || 1) - 1; + if (grid.row >= grid.rows || grid.column >= grid.cols) { + grid.overflow += 1; + } + } else if (final !== "m") { + throw new UnsupportedSequenceError(sequence); + } + at += sequence.length; + continue; + } + if (glyph === "\n") { + grid.row += 1; + grid.column = 0; + at += 1; + continue; + } + if (grid.row >= 0 && grid.row < grid.rows && grid.column >= 0 && grid.column < grid.cols) { + grid.cells[grid.row][grid.column] = glyph; + } else if (glyph !== " ") { + grid.overflow += 1; + } + grid.column += 1; + at += 1; + } + return grid; +} + +/** + * The part of a larger grid a smaller terminal can still see. + * + * After a terminal shrinks, whatever sits beyond its new edges is out of view + * and no longer anyone's business. What is inside those edges is, which is the + * region a stale cell would be found in. + */ +export function viewport(grid: Grid, cols: number, rows: number): Grid { + return { + cols, + rows, + cells: Array.from({ length: rows }, (_unused, row) => + Array.from({ length: cols }, (_also, column) => grid.cells[row]?.[column] ?? " "), + ), + row: 0, + column: 0, + overflow: grid.overflow, + }; +} + +/** The grid as a person reads it: one line per row, trailing blanks removed. */ +export function gridText(grid: Grid): string { + return grid.cells.map((row) => row.join("").replace(/ +$/, "")).join("\n"); +} + +/** Where two grids differ, named by cell, for a failure that has to be readable. */ +export function gridDifferences(one: Grid, other: Grid): string[] { + const differences: string[] = []; + const rows = Math.max(one.rows, other.rows); + const cols = Math.max(one.cols, other.cols); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < cols; column += 1) { + const left = one.cells[row]?.[column] ?? ""; + const right = other.cells[row]?.[column] ?? ""; + if (left !== right) { + differences.push(`${row},${column}: ${JSON.stringify(left)} ≠ ${JSON.stringify(right)}`); + } + } + } + return differences; +} diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts new file mode 100644 index 000000000..ed8813102 --- /dev/null +++ b/scripts/repl-study/store.ts @@ -0,0 +1,723 @@ +/** + * One place that holds where the person is. + * + * #838 kept location in four loose fields on a `View`, mutated by a reducer that + * read keys directly, and that is the defect #839 exists to remove: nothing + * could be reopened, because nothing had been said. Here the REPL has exactly + * three kinds of state, and telling them apart is what makes every acceptance + * criterion reachable. + * + * **Execution truth** belongs to the journal — `journal.ts` for this experiment, + * #842 for the real one. **Location** is the URL in `route.ts`, and nothing else + * is location. **Everything else is disposable**: the scroll anchor, the + * scrubber's selection, whether the overlay is on, which target is focused right + * now. Throwing the disposable half away and rebuilding from the durable half is + * `hydrate()`, and `projection()` is what two states are then compared by. + */ + +import { fixture, drawerOf, isDrawerKind } from "./fixtures.ts"; +import type { DrawerKind } from "./fixtures.ts"; +import { fold, JOURNAL, journalThrough, siblingsOf } from "./journal.ts"; +import type { JournalFixture, JournalRecord, Moment } from "./journal.ts"; +import { layoutFor, SURFACES } from "./layout.ts"; +import type { Layout, SurfaceName } from "./layout.ts"; +import type { FixtureName, Fixture, TransportMode } from "./model.ts"; +import type { Mutation } from "./mutations.ts"; +import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; +import type { Route, RouteChange, RouteSurface } from "./route.ts"; + +/** + * What the renderer reads. + * + * Every field is derived from the state below it. It exists because the + * renderer clips rather than scrolls, so the window over a long transcript and + * the selected marker have to be told to it in its own terms — not because the + * harness keeps a second copy of where the person is. + */ +export interface View { + readonly fixture: FixtureName; + /** Index of the first visible transcript line. */ + readonly anchor: number; + /** Index into the fixture's checkpoints, or -1 for "following the head". */ + readonly checkpoint: number; + readonly surface: SurfaceName; + readonly drawerOpen: boolean; +} + +export function initialView(subject: Fixture): View { + const selected = subject.history.selectedAt; + const checkpoint = + selected === undefined + ? -1 + : subject.history.checkpoints.findIndex((point) => point.at === selected); + return { + fixture: subject.name, + anchor: 0, + checkpoint, + surface: "transcript", + drawerOpen: subject.drawer !== undefined, + }; +} + +export function scrollBy(view: View, delta: number, limit: number): View { + const anchor = Math.max(0, Math.min(limit, view.anchor + delta)); + return anchor === view.anchor ? view : { ...view, anchor }; +} + +export function moveSurface(view: View, delta: number): View { + const at = SURFACES.indexOf(view.surface); + const next = SURFACES[(at + delta + SURFACES.length) % SURFACES.length]; + return { ...view, surface: next }; +} + +/** + * The four layout surfaces are not the five route surfaces. + * + * Narrow routing promotes one region to the whole screen, and the REPL input is + * never one of those because `layout.ts` already renders it inside the + * transcript. It is still somewhere focus can be, so it is a route surface; + * mapping it here is the whole of the reconciliation. + */ +export function surfaceOf(route: Route): SurfaceName { + return route.surface === "input" ? "transcript" : route.surface; +} + +export interface ReplState { + /** Parsed from the URL: the durable half, and the only thing reopening needs. */ + readonly route: Route; + /** Execution truth, as it stands. A fixture here; #842 owns the real one. */ + readonly journal: JournalFixture; + /** The fold of that journal at this route, minted with them and never alone. */ + readonly moment: Moment; + /** Disposable: the transcript window. */ + readonly anchor: number; + /** + * Which recorded marker the scrubber is on, or -1 for none. + * + * Derived from `route.at`, never set on its own. A selection that lived only + * in memory would render a state the URL could not reopen. + */ + readonly selection: number; + /** Disposable: whether the F1 focus map is drawn. */ + readonly overlay: boolean; + /** Which identity opened each drawer, so closing one can restore it. */ + readonly invokers: Readonly>; + /** The navigation stack, for Back. Entries are URLs. */ + readonly history: readonly string[]; + /** How many times a running entry has been interrupted, which never exits. */ + readonly interrupts: number; + readonly quit: boolean; +} + +function mint( + route: Route, + journal: JournalFixture, + rest: Omit, +): ReplState { + return { + route, + journal, + // Selecting a marker and reconstructing it are different things: the fold + // follows the head until `inspect` says the reconstruction is open. + moment: fold(journal, route.inspect ? route.at : undefined), + selection: + route.at === undefined ? -1 : journal.findIndex((record) => record.marker === route.at), + ...rest, + }; +} + +/** Rebuild everything durable from a URL and a journal, with nothing else. */ +export function hydrate(url: string, journal: JournalFixture, mutation?: Mutation): ReplState { + const parsed = parseRoute(url); + if (!parsed.ok) { + throw parsed.error; + } + return hydrateRoute(parsed.value, journal, mutation); +} + +export function hydrateRoute( + route: Route, + journal: JournalFixture, + mutation?: Mutation, +): ReplState { + const rebuilt = mint(route, journal, { + anchor: 0, + overlay: false, + invokers: {}, + history: [], + interrupts: 0, + quit: false, + }); + // The control throws the selection away on the way back in, which is what a + // selection kept outside the URL would have done on every cold start. + return mutation === "drop-selection-on-hydrate" ? { ...rebuilt, selection: -1 } : rebuilt; +} + +/** The semantic projection two states are compared by. Nothing disposable is in it. */ +export interface Projection { + readonly url: string; + readonly surface: RouteSurface; + readonly scopes: readonly string[]; + readonly drawers: readonly string[]; + readonly at?: string; + readonly inspect: boolean; + readonly draft: string; + readonly head?: string; + readonly transport: TransportMode; + readonly scope: string; + readonly published: readonly string[]; + readonly suspension?: DrawerKind; + readonly entry: Moment["entry"]; + /** The marker the scrubber has selected, and what was true there. */ + readonly selected?: string; + readonly selectedScope?: string; + readonly selectedPublished?: readonly string[]; +} + +export function projection(state: ReplState): Projection { + // The selected marker is folded for itself, so the projection carries the + // scope and the bindings a cold start has to come back with — not just the + // marker's name. + const selected = + state.selection < 0 ? undefined : fold(state.journal, state.journal[state.selection].marker); + return { + url: formatRoute(state.route), + surface: state.route.surface, + scopes: state.route.scopes, + drawers: state.route.drawers, + at: state.route.at, + inspect: state.route.inspect, + draft: state.route.draft, + head: state.journal[state.journal.length - 1]?.marker, + transport: state.moment.transport, + scope: state.moment.scope, + published: state.moment.published, + suspension: state.moment.suspension, + entry: state.moment.entry, + selected: selected?.marker, + selectedScope: selected?.scope, + selectedPublished: selected?.published, + }; +} + +export interface Size { + readonly cols: number; + readonly rows: number; +} + +export function layoutOf(state: ReplState, size: Size, mutation?: Mutation): Layout { + return layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: topDrawer(state.route) !== undefined, + surface: surfaceOf(state.route), + mutation, + }); +} + +/** + * The fixture this state is showing, with the journal's own answers on it. + * + * The moment decides the transport, whether what is on screen is a + * reconstruction, and which drawer is open — so `--route` and `--frame` really + * do drive the picture, rather than picking a fixture and hoping. + */ +export function fixtureFor(state: ReplState): Fixture { + const base = fixture(state.moment.shows); + const top = topDrawer(state.route); + const drawer = top !== undefined && isDrawerKind(top) ? drawerOf(top) : undefined; + const inspecting = state.route.inspect; + const selected = state.journal[state.selection]?.at; + return { + ...base, + badge: inspecting ? base.badge : undefined, + readOnly: inspecting, + drawer, + history: { + ...base.history, + transport: state.moment.transport, + // Selecting a marker marks the band; reconstructing it also reads + // read-only and carries the badge. Both show the same marker. + selectedAt: selected, + }, + }; +} + +export function viewOf(state: ReplState): View { + const subject = fixtureFor(state); + const selectedAt = subject.history.selectedAt; + return { + fixture: subject.name, + anchor: state.anchor, + checkpoint: + selectedAt === undefined + ? -1 + : subject.history.checkpoints.findIndex((point) => point.at === selectedAt), + surface: surfaceOf(state.route), + drawerOpen: subject.drawer !== undefined, + }; +} + +/** + * Go somewhere, and decide whether that is a place you can come Back from. + * + * A push records the URL being left. A replace does not, which is what keeps + * Back from an inspected marker returning to the head instead of walking back + * through every marker the scrubber passed. + */ +function go(state: ReplState, route: Route, change: RouteChange, mutation?: Mutation): ReplState { + const navigation = + mutation === "push-draft-edits" && change === "draft" ? "push" : navigationFor(change); + return mint(route, state.journal, { + anchor: state.anchor, + overlay: state.overlay, + invokers: state.invokers, + history: navigation === "push" ? [...state.history, formatRoute(state.route)] : state.history, + interrupts: state.interrupts, + quit: state.quit, + }); +} + +function withJournal(state: ReplState, journal: JournalFixture): ReplState { + return mint(state.route, journal, { + anchor: state.anchor, + overlay: state.overlay, + invokers: state.invokers, + history: state.history, + interrupts: state.interrupts, + quit: state.quit, + }); +} + +/** The journal as it stands once the execution records its next `kind`. */ +function extendTo(state: ReplState, kind: JournalRecord["kind"]): ReplState { + const from = state.journal.length; + const next = JOURNAL.slice(from).find((record) => record.kind === kind); + if (next === undefined) { + return state; + } + return withJournal(state, journalThrough(next.marker)); +} + +/** + * Take the route to wherever focus now is. + * + * Focus itself is the tree's and never appears here. What the route records is + * the *surface* focus landed in, and the caller reads that off the tree — this + * does not work it out from an identity string. A focus move that crosses a + * region boundary is a move the URL has to make too, in the same transition. + */ +export function followFocus( + state: ReplState, + surface: RouteSurface | undefined, + change: RouteChange = "focus", + mutation?: Mutation, +): ReplState { + if ( + surface === undefined || + surface === state.route.surface || + mutation === "keep-route-on-focus" + ) { + return state; + } + return go(state, { ...state.route, surface }, change, mutation); +} + +export type HarnessEvent = + /** Whatever the decoder produced. It is parsed here, never assumed. */ + | { readonly kind: "key"; readonly event: unknown } + | { readonly kind: "resize"; readonly cols: number; readonly rows: number } + | { readonly kind: "tick"; readonly advanceMs: number } + | { readonly kind: "background"; readonly record: JournalRecord } + | { readonly kind: "quit" }; + +export interface ReduceContext { + readonly size: Size; + readonly mutation?: Mutation; + /** How many transcript lines the window may scroll past. */ + readonly scrollLimit: number; + /** + * The identity the tree reports as focused. + * + * Supplied rather than stored: the tree owns focus, and a reducer that kept + * its own copy would be the second focus model this rework removes. + */ + readonly focused: string; +} + +/** + * What one event did, and what the tree should do about focus. + * + * Traversal and restoration are the tree's to perform — it is the thing that + * knows what exists. The reducer decides *what should happen*, names it, and + * lets the tree carry it out, so neither side keeps a second answer. + */ +export type FocusIntent = + | { readonly kind: "advance" } + | { readonly kind: "retreat" } + | { readonly kind: "to"; readonly identity: string } + | { readonly kind: "owner" }; + +export interface Reduction { + readonly state: ReplState; + readonly focus?: FocusIntent; +} + +export interface Key { + readonly type: string; + readonly code?: string; + readonly text?: string; + readonly ctrl?: boolean; + readonly shift?: boolean; + readonly alt?: boolean; +} + +/** A decoded key, read rather than assumed: the decoder's shape is its own. */ +export function asKey(event: unknown): Key { + const record = typeof event === "object" && event !== null ? { ...event } : {}; + const read = (name: string): string | undefined => { + const value = Reflect.get(record, name); + return typeof value === "string" ? value : undefined; + }; + const flag = (name: string): boolean => Reflect.get(record, name) === true; + return { + type: read("type") ?? "", + code: read("code"), + text: read("text"), + ctrl: flag("ctrl"), + shift: flag("shift"), + alt: flag("alt"), + }; +} + +/** True where typing has to reach the target rather than the navigator. */ +function editable(identity: string): boolean { + return identity === "region:input" || identity.startsWith("field:"); +} + +/** + * Reverse traversal, as a real terminal spells it. + * + * A terminal sends Shift+Tab as `ESC [ Z`, which this decoder reports as the + * key code `Backtab` carrying no shift flag. A reducer that tested `Tab` with + * `shift` was testing an event only a test had ever produced. + */ +function reverseTab(key: Key, mutation?: Mutation): boolean { + if (key.code === "Tab" && key.shift === true) { + return true; + } + return key.code === "Backtab" && mutation !== "ignore-backtab"; +} + +/** A recorded moment is read-only, so nothing that changes the run may happen in one. */ +function frozen(state: ReplState, mutation?: Mutation): boolean { + return state.route.inspect && mutation !== "mutate-while-inspecting"; +} + +/** + * How one event changes where you are. + * + * Pure, so every transition the interactive harness performs can be driven from + * a test without a terminal — and so that a background update can be shown to + * return state whose route, focus, selection and anchor are the *same + * references* it was handed. + */ +export function reduce(state: ReplState, event: HarnessEvent, context: ReduceContext): Reduction { + const { mutation } = context; + const only = (next: ReplState): Reduction => ({ state: next }); + + if (event.kind === "quit") { + return only({ ...state, quit: true }); + } + if (event.kind === "tick" || event.kind === "resize") { + // A frame passing and a terminal resizing change what is drawn, never where + // you are. The route survives a resize because the profile was never + // recorded in it. + if (event.kind === "resize" && mutation === "drop-route-on-resize") { + return only( + hydrateRoute( + { + execution: state.route.execution, + surface: "transcript", + scopes: [], + drawers: [], + inspect: false, + draft: "", + }, + state.journal, + ), + ); + } + return only(state); + } + if (event.kind === "background") { + // Nothing here touches focus at all — it is not in this model to touch, + // which is the strongest form the "no stealing" claim can take. The control + // has to reach past the store, into the tree, to break it. + return only(withJournal(state, [...state.journal, event.record])); + } + + const key = asKey(event.event); + if (key.type !== "keydown") { + return only(state); + } + const here = context.focused; + + if (key.code === "q" && !editable(here)) { + return only({ ...state, quit: true }); + } + if (key.ctrl === true && key.code === "c") { + // An entry that is paused, or being looked at through a reconstruction, is + // still running. Exiting instead of interrupting it would hand its + // lifecycle to whoever closed the terminal. + const active = state.moment.entry === "running" && mutation !== "exit-on-paused-interrupt"; + if (active) { + return only({ ...state, interrupts: state.interrupts + 1 }); + } + if (state.route.draft !== "") { + return only(go(state, { ...state.route, draft: "" }, "draft", mutation)); + } + return only({ ...state, quit: true }); + } + if (key.code === "F1") { + return only({ ...state, overlay: !state.overlay }); + } + if (key.code === "Tab" || key.code === "Backtab") { + // Traversal is the tree's: it is the thing that knows what exists now. + return { state, focus: reverseTab(key, mutation) ? { kind: "retreat" } : { kind: "advance" } }; + } + if (key.code === "Escape") { + return back(state, here, context.size, mutation); + } + if (key.code === "Enter") { + return only(activate(state, here, mutation)); + } + + const digit = Number(key.code); + if (!editable(here) && Number.isInteger(digit) && digit >= 1 && digit <= 5) { + const surface = (["sessions", "transcript", "bindings", "input", "history"] as const)[ + digit - 1 + ]; + return { + state: followFocus(state, surface, "surface", mutation), + focus: { kind: "to", identity: `region:${surface}` }, + }; + } + + if ( + key.ctrl === true && + key.code !== undefined && + key.code.startsWith("Arrow") && + // Structural navigation acts only outside an editable target, so a + // modified arrow is never stolen out of a draft somebody is typing. + !editable(here) + ) { + return only(structural(state, key.code, mutation)); + } + + if (key.code === "ArrowUp") { + return only({ ...state, anchor: Math.max(0, state.anchor - 1) }); + } + if (key.code === "ArrowDown") { + return only({ ...state, anchor: Math.min(context.scrollLimit, state.anchor + 1) }); + } + if (key.code === "PageUp") { + return only({ ...state, anchor: Math.max(0, state.anchor - 10) }); + } + if (key.code === "PageDown") { + return only({ ...state, anchor: Math.min(context.scrollLimit, state.anchor + 10) }); + } + if (key.code === "ArrowLeft" || key.code === "ArrowRight") { + return only(scrub(state, key.code === "ArrowLeft" ? -1 : 1, mutation)); + } + if (key.code === "d" && !editable(here)) { + return toggleDrawer(state, here, mutation); + } + + return only(type(state, key, here, mutation)); +} + +/** + * Back, which never discards the draft and never answers anything. + * + * The order is the study's: a drawer first, then a reconstruction, then a + * control, then the navigation stack. Every step of it is non-destructive, + * which is what lets Escape be the one key a person can always press. + */ +function back(state: ReplState, here: string, size: Size, mutation?: Mutation): Reduction { + void size; + const top = topDrawer(state.route); + if (top !== undefined) { + // The route loses the drawer; the tree removes the branch and restores the + // focus its push remembered. Neither side keeps the other's answer. + const closed = go( + state, + { ...state.route, drawers: state.route.drawers.slice(0, -1) }, + "drawer", + mutation, + ); + return { state: closed }; + } + if (state.route.inspect) { + return { state: go(state, { ...state.route, inspect: false }, "inspection", mutation) }; + } + if (!here.startsWith("region:")) { + // Which region owns this control is the tree's to answer; the route follows + // once the tree has moved, in the same transition. + return { state, focus: { kind: "owner" } }; + } + const previous = state.history[state.history.length - 1]; + if (previous === undefined) { + return { state }; + } + const parsed = parseRoute(previous); + if (!parsed.ok) { + return { state }; + } + return { + state: mint(parsed.value, state.journal, { + anchor: state.anchor, + overlay: state.overlay, + invokers: state.invokers, + history: state.history.slice(0, -1), + interrupts: state.interrupts, + quit: state.quit, + }), + }; +} + +/** Enter: what the focused target does when it is activated. */ +function activate(state: ReplState, here: string, mutation?: Mutation): ReplState { + if (here === "control:transport.pause") { + return frozen(state, mutation) ? state : extendTo(state, "paused"); + } + if (here === "control:transport.continue") { + return frozen(state, mutation) ? state : extendTo(state, "resumed"); + } + if (here === "control:transport.return-head") { + // Closing the reconstruction leaves the selection where it was: returning + // to the head is not the same act as deselecting a marker. + return go(state, { ...state.route, inspect: false }, "inspection", mutation); + } + if (here === "region:history" && state.selection >= 0 && !state.route.inspect) { + // A reconstruction has no live suspension, so the drawer stack does not + // survive into one. That is what makes study frame 12's focus walk real: + // the trapped controls leave the sequence and focus has to resolve to the + // nearest owner that did survive. + return go(state, { ...state.route, inspect: true, drawers: [] }, "inspection", mutation); + } + return state; +} + +/** + * The chronological axis: one semantic marker at a time. + * + * Selection moves and focus does not — the study is explicit that moving the + * selection never moves focus. While a reconstruction is open the selection is + * the reconstruction, so the URL moves with it, and it replaces rather than + * pushes. + */ +function scrub(state: ReplState, delta: number, mutation?: Mutation): ReplState { + const count = state.journal.length; + if (count === 0) { + return state; + } + const from = state.selection === -1 ? count : state.selection; + const selection = Math.max(0, Math.min(count - 1, from + delta)); + // The selection is canonical, so it moves in the URL whether or not the + // reconstruction is open — and it replaces, so Back from a marker returns to + // where you came from rather than walking every marker the scrubber passed. + return go(state, { ...state.route, at: state.journal[selection].marker }, "scrub", mutation); +} + +/** + * The structural axis: the locus, not the timeline. + * + * These move where you are in the execution's own tree, so they push. They act + * only when focus is not in an editable target, which is what keeps a modified + * arrow from being stolen out of a draft somebody is typing. + */ +function structural(state: ReplState, code: string, mutation?: Mutation): ReplState { + const scopes = state.route.scopes; + if (code === "ArrowUp") { + return scopes.length === 0 + ? state + : go(state, { ...state.route, scopes: scopes.slice(0, -1) }, "locus", mutation); + } + if (code === "ArrowDown") { + // The entry is the first segment; the journal's scope stack starts below it. + const children = siblingsOf(state.journal, scopes.slice(1)); + const first = children[0]; + return first === undefined + ? state + : go(state, { ...state.route, scopes: [...scopes, first] }, "locus", mutation); + } + if ((code === "ArrowLeft" || code === "ArrowRight") && mutation !== "inert-sibling-arrows") { + const current = scopes[scopes.length - 1]; + if (scopes.length < 2 || current === undefined) { + return state; + } + const siblings = siblingsOf(state.journal, scopes.slice(1, -1)); + const at = siblings.indexOf(current); + if (at === -1 || siblings.length === 0) { + return state; + } + const delta = code === "ArrowLeft" ? -1 : 1; + const next = siblings[(at + delta + siblings.length) % siblings.length]; + return next === current + ? state + : go(state, { ...state.route, scopes: [...scopes.slice(0, -1), next] }, "locus", mutation); + } + return state; +} + +/** `d` opens the suspension that is waiting, or closes the one that is open. */ +function toggleDrawer(state: ReplState, here: string, mutation?: Mutation): Reduction { + if (topDrawer(state.route) !== undefined) { + return back(state, here, { cols: 0, rows: 0 }, mutation); + } + const waiting = state.moment.suspension; + if (waiting === undefined || frozen(state, mutation)) { + return { state }; + } + return { state: openDrawer(state, waiting, here, mutation) }; +} + +/** + * Open a suspension's drawer. + * + * The route gains it and the invoking identity is remembered. Mounting the + * branch, pushing it as the focus root and seeding focus inside it are the + * tree's — this does not reach across and place focus itself. + */ +export function openDrawer( + state: ReplState, + kind: DrawerKind, + invoker: string, + mutation?: Mutation, +): ReplState { + const opened = go( + state, + { ...state.route, drawers: [...state.route.drawers, kind] }, + "drawer", + mutation, + ); + return { ...opened, invokers: { ...state.invokers, [kind]: invoker } }; +} + +/** Typing edits the draft, which replaces the current URL rather than adding to it. */ +function type(state: ReplState, key: Key, here: string, mutation?: Mutation): ReplState { + if (!editable(here) || frozen(state, mutation)) { + return state; + } + if (key.code === "Backspace") { + return go(state, { ...state.route, draft: state.route.draft.slice(0, -1) }, "draft", mutation); + } + const glyph = key.text ?? (key.code !== undefined && [...key.code].length === 1 ? key.code : ""); + if (glyph === "" || key.ctrl === true || key.alt === true) { + return state; + } + return go(state, { ...state.route, draft: state.route.draft + glyph }, "draft", mutation); +} + +export { JOURNAL, journalThrough }; diff --git a/scripts/repl-study/surfaces.ts b/scripts/repl-study/surfaces.ts new file mode 100644 index 000000000..420595215 --- /dev/null +++ b/scripts/repl-study/surfaces.ts @@ -0,0 +1,73 @@ +/** + * The vocabulary the interface is built from. + * + * Names only: which controls a drawer carries, and in what order. The tree in + * `tree.ts` turns these into nodes, and the renderer draws them. Nothing here + * knows about focus — that is the tree's, and having it in one place is the + * point of this file being this small. + */ + +import type { DrawerKind } from "./fixtures.ts"; + +export interface SurfaceControl { + readonly id: string; + readonly kind: "control" | "field"; + readonly label: string; +} + +/** Each drawer's own sequence, taken from study frames 07, 08 and 09. */ +const DRAWER_TARGETS: Record = { + project: [ + { id: "field:drawer.project.name", kind: "field", label: "Project name" }, + { id: "field:drawer.project.description", kind: "field", label: "Description" }, + { id: "control:drawer.project.schema", kind: "control", label: "Schema disclosure · ⌥S" }, + { id: "control:drawer.project.submit", kind: "control", label: "Submit" }, + ], + review: [ + { id: "control:drawer.review.scroll", kind: "control", label: "Plan review · scroll region" }, + { id: "control:drawer.review.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.review.request", kind: "control", label: "Request changes" }, + { id: "control:drawer.review.stop", kind: "control", label: "Stop" }, + { id: "control:drawer.review.submit", kind: "control", label: "Submit" }, + ], + confirm: [ + { + id: "control:drawer.confirm.preview", + kind: "control", + label: "README preview · scroll region", + }, + { id: "control:drawer.confirm.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.confirm.decline", kind: "control", label: "Decline" }, + ], +}; + +export function drawerTargets(kind: DrawerKind): readonly SurfaceControl[] { + return DRAWER_TARGETS[kind]; +} + +/** What the overlay writes beside a node, keyed by the node's own name. */ +export function labelFor(name: string): string { + for (const targets of Object.values(DRAWER_TARGETS)) { + const found = targets.find((target) => target.id === name); + if (found !== undefined) { + return found.label; + } + } + return REGION_LABELS[name] ?? CONTROL_LABELS[name] ?? name; +} + +const REGION_LABELS: Record = { + "region:sessions": "Sessions", + "region:transcript": "Transcript", + "region:bindings": "Bindings", + "region:input": "REPL input", + "region:history": "Execution History", +}; + +const CONTROL_LABELS: Record = { + "control:input.run": "Run", + "control:transport.pause": "Pause", + "control:transport.continue": "Continue", + "control:transport.return-head": "Return to paused head", + "control:transport.fork": "Fork from here", +}; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts new file mode 100644 index 000000000..9646d579d --- /dev/null +++ b/scripts/repl-study/tree.ts @@ -0,0 +1,427 @@ +/** + * The REPL's interface, as the tree that owns focus. + * + * #839's first attempt kept a flat `FocusTarget[]` beside the interface and + * rebuilt traversal order, ownership and restoration by hand. That is the + * manual-focus problem Freedom exists to remove: a parallel list has to be kept + * in step with the interface by whoever changes the interface, and every + * opening and closing of a panel is another chance to forget. + * + * Here the hierarchy *is* the interface. A surface is a node, a scope panel + * mounted inside it is a branch, a drawer is a branch pushed as the active + * focus root, and a control is a leaf. Traversal order is tree order, computed + * on demand. Closing a panel removes its branch, and its controls and their + * middleware go with it through structured teardown — there is no second list + * to update, because there is no second list. + * + * A control that is visible but disabled is still a node: the renderer draws it + * and the `F1` map numbers it. It is simply never made focusable, so it cannot + * enter the focus chain. That is Freedom's own distinction, not one this + * harness invents. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; + +import { + advance, + current, + focus, + focusable, + focusPush, + retreat, + useFocus, + useRoot, +} from "./vendor/freedom/upstream/index.ts"; +import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; + +import { drawerTargets, labelFor } from "./surfaces.ts"; +import { recordPath } from "./keys.ts"; +import { isDrawerKind } from "./fixtures.ts"; +import type { ReplState } from "./store.ts"; +import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; +import type { RouteSurface } from "./route.ts"; +import type { Mutation } from "./mutations.ts"; + +/** A node's semantic identity is its name, so the tree is readable as evidence. */ +export function identity(node: Node): string { + return node.name; +} + +/** Every node of the tree, in tree order. */ +export function walk(node: Node): Node[] { + const found: Node[] = [node]; + for (const child of node.children) { + found.push(...walk(child)); + } + return found; +} + +/** True where a node may take focus: Freedom marks exactly those. */ +export function isFocusable(node: Node): boolean { + return "focused" in node.props; +} + +export function find(root: Node, name: string): Node | undefined { + return walk(root).find((node) => node.name === name); +} + +/** + * The surface a node belongs to, read by walking up the tree. + * + * The first attempt parsed this out of the identity string. The tree already + * knows, and asking it cannot disagree with where the node actually is. + */ +export function surfaceOwning(node: Node): RouteSurface | undefined { + for (let at: Node | undefined = node; at; at = at.parent) { + const name = at.name; + if (name.startsWith("region:")) { + const region = name.slice("region:".length); + if (isRouteSurface(region)) { + return region; + } + } + } + return undefined; +} + +export interface ReplTree { + readonly root: Root; + /** Bring the interface into line with a state, mounting and removing branches. */ + sync(state: ReplState, mutation?: Mutation): Operation; + /** Where focus is, asked of the tree. */ + focused(): Node; + advance(): void; + retreat(): void; + /** Every node the `F1` overlay numbers, in tree order. */ + map(): Node[]; + /** The focus chain: visible, enabled, and in tree order. */ + chain(): Node[]; +} + +interface Mounted { + readonly node: Node; + readonly pop?: PopFocus; +} + +/** + * Build the interface once, then keep it in step. + * + * Nothing here rebuilds the tree from scratch. A rebuild would destroy every + * node each frame and take focus with it, which is the defect a live tree + * exists to avoid. + */ +export function useReplTree(state: ReplState): Operation { + return { + *[Symbol.iterator]() { + const root = yield* useRoot(); + const regions = new Map(); + for (const region of ROUTE_SURFACES) { + const node = root.node.createChild(`region:${region}`); + focusable(node); + recordPath(node, node.name); + regions.set(region, node); + } + useFocus(root.node); + + let drawers: Mounted[] = []; + let scopes: Node[] = []; + + /** + * Bring one region's controls into line, by name. + * + * Reconciled rather than rebuilt: removing and recreating every control + * on each sync would destroy the node focus is on and take focus with + * it, which is the defect a live tree exists to avoid. + */ + /** + * Bring one region's controls into line, by name. + * + * Two things have to hold at once, and an earlier round held only the + * first. **Surviving nodes keep their identity**, so focus and the + * middleware installed on them survive a sync — that is why this + * reconciles rather than rebuilds. And **the rendered order is + * canonical**, so the tree a live interaction arrives at is the tree a + * cold start rebuilds from the same URL and journal. Replacements are + * appended wherever there is room, so the order is restored explicitly + * rather than left to the order things happened to be created in. + */ + const reconcile = function* ( + parent: Node, + wanted: readonly Control[], + mutation?: Mutation, + ): Operation { + const childrenByName = () => + new Map([...parent.children].map((child) => [child.name, child] as const)); + const shouldFocus = (control: Control): boolean => + control.enabled || mutation === "focus-hidden-target"; + + // Additions come first. Removing the focused control before its + // replacement exists would leave the region with nothing to move focus + // to, and focus would land outside it — which is how a resumed run once + // lost its transport slot. + let present = childrenByName(); + for (const control of wanted) { + if (!present.has(control.name)) { + const node = parent.createChild(control.name); + // A disabled control is a node the renderer draws and the map + // numbers; not making it focusable is the whole of what disables it. + if (shouldFocus(control)) { + focusable(node); + } + } + } + + present = childrenByName(); + for (const [name, child] of present) { + if (!wanted.some((control) => control.name === name)) { + yield* until(child.remove()); + } + } + + // A control whose enabled-ness changed is a different node: making a + // node focusable is one-way, so the honest way to disable one is for it + // to stop being that node and start being another. + present = childrenByName(); + for (const control of wanted) { + const child = present.get(control.name); + if (child === undefined || shouldFocus(control) === isFocusable(child)) { + continue; + } + yield* until(child.remove()); + const replacement = parent.createChild(control.name); + if (shouldFocus(control)) { + focusable(replacement); + } + } + + const order = new Map(wanted.map((control, at) => [control.name, at] as const)); + if (mutation === "append-replacements") { + // Leave the order to however the nodes happened to be created, which + // is what makes a live tree and a rebuilt one disagree. + return; + } + parent.sort((one, other) => (order.get(one.name) ?? 0) - (order.get(other.name) ?? 0)); + }; + + const mountControls = function* (next: ReplState, mutation?: Mutation): Operation { + // The footer is an *explicit* region: its controls join the interface + // only once focus is inside it. The tree asks itself where focus is + // rather than being told, so the two can never disagree. + const within = withinHistory(current(root.node).name); + if (mutation === "rebuild-tree-each-sync") { + // Rebuilding destroys the node focus is on, and takes focus with it. + for (const region of [regions.get("input")!, regions.get("history")!]) { + for (const child of [...region.children]) { + yield* until(child.remove()); + } + } + } + yield* reconcile(regions.get("input")!, runControls(next), mutation); + yield* reconcile(regions.get("history")!, transportControls(next, within), mutation); + }; + + /** + * The locus, as nested panels inside the transcript. + * + * Only the part that actually diverged is removed. Navigating deeper + * keeps the panels already open, and with them whatever focus is inside. + */ + const mountScopes = function* (next: ReplState): Operation { + const wanted = next.route.scopes; + let diverged = 0; + while ( + diverged < Math.min(scopes.length, wanted.length) && + scopes[diverged].name === `panel:${wanted[diverged]}` + ) { + diverged += 1; + } + while (scopes.length > diverged) { + const leaf = scopes.pop()!; + yield* until(leaf.remove()); + } + let parent = scopes[scopes.length - 1] ?? regions.get("transcript")!; + for (const scope of wanted.slice(scopes.length)) { + const node = parent.createChild(`panel:${scope}`); + // A scope panel is a container, not a target: it owns the middleware + // and the lifetime of what is inside it, and the study never focuses + // one. Not making it focusable is the whole of that distinction. + node.set("container", true); + recordPath(node, node.name); + scopes.push(node); + parent = node; + } + }; + + const syncDrawers = function* (next: ReplState, mutation?: Mutation): Operation { + const wanted = next.route.drawers; + let kept = 0; + while ( + kept < Math.min(drawers.length, wanted.length) && + drawers[kept].node.name === `drawer:${wanted[kept]}` + ) { + kept += 1; + } + while (drawers.length > kept) { + const top = drawers.pop()!; + // Pop the focus root first, so the invoking focus is restored while + // the branch still exists, then remove the branch: its controls and + // their middleware go with it. + if (mutation !== "forget-drawer-invoker") { + top.pop?.(); + } + if (mutation !== "keep-closed-branch") { + yield* until(top.node.remove()); + } + } + for (let at = drawers.length; at < wanted.length; at += 1) { + const kind = wanted[at]; + if (!isDrawerKind(kind)) { + continue; + } + const node = root.node.createChild(`drawer:${kind}`); + node.set("container", true); + recordPath(node, node.name); + // The drawer's controls live in a body panel, so a key bound for one + // of them passes through the drawer *and* the panel — which is the + // ancestor path a flat registry has no way to produce. + const body = node.createChild(`panel:${kind}.body`); + body.set("container", true); + recordPath(body, body.name); + for (const target of drawerTargets(kind)) { + const child = body.createChild(target.id); + focusable(child); + } + // The footer stays reachable through a suspension, so it is inside + // the pushed root rather than outside it. + const footer = node.createChild("region:history"); + focusable(footer); + const pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); + drawers.push({ node, pop }); + } + }; + + yield* mountScopes(state); + yield* mountControls(state); + yield* syncDrawers(state); + + const tree: ReplTree = { + root, + *sync(next: ReplState, mutation?: Mutation) { + yield* mountScopes(next); + yield* mountControls(next, mutation); + yield* syncDrawers(next, mutation); + if (mutation === "steal-focus-on-background") { + // Nothing in the honest path writes focus when the world changes + // underneath; this one does. + advance(root.node); + } + }, + focused: () => current(root.node), + advance: () => advance(root.node), + retreat: () => retreat(root.node), + // The overlay numbers targets, not the containers that hold them. + map: () => + walk(activeRoot(root, drawers)).filter( + (node) => node.name !== "" && node.props.container !== true, + ), + chain: () => walk(activeRoot(root, drawers)).filter(isFocusable), + }; + return tree; + }, + }; +} + +/** The subtree traversal is trapped in: the top drawer, or the whole tree. */ +function activeRoot(root: Root, drawers: readonly Mounted[]): Node { + const top = drawers[drawers.length - 1]; + return top?.pop === undefined ? root.node : top.node; +} + +interface Control { + readonly name: string; + readonly enabled: boolean; +} + +/** True while the identity is the footer itself or one of its controls. */ +function withinHistory(identity: string): boolean { + return identity === "region:history" || identity.startsWith("control:transport."); +} + +/** `Run` is listed whenever there is something to run and nothing running. */ +function runControls(state: ReplState): readonly Control[] { + const runnable = state.moment.entry !== "running" && state.route.draft !== ""; + return runnable ? [{ name: "control:input.run", enabled: true }] : []; +} + +/** + * The transport controls the footer exposes. + * + * There is nothing to expose before an execution has been recorded, and + * `Continue` is visible but disabled while a reconstruction is open — the study + * numbers it and says Tab skips it. + */ +function transportControls(state: ReplState, within: boolean): readonly Control[] { + if (state.moment.entry === "none" || !within) { + return []; + } + if (state.moment.transport === "live") { + return [{ name: "control:transport.pause", enabled: true }]; + } + if (state.moment.transport === "paused") { + return [ + { name: "control:transport.continue", enabled: true }, + { name: "control:transport.return-head", enabled: true }, + ]; + } + if (state.moment.transport === "inspecting") { + return [ + { name: "control:transport.continue", enabled: false }, + { name: "control:transport.return-head", enabled: true }, + { name: "control:transport.fork", enabled: true }, + ]; + } + return []; +} + +export { focus, topDrawer }; + +export interface OverlayEntry { + readonly id: string; + readonly label: string; + /** False where the node is drawn and numbered but cannot take focus. */ + readonly enabled: boolean; + readonly number: number; +} + +/** + * The `F1` map, read off the live tree. + * + * There is no ordered registry to consult: the nodes are walked in tree order + * and numbered as the study numbers them — regions first, then the controls. + * Numbering and traversal are deliberately different orders, which is why the + * study's numbers look out of sequence: `Run` belongs to the input and is + * traversed there, but numbered after the last region. + */ +export function overlayOf(tree: ReplTree, mutation?: Mutation): readonly OverlayEntry[] { + if (mutation === "flat-overlay") { + // A list kept beside the interface rather than read off it: it goes on + // numbering the five regions whatever the tree currently holds. + return ROUTE_SURFACES.map((region, at) => ({ + id: `region:${region}`, + label: labelFor(`region:${region}`), + enabled: true, + number: at + 1, + })); + } + const nodes = tree.map(); + const regions = nodes.filter((node) => node.name.startsWith("region:")); + const rest = nodes.filter((node) => !node.name.startsWith("region:")); + const ordered = regions.length === ROUTE_SURFACES.length ? [...regions, ...rest] : nodes; + return ordered.map((node, at) => ({ + id: node.name, + label: labelFor(node.name), + enabled: isFocusable(node), + number: at + 1, + })); +} diff --git a/scripts/repl-study/vendor/freedom/LICENSE b/scripts/repl-study/vendor/freedom/LICENSE new file mode 100644 index 000000000..e72db0ec8 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/LICENSE @@ -0,0 +1,21 @@ +MIT License Copyright (c) 2026-Present [Bombshell contributors](https://bomb.sh/team) + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/scripts/repl-study/vendor/freedom/MANIFEST.json b/scripts/repl-study/vendor/freedom/MANIFEST.json new file mode 100644 index 000000000..91d85ca26 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/MANIFEST.json @@ -0,0 +1,45 @@ +{ + "upstream": { + "repository": "https://github.com/bombshell-dev/playground", + "commit": "8be97e7201cd6effddb2f8b240b4b5166641e7f0", + "branch": "focus-stack", + "directory": "packages/freedom", + "package": "@bomb.sh/freedom", + "version": "0.0.0", + "license": "MIT" + }, + "patches": [ + { + "file": "upstream/lib/root.ts", + "name": "owned-root", + "reason": "createRoot() parents the root scope to Effection global. useRoot() acquires the tree as a resource owned by the calling scope, which is the surface this repository uses; createRoot(owner) is the escape hatch behind it." + }, + { + "file": "upstream/lib/node.ts", + "name": "owned-root", + "reason": "NodeImpl takes the owning scope a parentless root is created under." + }, + { + "file": "upstream/lib/mod.ts", + "name": "owned-root", + "reason": "useRoot() joins the public surface." + }, + { + "file": "upstream/lib/focus.ts", + "name": "containment-removal", + "reason": "useFocus()'s remove middleware compared the removed node with the focused one. Closing a drawer or panel removes the branch above the focused control, which left focus on a node that no longer existed; it now asks whether the branch contains the focused node." + } + ], + "files": { + "LICENSE": "ff5318b9f1cbe44dec39dd306a042a50900ecfaf0d99489beb11b6a567ad21bf", + "upstream/index.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345", + "upstream/lib/dispatch.ts": "c75fbd34bda9786aae58e5539ebc05bd0cf88766771cef55dfb23db55d72e5ec", + "upstream/lib/focus.ts": "efe3da62c51e6d8c204e0eee31a13f7fcce22ace39de2a30512e298162f92f6e", + "upstream/lib/mod.ts": "48ef87d71e22dbf16d480d38d359d79071259c1f476d8c39b92678a13fee7f17", + "upstream/lib/node.ts": "e45e22c0426e8b593ae6cb78be99c78ff6f693ec8ca1102342c6a3ed0687b9a9", + "upstream/lib/root.ts": "38f0891afa7deb87f01c65bf63cbeef73c9482e98007b54d0f7d5a332c3b46fd", + "upstream/lib/state.ts": "6eb73c1f8daa2d560948a52eb7e87ec91b8cd8c47cacdb4ca57e6ef70f1c4e1f", + "upstream/lib/types.ts": "6f1654f4f7855e21167fd62349bf56e461ce61470ba51099382068a296b89e62", + "upstream/lib/validate.ts": "561f3b52c1ffdc91ca1cf976aae09319d66ea982758abb6e79e2c1ad351b9c6b" + } +} diff --git a/scripts/repl-study/vendor/freedom/PROVENANCE.md b/scripts/repl-study/vendor/freedom/PROVENANCE.md new file mode 100644 index 000000000..a29551d0e --- /dev/null +++ b/scripts/repl-study/vendor/freedom/PROVENANCE.md @@ -0,0 +1,74 @@ +# @bomb.sh/freedom, vendored + +`@bomb.sh/freedom` is `private: true`, version `0.0.0`, and unpublished, so it +cannot be installed. Its source is vendored here from the public playground +repository at the commit `MANIFEST.json` pins. + +```text +https://github.com/bombshell-dev/playground +8be97e7201cd6effddb2f8b240b4b5166641e7f0 (branch focus-stack) +packages/freedom +``` + +## It runs on this repository's Effection + +Freedom's own manifest asks for `effection@4.1.0-alpha.9`; this repository pins +`4.1.0`. That mattered more than a version number usually does — two Effection +copies would mean two scope trees and two context systems, and Freedom's +central claim, that its node tree and the Effection scope tree correspond, +would have been false against *our* tree. + +The sources run on `4.1.0` unmodified. The surface they use is +`createContext`, `createScope`, `createSignal`, `createQueue`, `createApi` from +`effection/experimental`, and `scope.set/get/expect/around/run` — all present +and unchanged. Nothing about the dependency layout moves for this experiment: +no lockfile entry, no second Effection. + +## Two patches, and why they are here rather than upstream + +Both are recorded in `MANIFEST.json` and reported upstream. They are behaviour +changes to a dependency, taken deliberately. + +**`owned-root`.** `createRoot()` parents the root scope to Effection `global`. +A globally-parented root means host context does not reach the tree, a failure +in node work raises into a boundary nobody observes, and the caller owns the +tree only by remembering to destroy it. `useRoot()` acquires the tree as a +resource owned by the acquiring scope, which makes all three structural. This +is the fix this repository's earlier Freedom evaluation identified and settled +on; the pinned branch carries the focus work but not that fix. + +**`containment-removal`.** `useFocus()` installs a `remove` middleware that +moves focus to a successor first, but it asked whether the *removed node* was +the focused one. A drawer or a panel is closed by removing the branch above the +focused control, so the common case fell through: focus was left on a node that +had just been destroyed, while a perfectly good sibling survived. + +```text +before removing the focused node → focus moves to the survivor +before removing its branch → focus left on nothing +after removing its branch → focus moves to the survivor +``` + +The predicate now asks whether the branch contains the focused node. + +## How it is held still + +`MANIFEST.json` records the upstream commit, both patches with their reasons, +and a SHA-256 for every vendored file; `scripts/tests/repl-focus.test.ts` +checks the bytes against it, so an unrecorded edit fails. + +The snapshot is excluded from `oxfmt` (`.oxfmtrc.json`) and from `oxlint` +(`.oxlintrc.json`) for the same reason the acpx and Cloudflare DOFS snapshots +are: reformatting upstream's bytes would break the identity the manifest holds +them to. The exclusion is deliberately **not** on the lint task's command line +in `package.json` — editing that manifest invalidates `deno.lock`, and the +build and publication suites that run `deno install --frozen` fail on it. + +## What is not vendored + +`@bomb.sh/input` pins `@bomb.sh/tty ^0.8.0`, and this repository pins `0.9.0` +exactly under a repository-wide frozen lockfile. The part this experiment needs +is its targeting rule — dispatch a key to the focused node's scope, so the +node's ancestors form the middleware path — which is implemented directly in +`scripts/repl-study/keys.ts`. `packages/input/src/lib/input.ts` at the pinned +commit is the reference it follows. diff --git a/scripts/repl-study/vendor/freedom/upstream/index.ts b/scripts/repl-study/vendor/freedom/upstream/index.ts new file mode 100644 index 000000000..945209dd1 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/index.ts @@ -0,0 +1 @@ +export * from "./lib/mod.ts"; diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts b/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts new file mode 100644 index 000000000..d14286df6 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts @@ -0,0 +1,24 @@ +// oxlint-disable require-yield +import type { Api, Operation, Result } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./types.ts"; +import { TreeContext } from "./state.ts"; + +export interface Dispatch { + dispatch(event: unknown): Operation>; + getNodeById(id: string): Operation; +} + +export const DispatchApi: Api = createApi( + "freedom:dispatch", + { + *dispatch(_event: unknown): Operation> { + return { ok: false, error: new Error("unhandled") }; + }, + + *getNodeById(id: string): Operation { + const tree = yield* TreeContext.expect(); + return tree.nodes.get(id); + }, + }, +); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts b/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts new file mode 100644 index 000000000..3dad1a15c --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts @@ -0,0 +1,228 @@ +// oxlint-disable bombshell-dev/no-generic-error + +//TODO: export as freedom/focus +import { createContext } from "effection"; +import type { Node } from "./types.ts"; +import { NodeApi } from "./node.ts"; + +// A pushed focus root: the boundary cycling is trapped within, the focus to +// restore, and the callback to notify when it is popped (§12). +interface FocusEntry { + node: Node; + restore: Node | undefined; + callback?: (value: unknown) => void; +} + +export type PopFocus = (value?: unknown) => void; + +// The focus stack lives on the tree root's scope, not module state (§12, FS2). +const FocusStackContext = createContext("freedom:focus-stack"); + +function findRoot(node: Node): Node { + let n = node; + while (n.parent) { + n = n.parent; + } + return n; +} + +// The tree's focus stack, lazily created on the root scope on first use. +function focusStack(node: Node): FocusEntry[] { + const root = findRoot(node); + let stack = root.scope.get(FocusStackContext); + if (!stack) { + stack = []; + root.scope.set(FocusStackContext, stack); + } + return stack; +} + +// The active focus root: the top of the stack, or the tree root (§12.3). +function focusRoot(node: Node): Node { + const stack = focusStack(node); + return stack.length > 0 ? stack[stack.length - 1].node : findRoot(node); +} + +function focusChain(node: Node): Node[] { + const result: Node[] = []; + if ("focused" in node.props) { + result.push(node); + } + for (const child of node.children) { + result.push(...focusChain(child)); + } + return result; +} + +function successorOf(node: Node): Node | undefined { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return undefined; + } + const idx = nodes.indexOf(node); + if (idx === -1) { + return undefined; + } + return nodes[(idx + 1) % nodes.length]; +} + +// TODO nename -> setFocusable +export function focusable(node: Node): void { + if (!("focused" in node.props)) { + node.set("focused", false); + } +} + +// TODO: rename -> getCurrentFocus() +export function current(node: Node): Node { + const root = focusRoot(node); + return focusChain(root).find((n) => n.props.focused === true) ?? root; +} + +export function advance(node: Node): void { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx + 1) % nodes.length].set("focused", true); +} + +export function retreat(node: Node): void { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx - 1 + nodes.length) % nodes.length].set("focused", true); +} + +export function focus(target: Node): void { + if (!("focused" in target.props)) { + throw new Error("Cannot focus a non-focusable node"); + } + if (!focusChain(focusRoot(target)).includes(target)) { + throw new Error("Cannot focus a node outside the active focus root"); + } + if (target.props.focused === true) { + return; + } + const old = focusChain(findRoot(target)).find((n) => n.props.focused === true); + if (old) { + old.set("focused", false); + } + target.set("focused", true); +} + +// XMD patch: does this subtree hold the node that currently has focus? +// Removal has to ask about containment, not identity — a drawer or panel is +// closed by removing the branch *above* the focused control, and a check that +// compared the removed node with the focused one left focus on a node that no +// longer exists. +function holdsFocus(branch: Node, focused: Node | undefined): boolean { + if (focused === undefined) { + return false; + } + for (let at: Node | undefined = focused; at; at = at.parent) { + if (at === branch) { + return true; + } + } + return false; +} + +// The next focusable outside the branch being removed, in tree order. +function successorOutside(branch: Node): Node | undefined { + const nodes = focusChain(focusRoot(branch)); + return nodes.find((candidate) => !holdsFocus(branch, candidate)); +} + +export function useFocus(node: Node): void { + const first = focusChain(node).find((n) => n !== node); + if (first) { + focus(first); + } + node.scope.around(NodeApi, { + remove([target], next) { + const focused = focusChain(findRoot(target)).find( + (n) => n.props.focused === true, + ); + if (target.props.focused === true) { + const successor = successorOf(target); + if (successor && successor !== target) { + focus(successor); + } + } else if (holdsFocus(target, focused)) { + const successor = successorOutside(target); + if (successor) { + focus(successor); + } else if (focused) { + focused.set("focused", false); + } + } + return next(target); + }, + }); +} + +// Push `node` as the active focus root: cycling is trapped within its focusable +// descendants (§12.4). Returns the bound pop (§12.5). +export function focusPush( + node: Node, + callback?: (value: unknown) => void, +): PopFocus { + const stack = focusStack(node); + const restore = focusChain(focusRoot(node)).find( + (n) => n.props.focused === true, + ); + const entry: FocusEntry = { node, restore, callback }; + stack.push(entry); + + const first = focusChain(node).find((n) => n !== node); + if (first) { + focus(first); // seed inside; clears the pre-push focus (FS6) + } else if (restore) { + restore.set("focused", false); // empty container: nothing focused (FS6) + } + + return (value?: unknown) => { + if (stack[stack.length - 1] !== entry) { + throw new Error("focus pop out of order (unbalanced push/pop)"); + } + stack.pop(); + restoreFocus(node, restore); + if (callback) { + callback(value); + } + }; +} + +// Restore focus after a pop: the remembered node if still valid, else the first +// focusable descendant of the now-active root, else clear any residual focus. +function restoreFocus(node: Node, restore: Node | undefined): void { + const root = focusRoot(node); + const chain = focusChain(root); + if (restore && chain.includes(restore)) { + focus(restore); + } else { + const first = chain.find((n) => n !== root); + if (first) { + focus(first); + } else { + const residual = focusChain(findRoot(node)).find( + (n) => n.props.focused === true, + ); + if (residual) { + residual.set("focused", false); + } + } + } +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts b/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts new file mode 100644 index 000000000..c9ac90e05 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts @@ -0,0 +1,25 @@ +export type { + JsonValue, + Node, + NodeData, + NodeDataKey, + Root, +} from "./types.ts"; + +export { createNodeData } from "./types.ts"; + +export { createRoot, useRoot } from "./root.ts"; +export { NodeApi } from "./node.ts"; + +export { type Dispatch, DispatchApi } from "./dispatch.ts"; + +export { + advance, + current, + focus, + focusable, + focusPush, + type PopFocus, + retreat, + useFocus, +} from "./focus.ts"; diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/node.ts b/scripts/repl-study/vendor/freedom/upstream/lib/node.ts new file mode 100644 index 000000000..9df396882 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/node.ts @@ -0,0 +1,198 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params +import { + type Context, + createContext, + createScope, + type Scope, +} from "effection"; +import { createApi } from "effection/experimental"; +import type { + CreateChildOptions, + JsonValue, + Node, + NodeData, + NodeDataKey, +} from "./types.ts"; +import { TreeContext } from "./state.ts"; +import { validateJsonValue } from "./validate.ts"; + +class NodeDataImpl implements NodeData { + _map: Map = new Map(); + + get(key: NodeDataKey): T | undefined { + return this._map.get(key.symbol) as T | undefined; + } + + set(key: NodeDataKey, value: T): void { + this._map.set(key.symbol, value); + } + + expect(key: NodeDataKey): T { + const val = this._map.get(key.symbol); + if (val !== undefined) { + return val as T; + } else if (key.defaultValue !== undefined) { + return key.defaultValue; + } else { + throw new Error(`NodeData '${key.symbol.description}' not found`); + } + } +} + +export class NodeImpl implements Node { + _props: Record = {}; + _children: Set = new Set(); + _sortFn: ((a: Node, b: Node) => number) | undefined = undefined; + data: NodeData = new NodeDataImpl(); + scope: Scope; + #dispose: () => Promise; + + constructor( + readonly id: string, + readonly name: string, + readonly _parent: NodeImpl | undefined, + // XMD patch: the scope a parentless root is owned by. Without it a root is + // parented to Effection `global`, where host context does not reach the + // tree and a node-work failure raises into a no-op boundary. + owner?: Scope, + ) { + const [scope, dispose] = createScope(_parent?.scope ?? owner); + this.scope = scope; + this.#dispose = dispose; + scope.set(NodeContext, this); + } + + get props(): Record { + return Object.freeze({ ...this._props }); + } + + get children(): Iterable { + if (this._sortFn) { + const fn = this._sortFn; + const indexed = [...this._children].map((c, i) => [c, i] as const); + indexed.sort(([a, ai], [b, bi]) => { + const result = fn(a, b); + if (result !== 0) { + return result; + } else { + return ai - bi; + } + }); + return indexed.map(([c]) => c); + } else { + return this._children; + } + } + + get parent(): Node | undefined { + return this._parent; + } + + get(key: string): JsonValue | undefined { + return NodeApi.invoke(this.scope, "get", [this, key]); + } + + set(key: string, value: JsonValue): void { + NodeApi.invoke(this.scope, "set", [this, key, value]); + } + + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void { + NodeApi.invoke(this.scope, "update", [this, key, fn]); + } + + unset(key: string): void { + NodeApi.invoke(this.scope, "unset", [this, key]); + } + + createChild(name = "", options?: CreateChildOptions): Node { + return NodeApi.invoke(this.scope, "createChild", [this, name, options]); + } + + sort(fn?: (a: Node, b: Node) => number): void { + NodeApi.invoke(this.scope, "sort", [this, fn]); + } + + // Internal scope teardown — not on the public `Node` interface. Used by + // `remove` and by `root.destroy()`; disposing the scope cascades to all + // descendants. Removing a non-root node should go through `remove`, which also + // detaches and notifies. + destroy(): Promise { + return this.#dispose(); + } + + remove(): Promise { + return NodeApi.invoke(this.scope, "remove", [this]); + } +} + +// Synchronous node mutation API. Core methods take the node first; interceptors +// are installed per scope via `node.scope.around(NodeApi, ...)`. +export const NodeApi = createApi("freedom:node", { + get(node: NodeImpl, key: string): JsonValue | undefined { + return node._props[key]; + }, + set(node: NodeImpl, key: string, value: JsonValue): void { + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + update( + node: NodeImpl, + key: string, + fn: (prev: JsonValue | undefined) => JsonValue, + ): void { + const value = fn(node._props[key]); + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + unset(node: NodeImpl, key: string): void { + if (key in node._props) { + delete node._props[key]; + node.scope.expect(TreeContext).markDirty(); + } + }, + createChild(node: NodeImpl, name: string, options?: CreateChildOptions): Node { + const state = node.scope.expect(TreeContext); + const child = new NodeImpl(state.nextId(), name, node); + const before = options?.before; + if (before) { + if (!node._children.has(before as NodeImpl)) { + throw new Error("createChild: `before` is not a child of this node"); + } + // Set has no positional insert, so rebuild it with `child` spliced in. + const reordered = new Set(); + for (const existing of node._children) { + if (existing === before) { + reordered.add(child); + } + reordered.add(existing); + } + node._children = reordered; + } else { + node._children.add(child); + } + state.nodes.set(child.id, child); + state.markDirty(); + return child; + }, + sort(node: NodeImpl, fn: ((a: Node, b: Node) => number) | undefined): void { + node._sortFn = fn; + node.scope.expect(TreeContext).markDirty(); + }, + remove(node: NodeImpl): Promise { + if (!node._parent) { + throw new Error("Cannot remove root node"); + } + const state = node.scope.expect(TreeContext); + node._parent._children.delete(node); + state.nodes.delete(node.id); + state.markDirty(); + return node.destroy(); + }, +}); + +export const NodeContext: Context = createContext( + "freedom:current-node", +); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/root.ts b/scripts/repl-study/vendor/freedom/upstream/lib/root.ts new file mode 100644 index 000000000..1f0574a3c --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/root.ts @@ -0,0 +1,84 @@ +import { createQueue, createSignal, ensure, resource, until, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import type { Root } from "./types.ts"; +import { NodeImpl } from "./node.ts"; +import { TreeContext, type TreeState } from "./state.ts"; +import { DispatchApi } from "./dispatch.ts"; + +export function createRoot(owner?: Scope): Root { + const output = createSignal(); + // Internal, always-drained buffer: createRoot is synchronous, so the drain + // loop subscribes asynchronously — a Queue keeps events dispatched before the + // loop runs from being lost (bounded, since the loop drains immediately). + const events = createQueue(); + + let counter = 0; + const state: TreeState = { + dirty: false, + output, + nodes: new Map(), + nextId() { + return `node-${++counter}`; + }, + markDirty() { + state.dirty = true; + }, + }; + + const node = new NodeImpl(state.nextId(), "", undefined, owner); + node.scope.set(TreeContext, state); + state.nodes.set(node.id, node); + + // Dispatch loop: drain events through the demux middleware chain. + node.scope.run(function* () { + while (true) { + const next = yield* events.next(); + if (next.done) { + break; + } + state.dirty = false; + yield* DispatchApi.operations.dispatch(next.value); + if (state.dirty) { + output.send(); + } + } + }); + + return { + node, + dispatch(event) { + events.add(event); + }, + [Symbol.iterator]: output[Symbol.iterator], + destroy() { + return node.destroy(); + }, + }; +} + +/** + * A tree owned by the scope that acquires it. + * + * XMD patch. `createRoot()` alone parents the root scope to Effection `global`, + * so host context does not reach the tree, a failure in node work raises into a + * boundary nobody observes, and the caller owns the tree only by remembering to + * destroy it. Acquiring the root as a resource makes all three structural: the + * root scope is a child of the calling scope, it inherits that scope's + * contexts, and teardown joins it. + * + * The cleanup is registered before the root exists, because a run halted while + * acquiring has nothing registered to unwind. + */ +export function useRoot(): Operation { + return resource(function* (provide) { + const owner = yield* useScope(); + let root: Root | undefined; + yield* ensure(function* () { + if (root) { + yield* until(root.destroy()); + } + }); + root = createRoot(owner); + yield* provide(root); + }); +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/state.ts b/scripts/repl-study/vendor/freedom/upstream/lib/state.ts new file mode 100644 index 000000000..455d1d992 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/state.ts @@ -0,0 +1,12 @@ +import { createContext, type Signal } from "effection"; +import type { NodeImpl } from "./node.ts"; + +export interface TreeState { + dirty: boolean; + output: Signal; + nodes: Map; + nextId(): string; + markDirty(): void; +} + +export const TreeContext = createContext("freedom:tree"); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/types.ts b/scripts/repl-study/vendor/freedom/upstream/lib/types.ts new file mode 100644 index 000000000..f7216392b --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/types.ts @@ -0,0 +1,54 @@ +import type { Scope, Stream } from "effection"; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface NodeDataKey { + readonly symbol: symbol; + readonly defaultValue?: T; +} + +export function createNodeData( + name: string, + defaultValue?: T, +): NodeDataKey { + return { symbol: Symbol(name), defaultValue }; +} + +export interface NodeData { + get(key: NodeDataKey): T | undefined; + set(key: NodeDataKey, value: T): void; + expect(key: NodeDataKey): T; +} + +export interface CreateChildOptions { + before?: Node; +} + +export interface Node { + readonly id: string; + readonly name: string; + readonly props: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly data: NodeData; + readonly scope: Scope; + get(key: string): JsonValue | undefined; + set(key: string, value: JsonValue): void; + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; + unset(key: string): void; + createChild(name?: string, options?: CreateChildOptions): Node; + sort(fn?: (a: Node, b: Node) => number): void; + remove(): Promise; +} + +export interface Root extends Stream { + node: Node; + dispatch(event: unknown): void; + destroy(): Promise; +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts b/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts new file mode 100644 index 000000000..d14c52463 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts @@ -0,0 +1,56 @@ +// oxlint-disable bombshell-dev/no-generic-error +import type { JsonValue } from "./types.ts"; + +export function validateJsonValue(value: unknown): asserts value is JsonValue { + if (value === undefined) { + throw new Error("undefined is not a valid JsonValue"); + } + if (typeof value === "number") { + if (Number.isNaN(value)) { + throw new Error("NaN is not a valid JsonValue"); + } + if (!Number.isFinite(value)) { + throw new Error(`${value} is not a valid JsonValue`); + } + return; + } + if ( + typeof value === "string" || typeof value === "boolean" || value === null + ) { + return; + } + if (typeof value === "function") { + throw new Error("functions are not valid JsonValues"); + } + if (typeof value === "symbol") { + throw new Error("symbols are not valid JsonValues"); + } + if (typeof value === "bigint") { + throw new Error("bigints are not valid JsonValues"); + } + if (value instanceof Date) { + throw new Error("Date instances are not valid JsonValues"); + } + if (value instanceof Map) { + throw new Error("Map instances are not valid JsonValues"); + } + if (value instanceof Set) { + throw new Error("Set instances are not valid JsonValues"); + } + if (value instanceof RegExp) { + throw new Error("RegExp instances are not valid JsonValues"); + } + if (Array.isArray(value)) { + for (const item of value) { + validateJsonValue(item); + } + return; + } + if (typeof value === "object" && value !== null) { + for (const key of Object.keys(value)) { + validateJsonValue((value as Record)[key]); + } + return; + } + throw new Error(`${String(value)} is not a valid JsonValue`); +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 9607be6d0..42bc4ba1e 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -35,6 +35,24 @@ export interface RuntimeExclusion { const DERIVED_SCOPE = "https://github.com/taras/executable.md/issues/144"; const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ + { + path: "scripts/tests/repl-study.test.ts", + reason: + "its subject is the Deno terminal harness in scripts/repl-study: the host reads Deno.consoleSize(), sets Deno.stdin raw mode, installs Deno signal listeners, and the restoration cases run `deno run` as a child. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", + issue: "https://github.com/taras/executable.md/issues/838", + }, + { + path: "scripts/tests/repl-focus.test.ts", + reason: + "its subject is the route and focus model of the same Deno terminal harness: it drives `@bomb.sh/tty`'s decoder for the pending-Escape flush and for Backtab, renders through the harness's Deno-only host, and runs `deno run` as a child to check the documented command. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", + issue: "https://github.com/taras/executable.md/issues/839", + }, + { + path: "scripts/tests/repl-compose-command.test.ts", + reason: + "its subject is the documented `deno task repl:compose` command, which it runs as a `deno` child process to check the journey and the structural trace. A Node or Bun shard has no `deno` on PATH; the composition itself is covered portably by scripts/tests/repl-compose-screen.test.ts", + issue: "https://github.com/taras/executable.md/issues/840", + }, { path: "scripts/tests/build-npm.test.ts", reason: diff --git a/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt new file mode 100644 index 000000000..03d3d046f --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt @@ -0,0 +1,28 @@ +frame-01.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL Tab ▸ + TRANSCRIPT FOCUS MAP · F1 + 1 Sessions + No executions yet. 2 Transcript + 3 Bindings + Submitted blocks append here as immutable entries. Each entry keep ▸ 4 REPL input + rendered output, and the bindings it published. 5 Execution Hist… + + + + + + + + + + + + + + + + + + +▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-focus/frame-01.wide.txt b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt new file mode 100644 index 000000000..5b0e66620 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt @@ -0,0 +1,48 @@ +frame-01.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. ▸ 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-02.wide.txt b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt new file mode 100644 index 000000000..8329dd7af --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt @@ -0,0 +1,48 @@ +frame-02.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. ▸ 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-03.wide.txt b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt new file mode 100644 index 000000000..bb35b617c --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt @@ -0,0 +1,48 @@ +frame-03.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. 4 REPL input + Agent sessions appear here as executions open │ ▸ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ +▌EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-04.wide.txt b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt new file mode 100644 index 000000000..3d6e2d491 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt @@ -0,0 +1,48 @@ +frame-04.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ ▸ 3 Bindings + │ No executions yet. 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt new file mode 100644 index 000000000..2c764bcc9 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt @@ -0,0 +1,15 @@ +frame-05.narrow · 90 × 28 + EXECUTION HISTORY · 4 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + HISTORY │ ┃ LI FOCUS MAP · F1 + 00:31 │ │ ┃ 00 1 Sessions + Entry 1 │ │ │ ┃ 2 Transcript + ───◆────●───────────●─────────●──────────────────●·─┃ 3 Bindings + notch height is scope depth · digits mark coalesced chec 4 REPL input + 5 Execution Hist… + CHECKPOINTS ▸ 6 Pause + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document + 00:12 ● Plan entered Entry 1 › document › Plan + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check diff --git a/scripts/tests/fixtures/repl-focus/frame-05.wide.txt b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt new file mode 100644 index 000000000..00845fa01 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt @@ -0,0 +1,51 @@ +frame-05.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 31.4s ↳ Plan scope open 2 Transcript + SESSIONS · 1 │ 3 Bindings + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input + │ document 5 Execution History + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running ▸ 6 Pause + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ENTER + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt new file mode 100644 index 000000000..b14c81861 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt @@ -0,0 +1,13 @@ +frame-07.narrow · 90 × 28 + INPUT REQUIRED + suspended at · document scope · validated ag FOCUS MAP · F1 + schema ▸ 1 Project name + 2 Description + Enter the project details. 3 Schema disclos… + 4 Submit + ▸ Project name 5 Execution Hist… + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-focus/frame-07.wide.txt b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt new file mode 100644 index 000000000..c004c9cdf --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt @@ -0,0 +1,51 @@ +frame-07.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ▸ 1 Project name ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Description + SESSIONS · 3 │ 3 Schema disclosure · ⌥S + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Submit + │ document 5 Execution History + │ plan-a91f7c │ ▶ ENTER + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ ▸ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-08.wide.txt b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt new file mode 100644 index 000000000..75eeeacf2 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt @@ -0,0 +1,51 @@ +frame-08.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Plan review · scroll region ─ + │ Entry 1 ● running · 31.4s ↳ Plan scope open ▸ 2 Approve + SESSIONS · 1 │ 3 Request changes + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Stop + │ document 5 Submit + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running 6 Execution History + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ · Plan scope · 59 lines returned + │ + │ # Create a project README + │ + │ Provide the project name and a one-sentence description. + │ + │ + │ Enter the project details. + │ ▸ 53 more lines · ⌥↓ scrolls the Plan + │ + │ (•) ▸ Approve + │ ( ) Request changes + │ ( ) Stop + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-focus/frame-09.wide.txt b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt new file mode 100644 index 000000000..32f603935 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt @@ -0,0 +1,51 @@ +frame-09.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 README preview · scroll re… ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended ▸ 2 Approve + SESSIONS · 3 │ 3 Decline + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Execution History + │ document + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ CONFIRMATION REQUIRED + │ suspended at · document scope + │ + │ Create README.md with the content shown above? + │ │ # Northstar + │ │ + │ │ A lightweight workspace for coordinating coding agents. + │ + │ [ ▸ Approve ] [ Decline ] + │ ⌘↵ approves · Esc closes the drawer without answering it + │ + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-10.wide.txt b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt new file mode 100644 index 000000000..2674a256c --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt @@ -0,0 +1,51 @@ +frame-10.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input + │ Plan 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE ▸ 6 Continue + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD PAUSED [▸Continue ] [ Return to paused head ] + recorded · 00:53 │ │ │ │ │ ┃ 00:53 + Entry 1 │ │ │ │ │ │ ┃ + ────◆─────●──────────────●───────────●──────────────────────●─·──────────≈───────────●────────────●───●─────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-11.wide.txt b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt new file mode 100644 index 000000000..53b69d415 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt @@ -0,0 +1,51 @@ +frame-11.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input + │ Plan ▸ 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + confirmation Elicit requested │ │ + 00:53 elapsed · Entry 1 › document │ │ + · elicit.requested confirmation │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ +▌EXECUTION HISTORY │ ┃ PAUSED HEAD PAUSED [ Continue ] [ Return to paused head ] + recorded · 00:53 │ │ │ │ │ ┃ 00:53 + Entry 1 │ │ │ │ │ │ ┃ + ────◆─────●──────────────●───────────●──────────────────────●─·──────────≈───────────●────────────●───●─────●─┃ + ▲ 00:53 · snapped · 0.0s before head diff --git a/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt new file mode 100644 index 000000000..862621387 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt @@ -0,0 +1,20 @@ +frame-12.narrow · 90 × 28 + EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue FOCUS MAP · F1 + 00:53 ││ ││┃ 00:53 1 Sessions + Entry 1 ││ │ ││┃ 2 Transcript + ─◆●─●●───2─≈●─●●┃ 3 Bindings + ▲ 00:12 · snapped · 41.0s before head 4 REPL input + 5 Execution Hist… + CHECKPOINTS 6 Continue + 00:02 ◆ Entry 1 submitted REPL 7 Return to paus… + 00:05 ● document scope entered Entry ▸ 8 Fork from here + 00:12 ● Plan entered Entry + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check + 00:41 ● review returned Approve … › Plan › Elicit + 00:47 ● Plan replaced by returned program Entry 1 › document + 00:49 ● project Elicit requested Entry 1 › document + 00:52 ● project Elicit answered Entry 1 › document + 00:53 ● confirmation Elicit requested Entry 1 › document diff --git a/scripts/tests/fixtures/repl-focus/frame-12.wide.txt b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt new file mode 100644 index 000000000..99ff84fd2 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt @@ -0,0 +1,51 @@ +frame-12.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input + │ Plan 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs ▸ 8 Fork from here + 00:18 ● planning inputs prepared │ │ ✓ SETTLED + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [▸Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-focus/frame-13.wide.txt b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt new file mode 100644 index 000000000..7e9f855ae --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt @@ -0,0 +1,51 @@ +frame-13.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Transcript + SESSIONS · 3 │ 3 Bindings + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input + │ document 5 Execution History + │ plan-a91f7c │ ▶ ENTER ▸ 6 Pause + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [▸Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt new file mode 100644 index 000000000..ebf7766ea --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt @@ -0,0 +1,28 @@ +frame-14.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines FOCUS MAP · F1 + 1 Sessions + Create a project README 2 Transcript + Provide the project name and a one-sentence description. 3 Bindings + │ MARKDOWN ▸ 4 REPL input + │ # Northstar 5 Execution Hist… + │ 6 Run + │ A lightweight workspace for coordinating coding agents. + │ README.md · 63 bytes · +3 lines + README.md was created for Northstar. + no REPL bindings published · 1 file written + + + + + + + + + + + + + +▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-focus/frame-14.wide.txt b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt new file mode 100644 index 000000000..665c4d8b7 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt @@ -0,0 +1,51 @@ +frame-14.wide · 200 × 50 + XMD REPL │ REPL · Entry 1 settled + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines 2 Transcript + SESSIONS · 3 │ 3 Bindings + persist after settling │ Create a project README ▸ 4 REPL input + │ Provide the project name and a one-sentence description. 5 Execution History + │ plan-a91f7c │ │ MARKDOWN 6 Run + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar + │ │ │ + review-b72e1d │ │ A lightweight workspace for coordinating coding agents. │ + ● responding reviewer · turn 1 · streaming │ │ README.md · 63 bytes · +3 lines │ + streaming · background update · selection u… │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │ ┃ + ─────◆──────●───────────────●─────────────●────────────────────────●─·───────────≈─────────────●─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/drawer.medium.txt b/scripts/tests/fixtures/repl-study/drawer.medium.txt new file mode 100644 index 000000000..2ae783bd1 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.medium.txt @@ -0,0 +1,39 @@ +drawer.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document · suspended + │ + SESSIONS · 3 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + │ plan-a91f7c │ │ document scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + review-b72e1d │ ▶ ENTER │ # Northstar + ● responding reviewer │ │ ▾ Ask for the project details │ + │ │ ● WAITING │ + implement-c31d2e │ │ │ Enter the project details. │ + · queued implementer │ │ ● WAITING │ + │ │ ▲ suspended · answer in the drawer below │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ────◆─────●────────────●───────────●────────────────────●─·────────≈───────────●──────────●───┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt new file mode 100644 index 000000000..ec8a37527 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt @@ -0,0 +1,14 @@ +drawer.narrow.sessions · 90 × 28 + SESSIONS · 1 / 4 REPL › Entry 1 › document · suspended Tab ▸ + SESSION JOURNAL STATE + + SESSIONS · 3 + + │ plan-a91f7c + ✓ completed planner + + review-b72e1d + ● responding reviewer + + implement-c31d2e + · queued implementer diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.txt new file mode 100644 index 000000000..89458114f --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.txt @@ -0,0 +1,13 @@ +drawer.narrow · 90 × 28 + INPUT REQUIRED + suspended at · document scope · validated against the Elicit + schema + + Enter the project details. + + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-study/drawer.too-small.txt b/scripts/tests/fixtures/repl-study/drawer.too-small.txt new file mode 100644 index 000000000..245556ada --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.too-small.txt @@ -0,0 +1,5 @@ +drawer.too-small · 64 × 18 + + Terminal too small + 72 × 20 required · 64 × 18 now + resize to continue diff --git a/scripts/tests/fixtures/repl-study/drawer.wide.txt b/scripts/tests/fixtures/repl-study/drawer.wide.txt new file mode 100644 index 000000000..af9420711 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.wide.txt @@ -0,0 +1,51 @@ +drawer.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/empty.medium.txt b/scripts/tests/fixtures/repl-study/empty.medium.txt new file mode 100644 index 000000000..4371d2269 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.medium.txt @@ -0,0 +1,36 @@ +empty.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL + │ + No sessions yet │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ TRANSCRIPT │ BINDINGS + Agent sessions appear here as │ │ REPL scope + executions open them. │ No executions yet. │ + They persist after an entry │ │ No REPL bindings yet + settles. │ Submitted blocks append here as immutable entries. Each entry keeps its │ Values named with as + │ source, its rendered output, and the bindings it published. │ appear here for the + │ │ active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded exec… diff --git a/scripts/tests/fixtures/repl-study/empty.narrow.txt b/scripts/tests/fixtures/repl-study/empty.narrow.txt new file mode 100644 index 000000000..6031a9db4 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.narrow.txt @@ -0,0 +1,28 @@ +empty.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL Tab ▸ + TRANSCRIPT + + No executions yet. + + Submitted blocks append here as immutable entries. Each entry keeps its source, its + rendered output, and the bindings it published. + + + + + + + + + + + + + + + + + + + REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-study/empty.wide.txt b/scripts/tests/fixtures/repl-study/empty.wide.txt new file mode 100644 index 000000000..259867759 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.wide.txt @@ -0,0 +1,48 @@ +empty.wide · 200 × 50 + XMD REPL │ REPL + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ TRANSCRIPT │ BINDINGS + No sessions yet │ │ REPL scope + │ No executions yet. │ + Agent sessions appear here as executions open │ │ No REPL bindings yet + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the │ Values named with as appear + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-study/generated.medium.txt b/scripts/tests/fixtures/repl-study/generated.medium.txt new file mode 100644 index 000000000..49117ccc3 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.medium.txt @@ -0,0 +1,39 @@ +generated.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document · active + │ + SESSIONS · 2 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + │ plan-a91f7c │ │ document scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ admitted + review-b72e1d │ ▶ ENTER │ # Create a project READ… + ● responding reviewer │ │ Create a project README │ + │ │ ◀ EXIT │ + │ │ │ ✓ Admit the approved Plan · admitted │ + │ │ ◀ EXIT │ + │ │ │ XMD │ + │ │ │ # Create a project README │ + │ │ │ │ + │ │ │ Provide the project name and a one-sentence description. │ + │ │ │ │ + │ │ │ │ + │ │ │ Enter the project details. │ + │ │ │ │ + │ │ │ │ + │ │ │ This is the README that will be created: │ + │ │ │ │ + │ │ │ │ + │ │ │ returned program · 59 lines · replaces the Plan expression, then evalua… │ + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. │ + │ │ ▶ ENTER │ + │ ▸ 2 more lines · ↑↓ PgUp PgDn │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ + ────◆─────●─────────────●──────────●─────────────────────●─·─────────≈──────────●───────────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/generated.narrow.txt b/scripts/tests/fixtures/repl-study/generated.narrow.txt new file mode 100644 index 000000000..dda99d6cc --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.narrow.txt @@ -0,0 +1,27 @@ +generated.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL › Entry 1 › document · active Tab ▸ + Entry 1 ● running · 48.1s ↳ document scope open + + ← opened from Entry 1 · live execution projection, not an editor + document + ▶ ENTER + │ Create a project README + │ ◀ EXIT + │ │ ✓ Admit the approved Plan · admitted + │ ◀ EXIT + │ │ XMD + │ │ # Create a project README + │ │ + │ │ Provide the project name and a one-sentence description. + │ │ + │ │ + │ │ Enter the project details. + │ │ + │ │ + │ │ This is the README that will be created: + │ │ + │ │ + │ │ returned program · 59 lines · replaces the Plan expression, then evaluates here + │ Create a project README + ▸ 4 more lines · ↑↓ PgUp PgDn + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] diff --git a/scripts/tests/fixtures/repl-study/generated.wide.txt b/scripts/tests/fixtures/repl-study/generated.wide.txt new file mode 100644 index 000000000..4f64577ff --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.wide.txt @@ -0,0 +1,51 @@ +generated.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · active + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + SESSIONS · 2 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ admitted + │ plan-a91f7c │ ▶ ENTER │ XMD source · 59 lines · sealed + ✓ completed planner · turn 1 · returned 5… │ │ Create a project README │ # Create a project README + │ │ ◀ EXIT │ + review-b72e1d │ │ │ ✓ Admit the approved Plan · admitted │ + ● responding reviewer · turn 1 · streaming │ │ ◀ EXIT │ + streaming · background update · selection u… │ │ │ XMD │ + │ │ │ # Create a project README │ + │ │ │ │ + │ │ │ Provide the project name and a one-sentence description. │ + │ │ │ │ + │ │ │ │ + │ │ │ Enter the project details. │ + │ │ │ │ + │ │ │ │ + │ │ │ This is the README that will be created: │ + │ │ │ │ + │ │ │ │ + │ │ │ returned program · 59 lines · replaces the Plan expression, then evaluates here │ + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. │ + │ │ ▶ ENTER │ + │ │ │ Enter the project details. │ + │ │ ▶ ENTER │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ + ──────◆────────●─────────────────────●──────────────────●────────────────────────────────●───·──────────────≈─────────────────●──────────────────●──┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/nested.medium.txt b/scripts/tests/fixtures/repl-study/nested.medium.txt new file mode 100644 index 000000000..e1d36bc66 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.medium.txt @@ -0,0 +1,39 @@ +nested.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document › Plan · active + │ + SESSIONS · 1 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │ plan-a91f7c │ │ Plan scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ prompt + │ repl:entry-1 · submitted source is immutable while running │ "Create an XMD program … + │ ▶ ENTER │ project name and a desc… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan │ syntax + │ │ component drafts the program that asks for them, reviews its own draft, │ XMD catalog · 47 symbols + │ │ and returns it for admission into this document scope. │ component, control, age… + │ │ ● ACTIVE │ + │ │ │ ✓ Read the Prompt · prompt │ inputs + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ { + │ │ │ ✓ Create the first draft · draft │ surface: "component", + │ │ │ ▾ Check the draft │ session: "plan-a91f7c… + │ │ │ ● ACTIVE │ budget: 3 + │ │ │ │ ✓ SETTLED │ } + │ │ │ ● WAITING │ + │ │ │ │ Review the generated Plan and choose Approve, Request changes or │ draft + │ │ │ │ Stop. │ # Create a project READ… + │ │ │ │ The reviewer has the draft, the schema it was checked against, and │ SETTLED │ + │ │ │ │ ✓ SETTLED │ + │ ▸ 4 more lines · ↑↓ PgUp PgDn │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────◆────────●────────────────────●──────────────────●────────────────────────────────●──·──┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt new file mode 100644 index 000000000..fe55ac798 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt @@ -0,0 +1,23 @@ +nested.narrow.bindings · 90 × 28 + BINDINGS · 3 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + BINDINGS + Plan scope + + prompt + "Create an XMD program that asks me for a + project name and a description…" + + syntax + XMD catalog · 47 symbols + component, control, agent, io + + inputs + { + surface: "component", + session: "plan-a91f7c", + budget: 3 + } + + draft + # Create a project README + diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.txt b/scripts/tests/fixtures/repl-study/nested.narrow.txt new file mode 100644 index 000000000..827f51dd8 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.narrow.txt @@ -0,0 +1,27 @@ +nested.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + Entry 1 ● running · 31.4s ↳ Plan scope open + + ← opened from Entry 1 · live execution projection, not an editor + document + repl:entry-1 · submitted source is immutable while running + ▶ ENTER + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the + │ program that asks for them, reviews its own draft, and returns it for admission into + │ this document scope. + │ ● ACTIVE + │ │ ✓ Read the Prompt · prompt + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft + │ │ ▾ Check the draft + │ │ ● ACTIVE + │ │ │ ✓ SETTLED + │ │ ● WAITING + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. + │ │ │ The reviewer has the draft, the schema it was checked against, and the + │ │ │ capabilities the document would be granted if the Plan is admitted. Nothing it + │ │ │ returns runs until this scope admits it. + │ │ │ ✓ SETTLED + ▸ 5 more lines · ↑↓ PgUp PgDn + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] diff --git a/scripts/tests/fixtures/repl-study/nested.wide.txt b/scripts/tests/fixtures/repl-study/nested.wide.txt new file mode 100644 index 000000000..30c24d319 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.wide.txt @@ -0,0 +1,51 @@ +nested.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + SESSIONS · 1 │ │ Plan scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ prompt + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running │ "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER │ project name and a descriptio… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ACTIVE │ "Create an XMD program … + 00:29 ● planning Agent response… │ │ ✓ Read the Prompt · prompt │ + 00:30 ● draft checked │ │ ▾ Prepare the planning inputs │ + 00:41 ● review returned Approve │ │ ✓ SETTLED │ + 00:47 ● Plan replaced by return… │ │ ● ACTIVE │ + 00:49 ● project Elicit requested │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit req… │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › docum… │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ──◆──●───────●──────●───────────●·────≈──────●─────●──●──●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt new file mode 100644 index 000000000..d23a72ead --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt @@ -0,0 +1,20 @@ +paused.narrow.history · 90 × 28 + EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + 00:53 ││ ││┃ 00:53 + Entry 1 ││ │ ││┃ + ─◆●─●●───2─≈●─●●┃ + ▲ 00:12 · snapped · 41.0s before head + + CHECKPOINTS + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document + 00:12 ● Plan entered Entry 1 › document › Plan + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check + 00:41 ● review returned Approve … › Plan › Elicit + 00:47 ● Plan replaced by returned program Entry 1 › document + 00:49 ● project Elicit requested Entry 1 › document + 00:52 ● project Elicit answered Entry 1 › document + 00:53 ● confirmation Elicit requested Entry 1 › document diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.txt b/scripts/tests/fixtures/repl-study/paused.narrow.txt new file mode 100644 index 000000000..5d7224093 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.narrow.txt @@ -0,0 +1,27 @@ +paused.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + Entry 1 ● running · 53.0s ↳ reconstructed · read-only + + reconstructed from the journal · no live action is possible here + Plan + ● ACTIVE + │ ✓ Read the Prompt · prompt + │ ▾ Prepare the planning inputs + │ ✓ SETTLED + │ ● ACTIVE + │ XMD catalog · 47 symbols · component, control, agent, io + + + + + + + + + + + + + + + DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] diff --git a/scripts/tests/fixtures/repl-study/paused.too-small.txt b/scripts/tests/fixtures/repl-study/paused.too-small.txt new file mode 100644 index 000000000..863e0e722 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.too-small.txt @@ -0,0 +1,5 @@ +paused.too-small · 64 × 18 + + Terminal too small + 72 × 20 required · 64 × 18 now + resize to continue diff --git a/scripts/tests/fixtures/repl-study/paused.wide.txt b/scripts/tests/fixtures/repl-study/paused.wide.txt new file mode 100644 index 000000000..fa11e9955 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.wide.txt @@ -0,0 +1,51 @@ +paused.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + ENTRY 1 · CREATE PROJECT README │ │ Plan scope · as recorded + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here │ + │ Plan │ prompt + 00:02 ◆ Entry 1 submitted │ ● ACTIVE │ "Create an XMD program that a… + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt │ + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs │ + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt new file mode 100644 index 000000000..4b012e3ff --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt @@ -0,0 +1,51 @@ +play.generated-drawer.midpoint · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ … │ + review-b72e1d │ │ + ● responding reviewer · turn 1 · streaming │ │ + streaming · background update · selection u… │ │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ │ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●───┃ ● + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt new file mode 100644 index 000000000..ad1c7ed0d --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt @@ -0,0 +1,51 @@ +play.generated-drawer.settled · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt new file mode 100644 index 000000000..f96308dd5 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt @@ -0,0 +1,51 @@ +play.generated-drawer.start · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ … │ readme + │ plan-a91f7c │ │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar + │ │ + review-b72e1d │ │ + ● responding reviewer · turn 1 · streaming │ │ + streaming · background update · selection u… │ │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ │ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●──┃ ● + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/settled.medium.txt b/scripts/tests/fixtures/repl-study/settled.medium.txt new file mode 100644 index 000000000..0a9251f6b --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.medium.txt @@ -0,0 +1,39 @@ +settled.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL · Entry 1 settled + │ + SESSIONS · 3 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + │ plan-a91f7c │ │ REPL scope + ✓ completed planner │ Create a project README │ + │ Provide the project name and a one-sentence description. │ Entry 1 published none + review-b72e1d │ │ MARKDOWN │ Values named with as + ● responding reviewer │ │ # Northstar │ appear here for the + │ │ │ active scope. + implement-c31d2e │ │ A lightweight workspace for coordinating coding agents. │ + · queued implementer │ │ README.md · 63 bytes · +3 lines │ + │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │┃ + ───◆───●─────────●────────●──────────────●─·──────≈───────●────────●──●───●─●────●────●┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/settled.narrow.txt b/scripts/tests/fixtures/repl-study/settled.narrow.txt new file mode 100644 index 000000000..9fc409451 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.narrow.txt @@ -0,0 +1,28 @@ +settled.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines + + Create a project README + Provide the project name and a one-sentence description. + │ MARKDOWN + │ # Northstar + │ + │ A lightweight workspace for coordinating coding agents. + │ README.md · 63 bytes · +3 lines + README.md was created for Northstar. + no REPL bindings published · 1 file written + + + + + + + + + + + + + + REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-study/settled.wide.txt b/scripts/tests/fixtures/repl-study/settled.wide.txt new file mode 100644 index 000000000..cacb41ace --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.wide.txt @@ -0,0 +1,51 @@ +settled.wide · 200 × 50 + XMD REPL │ REPL · Entry 1 settled + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + SESSIONS · 3 │ │ REPL scope + persist after settling │ Create a project README │ + │ Provide the project name and a one-sentence description. │ Entry 1 published none + │ plan-a91f7c │ │ MARKDOWN │ Values named with as appear + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar │ here for the active scope. + │ │ │ + review-b72e1d │ │ A lightweight workspace for coordinating coding agents. │ + ● responding reviewer · turn 1 · streaming │ │ README.md · 63 bytes · +3 lines │ + streaming · background update · selection u… │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │ ┃ + ─────◆──────●───────────────●─────────────●────────────────────────●─·───────────≈─────────────●─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/repl-compose-command.test.ts b/scripts/tests/repl-compose-command.test.ts new file mode 100644 index 000000000..0531094d4 --- /dev/null +++ b/scripts/tests/repl-compose-command.test.ts @@ -0,0 +1,98 @@ +/** + * The two documented commands, run as a person runs them. + * + * #840 asks for one command that runs the representative journey and one that + * prints its structural trace. A command nobody executes is a paragraph in a + * README, so these run the real thing as a child process and read what came + * back — which is also what catches a task that stops working for a reason the + * unit evidence cannot see, like a flag that no longer parses. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { exec } from "@effectionx/process"; +import type { Operation } from "effection"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const MAIN = "scripts/repl-compose/main.ts"; + +function* run(...args: readonly string[]): Operation { + const result = yield* exec(`deno run --allow-all ${MAIN} ${args.join(" ")}`, { + cwd: ROOT, + }).join(); + if (result.code !== 0) { + throw new Error(`${MAIN} ${args.join(" ")} exited ${result.code}\n${result.stderr}`); + } + return result.stdout; +} + +describe("REPL composition: the documented commands", () => { + it("runs the representative journey unattended", function* () { + const output = yield* run("--journey"); + + // Every moment of the journey, in order, drawn from the tree each one + // described. + expect(output).toContain("— the entry, no drawer —"); + expect(output).toContain("— the project drawer —"); + expect(output).toContain("— confirm stacked on it —"); + expect(output).toContain("— the top drawer closed —"); + expect(output).toContain("— a location the execution never went to —"); + + // Opening a drawer asks for frames, stacking asks for another, closing + // gives one back, and refusing gives them all back. + expect(output).toContain("frames wanted: 0\n"); + expect(output).toContain("frames wanted: 2\n"); + + // The refusal replaced the screen rather than covering it. + const refused = output.slice(output.indexOf("never went to")); + expect(refused).toContain("does not exist in this execution"); + expect(refused).not.toContain("owner:"); + + // And the renderer is swapped under the same mounted tree at the end. + expect(output).toContain("— the same tree, another renderer —"); + expect(output).toContain("│"); + }); + + it("prints the structural trace #840 asks for", function* () { + const output = yield* run( + "--trace", + "'xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect'", + ); + + for (const heading of [ + "1. decoded route", + "2. resolved against the model", + "3. keyed component description", + "4. mounted Freedom tree", + "5. action delivery", + "6. branch teardown", + "7. terminal output", + ]) { + expect(output).toContain(heading); + } + + expect(output).toContain("xmd://repl/e1/transcript/entry-1/document/+project/+confirm"); + expect(output).toContain("identities are the model's own values"); + expect(output).toContain("drawers entry-1:project → entry-1:confirm"); + // Focus is reconstructed from the URL first, and only then does an + // explicit pointer activation move it to a control. + expect(output).toContain( + "focus reconstructed from the URL at screen › workbench › project › confirm", + ); + expect(output).toContain("pointer action drawer.close"); + expect(output).toContain("keyboard action drawer.close"); + expect(output).toContain("equivalent yes"); + expect(output).toContain( + "removed project, project.answer, project.back, confirm, confirm.answer, confirm.back", + ); + expect(output).toContain("frame demand 2 → 0"); + }); + + it("refuses a location and says which segment", function* () { + const output = yield* run("--trace", "'xmd://repl/e1/transcript/entry-1/plan'"); + + expect(output).toContain("refused at scope[0]"); + expect(output).toContain('"plan" is not a scope of entry-1'); + }); +}); diff --git a/scripts/tests/repl-compose-reconcile.test.ts b/scripts/tests/repl-compose-reconcile.test.ts new file mode 100644 index 000000000..f6c3e42e1 --- /dev/null +++ b/scripts/tests/repl-compose-reconcile.test.ts @@ -0,0 +1,953 @@ +/** + * One description, one mounted tree, and nothing beside it. + * + * The reverted first #840 experiment kept rendering, focus order, the input + * path and the overlay derived from structures that had to agree with each + * other. Every case it wrote passed, because things that are kept in step + * always agree. The cases here are chosen to be ones only a single mounted tree + * can satisfy: a branch that is removed and therefore contributes nothing + * anywhere, and a keyed child that survives an update with the state its + * lifecycle is holding. + * + * Three named controls stand in for the designs this rejects — positional + * matching, a drawer that is hidden rather than removed, and a registry beside + * the tree — and each one *passes* the check the real design fails it on, which + * is what makes the check a check. + */ + +import { describe as suite, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { race, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { when } from "@effectionx/converge"; + +import { current, useRoot } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node, Root } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import { component, describe } from "../repl-compose/component.ts"; +import type { Component, Description } from "../repl-compose/component.ts"; +import { useFrameClock } from "../repl-compose/frames.ts"; +import { useHandoff } from "../repl-compose/handoff.ts"; +import type { Handoff } from "../repl-compose/handoff.ts"; +import type { FrameClock } from "../repl-compose/frames.ts"; +import { press } from "../repl-compose/input.ts"; +import { + AmbiguousFocus, + compose, + DuplicateKey, + focusTargets, + keyOf, + paint, + topology, +} from "../repl-compose/reconcile.ts"; +import { Control, Drawer, Panel, Workspace } from "../repl-compose/shell.ts"; +import type { DrawerInput, WorkspaceInput } from "../repl-compose/shell.ts"; + +const PANELS = [ + { key: "transcript", title: "Transcript", lines: ["entry-1"] }, + { key: "bindings", title: "Bindings", lines: ["draft"] }, +]; + +const PROJECT: DrawerInput = { + key: "+project", + kind: "project", + prompt: "Which project should the README describe?", + controls: [{ label: "Submit", action: "drawer.submit" }], +}; + +const CONFIRM: DrawerInput = { + key: "+confirm", + kind: "confirm", + prompt: "Commit and push the README now?", + controls: [{ label: "Commit", action: "drawer.commit" }], +}; + +/** A branch that holds onto a frame instead of returning for the next one. */ +interface HoldInput { + readonly hold: Operation; +} + +const Held: Component = component({ + name: "held", + focusable: false, + children: () => [], + *lifecycle({ node, input, frames, ready }): Operation { + const clock = yield* frames.subscribe(); + yield* ready(); + while (true) { + const at = yield* clock.next(); + node.set("at", at); + yield* input.hold; + } + }, + present: () => [], +}); + +/** One component whose lifecycle, children, presentation and keys all read one number. */ +interface ProbeInput { + readonly value: number; +} + +const Probe: Component = component({ + name: "probe", + focusable: true, + children: (input) => [describe(Leaf, "leaf", input)], + *lifecycle({ node, input, ready, updates }): Operation { + const later = yield* updates.receive(); + let seen = input.value; + let applied = 0; + node.set("lifecycle", seen); + node.set("applied", applied); + yield* ready(); + while (true) { + seen = (yield* later.next()).value; + applied += 1; + node.set("lifecycle", seen); + node.set("applied", applied); + } + }, + onPress: (input, key) => + key.key === "Enter" ? { kind: `probe.${input.value}`, from: "probe" } : undefined, + present: (input, children) => [`present:${input.value}`, ...children], +}); + +const Leaf: Component = component({ + name: "leaf", + focusable: false, + children: () => [], + lifecycle: null, + present: (input) => [`leaf:${input.value}`], +}); + +/** A focusable branch that asks for nothing. */ +const Plain: Component> = component({ + name: "plain", + focusable: true, + children: () => [], + lifecycle: null, + present: () => [], +}); + +/** A focusable branch that says the location is asking for it. */ +const Asking: Component<{ readonly within: readonly Description[] }> = component({ + name: "asking", + focusable: true, + lifecycle: null, + children: ({ within }) => within, + claimsFocus: () => "alone", + present: () => [], +}); + +function workspace(drawers: readonly DrawerInput[]): WorkspaceInput { + return { panels: PANELS, drawers }; +} + +function shell(input: WorkspaceInput): readonly Description[] { + return [describe(Workspace, "workspace", input)]; +} + +/** One mounted tree and the clock it is driven by. */ +interface Harness { + readonly root: Root; + readonly clock: FrameClock; + /** Compose, refusing to continue if the description tree was rejected. */ + show(input: WorkspaceInput): Operation; + /** Compose, handing back whatever the reconciler answered. */ + offer(descriptions: readonly Description[]): Operation>; +} + +function* harness(): Operation { + const root = yield* useRoot(); + const clock = yield* useFrameClock(); + const offer = function* (descriptions: readonly Description[]): Operation> { + return yield* compose(root.node, descriptions, clock); + }; + return { + root, + clock, + offer, + *show(input: WorkspaceInput): Operation { + const composed = yield* offer(shell(input)); + if (!composed.ok) { + throw composed.error; + } + }, + }; +} + +/** The node one key names, searched in the one tree there is. */ +function find(node: Node, key: string): Node | undefined { + if (keyOf(node) === key) { + return node; + } + for (const child of node.children) { + const found = find(child, key); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +function expectNode(node: Node, key: string): Node { + const found = find(node, key); + if (found === undefined) { + throw new Error(`no mounted branch is keyed ${JSON.stringify(key)}`); + } + return found; +} + +suite("REPL composition: keyed descriptions reconciled into Freedom", () => { + suite("a parent declares its direct children", () => { + it("mounts exactly the described topology, in described order", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + expect(topology(root.node)).toEqual([ + "workspace", + "transcript", + "bindings", + "+project", + "+project.Submit", + ]); + }); + + it("stacks a drawer as a child branch of the drawer below it", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + + const project = expectNode(root.node, "+project"); + const confirm = expectNode(root.node, "+confirm"); + + // Stacked, not adjacent: the second drawer is inside the first. + expect(confirm.parent).toBe(project); + expect(topology(project)).toEqual([ + "+project", + "+project.Submit", + "+confirm", + "+confirm.Commit", + ]); + }); + + it("draws the tree by walking it, each parent around its children", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + expect(paint(root.node)).toEqual([ + "Transcript:", + " entry-1", + "Bindings:", + " draft", + "— project: Which project should the README describe?", + " [ Submit ]", + ]); + }); + }); + + suite("reconciling preserves a matching keyed child", () => { + it("keeps the node, and the state its lifecycle is holding", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + const before = expectNode(root.node, "+project"); + yield* clock.advance(16); + yield* clock.advance(32); + expect(before.props.opened).toBe(2); + expect(before.props.at).toBe(32); + + // New input for the panels; the drawer's key and component are unchanged. + yield* show({ + panels: [{ key: "transcript", title: "Transcript", lines: ["entry-1", "entry-2"] }], + drawers: [PROJECT], + }); + + const after = expectNode(root.node, "+project"); + expect(after).toBe(before); + // The lifecycle was never restarted, so its count carries on rather than + // beginning again at zero. + expect(after.props.opened).toBe(2); + yield* clock.advance(48); + expect(after.props.opened).toBe(3); + }); + + it("replaces a node whose key is reused by a different component", function* () { + const { root, clock, offer } = yield* harness(); + + yield* offer([describe(Drawer, "slot", { drawer: PROJECT, above: [] })]); + const before = expectNode(root.node, "slot"); + expect(before.name).toBe("drawer"); + expect(clock.demand).toBe(1); + + // The same key, describing a different component. A key is not an + // identity on its own: this is a different child, so the drawer is + // unmounted rather than handed a Control's input. + yield* offer([describe(Control, "slot", { label: "Submit", action: "drawer.submit" })]); + + const after = expectNode(root.node, "slot"); + expect(after).not.toBe(before); + expect(after.name).toBe("control"); + expect(clock.demand).toBe(0); + }); + + it("reorders without remounting when the described order changes", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + const transcript = expectNode(root.node, "transcript"); + + yield* show({ panels: [PANELS[1], PANELS[0]], drawers: [PROJECT] }); + + expect(topology(root.node)).toEqual([ + "workspace", + "bindings", + "transcript", + "+project", + "+project.Submit", + ]); + expect(expectNode(root.node, "transcript")).toBe(transcript); + }); + }); + + suite("closing a drawer removes its whole branch", () => { + it("leaves no node, focus target, input path, frame demand or presentation", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + + const confirm = expectNode(root.node, "+confirm"); + const commit = expectNode(root.node, "+confirm.Commit"); + expect(clock.demand).toBe(2); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual([ + "transcript", + "bindings", + "+project", + "+project.Submit", + "+confirm", + "+confirm.Commit", + ]); + expect(press(root.node, commit, { key: "Enter" }).path).toEqual([ + "workspace", + "+project", + "+confirm", + "+confirm.Commit", + ]); + expect(paint(root.node).join("\n")).toContain("Commit and push"); + + // Close the top drawer: it is simply no longer described. + yield* show(workspace([PROJECT])); + + expect(find(root.node, "+confirm")).toBe(undefined); + expect(find(root.node, "+confirm.Commit")).toBe(undefined); + expect(topology(root.node)).toEqual([ + "workspace", + "transcript", + "bindings", + "+project", + "+project.Submit", + ]); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual([ + "transcript", + "bindings", + "+project", + "+project.Submit", + ]); + expect(clock.demand).toBe(1); + expect(paint(root.node).join("\n")).not.toContain("Commit and push"); + + // The branch is halted, not merely detached: its scope is gone, so a key + // sent to what used to be the control reaches no middleware at all. + expect(press(root.node, commit, { key: "Enter" }).path).toEqual([]); + expect(confirm.props.opened).toBe(0); + }); + + it("removes the whole stack when the drawer below it closes", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + expect(clock.demand).toBe(2); + + yield* show(workspace([])); + + expect(topology(root.node)).toEqual(["workspace", "transcript", "bindings"]); + expect(clock.demand).toBe(0); + }); + + it("stops the removed branch's frames rather than leaving them running", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + const confirm = expectNode(root.node, "+confirm"); + + yield* clock.advance(48); + expect(confirm.props.opened).toBe(1); + + yield* show(workspace([PROJECT])); + yield* clock.advance(16); + yield* clock.advance(32); + + // Its scope is destroyed, so the props it last wrote are all it has. + expect(confirm.props.opened).toBe(1); + expect(clock.demand).toBe(1); + }); + }); + + suite("input travels the live ancestry and comes back as an action", () => { + it("claims a key at the innermost branch that understands it", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + + const commit = expectNode(root.node, "+confirm.Commit"); + const delivered = press(root.node, commit, { key: "Enter" }); + + expect(delivered.action).toEqual({ kind: "drawer.commit", from: "Commit" }); + expect(delivered.path).toEqual(["workspace", "+project", "+confirm", "+confirm.Commit"]); + }); + + it("bubbles a key the control does not claim to the branch that does", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + + const commit = expectNode(root.node, "+confirm.Commit"); + const delivered = press(root.node, commit, { key: "Escape" }); + + // The control has no meaning for Escape; the drawer it is inside does. + expect(delivered.action).toEqual({ kind: "drawer.close", from: "+confirm" }); + }); + + it("answers from the input a branch was last reconciled to", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + const renamed: DrawerInput = { + ...PROJECT, + kind: "project", + controls: [{ label: "Submit", action: "drawer.retry" }], + }; + yield* show(workspace([renamed])); + + const submit = expectNode(root.node, "+project.Submit"); + expect(press(root.node, submit, { key: "Enter" }).action?.kind).toBe("drawer.retry"); + }); + }); + + suite("a key names one child", () => { + it("refuses two siblings under one key, and changes nothing doing it", function* () { + const { root, clock, show, offer } = yield* harness(); + yield* show(workspace([PROJECT])); + + const before = topology(root.node); + const drawer = expectNode(root.node, "+project"); + yield* clock.advance(16); + expect(drawer.props.opened).toBe(1); + + const refused = yield* offer([ + describe(Panel, "twice", PANELS[0]), + describe(Panel, "twice", PANELS[1]), + ]); + + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error).toBeInstanceOf(DuplicateKey); + if (refused.error instanceof DuplicateKey) { + expect(refused.error.key).toBe("twice"); + } + } + + // The tree is exactly what it was: nothing mounted, nothing removed, and + // the lifecycle that was already running is still the one running. + expect(topology(root.node)).toEqual(before); + expect(expectNode(root.node, "+project")).toBe(drawer); + yield* clock.advance(32); + expect(drawer.props.opened).toBe(2); + expect(clock.demand).toBe(1); + }); + + it("refuses a duplicate described deeper in the tree", function* () { + const { root, show, offer } = yield* harness(); + yield* show(workspace([])); + + const duplicated: DrawerInput = { + ...PROJECT, + controls: [ + { label: "Submit", action: "drawer.submit" }, + { label: "Submit", action: "drawer.retry" }, + ], + }; + const refused = yield* offer(shell(workspace([duplicated]))); + + expect(refused.ok).toBe(false); + if (!refused.ok && refused.error instanceof DuplicateKey) { + expect(refused.error.parent).toBe("+project"); + expect(refused.error.key).toBe("+project.Submit"); + } + expect(topology(root.node)).toEqual(["workspace", "transcript", "bindings"]); + }); + + it("mounts exactly one node and one lifecycle for that key afterwards", function* () { + const { root, clock, show, offer } = yield* harness(); + + const refused = yield* offer([ + describe(Panel, "twice", PANELS[0]), + describe(Panel, "twice", PANELS[1]), + ]); + expect(refused.ok).toBe(false); + + yield* show(workspace([PROJECT])); + + expect(topology(root.node).filter((key) => key === "+project")).toEqual(["+project"]); + expect(clock.demand).toBe(1); + }); + }); + + suite("a retained branch is told what changed", () => { + it("keeps its node and local state while acting on the new input", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + const drawer = expectNode(root.node, "+project"); + yield* clock.advance(16); + expect(drawer.props.opened).toBe(1); + expect(drawer.props.prompt).toBe(PROJECT.prompt); + + const asked: DrawerInput = { ...PROJECT, prompt: "Which project, exactly?" }; + yield* show(workspace([asked])); + + // Same node, same lifecycle, same count — and the new input already + // applied by the time the reconcile returned. + expect(expectNode(root.node, "+project")).toBe(drawer); + expect(drawer.props.opened).toBe(1); + expect(drawer.props.prompt).toBe("Which project, exactly?"); + expect(drawer.props.prompt).not.toBe(PROJECT.prompt); + + // The local state carried on rather than restarting. + yield* clock.advance(32); + expect(drawer.props.opened).toBe(2); + }); + + it("gives presentation, children and onPress that same current input", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + + const asked: DrawerInput = { + ...PROJECT, + prompt: "Which project, exactly?", + controls: [{ label: "Submit", action: "drawer.retry" }], + }; + yield* show(workspace([asked])); + + expect(paint(root.node).join("\n")).toContain("Which project, exactly?"); + expect(topology(root.node)).toContain("+project.Submit"); + const submit = expectNode(root.node, "+project.Submit"); + expect(press(root.node, submit, { key: "Enter" }).action?.kind).toBe("drawer.retry"); + }); + }); + + suite("one description carries one input", () => { + it("moves the lifecycle, children, presentation and keys together", function* () { + const { root, offer } = yield* harness(); + yield* offer([describe(Probe, "probe", { value: 2 })]); + + const probe = expectNode(root.node, "probe"); + expect(probe.props.lifecycle).toBe(2); + expect(probe.props.applied).toBe(0); + expect(paint(root.node)).toEqual(["present:2", "leaf:2"]); + expect(press(root.node, probe, { key: "Enter" }).action?.kind).toBe("probe.2"); + + yield* offer([describe(Probe, "probe", { value: 3 })]); + + // The node and its local state survived: the same node, and a counter + // that advanced rather than restarting. + expect(expectNode(root.node, "probe")).toBe(probe); + expect(probe.props.applied).toBe(1); + + // And every reader of the input moved to 3 together. There is no member + // to replace, so there is no arrangement where one of these is 2. + expect(probe.props.lifecycle).toBe(3); + expect(paint(root.node)).toEqual(["present:3", "leaf:3"]); + expect(press(root.node, probe, { key: "Enter" }).action?.kind).toBe("probe.3"); + }); + + it("split-description: a description has no input to replace", function* () { + const intended = describe(Probe, "probe", { value: 2 }); + + // There is no payload beside the closures, so there is nothing a spread + // could overwrite to leave the lifecycle on one input and the drawing on + // another. This is the member the split construction needed. + expect("input" in intended).toBe(false); + expect(Object.keys(intended)).toEqual(["key", "name", "identity", "focusable"]); + + // @ts-expect-error and nothing assembled from a description's parts is a + // description: only `describe()` can make one, so the split construction + // is rejected where it is written rather than detected once it has run. + const impersonator: Description = { ...intended, input: { value: 3 } }; + void impersonator; + + expect(intended.key).toBe("probe"); + }); + }); + + suite("a frame is delivered, not merely sent", () => { + it("has been applied by every subscriber once advancing returns", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + const project = expectNode(root.node, "+project"); + const confirm = expectNode(root.node, "+confirm"); + + yield* clock.advance(16); + + // No settling, no sleeping: the operation completed, so the frame landed. + expect(project.props.at).toBe(16); + expect(confirm.props.at).toBe(16); + expect(project.props.opened).toBe(1); + expect(confirm.props.opened).toBe(1); + }); + + it("keeps delivering to the survivor when one subscriber is removed", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + expect(clock.demand).toBe(2); + + yield* clock.advance(16); + const confirm = expectNode(root.node, "+confirm"); + yield* show(workspace([PROJECT])); + + expect(clock.demand).toBe(1); + + // The removed branch is not waited for, and does not hold the clock open. + yield* clock.advance(32); + + const project = expectNode(root.node, "+project"); + expect(project.props.at).toBe(32); + expect(project.props.opened).toBe(2); + // It also receives no later frame. + expect(confirm.props.at).toBe(16); + expect(confirm.props.opened).toBe(1); + }); + + it("releases a producer waiting on a branch that goes away mid-delivery", function* () { + const { root, clock, offer } = yield* harness(); + const held = withResolvers(); + + yield* offer([ + describe(Held, "held", { hold: held.operation }), + describe(Panel, "panel", PANELS[0]), + ]); + expect(clock.demand).toBe(1); + + const advancing = yield* spawn(() => clock.advance(16)); + // Wait until the frame has actually reached the slow branch, so the + // removal below happens while the producer is still owed an answer. + const slow = expectNode(root.node, "held"); + yield* when(function* () { + expect(slow.props.at).toBe(16); + }); + + yield* offer([describe(Panel, "panel", PANELS[0])]); + + const finished = yield* race([ + (function* delivered(): Operation { + yield* advancing; + return "delivered"; + })(), + (function* stranded(): Operation { + yield* sleep(500); + return "stranded"; + })(), + ]); + + expect(finished).toBe("delivered"); + expect(clock.demand).toBe(0); + held.resolve(); + }); + + it("leaves no demand and no waiting producer once the root is gone", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + yield* clock.advance(16); + + yield* until(root.destroy()); + + expect(clock.demand).toBe(0); + // Advancing a clock nobody is subscribed to completes rather than hanging. + yield* clock.advance(32); + }); + }); + + suite("a value with state is owned by a scope", () => { + it("ends a handoff's state with the scope that acquired it", function* () { + const acquired = withResolvers>(); + + const owner = yield* spawn(function* () { + const handoff = yield* useHandoff(); + acquired.resolve(handoff); + yield* suspend(); + }); + const handoff = yield* acquired.operation; + + // A receiver acquired outside the handoff's own scope, so it cannot be + // torn down merely by being a descendant of it. + const receiving = yield* spawn(function* () { + yield* handoff.receive(); + yield* suspend(); + }); + yield* when(function* () { + expect(handoff.demand).toBe(1); + }); + + yield* until(owner.halt()); + + // The scope that owned the handoff ended, so what it was holding is + // gone — not a set still counting a receiver nothing can deliver to. + expect(handoff.demand).toBe(0); + yield* until(receiving.halt()); + }); + + it("acknowledges a queued value when it is applied, not when it is fetched", function* () { + const handoff = yield* useHandoff(); + const receiver = yield* handoff.receive(); + let delivered = false; + + // Nobody is waiting, so this value queues. + const producing = yield* spawn(function* () { + yield* handoff.deliver(16); + delivered = true; + }); + yield* sleep(5); + expect(delivered).toBe(false); + + expect(yield* receiver.next()).toBe(16); + + // Fetching a queued value is not applying it. A release that lived on the + // receiver rather than on the value would have fired on that call, and + // told the producer its frame had landed before anything had been done + // with it. + yield* sleep(5); + expect(delivered).toBe(false); + + const applying = yield* spawn(() => receiver.next()); + yield* producing; + expect(delivered).toBe(true); + yield* until(applying.halt()); + }); + + it("gives a clock's demand back when its scope ends", function* () { + const acquired = withResolvers(); + + const owner = yield* spawn(function* () { + const clock = yield* useFrameClock(); + yield* clock.subscribe(); + acquired.resolve(clock); + yield* suspend(); + }); + const clock = yield* acquired.operation; + expect(clock.demand).toBe(1); + + yield* until(owner.halt()); + + expect(clock.demand).toBe(0); + }); + }); + + suite("one location asks for one place", () => { + it("is unmoved by reordering siblings that are not asking", function* () { + const asking = describe(Asking, "asks", { within: [] }); + const orders: readonly (readonly Description[])[] = [ + [describe(Plain, "one", {}), asking, describe(Plain, "two", {})], + [asking, describe(Plain, "one", {}), describe(Plain, "two", {})], + [describe(Plain, "two", {}), describe(Plain, "one", {}), asking], + ]; + + for (const order of orders) { + const { root, offer } = yield* harness(); + const composed = yield* offer(order); + expect(composed.ok).toBe(true); + + // Arbitration is structural, so where a sibling sits is not part of + // the answer. Flattening the tree and taking the last claimant made it + // part of the answer. + expect(keyOf(current(root.node))).toBe("asks"); + } + }); + + it("lets a claim inside a claim win, because it is the same place deeper", function* () { + const { root, offer } = yield* harness(); + const composed = yield* offer([ + describe(Plain, "outside", {}), + describe(Asking, "outer", { within: [describe(Asking, "inner", { within: [] })] }), + ]); + + expect(composed.ok).toBe(true); + expect(keyOf(current(root.node))).toBe("inner"); + // And only what is inside the deepest claim can be reached at all. + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual(["inner"]); + }); + + it("refuses two claims in unrelated subtrees, before anything moves", function* () { + const { root, offer } = yield* harness(); + yield* offer([describe(Plain, "one", {}), describe(Asking, "asks", { within: [] })]); + + const before = topology(root.node); + const focused = current(root.node); + + const refused = yield* offer([ + describe(Asking, "here", { within: [] }), + describe(Asking, "there", { within: [] }), + ]); + + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error).toBeInstanceOf(AmbiguousFocus); + if (refused.error instanceof AmbiguousFocus) { + expect(refused.error.claims).toEqual(["here", "there"]); + } + } + + // Nothing was mounted, nothing was removed, and focus did not move. + expect(topology(root.node)).toEqual(before); + expect(current(root.node)).toBe(focused); + }); + }); + + suite("negative controls", () => { + it("duplicate-keys-permitted: one of two same-keyed siblings becomes unreachable", function* () { + // The reconciler addresses a parent's mounted children by key. Two + // siblings under one key collapse to a single entry, so the shadowed one + // is never matched for an update and never counted as undescribed for + // removal — it stays mounted, and holding whatever it holds, for as long + // as its parent lives. + const permitted = [ + { key: "twice", node: "first" }, + { key: "twice", node: "second" }, + ]; + const addressable = new Map(permitted.map((child) => [child.key, child.node])); + + expect(addressable.size).toBe(1); + expect([...addressable.values()]).toEqual(["second"]); + expect([...addressable.values()]).not.toContain("first"); + + // Which is why the reconciler refuses before either node can exist. + const { root, offer } = yield* harness(); + const refused = yield* offer([ + describe(Panel, "twice", PANELS[0]), + describe(Panel, "twice", PANELS[1]), + ]); + + expect(refused.ok).toBe(false); + expect(topology(root.node)).toEqual([]); + }); + + it("erased-payload: a replaceable input plus a bivariant sink splits one component in two", function* () { + // The shape this boundary used to have, rebuilt here: a description that + // carries its input where anything can reach it, beside closures over the + // input it was made with — and a sink whose parameter is `unknown`, which + // a method signature accepts bivariantly. + interface ErasedDescription { + readonly input: unknown; + present(): string; + } + interface ErasedSink { + accept(input: unknown): void; + } + + let lifecycle = 0; + const sink: ErasedSink = { + accept: (value: number) => { + lifecycle = value; + }, + }; + + const intended: ErasedDescription = { input: 2, present: () => "present:2" }; + const split: ErasedDescription = { ...intended, input: 3 }; + + // Nothing refuses it: the payload is writable and the closures are not. + sink.accept(split.input); + + expect(lifecycle).toBe(3); + expect(split.present()).toBe("present:2"); + // One component, two inputs, and no identity check can tell — matching + // the component only proves who made the original description. + expect(lifecycle).not.toBe(Number(split.present().split(":")[1])); + }); + + it("positional-only reconciliation: matching by index moves state to the wrong child", function* () { + const { root, show } = yield* harness(); + yield* show(workspace([PROJECT])); + const transcript = expectNode(root.node, "transcript"); + const bindings = expectNode(root.node, "bindings"); + + // What an index-matched reconciler would have decided for the reorder: + // position 0 keeps position 0's node, so `bindings` would inherit the + // node `transcript` was mounted on. + const byPosition = [PANELS[1], PANELS[0]].map((panel, index) => ({ + key: panel.key, + node: index === 0 ? transcript : bindings, + })); + expect(byPosition[0]).toEqual({ key: "bindings", node: transcript }); + + yield* show({ panels: [PANELS[1], PANELS[0]], drawers: [PROJECT] }); + + // Matching by key keeps each child on the node it was mounted on. + expect(expectNode(root.node, "bindings")).toBe(bindings); + expect(expectNode(root.node, "transcript")).toBe(transcript); + }); + + it("hidden-but-live drawer: hiding leaves every contribution behind", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + const commit = expectNode(root.node, "+confirm.Commit"); + + // A "hide" that only stops drawing: the node stays, so the focus target, + // the input path and the frame demand all stay with it. + const hidden = expectNode(root.node, "+confirm"); + hidden.set("hidden", true); + + expect(focusTargets(root.node).map((node) => keyOf(node))).toContain("+confirm.Commit"); + expect(press(root.node, commit, { key: "Enter" }).action).toBeDefined(); + expect(clock.demand).toBe(2); + + // Removing it is what makes those contributions stop. + yield* show(workspace([PROJECT])); + + expect(focusTargets(root.node).map((node) => keyOf(node))).not.toContain("+confirm.Commit"); + expect(press(root.node, commit, { key: "Enter" }).action).toBe(undefined); + expect(clock.demand).toBe(1); + }); + + it("parallel registry: a collection beside the tree outlives what it records", function* () { + const { root, show } = yield* harness(); + + // A registry that records what was mounted, the way a component runtime + // beside Freedom would have to. + const registry: string[] = []; + const record = (node: Node): void => { + const key = keyOf(node); + if (key !== undefined && !registry.includes(key)) { + registry.push(key); + } + for (const child of node.children) { + record(child); + } + }; + + yield* show(workspace([PROJECT, CONFIRM])); + record(root.node); + expect(registry).toContain("+confirm"); + + yield* show(workspace([PROJECT])); + record(root.node); + + // Nothing told the registry, so it still says the drawer is there. + expect(registry).toContain("+confirm"); + // The tree needed telling by nobody. + expect(topology(root.node)).not.toContain("+confirm"); + }); + }); + + suite("the tree is the only thing that is mounted", () => { + it("destroys every branch when the root goes", function* () { + const { root, clock, show } = yield* harness(); + yield* show(workspace([PROJECT, CONFIRM])); + expect(clock.demand).toBe(2); + + yield* until(root.destroy()); + + expect(clock.demand).toBe(0); + }); + }); +}); diff --git a/scripts/tests/repl-compose-router.test.ts b/scripts/tests/repl-compose-router.test.ts new file mode 100644 index 000000000..bd4db045c --- /dev/null +++ b/scripts/tests/repl-compose-router.test.ts @@ -0,0 +1,837 @@ +/** + * One URL, one model, one answer. + * + * The reverted first #840 experiment could resolve a route, but its resolution + * shared topology decisions with view projection, layout and mounting, so a + * case that passed proved those representations agreed rather than that the + * execution went anywhere. The cases here are chosen to be ones a router that + * kept state, searched the tree, or treated drawers as a set could not satisfy: + * the same URL answered twice against two models, a scope reached only through + * its real parent, and a drawer stack that is an ordered prefix or nothing. + * + * Each claim carries a named control — a stand-in written here — that *accepts* + * what the real router refuses, or produces the answer a weaker implementation + * would have produced. A refusal nothing else would have accepted is a refusal + * nobody is checking. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { readTextFile } from "@effectionx/fs"; +import type { Operation } from "effection"; +import { fileURLToPath } from "node:url"; + +import { + EXECUTION, + HISTORY, + historyThrough, + projectModel, + SERIAL_HISTORY, +} from "../repl-compose/history.ts"; +import type { ReplHistory } from "../repl-compose/history.ts"; +import type { Checkpoint, Entry, ReplModel, Scope } from "../repl-compose/model.ts"; +import { + decodeRoute, + encodeRoute, + entryRoute, + resolveRoute, + RouteRefusal, + ROUTE_SURFACES, + RouteSyntaxError, + surfaceRoute, +} from "../repl-compose/router.ts"; +import type { + EntryRoute, + ResolvedLocation, + Route, + RouteSelection, +} from "../repl-compose/router.ts"; + +/** The whole recorded execution: head `cp-10`, two live suspensions. */ +const MODEL = projectModel(EXECUTION); + +/** The same execution one suspension earlier: head `cp-08`, stack `project`. */ +const EARLY = projectModel(EXECUTION, historyThrough("cp-08")); + +/** The same execution before `document` opened anything: head `cp-02`. */ +const OPENING = projectModel(EXECUTION, historyThrough("cp-02")); + +/** The same records, recorded by another execution. */ +const OTHER = projectModel("e2"); + +/** + * Two entries, one after the other: `entry-1` answers a `project` wait in its + * `document` scope and settles, then `entry-2` opens the same kind at the same + * path. Only the owner tells them apart. + */ +const SERIAL = projectModel(EXECUTION, SERIAL_HISTORY); + +/** The location #840 names, written the one way it is written. */ +const REPRESENTATIVE = + "xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect"; + +/** The same location, asked of whatever each model's head is. */ +const AT_HEAD = "xmd://repl/e1/transcript/entry-1/document/+project/+confirm"; + +function decoded(url: string): Route { + const result = decodeRoute(url); + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function refusedDecoding(url: string): RouteSyntaxError { + const result = decodeRoute(url); + if (result.ok) { + throw new Error(`${JSON.stringify(url)} was accepted, and should not have been`); + } + if (!(result.error instanceof RouteSyntaxError)) { + throw result.error; + } + return result.error; +} + +/** One decoded location that is inside an entry. */ +function insideEntry(url: string): EntryRoute { + const route = decoded(url); + if (route.kind !== "entry") { + throw new Error(`${JSON.stringify(url)} names no entry`); + } + return route; +} + +/** A checked route, or the reason it could not be built. */ +function built(result: ReturnType | ReturnType): Route { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function resolved(url: string, model: ReplModel): ResolvedLocation { + const result = resolveRoute(decoded(url), model); + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function refused(url: string, model: ReplModel): RouteRefusal { + const result = resolveRoute(decoded(url), model); + if (result.ok) { + throw new Error(`${JSON.stringify(url)} resolved, and should have been refused`); + } + if (!(result.error instanceof RouteRefusal)) { + throw result.error; + } + return result.error; +} + +/** Every scope in one entry that has not exited, as `entry/scope/path`. */ +function live(entry: Entry): string[] { + const found: string[] = []; + const walk = (scopes: readonly Scope[], path: string[]): void => { + for (const scope of scopes) { + if (!scope.settled) { + found.push([entry.id, ...path, scope.name].join("/")); + } + walk(scope.children, [...path, scope.name]); + } + }; + walk(entry.scopes, []); + return found; +} + +/** The checkpoint one marker names, read out of the model rather than resolved. */ +function checkpoint(model: ReplModel, marker: string): Checkpoint { + const found = model.checkpoints.find((candidate) => candidate.marker === marker); + if (found === undefined) { + throw new Error(`the model records no ${marker}`); + } + return found; +} + +describe("REPL composition: routing", () => { + describe("history projects into one immutable model", () => { + it("records one checkpoint per history record, ending at the head", function* () { + expect(MODEL.execution).toBe("e1"); + expect(MODEL.checkpoints.length).toBe(HISTORY.length); + expect(MODEL.head).toBe("cp-10"); + expect(MODEL.checkpoints[MODEL.checkpoints.length - 1].marker).toBe("cp-10"); + }); + + it("gives each checkpoint its own complete moment", function* () { + const opened = checkpoint(MODEL, "cp-03"); + const head = checkpoint(MODEL, "cp-10"); + + expect(opened.entries[0]).not.toBe(head.entries[0]); + expect(opened.entries[0].scopes[0].children.map((scope) => scope.name)).toEqual(["plan"]); + expect(head.entries[0].scopes[0].children.map((scope) => scope.name)).toEqual([ + "plan", + "write", + "publish", + ]); + + // Leaving a scope settles it; it stays in the tree, so a URL can still + // name it. + expect(opened.entries[0].scopes[0].children[0].settled).toBe(false); + expect(head.entries[0].scopes[0].children[0].settled).toBe(true); + }); + + it("records a two-drawer stack only where both suspensions are unanswered", function* () { + expect(checkpoint(MODEL, "cp-04").suspensions.map((one) => one.kind)).toEqual(["review"]); + expect(checkpoint(MODEL, "cp-05").suspensions.map((one) => one.kind)).toEqual([]); + expect(checkpoint(MODEL, "cp-08").suspensions.map((one) => one.kind)).toEqual(["project"]); + expect(checkpoint(MODEL, "cp-10").suspensions.map((one) => one.kind)).toEqual([ + "project", + "confirm", + ]); + }); + + it("names the entry and the scope that own each suspension", function* () { + expect( + checkpoint(MODEL, "cp-10").suspensions.map((one) => [one.entry, ...one.scope].join("/")), + ).toEqual(["entry-1/document/write", "entry-1/document/publish"]); + }); + + it("keeps the representative execution to one entry, because entries are serial", function* () { + for (const point of MODEL.checkpoints) { + expect(point.entries.map((entry) => entry.id)).toEqual(["entry-1"]); + expect(point.entries.filter((entry) => !entry.settled).length).toBeLessThanOrEqual(1); + } + }); + + it("refuses to describe two entries running at once", function* () { + const overlapping: ReplHistory = [ + ...HISTORY, + { + marker: "cp-11", + at: 54, + kind: "entry.submitted", + entry: "entry-2", + scope: [], + detail: "Update the changelog", + }, + ]; + + expect(() => projectModel(EXECUTION, overlapping)).toThrow( + /submits an entry while entry-1 is still running/, + ); + }); + + it("freezes the model through every value it reaches", function* () { + const scopes = MODEL.checkpoints[9].entries[0].scopes; + expect(() => (MODEL.checkpoints as Checkpoint[]).push(MODEL.checkpoints[0])).toThrow(); + expect(() => (scopes as Scope[]).pop()).toThrow(); + expect(() => { + (scopes[0] as { settled: boolean }).settled = true; + }).toThrow(); + }); + }); + + describe("decoding is structural and encoding is canonical", () => { + it("round-trips every canonical spelling", function* () { + const corpus = [ + "xmd://repl/e1/sessions", + "xmd://repl/e1/transcript/entry-1", + "xmd://repl/e1/transcript/entry-1/document/plan", + "xmd://repl/e1/transcript/entry-1/document/+project", + "xmd://repl/e1/history/entry-1?at=cp-04", + "xmd://repl/e1/input/entry-1?at=cp-04&inspect&draft=%3CPlan%3E%20write%20it", + REPRESENTATIVE, + ]; + + for (const url of corpus) { + expect(encodeRoute(decoded(url))).toBe(url); + } + }); + + it("keeps every part of the location it was given", function* () { + const route = insideEntry(REPRESENTATIVE); + + expect(route.execution).toBe("e1"); + expect(route.surface).toBe("transcript"); + expect(route.entry).toBe("entry-1"); + expect(route.scopes).toEqual(["document"]); + expect(route.drawers).toEqual(["project", "confirm"]); + expect(route.at).toBe("cp-10"); + expect(route.inspect).toBe(true); + expect(route.draft).toBe(""); + }); + + it("carries a draft and a separator through the encoding", function* () { + const route = built( + entryRoute({ + execution: "e1", + surface: "input", + entry: "entry-1", + scopes: ["a/b"], + drawers: ["+odd"], + at: "cp-04", + inspect: true, + draft: " write it", + }), + ); + const url = encodeRoute(route); + + expect(url).toBe( + "xmd://repl/e1/input/entry-1/a%2Fb/+%2Bodd?at=cp-04&inspect&draft=%3CPlan%3E%20write%20it", + ); + expect(decoded(url)).toEqual(route); + }); + + it("refuses a URL that is malformed or names two locations at once", function* () { + const cases: [string, string][] = [ + ["https://repl/e1/transcript", "a REPL route starts with"], + ["xmd://repl/e1", "names no surface"], + ["xmd://repl//transcript", "names no execution"], + ["xmd://repl/e1/plan/entry-1", "is not a surface"], + ["xmd://repl/e1/transcript/entry-1//document", "has an empty path segment"], + ["xmd://repl/e1/transcript/entry-1/", "has an empty path segment"], + ["xmd://repl/e1/transcript/entry-1/+project/document", "is a scope below a drawer"], + ["xmd://repl/e1/transcript/+project", "a drawer is opened inside an entry"], + ["xmd://repl/e1/transcript/entry-1?inspect=yes", "inspect takes no value"], + ["xmd://repl/e1/transcript/entry-1?inspect", "inspect needs the marker"], + ["xmd://repl/e1/transcript/entry-1?at=", "at= names no marker"], + ["xmd://repl/e1/transcript/entry-1?at=cp-10&at=cp-04", "is written twice"], + ["xmd://repl/e1/transcript/entry-1?when=cp-10", "is not part of a REPL route"], + ]; + + for (const [url, reason] of cases) { + expect(refusedDecoding(url).message).toContain(reason); + } + }); + + it("answers an equivalent spelling with the same location, canonically spelled", function* () { + const equivalent: [string, string][] = [ + [ + "xmd://repl/e1/transcript/entry-1?inspect&at=cp-10", + "xmd://repl/e1/transcript/entry-1?at=cp-10&inspect", + ], + ["xmd://repl/e1/transcript/%65ntry-1", "xmd://repl/e1/transcript/entry-1"], + ["xmd://repl/%651/transcript/entry-1", "xmd://repl/e1/transcript/entry-1"], + [ + "xmd://repl/e1/transcript/entry-1?draft=plain&at=cp-04", + "xmd://repl/e1/transcript/entry-1?at=cp-04&draft=plain", + ], + ["xmd://repl/e1/transcript/entry-1?draft=", "xmd://repl/e1/transcript/entry-1"], + ]; + + for (const [written, canonical] of equivalent) { + // Non-vacuous by construction: each spelling differs from the canonical + // one, so an encoder that echoed its input would fail here. + expect(written).not.toBe(canonical); + expect(decoded(written)).toEqual(decoded(canonical)); + expect(encodeRoute(decoded(written))).toBe(canonical); + } + }); + }); + + describe("resolving against a model", () => { + it("answers the representative location with the exact values it resolved", function* () { + const location = resolved(REPRESENTATIVE, MODEL); + const head = checkpoint(MODEL, "cp-10"); + + expect(location.checkpoint).toBe(head); + expect(location.entry).toBe(head.entries[0]); + expect(location.scopes).toEqual([head.entries[0].scopes[0]]); + expect(location.scopes[0]).toBe(head.entries[0].scopes[0]); + expect(location.drawers[0]).toBe(head.suspensions[0]); + expect(location.drawers[1]).toBe(head.suspensions[1]); + expect(location.surface).toBe("transcript"); + expect(location.inspecting).toBe(true); + }); + + it("resolves a drawer path that is a prefix of the stack", function* () { + const location = resolved("xmd://repl/e1/transcript/entry-1/document/+project", MODEL); + + expect(location.drawers.map((one) => one.kind)).toEqual(["project"]); + expect(location.drawers[0]).toBe(checkpoint(MODEL, "cp-10").suspensions[0]); + }); + + it("resolves a nested scope through its real parent, settled or not", function* () { + const location = resolved("xmd://repl/e1/transcript/entry-1/document/plan", MODEL); + + expect(location.scopes.map((scope) => scope.name)).toEqual(["document", "plan"]); + expect(location.scopes[1].settled).toBe(true); + }); + + it("selects the head when no marker is named", function* () { + expect(resolved("xmd://repl/e1/sessions", MODEL).checkpoint.marker).toBe("cp-10"); + expect(resolved("xmd://repl/e1/sessions", EARLY).checkpoint.marker).toBe("cp-08"); + }); + + it("answers one location differently against two models, changing neither", function* () { + const before = [JSON.stringify(MODEL), JSON.stringify(EARLY)]; + const route = decoded(AT_HEAD); + + const against = resolveRoute(route, MODEL); + const earlier = resolveRoute(route, EARLY); + + expect(against.ok).toBe(true); + expect(earlier.ok).toBe(false); + if (!earlier.ok && earlier.error instanceof RouteRefusal) { + expect(earlier.error.position).toBe("drawer[1]"); + expect(earlier.error.found).toEqual(["project"]); + } + expect([JSON.stringify(MODEL), JSON.stringify(EARLY)]).toEqual(before); + expect(decoded(AT_HEAD)).toEqual(route); + }); + + it("refuses a location recorded by another execution", function* () { + const refusal = refused(REPRESENTATIVE, OTHER); + + expect(refusal.position).toBe("execution"); + expect(refusal.segment).toBe("e1"); + expect(refusal.found).toEqual(["e2"]); + }); + }); + + describe("a refusal names the first segment that did not resolve", () => { + it("refuses a flattened scope path at the segment that skipped a parent", function* () { + const refusal = refused("xmd://repl/e1/transcript/entry-1/plan", MODEL); + + expect(refusal.position).toBe("scope[0]"); + expect(refusal.segment).toBe("plan"); + expect(refusal.found).toEqual(["document"]); + expect(refusal.message).toContain("is not a scope of entry-1 at cp-10"); + }); + + it("refuses a fabricated scope and says what is there instead", function* () { + const refusal = refused("xmd://repl/e1/transcript/entry-1/document/review", MODEL); + + expect(refusal.position).toBe("scope[1]"); + expect(refusal.found).toEqual(["plan", "write", "publish"]); + }); + + it("refuses a scope the execution had not yet entered", function* () { + const refusal = refused("xmd://repl/e1/transcript/entry-1/document/plan", OPENING); + + expect(refusal.position).toBe("scope[1]"); + expect(refusal.found).toEqual([]); + expect(refusal.message).toContain("the scopes there are none"); + }); + + it("refuses a reordered drawer stack at the first drawer", function* () { + const refusal = refused("xmd://repl/e1/transcript/entry-1/document/+confirm/+project", MODEL); + + expect(refusal.position).toBe("drawer[0]"); + expect(refusal.segment).toBe("confirm"); + expect(refusal.found).toEqual(["project"]); + }); + + it("refuses a drawer path that skips its real parent", function* () { + const refusal = refused("xmd://repl/e1/transcript/entry-1/document/+confirm", MODEL); + + expect(refusal.position).toBe("drawer[0]"); + expect(refusal.found).toEqual(["project"]); + }); + + it("refuses an extra drawer beyond the recorded stack", function* () { + const refusal = refused( + "xmd://repl/e1/transcript/entry-1/document/+project/+confirm/+review", + MODEL, + ); + + expect(refusal.position).toBe("drawer[2]"); + expect(refusal.found).toEqual(["project", "confirm"]); + expect(refusal.message).toContain("there is no drawer 3 of entry-1 at cp-10"); + }); + + it("refuses a drawer that belongs to another checkpoint", function* () { + const refusal = refused( + "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-04&inspect", + MODEL, + ); + + expect(refusal.position).toBe("drawer[0]"); + expect(refusal.found).toEqual(["review"]); + expect(refusal.message).toContain("its suspension stack is review"); + }); + + it("refuses an entry and a marker nothing recorded", function* () { + const entry = refused("xmd://repl/e1/transcript/entry-9", MODEL); + expect(entry.position).toBe("entry"); + expect(entry.found).toEqual(["entry-1"]); + + const marker = refused("xmd://repl/e1/transcript/entry-1?at=cp-99", MODEL); + expect(marker.position).toBe("at"); + expect(marker.found).toContain("cp-10"); + }); + }); + + describe("negative controls", () => { + it("partial-resolution: a resolver that stops after the entry accepts a fabricated scope", function* () { + const url = "xmd://repl/e1/transcript/entry-1/document/review"; + const route = insideEntry(url); + const head = checkpoint(MODEL, "cp-10"); + + const partial = head.entries.some((entry) => entry.id === route.entry); + + expect(partial).toBe(true); + expect(refused(url, MODEL).position).toBe("scope[1]"); + }); + + it("flattened-scopes: a resolver that searches the whole tree accepts a skipped parent", function* () { + const url = "xmd://repl/e1/transcript/entry-1/plan"; + const route = insideEntry(url); + + const anywhere = (scopes: readonly Scope[], name: string): boolean => + scopes.some((scope) => scope.name === name || anywhere(scope.children, name)); + + expect(anywhere(checkpoint(MODEL, "cp-10").entries[0].scopes, route.scopes[0])).toBe(true); + expect(refused(url, MODEL).position).toBe("scope[0]"); + }); + + it("drawers-as-a-set: membership accepts a reordered stack the prefix rule refuses", function* () { + const url = "xmd://repl/e1/transcript/entry-1/document/+confirm/+project"; + const route = insideEntry(url); + const open = checkpoint(MODEL, "cp-10").suspensions.map((one) => one.kind); + + const asSet = route.drawers.every((drawer) => open.includes(drawer)); + + expect(asSet).toBe(true); + expect(refused(url, MODEL).position).toBe("drawer[0]"); + }); + + it("last-wins-decoding: resolving a repeated key would pick one of two locations", function* () { + const url = "xmd://repl/e1/transcript/entry-1?at=cp-10&at=cp-04"; + + // A decoder that took the last value would have answered a location, and + // a decoder that took the first would have answered a different one. Both + // are spellings of an ask nobody made. + const [first, last] = ["cp-10", "cp-04"].map((at) => + encodeRoute( + built( + entryRoute({ + execution: "e1", + surface: "transcript", + entry: "entry-1", + scopes: [], + drawers: [], + at, + inspect: false, + draft: "", + }), + ), + ), + ); + + expect(first).not.toBe(last); + expect(refusedDecoding(url).message).toContain("is written twice"); + }); + + it("structural-normalizing: accepting a spelling is not accepting a structure", function* () { + // Equivalence is about how a location is written. A scope below a drawer + // and a drawer outside an entry are different structures, not different + // spellings, so relaxing the first must not relax the second. + expect( + refusedDecoding("xmd://repl/e1/transcript/entry-1/+project/document").message, + ).toContain("is a scope below a drawer"); + expect(refusedDecoding("xmd://repl/e1/transcript/+project").message).toContain( + "a drawer is opened inside an entry", + ); + }); + }); + + describe("malformed text refuses instead of throwing", () => { + it("refuses malformed percent-encoding in every part it decodes", function* () { + const cases: [string, string][] = [ + ["xmd://repl/%/transcript", "execution"], + ["xmd://repl/e1/transcript/%", "entry"], + ["xmd://repl/e1/transcript/entry-1/%E0%A4%A", "scope"], + ["xmd://repl/e1/transcript/entry-1/+%", "drawer"], + ["xmd://repl/e1/transcript/entry-1?at=%", "at"], + ["xmd://repl/e1/transcript/entry-1?draft=%", "draft"], + ]; + + for (const [url, part] of cases) { + const refusal = refusedDecoding(url); + expect(refusal.part).toBe(part); + expect(refusal.message).toContain("is not valid percent-encoding"); + } + }); + + it("answers a Result for anything at all, including text that is not a URL", function* () { + // The signature promises `Result` for untrusted text, so nothing + // here may leave by throwing. + for (const url of ["", "%", "xmd://repl/", "xmd://repl/%/%", "xmd://repl/e1/transcript?%="]) { + expect(decodeRoute(url).ok).toBe(false); + } + }); + }); + + describe("a Route cannot hold a structure its encoder would change", () => { + it("survives decodeRoute(encodeRoute(route)) for every representable route", function* () { + const selections: RouteSelection[] = [ + { inspect: false, draft: "" }, + { at: "cp-04", inspect: false, draft: "" }, + { at: "cp-04", inspect: true, draft: "" }, + { at: "cp-04", inspect: true, draft: " write it" }, + { inspect: false, draft: "a/b?c&d=e+f%g" }, + ]; + + const routes: Route[] = []; + for (const selection of selections) { + for (const surface of ROUTE_SURFACES) { + routes.push(built(surfaceRoute({ execution: "e1", surface, ...selection }))); + routes.push( + built( + entryRoute({ + execution: "e1", + surface, + entry: "entry-1", + scopes: [], + drawers: [], + ...selection, + }), + ), + ); + routes.push( + built( + entryRoute({ + execution: "a/b +c", + surface, + entry: "entry 1", + scopes: ["document", "a+b"], + drawers: ["project", "+confirm"], + ...selection, + }), + ), + ); + } + } + + expect(routes.length).toBe(75); + for (const route of routes) { + expect(decoded(encodeRoute(route))).toEqual(route); + } + }); + + it("cannot build a surface route that carries scopes or drawers", function* () { + const surfaceOnly = built( + surfaceRoute({ + execution: "e1", + surface: "transcript", + inspect: false, + draft: "", + }), + ); + + expect(surfaceOnly.kind).toBe("surface"); + // `scopes` and `drawers` live on the arm that has an entry to own them, + // so the surface arm has no member for a scope path to occupy. + expect(Object.keys(surfaceOnly)).not.toContain("scopes"); + expect(Object.keys(surfaceOnly)).not.toContain("drawers"); + expect(encodeRoute(surfaceOnly)).toBe("xmd://repl/e1/transcript"); + }); + + it("refuses an unnamed drawer, entry, scope, execution and marker", function* () { + expect(refusedDecoding("xmd://repl/e1/transcript/entry-1/+").message).toContain( + "a drawer segment names no drawer", + ); + + const empties = [ + entryRoute({ + execution: "e1", + surface: "transcript", + entry: "entry-1", + scopes: [], + drawers: [""], + inspect: false, + draft: "", + }), + entryRoute({ + execution: "e1", + surface: "transcript", + entry: "", + scopes: [], + drawers: [], + inspect: false, + draft: "", + }), + entryRoute({ + execution: "e1", + surface: "transcript", + entry: "entry-1", + scopes: [""], + drawers: [], + inspect: false, + draft: "", + }), + surfaceRoute({ execution: "", surface: "transcript", inspect: false, draft: "" }), + surfaceRoute({ execution: "e1", surface: "transcript", at: "", inspect: false, draft: "" }), + ]; + + for (const result of empties) { + expect(result.ok).toBe(false); + } + }); + + it("cannot encode a scope into the place an entry is read from", function* () { + // The defect this closes: a route carrying `scopes: ["document"]` and no + // entry once encoded to a URL whose first inside segment decoded as the + // entry `document`. + const scoped = built( + entryRoute({ + execution: "e1", + surface: "transcript", + entry: "entry-1", + scopes: ["document"], + drawers: [], + inspect: false, + draft: "", + }), + ); + const back = decoded(encodeRoute(scoped)); + + expect(back.kind).toBe("entry"); + if (back.kind === "entry") { + expect(back.entry).toBe("entry-1"); + expect(back.scopes).toEqual(["document"]); + } + }); + }); + + describe("a drawer belongs to the entry that owns the wait", () => { + it("puts the two entries one after the other, never both running", function* () { + const head = checkpoint(SERIAL, "sp-09"); + const [first, second] = head.entries; + + expect([first.id, second.id]).toEqual(["entry-1", "entry-2"]); + expect([first.settled, second.settled]).toEqual([true, false]); + // A settled entry keeps no live scope, so the only unsettled path at this + // moment is entry-2's. Two live scope trees would hand everything above + // the model a scope the execution had already left. + expect(live(first)).toEqual([]); + expect(live(second)).toEqual(["entry-2/document"]); + // Same scope spelling, same suspension kind, same path. Only the owner + // differs, which is the whole point of this fixture. + expect(first.scopes[0].name).toBe(second.scopes[0].name); + expect(first.scopes[0]).not.toBe(second.scopes[0]); + expect(head.suspensions.map((one) => [one.entry, ...one.scope, one.kind].join("/"))).toEqual([ + "entry-2/document/project", + ]); + }); + + it("refuses the settled entry's drawer and resolves the running entry's", function* () { + const head = checkpoint(SERIAL, "sp-09"); + + // entry-1 answered its `project` before settling, so it has no stack left + // even though a `project` is open at this very moment. + const refusal = refused("xmd://repl/e1/transcript/entry-1/document/+project", SERIAL); + expect(refusal.position).toBe("drawer[0]"); + expect(refusal.found).toEqual([]); + expect(refusal.message).toContain("there is no drawer 1 of entry-1 at sp-09"); + + const owned = resolved("xmd://repl/e1/transcript/entry-2/document/+project", SERIAL); + expect(owned.drawers[0]).toBe(head.suspensions[0]); + expect(owned.drawers[0].entry).toBe("entry-2"); + }); + + it("resolves the same drawer differently before and after the hand-over", function* () { + // At sp-03 the identical URL means entry-1's wait; at sp-08 entry-1 has + // none and entry-2 owns the only one. + const earlier = resolved( + "xmd://repl/e1/transcript/entry-1/document/+project?at=sp-03&inspect", + SERIAL, + ); + expect(earlier.drawers[0]).toBe(checkpoint(SERIAL, "sp-03").suspensions[0]); + expect(earlier.drawers[0].entry).toBe("entry-1"); + + expect(refused("xmd://repl/e1/transcript/entry-1/document/+project", SERIAL).found).toEqual( + [], + ); + }); + + it("refuses a stale answer instead of consuming the next entry's wait", function* () { + const stale: ReplHistory = [ + ...SERIAL_HISTORY, + { + marker: "sp-10", + at: 35, + kind: "suspension.answered", + entry: "entry-1", + scope: ["document"], + detail: "project", + }, + ]; + + // Matching an answer by kind and scope alone removed entry-2's live wait + // and left the head with an empty stack. + expect(() => projectModel(EXECUTION, stale)).toThrow( + /answers no suspension this entry has open/, + ); + expect(checkpoint(SERIAL, "sp-09").suspensions.map((one) => one.entry)).toEqual(["entry-2"]); + }); + + it("refuses to settle an entry whose scope has not exited", function* () { + const early: ReplHistory = [ + // Through sp-04 the wait is answered, but `document` has not exited. + ...SERIAL_HISTORY.slice(0, 4), + { + marker: "sp-05", + at: 17, + kind: "entry.settled", + entry: "entry-1", + scope: [], + detail: "Add a README to the project", + }, + ]; + + expect(() => projectModel(EXECUTION, early)).toThrow( + /settles an entry whose document scope has not exited/, + ); + }); + + it("refuses to settle an entry that is still waiting", function* () { + const early: ReplHistory = [ + ...SERIAL_HISTORY.slice(0, 3), + { + marker: "sp-04", + at: 16, + kind: "entry.settled", + entry: "entry-1", + scope: [], + detail: "Add a README to the project", + }, + ]; + + expect(() => projectModel(EXECUTION, early)).toThrow( + /settles an entry still waiting on project/, + ); + }); + }); + + describe("the router's own boundary", () => { + /** Every module specifier one source file imports, deduplicated and sorted. */ + function* importsOf(name: string): Operation { + const source = yield* readTextFile( + fileURLToPath(new URL(`../repl-compose/${name}`, import.meta.url)), + ); + const specifiers = [...source.matchAll(/^import[^;]*?from\s+"([^"]+)";/gms)].map( + (match) => match[1], + ); + return [...new Set(specifiers)].sort(); + } + + it("imports the result type and the model's types, and nothing else", function* () { + const specifiers = yield* importsOf("router.ts"); + + expect(specifiers.length).toBeGreaterThan(0); + expect(specifiers).toEqual(["./model.ts", "effection"]); + }); + + it("reaches no further, because the model it imports imports nothing", function* () { + // Routing cannot read a history record. Holding only `router.ts` to its own + // import list would leave that true by one hop and unchecked by two. + expect(yield* importsOf("model.ts")).toEqual([]); + }); + + it("keeps history records on the other side of the projection", function* () { + // The projection is the only place a record and a model meet. + expect(yield* importsOf("history.ts")).toEqual(["./model.ts"]); + }); + }); +}); diff --git a/scripts/tests/repl-compose-screen.test.ts b/scripts/tests/repl-compose-screen.test.ts new file mode 100644 index 000000000..3039eac54 --- /dev/null +++ b/scripts/tests/repl-compose-screen.test.ts @@ -0,0 +1,566 @@ +/** + * The whole way down: a URL, a model, a mounted tree, and what a terminal gets. + * + * Slice 1 proved a location resolves and Slice 2 proved a description mounts. + * What is left is the claim those two were for: that one resolved location + * decides the interface, and that everything after it — layout, rendering, + * focus, input, teardown — reads the one tree rather than agreeing with it. + * + * So the cases here are the ones a design with a second representation could + * not pass: the same location at two viewports describing the same tree, a + * refusal that leaves nothing of the screen it replaced, a renderer swapped + * under a running animation, and a host that can be read from top to bottom + * without finding the name of anything it is showing. + */ + +import { describe as suite, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { readTextFile } from "@effectionx/fs"; +import { until } from "effection"; +import type { Operation, Result } from "effection"; +import { fileURLToPath } from "node:url"; + +import { focus, useRoot } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { Node, Root } from "../repl-study/vendor/freedom/upstream/index.ts"; + +import { useFrameClock } from "../repl-compose/frames.ts"; +import type { FrameClock } from "../repl-compose/frames.ts"; +import { EXECUTION, projectModel } from "../repl-compose/history.ts"; +import { createHost, focused, normalize } from "../repl-compose/host.ts"; +import type { Host } from "../repl-compose/host.ts"; +import { focusTargets, keyOf, paint, topology } from "../repl-compose/reconcile.ts"; +import { framedRenderer, plainRenderer } from "../repl-compose/render.ts"; +import { decodeRoute, resolveRoute, ROUTE_SURFACES } from "../repl-compose/router.ts"; +import type { ResolvedLocation } from "../repl-compose/router.ts"; +import { describeScreen } from "../repl-compose/screen.ts"; +import type { SessionSnapshot, Viewport } from "../repl-compose/screen.ts"; + +const MODEL = projectModel(EXECUTION); +const SESSION: SessionSnapshot = { scroll: {} }; +const WIDE: Viewport = { columns: 120, rows: 30 }; +const NARROW: Viewport = { columns: 72, rows: 20 }; + +const ENTRY = "xmd://repl/e1/transcript/entry-1/document?at=cp-10&inspect"; +const PROJECT = "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-10&inspect"; +const STACKED = "xmd://repl/e1/transcript/entry-1/document/+project/+confirm?at=cp-10&inspect"; +const NOWHERE = "xmd://repl/e1/transcript/entry-1/document/+review?at=cp-10&inspect"; + +function located(url: string): Result { + const decoded = decodeRoute(url); + return decoded.ok ? resolveRoute(decoded.value, MODEL) : decoded; +} + +interface Harness { + readonly root: Root; + readonly clock: FrameClock; + readonly host: Host; + go(url: string, viewport?: Viewport): Operation; +} + +function* harness(): Operation { + const root = yield* useRoot(); + const clock = yield* useFrameClock(); + const host = createHost({ root, clock, renderer: plainRenderer, viewport: WIDE }); + return { + root, + clock, + host, + *go(url: string, viewport: Viewport = host.viewport): Operation { + host.resize(viewport); + const shown = yield* host.show(describeScreen(located(url), SESSION, viewport)); + if (!shown.ok) { + throw shown.error; + } + }, + }; +} + +function find(node: Node, key: string): Node | undefined { + if (keyOf(node) === key) { + return node; + } + for (const child of node.children) { + const found = find(child, key); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +/** Where a node sits, by the keys it was described by, outermost first. */ +function ancestryOf(node: Node): string[] { + const path: string[] = []; + for (let at: Node | undefined = node; at; at = at.parent) { + const key = keyOf(at); + if (key !== undefined) { + path.unshift(key); + } + } + return path; +} + +function expectNode(node: Node, key: string): Node { + const found = find(node, key); + if (found === undefined) { + throw new Error(`no mounted branch is keyed ${JSON.stringify(key)}`); + } + return found; +} + +suite("REPL composition: one location, all the way down", () => { + suite("a resolved location decides the tree", () => { + it("mounts the entry, its scope path and the drawer stack it names", function* () { + const { root, go } = yield* harness(); + yield* go(STACKED); + + expect(topology(root.node)).toEqual([ + "screen", + "workbench", + "sessions", + "transcript", + "entry-1", + "document", + "bindings", + "input", + "history", + "project", + "project.answer", + "project.back", + "confirm", + "confirm.answer", + "confirm.back", + ]); + + // The entry lives inside the transcript surface, which is the branch the + // URL's surface segment names. + expect(expectNode(root.node, "entry-1").parent).toBe(expectNode(root.node, "transcript")); + + // The stack is a branch: confirm is inside project, as the suspension + // stack says it is. + expect(expectNode(root.node, "confirm").parent).toBe(expectNode(root.node, "project")); + }); + + it("draws what the mounted tree drew, and nothing from anywhere else", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + + const drawn = host.draw(); + expect(drawn).toBe(plainRenderer.draw(paint(root.node), WIDE.columns)); + expect(drawn).toContain("owner: document/write"); + expect(drawn).toContain("owner: document/publish"); + }); + }); + + suite("the URL reconstructs focus", () => { + it("focuses the branch it names, cold, for every surface a route can name", function* () { + for (const surface of ROUTE_SURFACES) { + // A fresh root each time: nothing carried over, nothing remembered. + const { root, go } = yield* harness(); + yield* go(`xmd://repl/e1/${surface}/entry-1/document?at=cp-10&inspect`); + + expect({ surface, focused: keyOf(focused(root)) }).toEqual({ surface, focused: surface }); + } + }); + + it("rebuilds the same focus identity in a fresh root", function* () { + const first = yield* harness(); + yield* first.go(STACKED); + const identity = ancestryOf(focused(first.root)); + + const second = yield* harness(); + yield* second.go(STACKED); + + // Different nodes, because it is a different tree — and the same place, + // because the URL says where that is. + expect(ancestryOf(focused(second.root))).toEqual(identity); + expect(focused(second.root)).not.toBe(focused(first.root)); + }); + + it("hands focus to the drawer the location opened, and back when it closes", function* () { + const { root, go } = yield* harness(); + yield* go(ENTRY); + expect(keyOf(focused(root))).toBe("transcript"); + + yield* go(PROJECT); + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project"]); + + yield* go(STACKED); + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project", "confirm"]); + + yield* go(PROJECT); + // The branch that held focus is gone, so focus is back on what is asking + // for it now. Nothing outside the description said the word "drawer". + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project"]); + }); + + it("leaves focus alone when the same location is composed again", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + const back = expectNode(root.node, "confirm.back"); + host.deliver({ kind: "pointer", button: "primary", on: back.id }); + expect(keyOf(focused(root))).toBe("confirm.back"); + + yield* go(STACKED); + + // Focus that jumped on every reconcile would be taken away from whoever + // was using it. + expect(keyOf(focused(root))).toBe("confirm.back"); + }); + }); + + suite("the top drawer owns interaction", () => { + it("excludes the covered drawer and the surfaces behind it from traversal", function* () { + const { root, go } = yield* harness(); + yield* go(STACKED); + + // Everything is still mounted, still drawing, still animating. + expect(topology(root.node)).toContain("project.answer"); + expect(topology(root.node)).toContain("transcript"); + + // And exactly one branch can be reached. + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual([ + "confirm", + "confirm.answer", + "confirm.back", + ]); + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project", "confirm"]); + }); + + it("still lets a key the drawer does not claim bubble out through it", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + const answer = expectNode(root.node, "confirm.answer"); + host.deliver({ kind: "pointer", button: "primary", on: answer.id }); + + const escaped = host.deliver({ kind: "bytes", bytes: Uint8Array.from([27]) }); + + // Input travels scopes, not focusability, so the covered ancestry is + // still on the path even though none of it can be focused. + expect(escaped.action).toEqual({ kind: "drawer.close", from: "confirm" }); + expect(escaped.path).toEqual(["screen", "workbench", "project", "confirm", "confirm.answer"]); + }); + + it("restores the drawer beneath, and then the surface the URL names", function* () { + const { root, go } = yield* harness(); + yield* go(STACKED); + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project", "confirm"]); + + yield* go(PROJECT); + expect(ancestryOf(focused(root))).toEqual(["screen", "workbench", "project"]); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual([ + "project", + "project.answer", + "project.back", + ]); + + yield* go(ENTRY); + expect(keyOf(focused(root))).toBe("transcript"); + // With nothing open, the surfaces are reachable again. + expect(focusTargets(root.node).map((node) => keyOf(node))).toContain("history"); + }); + }); + + suite("layout presents; it does not decide existence", () => { + it("describes the same tree at two viewports and draws it differently", function* () { + const { root, host, go } = yield* harness(); + + yield* go(STACKED, WIDE); + const wideTopology = topology(root.node); + const wideFocus = focusTargets(root.node).map((node) => keyOf(node)); + const wideDrawn = host.draw(); + + yield* go(STACKED, NARROW); + + expect(topology(root.node)).toEqual(wideTopology); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual(wideFocus); + expect(host.draw()).not.toBe(wideDrawn); + expect(host.draw()).toContain("— narrow —"); + }); + + it("keeps every branch through a resize, rather than remounting them", function* () { + const { root, clock, go } = yield* harness(); + yield* go(STACKED, WIDE); + const confirm = expectNode(root.node, "confirm"); + yield* clock.advance(16); + expect(confirm.props.opened).toBe(1); + + yield* go(STACKED, NARROW); + + expect(expectNode(root.node, "confirm")).toBe(confirm); + expect(confirm.props.opened).toBe(1); + expect(clock.demand).toBe(2); + }); + }); + + suite("a refusal is the whole screen", () => { + it("leaves nothing of the location it replaced", function* () { + const { root, host, clock, go } = yield* harness(); + yield* go(STACKED); + expect(clock.demand).toBe(2); + + yield* go(NOWHERE); + + // Only the refusal is described, so only the refusal is mounted. There is + // no half-resolved screen behind it holding focus, input or frames. + expect(topology(root.node)).toEqual(["screen", "refusal"]); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual(["refusal"]); + expect(clock.demand).toBe(0); + expect(host.draw()).toContain("does not exist in this execution"); + expect(host.draw()).not.toContain("owner:"); + }); + + it("says which segment refused, in the words the router used", function* () { + const { host, go } = yield* harness(); + yield* go(NOWHERE); + + expect(host.draw()).toContain('"review" is not drawer 1 of entry-1 at cp-10'); + }); + + it("comes back to a whole screen when the next location resolves", function* () { + const { root, clock, go } = yield* harness(); + yield* go(NOWHERE); + yield* go(STACKED); + + expect(topology(root.node)).toContain("confirm.answer"); + expect(clock.demand).toBe(2); + }); + }); + + suite("keyboard and pointer are the same activation", () => { + it("normalizes both to one value before anything is dispatched", function* () { + // The keypress is the same value either way; the target rides alongside + // it, so the tree is handed something with no trace of how it arrived. + expect(normalize({ kind: "bytes", bytes: Uint8Array.from([13]) })?.key).toEqual({ + key: "Enter", + }); + expect(normalize({ kind: "pointer", button: "primary", on: "node-3" })).toEqual({ + key: { key: "Enter" }, + on: "node-3", + }); + expect(normalize({ kind: "bytes", bytes: Uint8Array.from([27]) })?.key).toEqual({ + key: "Escape", + }); + expect(normalize({ kind: "pointer", button: "secondary", on: "node-3" })?.key).toEqual({ + key: "Escape", + }); + }); + + it("emits one action down one live ancestry, whichever arrived", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + + const answer = expectNode(root.node, "confirm.answer"); + focus(answer); + const typed = host.deliver({ kind: "bytes", bytes: Uint8Array.from([13]) }); + + const clicked = host.deliver({ kind: "pointer", button: "primary", on: answer.id }); + + expect(typed.action).toEqual({ kind: "suspension.answer", from: "Answer" }); + expect(clicked.action).toEqual(typed.action); + expect(clicked.path).toEqual(typed.path); + expect(typed.path).toEqual(["screen", "workbench", "project", "confirm", "confirm.answer"]); + }); + + it("activates what the pointer was on, not what had focus", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + + const answer = expectNode(root.node, "confirm.answer"); + const back = expectNode(root.node, "confirm.back"); + focus(answer); + expect(keyOf(focused(root))).toBe("confirm.answer"); + + const clicked = host.deliver({ kind: "pointer", button: "primary", on: back.id }); + + // Focus moved to what was pointed at, and the action is that control's. + expect(keyOf(focused(root))).toBe("confirm.back"); + expect(clicked.action).toEqual({ kind: "drawer.close", from: "Back" }); + expect(clicked.path[clicked.path.length - 1]).toBe("confirm.back"); + + // And the keyboard at that same node now answers identically. + const typed = host.deliver({ kind: "bytes", bytes: Uint8Array.from([13]) }); + expect(typed.action).toEqual(clicked.action); + expect(typed.path).toEqual(clicked.path); + }); + + it("gives nothing to a covered, background, container or absent target", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + + const settled = keyOf(focused(root)); + const cases: readonly [string, string][] = [ + // The drawer underneath the open one: still mounted, still drawing, + // and not a place interaction can go. + ["covered drawer", expectNode(root.node, "project").id], + ["covered control", expectNode(root.node, "project.answer").id], + // A surface behind the drawer, for the same reason. + ["background surface", expectNode(root.node, "transcript").id], + // A branch that holds children and is not a place focus can be. + ["container", expectNode(root.node, "workbench").id], + // Nothing at all. + ["absent", "node-that-was-never-here"], + ]; + + for (const [name, on] of cases) { + const clicked = host.deliver({ kind: "pointer", button: "primary", on }); + expect({ name, path: clicked.path, action: clicked.action }).toEqual({ + name, + path: [], + action: undefined, + }); + expect(keyOf(focused(root))).toBe(settled); + } + + // Removed: the node existed a moment ago and does not now. + const going = expectNode(root.node, "confirm.answer").id; + yield* go(PROJECT); + const after = host.deliver({ kind: "pointer", button: "primary", on: going }); + expect(after.path).toEqual([]); + expect(after.action).toBe(undefined); + }); + + it("bubbles to the drawer when the control has nothing to say", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + focus(expectNode(root.node, "confirm.answer")); + + const answer = expectNode(root.node, "confirm.answer"); + const escaped = host.deliver({ kind: "bytes", bytes: Uint8Array.from([27]) }); + const secondary = host.deliver({ kind: "pointer", button: "secondary", on: answer.id }); + + expect(escaped.action).toEqual({ kind: "drawer.close", from: "confirm" }); + expect(secondary.action).toEqual(escaped.action); + }); + + it("puts focus on something that exists after the tree changes", function* () { + const { root, go } = yield* harness(); + yield* go(STACKED); + focus(expectNode(root.node, "confirm.answer")); + expect(keyOf(focused(root))).toBe("confirm.answer"); + + yield* go(PROJECT); + + // Focus is derived from the tree there is now. A host that remembered the + // node it focused last would be pointing at one that no longer exists, + // and the next key would go nowhere. + const now = focused(root); + expect(focusTargets(root.node)).toContain(now); + expect(keyOf(now)).not.toBe("confirm.answer"); + }); + + it("delivers nothing into a branch the location closed", function* () { + const { root, host, go } = yield* harness(); + yield* go(STACKED); + const answer = expectNode(root.node, "confirm.answer"); + + yield* go(PROJECT); + + // Focus is derived from the tree that exists now, so the host cannot even + // address the control that went — and the node it used to be is inert. + expect(focusTargets(root.node).map((node) => keyOf(node))).not.toContain("confirm.answer"); + expect(find(root.node, "confirm")).toBe(undefined); + expect(host.deliver({ kind: "pointer", button: "primary", on: answer.id }).path).toEqual([]); + expect(answer.props.opened).toBe(undefined); + }); + }); + + suite("the renderer is replaceable", () => { + it("changes the bytes and nothing else", function* () { + const { root, host, clock, go } = yield* harness(); + yield* go(STACKED); + yield* clock.advance(16); + yield* clock.advance(32); + + const confirm = expectNode(root.node, "confirm"); + const before = { + topology: topology(root.node), + focus: focusTargets(root.node).map((node) => keyOf(node)), + opened: confirm.props.opened, + at: confirm.props.at, + demand: clock.demand, + }; + const plain = host.draw(); + + host.use(framedRenderer); + const framed = host.draw(); + + expect(framed).not.toBe(plain); + expect(framed).toContain("│"); + expect(host.renderer.name).toBe("framed"); + + // The location, the topology and the animation are where they were. + expect(topology(root.node)).toEqual(before.topology); + expect(focusTargets(root.node).map((node) => keyOf(node))).toEqual(before.focus); + expect(expectNode(root.node, "confirm")).toBe(confirm); + expect(confirm.props.opened).toBe(before.opened); + expect(confirm.props.at).toBe(before.at); + expect(clock.demand).toBe(before.demand); + }); + + it("keeps animating after the swap, on the same branches", function* () { + const { root, host, clock, go } = yield* harness(); + yield* go(STACKED); + yield* clock.advance(16); + host.use(framedRenderer); + yield* clock.advance(32); + + const confirm = expectNode(root.node, "confirm"); + expect(confirm.props.opened).toBe(2); + expect(confirm.props.at).toBe(32); + }); + }); + + suite("the host knows nothing about what it is showing", () => { + it("names no route segment, drawer kind, surface or component", function* () { + const source = yield* readTextFile( + fileURLToPath(new URL("../repl-compose/host.ts", import.meta.url)), + ); + // The prose is allowed to explain the invariant; the code is what has to + // keep it, so the comments come out before this looks. + const code = source.replaceAll(/\/\*[\s\S]*?\*\//g, "").replaceAll(/\/\/[^\n]*/g, ""); + + for (const name of [ + "entry-1", + "document", + "project", + "confirm", + "transcript", + "sessions", + "bindings", + "drawer", + "workbench", + "suspension", + "checkpoint", + "xmd://repl", + ]) { + expect(code).not.toContain(name); + } + }); + + it("imports no history, no model and no router", function* () { + const source = yield* readTextFile( + fileURLToPath(new URL("../repl-compose/host.ts", import.meta.url)), + ); + const specifiers = [...source.matchAll(/^import[^;]*?from\s+"([^"]+)";/gms)].map( + (match) => match[1], + ); + + expect(specifiers).not.toContain("./history.ts"); + expect(specifiers).not.toContain("./model.ts"); + expect(specifiers).not.toContain("./router.ts"); + }); + }); + + suite("the tree ends with the run", () => { + it("takes every branch and every frame demand with it", function* () { + const { root, clock, go } = yield* harness(); + yield* go(STACKED); + expect(clock.demand).toBe(2); + + yield* until(root.destroy()); + + expect(clock.demand).toBe(0); + }); + }); +}); diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts new file mode 100644 index 000000000..035ebdd0e --- /dev/null +++ b/scripts/tests/repl-focus.test.ts @@ -0,0 +1,1085 @@ +/** + * The route, and the tree that owns focus. + * + * #839's first attempt kept a flat `FocusTarget[]` beside the interface and + * rebuilt traversal, ownership and restoration by hand. Every case it wrote + * passed, because a list compared against itself always agrees. What it could + * not do was answer a question about where a control actually *is* — so the + * cases here are chosen to be ones a flat registry cannot satisfy: a key's + * path through its ancestors' middleware, a branch that stops existing, and an + * overlay that is the tree rather than a copy of it. + * + * Two claims are still driven as **bytes**, because synthetic events are what + * hid the decoder defects this slice repairs. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createInput } from "@bomb.sh/tty"; +import type { Input, InputEvent } from "@bomb.sh/tty"; +import { readTextFile } from "@effectionx/fs"; +import { exec } from "@effectionx/process"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { captureFocus, captureText, PROFILE_SIZES, renderFrame } from "../repl-study/capture.ts"; +import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; +import { openingState, scanKeys } from "../repl-study/host.ts"; +import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; +import { + formatRoute, + navigationFor, + parseRoute, + ROUTE_SURFACES, + surfaceFor, +} from "../repl-study/route.ts"; +import { + fixtureFor, + hydrate, + layoutOf, + openDrawer, + projection, + viewOf, +} from "../repl-study/store.ts"; +import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; +import { drive, enterRoute } from "../repl-study/drive.ts"; +import { focus as focusNode } from "../repl-study/tree.ts"; +import { find, overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; +import type { ReplTree } from "../repl-study/tree.ts"; +import { KeyboardApi, sendKey } from "../repl-study/keys.ts"; +import type { Mutation } from "../repl-study/mutations.ts"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const GOLDENS = fileURLToPath(new URL("./fixtures/repl-focus/", import.meta.url)); +const MAIN = "scripts/repl-study/main.ts"; + +const WIDE: Size = PROFILE_SIZES.wide; +const NARROW: Size = PROFILE_SIZES.narrow; + +function context(size: Size, mutation?: Mutation) { + return { size, mutation, scrollLimit: 40 }; +} + +function key(code: string, extra: Record = {}): HarnessEvent { + return { kind: "key", event: { type: "keydown", key: code, code, ...extra } }; +} + +/** One state and the tree that renders it, built from a URL and a journal. */ +function* opened( + url: string, + head: string | undefined, +): Operation<{ + state: ReplState; + tree: ReplTree; +}> { + const state = hydrate(url, journalThrough(head)); + const tree = yield* useReplTree(state); + return { state, tree }; +} + +/** The identities Tab walks, in tree order. */ +function chain(tree: ReplTree): string[] { + return tree.chain().map((node) => node.name); +} + +function bytes(...codes: number[]): Uint8Array { + return Uint8Array.from(codes); +} + +function* decoded(input: Input, chunk: Uint8Array, mutation?: Mutation): Operation { + const events: InputEvent[] = []; + yield* scanKeys(input, chunk, (event) => events.push(event), mutation); + return events; +} + +const ESC = 0x1b; + +describe("the URL that says where you are", () => { + it("round-trips every frame's location", function* () { + for (const subject of FRAMES) { + const parsed = parseRoute(subject.url); + expect({ id: subject.id, ok: parsed.ok }).toEqual({ id: subject.id, ok: true }); + if (parsed.ok) { + expect(formatRoute(parsed.value)).toBe(subject.url); + } + } + }); + + it("parses every part of the schema, and refuses what is not in it", function* () { + const parsed = parseRoute( + "xmd://repl/e1/transcript/entry-1/plan/+project?at=cp-07&inspect&draft=%3CPlan%3E", + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.value).toEqual({ + execution: "e1", + surface: "transcript", + scopes: ["entry-1", "plan"], + drawers: ["project"], + at: "cp-07", + inspect: true, + draft: "", + }); + + for (const url of [ + "https://repl/e1/transcript", + "xmd://repl/e1/nowhere", + "xmd://repl//transcript", + "xmd://repl/e1/transcript/+project/plan", + "xmd://repl/e1/transcript?zoom=2", + "xmd://repl/e1/transcript?at=", + "xmd://repl/e1/transcript?inspect", + "xmd://repl/e1/transcript?at=cp-04&inspect=yes", + ]) { + expect({ url, ok: parseRoute(url).ok }).toEqual({ url, ok: false }); + } + }); + + it("says selecting a marker and reconstructing it separately", function* () { + const selected = hydrate("xmd://repl/e1/history?at=cp-04", journalThrough("cp-18")); + expect(selected.selection).toBeGreaterThanOrEqual(0); + expect(selected.moment.transport).toBe("paused"); + const reconstructed = hydrate( + "xmd://repl/e1/history?at=cp-04&inspect", + journalThrough("cp-18"), + ); + expect(reconstructed.selection).toBe(selected.selection); + expect(reconstructed.moment.transport).toBe("inspecting"); + }); + + it("names a surface for every region focus can be in", function* () { + expect([...ROUTE_SURFACES]).toEqual(["sessions", "transcript", "bindings", "input", "history"]); + }); +}); + +describe("every frame of the approved focus study", () => { + it("builds each frame's targets and numbering out of the live tree", function* () { + for (const subject of FRAMES) { + const { tree } = yield* useFrame(subject); + const entries = overlayOf(tree); + // With the overlay off the study draws only the focused target. + const shown = subject.overlay + ? entries + : entries.filter((entry) => entry.id === subject.focus); + expect({ + frame: subject.id, + targets: shown.map((entry) => ({ n: entry.number, id: entry.id })), + }).toEqual({ + frame: subject.id, + targets: subject.targets.map((target) => ({ n: target.n, id: target.id })), + }); + expect({ frame: subject.id, focus: tree.focused().name }).toEqual({ + frame: subject.id, + focus: subject.focus, + }); + } + }); + + it("moves where the study says Tab and Shift+Tab move", function* () { + for (const subject of FRAMES) { + const forward = yield* useFrame(subject); + forward.tree.advance(); + expect({ frame: subject.id, tab: forward.tree.focused().name }).toEqual({ + frame: subject.id, + tab: subject.tab, + }); + const reverse = yield* useFrame(subject); + reverse.tree.retreat(); + expect({ frame: subject.id, shift: reverse.tree.focused().name }).toEqual({ + frame: subject.id, + shift: subject.shift, + }); + } + }); + + it("drives the real keys through the real tree", function* () { + // The transition, not two destinations built independently. + for (const subject of FRAMES) { + const { state, tree } = yield* useFrame(subject); + yield* drive(tree, state, key("Tab"), context(WIDE)); + expect({ frame: subject.id, tab: tree.focused().name }).toEqual({ + frame: subject.id, + tab: subject.tab, + }); + } + }); + + it("takes the URL with it whenever focus changes region", function* () { + for (const subject of FRAMES) { + const { state, tree } = yield* useFrame(subject); + const driven = yield* drive(tree, state, key("Tab"), context(WIDE)); + const landed = surfaceOwning(tree.focused()); + expect({ frame: subject.id, surface: driven.state.route.surface }).toEqual({ + frame: subject.id, + surface: landed ?? driven.state.route.surface, + }); + } + }); + + it("leaves the URL behind when focus is allowed to move without it", function* () { + const { state, tree } = yield* useFrame(frame("02")!); + const driven = yield* drive(tree, state, key("Tab"), context(WIDE, "keep-route-on-focus")); + expect(tree.focused().name).toBe("region:history"); + expect(driven.state.route.surface).toBe("input"); + }); + + it("walks the whole ring in both directions and comes back to the start", function* () { + for (const subject of FRAMES) { + const { tree } = yield* useFrame(subject); + const size = tree.chain().length; + for (let at = 0; at < size; at += 1) { + tree.advance(); + } + expect({ frame: subject.id, at: tree.focused().name }).toEqual({ + frame: subject.id, + at: subject.focus, + }); + } + }); +}); + +describe("input reaches the focused node through its ancestors", () => { + it("passes through the panel and the drawer that contain it", function* () { + // A flat registry has no way to produce this: the path is the tree's. + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe("field:drawer.project.name"); + expect(delivery.path).toEqual(["drawer:project", "panel:project.body"]); + }); + + it("passes through the region that owns a transport control", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + void state; + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe("control:transport.continue"); + expect(delivery.path).toEqual(["region:history"]); + }); + + it("stops reaching a control whose branch was removed", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + const field = tree.chain().find((node) => node.name === "field:drawer.project.name")!; + const closed = hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal); + yield* tree.sync(closed); + // The node object still exists in this test's hand; the tree does not hold + // it, nothing can focus it, and no middleware path reaches it any more. + expect(chain(tree)).not.toContain("field:drawer.project.name"); + expect(walk(tree.root.node).map((node) => node.name)).not.toContain("drawer:project"); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.path).not.toContain("drawer:project"); + expect(delivery.target).not.toBe(field.name); + }); +}); + +describe("a branch may consume a key, and then nothing else runs it", () => { + const suspended = () => opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + + it("stops at the branch that claimed it, and the fallback never fires", function* () { + const { state, tree } = yield* suspended(); + const drawer = find(tree.root.node, "drawer:project")!; + drawer.scope.around(KeyboardApi, { + keydown([node, pressed], _next): boolean { + void node; + void pressed; + return true; + }, + }); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(driven.delivery?.handled).toBe(true); + // The path stops at the branch that consumed it — the body panel below it + // never ran. + expect(driven.delivery?.path).toEqual(["drawer:project"]); + // And the drawer is still open: Escape's global meaning did not happen. + expect(driven.state.route.drawers).toEqual(["project"]); + }); + + it("reaches the fallback and closes the drawer when nothing claims it", function* () { + const { state, tree } = yield* suspended(); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(driven.delivery?.handled).toBe(false); + expect(driven.delivery?.path).toEqual(["drawer:project", "panel:project.body"]); + expect(driven.state.route.drawers).toEqual([]); + }); + + it("asks the tree which region owns a control, not the control's name", function* () { + // Back from a control returns to the region that owns it. Which region that + // is comes from walking the live tree, so a node that moved would move with + // it. + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + const owner = surfaceOwning(tree.focused()); + expect(owner).toBe("history"); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(tree.focused().name).toBe("region:history"); + expect(driven.state.route.surface).toBe("history"); + }); +}); + +describe("a live tree and a rebuilt one are the same tree", () => { + /** Frame 11, driven into historical inspection through the real path. */ + function* inspected(mutation?: Mutation): Operation<{ + state: ReplState; + order: readonly string[]; + }> { + const { state, tree } = yield* useFrame(frame("11")!); + const driven = yield* drive(tree, state, key("Enter"), context(WIDE, mutation)); + return { + state: driven.state, + order: overlayOf(tree).map((entry) => entry.id), + }; + } + + /** The same URL and journal, with the store and the tree thrown away. */ + function* rebuilt(state: ReplState): Operation { + const fresh = hydrate(formatRoute(state.route), state.journal); + const tree = yield* useReplTree(fresh); + const region = tree.chain().find((node) => node.name === "region:history"); + if (region) { + focusNode(region); + } + yield* tree.sync(fresh); + return overlayOf(tree).map((entry) => entry.id); + } + + it("rebuilds the same ordered topology from the URL and the journal", function* () { + const live = yield* inspected(); + expect(live.state.route.inspect).toBe(true); + expect(yield* rebuilt(live.state)).toEqual(live.order); + }); + + it("keeps the transport in its canonical order either way", function* () { + const live = yield* inspected(); + const transport = (order: readonly string[]) => + order.filter((id) => id.startsWith("control:transport.")); + expect(transport(live.order)).toEqual([ + "control:transport.continue", + "control:transport.return-head", + "control:transport.fork", + ]); + expect(transport(yield* rebuilt(live.state))).toEqual(transport(live.order)); + }); + + it("diverges when a replaced control is left where it was appended", function* () { + const live = yield* inspected("append-replacements"); + expect(yield* rebuilt(live.state)).not.toEqual(live.order); + }); + + it("leaves focus on a surviving node after every replacement", function* () { + const { state, tree } = yield* useFrame(frame("11")!); + const driven = yield* drive(tree, state, key("Enter"), context(WIDE)); + void driven; + expect(chain(tree)).toContain(tree.focused().name); + // …and after a branch is torn down as well. + const { state: open, tree: withDrawer } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* withDrawer.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", open.journal)); + expect(chain(withDrawer)).toContain(withDrawer.focused().name); + }); +}); + +describe("branches, and what closing one destroys", () => { + it("adds a nested panel's focusables in tree order", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1", "cp-14"); + const before = chain(tree); + const deeper = hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal); + yield* tree.sync(deeper); + expect(chain(tree)).toEqual([ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + "region:history", + ]); + expect(before).not.toEqual(chain(tree)); + }); + + it("destroys the whole branch when it closes", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + const names = () => walk(tree.root.node).map((node) => node.name); + expect(names()).toContain("panel:project.body"); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + for (const gone of [ + "drawer:project", + "panel:project.body", + "field:drawer.project.name", + "control:drawer.project.submit", + ]) { + expect({ gone, present: names().includes(gone) }).toEqual({ gone, present: false }); + } + }); + + it("keeps a closed drawer's controls alive when the branch is not removed", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), + "keep-closed-branch", + ); + expect(walk(tree.root.node).map((node) => node.name)).toContain("field:drawer.project.name"); + }); + + it("keeps focus across a sync, because the tree is reconciled and not rebuilt", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + yield* tree.sync(state); + expect(tree.focused().name).toBe("control:transport.continue"); + }); + + it("loses focus when every node is rebuilt on each sync", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + yield* tree.sync(state, "rebuild-tree-each-sync"); + expect(tree.focused().name).not.toBe("control:transport.continue"); + }); +}); + +describe("drawers trap traversal and restore outward", () => { + it("traps the ring in the top drawer, with the footer inside it", function* () { + for (const subject of FRAMES.filter((one) => one.meta.trap)) { + const { tree } = yield* useFrame(subject); + const ids = chain(tree); + expect({ frame: subject.id, last: ids[ids.length - 1] }).toEqual({ + frame: subject.id, + last: "region:history", + }); + expect({ frame: subject.id, panes: ids.filter((id) => id.startsWith("region:")) }).toEqual({ + frame: subject.id, + panes: ["region:history"], + }); + } + }); + + it("restores first to the outer drawer, then to the invoking control", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + // Focus somewhere recognisable before anything is pushed. + yield* drive(tree, state, key("2"), context(WIDE)); + const invoker = tree.focused().name; + expect(invoker).toBe("region:transcript"); + + const outer = openDrawer(state, "project", invoker); + yield* tree.sync(outer); + expect(tree.focused().name).toBe("field:drawer.project.name"); + + const inner = openDrawer(outer, "confirm", tree.focused().name); + yield* tree.sync(inner); + expect(chain(tree)).toEqual([ + "control:drawer.confirm.preview", + "control:drawer.confirm.approve", + "control:drawer.confirm.decline", + "region:history", + ]); + + yield* tree.sync(outer); + expect(tree.focused().name).toBe("field:drawer.project.name"); + yield* tree.sync(state); + expect(tree.focused().name).toBe(invoker); + }); + + it("lets Tab escape the trap when the branch is not pushed as a focus root", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal), + "leak-drawer-trap", + ); + expect(chain(tree)).toContain("region:transcript"); + }); + + it("leaves focus behind when the drawer's push is never popped", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), + "forget-drawer-invoker", + ); + expect(tree.focused().name).not.toBe("region:transcript"); + }); +}); + +describe("removing the focused node", () => { + it("selects a surviving node before teardown", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + // Resuming removes the paused transport and mounts the live one. + const live = hydrate(formatRoute(state.route), journalThrough("cp-19")); + yield* tree.sync(live); + expect(chain(tree)).toContain(tree.focused().name); + expect(tree.focused().name).not.toBe("control:transport.continue"); + }); + + it("selects a survivor when the branch above the focused node goes", function* () { + // Freedom's own middleware asked whether the *removed node* was focused; + // a drawer is closed by removing the branch above the focused control. + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + expect(tree.focused().name).toBe("field:drawer.project.name"); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + expect(tree.focused().name).not.toBe(""); + expect(chain(tree)).toContain(tree.focused().name); + }); +}); + +describe("background updates", () => { + const streaming = (): HarnessEvent => ({ + kind: "background", + record: { + marker: "cp-live", + at: 50, + kind: "session.started", + scope: "document", + detail: "review-b72e1d", + shows: "drawer", + }, + }); + + it("changes nothing about where the person is", function* () { + const { state, tree } = yield* useFrame(frame("06")!); + const before = tree.focused().name; + const driven = yield* drive(tree, state, streaming(), context(WIDE)); + expect(tree.focused().name).toBe(before); + expect(driven.state.route).toBe(state.route); + expect(driven.state.journal.length).toBe(state.journal.length + 1); + }); + + it("is rejected when the update moves focus", function* () { + const { state, tree } = yield* useFrame(frame("06")!); + const before = tree.focused().name; + yield* drive(tree, state, streaming(), context(WIDE, "steal-focus-on-background")); + expect(tree.focused().name).not.toBe(before); + }); +}); + +describe("a disabled control is drawn and never focusable", () => { + it("numbers Continue in the overlay and keeps it out of the chain", function* () { + const { tree } = yield* useFrame(frame("12")!); + const entries = overlayOf(tree); + const continues = entries.find((entry) => entry.id === "control:transport.continue"); + expect(continues?.enabled).toBe(false); + expect(chain(tree)).not.toContain("control:transport.continue"); + expect(entries.map((entry) => entry.id)).toContain("control:transport.continue"); + }); + + it("admits it to the chain when a disabled control is made focusable", function* () { + const { state, tree } = yield* useFrame(frame("12")!); + yield* tree.sync(state, "focus-hidden-target"); + expect(chain(tree)).toContain("control:transport.continue"); + }); +}); + +describe("the overlay is the tree", () => { + it("matches the live tree exactly, node for node", function* () { + for (const subject of FRAMES) { + const { tree } = yield* useFrame(subject); + const fromTree = tree + .map() + .map((node) => node.name) + .sort(); + const fromOverlay = overlayOf(tree) + .map((entry) => entry.id) + .sort(); + expect({ frame: subject.id, fromOverlay }).toEqual({ + frame: subject.id, + fromOverlay: fromTree, + }); + } + }); + + it("follows the tree into a drawer rather than numbering the panes behind it", function* () { + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + expect(overlayOf(tree).map((entry) => entry.id)).toEqual([ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + "region:history", + ]); + }); + + it("goes on numbering the panes when the overlay is kept beside the tree", function* () { + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + expect(overlayOf(tree, "flat-overlay").map((entry) => entry.id)).toContain("region:transcript"); + }); +}); + +describe("inspecting a recorded moment", () => { + const paused = () => opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const inspecting = () => + opened("xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", "cp-18"); + + it("refuses a mutation while a reconstruction is open", function* () { + const { state, tree } = yield* inspecting(); + yield* drive(tree, state, key("4"), context(WIDE)); + const typed = yield* drive(tree, state, key("x"), context(WIDE)); + expect(typed.state.route.draft).toBe(""); + }); + + it("permits that mutation when the read-only rule is removed", function* () { + const { state, tree } = yield* inspecting(); + const focused = yield* drive(tree, state, key("4"), context(WIDE)); + const typed = yield* drive( + tree, + focused.state, + key("x"), + context(WIDE, "mutate-while-inspecting"), + ); + expect(typed.state.route.draft).toBe("x"); + }); + + it("keeps every recorded marker visible, including the ones after it", function* () { + const { state } = yield* inspecting(); + const later = fixtureFor(state).history.checkpoints.filter( + (point) => point.at > state.moment.at, + ); + expect(later.length).toBeGreaterThan(0); + }); + + it("withholds Continue until the paused head is regained", function* () { + const { state, tree } = yield* inspecting(); + // Enter the footer, so its controls exist to be walked. + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + expect(chain(tree)).not.toContain("control:transport.continue"); + tree.advance(); + expect(tree.focused().name).toBe("control:transport.return-head"); + const returned = yield* drive(tree, entered.state, key("Enter"), context(WIDE)); + expect(returned.state.route.inspect).toBe(false); + // Closing the reconstruction is not deselecting the marker. + expect(returned.state.route.at).toBe("cp-04"); + }); + + it("holds the transport slot across freezing and resuming", function* () { + const { state, tree } = yield* paused(); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + tree.advance(); + expect(tree.focused().name).toBe("control:transport.continue"); + const resumed = yield* drive(tree, entered.state, key("Enter"), context(WIDE)); + expect(resumed.state.moment.transport).toBe("live"); + // `Continue` is gone; the live counterpart is what the footer now offers, + // and focus is on a node that exists. + expect(chain(tree)).toContain("control:transport.pause"); + expect(chain(tree)).toContain(tree.focused().name); + }); +}); + +describe("the selected marker is location", () => { + it("writes the scrubber's selection into the URL, by replacing", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const focused = yield* drive(tree, state, key("5"), context(WIDE)); + const scrubbed = yield* drive(tree, focused.state, key("ArrowLeft"), context(WIDE)); + expect(scrubbed.state.route.at).toBeDefined(); + expect(scrubbed.state.route.inspect).toBe(false); + expect(scrubbed.state.history.length).toBe(focused.state.history.length); + expect(navigationFor("scrub")).toBe("replace"); + }); + + it("comes back to the same marker, scope and bindings from the URL alone", function* () { + const selected = stateFor(frame("11")!); + expect(selected.selection).toBeGreaterThanOrEqual(0); + const rebuilt = hydrate(formatRoute(selected.route), selected.journal); + expect(projection(rebuilt)).toEqual(projection(selected)); + expect(projection(rebuilt).selected).toBe("cp-16"); + }); + + it("loses the selection when it is kept outside the URL", function* () { + const selected = stateFor(frame("11")!); + const rebuilt = hydrate( + formatRoute(selected.route), + selected.journal, + "drop-selection-on-hydrate", + ); + expect(rebuilt.selection).toBe(-1); + }); +}); + +describe("structural navigation across siblings", () => { + const settled = () => opened("xmd://repl/e1/transcript/entry-1/document/plan", "cp-22"); + + it("reads the sibling list out of the journal, in source order", function* () { + expect(siblingsOf(JOURNAL, ["document"])).toEqual(["plan", "preview", "write"]); + expect(siblingsOf(JOURNAL, [])).toEqual(["document"]); + }); + + it("moves to the next and previous sibling, and takes the URL with it", function* () { + const { state, tree } = yield* settled(); + const next = yield* drive(tree, state, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(next.state.route.scopes).toEqual(["entry-1", "document", "preview"]); + const after = yield* drive(tree, next.state, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(after.state.route.scopes).toEqual(["entry-1", "document", "write"]); + const back = yield* drive(tree, after.state, key("ArrowLeft", { ctrl: true }), context(WIDE)); + expect(back.state.route.scopes).toEqual(["entry-1", "document", "preview"]); + }); + + it("moves out to the parent and in to the first child", function* () { + const { state, tree } = yield* settled(); + const out = yield* drive(tree, state, key("ArrowUp", { ctrl: true }), context(WIDE)); + expect(out.state.route.scopes).toEqual(["entry-1", "document"]); + const back = yield* drive(tree, out.state, key("ArrowDown", { ctrl: true }), context(WIDE)); + expect(back.state.route.scopes).toEqual(["entry-1", "document", "plan"]); + }); + + it("never intercepts a modified arrow out of a draft somebody is typing", function* () { + const { state, tree } = yield* settled(); + const typing = yield* drive(tree, state, key("4"), context(WIDE)); + const moved = yield* drive( + tree, + typing.state, + key("ArrowRight", { ctrl: true }), + context(WIDE), + ); + expect(moved.state.route.scopes).toEqual(typing.state.route.scopes); + }); + + it("leaves the arrows inert when the sibling list is ignored", function* () { + const { state, tree } = yield* settled(); + const moved = yield* drive( + tree, + state, + key("ArrowRight", { ctrl: true }), + context(WIDE, "inert-sibling-arrows"), + ); + expect(moved.state.route.scopes).toEqual(state.route.scopes); + }); +}); + +describe("push versus replace", () => { + const start = () => opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + + it("replaces the URL while a draft is typed", function* () { + const { state, tree } = yield* start(); + let driven = yield* drive(tree, state, key("4"), context(WIDE)); + const before = driven.state.history.length; + for (const glyph of ["a", "b", "c"]) { + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE)); + } + expect(driven.state.route.draft).toBe("abc"); + expect(driven.state.history.length).toBe(before); + expect(navigationFor("draft")).toBe("replace"); + }); + + it("fills the navigation stack when every keystroke pushes", function* () { + const { state, tree } = yield* start(); + let driven = yield* drive(tree, state, key("4"), context(WIDE, "push-draft-edits")); + const before = driven.state.history.length; + for (const glyph of ["a", "b", "c"]) { + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE, "push-draft-edits")); + } + expect(driven.state.history.length).toBe(before + 3); + }); +}); + +describe("Ctrl+C, three ways", () => { + it("interrupts a running entry, and a paused or reconstructed one", function* () { + for (const [url, head] of [ + ["xmd://repl/e1/transcript/entry-1/document", "cp-06"], + ["xmd://repl/e1/history/entry-1/document", "cp-18"], + ["xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", "cp-18"], + ] as const) { + const { state, tree } = yield* opened(url, head); + const driven = yield* drive(tree, state, key("c", { ctrl: true }), context(WIDE)); + expect({ url, quit: driven.state.quit, interrupts: driven.state.interrupts }).toEqual({ + url, + quit: false, + interrupts: 1, + }); + } + }); + + it("exits from a paused entry when only a live one counts as active", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const driven = yield* drive( + tree, + state, + key("c", { ctrl: true }), + context(WIDE, "exit-on-paused-interrupt"), + ); + expect(driven.state.quit).toBe(true); + }); + + it("clears a draft, then leaves, when nothing is running", function* () { + const withDraft = yield* opened("xmd://repl/e1/input?draft=%3CPlan%3E", "cp-22"); + const cleared = yield* drive( + withDraft.tree, + withDraft.state, + key("c", { ctrl: true }), + context(WIDE), + ); + expect(cleared.state.route.draft).toBe(""); + expect(cleared.state.quit).toBe(false); + + const empty = yield* opened("xmd://repl/e1/input", "cp-22"); + const left = yield* drive(empty.tree, empty.state, key("c", { ctrl: true }), context(WIDE)); + expect(left.state.quit).toBe(true); + }); +}); + +describe("rebuilding from the URL and the journal alone", () => { + function* journey(): Operation { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + let driven = yield* drive(tree, state, key("4"), context(WIDE)); + for (const glyph of ["<", "P", "l", "a", "n", ">"]) { + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE)); + } + driven = yield* drive(tree, driven.state, key("Tab"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("5"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("ArrowLeft"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("Enter"), context(WIDE)); + return driven.state; + } + + it("comes back to the same semantic state with nothing else", function* () { + const original = yield* journey(); + expect(original.route.draft).toBe(""); + const rebuilt = hydrate(formatRoute(original.route), original.journal); + expect(projection(rebuilt)).toEqual(projection(original)); + }); + + it("throws away the disposable half rather than pretending to restore it", function* () { + const original = yield* journey(); + const rebuilt = hydrate(formatRoute(original.route), original.journal); + expect(rebuilt.anchor).toBe(0); + expect(rebuilt.history).toEqual([]); + expect(rebuilt.selection).toBe(original.selection); + }); + + it("folds the journal rather than reading the fixtures", function* () { + const moment = fold(journalThrough("cp-08")); + expect(moment.scope).toBe("plan"); + expect(moment.published).toEqual(["inputs", "draft"]); + expect(moment.suspension).toBe("review"); + expect(markers(JOURNAL).length).toBe(JOURNAL.length); + }); +}); + +describe("the same route at two profiles", () => { + it("says the same thing wide and narrow", function* () { + for (const subject of FRAMES) { + const { state, tree } = yield* useFrame(subject); + const before = projection(state); + expect(layoutOf(state, WIDE).profile).toBe("wide"); + expect(layoutOf(state, NARROW).profile).toBe("narrow"); + let moved = yield* drive(tree, state, { kind: "resize", ...NARROW }, context(NARROW)); + moved = yield* drive(tree, moved.state, { kind: "resize", ...WIDE }, context(WIDE)); + expect({ frame: subject.id, after: projection(moved.state) }).toEqual({ + frame: subject.id, + after: before, + }); + } + }); + + it("loses the route when a resize rebuilds it from the profile", function* () { + const { state, tree } = yield* useFrame(frame("07")!); + const moved = yield* drive( + tree, + state, + { kind: "resize", ...NARROW }, + context(NARROW, "drop-route-on-resize"), + ); + expect(formatRoute(moved.state.route)).not.toBe(frame("07")!.url); + }); +}); + +describe("through a real decoder", () => { + it("delivers a lone Escape only after the pending flush", function* () { + const immediate: Input = yield* until(createInput({})); + const scanned = immediate.scan(bytes(ESC)); + expect(scanned.events).toEqual([]); + expect(scanned.pending?.delay).toBeGreaterThan(0); + + const flushed = yield* decoded(yield* until(createInput({})), bytes(ESC)); + expect(flushed.map((event) => ("code" in event ? event.code : ""))).toEqual(["Escape"]); + }); + + it("acts on the Escape those bytes produced", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC)); + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + let driven = { state }; + for (const event of events) { + driven = yield* drive(tree, driven.state, { kind: "key", event }, context(WIDE)); + } + expect(driven.state.route.drawers).toEqual([]); + }); + + it("swallows every Escape when the pending flush is dropped", function* () { + const input: Input = yield* until(createInput({})); + expect(yield* decoded(input, bytes(ESC), "swallow-pending-escape")).toEqual([]); + }); + + it("reads a real Shift+Tab, which arrives as Backtab with no shift flag", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); + expect(events.length).toBe(1); + const [event] = events; + expect("code" in event ? event.code : "").toBe("Backtab"); + expect("shift" in event ? event.shift : undefined).toBeUndefined(); + + const subject = frame("03")!; + const { state, tree } = yield* useFrame(subject); + for (const decodedEvent of events) { + yield* drive(tree, state, { kind: "key", event: decodedEvent }, context(WIDE)); + } + expect(tree.focused().name).toBe(subject.shift); + }); + + it("traverses forward when only a synthetic Tab+shift counts as reverse", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); + const subject = frame("03")!; + const { state, tree } = yield* useFrame(subject); + for (const event of events) { + yield* drive(tree, state, { kind: "key", event }, context(WIDE, "ignore-backtab")); + } + expect(tree.focused().name).toBe(subject.tab); + }); + + it("decodes the modified arrows structural navigation is specified on", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x31, 0x3b, 0x35, 0x41)); + const [event] = events; + expect("code" in event ? event.code : "").toBe("ArrowUp"); + expect("ctrl" in event ? event.ctrl : undefined).toBe(true); + }); +}); + +describe("the frames, as pictures", () => { + it("renders every committed focus capture exactly", function* () { + const captures = yield* captureFocus(); + expect(captures.length).toBeGreaterThan(0); + for (const capture of captures) { + const golden = yield* readTextFile(join(GOLDENS, `${capture.name}.txt`)); + expect(captureText(capture)).toBe(golden); + } + }); + + it("draws the focused region and the numbered map", function* () { + const subject = frame("12")!; + const { state, tree } = yield* useFrame(subject); + const rendered = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size: WIDE, + focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, + }); + expect(rendered.text).toContain("FOCUS MAP"); + expect(rendered.text).toContain("Fork from here"); + }); + + it("says nothing about focus in a frame that was not asked about it", function* () { + const state = stateFor(frame("07")!); + const rendered = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size: WIDE, + }); + expect(rendered.text).not.toContain("FOCUS MAP"); + }); +}); + +describe("the command opens at the frame it names", () => { + it("reproduces every frame through the harness's own opening path", function* () { + // `--frame ` builds its state the way `runInteractive` does, not the + // way the rest of this suite does. They were once different: the harness + // opened at a frame's location but not its focus, so the footer — whose + // controls exist only once focus is inside it — drew none of them, and no + // case noticed because every case entered another way. + for (const subject of FRAMES) { + // Exactly what `runInteractive` does: build the opening state from the + // flags, then enter the route with the frame's focus. + const state = openingState({ + fixture: subject.fixture, + route: subject.url, + head: subject.head, + }); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state, subject.focus); + expect({ frame: subject.id, focus: tree.focused().name }).toEqual({ + frame: subject.id, + focus: subject.focus, + }); + const entries = overlayOf(tree); + const shown = subject.overlay + ? entries + : entries.filter((entry) => entry.id === subject.focus); + expect({ + frame: subject.id, + targets: shown.map((entry) => ({ n: entry.number, id: entry.id })), + }).toEqual({ + frame: subject.id, + targets: subject.targets.map((target) => ({ n: target.n, id: target.id })), + }); + } + }); +}); + +describe("the documented command", () => { + it("opens at a route, a frame and with the map on", function* () { + for (const argument of [ + "--route xmd://repl/e1/transcript/entry-1/plan/+project", + "--frame 07", + "--frame 07 --focus-map", + ]) { + const result = yield* exec(`deno run --allow-all ${MAIN} ${argument}`, { cwd: ROOT }).join(); + expect({ argument, code: result.code }).toEqual({ argument, code: 2 }); + expect(`${result.stdout}${result.stderr}`).toContain("--capture"); + } + }); + + it("refuses a route it cannot parse, and a frame that does not exist", function* () { + const bad = yield* exec(`deno run --allow-all ${MAIN} --route xmd://repl/e1/nowhere`, { + cwd: ROOT, + }).join(); + expect(bad.code).toBe(2); + expect(bad.stdout).toContain("is not a surface"); + }); +}); + +describe("the vendored Freedom snapshot", () => { + const VENDOR = fileURLToPath(new URL("../repl-study/vendor/freedom/", import.meta.url)); + + it("matches the bytes its manifest records", function* () { + const manifest = JSON.parse(yield* readTextFile(join(VENDOR, "MANIFEST.json"))); + const digest = function* (path: string): Operation { + const text = yield* readTextFile(join(VENDOR, path)); + const bytes = new TextEncoder().encode(text); + const hash = yield* until(crypto.subtle.digest("SHA-256", bytes)); + return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); + }; + for (const [path, recorded] of Object.entries(manifest.files)) { + expect({ path, sha256: yield* digest(path) }).toEqual({ path, sha256: recorded }); + } + }); + + it("names the upstream commit and every file it patched", function* () { + const manifest = JSON.parse(yield* readTextFile(join(VENDOR, "MANIFEST.json"))); + expect(manifest.upstream.commit).toBe("8be97e7201cd6effddb2f8b240b4b5166641e7f0"); + expect(manifest.upstream.repository).toBe("https://github.com/bombshell-dev/playground"); + const patched = new Set(manifest.patches.map((patch: { file: string }) => patch.file)); + expect([...patched].sort()).toEqual([ + "upstream/lib/focus.ts", + "upstream/lib/mod.ts", + "upstream/lib/node.ts", + "upstream/lib/root.ts", + ]); + for (const patch of manifest.patches) { + expect(typeof patch.reason).toBe("string"); + expect(patch.reason.length).toBeGreaterThan(30); + } + }); +}); diff --git a/scripts/tests/repl-hydration-fork.test.ts b/scripts/tests/repl-hydration-fork.test.ts new file mode 100644 index 000000000..c75801387 --- /dev/null +++ b/scripts/tests/repl-hydration-fork.test.ts @@ -0,0 +1,688 @@ +/** + * A fork stands on its own, and points at where it came from. + * + * Slice 4 of #842. Two things have to be true at once and they pull in + * opposite directions: the fork must reconstruct with the parent gone, and it + * must still show what it was taken from. They are reconciled by recording + * the parent's *name* and not its values — the environment is published into + * the fork's own Journal, and the pointer is a label that needs the parent + * only when someone follows it. + * + * The second half of this file is the refusal matrix: every layer that reads + * untrusted input — the record parser, the projection, the URL codec, the URL + * resolver and hydration — asked for a malformed, a truncated and an + * inconsistent input, and required to answer with a refusal rather than a + * plausible partial view. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; + +import { + EXECUTION, + FORK_EXECUTION, + FORK_INHERITED, + FORK_JOURNAL, + FORK_SOURCE, + forkChanging, + forkWith, + JOURNAL, + journalChanging, + journalDropping, + journalMissing, + journalWith, + journalWithout, + positionOf, + truncatedAfter, +} from "../repl-hydration/fixture.ts"; +import { inherit } from "../repl-hydration/fork.ts"; +import { JournalParseError, parseJournal } from "../repl-hydration/journal.ts"; +import { decodeRoute, resolveLocation, RouteRefusal } from "../repl-hydration/location.ts"; +import type { SemanticModel } from "../repl-hydration/model.ts"; +import { ProjectionError, projectPrefix, UnknownMarkerError } from "../repl-hydration/project.ts"; +import { alone, library, provenanceLink } from "../repl-hydration/provenance.ts"; +import { foreignValues } from "../repl-hydration/purity.ts"; +import { hydrate } from "../repl-hydration/store.ts"; +import type { ReplSession } from "../repl-hydration/store.ts"; + +const AT_FORK = "xmd://repl/e1-fork/transcript/entry-1/document"; + +function forked(records: readonly unknown[] = FORK_JOURNAL): SemanticModel { + const events = parseJournal(records); + if (!events.ok) { + throw events.error; + } + const model = projectPrefix(FORK_EXECUTION, events.value, undefined); + if (!model.ok) { + throw model.error; + } + return model.value; +} + +function* open( + execution: string, + url: string, + records: readonly unknown[], +): Operation { + const session = yield* hydrate(execution, url, records); + if (!session.ok) { + throw session.error; + } + return session.value; +} + +/** Every journal this process can reach when the parent is still there. */ +const WITH_PARENT = library({ [EXECUTION]: JOURNAL, [FORK_EXECUTION]: FORK_JOURNAL }); + +describe("a fork owns its own past", () => { + it("publishes what it inherited into its own Journal", function* () { + const model = forked(); + + expect(model.provenance).toEqual({ + kind: "forked", + parent: "e1", + source: FORK_SOURCE, + entry: "entry-0", + }); + // The environment the parent had at r-07, republished here. Not a + // reference to the parent's record: a record of this execution's own. + expect(model.entries[0].id).toBe("entry-0"); + expect(model.entries[0].outcome).toEqual({ status: "settled" }); + expect(model.bindings.map((one) => `${one.name}=${one.value}`)).toEqual([ + "project=executable.md", + "release=0.13.1", + ]); + expect(model.bindings[0].entry).toBe("entry-0"); + + // The fork's own entry inherits what the inherited entry published. + const mine = model.entries.find((entry) => entry.id === "entry-1"); + expect(mine?.inherited.map((one) => one.name)).toEqual(["project"]); + }); + + it("carries the whole inherited environment in its first record", function* () { + // One record in, and the environment is already complete. There is no + // prefix of a fork that has the entry and only part of what it inherited. + const events = parseJournal(FORK_INHERITED); + if (!events.ok) { + throw events.error; + } + expect(events.value.length).toBe(1); + + const model = projectPrefix(FORK_EXECUTION, events.value, undefined); + if (!model.ok) { + throw model.error; + } + expect(model.value.bindings.map((one) => `${one.name}=${one.value}`)).toEqual([ + "project=executable.md", + ]); + // The synthetic entry has not settled yet, and that changes nothing + // about the environment it brought with it. + expect(model.value.entries[0].outcome).toEqual({ status: "running" }); + expect(model.value.entries[0].inherited).toEqual([]); + expect(model.value.bindings[0].marker).toBe(model.value.entries[0].marker); + }); + + it("takes that payload from the parent at the source marker, never from its head", function* () { + const built = inherit(parsedParent(), { + parent: EXECUTION, + source: FORK_SOURCE, + entry: "entry-0", + title: "Forked from the README run", + id: "f-01", + }); + if (!built.ok) { + throw built.error; + } + // The fixture's first record *is* this, rather than a hand-written copy + // that could agree with nothing but itself. + expect(built.value).toEqual(FORK_JOURNAL[0]); + + const atHead = inherit(parsedParent(), { + parent: EXECUTION, + source: "r-23", + entry: "entry-0", + title: "Forked from the head", + id: "f-01", + }); + if (!atHead.ok) { + throw atHead.error; + } + const names = (record: unknown) => + (Object(record).bindings as readonly { name: string }[]).map((one) => one.name); + expect(names(built.value)).toEqual(["project"]); + expect(names(atHead.value)).toEqual(["project", "release", "changelog"]); + + // A marker the parent never minted has no environment to inherit. + expect( + inherit(parsedParent(), { + parent: EXECUTION, + source: "r-99", + entry: "entry-0", + title: "Nowhere", + id: "f-01", + }).ok, + ).toBe(false); + }); + + it("settles and carries on with the parent absent", function* () { + const session = yield* open(FORK_EXECUTION, AT_FORK, FORK_JOURNAL); + const model = session.semantic().model; + + expect(provenanceLink(model, alone()).kind).toBe("unavailable"); + expect(model.entries.map((entry) => `${entry.id}:${entry.outcome.status}`)).toEqual([ + "entry-0:settled", + "entry-1:running", + ]); + // The work it did after settling inherited what the fork brought over. + expect(model.entries[1].inherited.map((one) => one.name)).toEqual(["project"]); + expect(model.suspensions.map((one) => one.wait)).toEqual(["confirm"]); + expect(model.bindings.map((one) => `${one.name}=${one.value}`)).toEqual([ + "project=executable.md", + "release=0.13.1", + ]); + }); + + it("refuses an environment that names one binding twice or holds a malformed member", function* () { + const cases: readonly (readonly [string, unknown])[] = [ + [ + "a repeated name", + [ + { name: "project", value: "one" }, + { name: "project", value: "two" }, + ], + ], + ["a member that is not a binding", [{ name: "project", value: "one" }, "project=two"]], + ["a member with no name", [{ name: "", value: "one" }]], + ["a member with no text value", [{ name: "project", value: 1 }]], + ["a member carrying more", [{ name: "project", value: "one", secret: true }]], + ["an environment that is not a list", { project: "one" }], + ]; + + for (const [what, bindings] of cases) { + const refused = parseJournal(forkChanging(0, { bindings })); + expect(`${what}:${refused.ok}`).toBe(`${what}:false`); + if (refused.ok) { + continue; + } + expect(refused.error).toBeInstanceOf(JournalParseError); + expect((refused.error as JournalParseError).field).toBe("bindings"); + } + + // An empty environment is a fork of an execution that had published + // nothing, which is a thing that happens. + expect(parseJournal(forkChanging(0, { bindings: [] })).ok).toBe(true); + }); + + it("inherits the parent's environment as of the source marker and no later", function* () { + const parent = projectPrefix(EXECUTION, parsedParent(), FORK_SOURCE); + if (!parent.ok) { + throw parent.error; + } + + expect(parent.value.bindings.map((one) => `${one.name}=${one.value}`)).toEqual([ + "project=executable.md", + ]); + // Everything the parent published after r-07 is absent here, which is + // what makes this an inheritance rather than a copy of the head. + const inheritedAtFork = forked().entries[0]; + expect(inheritedAtFork.inherited).toEqual([]); + expect(JSON.stringify(forked())).not.toContain("changelog"); + expect(JSON.stringify(forked())).not.toContain("0.13.0"); + }); + + it("reconstructs the same fork whether or not the parent is there", function* () { + // Hydration takes records and a URL. There is no argument a parent + // journal would go in, so "with the parent" and "without it" differ only + // in what the surrounding process can reach — and that is the link. + const beside = yield* open(FORK_EXECUTION, AT_FORK, FORK_JOURNAL); + const withParent = beside.semantic(); + expect(provenanceLink(withParent.model, WITH_PARENT).kind).toBe("resolvable"); + + const orphan = yield* open(FORK_EXECUTION, AT_FORK, FORK_JOURNAL); + const withoutParent = orphan.semantic(); + expect(provenanceLink(withoutParent.model, alone()).kind).toBe("unavailable"); + + expect(withoutParent).toEqual(withParent); + expect(withoutParent.model.provenance).toEqual(forked().provenance); + expect(withoutParent.model.bindings.map((one) => one.name)).toEqual(["project", "release"]); + // Nothing of the parent's later execution is in here. + expect(JSON.stringify(orphan.state())).not.toContain("r-22"); + expect(foreignValues(orphan.state(), "state")).toEqual([]); + }); + + it("points at the parent only while the parent is reachable", function* () { + const model = forked(); + + const reachable = provenanceLink(model, WITH_PARENT); + expect(reachable).toEqual({ + kind: "resolvable", + parent: "e1", + source: FORK_SOURCE, + url: "xmd://repl/e1/transcript?at=r-07", + }); + + // The one canonical spelling, and it resolves against the parent. + if (reachable.kind === "resolvable") { + const route = decodeRoute(reachable.url); + if (!route.ok) { + throw route.error; + } + const where = resolveLocation(route.value, EXECUTION, parsedParent()); + expect(where.ok).toBe(true); + } + + const gone = provenanceLink(model, alone()); + expect(gone.kind).toBe("unavailable"); + if (gone.kind === "unavailable") { + // It still names what it came from. Only following it is impossible. + expect(gone.parent).toBe("e1"); + expect(gone.source).toBe(FORK_SOURCE); + expect(gone.why).toContain("not available"); + } + }); + + it("says nothing about provenance for an execution that was not forked", function* () { + const root = projectPrefix(EXECUTION, parsedParent(), undefined); + if (!root.ok) { + throw root.error; + } + expect(root.value.provenance).toEqual({ kind: "root" }); + expect(provenanceLink(root.value, WITH_PARENT)).toEqual({ kind: "none" }); + }); + + it("makes the link unavailable rather than failing when the parent is unusable", function* () { + const model = forked(); + + const corrupt = library({ [EXECUTION]: [...JOURNAL, { id: "bad" }] }); + expect(provenanceLink(model, corrupt).kind).toBe("unavailable"); + + const shortened = library({ [EXECUTION]: truncatedAfter(3) }); + const missing = provenanceLink(model, shortened); + expect(missing.kind).toBe("unavailable"); + if (missing.kind === "unavailable") { + expect(missing.why).toContain("no marker r-07"); + } + + // A parent that reads but cannot have happened, at or before the marker + // this fork points at, has nothing to open there. + const impossible = library({ + [EXECUTION]: journalChanging(positionOf("r-04"), { entry: "entry-9" }), + }); + const unreconstructable = provenanceLink(model, impossible); + expect(unreconstructable.kind).toBe("unavailable"); + if (unreconstructable.kind === "unavailable") { + expect(unreconstructable.why).toContain("cannot be reconstructed"); + } + + // An inconsistency the parent only reaches *after* the source marker + // leaves the link alone: the moment this fork points at is still there. + const laterTrouble = library({ [EXECUTION]: journalWithout(positionOf("r-08")) }); + expect(provenanceLink(model, laterTrouble).kind).toBe("resolvable"); + + // The fork itself is unaffected by any of it. + expect(forked()).toEqual(model); + }); + + it("copies no secret and no process-local state", function* () { + // The inherited payload first, since that is the one record that reads + // the parent, and then the whole Journal. + const payload = JSON.stringify(FORK_JOURNAL[0]); + expect(payload).not.toContain("secret"); + expect(payload).not.toContain("prompt"); + expect(payload).not.toContain("canContinue"); + + const serialized = JSON.stringify(FORK_JOURNAL); + for (const word of ["secret", "token", "pauseMarker", "canContinue", "EXPANSION PAUSED"]) { + if (word === "secret") { + // `secret: false` is a property of a request, not a value. + expect(serialized).not.toContain('"secret":true'); + continue; + } + expect(serialized).not.toContain(word); + } + }); + + it("refuses a second inheritance and one taken mid-execution", function* () { + const twice = forkWith({ + id: "f-07", + seq: 7, + at: 20, + kind: "entry.inherited", + entry: "entry-2", + title: "Forked again", + parent: "e1", + source: "r-03", + bindings: [], + }); + const again = projectPrefix(FORK_EXECUTION, parsedFork(twice), undefined); + expect(again.ok).toBe(false); + if (!again.ok) { + expect(again.error.message).toContain("already forked"); + } + + // The same inheritance, written after this execution had already begun. + const late = [ + { ...Object(FORK_JOURNAL[2]), seq: 1, id: "g-01" }, + { ...Object(FORK_JOURNAL[0]), seq: 2, id: "g-02" }, + ]; + const afterwards = projectPrefix(FORK_EXECUTION, parsedFork(late), undefined); + expect(afterwards.ok).toBe(false); + if (!afterwards.ok) { + expect(afterwards.error.message).toContain("already began"); + } + }); + + describe("negative controls", () => { + it("multi-record-inheritance: a copy spread over records can stop halfway", function* () { + // What the previous representation looked like: the entry, then the + // environment published one record at a time. + const spread: readonly unknown[] = [ + { + id: "m-01", + seq: 1, + at: 0, + kind: "entry.inherited", + entry: "entry-0", + title: "Forked from the README run", + parent: "e1", + source: FORK_SOURCE, + bindings: [], + }, + { + id: "m-02", + seq: 2, + at: 1, + kind: "binding.published", + entry: "entry-0", + name: "project", + value: "executable.md", + }, + { + id: "m-03", + seq: 3, + at: 2, + kind: "binding.published", + entry: "entry-0", + name: "team", + value: "frontside", + }, + ]; + + const halfway = parseJournal(spread.slice(0, 2)); + if (!halfway.ok) { + throw halfway.error; + } + const partial = projectPrefix(FORK_EXECUTION, halfway.value, undefined); + if (!partial.ok) { + throw partial.error; + } + // It hydrates, and it is wrong in a way nothing downstream can see: + // an environment that existed at no point in either execution. + expect(partial.value.bindings.map((one) => one.name)).toEqual(["project"]); + expect(partial.value.provenance.kind).toBe("forked"); + + // One record cannot be half-read, so the real representation has no + // prefix that answers anything but the whole environment. + const atomic = parseJournal(FORK_INHERITED); + if (!atomic.ok) { + throw atomic.error; + } + const whole = projectPrefix(FORK_EXECUTION, atomic.value, undefined); + if (!whole.ok) { + throw whole.error; + } + expect(whole.value.bindings.map((one) => one.name)).toEqual(["project"]); + expect(FORK_INHERITED.length).toBe(1); + }); + + it("parent-backed-fork: reading the environment from the parent loses it with the parent", function* () { + // What a fork that referenced its parent would have to do: go and read + // the parent's bindings at the source marker. + const borrowed = (reach: ReturnType) => { + const found = reach.journalOf("e1"); + if (!found.found) { + return []; + } + const events = parseJournal(found.records); + if (!events.ok) { + return []; + } + const at = projectPrefix(EXECUTION, events.value, FORK_SOURCE); + return at.ok ? at.value.bindings.map((one) => one.name) : []; + }; + + expect(borrowed(WITH_PARENT)).toEqual(["project"]); + expect(borrowed(alone())).toEqual([]); + // The fork's own answer does not move. + expect(forked().bindings.map((one) => one.name)).toEqual(["project", "release"]); + }); + + it("provenance-at-hydration: resolving the link while hydrating makes the parent a dependency", function* () { + const model = forked(); + const link = provenanceLink(model, alone()); + + // A hydration that insisted on a resolvable link would have refused + // here. Hydration never asks, so it cannot. + expect(link.kind).toBe("unavailable"); + const session = yield* open(FORK_EXECUTION, AT_FORK, FORK_JOURNAL); + expect(session.semantic().model.provenance.kind).toBe("forked"); + }); + }); +}); + +function parsedParent() { + const events = parseJournal(JOURNAL); + if (!events.ok) { + throw events.error; + } + return events.value; +} + +function parsedFork(records: readonly unknown[]) { + const events = parseJournal(records); + if (!events.ok) { + throw events.error; + } + return events.value; +} + +describe("every reader refuses rather than guessing", () => { + it("refuses malformed records at the durable boundary", function* () { + const cases: readonly (readonly [string, readonly unknown[], string])[] = [ + ["not an object", journalWith(3, "r-04"), "record"], + ["an array", journalWith(3, ["r-04"]), "record"], + ["an unknown kind", journalChanging(positionOf("r-22"), { kind: "pause.held" }), "kind"], + ["an undeclared field", journalChanging(positionOf("r-22"), { held: true }), "held"], + [ + "a field of the wrong type", + journalChanging(positionOf("r-22"), { secret: "yes" }), + "secret", + ], + [ + "a path that is not one", + journalChanging(positionOf("r-22"), { scope: "document" }), + "scope", + ], + ["an empty name", journalChanging(positionOf("r-02"), { name: "" }), "name"], + ["a repeated identity", journalChanging(positionOf("r-08"), { id: "r-07" }), "id"], + ]; + + for (const [what, records, field] of cases) { + const refused = parseJournal(records); + expect(refused.ok).toBe(false); + if (refused.ok) { + continue; + } + expect(refused.error).toBeInstanceOf(JournalParseError); + expect(`${what}:${(refused.error as JournalParseError).field}`).toBe(`${what}:${field}`); + } + }); + + it("refuses a truncated record and a truncated stream", function* () { + const cut = parseJournal(journalDropping(positionOf("r-07"), "value")); + expect(cut.ok).toBe(false); + + const gap = parseJournal(journalMissing(positionOf("r-07"))); + expect(gap.ok).toBe(false); + if (!gap.ok) { + expect((gap.error as JournalParseError).field).toBe("seq"); + } + + // A stream that simply stops is short, not corrupt, and reads. + expect(parseJournal(truncatedAfter(8)).ok).toBe(true); + expect(parseJournal([]).ok).toBe(true); + }); + + it("refuses an inconsistent journal at the projection", function* () { + const cases: readonly (readonly [string, readonly unknown[]])[] = [ + ["overlapping entries", journalWithout(positionOf("r-09"))], + ["settling over an open scope", journalWithout(positionOf("r-08"))], + ["completing around a live child", journalWithout(positionOf("r-06"))], + ["a stranger's entry", journalChanging(positionOf("r-04"), { entry: "entry-9" })], + ["a scope path nothing opened", journalChanging(positionOf("r-03"), { scope: ["report"] })], + ["two siblings at one source", journalChanging(positionOf("r-18"), { source: 1 })], + ["an unowned answer", journalChanging(positionOf("r-05"), { scope: ["document"] })], + ]; + + for (const [what, records] of cases) { + const events = parseJournal(records); + expect(`${what}:${events.ok}`).toBe(`${what}:true`); + if (!events.ok) { + continue; + } + const projected = projectPrefix(EXECUTION, events.value, undefined); + expect(`${what}:${projected.ok}`).toBe(`${what}:false`); + if (!projected.ok) { + expect(projected.error).toBeInstanceOf(ProjectionError); + } + } + }); + + it("refuses a fork whose inherited prefix is inconsistent", function* () { + const orphaned = forkChanging(1, { entry: "entry-9" }); + const events = parseJournal(orphaned); + expect(events.ok).toBe(true); + if (!events.ok) { + return; + } + const projected = projectPrefix(FORK_EXECUTION, events.value, undefined); + expect(projected.ok).toBe(false); + + const unnamed = parseJournal(forkChanging(0, { parent: "" })); + expect(unnamed.ok).toBe(false); + }); + + it("refuses a URL that is not spelled like a location", function* () { + const urls = [ + "https://repl/e1/transcript", + "xmd://repl/", + "xmd://repl/e1", + "xmd://repl/e1/nowhere", + "xmd://repl/e1/transcript//document", + "xmd://repl/e1/transcript/entry-1/+project/document", + "xmd://repl/e1/+project", + "xmd://repl/e1/transcript?at=", + "xmd://repl/e1/transcript?inspect", + "xmd://repl/e1/transcript?at=r-07&at=r-03", + "xmd://repl/e1/transcript?nope=1", + "xmd://repl/e1/transcript?inspect=yes", + "xmd://repl/e1/transcript/%ZZ", + ]; + for (const url of urls) { + expect(`${url}:${decodeRoute(url).ok}`).toBe(`${url}:false`); + } + }); + + it("refuses a well-spelled URL the execution never went to", function* () { + const cases: readonly (readonly [string, string])[] = [ + ["xmd://repl/e9/transcript", "execution"], + ["xmd://repl/e1/transcript?at=r-99", "at"], + ["xmd://repl/e1/transcript?at=r-05", "at"], + ["xmd://repl/e1/transcript/entry-9", "entry"], + ["xmd://repl/e1/transcript/entry-3/plan", "scope[0]"], + ["xmd://repl/e1/transcript/entry-3/document/plan", "scope[1]"], + ["xmd://repl/e1/transcript/entry-3/document/+confirm/+source", "drawer[0]"], + ["xmd://repl/e1/transcript/entry-1/document/+review", "drawer[0]"], + ]; + + for (const [url, position] of cases) { + const route = decodeRoute(url); + expect(`${url}:${route.ok}`).toBe(`${url}:true`); + if (!route.ok) { + continue; + } + const where = resolveLocation(route.value, EXECUTION, parsedParent()); + expect(`${url}:${where.ok}`).toBe(`${url}:false`); + if (where.ok) { + continue; + } + expect(where.error).toBeInstanceOf(RouteRefusal); + expect(`${url}:${(where.error as RouteRefusal).position}`).toBe(`${url}:${position}`); + } + }); + + it("refuses at hydration too, and builds no half-session", function* () { + for (const [url, records] of [ + ["xmd://repl/e1/transcript", journalWith(3, "r-04")], + ["xmd://repl/e1/transcript", journalWithout(positionOf("r-09"))], + ["xmd://repl/e1/nowhere", JOURNAL], + ["xmd://repl/e1/transcript/entry-9", JOURNAL], + ] as const) { + const refused = yield* hydrate(EXECUTION, url, records); + expect(`${url}:${refused.ok}`).toBe(`${url}:false`); + } + + // A live session refuses a bad move and keeps the state it had. + const session = yield* open(EXECUTION, "xmd://repl/e1/transcript", JOURNAL); + const before = session.semantic(); + for (const url of ["xmd://repl/e1/nowhere", "xmd://repl/e1/transcript/entry-9"]) { + const moved = yield* session.navigate(url); + expect(moved.ok).toBe(false); + } + expect(session.semantic()).toEqual(before); + expect(session.visits()).toEqual([before.url]); + }); + + describe("negative controls", () => { + it("plausible-partial: reading what parses and stopping produces a view nobody can tell is wrong", function* () { + const damaged = journalDropping(positionOf("r-07"), "value"); + const upToIt = damaged.slice(0, positionOf("r-07")); + + // The prefix before the damaged record reads perfectly, and describes + // an execution in which `project` was simply never published. + const partial = parseJournal(upToIt); + expect(partial.ok).toBe(true); + if (!partial.ok) { + return; + } + const model = projectPrefix(EXECUTION, partial.value, undefined); + expect(model.ok).toBe(true); + if (!model.ok) { + return; + } + expect(model.value.bindings).toEqual([]); + + // The real reader is handed the whole thing and refuses all of it. + expect(parseJournal(damaged).ok).toBe(false); + }); + + it("marker-without-a-record: naming a position nothing minted answers a moment that never was", function* () { + const closing = projectPrefix(EXECUTION, parsedParent(), "r-05"); + expect(closing.ok).toBe(false); + if (!closing.ok) { + expect(closing.error).toBeInstanceOf(UnknownMarkerError); + } + + // A reader that took "the prefix ending at record r-05" anyway would + // have answered, and answered a position the History does not have. + const events = parsedParent(); + const at = events.findIndex((event) => event.id === "r-05"); + const anyway = projectPrefix(EXECUTION, events.slice(0, at + 1), undefined); + expect(anyway.ok).toBe(true); + if (anyway.ok) { + expect(anyway.value.marker).toBe("r-04"); + } + }); + }); +}); diff --git a/scripts/tests/repl-hydration-projection.test.ts b/scripts/tests/repl-hydration-projection.test.ts new file mode 100644 index 000000000..814e07b25 --- /dev/null +++ b/scripts/tests/repl-hydration-projection.test.ts @@ -0,0 +1,903 @@ +/** + * One Journal, one URL, one semantic answer — and nothing else in the answer. + * + * Slice 1 of #842 establishes the pure boundary before StarFX exists, so every + * case here is about what a *record* can produce and what it cannot reach. The + * claims that matter are the negative ones: a historical prefix that cannot + * see a later fact, a projector that cannot be handed a cached snapshot, a + * durable record that cannot carry a continuation, and a model that cannot + * hold anything but frozen plain data. + * + * Each structural claim carries a named control — a weaker implementation + * written here — that *accepts* what the real one refuses, or produces the + * answer the weaker one would have produced. A refusal nothing else would have + * accepted is a refusal nobody is checking. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { readTextFile } from "@effectionx/fs"; +import type { Operation } from "effection"; +import { fileURLToPath } from "node:url"; + +import { + EXECUTION, + JOURNAL, + journalChanging, + journalDropping, + journalMissing, + journalWith, + journalWithout, + LIVE_HEAD, + PAUSE_MARKER, + positionOf, + TERMINAL_EXECUTION, + FORK_JOURNAL, + TERMINAL_JOURNAL, + TERMINAL_MARKERS, + BEFORE_FAILURE, + truncatedAfter, +} from "../repl-hydration/fixture.ts"; +import { + JournalParseError, + MARKER_KINDS, + MARKER_POLICY, + markerWeight, + mintsMarker, + parseJournal, + SEMANTIC_KINDS, +} from "../repl-hydration/journal.ts"; +import type { SemanticEvent } from "../repl-hydration/journal.ts"; +import { + decodeRoute, + encodeRoute, + resolveLocation, + RouteRefusal, +} from "../repl-hydration/location.ts"; +import type { EntryLocation, SemanticLocation } from "../repl-hydration/location.ts"; +import type { Entry, Outcome, Scope, SemanticModel } from "../repl-hydration/model.ts"; +import * as overlay from "../repl-hydration/overlay.ts"; +import { + foldMarkers, + markersOf, + ProjectionError, + projectPrefix, + UnknownMarkerError, +} from "../repl-hydration/project.ts"; +import { foreignValues } from "../repl-hydration/purity.ts"; + +function read(records: readonly unknown[]): readonly SemanticEvent[] { + const parsed = parseJournal(records); + if (!parsed.ok) { + throw parsed.error; + } + return parsed.value; +} + +function refusedRead(records: readonly unknown[]): JournalParseError { + const parsed = parseJournal(records); + if (parsed.ok) { + throw new Error("the journal was read, and should not have been"); + } + if (!(parsed.error instanceof JournalParseError)) { + throw parsed.error; + } + return parsed.error; +} + +const EVENTS = read(JOURNAL); +const MARKERS = markersOf(EVENTS); + +const TERMINAL = read(TERMINAL_JOURNAL); +const TERMINAL_MARKER_IDS = markersOf(TERMINAL); + +function ended(marker?: string): SemanticModel { + const projected = projectPrefix(TERMINAL_EXECUTION, TERMINAL, marker); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +function entryOf(model: SemanticModel, id: string): Entry { + const entry = model.entries.find((one) => one.id === id); + if (entry === undefined) { + throw new Error(`no ${id} at ${model.marker}`); + } + return entry; +} + +function statuses(scopes: readonly Scope[]): readonly string[] { + return scopes.flatMap((scope) => [ + `${scope.name}:${scope.outcome.status}`, + ...statuses(scope.children), + ]); +} + +function reasonOf(outcome: Outcome): string { + return outcome.status === "failed" || outcome.status === "interrupted" ? outcome.reason : ""; +} + +function at(marker?: string, events: readonly SemanticEvent[] = EVENTS): SemanticModel { + const projected = projectPrefix(EXECUTION, events, marker); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +function refusedProjection(events: readonly SemanticEvent[], marker?: string): ProjectionError { + const projected = projectPrefix(EXECUTION, events, marker); + if (projected.ok) { + throw new Error("the journal projected, and should not have"); + } + if (!(projected.error instanceof ProjectionError)) { + throw projected.error; + } + return projected.error; +} + +function located(url: string, events: readonly SemanticEvent[] = EVENTS): SemanticLocation { + const route = decodeRoute(url); + if (!route.ok) { + throw route.error; + } + const answer = resolveLocation(route.value, EXECUTION, events); + if (!answer.ok) { + throw answer.error; + } + return answer.value; +} + +function inside(url: string, events: readonly SemanticEvent[] = EVENTS): EntryLocation { + const where = located(url, events); + if (where.kind !== "entry") { + throw new Error(`${url} named a surface, not an entry`); + } + return where; +} + +function refusedLocation(url: string, events: readonly SemanticEvent[] = EVENTS): RouteRefusal { + const route = decodeRoute(url); + if (!route.ok) { + throw route.error; + } + const answer = resolveLocation(route.value, EXECUTION, events); + if (answer.ok) { + throw new Error(`${url} resolved, and should not have`); + } + if (!(answer.error instanceof RouteRefusal)) { + throw answer.error; + } + return answer.error; +} + +function names(scopes: readonly Scope[]): readonly string[] { + return scopes.map((scope) => scope.name); +} + +function scopeOf(model: SemanticModel, entry: string, path: readonly string[]): Scope { + let level = model.entries.find((one) => one.id === entry)?.scopes ?? []; + let found: Scope | undefined; + for (const name of path) { + found = level.find((scope) => scope.name === name); + if (found === undefined) { + throw new Error(`no ${path.join("/")} in ${entry} at ${model.marker}`); + } + level = found.children; + } + if (found === undefined) { + throw new Error(`no scope path given for ${entry}`); + } + return found; +} + +describe("the durable vocabulary", () => { + it("reads every journal into one closed set of kinds", function* () { + expect(EVENTS.length).toBe(23); + expect(TERMINAL.length).toBe(18); + const forked = read(FORK_JOURNAL); + const used = new Set([...EVENTS, ...TERMINAL, ...forked].map((event) => event.kind)); + expect([...used].every((kind) => SEMANTIC_KINDS.some((one) => one === kind))).toBe(true); + expect(used.size).toBe(SEMANTIC_KINDS.length); + expect(SEMANTIC_KINDS.length).toBe(11); + }); + + it("mints a marker for every record but a closing one, at the policy's weight", function* () { + expect(MARKER_POLICY).toEqual({ + "entry.submitted": "boundary", + "entry.inherited": "boundary", + "entry.settled": "terminal", + "entry.failed": "terminal", + "entry.interrupted": "terminal", + "scope.opened": "opening", + "suspension.opened": "opening", + "binding.published": "checkpoint", + "outcome.recorded": "checkpoint", + "scope.completed": "none", + "suspension.answered": "none", + }); + for (const kind of SEMANTIC_KINDS) { + expect(mintsMarker(kind)).toBe(markerWeight(kind) !== "none"); + expect(mintsMarker(kind)).toBe(MARKER_KINDS.includes(kind)); + } + + expect(MARKERS.length).toBe(20); + expect(MARKERS).toContain("r-09"); + expect(MARKERS).toContain("r-14"); + expect(MARKERS).toContain("r-22"); + expect(MARKERS).toContain("r-23"); + + // Only the two closing kinds mint nothing. + for (const id of ["r-05", "r-06", "r-08"]) { + expect(MARKERS).not.toContain(id); + } + for (const id of ["t-04", "t-05", "t-10"]) { + expect(TERMINAL_MARKER_IDS).not.toContain(id); + } + expect(TERMINAL_MARKER_IDS.length).toBe(15); + }); + + it("refuses a record naming the pause controller", function* () { + const refused = refusedRead(journalChanging(positionOf("r-22"), { kind: "pause.held" })); + expect(refused.field).toBe("kind"); + expect(refused.message).toContain("is not a semantic kind"); + }); + + it("refuses a record carrying a continuation it never declared", function* () { + const refused = refusedRead(journalChanging(positionOf("r-22"), { continuation: "held" })); + expect(refused.field).toBe("continuation"); + expect(refused.message).toContain("is not part of a suspension.opened record"); + }); + + it("refuses a field holding something that is not durable data", function* () { + const refused = refusedRead( + journalChanging(positionOf("r-22"), { prompt: () => "ask the user" }), + ); + expect(refused.field).toBe("prompt"); + }); + + it("refuses a truncated record, naming the field it is missing", function* () { + const refused = refusedRead(journalDropping(positionOf("r-07"), "value")); + expect(refused.index).toBe(6); + expect(refused.field).toBe("value"); + }); + + it("refuses a journal whose append positions do not run in order", function* () { + expect(refusedRead(journalChanging(positionOf("r-07"), { seq: 99 })).field).toBe("seq"); + + // A record lifted out of the middle leaves a gap, and the gap is what + // says the stream is short rather than merely different. + const gap = refusedRead(journalMissing(positionOf("r-07"))); + expect(gap.field).toBe("seq"); + expect(gap.index).toBe(6); + }); + + it("refuses a record recorded twice under one identity", function* () { + expect(refusedRead(journalChanging(positionOf("r-08"), { id: "r-07" })).field).toBe("id"); + }); + + it("refuses a record that is not a record", function* () { + expect(refusedRead(journalWith(3, ["r-04"])).field).toBe("record"); + expect(refusedRead(journalWith(3, null)).field).toBe("record"); + expect(refusedRead(journalWith(3, "r-04")).field).toBe("record"); + }); + + it("reads a journal that simply stops, because stopping is not corruption", function* () { + const cut = parseJournal(truncatedAfter(8)); + expect(cut.ok).toBe(true); + }); + + describe("negative controls", () => { + it("open-vocabulary: a reader that passes an unknown kind through accepts the pause record", function* () { + const records = journalChanging(positionOf("r-22"), { kind: "pause.held" }); + const permissive = records.filter( + (record) => record !== null && typeof record === "object" && "kind" in record, + ); + + expect(permissive.length).toBe(records.length); + expect(refusedRead(records).field).toBe("kind"); + }); + + it("skip-malformed: a reader that drops what it cannot read answers a plausible journal", function* () { + const records = journalDropping(positionOf("r-07"), "value"); + const skipping = records.filter((_, index) => index !== positionOf("r-07")); + + // The weaker reader answers 22 readable records and a transcript that is + // wrong in exactly one invisible way: `project` was never published. + expect(skipping.length).toBe(22); + expect(refusedRead(records).field).toBe("value"); + }); + }); +}); + +describe("the semantic projection", () => { + it("folds incrementally to the same model a prefix projects from scratch", function* () { + const accumulated = foldMarkers(EXECUTION, EVENTS); + if (!accumulated.ok) { + throw accumulated.error; + } + expect([...accumulated.value.keys()]).toEqual([...MARKERS]); + for (const marker of MARKERS) { + expect(at(marker)).toEqual(accumulated.value.get(marker)); + } + + const terminal = foldMarkers(TERMINAL_EXECUTION, TERMINAL); + if (!terminal.ok) { + throw terminal.error; + } + expect([...terminal.value.keys()]).toEqual([...TERMINAL_MARKER_IDS]); + for (const marker of TERMINAL_MARKER_IDS) { + expect(ended(marker)).toEqual(terminal.value.get(marker)); + } + }); + + it("ends the prefix at the selected record, so the live head is the newest marker", function* () { + expect(at().marker).toBe(LIVE_HEAD); + // The journal's last record mints a marker, so these two selections name + // one prefix. The count is what says so rather than the assumption. + expect(at()).toEqual(at(LIVE_HEAD)); + expect(at().records).toBe(JOURNAL.length); + }); + + it("excludes a background outcome at the expansion pause point and includes it at the head", function* () { + const held = at(PAUSE_MARKER); + const head = at(LIVE_HEAD); + + expect(held.outcomes).toEqual([]); + expect(head.outcomes.map((outcome) => outcome.label)).toEqual(["remote tags fetched"]); + expect(head.records).toBe(held.records + 1); + + // The two positions are independent: the pause point moved nowhere while + // the durable head advanced, and the drawers at both are the same stack. + expect(held.suspensions.map((one) => one.wait)).toEqual(["source", "confirm"]); + expect(head.suspensions.map((one) => one.wait)).toEqual(["source", "confirm"]); + }); + + it("cannot observe a later binding, outcome, drawer or settlement from an earlier marker", function* () { + const early = at("r-03"); + const head = at(); + + expect(early.bindings).toEqual([]); + expect(head.bindings.length).toBe(3); + + expect(early.outcomes).toEqual([]); + expect(early.suspensions).toEqual([]); + expect(head.suspensions.length).toBe(2); + + expect(scopeOf(early, "entry-1", ["document"]).outcome).toEqual({ status: "running" }); + expect(scopeOf(early, "entry-1", ["document", "plan"]).outcome).toEqual({ status: "running" }); + expect(scopeOf(head, "entry-1", ["document", "plan"]).outcome).toEqual({ status: "settled" }); + + expect(early.entries.map((entry) => entry.id)).toEqual(["entry-1"]); + expect(early.markers.map((marker) => marker.id)).toEqual(["r-01", "r-02", "r-03"]); + }); + + it("reconstructs the version of a rebound name that the selected marker knew", function* () { + const before = at("r-16").bindings.find((binding) => binding.name === "project"); + const after = at().bindings.find((binding) => binding.name === "project"); + + expect(before?.value).toBe("executable.md"); + expect(before?.marker).toBe("r-07"); + expect(after?.value).toBe("executable.md@0.13.1"); + expect(after?.marker).toBe("r-20"); + }); + + it("inherits the latest published root bindings into each serial entry", function* () { + const head = at(); + const inherited = (id: string) => + head.entries + .find((entry) => entry.id === id) + ?.inherited.map((one) => `${one.name}=${one.value}`); + + expect(inherited("entry-1")).toEqual([]); + expect(inherited("entry-2")).toEqual(["project=executable.md"]); + expect(inherited("entry-3")).toEqual(["project=executable.md", "release=0.13.0"]); + // What entry-3 published itself is not what it inherited. + expect(inherited("entry-3")).not.toContain("changelog=CHANGELOG.md"); + }); + + it("keeps a binding published before a later failure, and interrupts what it left open", function* () { + const head = at(); + const release = head.bindings.find((binding) => binding.name === "release"); + + expect(release?.value).toBe("0.13.0"); + expect(release?.entry).toBe("entry-2"); + + const failed = head.entries.find((entry) => entry.id === "entry-2"); + expect(failed?.outcome).toEqual({ + status: "failed", + reason: "tag 0.13.0 already exists on the remote", + }); + // Interrupted, never settled and never independently failed: the Journal + // recorded one ending, and the scopes carry its reason rather than each + // inventing a failure of its own. + expect(scopeOf(head, "entry-2", ["document"]).outcome).toEqual({ + status: "interrupted", + reason: "tag 0.13.0 already exists on the remote", + }); + expect(scopeOf(head, "entry-2", ["document", "tag"]).outcome.status).toBe("interrupted"); + }); + + it("orders concurrent sibling scopes by source, not by the order they opened", function* () { + const head = at(); + expect(names(scopeOf(head, "entry-3", ["document"]).children)).toEqual(["write", "publish"]); + + const opened = EVENTS.filter( + (event) => + event.kind === "scope.opened" && event.entry === "entry-3" && event.scope.length === 1, + ).map((event) => event.id); + expect(opened).toEqual(["r-17", "r-18"]); + }); + + it("holds nothing but frozen plain data", function* () { + for (const marker of MARKERS) { + expect(foreignValues(at(marker))).toEqual([]); + } + expect(Object.isFrozen(at())).toBe(true); + expect(Object.isFrozen(at().entries)).toBe(true); + expect(foreignValues(at())).toEqual([]); + }); + + it("refuses a marker no record minted", function* () { + const missing = projectPrefix(EXECUTION, EVENTS, "r-99"); + expect(missing.ok).toBe(false); + if (missing.ok) { + return; + } + expect(missing.error).toBeInstanceOf(UnknownMarkerError); + expect(missing.error.message).toContain("names no semantic marker"); + + // A record that exists and mints nothing is not a marker either. + const completion = projectPrefix(EXECUTION, EVENTS, "r-06"); + expect(completion.ok).toBe(false); + }); + + describe("negative controls", () => { + it("leaky-prefix: hiding later entries from the head still shows later facts", function* () { + const head = at(); + const leaked = { + ...head, + entries: head.entries.filter((entry) => entry.marker <= "r-03"), + }; + + // A "historical view" built by filtering the head keeps every binding, + // drawer, outcome and settlement that happened afterwards. + expect(leaked.bindings.length).toBe(3); + expect(leaked.suspensions.length).toBe(2); + expect(leaked.outcomes.length).toBe(1); + expect(at("r-03").bindings).toEqual([]); + expect(at("r-03").outcomes).toEqual([]); + }); + + it("pause-truncates-head: stopping the fold at the pause marker loses the background outcome", function* () { + const stopped = at(PAUSE_MARKER); + + expect(stopped.outcomes).toEqual([]); + expect(at().outcomes.length).toBe(1); + expect(at().marker).not.toBe(PAUSE_MARKER); + }); + + it("append-order-siblings: keeping the order the coroutines opened in reverses the document", function* () { + const appended = EVENTS.filter( + (event) => + event.kind === "scope.opened" && event.entry === "entry-3" && event.scope.length === 1, + ).map((event) => (event.kind === "scope.opened" ? event.name : "")); + + expect(appended).toEqual(["publish", "write"]); + expect(names(scopeOf(at(), "entry-3", ["document"]).children)).toEqual(["write", "publish"]); + }); + + it("decorated-model: one renderer handle on the model is found and named", function* () { + const decorated = Object.freeze({ + ...at(), + renderer: Object.freeze({ draw: () => "" }), + }); + + expect(foreignValues(decorated)).toEqual(["model.renderer.draw: a function"]); + expect(foreignValues(Object.freeze({ ...at(), cells: new Uint8Array(4) }))).toEqual([ + "model.cells: a Uint8Array", + ]); + expect(foreignValues(Object.freeze({ ...at(), scroll: { top: 4 } }))).toEqual([ + "model.scroll: an unfrozen object", + ]); + expect(foreignValues(at())).toEqual([]); + }); + + it("snapshot-dependent: a projector that needs its cache answers nothing without one", function* () { + const accumulated = foldMarkers(EXECUTION, EVENTS); + if (!accumulated.ok) { + throw accumulated.error; + } + const cached = (table: ReadonlyMap, marker: string) => + table.get(marker); + + expect(cached(accumulated.value, PAUSE_MARKER)).toBeDefined(); + expect(cached(new Map(), PAUSE_MARKER)).toBeUndefined(); + // The real projector takes records and a marker. There is no argument a + // cache would go in, so discarding one changes nothing. + expect(at(PAUSE_MARKER)).toEqual(at(PAUSE_MARKER, read(JOURNAL))); + }); + }); +}); + +describe("a journal that reads but cannot have happened", () => { + it("refuses overlapping top-level entries", function* () { + const refused = refusedProjection(read(journalWithout(positionOf("r-09")))); + expect(refused.record).toBe("r-10"); + expect(refused.message).toContain("overlaps entry-1, which is still running"); + }); + + it("refuses an entry that settles while a scope it opened has not completed", function* () { + const refused = refusedProjection(read(journalWithout(positionOf("r-08")))); + expect(refused.record).toBe("r-09"); + expect(refused.message).toContain("has not completed"); + }); + + it("refuses a scope completed twice, and one completed around a live child", function* () { + const twice = refusedProjection( + read( + journalWith(positionOf("r-07"), { + id: "r-07", + seq: 7, + at: 21, + kind: "scope.completed", + entry: "entry-1", + scope: ["document"], + name: "plan", + }), + ), + ); + expect(twice.message).toContain("which is already settled"); + + const around = refusedProjection(read(journalWithout(positionOf("r-06")))); + expect(around.message).toContain("still open inside it"); + }); + + it("refuses malformed ownership", function* () { + const stranger = refusedProjection( + read(journalChanging(positionOf("r-04"), { entry: "entry-9" })), + ); + expect(stranger.message).toContain("names an entry no record submitted"); + + const unowned = refusedProjection( + read(journalChanging(positionOf("r-05"), { scope: ["document"] })), + ); + expect(unowned.message).toContain("answers no wait this entry has open there"); + + const nowhere = refusedProjection( + read(journalChanging(positionOf("r-03"), { scope: ["report"] })), + ); + expect(nowhere.message).toContain("names a scope path no record opened"); + }); + + it("refuses two siblings claiming one source position", function* () { + const refused = refusedProjection(read(journalChanging(positionOf("r-18"), { source: 1 }))); + expect(refused.message).toContain("claims source position 1, which publish holds"); + }); + + describe("negative controls", () => { + it("permissive-ownership: a fold without the serial rule describes two live entries", function* () { + const events = read(journalWithout(positionOf("r-09"))); + const live = events + .filter((event) => event.kind === "entry.submitted") + .map((one) => one.entry); + + // Nothing in the records prevents this: the refusal is the projection's. + expect(live).toEqual(["entry-1", "entry-2", "entry-3"]); + expect(events.some((event) => event.kind === "entry.settled")).toBe(false); + expect(refusedProjection(events).record).toBe("r-10"); + }); + + it("permissive-closure: accepting a settlement over an open scope keeps a false live tree", function* () { + const events = read(journalWithout(positionOf("r-08"))); + const completed = events.filter( + (event) => event.kind === "scope.completed" && event.entry === "entry-1", + ); + + expect(completed.map((event) => event.id)).toEqual(["r-06"]); + expect(refusedProjection(events).record).toBe("r-09"); + }); + }); +}); + +describe("how an entry ends", () => { + function terminalInside(url: string): EntryLocation { + const route = decodeRoute(url); + if (!route.ok) { + throw route.error; + } + const answer = resolveLocation(route.value, TERMINAL_EXECUTION, TERMINAL); + if (!answer.ok) { + throw answer.error; + } + if (answer.value.kind !== "entry") { + throw new Error(`${url} named a surface, not an entry`); + } + return answer.value; + } + + it("fails the entry and interrupts only what was still open", function* () { + const model = ended(TERMINAL_MARKERS.failed); + const entry = entryOf(model, "entry-2"); + const reason = "the registry rejected the tarball"; + + expect(entry.outcome).toEqual({ status: "failed", reason }); + // `verify` completed before the failure and stays completed; `upload` and + // the `document` around it were open, and carry the entry's reason. + expect(statuses(entry.scopes)).toEqual([ + "document:interrupted", + "verify:settled", + "upload:interrupted", + ]); + expect(reasonOf(scopeOf(model, "entry-2", ["document", "upload"]).outcome)).toBe(reason); + expect(reasonOf(scopeOf(model, "entry-2", ["document", "verify"]).outcome)).toBe(""); + }); + + it("interrupts the entry itself when the run was stopped rather than failing", function* () { + const model = ended(TERMINAL_MARKERS.interrupted); + const entry = entryOf(model, "entry-3"); + const reason = "the operator stopped the run"; + + expect(entry.outcome).toEqual({ status: "interrupted", reason }); + expect(statuses(entry.scopes)).toEqual(["document:interrupted", "watch:interrupted"]); + // The wait it was holding closes with it. + expect(model.suspensions).toEqual([]); + expect(ended(BEFORE_FAILURE).suspensions).toEqual([]); + expect(ended("t-17").suspensions.map((one) => one.wait)).toEqual(["approve"]); + }); + + it("keeps a binding published before the failure", function* () { + expect(ended().bindings.map((binding) => `${binding.name}=${binding.value}`)).toEqual([ + "release=0.14.0", + ]); + expect(ended().bindings[0].entry).toBe("entry-2"); + }); + + it("mints one terminal marker for each way an entry ends", function* () { + for (const marker of Object.values(TERMINAL_MARKERS)) { + expect(TERMINAL_MARKER_IDS).toContain(marker); + const model = ended(marker); + expect(model.markers[model.markers.length - 1].weight).toBe("terminal"); + } + expect(markerWeight("entry.settled")).toBe("terminal"); + expect(markerWeight("entry.failed")).toBe("terminal"); + expect(markerWeight("entry.interrupted")).toBe("terminal"); + expect(markerWeight("entry.submitted")).toBe("boundary"); + expect(markerWeight("scope.completed")).toBe("none"); + expect(markerWeight("suspension.answered")).toBe("none"); + }); + + it("resolves each terminal marker through the #840 grammar to its exact end state", function* () { + const settled = terminalInside( + `xmd://repl/e2/transcript/entry-1/document/check?at=${TERMINAL_MARKERS.settled}`, + ); + expect(entryOf(settled.model, "entry-1").outcome).toEqual({ status: "settled" }); + expect(settled.scopes.map((scope) => scope.outcome.status)).toEqual(["settled", "settled"]); + + const failed = terminalInside( + `xmd://repl/e2/transcript/entry-2/document/upload?at=${TERMINAL_MARKERS.failed}`, + ); + expect(entryOf(failed.model, "entry-2").outcome.status).toBe("failed"); + expect(failed.scopes.map((scope) => scope.outcome.status)).toEqual([ + "interrupted", + "interrupted", + ]); + + const stopped = terminalInside( + `xmd://repl/e2/transcript/entry-3/document/watch?at=${TERMINAL_MARKERS.interrupted}`, + ); + expect(entryOf(stopped.model, "entry-3").outcome.status).toBe("interrupted"); + expect(stopped.drawers).toEqual([]); + + // Each is the one spelling of that location. + expect(encodeRoute(failed.route)).toBe( + "xmd://repl/e2/transcript/entry-2/document/upload?at=t-13", + ); + }); + + it("shows no ending at all in a prefix before the failure", function* () { + const before = ended(BEFORE_FAILURE); + const outcomes = before.entries.map((entry) => entry.outcome.status); + + expect(outcomes).toEqual(["settled", "running"]); + expect(statuses(entryOf(before, "entry-2").scopes)).toEqual([ + "document:running", + "verify:settled", + "upload:running", + ]); + expect(JSON.stringify(before)).not.toContain("interrupted"); + expect(JSON.stringify(before)).not.toContain("failed"); + }); + + describe("negative controls", () => { + it("restore-abandoned: a fifth status renames an interruption into something the model has no record of", function* () { + const scope = scopeOf(ended(TERMINAL_MARKERS.failed), "entry-2", ["document", "upload"]); + const restored = { ...scope.outcome, status: "abandoned" }; + + expect(restored.status).toBe("abandoned"); + expect(scope.outcome.status).toBe("interrupted"); + expect(JSON.stringify(ended())).not.toContain("abandoned"); + expect(JSON.stringify(at())).not.toContain("abandoned"); + expect(SEMANTIC_KINDS.join(" ")).not.toContain("abandon"); + }); + + it("omit-terminal-kinds: a policy without them cannot stand where the entry ended", function* () { + const terminals = ["entry.settled", "entry.failed", "entry.interrupted"]; + const weaker = TERMINAL.filter( + (event) => mintsMarker(event.kind) && !terminals.includes(event.kind), + ); + + expect(weaker.map((event) => event.id)).not.toContain(TERMINAL_MARKERS.failed); + + // The nearest position the weaker policy can offer is the scope opening + // before it, where entry-2 is still running. + const reachable = weaker.filter((event) => event.seq <= 13); + const nearest = reachable[reachable.length - 1].id; + expect(nearest).toBe("t-12"); + expect(entryOf(ended(nearest), "entry-2").outcome.status).toBe("running"); + expect(entryOf(ended(TERMINAL_MARKERS.failed), "entry-2").outcome.status).toBe("failed"); + }); + + it("closing-marker: minting on completion gives one scope two positions", function* () { + const closing = TERMINAL.filter( + (event) => mintsMarker(event.kind) || event.kind === "scope.completed", + ).map((event) => event.id); + + expect(closing.length).toBe(TERMINAL_MARKER_IDS.length + 3); + expect(closing).toContain("t-04"); + // `check` opened at t-03 and completed at t-04. One marker, updated. + expect(TERMINAL_MARKER_IDS).toContain("t-03"); + expect(TERMINAL_MARKER_IDS).not.toContain("t-04"); + expect(scopeOf(ended(), "entry-1", ["document", "check"]).marker).toBe("t-03"); + }); + + it("interrupt-completed-scope: stamping the whole subtree rewrites a scope that finished", function* () { + const entry = entryOf(ended(TERMINAL_MARKERS.failed), "entry-2"); + const stamped = (scopes: readonly Scope[]): readonly string[] => + scopes.flatMap((scope) => [`${scope.name}:interrupted`, ...stamped(scope.children)]); + + expect(stamped(entry.scopes)).toEqual([ + "document:interrupted", + "verify:interrupted", + "upload:interrupted", + ]); + expect(statuses(entry.scopes)).toEqual([ + "document:interrupted", + "verify:settled", + "upload:interrupted", + ]); + }); + }); +}); + +describe("the #840 grammar, resolved against a prefix", () => { + it("decodes and re-encodes one location in one spelling", function* () { + const url = + "xmd://repl/e1/transcript/entry-3/document/publish/+source/+confirm?at=r-22&inspect"; + const where = inside(url); + + expect(encodeRoute(where.route)).toBe(url); + expect(where.surface).toBe("transcript"); + expect(where.inspecting).toBe(true); + expect(where.model.marker).toBe(PAUSE_MARKER); + expect(names(where.scopes)).toEqual(["document", "publish"]); + expect(where.drawers.map((drawer) => drawer.wait)).toEqual(["source", "confirm"]); + }); + + it("accepts an equivalent spelling and answers the same structure", function* () { + const canonical = "xmd://repl/e1/transcript/entry-3/document?at=r-22&draft=tag%20it"; + const equivalent = "xmd://repl/e1/transcript/entry-3/%64ocument?draft=tag%20it&at=r-22"; + + expect(encodeRoute(inside(equivalent).route)).toBe(canonical); + expect(inside(equivalent).draft).toBe("tag it"); + }); + + it("hands back the model's own values rather than a second projection", function* () { + const where = inside("xmd://repl/e1/transcript/entry-3/document/write?at=r-22"); + const entry = where.model.entries.find((one) => one.id === "entry-3"); + + expect(where.entry).toBe(entry); + expect(where.scopes[0]).toBe(entry?.scopes[0]); + expect(where.scopes[1]).toBe(entry?.scopes[0].children[0]); + }); + + it("answers the same URL differently at two markers, and refuses where the execution had not been", function* () { + const live = "xmd://repl/e1/transcript/entry-3/document"; + expect(inside(live).model.marker).toBe(LIVE_HEAD); + expect(names(inside(live).scopes)).toEqual(["document"]); + + const early = refusedLocation("xmd://repl/e1/transcript/entry-3/document?at=r-03"); + expect(early.position).toBe("entry"); + expect(early.found).toEqual(["entry-1"]); + + const closed = refusedLocation("xmd://repl/e1/transcript/entry-3/document/+source?at=r-16"); + expect(closed.position).toBe("drawer[0]"); + expect(closed.found).toEqual([]); + }); + + it("refuses an unresolved segment, a marker nothing minted, and another execution", function* () { + // `document` resolves and `plan` does not, so the refusal names the + // segment that missed rather than the path that contains it. + const scope = refusedLocation("xmd://repl/e1/transcript/entry-3/document/plan"); + expect(scope.position).toBe("scope[1]"); + expect(scope.found).toEqual(["write", "publish"]); + + const marker = refusedLocation("xmd://repl/e1/bindings?at=r-99"); + expect(marker.position).toBe("at"); + expect(marker.found).toContain(PAUSE_MARKER); + + const execution = refusedLocation("xmd://repl/e2/bindings"); + expect(execution.position).toBe("execution"); + }); + + it("refuses a URL that is not spelled like a location before any journal is read", function* () { + const syntax = decodeRoute("xmd://repl/e1/transcript/entry-1/+project/document"); + expect(syntax.ok).toBe(false); + }); +}); + +describe("the boundary", () => { + /** Every module specifier one source file imports, deduplicated and sorted. */ + function* importsOf(name: string): Operation { + const source = yield* readTextFile( + fileURLToPath(new URL(`../repl-hydration/${name}`, import.meta.url)), + ); + const found = [...source.matchAll(/(?:^|\n)(?:import|export)[^\n]*?from\s+"([^"]+)"/g)]; + return [...new Set(found.map((one) => one[1]))].toSorted(); + } + + it("keeps process-local state out of everything that reads durable state", function* () { + const durable = [ + "journal.ts", + "model.ts", + "project.ts", + "purity.ts", + "location.ts", + "store.ts", + ]; + for (const name of durable) { + // The pause controller and the Agent stream are the two halves process + // loss takes. Neither is reachable from anything that reads a record. + expect(yield* importsOf(name)).not.toContain("./overlay.ts"); + expect(yield* importsOf(name)).not.toContain("./ephemeral.ts"); + } + expect(yield* importsOf("overlay.ts")).toEqual([]); + expect(yield* importsOf("ephemeral.ts")).toEqual([]); + + // Replay is where a live process and a record meet, so it may reach both + // — and it reads the projection, because a journal that cannot have + // happened is not one to resume from. + expect(yield* importsOf("replay.ts")).toEqual([ + "./ephemeral.ts", + "./journal.ts", + "./project.ts", + "effection", + ]); + }); + + it("reaches the URL grammar through #840 rather than restating it", function* () { + expect(yield* importsOf("location.ts")).toEqual([ + "../repl-compose/router.ts", + "./journal.ts", + "./model.ts", + "./project.ts", + "effection", + ]); + expect(yield* importsOf("journal.ts")).toEqual(["effection"]); + expect(yield* importsOf("project.ts")).toEqual(["./journal.ts", "./model.ts", "effection"]); + }); + + it("leaves the pause controller nowhere but the live overlay", function* () { + const held = overlay.live(PAUSE_MARKER); + expect(held.canContinue).toBe(true); + expect(overlay.released(held).canContinue).toBe(false); + expect(overlay.cold().held).toBe(false); + + // Nothing durable and nothing addressable knows any of that. + const serialized = JSON.stringify({ journal: JOURNAL, model: at(), markers: MARKERS }); + for (const word of ["pauseMarker", "canContinue", "continuation", "EXPANSION PAUSED"]) { + expect(serialized).not.toContain(word); + } + }); +}); diff --git a/scripts/tests/repl-hydration-replay.test.ts b/scripts/tests/repl-hydration-replay.test.ts new file mode 100644 index 000000000..e51663345 --- /dev/null +++ b/scripts/tests/repl-hydration-replay.test.ts @@ -0,0 +1,877 @@ +/** + * What survives a restart, and what must not. + * + * Slice 3 of #842. Slices 1 and 2 showed that a Journal and a URL determine + * the view; this asks what happens when the process that produced them is + * gone. Three things are being separated: + * + * - typing, which moves the URL and touches neither the Journal nor the + * ordinary navigation history; + * - an Agent's partial output, which is how someone watched it think, against + * its admitted result, which is what happened; + * - a recovered answer, which replay reads out of the record, against a + * secret, which it can only ask for again. + * + * The document is a fixture and there is no model provider anywhere near it: + * `SCRIPT` is a deterministic list of steps, and the whole point is that it + * can be run twice and the two runs compared. + * + * The secret used throughout is a literal this file owns, so "it appears + * nowhere" is a claim the evidence can actually search for. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; + +import { createStreaming, noSecrets, scriptedSecrets } from "../repl-hydration/ephemeral.ts"; +import { parseJournal } from "../repl-hydration/journal.ts"; +import * as overlay from "../repl-hydration/overlay.ts"; +import { projectPrefix } from "../repl-hydration/project.ts"; +import { + answered, + elicitation, + ExecutionGap, + ReplayDivergence, + resume, + SCRIPT, +} from "../repl-hydration/replay.ts"; +import type { Run } from "../repl-hydration/replay.ts"; +import { hydrate } from "../repl-hydration/store.ts"; +import type { ReplSession } from "../repl-hydration/store.ts"; + +/** The secret, written once so the evidence can hunt for it. */ +const TOKEN = "npm_Ie4Xz9QqSECRETvalue"; +const EXECUTION = "e3"; + +const CHANNEL = elicitation("channel"); +const SECRET = elicitation("token"); + +function ran(run: ReturnType): Run { + if (!run.ok) { + throw run.error; + } + return run.value; +} + +function recorded(records: readonly unknown[], step = CHANNEL, value = "#releases") { + const next = answered(records, step, value); + if (!next.ok) { + throw next.error; + } + return next.value; +} + +/** The journal of a first run that stopped at the ordinary elicitation. */ +function firstRun() { + const streaming = createStreaming(); + const run = ran(resume({ script: SCRIPT, prior: [], streaming, secrets: noSecrets() })); + return { run, streaming }; +} + +/** The journal where the secret has been asked and not yet answered. */ +function awaitingSecret(): readonly unknown[] { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const withChannel = recorded(first.records, CHANNEL, "#releases"); + return ran( + resume({ + script: SCRIPT, + prior: withChannel, + streaming: createStreaming(), + secrets: noSecrets(), + }), + ).records; +} + +/** The journal after both answers were recorded: what a restart would find. */ +function bothAnswered(): readonly unknown[] { + return recorded(awaitingSecret(), SECRET, ""); +} + +/** The journal of a document that ran all the way to the end. */ +function finished(): readonly unknown[] { + return ran( + resume({ + script: SCRIPT, + prior: bothAnswered(), + secrets: scriptedSecrets({ token: TOKEN }), + streaming: createStreaming(), + }), + ).records; +} + +function* open(url: string, records: readonly unknown[]): Operation { + const session = yield* hydrate(EXECUTION, url, records); + if (!session.ok) { + throw session.error; + } + return session.value; +} + +describe("a draft is not a record and not a visit", () => { + const AT = "xmd://repl/e3/transcript/entry-1/document"; + + it("moves the URL and nothing else", function* () { + const records = bothAnswered(); + const session = yield* open(AT, records); + const before = session.semantic(); + + for (const draft of ["g", "gi", "git"]) { + const typed = yield* session.type(draft); + expect(typed.ok).toBe(true); + } + + const after = session.semantic(); + expect(after.url).toBe(`${AT}?draft=git`); + expect(after.url).not.toBe(before.url); + + // The Journal did not move, and neither did anything projected from it. + expect(session.state().records).toEqual(records); + expect(after.model).toEqual(before.model); + expect(after.history).toEqual(before.history); + + // Three keystrokes are one place, not three. + expect(session.visits()).toEqual([`${AT}?draft=git`]); + }); + + it("grows the navigation history when the location actually changes", function* () { + const session = yield* open(AT, bothAnswered()); + yield* session.type("gi"); + const moved = yield* session.navigate("xmd://repl/e3/bindings"); + expect(moved.ok).toBe(true); + yield* session.type("no"); + + expect(session.visits()).toEqual([`${AT}?draft=gi`, "xmd://repl/e3/bindings?draft=no"]); + expect(session.state().records.length).toBe(bothAnswered().length); + }); + + it("keeps the draft out of every record kind", function* () { + const session = yield* open(AT, bothAnswered()); + yield* session.type("something typed and never run"); + + const serialized = JSON.stringify(session.state().records); + expect(serialized).not.toContain("something typed"); + expect(session.semantic().url).toContain("something%20typed"); + }); +}); + +describe("an Agent's stream is not its result", () => { + it("streams while it runs and records only what was admitted", function* () { + const { run, streaming } = firstRun(); + + expect(run.performed).toContain("agent entry-1/document/draft"); + expect(run.consumed).toEqual([]); + // The chunks went somewhere process-local and were dropped at admission. + expect(streaming.streaming()).toEqual([]); + expect(run.streaming).toEqual([]); + + const events = parseJournal(run.records); + if (!events.ok) { + throw events.error; + } + const outcomes = events.value.filter((event) => event.kind === "outcome.recorded"); + expect(outcomes.length).toBe(1); + expect(JSON.stringify(run.records)).not.toContain('Rele"'); + expect(JSON.stringify(run.records)).toContain("Release notes for 0.14.0"); + }); + + it("shows the admitted result and its scopes after a cold restart, and no partial text", function* () { + const records = bothAnswered(); + const streaming = createStreaming(); + const replayed = ran( + resume({ + script: SCRIPT, + prior: records, + secrets: scriptedSecrets({ token: TOKEN }), + streaming, + }), + ); + + // The Agent did not run: its result was read out of the record. + expect(replayed.performed).toEqual([]); + expect(replayed.consumed).toContain("agent entry-1/document/draft"); + expect(streaming.streaming()).toEqual([]); + + const session = yield* open("xmd://repl/e3/transcript/entry-1/document/draft", records); + const draft = session.semantic().model.entries[0].scopes[0].children[0]; + expect(draft.name).toBe("draft"); + // The semantic scope the admitted result created survives with it. + expect(draft.children.map((scope) => scope.name)).toEqual(["review"]); + expect(session.semantic().model.outcomes.map((one) => one.label)).toEqual([ + "Release notes for 0.14.0", + ]); + expect(JSON.stringify(session.state())).not.toContain('"Rele"'); + }); +}); + +describe("replay consumes what was recorded and stops at the frontier", () => { + it("reaches the first unanswered elicitation without repeating anything", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + + expect(first.frontier).toEqual({ + kind: "awaiting", + wait: "channel", + prompt: CHANNEL.prompt, + secret: false, + }); + expect(first.performed).toEqual(["agent entry-1/document/draft", "publish notes"]); + + // Run it again over what it wrote: the same position, nothing performed. + const again = ran( + resume({ + script: SCRIPT, + prior: first.records, + streaming: createStreaming(), + secrets: noSecrets(), + }), + ); + expect(again.frontier).toEqual(first.frontier); + expect(again.performed).toEqual([]); + expect(again.consumed).toEqual(["agent entry-1/document/draft", "publish notes"]); + expect(again.records).toEqual(first.records); + }); + + it("recovers an ordinary answer from the record and advances the frontier", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const secrets = scriptedSecrets({ token: TOKEN }); + const second = ran( + resume({ + script: SCRIPT, + prior: recorded(first.records, CHANNEL, "#releases"), + secrets, + streaming: createStreaming(), + }), + ); + + expect(second.recovered).toEqual(["channel=#releases"]); + expect(secrets.asked).toEqual([]); + + // The recovered answer reached the continuation, not just the report: + // the step after it publishes what the person said. + expect(second.performed).toEqual(["publish announced"]); + const announced = second.records.find((record) => Object(record).name === "announced"); + expect(Object(announced).value).toBe("#releases"); + + expect(second.frontier).toEqual({ + kind: "awaiting", + wait: "token", + prompt: SECRET.prompt, + secret: true, + }); + }); + + it("runs to completion once every answer is recorded, performing nothing twice", function* () { + const secrets = scriptedSecrets({ token: TOKEN }); + const run = ran( + resume({ script: SCRIPT, prior: bothAnswered(), secrets, streaming: createStreaming() }), + ); + + expect(run.frontier).toEqual({ kind: "complete" }); + expect(run.performed).toEqual([]); + expect(run.consumed).toEqual([ + "agent entry-1/document/draft", + "publish notes", + "publish announced", + ]); + + // The document finished, and the journal it finished on projects. + const events = parseJournal(run.records); + if (!events.ok) { + throw events.error; + } + const model = projectPrefix(EXECUTION, events.value, undefined); + if (!model.ok) { + throw model.error; + } + expect(model.value.entries[0].outcome).toEqual({ status: "settled" }); + expect(model.value.bindings.map((one) => one.name)).toEqual(["notes", "announced"]); + }); +}); + +describe("a secret is asked for again, never recovered", () => { + it("records that it was asked and answered, and nothing of what was said", function* () { + const records = bothAnswered(); + const events = parseJournal(records); + if (!events.ok) { + throw events.error; + } + + const opened = events.value.filter((event) => event.kind === "suspension.opened"); + const secret = opened.find((event) => event.kind === "suspension.opened" && event.secret); + expect(secret?.kind).toBe("suspension.opened"); + + const answers = events.value.filter((event) => event.kind === "suspension.answered"); + expect( + answers.map((event) => (event.kind === "suspension.answered" ? event.answer : "?")), + ).toEqual(["#releases", ""]); + }); + + it("re-prompts on replay rather than reading a value that is not there", function* () { + const secrets = scriptedSecrets({ token: TOKEN }); + const replayed = ran( + resume({ script: SCRIPT, prior: bothAnswered(), secrets, streaming: createStreaming() }), + ); + + expect(replayed.frontier).toEqual({ kind: "complete" }); + expect(secrets.asked).toEqual(["token"]); + expect(replayed.asked).toEqual(["token"]); + expect(replayed.recovered).toEqual(["channel=#releases"]); + }); + + it("stops at the secret frontier when nobody is there to ask", function* () { + const secrets = noSecrets(); + const headless = ran( + resume({ script: SCRIPT, prior: bothAnswered(), secrets, streaming: createStreaming() }), + ); + + expect(headless.frontier).toEqual({ + kind: "unrevealed", + wait: "token", + prompt: SECRET.prompt, + }); + expect(secrets.asked).toEqual(["token"]); + }); + + it("appears in no journal, URL, store, history or error", function* () { + const records = bothAnswered(); + const secrets = scriptedSecrets({ token: TOKEN }); + const replayed = ran( + resume({ script: SCRIPT, prior: records, secrets, streaming: createStreaming() }), + ); + + const session = yield* open("xmd://repl/e3/transcript/entry-1/document/publish", records); + yield* session.type(TOKEN.slice(0, 4)); + + const refused = yield* session.navigate("xmd://repl/e3/transcript/entry-9"); + expect(refused.ok).toBe(false); + + const surfaces = [ + JSON.stringify(records), + JSON.stringify(replayed), + JSON.stringify(session.state()), + JSON.stringify(session.semantic()), + JSON.stringify(session.visits()), + JSON.stringify(secrets.asked), + refused.ok ? "" : `${refused.error.name} ${refused.error.message}`, + ]; + for (const surface of surfaces) { + expect(surface).not.toContain(TOKEN); + } + }); + + it("refuses a journal that recorded a secret's value", function* () { + // The same wait, answered as though it were ordinary. A record like this + // is well formed, so the parser reads it; the projection is what refuses + // it, because only the projection knows the wait was opened as secret. + const leaked = answered(awaitingSecret(), { ...SECRET, secret: false }, TOKEN); + if (!leaked.ok) { + throw leaked.error; + } + + const events = parseJournal(leaked.value); + expect(events.ok).toBe(true); + if (!events.ok) { + return; + } + const projected = projectPrefix(EXECUTION, events.value, undefined); + expect(projected.ok).toBe(false); + if (projected.ok) { + return; + } + expect(projected.error.message).toContain("with a recorded value"); + expect(projected.error.message).not.toContain(TOKEN); + + // The helper refuses to write one in the first place. + expect(answered([], SECRET, TOKEN).ok).toBe(false); + }); +}); + +describe("replay consumes only its own records", () => { + function replayed(prior: readonly unknown[]) { + return resume({ + script: SCRIPT, + prior, + streaming: createStreaming(), + secrets: scriptedSecrets({ token: TOKEN }), + }); + } + + function diverged(prior: readonly unknown[]): ReplayDivergence { + const run = replayed(prior); + if (run.ok) { + throw new Error("the journal was resumed, and should not have been"); + } + if (!(run.error instanceof ReplayDivergence)) { + throw run.error; + } + return run.error; + } + + /** The representative journal with one record's fields changed. */ + function changing(prior: readonly unknown[], at: number, changes: Record) { + return prior.map((record, index) => + index === at ? { ...Object(record), ...changes } : record, + ); + } + + function positionOf(prior: readonly unknown[], kind: string): number { + const at = prior.findIndex((record) => Object(record).kind === kind); + if (at === -1) { + throw new Error(`no ${kind} in this journal`); + } + return at; + } + + it("refuses a journal that submitted another entry", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + // A coherent journal belonging to another document, not a damaged one: + // it projects perfectly well, and it is still not this document's past. + const foreign = first.records.map((record) => ({ ...Object(record), entry: "other-entry" })); + const before = JSON.stringify(foreign); + const refusal = diverged(foreign); + + expect(refusal.position).toBe(0); + expect(refusal.expected).toContain('entry.submitted "entry-1"'); + expect(refusal.found).toContain("other-entry"); + // Nothing ran and nothing was written: the journal handed in is intact. + expect(JSON.stringify(foreign)).toBe(before); + }); + + it("refuses another binding under the right record kind", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const at = positionOf(first.records, "binding.published"); + const refusal = diverged(changing(first.records, at, { name: "other", value: "wrong" })); + + expect(refusal.position).toBe(at); + expect(refusal.expected).toContain('binding.published "notes"'); + expect(refusal.found).toContain('"other"'); + }); + + it("refuses another Agent occurrence under outcome.recorded", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const at = positionOf(first.records, "outcome.recorded"); + + // The result is untouched; only the request differs. Matching on what + // came back rather than what was asked would have accepted this. + const refusal = diverged( + changing(first.records, at, { request: "entry-1/document/elsewhere" }), + ); + expect(refusal.position).toBe(at); + expect(refusal.expected).toContain("entry-1/document/draft"); + expect(refusal.found).toContain("elsewhere"); + + const moved = diverged(changing(first.records, at, { scope: ["document"] })); + expect(moved.position).toBe(at); + }); + + it("refuses another suspension under the right record kind", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const at = positionOf(first.records, "suspension.opened"); + const renamed = diverged(changing(first.records, at, { wait: "elsewhere" })); + + expect(renamed.position).toBe(at); + expect(renamed.expected).toContain('suspension.opened "channel"'); + expect(renamed.found).toContain("elsewhere"); + + // An answer that belongs to no open wait never reaches alignment: the + // projection refuses it first, which is the earlier of the two guards. + const records = bothAnswered(); + const answers = records.findIndex((record) => Object(record).kind === "suspension.answered"); + expect(replayed(changing(records, answers, { wait: "elsewhere" })).ok).toBe(false); + }); + + it("refuses a retained record left over once the document has finished", function* () { + const complete = finished(); + // A perfectly valid record — a second entry may follow a settled one — + // that this document nonetheless does not write. + const extra = [ + ...complete, + { + id: "x-01", + seq: complete.length + 1, + at: 99, + kind: "entry.submitted", + entry: "entry-2", + title: "Something this document never submits", + }, + ]; + + const refusal = diverged(extra); + expect(refusal.position).toBe(complete.length); + expect(refusal.expected).toContain("the document to be finished"); + expect(refusal.found).toContain("entry-2"); + + // The same journal without it resumes to completion and appends nothing. + const run = ran(replayed(complete)); + expect(run.frontier).toEqual({ kind: "complete" }); + expect(run.records).toEqual(complete); + }); + + it("performs the first unrecorded effect exactly once on a compatible prefix", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const agentAt = positionOf(first.records, "outcome.recorded"); + // Everything up to and including the admitted Agent result, and no more. + const prefix = first.records.slice(0, agentAt + 1); + + const run = ran(replayed(prefix)); + expect(run.consumed).toEqual(["agent entry-1/document/draft"]); + expect(run.performed).toEqual(["publish notes"]); + expect(run.frontier).toEqual({ + kind: "awaiting", + wait: "channel", + prompt: CHANNEL.prompt, + secret: false, + }); + + // Once, not twice: resuming over what it just wrote performs nothing. + const again = ran(replayed(run.records)); + expect(again.performed).toEqual([]); + expect(again.consumed).toEqual(["agent entry-1/document/draft", "publish notes"]); + expect(again.records).toEqual(run.records); + }); + + it("performs nothing at all on an exact replay", function* () { + const records = finished(); + const run = ran(replayed(records)); + + expect(run.performed).toEqual([]); + expect(run.consumed).toEqual([ + "agent entry-1/document/draft", + "publish notes", + "publish announced", + ]); + expect(run.records).toEqual(records); + expect(run.frontier).toEqual({ kind: "complete" }); + + // An unfinished journal appends the records its remaining steps write, + // and still performs no durable effect that is already recorded. + const partial = ran(replayed(bothAnswered())); + expect(partial.performed).toEqual([]); + expect(partial.records.length).toBeGreaterThan(bothAnswered().length); + }); + + it("refuses a journal that reads but cannot have happened, before performing anything", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + // A scope completed twice describes a run the projection refuses, and the + // refusal has to arrive before any effect does. + const impossible = [ + ...first.records.slice(0, 3), + first.records[5], + ...first.records.slice(3), + ].map((record, index) => ({ ...Object(record), seq: index + 1 })); + + const run = replayed(impossible); + expect(run.ok).toBe(false); + }); + + describe("negative controls", () => { + it("kind-only-replay: matching on the record kind consumes another document's records", function* () { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const kindOnly = (one: unknown, other: unknown) => Object(one).kind === Object(other).kind; + + const foreign = changing(first.records, 0, { entry: "other-entry" }); + const wrongBinding = changing(first.records, positionOf(first.records, "binding.published"), { + name: "other", + value: "wrong", + }); + const wrongAgent = changing(first.records, positionOf(first.records, "outcome.recorded"), { + request: "entry-1/document/elsewhere", + }); + const leftOver = [ + ...finished(), + { + id: "x-01", + seq: finished().length + 1, + at: 99, + kind: "entry.submitted", + entry: "entry-2", + title: "Something this document never submits", + }, + ]; + + // A kind-only reader sees nothing wrong with any of the first three, + // and has no opinion at all about the fourth. + expect(kindOnly(foreign[0], first.records[0])).toBe(true); + expect( + kindOnly( + wrongBinding[positionOf(first.records, "binding.published")], + first.records[positionOf(first.records, "binding.published")], + ), + ).toBe(true); + expect( + kindOnly( + wrongAgent[positionOf(first.records, "outcome.recorded")], + first.records[positionOf(first.records, "outcome.recorded")], + ), + ).toBe(true); + + for (const journal of [foreign, wrongBinding, wrongAgent, leftOver]) { + expect(replayed(journal).ok).toBe(false); + } + }); + }); +}); + +describe("a matched operation returns its recorded result", () => { + const RESTORED = "RESTORED AGENT RESULT"; + + /** The valid prefix through the admitted Agent result, with that result altered. */ + function altered(): readonly unknown[] { + const first = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + const at = first.records.findIndex((record) => Object(record).kind === "outcome.recorded"); + return first.records + .slice(0, at + 1) + .map((record, index) => (index === at ? { ...Object(record), label: RESTORED } : record)); + } + + function replayed(prior: readonly unknown[]) { + return ran( + resume({ + script: SCRIPT, + prior, + streaming: createStreaming(), + secrets: scriptedSecrets({ token: TOKEN }), + }), + ); + } + + it("publishes the recorded Agent result, not the one the script would have produced", function* () { + const prior = altered(); + const run = replayed(prior); + + // 1. The Agent is consumed, not performed. + expect(run.consumed).toEqual(["agent entry-1/document/draft"]); + expect(run.performed).toEqual(["publish notes"]); + + // 2 and 3. The binding is published once, carrying the recorded result. + const published = run.records.filter( + (record) => Object(record).kind === "binding.published" && Object(record).name === "notes", + ); + expect(published.length).toBe(1); + expect(Object(published[0]).value).toBe(RESTORED); + expect(Object(published[0]).value).not.toBe("Release notes for 0.14.0"); + + // 4. Resuming what it wrote performs nothing. + const again = replayed(run.records); + expect(again.performed).toEqual([]); + expect(again.consumed).toEqual(["agent entry-1/document/draft", "publish notes"]); + expect(again.records).toEqual(run.records); + }); + + it("projects the altered result and the binding derived from it", function* () { + const run = replayed(altered()); + const events = parseJournal(run.records); + if (!events.ok) { + throw events.error; + } + const model = projectPrefix(EXECUTION, events.value, undefined); + if (!model.ok) { + throw model.error; + } + + // 5. A cold projection of the resulting journal agrees with both. + expect(model.value.outcomes.map((one) => one.label)).toEqual([RESTORED]); + const notes = model.value.bindings.find((one) => one.name === "notes"); + expect(notes?.value).toBe(RESTORED); + expect(JSON.stringify(model.value)).not.toContain("Release notes for 0.14.0"); + }); + + it("refuses a step whose value nothing before it produced", function* () { + const orphan = SCRIPT.filter((step) => !(step.kind === "agent" && step.produces === "notes")); + const run = resume({ + script: orphan, + prior: [], + streaming: createStreaming(), + secrets: noSecrets(), + }); + + expect(run.ok).toBe(false); + if (run.ok) { + return; + } + expect(run.error).toBeInstanceOf(ExecutionGap); + expect(run.error.message).toContain('publish notes needs "notes"'); + }); + + describe("negative controls", () => { + it("discarded-result: recognizing the record and then using the script's own literal", function* () { + const prior = altered(); + const run = replayed(prior); + const agent = SCRIPT.find((step) => step.kind === "agent"); + if (agent === undefined || agent.kind !== "agent") { + throw new Error("the document has no Agent step"); + } + + // A replay that matched the record, reported it consumed, and then let + // the continuation read `step.admitted` would publish this instead. + const discarded = agent.admitted; + const restored = Object( + run.records.find( + (record) => + Object(record).kind === "binding.published" && Object(record).name === "notes", + ), + ).value; + + expect(discarded).toBe("Release notes for 0.14.0"); + expect(restored).toBe(RESTORED); + expect(restored).not.toBe(discarded); + + // And the script has no second copy of the result to fall back to. + const publishes = SCRIPT.filter((step) => step.kind === "publish"); + expect(publishes.length).toBeGreaterThan(0); + for (const step of publishes) { + expect(Object.keys(step)).not.toContain("value"); + } + }); + }); +}); + +describe("process loss offers replay, not Continue", () => { + it("removes Continue and claims no pause, while the reconstruction is unchanged", function* () { + const records = bothAnswered(); + const events = parseJournal(records); + if (!events.ok) { + throw events.error; + } + const waits = events.value.filter((event) => event.kind === "suspension.opened"); + const at = waits[waits.length - 1].id; + + const live = yield* open(`xmd://repl/e3/transcript?at=${at}`, records); + expect(live.semantic().model.marker).toBe(at); + + // A process that is holding expansion there offers Continue. + expect(overlay.canContinueAt(overlay.live(at), live.semantic().model.marker)).toBe(true); + + // The same records and the same URL, read by a process that holds + // nothing. Continue is gone; the reconstruction is byte-for-byte the one + // the live process had. + const cold = yield* open(`xmd://repl/e3/transcript?at=${at}`, records); + expect(overlay.canContinueAt(overlay.cold(), cold.semantic().model.marker)).toBe(false); + expect(cold.semantic()).toEqual(live.semantic()); + + // What a cold process has instead of Continue is a frontier. + const headless = ran( + resume({ + script: SCRIPT, + prior: records, + secrets: noSecrets(), + streaming: createStreaming(), + }), + ); + expect(headless.frontier.kind).toBe("unrevealed"); + for (const word of ["EXPANSION PAUSED", "canContinue", "pauseMarker", "held"]) { + expect(JSON.stringify(cold.state())).not.toContain(word); + } + }); + + describe("negative controls", () => { + it("replay-reperforms: a run that ignores the record does the durable work twice", function* () { + const records = bothAnswered(); + const honest = ran( + resume({ + script: SCRIPT, + prior: records, + secrets: scriptedSecrets({ token: TOKEN }), + streaming: createStreaming(), + }), + ); + // A replay that started from nothing would perform everything again, + // and the effects it names are the ones that must not repeat. + const ignoring = ran( + resume({ script: SCRIPT, prior: [], streaming: createStreaming(), secrets: noSecrets() }), + ); + + expect(ignoring.performed).toEqual(["agent entry-1/document/draft", "publish notes"]); + expect(honest.performed).toEqual([]); + // Every effect the ignoring run carried out is one the honest run read + // out of the record instead. + for (const effect of ignoring.performed) { + expect(honest.consumed).toContain(effect); + } + }); + + it("recoverable-secret: an answer in the record makes the re-prompt disappear", function* () { + const secrets = scriptedSecrets({ token: TOKEN }); + ran(resume({ script: SCRIPT, prior: bothAnswered(), secrets, streaming: createStreaming() })); + expect(secrets.asked).toEqual(["token"]); + + // Treating the secret as ordinary is what a recoverable answer would + // look like: nobody is asked, and the value would have to be somewhere. + const ordinary = scriptedSecrets({ token: TOKEN }); + const script = SCRIPT.map((step) => + step.kind === "elicit" && step.wait === "token" ? { ...step, secret: false } : step, + ); + const run = ran( + resume({ script, prior: bothAnswered(), secrets: ordinary, streaming: createStreaming() }), + ); + + expect(ordinary.asked).toEqual([]); + expect(run.recovered).toEqual(["channel=#releases", "token="]); + }); + + it("streamed-into-the-record: a chunk under the right request is still the producer's job", function* () { + const { run } = firstRun(); + const chunk = { + id: "x-01", + seq: 1, + at: 1, + kind: "outcome.recorded", + entry: "entry-1", + scope: ["document", "draft"], + request: "entry-1/document/draft", + label: "Rele", + }; + + // Under a request nobody asked for, alignment turns it away. + const foreign = resume({ + script: SCRIPT, + prior: [{ ...chunk, request: "entry-1/document/elsewhere" }], + streaming: createStreaming(), + secrets: noSecrets(), + }); + expect(foreign.ok).toBe(false); + + // Under the *right* request it aligns, and nothing downstream can tell + // a chunk from the result: both are text under one durable name. That + // is why admission discards the buffer at the producer instead of a + // reader guessing whether the label looks finished. + expect(parseJournal([chunk]).ok).toBe(true); + expect(JSON.stringify(run.records)).not.toContain('"Rele"'); + expect(run.consumed).toEqual([]); + }); + + it("draft-as-a-visit: pushing on every keystroke buries where the person came from", function* () { + const session = yield* open("xmd://repl/e3/bindings", bothAnswered()); + const pushed: string[] = [session.semantic().url]; + for (const draft of ["g", "gi", "git"]) { + yield* session.type(draft); + pushed.push(session.semantic().url); + } + + expect(pushed.length).toBe(4); + expect(session.visits()).toEqual(["xmd://repl/e3/bindings?draft=git"]); + }); + }); +}); diff --git a/scripts/tests/repl-hydration-store.test.ts b/scripts/tests/repl-hydration-store.test.ts new file mode 100644 index 000000000..db47f7e6f --- /dev/null +++ b/scripts/tests/repl-hydration-store.test.ts @@ -0,0 +1,303 @@ +/** + * The actual StarFX store, and what it is allowed to be. + * + * Slice 2 of #842. Slice 1 proved that records and a URL determine one + * semantic model; this proves that putting that model in a real immutable + * store adds nothing to it. The store is `starfx` — `createSchema`, + * `createStore`, `slice` — and every case here is about a difference that + * must *not* appear: between a session that accumulated records one at a time + * and one built from all of them at once, between a store whose cache is warm + * and one whose cache has been thrown away, and between the same state drawn + * for two different terminals. + * + * The journey is the one #842 names: empty, a first entry, nested scopes, a + * binding, the expansion pause marker, a background append while expansion is + * held, historical inspection, the live head, and back to the pause marker. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; + +import { EXECUTION, JOURNAL, LIVE_HEAD, PAUSE_MARKER } from "../repl-hydration/fixture.ts"; +import { layout, topology } from "../repl-hydration/layout.ts"; +import type { Viewport } from "../repl-hydration/layout.ts"; +import type { SemanticModel } from "../repl-hydration/model.ts"; +import * as overlay from "../repl-hydration/overlay.ts"; +import { foreignValues } from "../repl-hydration/purity.ts"; +import { hydrate } from "../repl-hydration/store.ts"; +import type { ReplSession, SemanticState } from "../repl-hydration/store.ts"; + +/** The journey, as the locations it visits and the records it has by then. */ +const EMPTY = "xmd://repl/e1/transcript"; +const FIRST_ENTRY = "xmd://repl/e1/transcript/entry-1"; +const NESTED = "xmd://repl/e1/transcript/entry-1/document/plan"; +const BINDINGS = "xmd://repl/e1/bindings"; +const AT_PAUSE = `xmd://repl/e1/transcript/entry-3/document/publish/+source/+confirm?at=${PAUSE_MARKER}&inspect`; +const HISTORICAL = "xmd://repl/e1/transcript/entry-1/document?at=r-03"; +const AT_HEAD = "xmd://repl/e1/transcript/entry-3/document/write"; + +const WIDE: Viewport = { columns: 120, rows: 40 }; +const NARROW: Viewport = { columns: 28, rows: 40 }; + +function* open(url: string, records: readonly unknown[]): Operation { + const session = yield* hydrate(EXECUTION, url, records); + if (!session.ok) { + throw session.error; + } + return session.value; +} + +function* advance(session: ReplSession, to: number): Operation { + const seen = session.state().records.length; + for (const record of JOURNAL.slice(seen, to)) { + const applied = yield* session.append(record); + if (!applied.ok) { + throw applied.error; + } + } +} + +function* go(session: ReplSession, url: string): Operation { + const moved = yield* session.navigate(url); + if (!moved.ok) { + throw moved.error; + } +} + +/** One step of the journey: where the URL points and how much has arrived. */ +interface Step { + readonly name: string; + readonly url: string; + readonly records: number; +} + +const JOURNEY: readonly Step[] = [ + { name: "empty", url: EMPTY, records: 0 }, + { name: "the first entry", url: FIRST_ENTRY, records: 1 }, + { name: "nested scopes", url: NESTED, records: 3 }, + { name: "a published binding", url: BINDINGS, records: 7 }, + { name: "the expansion pause marker", url: AT_PAUSE, records: 22 }, + { name: "a background append while expansion is held", url: AT_PAUSE, records: 23 }, + { name: "historical inspection", url: HISTORICAL, records: 23 }, + { name: "the live head", url: AT_HEAD, records: 23 }, + { name: "back to the expansion pause marker", url: AT_PAUSE, records: 23 }, +]; + +describe("the StarFX store, hydrated from Journal plus URL", () => { + it("accumulates to the same semantic state a rebuild produces at every step", function* () { + const live = yield* open(EMPTY, []); + for (const step of JOURNEY) { + yield* advance(live, step.records); + yield* go(live, step.url); + + // The whole store is discarded and another one is built from the same + // two inputs. Nothing carries over — not the cache, not the scope. + const cold = yield* open(step.url, JOURNAL.slice(0, step.records)); + + expect(live.semantic()).toEqual(cold.semantic()); + expect(cold.cached().length).toBeLessThanOrEqual(1); + } + }); + + it("holds the expansion pause point still while the durable head advances", function* () { + const session = yield* open(AT_PAUSE, JOURNAL.slice(0, 22)); + const held = session.semantic(); + + expect(held.model.marker).toBe(PAUSE_MARKER); + expect(held.model.records).toBe(22); + expect(held.model.outcomes).toEqual([]); + expect(held.history.filter((one) => one.position === "future")).toEqual([]); + + yield* advance(session, 23); + const after = session.semantic(); + + // The selected prefix did not move; the Execution History grew. + expect(after.model).toEqual(held.model); + expect(after.url).toBe(held.url); + expect(after.history.length).toBe(held.history.length + 1); + expect(after.history.filter((one) => one.position === "future").map((one) => one.id)).toEqual([ + LIVE_HEAD, + ]); + + // A future marker is navigation context and never a fact. + expect(JSON.stringify(after.model)).not.toContain("remote tags fetched"); + expect(JSON.stringify(after.history)).not.toContain("remote tags fetched"); + }); + + it("answers navigation the same way with a warm cache and with none", function* () { + const session = yield* open(EMPTY, JOURNAL); + const tour = [AT_PAUSE, HISTORICAL, AT_HEAD, AT_PAUSE]; + + const warm: SemanticState[] = []; + for (const url of tour) { + yield* go(session, url); + warm.push(session.semantic()); + } + expect(session.cached()).toEqual(["r-03", PAUSE_MARKER]); + + yield* session.discardSnapshots(); + expect(session.cached()).toEqual([]); + + const cold: SemanticState[] = []; + for (const url of tour) { + yield* go(session, url); + cold.push(session.semantic()); + } + expect(cold).toEqual(warm); + }); + + it("never memoizes the live head, because that is the prefix that grows", function* () { + const session = yield* open(AT_HEAD, JOURNAL.slice(0, 22)); + expect(session.cached()).toEqual([]); + + const before = session.semantic().model; + yield* advance(session, 23); + const after = session.semantic().model; + + expect(before.records).toBe(22); + expect(after.records).toBe(23); + expect(after.outcomes.map((outcome) => outcome.label)).toEqual(["remote tags fetched"]); + expect(session.cached()).toEqual([]); + }); + + it("reflows for another terminal without moving the semantics", function* () { + const session = yield* open(AT_HEAD, JOURNAL); + const state = session.semantic(); + + const wide = layout(state, WIDE); + const narrow = layout(state, NARROW); + + expect(wide).not.toEqual(narrow); + expect(narrow.length).toBeGreaterThan(wide.length); + expect(Math.max(...narrow.map((line) => line.length))).toBeLessThanOrEqual(NARROW.columns); + + // The store did not notice, and neither did the structure. + expect(session.semantic()).toEqual(state); + expect(topology(state.model)).toEqual(topology(session.semantic().model)); + }); + + it("holds no terminal cell, escape byte or animation frame", function* () { + const session = yield* open(AT_PAUSE, JOURNAL); + const state = session.state(); + const serialized = JSON.stringify(state); + + expect(serialized).not.toContain("\\u001b"); + expect(serialized).not.toContain("\u001b"); + for (const word of ["columns", "rows", "viewport", "frame", "cells", "ansi"]) { + expect(serialized).not.toContain(word); + } + // StarFX's own slices are required by the schema and stay empty. + expect(state.cache).toEqual({}); + expect(state.loaders).toEqual({}); + + // Everything in the store, and everything it hands out, is frozen plain + // data: no function, no class instance, no typed array, no live handle. + expect(foreignValues(state, "state")).toEqual([]); + expect(foreignValues(session.semantic(), "semantic")).toEqual([]); + }); + + it("offers Continue only from the process that still holds the continuation", function* () { + const session = yield* open(AT_PAUSE, JOURNAL); + const held = overlay.live(PAUSE_MARKER); + const paused = session.semantic(); + + expect(overlay.canContinueAt(held, paused.model.marker)).toBe(true); + + yield* go(session, AT_HEAD); + expect(overlay.canContinueAt(held, session.semantic().model.marker)).toBe(false); + + yield* go(session, AT_PAUSE); + expect(overlay.canContinueAt(held, session.semantic().model.marker)).toBe(true); + + // Discarding the continuation, and then the whole process, removes the + // capability and changes nothing that was reconstructed. + expect(overlay.canContinueAt(overlay.released(held), PAUSE_MARKER)).toBe(false); + expect(overlay.canContinueAt(overlay.cold(), PAUSE_MARKER)).toBe(false); + expect(session.semantic()).toEqual(paused); + + const restarted = yield* open(AT_PAUSE, JOURNAL); + expect(restarted.semantic()).toEqual(paused); + expect(JSON.stringify(restarted.state())).not.toContain("pauseMarker"); + }); + + it("refuses a malformed record, an unspellable URL and an unresolved one", function* () { + const broken = yield* hydrate(EXECUTION, AT_HEAD, [...JOURNAL, { id: "r-24" }]); + expect(broken.ok).toBe(false); + + const unspellable = yield* hydrate(EXECUTION, "xmd://repl/e1/nowhere", JOURNAL); + expect(unspellable.ok).toBe(false); + + const unresolved = yield* hydrate(EXECUTION, "xmd://repl/e1/transcript/entry-9", JOURNAL); + expect(unresolved.ok).toBe(false); + + // A refusal leaves nothing half-built: there is no session to observe. + const session = yield* open(AT_HEAD, JOURNAL); + const before = session.semantic(); + const rejected = yield* session.navigate("xmd://repl/e1/transcript/entry-9"); + expect(rejected.ok).toBe(false); + expect(session.semantic()).toEqual(before); + }); + + describe("negative controls", () => { + it("persisted-snapshots: a cache that outlived its records answers the wrong moment", function* () { + const session = yield* open(AT_PAUSE, JOURNAL); + const truthful = session.semantic().model; + + // What a durable snapshot would look like: the model of another prefix, + // stored under this marker's name. Resolution reads the cache first, so + // it would be believed. + const early = yield* open(HISTORICAL, JOURNAL); + const stale: SemanticModel = early.semantic().model; + + expect(stale).not.toEqual(truthful); + expect(stale.records).toBe(3); + expect(truthful.records).toBe(22); + + // A rebuild cannot be handed one, so the stale model has no way in. + const rebuilt = yield* open(AT_PAUSE, JOURNAL); + expect(rebuilt.cached()).toEqual([PAUSE_MARKER]); + expect(rebuilt.semantic().model).toEqual(truthful); + }); + + it("head-memoized: caching the live head freezes an answer that keeps changing", function* () { + const session = yield* open(AT_HEAD, JOURNAL.slice(0, 22)); + const frozen = session.semantic().model; + + yield* advance(session, 23); + const now = session.semantic().model; + + // A cache keyed on "the head" would have returned `frozen` here. + expect(frozen.records).toBe(22); + expect(now.records).toBe(23); + expect(frozen).not.toEqual(now); + expect(session.cached()).toEqual([]); + }); + + it("layout-in-the-store: a viewport written into the state makes two terminals two executions", function* () { + const session = yield* open(AT_HEAD, JOURNAL); + const state = session.semantic(); + + const wide = { ...state, viewport: WIDE, lines: layout(state, WIDE) }; + const narrow = { ...state, viewport: NARROW, lines: layout(state, NARROW) }; + + expect(wide).not.toEqual(narrow); + expect(session.semantic()).toEqual(state); + expect(JSON.stringify(session.state())).not.toContain("columns"); + }); + + it("overlay-in-the-store: a pause flag in the state survives a restart that cannot know it", function* () { + const session = yield* open(AT_PAUSE, JOURNAL); + const held = overlay.live(PAUSE_MARKER); + const smuggled = { ...session.semantic(), ...held }; + + expect(smuggled.pauseMarker).toBe(PAUSE_MARKER); + expect(smuggled.canContinue).toBe(true); + + const restarted = yield* open(AT_PAUSE, JOURNAL); + expect(Object.keys(restarted.semantic())).not.toContain("pauseMarker"); + expect(Object.keys(restarted.semantic())).not.toContain("canContinue"); + expect(restarted.semantic()).toEqual(session.semantic()); + }); + }); +}); diff --git a/scripts/tests/repl-pause-gate.test.ts b/scripts/tests/repl-pause-gate.test.ts new file mode 100644 index 000000000..225f090b6 --- /dev/null +++ b/scripts/tests/repl-pause-gate.test.ts @@ -0,0 +1,707 @@ +/** + * Can middleware around an XMD-owned execution Api hold a live subtree? + * + * #841's first slice asks one question, and these cases are chosen so that the + * answer cannot come from the fixture cooperating. The unrelated sibling runs the + * *same* mediated loop as the target's children with the decoration absent, so + * "the target stopped" is measured against a control that did not. Every step is + * numbered, so a continuation released twice would show up as a duplicate in the + * history. Every wait is for an announced advance, never for an interval. + * + * The last case is the one that matters most: a descendant that advances in + * ordinary Effection without invoking the Api. It is not a contrived escape — it + * is what any component does between two journaled steps — and the gate can + * never report `paused` while it is live. That case failing is the finding, not + * a defect in the fixture. + */ + +import { describe as suite, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { race, sleep } from "effection"; +import type { Operation } from "effection"; + +import { advanceOf, startFixture } from "../repl-pause/fixture.ts"; +import { + advanceOf as advanceOfXmd, + isolated, + runSiblingExecution, + startXmdFixture, +} from "../repl-pause/xmd-fixture.ts"; +import type { XmdFixture } from "../repl-pause/xmd-fixture.ts"; + +/** + * A promise settled from outside Effection, standing in for a subprocess or a + * provider request. Built by hand rather than with `Promise.withResolvers`, + * which the oldest runtime in the matrix does not have. + */ +function deferred(): { promise: Promise; settle: (v: string) => void } { + let settle: (value: string) => void = () => {}; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, settle }; +} + +/** How a task settled, as a string, so a terminal outcome can be compared. */ +function* settlement(task: Operation): Operation { + try { + const value = yield* task; + return `ok:${String(value).slice(0, 24)}`; + } catch (error) { + return `threw:${error instanceof Error ? error.message : String(error)}`; + } +} + +const advanceOfExecution = advanceOfXmd; + +/** + * A fresh advance subscription. + * + * The advance signal buffers everything since the moment a subscription is taken, + * so an interval measured on an old subscription drains a backlog instantly and + * measures no time at all. Every interval below starts from a new one. + */ +function freshAdvances(fixture: XmdFixture) { + return fixture.advances; +} + +/** + * Give an operation a bound so a wedged handshake reports instead of hanging. + * + * Nothing asserts on the bound: a case that depends on which branch won would be + * an elapsed-time assumption. It exists so that "never settles" is a returned + * value the negative control can name. + */ +function* bounded(operation: Operation, label: string): Operation { + return yield* race([ + operation, + (function* () { + yield* sleep(250); + return `unsettled:${label}`; + })(), + ]); +} + +suite("REPL pause — the XMD execution Api as a pause seam", () => { + it("dispatches every target descendant through the middleware installed on the target, and nothing outside it", function* () { + const fixture = yield* startFixture({ childB: "mediated" }); + const advancing = yield* fixture.advances; + + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + yield* advanceOf(advancing, "entry"); + + const seen = fixture.gate.inspect(); + + // The entry task and both nested children — one and two levels below the + // scope the decoration was installed on. + expect(seen.live.length).toBe(3); + expect(seen.mediated.length).toBe(3); + expect(seen.live.join(" ")).toContain("childA"); + expect(seen.live.join(" ")).toContain("childB"); + + // The sibling has been running the identical mediated loop the whole time. + expect(fixture.journal.headOf("sibling")).toBeGreaterThan(0); + expect(seen.strangers).toEqual([]); + }); + + it("enters pausing at once, and reaches paused only once every live descendant is held", function* () { + const fixture = yield* startFixture({ childB: "mediated" }); + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "childA"); + + expect(fixture.gate.state).toBe("running"); + + fixture.gate.request(); + + // Synchronous with the request: no continuation has run in between. + expect(fixture.gate.state).toBe("pausing"); + const requested = fixture.gate.inspect(); + expect(requested.state).toBe("pausing"); + expect(requested.held).toEqual([]); + expect(requested.unaccounted.length).toBe(3); + + const report = yield* bounded(fixture.gate.reached(), "reached"); + + expect(fixture.gate.state).toBe("paused"); + const settled = fixture.gate.inspect(); + expect(settled.unaccounted).toEqual([]); + expect(settled.held.length).toBe(3); + expect(report).toEqual({ + held: settled.held, + targetHead: settled.targetHead, + }); + + fixture.gate.release(); + }); + + it("holds the target's progress and history while the unrelated sibling advances and the controller stays usable", function* () { + const fixture = yield* startFixture({ childB: "mediated" }); + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "childA"); + + fixture.gate.request(); + yield* bounded(fixture.gate.reached(), "reached"); + expect(fixture.gate.state).toBe("paused"); + + const atPause = fixture.gate.inspect(); + + // The decoration was installed on the target scope, and what it is holding + // two levels down is an ordinary advance, not a creation. + expect(atPause.held.join(" ")).toMatch(/childA\)@stepp?e?d?:childA#/); + + const targetRecords = fixture.journal.snapshot().filter((record) => record.owner !== "sibling"); + const siblingAtPause = fixture.journal.headOf("sibling"); + + // Both sides of the comparison are measured across the same interval, and + // the interval is defined by the sibling's own announced advances. + for (let advance = 0; advance < 5; advance += 1) { + yield* advanceOf(advancing, "sibling"); + } + + const afterPause = fixture.gate.inspect(); + + expect(afterPause.state).toBe("paused"); + expect(afterPause.targetHead).toBe(atPause.targetHead); + expect(afterPause.held).toEqual(atPause.held); + expect(afterPause.unaccounted).toEqual([]); + expect(fixture.journal.snapshot().filter((record) => record.owner !== "sibling")).toEqual( + targetRecords, + ); + + expect(fixture.journal.headOf("sibling")).toBeGreaterThan(siblingAtPause); + + fixture.gate.release(); + }); + + it("releases the same held continuations exactly once, and repeats no completed work", function* () { + const fixture = yield* startFixture({ childB: "mediated" }); + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "childA"); + + fixture.gate.request(); + yield* bounded(fixture.gate.reached(), "reached"); + + const held = fixture.gate.inspect().held; + const before = fixture.journal.snapshot(); + const releasesBefore = fixture.gate.releases; + + // Without this, a gate that held nothing at all would satisfy every + // assertion below by releasing nothing exactly zero times. + expect(fixture.gate.state).toBe("paused"); + expect(held.length).toBe(3); + + fixture.gate.release(); + + expect(fixture.gate.state).toBe("running"); + expect(fixture.gate.releases - releasesBefore).toBe(3); + expect(fixture.gate.doubleReleases).toBe(0); + + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + yield* advanceOf(advancing, "entry"); + + const after = fixture.journal.snapshot(); + + // The released continuations carried on from where they were held. + expect(after.length).toBeGreaterThan(before.length); + + // Nothing before the pause was rewritten or appended again, and no label + // occurs twice — a replayed or respawned continuation would do both. + expect(after.slice(0, before.length)).toEqual(before); + const labels = after.map((record) => `${record.owner}:${record.label}`); + expect(new Set(labels).size).toBe(labels.length); + expect(fixture.gate.doubleReleases).toBe(0); + }); + + it("keeps a mediated external operation's continuation held after the external work has completed", function* () { + const pending = deferred(); + const fixture = yield* startFixture({ + childB: "mediated", + pending: pending.promise, + }); + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "childB"); + + // Child A is inside the external operation, so it is live and unheld: the + // pause cannot settle while external work is in flight. + fixture.gate.request(); + expect(fixture.gate.inspect().unaccounted.length).toBe(3); + expect(fixture.pastExternal()).toBe(0); + + // The external system completes while the subtree is being paused. Nothing + // about the gate reached it, and its continuation must still be held. + pending.settle("done"); + + yield* bounded(fixture.gate.reached(), "reached"); + expect(fixture.gate.state).toBe("paused"); + + const settled = fixture.gate.inspect(); + expect(settled.held.join(" ")).toContain("returned:await"); + expect(fixture.pastExternal()).toBe(0); + + // The record the external operation produced was journaled while `pausing`, + // before the subtree was held — the head is fixed from `paused`, not before. + expect(fixture.journal.labelsOf("childA")).toEqual(["await=done"]); + + fixture.gate.release(); + yield* advanceOf(advancing, "childA"); + expect(fixture.pastExternal()).toBe(1); + }); + + it("never settles a pause while an unmediated descendant is live, and names it", function* () { + const fixture = yield* startFixture({ childB: "raw" }); + const advancing = yield* fixture.advances; + yield* advanceOf(advancing, "childA"); + yield* advanceOf(advancing, "childB"); + + const running = fixture.gate.inspect(); + expect(running.live.length).toBe(3); + + // Child B *is* dispatched through the middleware — once, when it is forked. + // Being mediated at creation is not the same as being pausable: every + // advance after that is ordinary Effection, and the journal shows it + // never comes back to the boundary. + expect(running.mediated.length).toBe(3); + expect(fixture.journal.headOf("childB")).toBe(0); + expect(fixture.journal.headOf("childA")).toBeGreaterThan(0); + + fixture.gate.request(); + + const outcome = yield* bounded(fixture.gate.reached(), "reached"); + + expect(outcome).toBe("unsettled:reached"); + expect(fixture.gate.state).toBe("pausing"); + + const stuck = fixture.gate.inspect(); + expect(stuck.held.length).toBe(2); + expect(stuck.unaccounted.length).toBe(1); + expect(stuck.unaccounted.join(" ")).toContain("childB"); + + // And it is not merely unheld — it is still advancing while the two + // mediated descendants are held. + const advanced = yield* advanceOf(advancing, "childB"); + expect(advanced.count).toBeGreaterThan(1); + expect(fixture.gate.inspect().state).toBe("pausing"); + + fixture.gate.release(); + }); +}); + +/** + * EXPANSION PAUSED — pausing XMD expansion over the surfaces XMD already has. + * + * The obligation set is **expansion walks**, not Effection scopes. Effection is + * the runtime and it keeps running: tasks stay live, timers fire, external work + * finishes, and background work records what it produced. What these cases prove + * is that the *expansion* of one execution subtree stops, that the controller can + * say so, and that the durable Journal is free to move past the fixed expansion + * pause point while it is stopped. + * + * `paused` here means exactly: every active expansion walk in the selected + * subtree is held at an expansion boundary or has settled. It does not mean the + * runtime is quiescent, that external systems are frozen, or that the History + * head is stationary. + * + * Every interval is measured on a **fresh** advance subscription, because the + * signal buffers from the moment a subscription is taken — an interval measured + * on an older one drains a backlog and measures nothing. + */ + +suite("REPL pause — EXPANSION PAUSED over existing XMD surfaces", () => { + it("1+15. is transparent while playing, and every expansion path crosses a controlled boundary", function* () { + const control = yield* isolated(function* () { + const fixture = yield* startXmdFixture({ withoutMiddleware: true }); + expect(fixture.gate).toBe(undefined); + const output = yield* fixture.execution; + return { output: String(output), journal: yield* fixture.journalKinds() }; + }); + + const instrumented = yield* isolated(function* () { + const fixture = yield* startXmdFixture({}); + const output = yield* fixture.execution; + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + return { + output: String(output), + journal: yield* fixture.journalKinds(), + surfaces: [...new Set(gate.crossings.map((c) => c.surface))].toSorted(), + brackets: [ + ...new Set(gate.crossings.filter((c) => c.kind === "walk").map((c) => c.surface)), + ].toSorted(), + walks: gate.inspect().walks, + held: gate.inspect().held, + state: gate.state, + releases: gate.releases, + }; + }); + + // Transparent: same behaviour and the same recorded outcomes. + expect(instrumented.output).toBe(control.output); + expect(instrumented.journal).toEqual(control.journal); + + // The boundary inventory. Every expansion path the document exercises crosses + // one of these, including document output — which is what covers prose. + expect(instrumented.surfaces).toEqual([ + "applyBoundModifiers", + "applyModifiers", + "codeBlock", + "content", + "document", + "expand", + "importComponent", + "output", + "region", + "replCheckpoint", + "retain", + ]); + // Four of them bracket a walk; the rest are step gates inside one. + expect(instrumented.brackets).toEqual(["content", "document", "expand", "region"]); + + // Nothing waited and nothing was retained. + expect(instrumented.walks).toEqual([]); + expect(instrumented.held).toEqual([]); + expect(instrumented.state).toBe("playing"); + expect(instrumented.releases).toBe(0); + }); + + it("2+4+7. reaches EXPANSION PAUSED on a real execution while ordinary Effection work keeps running", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({}); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + + // Pause while a component body is running ordinary Effection, and after a + // component has already spawned ordinary children of its own. + yield* advanceOfExecution(advancing, "slow"); + expect(fixture.fanoutSteps()).toBeGreaterThan(0); + + expect(gate.state).toBe("playing"); + gate.request(); + expect(gate.state).toBe("pausing"); + + const report = yield* bounded(gate.reached(), "reached"); + + // The real execution reaches it. This is the corrected claim. + expect(gate.state).toBe("paused"); + const resting = gate.inspect(); + expect(resting.advancing).toEqual([]); + expect(resting.held.length).toBeGreaterThan(0); + expect(report).toEqual(resting); + + // The runtime is demonstrably busy, and that is not a pause obligation. + expect(resting.liveScopes).toBeGreaterThan(5); + + const laterAtRest = fixture.laterRan(); + const fanoutAtRest = fixture.fanoutSteps(); + const heldAtRest = resting.held.join("|"); + expect(laterAtRest).toBe(0); + + // A real interval, on a fresh subscription. + const interval = yield* freshAdvances(fixture); + for (let advance = 0; advance < 20; advance += 1) { + yield* advanceOfExecution(interval, "sibling"); + } + + // Expansion is stopped: the next element has still not expanded, and the + // same continuation is still held in the same place. + expect(fixture.laterRan()).toBe(laterAtRest); + expect(gate.inspect().held.join("|")).toBe(heldAtRest); + expect(gate.state).toBe("paused"); + + // And ordinary Effection descendants of a component carried on throughout. + expect(fixture.fanoutSteps()).toBeGreaterThan(fanoutAtRest); + + gate.release(); + yield* fixture.execution; + expect(fixture.laterRan()).toBe(1); + }); + }); + + it("5+6+7+8. records external work durably while paused, keeps expansion stopped, and replays nothing", function* () { + yield* isolated(function* () { + const external = deferred(); + const fixture = yield* startXmdFixture({ background: external.promise }); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "slow"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + expect(gate.state).toBe("paused"); + + const journalAtRest = yield* fixture.journalKinds(); + const laterAtRest = fixture.laterRan(); + const heldAtRest = gate.inspect().held.join("|"); + + // The external system completes while expansion is paused. Nothing the + // controller did reached it. + const interval = yield* freshAdvances(fixture); + external.settle("external-done"); + yield* advanceOfExecution(interval, "recorded"); + + const journalAfter = yield* fixture.journalKinds(); + + // Its durable outcome is appended normally: the Journal head moved. + expect(journalAfter.length).toBe(journalAtRest.length + 1); + expect(journalAfter.at(-1)).toBe("yield:background"); + + // The expansion pause point did not move with it. + expect(gate.state).toBe("paused"); + expect(fixture.laterRan()).toBe(laterAtRest); + expect(gate.inspect().held.join("|")).toBe(heldAtRest); + + const appendsAtRelease = fixture.appendCount(); + gate.release(); + yield* fixture.execution; + + const final = yield* fixture.journalKinds(); + + // Continue did not replay the already-recorded background outcome. Counted + // at append time, so a duplicate landing at any moment is caught. + expect(fixture.appendsOf("yield:background")).toBe(1); + expect(final.filter((kind) => kind === "yield:background").length).toBe(1); + expect(final.slice(0, journalAfter.length)).toEqual(journalAfter); + expect(fixture.appendCount()).toBeGreaterThan(appendsAtRelease); + expect(fixture.laterRan()).toBe(1); + }); + }); + + it("9. accounts for every concurrent expansion walk before reporting paused", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({ concurrentRegions: true }); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + + // Pause while both regions are genuinely mid-expansion. + yield* advanceOfExecution(advancing, "region"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + + expect(gate.state).toBe("paused"); + const resting = gate.inspect(); + + // Two concurrent region walks, each holding its own continuation, plus the + // two walks delegating to them. Every one accounted for. + expect(resting.advancing).toEqual([]); + const regionWalks = resting.walks.filter((walk) => walk.includes(":region(")); + expect(regionWalks.length).toBe(2); + for (const walk of regionWalks) { + expect(walk).toContain("held@"); + } + expect(resting.walks.join(" ")).toContain("delegating->"); + + gate.release(); + yield* fixture.execution; + }); + }); + + it("3. does not report paused while a targeted expansion walk can still expand", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({ concurrentRegions: true }); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + + // Pause while both region walks are genuinely mid-expansion. + yield* advanceOfExecution(advancing, "region"); + + gate.request(); + + // Synchronously with the request, before any continuation has run: targeted + // walks can still expand, and the controller has not claimed otherwise. + expect(gate.state).toBe("pausing"); + const requested = gate.inspect(); + expect(requested.advancing.length).toBeGreaterThan(0); + expect(requested.advancing.join(" ")).toContain("advancing"); + expect(requested.held).toEqual([]); + + // It settles only once nothing is advancing any more. That is the rule, and + // these two observations together are what the rule says. + yield* bounded(gate.reached(), "reached"); + expect(gate.state).toBe("paused"); + expect(gate.inspect().advancing).toEqual([]); + expect(gate.inspect().held.length).toBeGreaterThan(0); + + gate.release(); + yield* fixture.execution; + }); + }); + + it("10. leaves an execution outside the selected subtree running and recording", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({}); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "slow"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + expect(gate.state).toBe("paused"); + const heldAtRest = gate.inspect().held.join("|"); + + // A whole separate execution expands and records while the target is held. + const sibling = yield* bounded(runSiblingExecution(), "sibling"); + if (typeof sibling === "string") { + throw new Error(`the sibling execution did not finish: ${sibling}`); + } + expect(sibling.output).toContain("Hello from declared Markdown."); + expect(sibling.journal.at(-1)).toBe("close"); + expect(sibling.journal).toContain("yield:exec"); + + // And the target is exactly where it was. + expect(gate.state).toBe("paused"); + expect(gate.inspect().held.join("|")).toBe(heldAtRest); + expect(fixture.laterRan()).toBe(0); + + gate.release(); + yield* fixture.execution; + }); + }); + + it("11. releases every held expansion continuation exactly once", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({ concurrentRegions: true }); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "region"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + + const held = gate.inspect().held; + // Without this the assertions below would be satisfied by a controller that + // held nothing and released nothing. + expect(gate.state).toBe("paused"); + expect(held.length).toBeGreaterThan(1); + const releasesBefore = gate.releases; + + gate.release(); + + expect(gate.state).toBe("playing"); + expect(gate.releases - releasesBefore).toBe(held.length); + expect(gate.doubleReleases).toBe(0); + + const output = yield* fixture.execution; + expect(String(output)).toContain("Hello from declared Markdown."); + expect(gate.doubleReleases).toBe(0); + expect(gate.inspect().held).toEqual([]); + }); + }); + + it("12. propagates an expansion failure to the owner", function* () { + let captured: XmdFixture | undefined; + const observed = { state: "", held: 0 }; + + const escaped = yield* settlement( + isolated(function* () { + const fixture = yield* startXmdFixture({ failing: true }); + captured = fixture; + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "slow"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + observed.state = gate.state; + observed.held = gate.inspect().held.length; + + // Releasing is where the failure resumes and reaches the owner. This + // scope is torn down by it, so nothing after this line runs. + gate.release(); + yield* bounded(settlement(fixture.execution), "execution"); + return "survived"; + }), + ); + + expect(observed.state).toBe("paused"); + expect(observed.held).toBeGreaterThan(0); + + // The failure reaches the owner rather than being swallowed or deferred by + // the pause machinery. + expect(escaped).toContain("threw:"); + expect(escaped).toContain("Slow failed while the controller was coordinating"); + expect(captured?.lifecycle()).toEqual(["acquired:held", "released:held"]); + }); + + it("13. unwinds held expansion continuations on interruption without releasing them", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({}); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "slow"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + expect(gate.state).toBe("paused"); + expect(gate.inspect().held.length).toBeGreaterThan(0); + expect(fixture.lifecycle()).toEqual(["acquired:held"]); + + const journalAtHold = yield* fixture.journalKinds(); + + yield* bounded(fixture.execution.halt(), "halt"); + + const outcome = yield* bounded(settlement(fixture.execution), "settle"); + expect(outcome).toBe("threw:halted"); + + // Unwound, not released into ordinary execution: the gate released nothing. + expect(gate.releases).toBe(0); + expect(fixture.laterRan()).toBe(0); + expect(yield* fixture.journalKinds()).toEqual(journalAtHold); + + // And what the document owned came back. + expect(fixture.lifecycle()).toEqual(["acquired:held", "released:held"]); + }); + }); + + it("14. unwinds holds and retained resources on owner shutdown, without reporting success", function* () { + yield* isolated(function* () { + const fixture = yield* startXmdFixture({}); + const gate = fixture.gate; + if (!gate) { + throw new Error("expected a gate"); + } + const advancing = yield* freshAdvances(fixture); + yield* advanceOfExecution(advancing, "slow"); + + gate.request(); + yield* bounded(gate.reached(), "reached"); + expect(gate.state).toBe("paused"); + expect(gate.inspect().held.length).toBeGreaterThan(0); + + yield* bounded(fixture.shutdown(), "shutdown"); + + const outcome = yield* bounded(settlement(fixture.execution), "settle"); + expect(outcome).toBe("threw:halted"); + expect(gate.releases).toBe(0); + expect(fixture.laterRan()).toBe(0); + expect(fixture.lifecycle()).toEqual(["acquired:held", "released:held"]); + }); + }); +}); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts new file mode 100644 index 000000000..1ed3355c1 --- /dev/null +++ b/scripts/tests/repl-study.test.ts @@ -0,0 +1,1122 @@ +/** + * The REPL interaction study, rendered and checked. + * + * A terminal interface is the kind of thing that only ever failed in front of a + * person: a pane that lost a row, a footer a drawer sat on top of, cells left + * behind by a resize nobody told the renderer about. `@bomb.sh/tty` does layout, + * input decoding and ANSI in pure computation with no terminal attached, so all + * of that can be asked here instead — of the same frames the interactive harness + * writes to a real terminal. + * + * Every claim has a control that breaks it. Each control is one value of the + * closed enum in `mutations.ts`, and the same oracle that admits the honest + * render has to reject it by name rather than merely crash. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { exec } from "@effectionx/process"; +import { close, fixed, grow, open, rgba, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; +import type { Operation } from "effection"; +import { z } from "zod"; +import { exists, readdir, readTextFile } from "@effectionx/fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + captureAll, + captureText, + journeyFrames, + playFrames, + PROFILE_SIZES, + renderFrame, + renderInto, + useTerm, + writeCaptures, +} from "../repl-study/capture.ts"; +import type { Size } from "../repl-study/capture.ts"; +import { fixture, fixtures } from "../repl-study/fixtures.ts"; +import { FIXTURE_NAMES } from "../repl-study/model.ts"; +import { terminalModes } from "../repl-study/host.ts"; +import type { HarnessState } from "../repl-study/host.ts"; +import { + JOURNEY, + journeyDurationMs, + journeyPlan, + motionAt, + playbackBetween, + segmentDurationMs, + segmentLabel, +} from "../repl-study/playback.ts"; +import { FRAME_SECONDS } from "../repl-study/host.ts"; +import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; +import type { Profile } from "../repl-study/layout.ts"; +import { MUTATIONS } from "../repl-study/mutations.ts"; +import { + bandGeometry, + BAND_ROWS, + DRAWER_TRANSITION_SECONDS, + columnFor, + NOTE_ROW, + TRACK_ROW, + notchHeightForDepth, + notchLayout, + transcriptLines, +} from "../repl-study/render.ts"; +import { + applyAnsi, + createGrid, + gridDifferences, + gridText, + viewport, +} from "../repl-study/screen.ts"; +import { initialView, scrollBy } from "../repl-study/store.ts"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const GOLDENS = fileURLToPath(new URL("./fixtures/repl-study/", import.meta.url)); +const MAIN = "scripts/repl-study/main.ts"; + +/** The band rows of a rendered frame, as a grid of glyphs. */ +function bandRows(text: string, size: Size): string[] { + const rows = text.split("\n"); + return BAND_ROWS.map((offset) => rows[size.rows - BAND_ROWS.length + offset] ?? ""); +} + +function glyphAt(row: string, column: number): string { + return [...row][column] ?? " "; +} + +/** + * How tall the notch in this column is. + * + * Only the rows a notch can reach are counted: the label row below the track + * belongs to the selection's note, and counting it would make a described + * marker look one level shallower than it is. + */ +function notchHeight(rows: readonly string[], column: number): number { + return rows.slice(0, NOTE_ROW).filter((row) => { + const glyph = glyphAt(row, column); + return glyph !== " " && glyph !== "─"; + }).length; +} + +function clockOf(seconds: number): string { + const minutes = String(Math.floor(seconds / 60)).padStart(2, "0"); + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; +} + +/** + * One column per depth, taken only from notches that are not sharing. + * + * A coalesced column takes its height from the shallowest scope in it, so + * measuring what a depth looks like needs a marker that has a column to itself. + */ +function soleNotchColumns( + subject: ReturnType, + geometry: { readonly trackLeft: number; readonly trackWidth: number }, +): Map { + const byDepth = new Map(); + for (const notch of notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth)) { + if (notch.checkpoints.length !== 1) { + continue; + } + const [point] = notch.checkpoints; + if (!byDepth.has(point.depth)) { + byDepth.set(point.depth, notch.column); + } + } + return byDepth; +} + +/** + * Run a command with a pseudo-terminal attached. + * + * `script` is the one pty allocator both a developer's macOS machine and a + * Linux runner have, and its two dialects disagree about argument order. + */ +function ptyCommand(_command: string): string { + return "script"; +} + +function ptyArguments(command: string): string[] { + const full = `deno run --allow-all ${command}`; + if (Deno.build.os === "darwin") { + return ["-q", "/dev/null", ...full.split(" ")]; + } + if (Deno.build.os === "linux") { + return ["-qec", full, "/dev/null"]; + } + throw new Error(`this evidence needs a pseudo-terminal, and ${Deno.build.os} has no script(1)`); +} + +function* readTrace(path: string): Operation { + const text = yield* readTextFile(path); + return text + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => TRACE_ENTRY.parse(JSON.parse(line))); +} + +/** Parsed rather than cast, so a malformed trace fails here and not later. */ +const TRACE_ENTRY = z.object({ + frame: z.number(), + elapsedMs: z.number(), + deltaSeconds: z.number(), + animating: z.boolean(), + motionDone: z.boolean().nullable(), + bytes: z.number(), + segment: z.string(), + fixture: z.string(), +}); + +/** A trace line, parsed. The fixture name is narrowed where the harness reads it. */ +type TracedFrame = z.infer; + +/** The order a run visited its moments in, with repeats collapsed. */ +function visited(entries: readonly T[]): string[] { + const order: string[] = []; + for (const entry of entries) { + if (order[order.length - 1] !== entry.segment) { + order.push(entry.segment); + } + } + return order; +} + +/** What the whole demonstration is supposed to visit, in order. */ +const JOURNEY_SEGMENTS = [ + "hold:empty", + "play:empty→nested", + "hold:nested", + "play:nested→generated", + "hold:generated", + "play:generated→drawer", + "hold:drawer", + "play:drawer→paused", + "hold:paused", + "play:paused→settled", + "hold:settled", +]; + +describe("fixture rendering", () => { + it("renders every committed capture exactly", function* () { + const captures = yield* captureAll(); + expect(captures.length).toBeGreaterThan(0); + for (const capture of captures) { + const golden = yield* readTextFile(join(GOLDENS, `${capture.name}.txt`)); + expect(captureText(capture)).toBe(golden); + } + }); + + it("commits a capture for every fixture at every composed profile", function* () { + const names = (yield* readdir(GOLDENS)).filter((name) => name.endsWith(".txt")); + for (const subject of fixtures()) { + for (const profile of ["wide", "medium", "narrow"]) { + expect(names).toContain(`${subject.name}.${profile}.txt`); + } + } + expect(names).toContain("drawer.too-small.txt"); + expect(names).toContain("paused.narrow.history.txt"); + }); + + it("reports no renderer errors for any fixture or profile", function* () { + for (const subject of fixtures()) { + for (const profile of ["wide", "medium", "narrow", "too-small"] as Profile[]) { + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES[profile], + }); + expect(frame.text.length).toBeGreaterThan(0); + } + } + }); + + it("rejects a frame drawn from stale state", function* () { + const subject = fixture("settled"); + const stale = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES.wide, + mutation: "stale-frame", + }); + const golden = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + expect(golden).not.toContain(stale.text.replace(/\n+$/, "")); + expect(stale.text).not.toContain("Entry 1 ✓ completed"); + }); +}); + +describe("layout profiles", () => { + it("chooses a profile from the measured size alone", function* () { + expect(profileFor(200, 50)).toBe("wide"); + expect(profileFor(160, 36)).toBe("wide"); + expect(profileFor(159, 36)).toBe("medium"); + expect(profileFor(160, 35)).toBe("medium"); + expect(profileFor(120, 30)).toBe("medium"); + expect(profileFor(119, 30)).toBe("narrow"); + expect(profileFor(72, 20)).toBe("narrow"); + expect(profileFor(71, 20)).toBe("too-small"); + expect(profileFor(72, 19)).toBe("too-small"); + }); + + it("keeps every composed pane wide enough to read", function* () { + for (const [cols, rows] of [ + [200, 50], + [160, 36], + [140, 38], + [120, 30], + ]) { + const layout = layoutFor({ cols, rows, drawer: false, surface: "transcript" }); + expect(layout.sidebar?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.sidebar); + expect(layout.bindings?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.bindings); + expect(layout.transcript?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.transcript); + } + }); + + it("rejects the wide composition kept at narrow dimensions", function* () { + const layout = layoutFor({ + cols: 90, + rows: 28, + drawer: false, + surface: "transcript", + mutation: "shrink-wide-at-narrow", + }); + expect(layout.transcript?.width ?? 0).toBeLessThan(PANE_MINIMUMS.transcript); + }); + + it("preserves the fixture, the window and the selection across every transition", function* () { + const subject = fixture("paused"); + let state: HarnessState = { + fixture: subject, + view: { ...initialView(subject), anchor: 3, surface: "bindings" }, + cols: 200, + rows: 50, + quit: false, + }; + const before = state.view; + for (const size of [ + PROFILE_SIZES.medium, + PROFILE_SIZES.narrow, + PROFILE_SIZES["too-small"], + PROFILE_SIZES.wide, + ]) { + // `reduce` reads the size from the terminal, so the resize is applied the + // way the interactive harness applies it: as new dimensions, not as a new + // view. + state = { ...state, cols: size.cols, rows: size.rows }; + expect(state.view).toEqual(before); + const frame = yield* renderFrame({ fixture: state.fixture, view: state.view, size }); + expect(frame.text.length).toBeGreaterThan(0); + } + expect(state.view.anchor).toBe(3); + expect(state.view.checkpoint).toBe(before.checkpoint); + expect(state.view.surface).toBe("bindings"); + }); +}); + +describe("the history footer", () => { + it("stays at the bottom, full width, with a drawer open", function* () { + for (const profile of ["wide", "medium"] as Profile[]) { + const size = PROFILE_SIZES[profile]; + const subject = fixture("drawer"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: true, + surface: "transcript", + }); + expect(layout.footer).toBeDefined(); + expect(layout.footer?.y).toBe(size.rows - BAND_ROWS.length); + expect(layout.footer?.width).toBe(size.cols); + expect(layout.contextual).toBeDefined(); + expect(intersects(layout.contextual!, layout.footer!)).toBe(false); + + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + const rows = bandRows(frame.text, size); + // Visible means all of it: the label the study puts at the left, the + // transport at the right, and the track with the head on it between them. + expect(rows[0]).toContain("EXECUTION HISTORY"); + expect(rows[0]).toContain("[ Pause ]"); + expect(rows[TRACK_ROW]).toContain("┃"); + } + }); + + it("rejects a drawer that takes the footer's rows", function* () { + const size = PROFILE_SIZES.wide; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: true, + surface: "transcript", + }); + const covering = { + ...layout.contextual!, + height: layout.contextual!.height + layout.footer!.height, + }; + expect(intersects(covering, layout.footer!)).toBe(true); + + const subject = fixture("drawer"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "drawer-covers-footer", + }); + const rows = bandRows(frame.text, size); + expect(rows[0]).not.toContain("[ Pause ]"); + expect(rows[TRACK_ROW]).not.toContain("┃"); + }); + + it("makes a notch's height its scope depth", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("settled"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + const rows = bandRows(frame.text, size); + const byDepth = soleNotchColumns(subject, geometry); + + // The four heights four rows can spell, one for each depth. + for (const depth of [0, 1, 2, 3]) { + const column = byDepth.get(depth); + expect({ + depth, + height: column === undefined ? "no notch of its own" : notchHeight(rows, 1 + column), + }).toEqual({ depth, height: notchHeightForDepth(depth) }); + } + + // Anything deeper shares the shortest notch rather than inventing a height. + const deeper = byDepth.get(4); + if (deeper !== undefined) { + expect(notchHeight(rows, 1 + deeper)).toBe(notchHeightForDepth(3)); + } + }); + + it("says selection, the head and entry status some way other than height", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("paused"); + const history = subject.history; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const byDepth = soleNotchColumns(subject, geometry); + const deepColumn = byDepth.get(3) ?? byDepth.get(2)!; + const deepPoint = history.checkpoints.find( + (point) => + columnFor(point.at, history, geometry.trackLeft, geometry.trackWidth) === deepColumn, + )!; + + const unselected = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: -1 }, + size, + }); + const selected = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: history.checkpoints.indexOf(deepPoint) }, + size, + }); + + // Selecting a checkpoint must not make its notch taller. Height belongs to + // depth; selection is said with the caret and the label instead. + expect(notchHeight(bandRows(selected.text, size), 1 + deepColumn)).toBe( + notchHeight(bandRows(unselected.text, size), 1 + deepColumn), + ); + expect(selected.text).toContain("▲"); + expect(selected.text).toContain(`${clockOf(deepPoint.at)} · snapped`); + + // An entry boundary is a glyph, and the playhead is its own stem and label. + const boundary = history.checkpoints.find((point) => point.kind === "entry")!; + const boundaryColumn = columnFor(boundary.at, history, geometry.trackLeft, geometry.trackWidth); + expect(glyphAt(bandRows(unselected.text, size)[TRACK_ROW], 1 + boundaryColumn)).toBe("◆"); + expect(bandRows(unselected.text, size)[0]).toContain("PAUSED HEAD"); + }); + + it("rejects one notch height for every depth", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("settled"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "flatten-notches", + }); + const rows = bandRows(frame.text, size); + const byDepth = soleNotchColumns(subject, geometry); + for (const depth of [0, 1, 2]) { + const column = byDepth.get(depth); + if (column !== undefined) { + expect(notchHeight(rows, 1 + column)).toBe(1); + } + } + }); +}); + +describe("the supported minimum", () => { + it("refuses a terminal below it, and says what it needs", function* () { + const size = PROFILE_SIZES["too-small"]; + const subject = fixture("drawer"); + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + expect(frame.text).toContain("Terminal too small"); + expect(frame.text).toContain( + `${MINIMUM.cols} × ${MINIMUM.rows} required · ${size.cols} × ${size.rows} now`, + ); + expect(frame.text).not.toContain("EXECUTION HISTORY"); + }); + + it("recovers the same view when the terminal grows again", function* () { + const subject = fixture("paused"); + const view = { ...initialView(subject), anchor: 2 }; + const small = yield* renderFrame({ fixture: subject, view, size: PROFILE_SIZES["too-small"] }); + expect(small.text).toContain("Terminal too small"); + const restored = yield* renderFrame({ fixture: subject, view, size: PROFILE_SIZES.wide }); + const golden = yield* readTextFile(join(GOLDENS, "paused.wide.txt")); + expect(restored.text.length).toBeGreaterThan(0); + expect(golden).toContain("EXECUTION HISTORY"); + }); + + it("rejects an interface composed below the minimum", function* () { + const subject = fixture("drawer"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES["too-small"], + mutation: "ignore-minimum", + }); + expect(frame.text).not.toContain("Terminal too small"); + }); +}); + +describe("resize", () => { + /** + * One run across every profile, ending smaller than it started — which is + * where a renderer that was not told about the resize leaves its evidence. + */ + const script: readonly { readonly fixture: string; readonly size: Size }[] = [ + { fixture: "nested", size: PROFILE_SIZES.wide }, + { fixture: "drawer", size: PROFILE_SIZES.medium }, + { fixture: "paused", size: PROFILE_SIZES["too-small"] }, + { fixture: "settled", size: PROFILE_SIZES.narrow }, + ]; + + /** + * Play the script through one terminal that really is being resized. + * + * The grid is the terminal, so it changes size at every step whether or not + * the renderer was told. A renderer still drawing at the old size addresses + * cells this terminal no longer has, which is what corrupts a real one. + */ + function* play(told: boolean) { + const term = yield* useTerm(PROFILE_SIZES.wide); + let grid = createGrid(PROFILE_SIZES.wide.cols, PROFILE_SIZES.wide.rows); + for (const step of script) { + const subject = fixture(step.fixture); + if (grid.cols !== step.size.cols || grid.rows !== step.size.rows) { + const resized = createGrid(step.size.cols, step.size.rows); + resized.overflow = grid.overflow; + grid = resized; + } + if (told) { + term.update({ width: step.size.cols, height: step.size.rows }); + } + // Ignoring a resize means ignoring it completely: the renderer keeps both + // the terminal's old dimensions and the layout it computed from them. + const frame = renderInto(term, { + fixture: subject, + view: initialView(subject), + size: told ? step.size : PROFILE_SIZES.wide, + mutation: told ? undefined : "skip-resize-update", + }); + applyAnsi(grid, frame.ansi); + } + const last = script[script.length - 1]; + const subject = fixture(last.fixture); + const fresh = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: last.size, + }); + return { + seen: viewport(grid, last.size.cols, last.size.rows), + fresh: applyAnsi(createGrid(last.size.cols, last.size.rows), fresh.ansi), + }; + } + + it("leaves no stale cells behind", function* () { + const { seen, fresh } = yield* play(true); + expect(seen.overflow).toBe(0); + expect(gridDifferences(seen, fresh)).toEqual([]); + expect(gridText(seen)).toBe(gridText(fresh)); + }); + + it("corrupts the screen when the renderer is not told the size changed", function* () { + const { seen, fresh } = yield* play(false); + expect(seen.overflow).toBeGreaterThan(0); + expect(gridDifferences(seen, fresh).length).toBeGreaterThan(0); + }); + + it("refuses a terminal sequence it does not model", function* () { + const grid = createGrid(10, 2); + expect(() => applyAnsi(grid, new TextEncoder().encode("\u001b[2J"))).toThrow( + "does not model the terminal sequence", + ); + }); +}); + +describe("staying operable", () => { + it("windows a long transcript and marks what is clipped", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("nested"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const width = layout.transcript!.width - 2; + const total = transcriptLines(subject.entry!, width).length; + expect(total).toBeGreaterThan(layout.transcript!.height); + + let view = initialView(subject); + const seen = new Set(); + const limit = total; + for (let step = 0; step <= total; step += 1) { + const frame = yield* renderFrame({ fixture: subject, view, size }); + for (const line of frame.text.split("\n")) { + seen.add(line.trim()); + } + view = scrollBy(view, 1, limit); + } + const last = transcriptLines(subject.entry!, width).at(-1)!; + const lastText = last.segments + .map((segment) => segment.text) + .join("") + .trim(); + expect([...seen].some((line) => line.includes(lastText.slice(0, 20)))).toBe(true); + + const first = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + expect(first.text).toContain("more lines"); + }); + + it("clips a long transcript with no route out when the window is removed", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("nested"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "clip-long-transcript", + }); + expect(frame.text).not.toContain("more lines"); + }); + + it("reaches every checkpoint even where the band had to coalesce", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("paused"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "history", + }); + const rect = layout.footer!; + const geometry = bandGeometry(subject, layout, rect); + const notches = notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth); + const gathered = notches.reduce((total, notch) => total + notch.checkpoints.length, 0); + + expect(notches.some((notch) => notch.checkpoints.length > 1)).toBe(true); + expect(gathered).toBe(subject.history.checkpoints.length); + + for (let index = 0; index < subject.history.checkpoints.length; index += 1) { + const point = subject.history.checkpoints[index]; + const frame = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: index, surface: "history", drawerOpen: false }, + size, + surface: "history", + }); + expect(frame.text).toContain( + `${String(Math.floor(point.at / 60)).padStart(2, "0")}:${String(point.at % 60).padStart(2, "0")} · snapped`, + ); + } + }); + + it("loses checkpoints when the band stops coalescing", function* () { + const subject = fixture("paused"); + const size = PROFILE_SIZES.narrow; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "history", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const notches = notchLayout( + subject.history, + geometry.trackLeft, + geometry.trackWidth, + "clip-long-transcript", + ); + const columns = new Set(notches.map((notch) => notch.column)); + expect(columns.size).toBeLessThan(subject.history.checkpoints.length); + }); +}); + +describe("terminal restoration", () => { + const modes = terminalModes(); + const apply = new TextDecoder().decode(modes.apply); + const revert = new TextDecoder().decode(modes.revert); + + it("restores the modes it changed on an ordinary exit", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay`, { cwd: ROOT }).join(); + expect(result.code).toBe(0); + expect(result.stdout.startsWith(apply)).toBe(true); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("restores them when the run is interrupted by a signal", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay --interrupt-after 2`, { + cwd: ROOT, + }).join(); + expect(result.stdout.startsWith(apply)).toBe(true); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("restores them when a frame fails", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay --fail-after 2`, { + cwd: ROOT, + }).join(); + expect(result.code).not.toBe(0); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("rejects a run that leaves the terminal in the modes it turned on", function* () { + const result = yield* exec( + `deno run --allow-all ${MAIN} --replay --mutation leak-terminal-modes`, + { cwd: ROOT }, + ).join(); + expect(result.stdout.endsWith(revert)).toBe(false); + }); + + it("refuses interactive mode when there is no terminal, and names what to use instead", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN}`, { cwd: ROOT }).join(); + expect(result.code).toBe(2); + expect(`${result.stdout}${result.stderr}`).toContain("--capture"); + }); +}); + +describe("the boundary this experiment keeps", () => { + it("keeps terminal cells out of the fixtures", function* () { + // `rows` is allowed and is not a terminal row: an entry's rows are the + // study's own transcript rows, which stay semantic until `render.ts` turns + // them into lines for a width it was given. + const forbidden = [ + "x", + "y", + "width", + "height", + "cols", + "columns", + "column", + "anchor", + "profile", + ]; + const walk = (value: unknown, path: string) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (typeof value !== "object" || value === null) { + return; + } + for (const [key, nested] of Object.entries(value)) { + expect({ path: `${path}.${key}`, forbidden: forbidden.includes(key) }).toEqual({ + path: `${path}.${key}`, + forbidden: false, + }); + walk(nested, `${path}.${key}`); + } + }; + for (const subject of fixtures()) { + walk(subject, subject.name); + } + }); + + it("depends on the renderer from the root manifest alone", function* () { + const root = JSON.parse(yield* readTextFile(join(ROOT, "package.json"))); + expect(Object.keys(root.dependencies)).not.toContain("@bomb.sh/tty"); + expect(root.devDependencies["@bomb.sh/tty"]).toBe("0.9.0"); + + for (const member of yield* readdir(join(ROOT, "packages"))) { + for (const manifest of ["package.json", "deno.json"]) { + const path = join(ROOT, "packages", member, manifest); + if (!(yield* exists(path))) { + continue; + } + const text = yield* readTextFile(path); + expect(text).not.toContain("@bomb.sh/tty"); + expect(text).not.toContain("repl-study"); + } + } + }); + + it("uses every control it declares", function* () { + // A control nobody passes is a claim nobody is checking, so the evidence's + // own source has to mention each one. #838's controls are exercised here + // and #839's next door; the declaration is one list, so the check reads + // both suites rather than letting either half go unclaimed. + const suites = ["./repl-study.test.ts", "./repl-focus.test.ts"]; + const sources: string[] = []; + for (const suite of suites) { + sources.push(yield* readTextFile(fileURLToPath(new URL(suite, import.meta.url)))); + } + for (const mutation of MUTATIONS) { + const used = sources.some((source) => source.includes(mutation)); + expect({ mutation, used }).toEqual({ mutation, used: true }); + } + }); + + it("writes its captures where the goldens live", function* () { + const directory = yield* useTempDirectory("repl-study-captures"); + const captures = yield* captureAll(); + yield* writeCaptures(directory, captures); + const written = yield* readdir(directory); + expect(written.filter((name) => name.endsWith(".txt")).length).toBe(captures.length); + }); +}); + +describe("animation", () => { + const PLAYBACK = playbackBetween("generated", "drawer")!; + + it("interpolates the drawer itself and reports that it is still moving", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + expect(frames.length).toBeGreaterThan(3); + + // The renderer owns this one: the harness declared a transition and then + // only supplied time. + expect(frames.some((frame) => frame.animating)).toBe(true); + expect(frames[frames.length - 1].animating).toBe(false); + + const heights = frames.map((frame) => frame.bounds.contextual?.height ?? 0); + const first = heights[0]; + const last = heights[heights.length - 1]; + expect(last).toBeGreaterThan(first); + // It arrives by passing through, rather than by jumping. + expect(heights.some((height) => height > first && height < last)).toBe(true); + for (const [index, height] of heights.entries()) { + if (index > 0) { + expect(height).toBeGreaterThanOrEqual(heights[index - 1]); + } + } + }); + + it("declares and advances transitions in the renderer's unit, which is seconds", function* () { + // Scaling both sides by the same thousand is invisible: milliseconds of + // delta against a duration also written in milliseconds produces the same + // frame count and the same picture. So this pins the unit against the + // library's own documented arithmetic — a 0.2 transition is halfway after + // 0.1 and finished after 0.2 — which a millisecond reading cannot satisfy. + const term = yield* useTerm({ cols: 20, rows: 8 }); + const box = (color: number): Op[] => [ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" } }), + open("box", { + layout: { width: grow(), height: fixed(4) }, + bg: color, + transition: { duration: 0.2, easing: "linear", properties: ["bg"] }, + }), + text("box"), + close(), + close(), + ]; + + term.render(box(rgba(255, 0, 0)), { deltaTime: 0 }); + term.render(box(rgba(0, 0, 255)), { deltaTime: 0 }); + expect(term.render(box(rgba(0, 0, 255)), { deltaTime: 0.1 }).animating).toBe(true); + term.render(box(rgba(0, 0, 255)), { deltaTime: 0.15 }); + // A frame of lag: the flag clears on the render after the one that arrives, + // which is why the library's own test spends 0.3 on a 0.2 transition. + expect(term.render(box(rgba(0, 0, 255)), { deltaTime: 0.05 }).animating).toBe(false); + + // And the reading that would make this harness's own numbers wrong: one + // frame's worth of seconds must not finish a transition declared in them. + const other = yield* useTerm({ cols: 20, rows: 8 }); + other.render(box(rgba(255, 0, 0)), { deltaTime: 0 }); + other.render(box(rgba(0, 0, 255)), { deltaTime: 0 }); + expect(other.render(box(rgba(0, 0, 255)), { deltaTime: FRAME_SECONDS }).animating).toBe(true); + }); + + it("keeps the harness's own transition in that unit", function* () { + // A duration meant as milliseconds would read as several minutes here. + expect(DRAWER_TRANSITION_SECONDS).toBeLessThan(2); + expect(DRAWER_TRANSITION_SECONDS).toBeGreaterThan(0); + }); + + it("supplies every captured frame its own delta, in seconds", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + const animating = frames.filter((frame) => frame.animating).length; + // Sixteen milliseconds is 0.016 of the renderer's seconds, so a 0.26s + // transition takes about seventeen of them. + expect(animating).toBeGreaterThan(DRAWER_TRANSITION_SECONDS / FRAME_SECONDS - 4); + expect(animating).toBeLessThan(DRAWER_TRANSITION_SECONDS / FRAME_SECONDS + 4); + }); + + it("never lets the drawer's movement cover the history footer", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + for (const frame of frames) { + const footer = frame.bounds.footer; + const contextual = frame.bounds.contextual; + expect(footer?.y).toBe(PROFILE_SIZES.wide.rows - BAND_ROWS.length); + if (contextual !== undefined && footer !== undefined) { + expect(contextual.y + contextual.height).toBeLessThanOrEqual(footer.y + 1); + } + } + }); + + it("moves the head and reveals the transcript on the application's own clock", function* () { + const start = motionAt(PLAYBACK, 0); + const middle = motionAt(PLAYBACK, PLAYBACK.durationMs / 2); + const end = motionAt(PLAYBACK, PLAYBACK.durationMs); + + expect(start.progress).toBe(0); + expect(end.done).toBe(true); + expect(middle.headAt).toBeGreaterThan(start.headAt); + expect(end.headAt).toBeGreaterThan(middle.headAt); + expect(end.headAt).toBe(fixture(PLAYBACK.to).history.headAt); + + // Time in, frame out: the same instant renders identically every time. + const once = yield* renderFrame({ + fixture: fixture(PLAYBACK.to), + view: initialView(fixture(PLAYBACK.to)), + size: PROFILE_SIZES.wide, + motion: middle, + }); + const twice = yield* renderFrame({ + fixture: fixture(PLAYBACK.to), + view: initialView(fixture(PLAYBACK.to)), + size: PROFILE_SIZES.wide, + motion: middle, + }); + expect(once.text).toBe(twice.text); + }); + + it("captures a start, a midpoint and a settled frame that differ", function* () { + const start = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.start.txt`), + ); + const midpoint = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.midpoint.txt`), + ); + const settled = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.settled.txt`), + ); + expect(start).not.toBe(midpoint); + expect(midpoint).not.toBe(settled); + expect(settled).toContain("INPUT REQUIRED"); + expect(settled).toContain("EXECUTION HISTORY"); + }); + + it("draws one frame and stops when nothing schedules the next", function* () { + const directory = yield* useTempDirectory("repl-study-stalled"); + const trace = join(directory, "stalled.jsonl"); + const command = `${MAIN} --play generated drawer --frames 30 --trace ${trace} --mutation never-tick`; + yield* exec(ptyCommand(command), { cwd: ROOT, arguments: ptyArguments(command) }).join(); + const drawn = yield* readTrace(trace); + expect(drawn.length).toBe(1); + expect(drawn[0].motionDone).toBe(false); + }); + + it("rejects a reconstruction that lands halfway through a transition", function* () { + const subject = fixture("settled"); + const halfway = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES.wide, + mutation: "restore-mid-animation", + }); + const golden = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + expect(golden).not.toContain(halfway.text.replace(/\n+$/, "")); + }); +}); + +describe("animation in a real terminal", () => { + it("keeps drawing without a keystroke", function* () { + const directory = yield* useTempDirectory("repl-study-pty"); + const trace = join(directory, "pty.jsonl"); + yield* exec(ptyCommand(`${MAIN} --play generated drawer --frames 80 --trace ${trace}`), { + cwd: ROOT, + arguments: ptyArguments(`${MAIN} --play generated drawer --frames 80 --trace ${trace}`), + }).join(); + + const drawn = yield* readTrace(trace); + // Nothing was typed at it, and it went on drawing anyway. + expect(drawn.length).toBeGreaterThan(10); + expect( + drawn.every((entry, index) => index === 0 || entry.elapsedMs > drawn[index - 1].elapsedMs), + ).toBe(true); + expect(drawn.some((entry) => entry.animating)).toBe(true); + expect(drawn[drawn.length - 1].motionDone).toBe(true); + expect(drawn.filter((entry) => entry.bytes > 0).length).toBeGreaterThan(3); + }); + + it("stops the clock and restores the terminal when interrupted mid-animation", function* () { + const directory = yield* useTempDirectory("repl-study-interrupt"); + const trace = join(directory, "interrupted.jsonl"); + const command = `${MAIN} --play generated drawer --frames 200 --interrupt-after-frames 4 --trace ${trace}`; + const result = yield* exec(ptyCommand(command), { + cwd: ROOT, + arguments: ptyArguments(command), + }).join(); + + const drawn = yield* readTrace(trace); + // The interruption arrived while the transition was still running, and no + // frame was drawn after it: the clock went down with the session. + expect(drawn.length).toBe(4); + expect(drawn[drawn.length - 1].motionDone).toBe(false); + expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); + }); +}); + +describe("the whole demonstration", () => { + it("visits every moment of the approved story, in order", function* () { + const planned = journeyPlan(); + expect(visited(planned.map((frame) => ({ segment: frame.label })))).toEqual(JOURNEY_SEGMENTS); + + // Every fixture appears, in the order the study tells them. + const moments: string[] = []; + for (const frame of planned) { + if (moments[moments.length - 1] !== frame.fixture) { + moments.push(frame.fixture); + } + } + expect(moments).toEqual([...FIXTURE_NAMES]); + }); + + it("holds each moment long enough to read it", function* () { + for (const segment of JOURNEY) { + // Nothing is on screen for less than a second unless it is moving. + const duration = segmentDurationMs(segment); + if (segment.kind === "hold") { + expect({ segment: segmentLabel(segment), long: duration >= 1000 }).toEqual({ + segment: segmentLabel(segment), + long: true, + }); + } + } + // Long enough to watch, short enough to sit through. + expect(journeyDurationMs()).toBeGreaterThan(10_000); + expect(journeyDurationMs()).toBeLessThan(30_000); + }); + + it("animates both ways along the way, and ends on the settled entry", function* () { + const { frames } = yield* journeyFrames(PROFILE_SIZES.wide); + + // The renderer's own interpolation happened. + expect(frames.some((frame) => frame.animating)).toBe(true); + // And the application's: a transition frame carrying unfinished motion. + expect(frames.some((frame) => frame.label.startsWith("play:"))).toBe(true); + + const settled = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + const last = frames[frames.length - 1]; + expect(last.label).toBe("hold:settled"); + expect(last.animating).toBe(false); + // What remains is the fixture, exactly — no trace of the journey that + // arrived at it. + expect(settled).toContain(last.text.replace(/\n+$/, "")); + }); + + it("keeps the journey out of everything that outlives it", function* () { + // The journey is derived, not stored. No fixture carries a key belonging to + // it, so there is nothing for a journal to restore halfway through one. + const forbidden = ["segment", "playback", "motion", "progress", "durationMs", "reveal"]; + const walk = (value: unknown, path: string) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (typeof value !== "object" || value === null) { + return; + } + for (const [key, nested] of Object.entries(value)) { + expect({ at: `${path}.${key}`, journeyState: forbidden.includes(key) }).toEqual({ + at: `${path}.${key}`, + journeyState: false, + }); + walk(nested, `${path}.${key}`); + } + }; + for (const subject of fixtures()) { + walk(subject, subject.name); + } + }); + + it("rebuilds the renderer when it runs out of room to measure text", function* () { + // Clay caches measured words, and a wide terminal running the whole story + // exhausts that cache part way through. The demonstration has to survive it, + // so this asserts both that it happens and that the run still finishes. + const { frames, rebuilds } = yield* journeyFrames(PROFILE_SIZES.wide); + expect(rebuilds).toBeGreaterThan(0); + expect(frames.length).toBe(journeyPlan().length); + expect(frames[frames.length - 1].label).toBe("hold:settled"); + }); +}); + +describe("the whole demonstration, in a real terminal", () => { + it("plays start to finish with nobody at the keyboard", function* () { + const directory = yield* useTempDirectory("repl-study-journey"); + const trace = join(directory, "journey.jsonl"); + const budget = 600; + const command = `${MAIN} --play --frames ${budget} --trace ${trace}`; + yield* exec(ptyCommand(command), { cwd: ROOT, arguments: ptyArguments(command) }).join(); + + const drawn = yield* readTrace(trace); + expect(visited(drawn)).toEqual([...JOURNEY_SEGMENTS, "settled"]); + expect(drawn.some((entry) => entry.animating)).toBe(true); + expect( + drawn.some((entry) => entry.segment.startsWith("play:") && entry.motionDone === false), + ).toBe(true); + + // It stopped because the story ended, not because it ran out of budget — + // which is what it means for the clock to stop after the settled state. + expect(drawn.length).toBeLessThan(budget); + const last = drawn[drawn.length - 1]; + expect(last.segment).toBe("settled"); + expect(last.fixture).toBe("settled"); + }); + + it("cancels the whole journey when interrupted part way through", function* () { + const directory = yield* useTempDirectory("repl-study-journey-interrupt"); + const trace = join(directory, "interrupted.jsonl"); + const command = `${MAIN} --play --frames 600 --interrupt-after-frames 45 --trace ${trace}`; + const result = yield* exec(ptyCommand(command), { + cwd: ROOT, + arguments: ptyArguments(command), + }).join(); + + const drawn = yield* readTrace(trace); + expect(drawn.length).toBe(45); + // It was interrupted in the middle of the story, and nothing was drawn + // afterwards: the clock went down with the session. + const last = drawn[drawn.length - 1]; + expect(last.segment).not.toBe("settled"); + expect(JOURNEY_SEGMENTS).toContain(last.segment); + expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); + }); +});