From 7d5c20a8c385eadd8a02cd0e01359f9ba2729020 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 14:22:42 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20File=20and=20read=20issues=20fr?= =?UTF-8?q?om=20a=20REPL=20entry=20(#181,=206/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry files an issue in its enclosing `` and reads one back, wherever the REPL was started: no repository is discovered for it, no managed root is made and no hold is taken. Both operations are retained, so leaving at a question and reopening the printed location answers from the records rather than asking the tracker again. The durable envelope moves to `issue/owned.ts`, shared by the two profiles that retain Issue work. It takes the live operation as a callback, which is what lets a workflow run keep calling `IssueApi` directly while an entry composes `IssueOperations` over a transport-facing default of its own — one envelope per operation either way. The workflow wrapper keeps its identity derivation, its inherited-prefix borrowing and its record encodings byte for byte. An entry's issue work is named by the owner the session derives from the execution and the entry key, and by the identity canonical core minted where it selected this implementation. Neither reaches the component through a prop, a tracker, a Context or a middleware answer, and the entry's `` is an ordinary shadowable registration a repository component still outranks. --- .github/workflows/ci.yml | 3 + architecture.md | 76 +- packages/cli/src/compiled.ts | 8 +- packages/cli/src/deno-repositories.ts | 23 +- packages/cli/src/deno.ts | 8 +- packages/cli/src/repl-entry-installation.ts | 42 + packages/cli/src/repl-profile.ts | 20 + packages/cli/src/repl/program.ts | 3 + packages/cli/src/repl/session.ts | 52 +- packages/cli/tests/repl-issues-pty.test.ts | 49 + packages/cli/tests/repl-issues.test.ts | 1174 +++++++++++++++++ .../cli/tests/support/repl-issues-journey.ts | 334 +++++ packages/git/deno.ts | 10 + .../git/src/composition/components/Issue.ts | 113 +- packages/git/src/composition/installation.ts | 12 +- .../src/deno/retained-repository/issues.ts | 364 +++++ packages/git/src/issue/effect.ts | 168 +-- packages/git/src/issue/owned.ts | 231 ++++ packages/git/tests/issue-owned.test.ts | 704 ++++++++++ .../git/tests/support/issue-tracker-server.ts | 12 + scripts/runtime-test-exclusions.ts | 12 + scripts/tests/repl-issues-compiled.test.ts | 58 + scripts/tests/runtime-exclusions.test.ts | 1 + specs/executable-mdx-spec.md | 27 +- specs/repl-spec.md | 89 +- specs/workflow-workspace-spec.md | 15 + 26 files changed, 3409 insertions(+), 199 deletions(-) create mode 100644 packages/cli/src/repl-entry-installation.ts create mode 100644 packages/cli/tests/repl-issues-pty.test.ts create mode 100644 packages/cli/tests/repl-issues.test.ts create mode 100644 packages/cli/tests/support/repl-issues-journey.ts create mode 100644 packages/git/src/deno/retained-repository/issues.ts create mode 100644 packages/git/src/issue/owned.ts create mode 100644 packages/git/tests/issue-owned.test.ts create mode 100644 scripts/tests/repl-issues-compiled.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20546f855..0ad007d38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,6 +237,9 @@ jobs: - name: A REPL entry's files across the compiled binary run: deno test --allow-all --frozen scripts/tests/repl-repositories-compiled.test.ts + - name: A REPL entry's issues across the compiled binary + run: deno test --allow-all --frozen scripts/tests/repl-issues-compiled.test.ts + # Each package's `components.md` is a packaged asset on the same footing, # and the long-form documentation is the only thing that reports whether # one shipped — a binary missing them still lists every component and diff --git a/architecture.md b/architecture.md index 29d3c9661..a9aeebe1b 100644 --- a/architecture.md +++ b/architecture.md @@ -2525,6 +2525,14 @@ mutating, adopting a compatible issue, creating once, recovering an interrupted creation and refusing conflict and ambiguity all belong to the middleware, and the key is what this side hands over so the middleware can make it true. +Two profiles retain Issue work under this one effect type: a workflow run, +named by the run and the expansion, and a REPL entry, named by that entry's own +owner and the invocation identity canonical core minted. The envelope is the +same in both, and what differs is what one live attempt is — a workflow run +calls the provider surface directly, and an entry composes the profile-level +operation over a transport-facing default of its own. Neither nests inside the +other: two envelopes over one operation would retain it twice. + An IssueTracker URL is canonicalized before use. Credentials, a query and a fragment are refused rather than stripped, so normalization cannot silently turn the authored target into another request. @@ -6597,11 +6605,68 @@ deletion or repair. An acknowledged placement never enters that path again. A failed append fences later work with the first persistence failure, with no success result and no compensating Close. -There is no local Git operation here and nothing external: ``, -``, ``, ``, `` and `` stay -declared where they already are and refuse through their own unchanged -missing-provider defaults. Nothing inventories the managed root, snapshots a -checkout's contents or restores one. +There is no local Git operation here: ``, ``, +``, `` and `` stay declared where they +already are and refuse through their own unchanged missing-provider defaults. +Nothing inventories the managed root, snapshots a checkout's contents or +restores one. + +### What one entry retains about the issue it filed + +One external effect is retained beside the file work, and it is the one that +does not live in a checkout. `` files and reads in an entry wherever the +REPL was started: no repository is discovered for it, no managed root is made +and no hold is taken, because an issue is at a service and the entry's checkout +has nothing to do with it. + +Both operations are retained under the ordinary `issue_effect` records, named by +the same digest of the complete request every other profile uses. A completed +read answers with the snapshot it saw and a completed upsert with the URL it was +filed under, neither of them resolving a provider, reading configuration or +opening a credential. A REPL entry is not a WorkflowRun and installs no +synthetic one: what names the work is the entry's own owner — derived by the +session from the execution and the entry's admission-order position — together +with the identity canonical core minted where it selected this exact +implementation for this exact invocation. The `runId` and `expansionId` field +spellings in the record are a compatibility encoding of those two and nothing +more. + +Neither member can be supplied from outside. The owner reaches the +implementation as a trusted closure the host built for one entry, the identity +authenticator is delivered to that implementation by canonical execution and +exists only as the argument of that call, and neither appears in a prop, a +tracker, a Context, a middleware request or a journal field a document can +write. The entry's `` is an ordinary shadowable registration of the name +the Plugin already declares, so a repository component of that name keeps its +ordinary precedence — and such a replacement obtains no identity of the entry's. +Which of the two external effects an invocation asks for is settled by canonical +form dispatch from the form the scan recorded, because a composable answer about +an element's shape is not something an irreversible effect may be selected by. + +One durable boundary encloses the operation, and the live attempt inside it is +composed rather than hard-wired: the trusted implementation dispatches +`IssueOperations`, where a document's own middleware answers, transforms, +refuses and delegates as it does anywhere else, over a transport-facing default +of the entry's own that reaches `IssueApi` and adds no second envelope. That +default is bound in a scope of the attempt's own and closes over the attempt's +idempotency key, so two concurrent invocations each dispatch under their own and +neither can present the other's; its lifetime ends before the envelope publishes +anything. A middleware answer is the composition's outcome rather than proof +that a service was reached, and the record holds that normalized answer. +Completed replay invokes none of it. + +The interrupted case is the one the key exists for. An upsert the service +accepted and this end never acknowledged leaves no record, and the next attempt +presents the same key — so the provider recognizes its own mark and answers with +the issue already filed instead of filing a second one. A read has no key, +reconciles nothing, and may simply be made again. A failed or cancelled append +leaves no acknowledged success and fences what follows it, exactly as the file +work's does. + +An entry's history is held to its own owner before any of it is replayed: a +retained Issue record that parses, is named by the request it carries and +carries a valid normalized result for its own operation is readable, and one +written under another owner is refused rather than borrowed. ### What the screen teaches, and what leaving it costs @@ -6979,6 +7044,7 @@ Status is measured against main. | caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in [Invariant](#one-database-at-a-time-and-a-transaction-a-caller-can-hold). | built on main | | REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside a checkout the entry is working in — the ambient one it was started in, or a managed one it selected — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. Recording is ordinary ancestor-max `Files` middleware, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. There is no local Git operation and nothing external here; work outside every checkout the entry is working in stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained selection | one executing REPL entry's own `` and `` selections, recorded beside its file work as small routing records: the canonical managed root, the checkout root, the common Git directory, the credential-free repository identity, the display name and the owner checkout, with a root-level ``'s ambient owner recorded as its own answer. The selector is the ordinary managed one, with its own creation, compatible-reuse, conflict and interrupted-adoption policy; what this adds is that a completed selection answers from its record, calling no selector and reaching no clone, `worktree add`, sidecar, credential or lock — so a continuation works in a checkout somebody has since changed or removed, while only new work meets today's filesystem. Parsed facts are held to the call they stand at with pure slot arithmetic against the root the record itself carries, registered in entry-private state and minted as a fresh selection, so a copied or edited Repository value can misname a target and never reach one. One entry at a time *owns* a managed slot, held until teardown and reacquired lazily by the first new operation inside a restored placement; that is a different hold from the shared Git directory's mutation lease, which only a new `` selector and a new mutation take. A selection is acknowledged before its path is bound, and native work that was never acknowledged runs live again under the ordinary partial-creation policy [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | +| REPL entry retained issue work | one executing REPL entry's own `` work, retained under the ordinary `issue_effect` records beside its file work and needing no checkout at all: no repository is discovered for it, no managed root is made and no hold is taken. A completed read answers with the snapshot it saw and a completed upsert with the URL it was filed under, neither resolving a provider, reading configuration nor opening a credential. What names it is the entry's own owner — derived by the session from the execution and the entry's admission-order position — with the identity canonical execution minted where it selected this implementation for this invocation; the `runId` and `expansionId` spellings are a compatibility encoding of those two, and a REPL entry installs no synthetic WorkflowRun. Neither member is reachable from a prop, a tracker, a Context, a middleware request or a record field a document can write, and the entry's `` is an ordinary shadowable registration a repository component still outranks. One durable boundary encloses the operation and the live attempt inside it composes `IssueOperations` over a transport-facing default of the entry's own, bound in the attempt's own scope under the attempt's own idempotency key and gone before the envelope publishes; canonical form dispatch selects which of the two effects an invocation asks for. An upsert the service accepted and this end never acknowledged retains nothing and presents the same key next time, which is what the provider adopts under; a read has no key and may be made again. The entry's retained issue records are held to its own owner before any of them are replayed [Reference](#what-one-entry-retains-about-the-issue-it-filed). | built on the #181 stack | | live durable-operation coordinator | explicitly coordinates structured live execution with existing Yield publication while leaving replay and callback effects unchanged [Invariant](#live-effect-coordination). | built on the #365 stack | | Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation [Invariant](#live-effect-coordination). | built on the #365 stack; the Deno provider installs an adapter-private atomic handler | | shared Git-host reconciliation | Reconciles Git push and pull-request upsert through one request-only boundary whose private terminal alone settles the retained outcome. [Invariant](#external-effects-and-git-host-mutations); [Invariant](#issue-effects). | Built on #297; Push (#370) and PullRequest (#295) consume it. Issue (#296) has its own middleware boundary. | diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 85b7bd98e..6028c3da8 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -16,7 +16,8 @@ import { importPluginModule } from "./host-plugin-modules.ts"; import { compiledUpgradeAssembly } from "./compiled-upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoRunRepositories } from "./deno-repositories.ts"; +import { denoReplIssues, denoRunRepositories } from "./deno-repositories.ts"; +import { ReplEntryHost } from "./repl-entry-installation.ts"; import { BundledRunPlugin } from "./run-profile.ts"; import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; @@ -90,6 +91,11 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // the value and imports no adapter, and an ordinary execution installs it // in its own scope. yield* BundledRunPlugin.set(denoRunRepositories(HELPER)); + // The issue capability a REPL entry runs with, selected on the same terms + // and for the same reason: the adapter is this runtime's, and the entry that + // owns one is chosen by the session rather than by the host. Node and Bun + // select none, so `` there reaches its missing-provider refusal. + yield* ReplEntryHost.set(denoReplIssues); yield* runXmd( args, useCompiledService, diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index 078767b88..869b5e18d 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -29,9 +29,14 @@ import type { Plugin, PluginInstallRequest, PluginInstallation } from "@executab import type { Operation } from "effection"; import { cwd } from "@executablemd/runtime"; import { gitPlugin } from "@executablemd/git"; -import { useReplRepositoryRecording, useRunComposition } from "@executablemd/git/deno"; +import { + replIssueInstallation, + useReplRepositoryRecording, + useRunComposition, +} from "@executablemd/git/deno"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; +import type { ReplEntryInstallation } from "./repl-entry-installation.ts"; /** * The bundled Git Plugin, configured for this deployment. @@ -102,6 +107,22 @@ export function denoRunRepositories( }; } +/** + * The issue capability a REPL entry runs with on this host. + * + * Inert, and selected by the runtime entrypoint beside the bundled Plugin: + * naming the native GitHub adapter is a runtime-named module's business, and + * this is the value the shared command path carries without importing one. It + * reads no configuration and holds no credential — what an entry's issue work + * is allowed to reach is read by the invoked operation that needs it. + * + * The owner is supplied per entry by the session that derived it, which is why + * this is a factory rather than an installation: one value per host, one + * installation per entry. + */ +export const denoReplIssues: ReplEntryInstallation = (owner: string) => + replIssueInstallation(owner); + /** * This deployment's helper configuration, copied by value. * diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 9f8c476a7..3f30b6e60 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -19,7 +19,8 @@ import { importPluginModule } from "./host-plugin-modules.ts"; import type { UpgradeAssembly } from "./upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoRunRepositories } from "./deno-repositories.ts"; +import { denoReplIssues, denoRunRepositories } from "./deno-repositories.ts"; +import { ReplEntryHost } from "./repl-entry-installation.ts"; import { BundledRunPlugin } from "./run-profile.ts"; import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; @@ -109,6 +110,11 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // the value and imports no adapter, and an ordinary execution installs it // in its own scope. yield* BundledRunPlugin.set(denoRunRepositories(HELPER)); + // The issue capability a REPL entry runs with, selected on the same terms + // and for the same reason: the adapter is this runtime's, and the entry that + // owns one is chosen by the session rather than by the host. Node and Bun + // select none, so `` there reaches its missing-provider refusal. + yield* ReplEntryHost.set(denoReplIssues); yield* runXmd( args, useDenoService, diff --git a/packages/cli/src/repl-entry-installation.ts b/packages/cli/src/repl-entry-installation.ts new file mode 100644 index 000000000..bb25f0da7 --- /dev/null +++ b/packages/cli/src/repl-entry-installation.ts @@ -0,0 +1,42 @@ +/** + * The entry capability this host carries, as inert code. + * + * `run-profile.ts` does the same thing one layer up: a runtime entrypoint + * selects a value before any command is dispatched, and the shared command path + * reads it without importing an adapter. This is that selection for the one + * thing a REPL entry needs and an ordinary run does not — a capability built + * per *entry*, for the exact execution and entry key the entry belongs to. + * + * What crosses is a factory, and nothing else. It holds code and this + * deployment's configuration; it holds no owner, no site, no provider, no + * credential, no Git session and no identity, so nothing read from here is + * authority over permission, durable identity or reconciliation. The owner is + * derived by the session for the entry it is about to run, and the identity + * inside the installation is minted by canonical core. + * + * Deno and the compiled binary select a value; Node and Bun leave the default + * absence, which is what makes `` on those runtimes reach its ordinary + * missing-provider refusal rather than a half-configured host. + * + * It is CLI-private. No package member exports it and no selector names it: + * this is the host saying what an entry runs with. + */ + +import { createContext } from "effection"; +import type { Context } from "effection"; +import type { ExecutionInstallation } from "@executablemd/core/host"; + +/** + * What one entry's capability is built from: the owner of that entry's retained + * work. + * + * Called inside the entry task, once, for the entry about to execute. Calling + * it performs nothing — what it answers with is a declaration, an admission and + * an installation callback the execution runs in its own scope. + */ +export type ReplEntryInstallation = (owner: string) => ExecutionInstallation; + +/** The factory this host runs REPL entries with, or absence. */ +export const ReplEntryHost: Context = createContext< + ReplEntryInstallation | undefined +>("executablemd.cli.replEntryInstallation", undefined); diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index a4156294f..a037dd8e7 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -27,6 +27,8 @@ import { ordinaryGeneratedSelection } from "./evaluation-profile.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./plan-writer-profile.ts"; import type { CommandPlugins } from "./plugin-host.ts"; +import { ReplEntryHost } from "./repl-entry-installation.ts"; +import type { ReplEntryInstallation } from "./repl-entry-installation.ts"; /** * Where a REPL entry looks for components. @@ -77,6 +79,19 @@ export interface ReplExecutionProfile { readonly generated: GeneratedSelection; /** How this REPL answers an Agent permission request. */ readonly permissionMode: PermissionMode; + /** + * What each executing entry installs for itself, built per entry. + * + * The one thing here that is not a finished installation, because it cannot + * be: an entry's issue work is retained under an owner derived from the + * execution and the entry key, so the installation exists once that entry + * does. The factory is the host's selection, captured here with everything + * else this command decided once. + * + * Absent on a runtime that selected none, which is an entry with no issue + * host rather than one with a partly configured one. + */ + readonly entryInstallation?: ReplEntryInstallation; } /** @@ -119,6 +134,10 @@ export function* assembleReplProfile( installElicitation: noElicitationOfItsOwn, }); + // The host's selection, read once in the command scope. A value read per + // entry would be a value a later installation could have changed. + const entryInstallation = yield* ReplEntryHost.get(); + return Object.freeze({ includes: REPL_INCLUDES, installations: Object.freeze([ @@ -130,6 +149,7 @@ export function* assembleReplProfile( ]), generated: ordinaryGeneratedSelection(), permissionMode: stack.permissionMode, + ...(entryInstallation === undefined ? {} : { entryInstallation }), }); } diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 9e46e520c..c884e295a 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -208,6 +208,9 @@ export function* runReplProgram(options: ReplProgramOptions): Operation> { - const { execution, includes, installations = [], generated, selection, permissionMode } = options; + const { + execution, + includes, + installations = [], + generated, + selection, + permissionMode, + entryInstallation, + } = options; const stream = execution.stream; const events = yield* stream.readAll(); @@ -947,6 +965,14 @@ function* start( // provider work come down first, then this attachment discards whatever it // never transferred, and only then is the session's entry slot freed. const installation = yield* agent.owning(key); + // This entry's own external capability, built here and nowhere else, + // for the exact execution and entry key its retained work is named by. + // It is built before the execution captures its installations, because + // what a host attaches is copied by value before anything installs: + // an entry that got one afterwards would be running under a capability + // the execution never admitted. + const owned = + entryInstallation === undefined ? [] : [entryInstallation(entryOwner(execution.id, key))]; // This entry's own selection, offered in the scope this execution runs // in. Every entry installs it again: core reads the final contextual // answer once per execution, and a chain assembled in a scope that has @@ -972,7 +998,7 @@ function* start( // Installed into this exact execution, and nowhere else: the live // observer and the permission policy are this session's, not the // process's, and the attachment carries the entry whose turns they are. - [...installations, lifecycle.installation, installation], + [...installations, lifecycle.installation, installation, ...owned], { initialBindings }, ); // Consumed as it arrives, inside this session's scope. Collecting until @@ -1177,6 +1203,28 @@ function* preflight( : Ok(undefined); } +/** + * What one entry's retained external work is named by. + * + * The execution and the entry's admission-order key, and nothing else. Both + * survive a reopening — the execution id is in the printed location and the key + * is the position the history already holds — so an entry that was interrupted + * presents the same owner when it is continued, and the key an unfinished + * upsert was made under is the one its next attempt presents. + * + * Neither the source, a counter, the working directory, a tracker, a prop nor + * anything a document can write takes part in it: two entries that happen to + * hold the same text are two entries, and an entry of another execution is + * somebody else's. + * + * Derived here, in the session, because this is where both facts are known and + * where nothing composable can answer for them. It is handed to the host's + * factory as a string and appears in no Context, no prop and no request data. + */ +function entryOwner(execution: string, entry: string): string { + return canonicalJson(["executablemd.repl.issue-owner", 1, execution, entry]); +} + /** Every identity component these installations declare, in installation order. */ function declaredComponents( installations: readonly ExecutionInstallation[], diff --git a/packages/cli/tests/repl-issues-pty.test.ts b/packages/cli/tests/repl-issues-pty.test.ts new file mode 100644 index 000000000..5591a4f2b --- /dev/null +++ b/packages/cli/tests/repl-issues-pty.test.ts @@ -0,0 +1,49 @@ +/** + * The whole public behavior of an entry's issues, on a real terminal, from + * source (#181 E8). + * + * ## One suite, two behavior, by runtime + * + * Deno is where the entry issue host exists, so the Deno branch runs the + * journeys. Node and Bun assemble none — the Deno adapter's module graph + * reaches `node:sqlite`, which Bun has not — so their branch runs the other + * behavior that matters, which is that `` there reports no provider + * rather than reaching a transport nobody installed. Neither branch silently + * skips its own behavior. + * + * The journeys themselves are `support/repl-issues-journey.ts`, shared with the + * compiled row so the two cannot drift. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { cliRuntime, cliShellCommand } from "@executablemd/test-support/launch"; +import type { Operation } from "effection"; +import { + replIssuesAdoptionJourney, + replIssuesJourney, + replIssuesUnsupported, +} from "./support/repl-issues-journey.ts"; + +const JOURNEY = cliRuntime() === "deno"; + +/** How this suite invokes `xmd`: the production entrypoint for this runtime. */ +function sourceCommand(args: readonly string[]): string { + return cliShellCommand([...args]); +} + +describe("a REPL entry's issues, across leaving and coming back", () => { + it( + JOURNEY + ? "E8: a filed issue and the snapshot it read answer again at the printed location" + : "E8: this runtime resolves and reports that it has no provider", + function* (): Operation { + yield* JOURNEY ? replIssuesJourney(sourceCommand) : replIssuesUnsupported(sourceCommand); + }, + ); + + if (JOURNEY) { + it("E8: an accepted create this end never learned of is adopted, not repeated", function* (): Operation { + yield* replIssuesAdoptionJourney(sourceCommand); + }); + } +}); diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts new file mode 100644 index 000000000..5c06d59d5 --- /dev/null +++ b/packages/cli/tests/repl-issues.test.ts @@ -0,0 +1,1174 @@ +/** + * How a REPL entry gets its issues, what names them, and who never gets any. + * + * The entry host is the one thing this command prepares per *entry*, and an + * issue is the first thing it prepares that lives at a service rather than in a + * checkout. So what these rows hold is the pair of questions that follows from + * that: which work is this entry's, and what happens to work the entry started + * and never finished. + * + * Every row drives the production path — `submitReplEntry`, the real session, + * the real per-entry installation, the shipped GitHub adapter — against a + * tracker listening on loopback. The credential is the only substitution, + * because a suite cannot own the process environment the production reader + * looks at. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { exists, writeTextFile } from "@effectionx/fs"; +import { rm } from "@effectionx/fs"; +import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises"; +import { appendFile, open } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { content, hasContent } from "@executablemd/core"; +import type { Json } from "@executablemd/core"; +import { Component, Execution } from "@executablemd/core/api"; +import type { ComponentDefinition, FunctionComponentDefinition } from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; +import { gitSession } from "../../git/src/deno/composition/git.ts"; +import { useReplRepositoryRecording } from "../../git/src/deno/retained-repository/provider.ts"; +import type { GitOutcome, RepositoryHost } from "../../git/src/deno/composition/host.ts"; +import { gitPlugin } from "../../git/src/plugin.ts"; +import { ISSUE_EFFECT } from "../../git/src/issue/effect-type.ts"; +import { issueIdempotencyKey, parseIssueRequest } from "../../git/src/issue/records.ts"; +import { issueOriginMarker } from "../../git/src/deno/issue/github.ts"; +import { IssueOperations } from "../../git/src/issue/operations.ts"; +import type { + IssueOperationsApi, + IssueReadInvocation, + IssueUpsertInvocation, +} from "../../git/src/issue/operations.ts"; +import type { IssueDetails, IssueReference } from "../../git/src/issue/api.ts"; +import { replIssueInstallation } from "../../git/src/deno/retained-repository/issues.ts"; +import { gitHubSource } from "../../git/src/deno/composition/github.ts"; +import { useIssueTrackerServer } from "../../git/tests/support/issue-tracker-server.ts"; +import type { + IssueTrackerServer, + ServedIssue, +} from "../../git/tests/support/issue-tracker-server.ts"; +import { gitHubAccessFor } from "../../git/tests/support/issue-providers.ts"; +import type { GitHubIssuesOptions } from "@executablemd/git/deno"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; +import { useDenoReplRepositories } from "../src/deno-repositories.ts"; +import { ReplHost } from "../src/repl/host.ts"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplEntryInstallation } from "../src/repl-entry-installation.ts"; +import type { ReplExecution, RetainedStream } from "../src/repl/journal.ts"; + +/** The tracker every row files in, as a document writes it. */ +const TRACKER = "https://github.com/octo/project"; + +/** + * What a command contributes to an entry's execution. + * + * Taken from the Git Plugin's own contribution for the `repl` command rather + * than written out here, because that contribution is the production path. + */ +function* replInstallations(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installs nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + if (installed === undefined) { + throw new Error("the Git Plugin declared nothing for the repl command"); + } + return [{ admissions: [...(installed.admissions ?? [])] }]; +} + +interface Fixture { + readonly root: string; + readonly checkout: string; + readonly server: IssueTrackerServer; +} + +function useFixture( + issues: readonly ServedIssue[] = [], + options: { readonly hold?: () => Operation } = {}, +): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-issues-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const checkout = join(root, "checkout"); + const git = gitSession(denoRepositoryHost(), root); + yield* git.run(["init", "-b", "main", checkout], root); + yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); + const server = yield* useIssueTrackerServer({ + issues, + ...(options.hold === undefined ? {} : { hold: options.hold }), + }); + yield* provide({ root, checkout, server }); + }); +} + +/** The host half a runtime entrypoint owns, installed here by the suite. */ +function useNodeReplHost(name: string): Operation { + return ReplHost.around( + { + // deno-lint-ignore require-yield + *identify(): Operation { + return name; + }, + *createExclusive([path]: [string]): Operation { + const handle = yield* until(open(path, "wx")); + yield* until(handle.close()); + }, + *appendRecord([path, record]: [string, string]): Operation { + yield* until(appendFile(path, record)); + }, + }, + { at: "min" }, + ); +} + +/** Where every authored path in these entries resolves. */ +function useWorkingDirectory(directory: string): Operation { + return API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return directory; + }, + }, + { at: "min" }, + ); +} + +/** What the suite counts about one row, across every execution it runs. */ +interface Counted { + /** Each owner the session derived, in the order entries ran. */ + readonly owners: string[]; +} + +function counted(): Counted { + return { owners: [] }; +} + +/** + * The entry host, as a runtime entrypoint selects it — with the transport + * pointed at this row's loopback tracker. + * + * The owner is recorded on the way through, because what the session derives is + * the thing these rows are mostly about and it appears nowhere a document can + * see. + */ +function entryHost( + fixture: Fixture, + seen: Counted, + options: GitHubIssuesOptions = {}, +): ReplEntryInstallation { + return (owner: string) => { + seen.owners.push(owner); + return replIssueInstallation(owner, { + access: gitHubSource(gitHubAccessFor(fixture.server.url, { credential: "valid" })), + configuration: { ceiling: [TRACKER] }, + ...options, + }); + }; +} + +interface Opened { + readonly session: ReplSession; +} + +function opened(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** Wait until this session is holding a question. */ +function* waitForQuestion(session: ReplSession): Operation { + const changes = yield* session.elicitation.changes; + if (session.elicitation.pending !== undefined) { + return; + } + yield* changes.next(); +} + +function execution(id: string, events: readonly DurableEvent[] = []): ReplExecution { + return { id, stream: new InMemoryStream([...events]) }; +} + +/** What one row's execution did, as its assertions read it. */ +interface Ran { + readonly events: readonly DurableEvent[]; + readonly outcome: Result | undefined; +} + +interface RunOptions { + /** The entry host this run selects, or none at all. */ + readonly host?: ReplEntryInstallation; + /** Where authored paths resolve, when not the fixture's checkout. */ + readonly cwd?: string; + /** Extra host installations, for the rows about public middleware. */ + readonly installations?: readonly ExecutionInstallation[]; + /** Where components are looked for. */ + readonly includes?: readonly string[]; + /** Install the production repository recording, as the REPL host does. */ + readonly repositories?: string; + /** Leave at the first question instead of running to settlement. */ + readonly leave?: boolean; +} + +/** One submitted entry, run against this execution's history. */ +function submit( + fixture: Fixture, + source: string, + run: ReplExecution, + options: RunOptions = {}, +): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(options.cwd ?? fixture.checkout); + if (options.repositories !== undefined) { + yield* useDenoReplRepositories( + { runtime: "source", platform: "unix", execPath: "/nonexistent/xmd" }, + options.repositories, + ); + } + const installations = [...(yield* replInstallations()), ...(options.installations ?? [])]; + const session = opened( + yield* submitReplEntry({ + execution: run, + source, + installations, + generated: ordinaryGeneratedSelection(), + ...(options.includes === undefined ? {} : { includes: options.includes }), + ...(options.host === undefined ? {} : { entryInstallation: options.host }), + }), + ); + if (options.leave === true) { + yield* waitForQuestion(session); + // This scope's teardown halts the entry, which is what leaving one is. + return { events: yield* run.stream.readAll(), outcome: undefined }; + } + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** One reopened session over a history, run to settlement or left again. */ +function reopen(fixture: Fixture, run: ReplExecution, options: RunOptions = {}): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(options.cwd ?? fixture.checkout); + if (options.repositories !== undefined) { + yield* useDenoReplRepositories( + { runtime: "source", platform: "unix", execPath: "/nonexistent/xmd" }, + options.repositories, + ); + } + const installations = [...(yield* replInstallations()), ...(options.installations ?? [])]; + const session = opened( + yield* openReplSession({ + execution: run, + installations, + generated: ordinaryGeneratedSelection(), + ...(options.includes === undefined ? {} : { includes: options.includes }), + ...(options.host === undefined ? {} : { entryInstallation: options.host }), + }), + ); + if (options.leave === true) { + yield* waitForQuestion(session); + return { events: yield* run.stream.readAll(), outcome: undefined }; + } + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** Every Issue record this history holds, in order. */ +function issueRecords(events: readonly DurableEvent[]): readonly DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === ISSUE_EFFECT, + ); +} + +/** + * Every settled Issue record, which is what an acknowledged outcome is. + * + * A refused operation retains a record too — its outcome is the durable + * operation's failure — so "nothing was acknowledged" is a question about the + * successful ones. + */ +function settledIssueRecords(events: readonly DurableEvent[]): readonly DurableEvent[] { + return issueRecords(events).filter( + (event) => event.type === "yield" && event.result.status === "ok", + ); +} + +/** The identity every Issue record in this history was written under. */ +function identities(events: readonly DurableEvent[]): readonly string[] { + return issueRecords(events).map((event) => { + const request = + event.type === "yield" ? parseIssueRequest(event.description["request"]) : undefined; + return request === undefined + ? "" + : `${request.identity.runId}\u0000${request.identity.expansionId}`; + }); +} + +/** The URL every settled Issue record in this history holds. */ +function urls(events: readonly DurableEvent[]): readonly unknown[] { + return issueRecords(events).map((event) => + event.type === "yield" && event.result.status === "ok" + ? Reflect.get(Object(event.result.value), "url") + : undefined, + ); +} + +/** The issue bodies this tracker holds, which is where a key leaves its mark. */ +function bodies(server: IssueTrackerServer): readonly string[] { + return server.issues.map((issue) => issue.body ?? ""); +} + +/** Every request path this tracker received, in order. */ +function paths(server: IssueTrackerServer): readonly string[] { + return server.requests.map((request) => `${request.method} ${request.path}`); +} + +/** A seeded issue, for the rows that read one. */ +const SEEDED = { + number: 1, + state: "open" as const, + title: "Seeded", + body: "what the tracker already held", + labels: [] as string[], + assignee: null, +}; + +/** What one observing middleware saw and did. */ +interface Chain { + /** Each public invocation this middleware answered, in order. */ + readonly calls: string[]; +} + +/** + * Ordinary public middleware, installed as a host installation. + * + * It transforms the issue it is handed and delegates once, which is what the + * seam is for — and what must not move is the identity the record is named by + * or the key the provider is handed. + */ +function observingChain(chain: Chain, transformed: string): ExecutionInstallation { + return { + *install(): Operation { + yield* IssueOperations.around({ + *read([invocation], next): Operation { + chain.calls.push(`read ${invocation.url}`); + return yield* next(invocation); + }, + *upsert([invocation], next): Operation { + chain.calls.push(`upsert ${invocation.target}`); + return yield* next({ + ...invocation, + issue: { ...invocation.issue, title: transformed }, + }); + }, + }); + }, + }; +} + +/** What a region's components were told about their own shape. */ +interface Probe { + readonly paired: boolean[]; +} + +/** + * A region whose components are told the opposite of how they were written. + * + * Installed as a component rather than for the whole execution, because + * `` reads the same answer to decide whether it has a region to + * expand at all: reversing it everywhere would refuse the tracker before any + * `` ran. `` is inside the region so the lie is observable rather + * than assumed — a row that reversed nothing would otherwise pass. + */ +function reversedRegion(probe: Probe): ExecutionInstallation { + // Declared rather than registered inside `install()`: an entry is validated + // against what its installations declare before anything runs, so a name only + // a scope registration knows about does not resolve at all. + return { + components: [ + { + name: "Reversed", + origin: PROBE_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + factory: () => + function* Reversed(): Operation { + yield* Component.around({ + *hasContent(_args, next): Operation { + return !(yield* next()); + }, + }); + return yield* content(); + }, + }, + { + name: "Shape", + origin: PROBE_ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + factory: () => + function* Shape(): Operation { + probe.paired.push(yield* hasContent()); + return null; + }, + }, + ], + }; +} + +/** Where this suite's own probe components report they come from. */ +const PROBE_ORIGIN = "@executablemd/cli/tests/repl-issues"; + +/** The Issue definition this execution resolved, captured on the way past. */ +interface Captured { + definition: ComponentDefinition | FunctionComponentDefinition | undefined; +} + +function capturingImport(captured: Captured): ExecutionInstallation { + return { + *install(): Operation { + yield* Component.around({ + *importComponent( + [name, position], + next, + ): Operation { + const definition = yield* next(name, position); + if (name === "Issue") { + captured.definition = definition; + } + return definition; + }, + }); + }, + }; +} + +/** + * A journal whose append can be held, which is where an interruption lands. + * + * The window these rows are about is between a service accepting an upsert and + * this end writing that down: an attempt interrupted there has done the remote + * work and acknowledged none of it. Holding the append is how a row stands in + * that window deliberately rather than racing for it. + */ +interface HeldStream extends RetainedStream { + /** What actually reached the journal, which is what a reopen reads. */ + readonly events: DurableEvent[]; +} + +function heldStream( + hold: (event: DurableEvent) => boolean, + reached: { resolve(value: void): void }, + retained: readonly DurableEvent[] = [], +): HeldStream { + const events: DurableEvent[] = [...retained]; + const held: HeldStream = { + onAppend: null, + // deno-lint-ignore require-yield + *readAll(): Operation { + return [...events]; + }, + *append(event: DurableEvent): Operation { + if (hold(event)) { + reached.resolve(); + // Held for as long as this entry lives. Whoever is holding it decides + // when the entry stops, and the record never lands. + yield* suspend(); + } + events.push(event); + held.onAppend?.(event); + }, + events, + }; + return held; +} + +/** A journal that refuses to retain this entry's Issue record. */ +function refusingStream(): HeldStream { + const events: DurableEvent[] = []; + const refusing: HeldStream = { + onAppend: null, + // deno-lint-ignore require-yield + *readAll(): Operation { + return [...events]; + }, + // deno-lint-ignore require-yield + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === ISSUE_EFFECT) { + throw new Error("this journal cannot retain what the entry just did"); + } + events.push(event); + refusing.onAppend?.(event); + }, + events, + }; + return refusing; +} + +/** Whether this event is an Issue record of the given operation. */ +function issueAppend(operation: "read" | "upsert"): (event: DurableEvent) => boolean { + return (event: DurableEvent) => { + if (event.type !== "yield" || event.description.type !== ISSUE_EFFECT) { + return false; + } + return parseIssueRequest(event.description["request"])?.operation === operation; + }; +} + +/** How many times a repository capability was acquired, which must be never. */ +interface Acquisitions { + count: number; +} + +/** + * A repository host that fails the moment anything is acquired through it. + * + * Issue work reaches no checkout, so every one of these is a failure rather + * than a cost: a row installing this and still filing an issue has shown that + * nothing about an entry's issues goes looking for a repository. + */ +function failingHost(acquired: Acquisitions): RepositoryHost { + return { + // deno-lint-ignore require-yield + *git(): Operation { + acquired.count += 1; + throw new Error("this row's host runs no git"); + }, + // deno-lint-ignore require-yield + *useDirectory(): Operation { + acquired.count += 1; + throw new Error("this row's host owns no isolation directory"); + }, + }; +} + +/** The production entry recording, over a host that refuses to be acquired. */ +function failingRecording(acquired: Acquisitions, root: string): Operation { + return Execution.around({ + *init(_args, next): Operation { + yield* useReplRepositoryRecording({ host: failingHost(acquired), root }); + yield* next(); + }, + }); +} + +/** One entry that files one issue, and one that leaves a question after it. */ +const FILES_ONE = `a described issue`; + +const QUESTION = + 'Continue?'; + +describe("how a REPL entry gets its issues", () => { + it("E1: composes public middleware once, under the key the request itself names", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const chain: Chain = { calls: [] }; + + const ran = yield* submit(fixture, FILES_ONE, execution("execution-1"), { + host: entryHost(fixture, seen), + installations: [observingChain(chain, "Transformed")], + }); + + expect(ran.outcome?.ok).toBe(true); + // Once. A second delegation would be a second attempt at one position. + expect(chain.calls).toEqual([`upsert ${TRACKER}`]); + // What the provider was sent is what the middleware transformed. + expect(fixture.server.issues.map((issue) => issue.title)).toEqual(["Transformed"]); + // What the journal holds is the request as the document made it: a + // middleware that rewrote the issue did not rewrite the durable identity. + const record = issueRecords(ran.events)[0]; + const asked = + record?.type === "yield" ? parseIssueRequest(record.description["request"]) : undefined; + expect(asked?.operation).toBe("upsert"); + expect(asked?.operation === "upsert" ? asked.issue.title : "").toBe("Filed"); + // And the mark the provider carried is the one this record's own identity + // and the authored target produce — not anything the middleware supplied. + expect(bodies(fixture.server)[0]).toContain( + issueOriginMarker( + issueIdempotencyKey( + asked === undefined ? { runId: "", expansionId: "" } : asked.identity, + "upsert", + TRACKER, + ), + ), + ); + }); + + it("E1: a nested read finishes at its own position, before the upsert around it", function* (): Operation { + const fixture = yield* useFixture([SEEDED]); + const seen = counted(); + const source = [ + ``, + ``, + ``, + "a described issue", + "", + "", + ].join("\n"); + + const ran = yield* submit(fixture, source, execution("execution-1"), { + host: entryHost(fixture, seen), + }); + + expect(ran.outcome?.ok).toBe(true); + const asked = issueRecords(ran.events).map((event) => + event.type === "yield" + ? parseIssueRequest(event.description["request"])?.operation + : undefined, + ); + // One envelope per Issue, and the child's is complete before the parent's + // is offered: the content is rendered outside the upsert's boundary. + expect(asked).toEqual(["read", "upsert"]); + }); + + it("E2: keeps one site's key across a reopen, and gives every other site its own", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const two = [ + ``, + 'the first issue', + 'the second issue', + "", + QUESTION, + ].join("\n"); + + const first = execution("execution-1"); + const left = yield* submit(fixture, two, first, { + host: entryHost(fixture, seen), + leave: true, + }); + + // Two sites, two issues, two different marks. + expect(fixture.server.issues.length).toBe(2); + const marks = bodies(fixture.server); + expect(marks[0]).not.toBe(marks[1]); + expect(new Set(identities(left.events)).size).toBe(2); + + // Reopened: the same unfinished entry, the same execution, the same sites. + // Nothing is filed again and no mark changes. + const posts = paths(fixture.server).filter((path) => path.startsWith("POST")); + const again = yield* reopen( + fixture, + { id: first.id, stream: first.stream }, + { + host: entryHost(fixture, seen), + leave: true, + }, + ); + expect(paths(fixture.server).filter((path) => path.startsWith("POST"))).toEqual(posts); + expect(bodies(fixture.server)).toEqual(marks); + expect(identities(again.events)).toEqual(identities(left.events)); + + // Another execution's first entry, with identical text: its own owner, its + // own sites, its own issues. + const other = execution("execution-2"); + yield* submit(fixture, two, other, { host: entryHost(fixture, seen), leave: true }); + expect(fixture.server.issues.length).toBe(4); + const everything = bodies(fixture.server); + expect(new Set(everything).size).toBe(4); + expect( + new Set([...identities(again.events), ...identities(yield* other.stream.readAll())]).size, + ).toBe(4); + }); + + it("E2: a successor entry of one execution is not the entry before it", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-1"); + + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + const session = opened( + yield* submitReplEntry({ + execution: run, + source: FILES_ONE, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, seen), + }), + ); + expect((yield* session.join()).ok).toBe(true); + // The same text again, as the next entry of the same execution. One + // entry's retained work is not an answer to another's. + const admitted = yield* session.submit(FILES_ONE); + expect(admitted.ok).toBe(true); + expect((yield* session.join()).ok).toBe(true); + }); + + expect(seen.owners.length).toBe(2); + expect(seen.owners[0]).not.toBe(seen.owners[1]); + expect(fixture.server.issues.length).toBe(2); + expect(new Set(bodies(fixture.server)).size).toBe(2); + }); + + it("E2: no replaceable answer moves the owner, the site or the key", function* (): Operation { + const fixture = yield* useFixture(); + const plain = yield* submit(fixture, FILES_ONE, execution("execution-1"), { + host: entryHost(fixture, counted()), + }); + expect(plain.outcome?.ok).toBe(true); + + // The same entry of a second execution, with a middleware that rewrites + // the issue it is handed. What it sends changes and what names the work + // does not: the site is the same site, and only the owner differs, because + // the execution does. + const second = yield* submit(fixture, FILES_ONE, execution("execution-2"), { + host: entryHost(fixture, counted()), + installations: [observingChain({ calls: [] }, "Rewritten")], + }); + expect(second.outcome?.ok).toBe(true); + + const site = (events: readonly DurableEvent[]): string | undefined => + identities(events)[0]?.split("\u0000")[1]; + expect(site(second.events)).toBe(site(plain.events)); + expect(identities(second.events)[0]).not.toBe(identities(plain.events)[0]); + }); + + it("E2: the authored form selects the effect, whatever the chain answers", function* (): Operation { + const fixture = yield* useFixture([SEEDED]); + const probe: Probe = { paired: [] }; + const source = [ + ``, + "", + "written with content", + ``, + 'a described issue', + "", + "", + ].join("\n"); + + const ran = yield* submit(fixture, source, execution("execution-1"), { + host: entryHost(fixture, counted()), + installations: [reversedRegion(probe)], + }); + + expect(ran.outcome?.ok).toBe(true); + // The chain really is answering the opposite: a paired element was told it + // was written self-closing. + expect(probe.paired).toEqual([false]); + const asked = issueRecords(ran.events).map((event) => + event.type === "yield" + ? parseIssueRequest(event.description["request"])?.operation + : undefined, + ); + expect(asked).toEqual(["read", "upsert"]); + }); + + it("E2: an invocation the engine did not issue names nothing", function* (): Operation { + const fixture = yield* useFixture(); + const captured: Captured = { definition: undefined }; + const ran = yield* submit(fixture, FILES_ONE, execution("execution-1"), { + host: entryHost(fixture, counted()), + installations: [capturingImport(captured)], + }); + expect(ran.outcome?.ok).toBe(true); + + const fn = Reflect.get(Object(captured.definition), "fn"); + if (typeof fn !== "function") { + throw new Error("the entry's Issue definition carries no implementation to call"); + } + const before = paths(fixture.server).length; + let refusal: string | undefined; + try { + // A definition a handler kept, called with an object of the right shape. + // There is no shape a forger cannot copy, which is why what answers here + // is the issuance the engine holds rather than anything handed in. + yield* fn({ title: "Forged" }, { hasContent: () => true }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal ?? "").toContain("without the invocation the engine issued"); + expect(paths(fixture.server).length).toBe(before); + }); + + it("E3: adopts an upsert the tracker accepted and this end never wrote down", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const reached = withResolvers(); + const stream = heldStream(issueAppend("upsert"), reached); + const run: ReplExecution = { id: "execution-1", stream }; + + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + opened( + yield* submitReplEntry({ + execution: run, + source: FILES_ONE, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, seen), + }), + ); + // Held exactly where the interesting state is: the tracker has the issue + // and this journal has nothing about it. + yield* reached.operation; + }); + + expect(fixture.server.issues.length).toBe(1); + const mark = bodies(fixture.server)[0]; + expect(issueRecords(stream.events).length).toBe(0); + + // Reopened. The same execution and the same entry, so the same owner and + // the same key — and the adapter finds its own mark and adopts rather than + // filing a second issue. + const posts = paths(fixture.server).filter((path) => path.startsWith("POST")); + const again = yield* reopen( + fixture, + { id: run.id, stream: new InMemoryStream(stream.events) }, + { host: entryHost(fixture, seen) }, + ); + + expect(again.outcome?.ok).toBe(true); + expect(fixture.server.issues.length).toBe(1); + expect(bodies(fixture.server)[0]).toBe(mark); + expect(paths(fixture.server).filter((path) => path.startsWith("POST"))).toEqual(posts); + expect(urls(again.events)).toEqual([`${TRACKER}/issues/1`]); + expect(seen.owners[0]).toBe(seen.owners[1]); + }); + + it("E3: an acknowledged upsert replays with no provider call at all", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const source = [FILES_ONE, QUESTION].join("\n"); + const run = execution("execution-1"); + + const left = yield* submit(fixture, source, run, { + host: entryHost(fixture, seen), + leave: true, + }); + expect(issueRecords(left.events).length).toBe(1); + const before = paths(fixture.server).length; + + const again = yield* reopen( + fixture, + { id: run.id, stream: run.stream }, + { + host: entryHost(fixture, seen), + leave: true, + }, + ); + + // The control: an acknowledged record is replayed, and the provider is not + // reached at all — not to observe, not to adopt, not to confirm. + expect(paths(fixture.server).length).toBe(before); + expect(issueRecords(again.events).length).toBe(1); + }); + + it("E3: an unacknowledged read is repeated, and repeating it is safe", function* (): Operation { + const fixture = yield* useFixture([SEEDED]); + const seen = counted(); + const reached = withResolvers(); + const stream = heldStream(issueAppend("read"), reached); + const run: ReplExecution = { id: "execution-1", stream }; + const source = ``; + + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + opened( + yield* submitReplEntry({ + execution: run, + source, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, seen), + }), + ); + yield* reached.operation; + }); + + const observed = paths(fixture.server).filter((path) => path.startsWith("GET")); + expect(observed.length).toBeGreaterThan(0); + expect(issueRecords(stream.events).length).toBe(0); + + const again = yield* reopen( + fixture, + { id: run.id, stream: new InMemoryStream(stream.events) }, + { host: entryHost(fixture, seen) }, + ); + + // A read reconciles nothing and has no key, so repeating it is an ordinary + // second observation of the same object rather than a second effect. + expect(again.outcome?.ok).toBe(true); + expect(paths(fixture.server).filter((path) => path.startsWith("GET")).length).toBeGreaterThan( + observed.length, + ); + expect(fixture.server.issues.length).toBe(1); + expect(issueRecords(again.events).length).toBe(1); + }); + + it("E4: files an issue outside a checkout without acquiring one", function* (): Operation { + const fixture = yield* useFixture(); + const acquired: Acquisitions = { count: 0 }; + const outside = join(fixture.root, "outside"); + yield* until(writeFile(join(fixture.root, "keep.md"), "keep\n")); + const managed = join(fixture.root, "repositories"); + + const ran = yield* scoped(function* (): Operation { + yield* failingRecording(acquired, managed); + return yield* submit(fixture, FILES_ONE, execution("execution-1"), { + host: entryHost(fixture, counted()), + cwd: fixture.root, + }); + }); + + expect(ran.outcome?.ok).toBe(true); + expect(urls(ran.events)).toEqual([`${TRACKER}/issues/1`]); + // Nothing was acquired: no git session, no isolation directory, no managed + // root, and no lease. + expect(acquired.count).toBe(0); + expect(yield* exists(managed)).toBe(false); + expect(yield* exists(outside)).toBe(false); + }); + + it("E4: behaves the same inside a checkout, and still makes no managed root", function* (): Operation { + const fixture = yield* useFixture(); + const acquired: Acquisitions = { count: 0 }; + const managed = join(fixture.root, "repositories"); + + const ran = yield* scoped(function* (): Operation { + yield* failingRecording(acquired, managed); + return yield* submit(fixture, FILES_ONE, execution("execution-1"), { + host: entryHost(fixture, counted()), + cwd: fixture.checkout, + }); + }); + + expect(ran.outcome?.ok).toBe(true); + expect(urls(ran.events)).toEqual([`${TRACKER}/issues/1`]); + expect(acquired.count).toBe(0); + expect(yield* exists(managed)).toBe(false); + }); + + it("E5: sends nothing at all to a target this host did not authorize", function* (): Operation { + const fixture = yield* useFixture(); + const elsewhere = + 'a described issue'; + + const ran = yield* submit(fixture, elsewhere, execution("execution-1"), { + host: entryHost(fixture, counted()), + }); + + expect(ran.outcome?.ok).toBe(false); + // No request, so no credential either: the ceiling is asked before a + // session is opened and before anything is built. + expect(paths(fixture.server)).toEqual([]); + // The refusal is retained as this position's failed outcome, and nothing + // about it is an acknowledged issue. + expect(settledIssueRecords(ran.events).length).toBe(0); + }); + + it("E5: a settled history installs no issue host and reaches nothing", function* (): Operation { + const fixture = yield* useFixture(); + const run = execution("execution-1"); + const ran = yield* submit(fixture, FILES_ONE, run, { host: entryHost(fixture, counted()) }); + expect(ran.outcome?.ok).toBe(true); + const before = paths(fixture.server).length; + + // Reopened as a reader: the entry settled, so nothing executes, the factory + // is never called and the tracker never hears from this process again. + const passive = counted(); + yield* reopen( + fixture, + { id: run.id, stream: run.stream }, + { + host: entryHost(fixture, passive), + }, + ); + + expect(passive.owners).toEqual([]); + expect(paths(fixture.server).length).toBe(before); + }); + + it("E6: leaving a blocked request acknowledges nothing and admits no successor", function* (): Operation { + const blocked = withResolvers(); + const arrived = withResolvers(); + // Counted in the hold, because the server records a request only after it + // has answered one: while this row is standing in the window, the request + // it is holding is not in that list yet. + const holding = { count: 0 }; + const fixture = yield* useFixture([], { + *hold(): Operation { + holding.count += 1; + arrived.resolve(); + yield* blocked.operation; + }, + }); + const run = execution("execution-1"); + + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + const session = opened( + yield* submitReplEntry({ + execution: run, + source: FILES_ONE, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, counted()), + }), + ); + yield* arrived.operation; + // While the request is in flight, nothing may follow this entry: an + // entry with no outcome is not something a successor starts after. + const admitted = yield* session.submit(FILES_ONE); + expect(admitted.ok).toBe(false); + }); + + // The request was really in flight, and leaving acknowledged none of it. + expect(holding.count).toBeGreaterThan(0); + expect(settledIssueRecords(yield* run.stream.readAll()).length).toBe(0); + }); + + it("E6: a journal that cannot retain the result allows no suffix effect", function* (): Operation { + const fixture = yield* useFixture(); + const stream = refusingStream(); + const source = [FILES_ONE, 'continued'].join("\n"); + + const ran = yield* submit( + fixture, + source, + { id: "execution-1", stream }, + { + host: entryHost(fixture, counted()), + }, + ); + + expect(ran.outcome?.ok).toBe(false); + // The tracker did the work and this end could not write it down, which is + // fail-stop: no acknowledged success, and nothing after it ran. + expect(fixture.server.issues.length).toBe(1); + expect(issueRecords(stream.events).length).toBe(0); + expect(yield* exists(join(fixture.checkout, "after.md"))).toBe(false); + }); + + it("E2: a repository component named Issue keeps its ordinary precedence", function* (): Operation { + const fixture = yield* useFixture(); + const local = join(fixture.root, "local"); + yield* until(mkdir(local, { recursive: true })); + + // Neither form: the entry's own implementation refuses this, which is what + // makes the next run's success evidence that a different component ran. + const refused = yield* submit(fixture, '', execution("execution-1"), { + host: entryHost(fixture, counted()), + includes: [local], + }); + expect(refused.outcome?.ok).toBe(false); + expect(String(refused.outcome?.ok === false ? refused.outcome.error.message : "")).toContain( + "either reads an issue", + ); + + yield* writeTextFile(join(local, "Issue.md"), "a repository component ran\n"); + const overridden = yield* submit(fixture, '', execution("execution-2"), { + host: entryHost(fixture, counted()), + includes: [local], + }); + + // A repository file outranks a registered default, and this one is a + // default — so the entry's implementation is not reached, files nothing and + // retains nothing. + expect(overridden.outcome?.ok).toBe(true); + expect(issueRecords(overridden.events).length).toBe(0); + expect(paths(fixture.server)).toEqual([]); + }); + + it("E5: a frozen position installs no issue host and reaches nothing", function* (): Operation { + const fixture = yield* useFixture(); + const run = execution("execution-1"); + const passive = counted(); + + const marker = yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + const session = opened( + yield* submitReplEntry({ + execution: run, + source: FILES_ONE, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, counted()), + }), + ); + expect((yield* session.join()).ok).toBe(true); + const checkpoint = session.navigation.checkpoints[0]; + if (checkpoint === undefined) { + throw new Error("this history offers no position to stand at"); + } + return checkpoint.marker; + }); + + const before = paths(fixture.server).length; + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = yield* replInstallations(); + opened( + yield* openReplSession({ + execution: run, + installations, + generated: ordinaryGeneratedSelection(), + selection: marker, + entryInstallation: entryHost(fixture, passive), + }), + ); + }); + + // A reader standing at a recorded position executes nothing, so the + // factory is never called and the tracker never hears from this process. + expect(passive.owners).toEqual([]); + expect(paths(fixture.server).length).toBe(before); + }); + + it("E2: shadows the inherited default, and refuses where no host selected one", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + + // No entry host: `` is still declared by the Plugin for this + // command, and reaching it is the ordinary missing-provider refusal. + const without = yield* submit(fixture, FILES_ONE, execution("execution-1")); + expect(without.outcome?.ok).toBe(false); + expect(String(without.outcome?.ok === false ? without.outcome.error.message : "")).toContain( + "no Issue provider is installed", + ); + expect(issueRecords(without.events).length).toBe(0); + expect(paths(fixture.server)).toEqual([]); + + // With one, the entry's own implementation answers instead — one retained + // record, and one issue at the tracker. + const with_ = yield* submit(fixture, FILES_ONE, execution("execution-2"), { + host: entryHost(fixture, seen), + }); + expect(with_.outcome?.ok).toBe(true); + expect(issueRecords(with_.events).length).toBe(1); + expect(urls(with_.events)).toEqual([`${TRACKER}/issues/1`]); + expect(seen.owners.length).toBe(1); + }); +}); diff --git a/packages/cli/tests/support/repl-issues-journey.ts b/packages/cli/tests/support/repl-issues-journey.ts new file mode 100644 index 000000000..775efce3a --- /dev/null +++ b/packages/cli/tests/support/repl-issues-journey.ts @@ -0,0 +1,334 @@ +/** + * The one real-terminal journey an entry's issues rest on. + * + * Shared by two rows that differ in exactly one thing: which `xmd` runs. The + * Deno source row proves the entry host this repository builds; the compiled + * row proves the binary carries it. Writing the journey twice would let the two + * drift, and a compiled row asserting something slightly weaker than the source + * row is how a packaging defect survives. + * + * Every other row of this feature's evidence installs its own host, which is + * what makes those rows portable and deterministic. None of them can show the + * thing a person actually does: type an entry that files an issue, read it + * back, walk away at a question, and come back to the printed location. So this + * launches the production entrypoint on a pty against a tracker listening on + * loopback, and reads the answer off the screen and off the tracker. + * + * The credential and the endpoint arrive as the two environment variables an + * operator would write. Nothing else is substituted: the adapter, the entry + * host, the identity and the journal are the shipped ones. + */ + +import { expect } from "@executablemd/test-support/expect"; +import { cliRuntime, shellQuote } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { scoped, sleep, withResolvers } from "effection"; +import { exists, readTextFile } from "@effectionx/fs"; +import { join } from "node:path"; +import type { Operation } from "effection"; +import { useIssueTrackerServer } from "../../../git/tests/support/issue-tracker-server.ts"; +import type { IssueTrackerServer } from "../../../git/tests/support/issue-tracker-server.ts"; +import { credential } from "../../../git/tests/support/issue-tracker-server.ts"; +import { allocate, showing } from "./pty.ts"; +import type { Pty } from "./pty.ts"; + +/** + * How long reaching the screen may take, and how long one interaction may take. + * + * Reaching the screen compiles or loads an entrypoint, which is slow and + * uninteresting. Everything after that is the subject. + */ +const REACH_MS = 180_000; +const ACT_MS = 60_000; + +/** How this row's `xmd` is invoked, as one shell command. */ +export type ReplCommand = (args: readonly string[]) => string; + +/** The tracker a document writes, which is a github.com name and not the socket. */ +const TRACKER = "https://github.com/octo/project"; + +/** + * The entry this journey types. + * + * One upsert, one read of what the upsert produced, the two facts a person came + * for, a question nobody has answered yet, and a write after it. The read is + * what makes the reopen observable: its binding is a record, and the tracker + * is not asked again for it. + */ +const ENTRY = [ + ``, + 'what this journey asked for', + '', + "", + "filed at {found.url}", + "titled {found.title}", + 'Recorded issue: {found.url}. Continue?', + 'continued', +].join("\n"); + +/** Wait until this file holds this exact text. */ +function* holding(path: string, content: string): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + if ((yield* exists(path)) && (yield* readTextFile(path)) === content) { + return; + } + if (Date.now() > deadline) { + const found = (yield* exists(path)) ? yield* readTextFile(path) : ""; + throw new Error( + `${path} never held ${JSON.stringify(content)}; it holds ${JSON.stringify(found)}`, + ); + } + yield* sleep(200); + } +} + +/** Walk the focus ring until this control is the marked one. */ +function* reach(pty: Pty, label: string): Operation { + const deadline = Date.now() + ACT_MS; + while (!(yield* pty.shows()).some((line) => line.includes(`> ${label}`))) { + if (Date.now() > deadline) { + throw new Error( + `focus never reached ${label} under ${cliRuntime()}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* pty.type("\t"); + yield* sleep(250); + } +} + +/** + * Activate the control holding this text with a pointer. + * + * The focus ring inside the question drawer does not stop on an enum's offered + * values: they are drawn and pointable, and a pointer is how a keyboard-only + * harness reaches one. + */ +function* click(pty: Pty, text: string): Operation { + const screen = yield* pty.shows(); + const row = screen.findIndex((line) => line.includes(text)); + if (row < 0) { + throw new Error( + `nothing on this screen holds ${JSON.stringify(text)} to click. screen=` + + JSON.stringify(screen.map((one) => one.trimEnd())), + ); + } + const column = (screen[row] ?? "").indexOf(text) + 1; + yield* pty.type(`\u001b[<0;${column};${row + 1}M`); + yield* pty.type(`\u001b[<0;${column};${row + 1}m`); + yield* sleep(300); +} + +/** Wait for the command to end, and answer with its status. */ +function* ended(pty: Pty): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const status = yield* pty.status(); + if (status !== undefined) { + return status; + } + if (Date.now() > deadline) { + throw new Error( + `the command never finished under ${cliRuntime()}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* sleep(150); + } +} + +/** The one location this command printed for a caller to come back with. */ +function printedLocation(screen: string): string { + const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); + if (found === null || found[1] === undefined) { + throw new Error( + `this command printed no reopenable location. screen=${JSON.stringify(screen)}`, + ); + } + return found[1]; +} + +/** How a process reaches this tracker: the operator's two variables. */ +function configured(server: IssueTrackerServer, token: string): readonly string[] { + return [ + `export XMD_WORKFLOW_GITHUB_ISSUES=${shellQuote( + JSON.stringify({ ceiling: [TRACKER], endpoint: server.url }), + )}`, + `export GH_TOKEN=${shellQuote(token)}`, + ]; +} + +/** Every POST this tracker received, which is where a second issue would show. */ +function creates(server: IssueTrackerServer): number { + return server.requests.filter((request) => request.method === "POST").length; +} + +/** + * File an issue, leave at a question, and come back to the printed location. + * + * The reopened process carries a credential this tracker rejects, so any live + * request it made would fail where the screen can see it. What it shows instead + * is the issue it already filed and the snapshot it already read. + */ +export function replIssuesJourney(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-issues-pty-"); + const server = yield* useIssueTrackerServer(); + const inside = (args: readonly string[], token: string): string => + [...configured(server, token), `cd ${shellQuote(home)}`, repl(args)].join("; "); + + const first = yield* allocate(inside(["repl", "--deny-all"], credential()), home); + yield* showing(first, "[exit]", REACH_MS); + + // Typed as a person types it: one paste carrying newlines, then Enter. + yield* first.type(ENTRY); + yield* sleep(500); + yield* first.type("\r"); + + // The prefix ran: one issue was filed, and the two facts the document + // asked for are on the screen. + yield* showing(first, `filed at ${TRACKER}/issues/1`, ACT_MS); + yield* showing(first, "titled A journey issue", ACT_MS); + expect(server.issues.length).toBe(1); + expect(creates(server)).toBe(1); + const mark = server.issues[0]?.body ?? ""; + + // And the write after the question has not happened. + yield* showing(first, "waiting for an answer", ACT_MS); + + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + + // Reopened with a credential this tracker refuses. A replayed upsert or + // read would reach it and fail; what the entry holds is already retained. + const second = yield* allocate( + inside(["repl", "--deny-all", location], "a-credential-this-tracker-does-not-accept"), + home, + ); + yield* showing(second, "[exit]", REACH_MS); + yield* showing(second, "waiting for an answer", ACT_MS); + expect(creates(server)).toBe(1); + expect(yield* exists(join(home, "after.md"))).toBe(false); + + // Answered through the real drawer, which is where the prompt is rendered. + // What it carries is the URL the completed read bound — from a record, in a + // process whose credential this tracker rejects. + yield* reach(second, "[answer]"); + yield* second.type("\r"); + yield* showing(second, `Recorded issue: ${TRACKER}/issues/1`, ACT_MS); + yield* click(second, "( ) continue"); + yield* showing(second, "[ok] entry-1", ACT_MS); + + // One new suffix effect, executed on the answer and nothing before it: the + // issue is the one issue, with the mark it was filed under untouched. + yield* holding(join(home, "after.md"), "continued"); + expect(server.issues.length).toBe(1); + expect(server.issues[0]?.body ?? "").toBe(mark); + expect(creates(server)).toBe(1); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + }); +} + +/** + * Leave while the tracker is holding an accepted create, and come back. + * + * The one state a reconciliation key exists for: the issue is filed and this + * end never learned of it, so the next attempt has to recognize its own mark + * rather than file a second one. + */ +export function replIssuesAdoptionJourney(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-issues-adopt-"); + const accepted = withResolvers(); + const release = withResolvers(); + let held = false; + const server = yield* useIssueTrackerServer({ + *settle(answer): Operation { + // Only the create, and only once: the reopened attempt has to be able + // to observe and adopt without being held as well. + if (answer.status !== 201 || held) { + return; + } + held = true; + accepted.resolve(); + yield* release.operation; + }, + }); + const inside = (args: readonly string[]): string => + [...configured(server, credential()), `cd ${shellQuote(home)}`, repl(args)].join("; "); + + const first = yield* allocate(inside(["repl", "--deny-all"]), home); + yield* showing(first, "[exit]", REACH_MS); + yield* first.type(ENTRY); + yield* sleep(500); + yield* first.type("\r"); + + // The tracker has the issue, and the answer is still on this side of the + // wire. Nothing about it is retained. + yield* accepted.operation; + expect(server.issues.length).toBe(1); + const mark = server.issues[0]?.body ?? ""; + + // Left while the request is in flight: the entry is cancelled and the + // command still prints the location it ended at. + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + release.resolve(); + + const second = yield* allocate(inside(["repl", "--deny-all", location]), home); + yield* showing(second, "[exit]", REACH_MS); + // The same owner and the same site, so the same key: the adapter finds its + // own mark on the issue that is already there and adopts it. + yield* showing(second, `filed at ${TRACKER}/issues/1`, ACT_MS); + expect(server.issues.length).toBe(1); + expect(server.issues[0]?.body ?? "").toBe(mark); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + }); +} + +/** + * What a runtime with no entry issue host does instead. + * + * Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not — so their branch runs the behavior that + * matters there: `` is still a name the REPL resolves, and reaching it + * says there is no provider rather than reaching a transport nobody configured. + * Neither branch silently skips its own behavior. + */ +export function replIssuesUnsupported(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-issues-absent-"); + const server = yield* useIssueTrackerServer(); + const line = [ + ...configured(server, credential()), + `cd ${shellQuote(home)}`, + repl(["repl", "--deny-all"]), + ].join("; "); + + const pty = yield* allocate(line, home); + yield* showing(pty, "[exit]", REACH_MS); + yield* pty.type(ENTRY); + yield* sleep(500); + yield* pty.type("\r"); + + yield* showing(pty, "no Issue provider is installed", ACT_MS); + // Configured and reachable, and never reached: what is absent is the host + // that would have installed a lifecycle, not the operator's configuration. + expect(server.requests).toEqual([]); + + yield* reach(pty, "[exit]"); + yield* pty.type("\r"); + expect(yield* ended(pty)).toBe(0); + }); +} diff --git a/packages/git/deno.ts b/packages/git/deno.ts index 2c3bfcd86..203197227 100644 --- a/packages/git/deno.ts +++ b/packages/git/deno.ts @@ -50,3 +50,13 @@ export { useRunComposition } from "./src/deno/run-composition/provider.ts"; export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; export { useReplRepositoryRecording } from "./src/deno/retained-repository/provider.ts"; export type { ReplRecordingOptions } from "./src/deno/retained-repository/provider.ts"; +/** + * One REPL entry's issue capability, as the inert value the CLI selects. + * + * The installation alone. What it builds — the entry's own `` + * implementation, the identity behind it, the admission over its history and + * the transport-facing default inside a live attempt — stays inside it: a + * package that could reach one of those could file an issue under an entry it + * does not own. + */ +export { replIssueInstallation } from "./src/deno/retained-repository/issues.ts"; diff --git a/packages/git/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts index cf81b7ea5..24566055f 100644 --- a/packages/git/src/composition/components/Issue.ts +++ b/packages/git/src/composition/components/Issue.ts @@ -17,10 +17,16 @@ * Invalid invocation, missing tracker and non-credential-free URLs are local * refusals; provider conflicts, ambiguity, availability and ceiling failures keep * the provider's own vocabulary. + * + * The three parts below the component — which form this is, what a read asks for + * and what an upsert asks for — are shared with the trusted implementation a + * REPL entry installs, which asks the same questions of the same props and then + * retains the answer under its own identity. Two copies of these checks would + * be two vocabularies for one component. */ import { content, hasContent } from "@executablemd/core"; -import type { PropsSchema, ReturnsSchema } from "@executablemd/core"; +import type { FirstPartyDocumentation, PropsSchema, ReturnsSchema } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { currentIssueTracker } from "../../issue/context.ts"; @@ -30,7 +36,8 @@ import { resolveIssueDestination, } from "../../issue/tracker.ts"; import { IssueOperations } from "../../issue/operations.ts"; -import type { IssueInput } from "../../issue/api.ts"; +import type { IssueReadInvocation, IssueUpsertInvocation } from "../../issue/operations.ts"; +import type { IssueDetails, IssueInput } from "../../issue/api.ts"; import { normalizedTags } from "../../issue/records.ts"; import { IssueContentError, IssueProtocolError, IssueTrackerError } from "../../issue/errors.ts"; @@ -104,7 +111,7 @@ export const returns: ReturnsSchema = { }; /** What the shape of this invocation asks for. */ -type Mode = "read" | "upsert"; +export type IssueMode = "read" | "upsert"; /** * Which of the two this invocation is, or the refusal that says why neither. @@ -112,8 +119,13 @@ type Mode = "read" | "upsert"; * Decided from the props and the shape alone, before a tracker is read and * before the boundary is reached. Every branch names what to write instead, * because each of them is a document that meant one of the two forms. + * + * `paired` is how the element was written. The ordinary component reads it + * through `Component.hasContent()`; the trusted implementation is entered by + * canonical form dispatch and states the form the scan recorded, which is the + * same fact established somewhere nothing composable can answer for it. */ -function mode(props: Record, paired: boolean): Mode { +export function issueMode(props: Record, paired: boolean): IssueMode { const url = typeof props.url === "string" && props.url !== ""; const title = typeof props.title === "string" && props.title !== ""; @@ -161,15 +173,13 @@ function mode(props: Record, paired: boolean): Mode { ); } -export default function* Issue(props: Record): Operation { - // The shape first, and locally. Everything after this depends on which of the - // two questions is being asked, and a document that asked neither must not - // have one of them chosen for it. - const asked = mode(props, yield* hasContent()); - return asked === "read" ? yield* read(props) : yield* upsert(props); -} - -function* read(props: Record): Operation { +/** + * What this read asks for: a canonical issue URL, and a discriminator or none. + * + * No tracker is consulted. The URL is the identity, so a tracker written + * around a read has nothing to add to it and changes nothing about it. + */ +export function issueReadRequest(props: Record): IssueReadInvocation { const url = canonicalIssueTarget(props.url); if (url === undefined) { throw new IssueTrackerError( @@ -178,22 +188,20 @@ function* read(props: Record): Operation { "query and fragment.", ); } - const provider = named(props.provider); - // No tracker is consulted. The URL is the identity, so a tracker written - // around a read has nothing to add to it and changes nothing about it. - const details = yield* IssueOperations.operations.read({ url, provider }); - return { - url: details.url, - title: details.title, - description: details.description, - tags: [...details.tags], - assignee: details.assignee, - }; + return { url, provider: named(props.provider) }; } -function* upsert(props: Record): Operation { - // The description before the destination, so a body that failed to render - // stops this element before a tracker is read. +/** + * What this upsert asks for: the rendered description, the destination the + * enclosing tracker names, and the normalized issue. + * + * The description before the destination, so a body that failed to render stops + * this element before a tracker is read. Rendering it is this element's own + * content projection and belongs outside whatever durable boundary the caller + * puts around the request: a child that retains work of its own finishes in its + * own order, before this request exists. + */ +export function* issueUpsertRequest(props: Record): Operation { const description = yield* content(); if (description === "") { throw new IssueContentError( @@ -217,11 +225,52 @@ function* upsert(props: Record): Operation { assignee: typeof props.assignee === "string" && props.assignee !== "" ? props.assignee : null, }); - const reference = yield* IssueOperations.operations.upsert({ - target: destination.target, - provider: destination.provider, - issue, - }); + return { target: destination.target, provider: destination.provider, issue }; +} + +/** + * What the catalog says about ``, wherever it is registered. + * + * One value, because two registrations describe one component: the bundled + * default every document command declares, and the trusted implementation a + * REPL entry installs over it. A reader asking either of them what `` + * is gets this. + */ +export const documentation: FirstPartyDocumentation = { + description: + `Read or file an issue. \`\` reads one. ` + + `\`…\` files its content in the issue tracker ` + + `in scope.`, + as: "Required for a read, which binds url, title, description, tags and assignee. A file binds the url alone.", + context: "The issue's description, for the form that files one.", +}; + +/** What a read binds, as a document reads it. */ +export function issueReadBinding(details: IssueDetails): Json { + return { + url: details.url, + title: details.title, + description: details.description, + tags: [...details.tags], + assignee: details.assignee, + }; +} + +export default function* Issue(props: Record): Operation { + // The shape first, and locally. Everything after this depends on which of the + // two questions is being asked, and a document that asked neither must not + // have one of them chosen for it. + const asked = issueMode(props, yield* hasContent()); + return asked === "read" ? yield* read(props) : yield* upsert(props); +} + +function* read(props: Record): Operation { + const details = yield* IssueOperations.operations.read(issueReadRequest(props)); + return issueReadBinding(details); +} + +function* upsert(props: Record): Operation { + const reference = yield* IssueOperations.operations.upsert(yield* issueUpsertRequest(props)); return { url: reference.url }; } diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index 6ac35fcc4..7d912ea35 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -49,7 +49,11 @@ import PullRequest, { props as pullRequestProps, returns as pullRequestReturns, } from "./components/PullRequest.ts"; -import Issue, { props as issueProps, returns as issueReturns } from "./components/Issue.ts"; +import Issue, { + documentation as issueDocumentation, + props as issueProps, + returns as issueReturns, +} from "./components/Issue.ts"; import { checksForm, checksReturns, @@ -214,11 +218,7 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ props: issueProps, returns: issueReturns, fn: Issue, - ...documented({ - description: `Read or file an issue. \`\` reads one. \`…\` files its content in the issue tracker in scope.`, - as: "Required for a read, which binds url, title, description, tags and assignee. A file binds the url alone.", - context: "The issue's description, for the form that files one.", - }), + ...documented(issueDocumentation), }, ]; diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/git/src/deno/retained-repository/issues.ts new file mode 100644 index 000000000..80743b55b --- /dev/null +++ b/packages/git/src/deno/retained-repository/issues.ts @@ -0,0 +1,364 @@ +/** + * One REPL entry's issues: what the entry executing right now may file and read. + * + * `provider.ts` beside this answers the same question for files and managed + * checkouts. This one is smaller and differs in what it reaches: an issue lives + * at a service rather than in a checkout, so there is no discovery, no lease and + * no managed root here at all — just the GitHub adapter this platform can talk + * to, and the durable envelope that makes one entry's issue work answerable + * again after the person walks away from it. + * + * ## What the entry owns, and what it cannot be told + * + * An entry's issue work is retained under an owner the CLI derives from the + * execution and the entry key, and each invocation inside it is named by the + * identity canonical core minted where it selected this exact implementation. + * The factory closes over the owner and core delivers the identity + * authenticator, so neither + * arrives through a prop, a tracker, a Context, a middleware answer or a + * registry: a party that could name either could file an issue under somebody + * else's entry, or read back the result of one. + * + * ## One envelope, with live composition inside it + * + * The trusted body owns the one durable boundary. Inside a live attempt it + * composes `IssueOperations` — so a document's own middleware still answers, + * transforms, refuses and delegates exactly as it does anywhere else — over a + * transport-facing default of its own that reaches `IssueApi` and adds no + * second envelope. A completed replay invokes none of that: no middleware, no + * transport, no configuration and no credential. + * + * The fallback is installed in a scope of the attempt's own and closes over that + * attempt's idempotency key, so two concurrent `` elements each dispatch + * through their own and neither can present the other's key. Its lifetime ends + * when the attempt's live operation returns, which is before the envelope + * publishes anything. + * + * ## Installing it reaches nothing + * + * No configuration, no token, no network, no Git session, no ambient discovery, + * no checkout lease and no managed root. The adapter reads what this deployment + * authorized when an invoked request turns out to be its own, so an entry that + * writes no `` reads neither variable and obtains no credential, and a + * passive view — a settled history, a frozen position — initializes nothing. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { documented, formDispatcher } from "@executablemd/core"; +import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; +import type { + ExecutionInstallation, + IdentityClaimant, + IdentityComponent, + JournalAdmission, +} from "@executablemd/core/host"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { StaleInputError } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; +import { + documentation as issueDocumentation, + issueMode, + issueReadBinding, + issueReadRequest, + issueUpsertRequest, + props as issueProps, + returns as issueReturns, +} from "../../composition/components/Issue.ts"; +import { IssueApi } from "../../issue/api.ts"; +import type { IssueDetails, IssueReference } from "../../issue/api.ts"; +import { IssueOperations } from "../../issue/operations.ts"; +import type { + IssueOperationsApi, + IssueReadInvocation, + IssueUpsertInvocation, +} from "../../issue/operations.ts"; +import { IssueProtocolError } from "../../issue/errors.ts"; +import { ISSUE_EFFECT } from "../../issue/effect-type.ts"; +import { + issueIdempotencyKey, + issueRequestFingerprint, + parseIssueDetails, + parseIssueRecord, + parseIssueRequest, +} from "../../issue/records.ts"; +import type { IssueRequest } from "../../issue/records.ts"; +import { readOwnedIssue, upsertOwnedIssue } from "../../issue/owned.ts"; +import { useGitHubIssues } from "../issue/github.ts"; +import type { GitHubIssuesOptions } from "../issue/github.ts"; +import { denoGitHubSource } from "../composition/github-host.ts"; +import type { Around } from "@effectionx/context-api"; + +/** Why one entry's retained issue history cannot be continued. */ +export type ReplIssueRefusal = + /** A record of the Issue effect type that this host cannot read. */ + | "malformed-record" + /** A record whose name is not the name of the request it carries. */ + | "request-fingerprint" + /** A record naming a history that is not this entry's. */ + | "foreign-owner" + /** A settled record whose retained value is not the result its operation returns. */ + | "malformed-result"; + +/** + * A retained issue history this entry cannot continue from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. + */ +export class ReplIssueHistoryError extends StaleInputError { + override name = "ReplIssueHistoryError"; + + readonly reason: ReplIssueRefusal; + + constructor(reason: ReplIssueRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * The issue capability one REPL entry runs with, as inert code. + * + * Called by the CLI for the exact execution and entry it derived the owner + * from, and called nowhere else. Building it performs nothing: what it carries + * is a component declaration, a refusal-only admission and an installation that + * registers middleware. + */ +export function replIssueInstallation( + owner: string, + options: GitHubIssuesOptions = {}, +): ExecutionInstallation { + return { + components: [ownedIssueComponent(owner)], + admissions: [ownedIssueAdmission(owner)], + *install(): Operation { + // The platform's transport, handed to an adapter that knows the protocol + // and not the platform. Naming it here is not building one: it is a + // factory the adapter calls if and when a request turns out to be its own. + yield* useGitHubIssues({ host: denoGitHubSource, ...options }); + }, + }; +} + +/** + * The entry's own `Issue`, declared as an ordinary shadowable component. + * + * It shadows the inherited bundled default for this execution's scope without + * registering two defaults in one scope and without touching that inherited + * registration, so a repository-local `` keeps its ordinary precedence + * over both. The name is neither reserved nor protected: what a replacement + * does not get is an identity authenticator, so it names no durable work of + * this entry's. + * + * Inspection describes one `Issue` — the same origin, props, return contract + * and prose the bundled registration carries — because it is the same component + * with the entry's identity behind it. + */ +function ownedIssueComponent(owner: string): IdentityComponent { + return { + name: "Issue", + origin: COMPOSITION_ORIGIN, + props: issueProps, + returns: issueReturns, + forms: ["self-closing", "paired"], + ...documented(issueDocumentation), + factory: (claim: IdentityClaimant) => + // Canonical form dispatch, so which of the two external effects this + // element asks for is settled by the scan before the body runs. A + // contextual `hasContent()` answer cannot select it: the chain that + // answers one is composed by whoever installed a handler, and an + // irreversible effect is not something a handler may choose. + formDispatcher({ + forms: "both", + "self-closing": readingBody(owner, claim), + paired: upsertingBody(owner, claim), + }), + }; +} + +function readingBody(owner: string, claim: IdentityClaimant): FunctionComponent { + return function* Issue( + props: Record, + invocation: ComponentInvocation, + ): Operation { + // The same input checks the ordinary component makes, for the form that was + // dispatched here. Which of the two this is was decided by the engine, so + // the answer is not read back: what is wanted from this call is its + // refusals, which are the component's settled sentences. + issueMode(props, false); + const site = yield* claim(invocation); + const request = issueReadRequest(props); + const details = yield* readOwnedIssue({ owner, site, request }, liveRead(request)); + return issueReadBinding(details); + }; +} + +function upsertingBody(owner: string, claim: IdentityClaimant): FunctionComponent { + return function* Issue( + props: Record, + invocation: ComponentInvocation, + ): Operation { + issueMode(props, true); + // Before this element projects its own content, because an invocation names + // nothing while it is expanding what is inside it — and after it would be + // naming the identity from a frame the children have already left. + const site = yield* claim(invocation); + // The description is rendered here, outside the envelope below. A child + // `` or any other durable operation inside this content therefore + // finishes at its own journal position, before this upsert is offered. + const request = yield* issueUpsertRequest(props); + const reference = yield* upsertOwnedIssue( + { owner, site, request }, + liveUpsert( + request, + issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", request.target), + ), + ); + return { url: reference.url }; + }; +} + +/** + * One live read attempt: the composed `IssueOperations` question, answered + * beneath by this entry's transport-facing default. + * + * Scoped, so the default is gone before the envelope publishes anything, and + * installed at `min`, so every public handler a document or a plugin composed + * still wraps the dispatch. + */ +function liveRead(request: IssueReadInvocation): () => Operation { + return () => + scoped(function* (): Operation { + yield* IssueOperations.around(transportRead(), { at: "min" }); + return yield* IssueOperations.operations.read(request); + }); +} + +/** One live upsert attempt, on the same terms, under this attempt's own key. */ +function liveUpsert(request: IssueUpsertInvocation, key: string): () => Operation { + return () => + scoped(function* (): Operation { + yield* IssueOperations.around(transportUpsert(key), { at: "min" }); + return yield* IssueOperations.operations.upsert(request); + }); +} + +function transportRead(): Partial> { + return { + *read([invocation]: [IssueReadInvocation]): Operation { + const answered = yield* IssueApi.operations.read(invocation.url, { + ...(invocation.provider === undefined ? {} : { provider: invocation.provider }), + }); + const details = parseIssueDetails(answered); + if (details === undefined) { + throw new IssueProtocolError( + "the issue provider answered a read with something that is not an issue's shared " + + "fields", + ); + } + return details; + }, + }; +} + +/** + * The transport-facing default for one upsert attempt. + * + * The key is this attempt's, derived from the complete request's own identity + * and target before any middleware saw it. A handler that transformed the + * issue, the target or the discriminator changes what is sent — that is what + * middleware is for — and changes nothing about which attempt this is. + */ +function transportUpsert(key: string): Partial> { + return { + *upsert([invocation]: [IssueUpsertInvocation]): Operation { + const answered = yield* IssueApi.operations.upsert(invocation.issue, { + url: invocation.target, + ...(invocation.provider === undefined ? {} : { provider: invocation.provider }), + idempotencyKey: key, + }); + const record = parseIssueRecord(answered); + if (record === undefined) { + throw new IssueProtocolError( + "the issue provider answered an upsert with something that is not a URL", + ); + } + return record; + }, + }; +} + +/** + * What this entry requires of the issue records its history holds. + * + * Three questions, and only three. **Integrity**: every settled Issue record + * parses, is named by the request it carries, and holds a value that is the + * result its own operation returns. **Ownership**: every one of them names this + * entry's owner. **Nothing else**: no service is reached, no issue is read and + * no result is handed out — replaying a completed operation is the durable + * engine's own positional matching, and this runs before any of it. + * + * Ownership is asked here because an owner is what the durable name and the + * idempotency key are derived from. A record naming another entry's owner would + * replay under a key this entry never presented, so a history carrying one is + * refused rather than continued — and borrowing it is exactly what is not + * allowed, however the record came to be in this segment. + */ +function ownedIssueAdmission(owner: string): JournalAdmission { + return function* (retained: readonly DurableEvent[]): Operation { + for (const event of retained) { + if (event.type !== "yield" || event.description.type !== ISSUE_EFFECT) { + continue; + } + if (event.result.status !== "ok") { + // A failed Issue effect retains no result of its own: its outcome is + // the durable operation's failed result, and there is nothing in it to + // hold to a request. + continue; + } + const request = parseIssueRequest(event.description["request"]); + if (request === undefined) { + throw refusal( + "malformed-record", + "an issue record in this entry's history is not one this host can read, so what it " + + "says about work already done is unknown.", + ); + } + if (request.identity.runId !== owner) { + throw refusal( + "foreign-owner", + "an issue record in this entry's history was written under another owner, so the " + + "work it describes was not done by this entry and its key is not one this entry " + + "can present again.", + ); + } + if (event.description.name !== (yield* issueRequestFingerprint(request))) { + throw refusal( + "request-fingerprint", + "a retained issue record is journaled under a name that is not the name its own " + + "request produces, so the record and the operation it claims to be do not match.", + ); + } + if (!settles(request, event.result.value)) { + throw refusal( + "malformed-result", + "a retained issue record holds a value that is not the result its own operation " + + "answers with, so what it says was filed or read is unknown.", + ); + } + } + }; +} + +/** Whether this retained value is the result this request's operation returns. */ +function settles(request: IssueRequest, value: unknown): boolean { + return request.operation === "read" + ? parseIssueDetails(value) !== undefined + : parseIssueRecord(value) !== undefined; +} + +function refusal(reason: ReplIssueRefusal, sentence: string): ReplIssueHistoryError { + return new ReplIssueHistoryError(reason, sentence); +} diff --git a/packages/git/src/issue/effect.ts b/packages/git/src/issue/effect.ts index e042d9af2..f87290b10 100644 --- a/packages/git/src/issue/effect.ts +++ b/packages/git/src/issue/effect.ts @@ -1,11 +1,11 @@ /** - * The durable envelope the two Issue operations run inside. + * The retained Issue lifecycle a workflow run installs. * - * Each derives what it needs, wraps exactly one `IssueApi` call as this run's - * next durable effect, validates what came back, and retains it. That is all - * either does — and the shortness is the design. Reconciliation is knowledge - * about what a particular service can prove, so it belongs to the middleware - * that holds the credential rather than to a state machine here. + * The envelope itself is `owned.ts`, shared with the other profile that retains + * Issue work. What belongs here is the half that is a workflow run's own: that + * the run and the expansion are what name a durable effect, that an inherited + * prefix may lend an upsert the identity it was created under, and that a live + * attempt calls `IssueApi` directly. * * ## One effect type, two operations * @@ -37,51 +37,20 @@ */ import type { Operation } from "effection"; -import { - createDurableOperation, - type EffectDescription, - type Json, - type Workflow, -} from "@executablemd/durable-streams"; -import { getExpansion, sourceDescription } from "@executablemd/core"; +import { getExpansion } from "@executablemd/core"; import { getWorkflowRun } from "@executablemd/workflow"; import { retainedIssueIdentitiesHere } from "../identities.ts"; import { claimRetainedIssueIdentity, exhaustRetainedIssueIdentities } from "./identities.ts"; -import { ISSUE_EFFECT } from "./effect-type.ts"; import { IssueApi } from "./api.ts"; import { IssueOperations } from "./operations.ts"; import type { IssueReadInvocation, IssueUpsertInvocation } from "./operations.ts"; -import type { IssueDetails, IssueInput, IssueReference } from "./api.ts"; +import type { IssueDetails, IssueReference } from "./api.ts"; import { IssueProtocolError } from "./errors.ts"; -import { - issueDetailsJson, - issueIdempotencyKey, - issueRecordJson, - issueRequestFingerprint, - issueRequestJson, - parseIssueDetails, - parseIssueRecord, - type IssueReadRequest, - type IssueRequest, - type IssueUpsertRequest, -} from "./records.ts"; +import { issueIdempotencyKey } from "./records.ts"; +import { ownedUpsertRequest, readOwnedIssue, upsertOwnedIssue } from "./owned.ts"; +import type { IssueReadEffectRequest, IssueUpsertEffectRequest } from "./owned.ts"; -/** What one read asks the envelope for. */ -export interface IssueReadEffectRequest { - /** The canonical issue URL. */ - readonly url: string; - /** The explicit discriminator, or `undefined` when the document named none. */ - readonly provider: string | undefined; -} - -/** What one upsert asks the envelope for. */ -export interface IssueUpsertEffectRequest { - /** The canonical container URL. */ - readonly target: string; - /** The explicit discriminator, or `undefined` when the tracker named none. */ - readonly provider: string | undefined; - readonly issue: IssueInput; -} +export type { IssueReadEffectRequest, IssueUpsertEffectRequest } from "./owned.ts"; /** * Read one issue, as this run's next durable effect. @@ -91,40 +60,15 @@ export interface IssueUpsertEffectRequest { * reaching one, or appending anything. */ export function* readIssue(request: IssueReadEffectRequest): Operation { - const complete: IssueReadRequest = Object.freeze({ - operation: "read" as const, - identity: yield* effectIdentity(), - url: request.url, - provider: request.provider ?? null, - }); - - const retained: unknown = yield* attempt( - yield* describe(complete), - function* (): Operation { - const answered = yield* IssueApi.operations.read(complete.url, { - ...(complete.provider === null ? {} : { provider: complete.provider }), + const identity = yield* effectIdentity(); + return yield* readOwnedIssue( + { owner: identity.runId, site: identity.expansionId, request }, + function* (): Operation { + return yield* IssueApi.operations.read(request.url, { + ...(request.provider === undefined ? {} : { provider: request.provider }), }); - // Validated before it is retained. A provider that answered with more - // than the shared fields answered with something this boundary will not - // carry, and reading it anyway is how a provider's own shape reaches a - // document. - const details = parseIssueDetails(answered); - if (details === undefined) { - throw new IssueProtocolError( - "the issue provider answered a read with something that is not an issue's shared " + - "fields, so nothing was retained", - ); - } - return issueDetailsJson(details); }, ); - const details = parseIssueDetails(retained); - if (details === undefined) { - throw new IssueProtocolError( - "the journal holds a value that does not describe an issue's shared fields", - ); - } - return details; } /** @@ -136,13 +80,6 @@ export function* readIssue(request: IssueReadEffectRequest): Operation { const identity = yield* effectIdentity(); - const live: IssueUpsertRequest = Object.freeze({ - operation: "upsert" as const, - identity, - target: request.target, - provider: request.provider ?? null, - issue: request.issue, - }); // A position this run already retains a record at is named by the identity // that record holds; everywhere else by this run's own. A fork inherits its @@ -153,15 +90,20 @@ export function* upsertIssue(request: IssueUpsertEffectRequest): Operation { + return yield* upsertOwnedIssue( + { owner, site: identity.expansionId, request }, + function* (): Operation { if (borrowed !== undefined) { // The request was named by a retained record, which cannot be a live // request: performing under a run this execution is not would repeat @@ -173,28 +115,13 @@ export function* upsertIssue(request: IssueUpsertEffectRequest): Operation { @@ -203,31 +130,6 @@ function* effectIdentity(): Operation<{ runId: string; expansionId: string }> { return { runId: run.runId, expansionId: expansion.id }; } -function* describe(request: IssueRequest): Operation { - const expansion = yield* getExpansion(); - return { - type: ISSUE_EFFECT, - name: yield* issueRequestFingerprint(request), - // Carried in the description so a retained history describes what it asked - // for. The identity table reads it back to recognize a record a fork - // inherited, and it holds only what a document wrote. - request: issueRequestJson(request), - // Where the authored component asking for this effect was written — - // diagnostic journal data, never part of the fingerprint, the request, or - // the identity table. - ...sourceDescription(expansion.position), - }; -} - -function* attempt( - description: EffectDescription, - perform: () => Operation, -): Workflow { - return yield createDurableOperation(description, perform); -} - -export { issueRequestJson }; - /** * Install the retained Issue lifecycle for the current scope and below. * diff --git a/packages/git/src/issue/owned.ts b/packages/git/src/issue/owned.ts new file mode 100644 index 000000000..e82385aeb --- /dev/null +++ b/packages/git/src/issue/owned.ts @@ -0,0 +1,231 @@ +/** + * The durable envelope an Issue operation runs inside, for whoever owns it. + * + * `effect.ts` used to be both halves of this: the envelope, and the decision + * that a workflow run and an expansion are what name it. Two profiles now + * retain Issue work — a workflow run, and a REPL entry — and they agree about + * everything except where the name comes from and what the live attempt does. + * So the agreement is here and the two decisions stay with their profiles. + * + * What one of these workers does is fixed: build the complete request, name the + * durable operation after a digest of it, invoke the caller's live operation + * exactly once inside that operation, validate what came back before it is + * retained, and validate it again when it is read back. The live operation is a + * callback because what a live attempt *is* differs — a workflow run calls + * `IssueApi` directly, and an entry composes `IssueOperations` over it — and + * neither of those belongs to the envelope. + * + * ## Owner and site, rather than run and expansion + * + * The two members of the retained identity, named for what they are rather than + * for the profile that first supplied them. `owner` is whoever this operation's + * history belongs to and `site` is which invocation inside it this is; a + * workflow run supplies its run id and the expansion, and a REPL entry supplies + * its own private owner and the invocation identity canonical core minted. The + * `runId`/`expansionId` field spellings in the record are a compatibility + * encoding of exactly those two, so a history written under either profile is + * read by both. + * + * Neither member comes from the document, the tracker, middleware or a + * provider, for the reason it never did: a party that could name the identity + * could name someone else's issue. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + type EffectDescription, + type Json, +} from "@executablemd/durable-streams"; +import { getExpansion, sourceDescription } from "@executablemd/core"; +import { ISSUE_EFFECT } from "./effect-type.ts"; +import type { IssueDetails, IssueInput, IssueReference } from "./api.ts"; +import { IssueProtocolError } from "./errors.ts"; +import { + issueDetailsJson, + issueRecordJson, + issueRequestFingerprint, + issueRequestJson, + parseIssueDetails, + parseIssueRecord, + type IssueReadRequest, + type IssueRequest, + type IssueUpsertRequest, +} from "./records.ts"; + +/** What one read asks the envelope for. */ +export interface IssueReadEffectRequest { + /** The canonical issue URL. */ + readonly url: string; + /** The explicit discriminator, or `undefined` when the document named none. */ + readonly provider: string | undefined; +} + +/** What one upsert asks the envelope for. */ +export interface IssueUpsertEffectRequest { + /** The canonical container URL. */ + readonly target: string; + /** The explicit discriminator, or `undefined` when the tracker named none. */ + readonly provider: string | undefined; + readonly issue: IssueInput; +} + +/** Whose retained history this operation belongs to, and which invocation it is. */ +interface OwnedIssue { + /** The history this operation is retained in: a workflow run, or one entry. */ + readonly owner: string; + /** The invocation inside that history, as its own profile authenticated it. */ + readonly site: string; +} + +export interface OwnedIssueRead extends OwnedIssue { + readonly request: IssueReadEffectRequest; +} + +export interface OwnedIssueUpsert extends OwnedIssue { + readonly request: IssueUpsertEffectRequest; +} + +/** + * The complete read request this owner and site name. + * + * Built here rather than by each caller, because the encoding is what the + * durable name, the identity table and the retained history all agree about. + */ +export function ownedReadRequest(input: OwnedIssueRead): IssueReadRequest { + return Object.freeze({ + operation: "read" as const, + identity: { runId: input.owner, expansionId: input.site }, + url: input.request.url, + provider: input.request.provider ?? null, + }); +} + +/** The complete upsert request this owner and site name, on the same terms. */ +export function ownedUpsertRequest(input: OwnedIssueUpsert): IssueUpsertRequest { + return Object.freeze({ + operation: "upsert" as const, + identity: { runId: input.owner, expansionId: input.site }, + target: input.request.target, + provider: input.request.provider ?? null, + issue: input.request.issue, + }); +} + +/** + * Read one issue, as this owner's next durable effect at this site. + * + * Live, `perform` is invoked once and the fields it answered with are retained. + * Replayed, the snapshot comes back without `perform` being invoked at all — so + * no provider is resolved, no transport is reached and nothing is appended. + */ +export function readOwnedIssue( + input: OwnedIssueRead, + perform: () => Operation, +): Operation { + return reading(ownedReadRequest(input), perform); +} + +function* reading( + complete: IssueReadRequest, + perform: () => Operation, +): Operation { + const retained: unknown = yield* attempt( + yield* describe(complete), + function* (): Operation { + const answered = yield* perform(); + // Validated before it is retained. A provider that answered with more + // than the shared fields answered with something this boundary will not + // carry, and reading it anyway is how a provider's own shape reaches a + // document. + const details = parseIssueDetails(answered); + if (details === undefined) { + throw new IssueProtocolError( + "the issue provider answered a read with something that is not an issue's shared " + + "fields, so nothing was retained", + ); + } + return issueDetailsJson(details); + }, + ); + const details = parseIssueDetails(retained); + if (details === undefined) { + throw new IssueProtocolError( + "the journal holds a value that does not describe an issue's shared fields", + ); + } + return details; +} + +/** + * Create or bring up to date one issue, as this owner's next durable effect at + * this site. + * + * Live, `perform` is invoked once and the URL it answered with is retained. + * Replayed, that URL comes back without `perform` being invoked. + * + * What makes a second attempt the same attempt is not decided here: the key is + * derived from this request's own identity and target, and the caller carries it + * to the transport. An attempt interrupted after the service accepted it leaves + * no record, and the next attempt presents the same key. + */ +export function upsertOwnedIssue( + input: OwnedIssueUpsert, + perform: () => Operation, +): Operation { + return upserting(ownedUpsertRequest(input), perform); +} + +function* upserting( + complete: IssueUpsertRequest, + perform: () => Operation, +): Operation { + const retained: unknown = yield* attempt( + yield* describe(complete), + function* (): Operation { + const answered = yield* perform(); + const record = parseIssueRecord(answered); + if (record === undefined) { + throw new IssueProtocolError( + "the issue provider answered an upsert with something that is not a URL, so nothing " + + "was retained", + ); + } + return issueRecordJson(record); + }, + ); + const record = parseIssueRecord(retained); + if (record === undefined) { + throw new IssueProtocolError( + "the journal holds a value that does not describe an issue reference", + ); + } + return record; +} + +function* describe(request: IssueRequest): Operation { + const expansion = yield* getExpansion(); + return { + type: ISSUE_EFFECT, + name: yield* issueRequestFingerprint(request), + // Carried in the description so a retained history describes what it asked + // for. The identity table reads it back to recognize a record a fork + // inherited, and it holds only what a document wrote. + request: issueRequestJson(request), + // Where the authored component asking for this effect was written — + // diagnostic journal data, never part of the fingerprint, the request, or + // the identity table. + ...sourceDescription(expansion.position), + }; +} + +/** + * Whatever comes back is journal data on a replay, so it is handed on as the + * unknown it is and parsed by the caller above rather than trusted here. + */ +function* attempt( + description: EffectDescription, + perform: () => Operation, +): Operation { + return yield createDurableOperation(description, perform); +} diff --git a/packages/git/tests/issue-owned.test.ts b/packages/git/tests/issue-owned.test.ts new file mode 100644 index 000000000..1cecab613 --- /dev/null +++ b/packages/git/tests/issue-owned.test.ts @@ -0,0 +1,704 @@ +/** + * The durable envelope an owned Issue operation runs inside, and the + * compatibility it owes the profile that used to be the only one. + * + * Portable on purpose: the envelope is provider-neutral — a request encoding, a + * durable name, one live callback and the validation around it — so it is held + * to the same rules under every runtime the CLI starts on. Nothing here opens a + * socket or reads a credential: the live callback is the suite's, which is + * exactly the seam the workers exist to offer. + * + * The two halves are: + * + * - **E1, the envelope.** One live attempt per position, the composed answer + * retained and validated, a replay that invokes nothing, and a changed + * request that refuses rather than consuming the result retained for a + * different question. + * - **E7, compatibility.** A workflow run's record is byte-for-byte the one it + * has always written, an inherited prefix replays under the identity it was + * created with, and a request named by inherited history that reaches live + * execution still refuses. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { resource, scoped } from "effection"; +import type { Operation } from "effection"; +import { writeTextFile } from "@effectionx/fs"; +import { mkdtemp, realpath } from "node:fs/promises"; +import { ensure, until } from "effection"; +import { rm } from "@effectionx/fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import { collect, content, registerComponents } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { retainedWorkflowInstallation } from "@executablemd/workflow"; +import { WORKFLOW_RUN } from "../../workflow/src/journal.ts"; +import type { WorkflowRun } from "@executablemd/workflow"; +import { ISSUE_EFFECT } from "../src/issue/effect-type.ts"; +import { IssueApi } from "../src/issue/api.ts"; +import type { + IssueDetails, + IssueInput, + IssueReadOptions, + IssueReference, + IssueUpsertOptions, +} from "../src/issue/api.ts"; +import { readOwnedIssue, upsertOwnedIssue } from "../src/issue/owned.ts"; +import { useRetainedIssueOperations } from "../src/issue/effect.ts"; +import { useCompositionComponents } from "../src/composition/installation.ts"; +import { issueIdentityAdmission } from "../src/identities.ts"; + +const ORIGIN = "@executablemd/git/tests/issue-owned"; + +/** The issue every row reads, and the tracker every row files in. */ +const ISSUE_URL = "https://github.com/octo/project/issues/7"; +const TRACKER = "https://github.com/octo/project"; + +/** What a read's live callback answers with, unless a row says otherwise. */ +const DETAILS: IssueDetails = Object.freeze({ + url: ISSUE_URL, + title: "A read issue", + description: "what the tracker holds", + tags: Object.freeze(["one", "two"]), + assignee: null, +}); + +/** What one execution of a row observed, from inside the components. */ +interface Observed { + /** Each live callback invocation, in order, by operation. */ + readonly live: string[]; + /** Each `IssueApi` call, in order. */ + readonly transport: string[]; + /** What each worker handed back, in order. */ + readonly answers: Json[]; + /** The idempotency key each upsert presented to the transport, in order. */ + readonly keys: string[]; +} + +function observed(): Observed { + return { live: [], transport: [], answers: [], keys: [] }; +} + +/** What a row's live callbacks do, which is the whole point of the seam. */ +interface Live { + /** Answer a read without any transport at all. */ + read?: () => Operation; + /** Answer an upsert without any transport at all. */ + upsert?: () => Operation; +} + +interface Fixture { + readonly root: string; +} + +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "issue-owned-"))))); + yield* ensure(() => rm(root, { recursive: true })); + yield* provide({ root }); + }); +} + +/** + * The two components a row writes, which call the workers directly. + * + * Authored here rather than reached through `` on purpose: what these + * rows are about is the envelope, and the component above it is the subject of + * the entry suite. A component is still needed, because a durable operation is + * named after the expansion that asked for it and there is no expansion outside + * an element. + */ +function useOwnedComponents(seen: Observed, live: Live): Operation { + return registerComponents([ + { + name: "OwnedRead", + origin: ORIGIN, + props: { + type: "object", + properties: { + url: { type: "string" }, + owner: { type: "string" }, + site: { type: "string" }, + }, + additionalProperties: false, + }, + *fn(props: Record): Operation { + const request = { url: String(props.url ?? ISSUE_URL), provider: undefined }; + const details = yield* readOwnedIssue( + { + owner: String(props.owner ?? "owner-1"), + site: String(props.site ?? "site-1"), + request, + }, + function* (): Operation { + seen.live.push("read"); + if (live.read === undefined) { + throw new Error("this row's read was not supposed to run live"); + } + return yield* live.read(); + }, + ); + seen.answers.push({ ...details, tags: [...details.tags] }); + return null; + }, + }, + { + name: "OwnedUpsert", + origin: ORIGIN, + props: { + type: "object", + properties: { + title: { type: "string" }, + owner: { type: "string" }, + site: { type: "string" }, + target: { type: "string" }, + }, + additionalProperties: false, + }, + *fn(props: Record): Operation { + const issue: IssueInput = Object.freeze({ + title: String(props.title ?? "Filed"), + description: "what this entry asked for", + tags: Object.freeze([]), + assignee: null, + }); + const reference = yield* upsertOwnedIssue( + { + owner: String(props.owner ?? "owner-1"), + site: String(props.site ?? "site-1"), + request: { target: String(props.target ?? TRACKER), provider: undefined, issue }, + }, + function* (): Operation { + seen.live.push("upsert"); + if (live.upsert === undefined) { + throw new Error("this row's upsert was not supposed to run live"); + } + return yield* live.upsert(); + }, + ); + seen.answers.push({ url: reference.url }); + return null; + }, + }, + { + // A paired component, so a row can put one worker call inside another's + // content and see which record lands first. + name: "OwnedParent", + origin: ORIGIN, + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + // Projected, because content a component does not render is content + // nothing reaches: the child worker call is the point of this one. + yield* content(); + return null; + }, + }, + ]); +} + +/** + * The transport, counted — and answering only where a row said it may. + * + * Every row counts it, because "no provider was reached" is most of what these + * rows assert. A row that does not supply an answer gets a transport that + * refuses, so reaching one is a failure rather than a silently served request. + */ +function useCountedTransport(seen: Observed, transport: Transport): Operation { + return IssueApi.around( + { + *read([url]: [string, IssueReadOptions]): Operation { + seen.transport.push(`read ${url}`); + if (transport.read === undefined) { + throw new Error("this row was not supposed to reach a transport"); + } + return yield* transport.read(url); + }, + *upsert([issue, options]: [IssueInput, IssueUpsertOptions]): Operation { + seen.transport.push(`upsert ${options.url}`); + seen.keys.push(options.idempotencyKey); + if (transport.upsert === undefined) { + throw new Error("this row was not supposed to reach a transport"); + } + return yield* transport.upsert(issue, options); + }, + }, + { at: "min" }, + ); +} + +/** What a row's transport answers, where a row lets one be reached. */ +interface Transport { + read?: (url: string) => Operation; + upsert?: (issue: IssueInput, options: IssueUpsertOptions) => Operation; +} + +interface Ran { + readonly seen: Observed; + readonly events: readonly DurableEvent[]; + readonly refusal: string | undefined; +} + +/** One document, over one journal, with the live callbacks this row supplies. */ +function run( + fixture: Fixture, + source: string, + stream: DurableStream, + live: Live, + options: { + readonly transport?: Transport; + readonly installations?: readonly ExecutionInstallation[]; + /** Install the workflow run's own Issue lifecycle, as a workflow host does. */ + readonly retained?: boolean; + } = {}, +): Operation { + return scoped(function* (): Operation { + const path = join(fixture.root, `entry-${crypto.randomUUID()}.md`); + yield* writeTextFile(path, source); + const seen = observed(); + yield* useCompositionComponents(); + yield* useOwnedComponents(seen, live); + yield* useCountedTransport(seen, options.transport ?? {}); + if (options.retained === true) { + yield* useRetainedIssueOperations(); + } + let refusal: string | undefined; + try { + yield* collect(yield* executeInstalled({ path, stream }, [...(options.installations ?? [])])); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { seen, events: yield* stream.readAll(), refusal }; + }); +} + +/** Every Issue effect this journal holds, in order. */ +function issueRecords(events: readonly DurableEvent[]): readonly DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === ISSUE_EFFECT, + ); +} + +/** The request one record was named after, as the journal holds it. */ +function requestOf(event: DurableEvent | undefined): unknown { + return event?.type === "yield" ? event.description["request"] : undefined; +} + +function valueOf(event: DurableEvent | undefined): unknown { + return event?.type === "yield" && event.result.status === "ok" ? event.result.value : undefined; +} + +function nameOf(event: DurableEvent | undefined): string | undefined { + return event?.type === "yield" ? event.description.name : undefined; +} + +/** + * The digest a record's name is, computed by this suite rather than by the + * code under test. + * + * Written out here because that is what makes it evidence: a row comparing the + * production fingerprint with itself would pass however the encoding moved. + */ +function* digestOf(value: unknown): Operation { + const text = JSON.stringify(sorted(value)); + const encoded = new TextEncoder().encode(text === undefined ? "null" : text); + const digest = yield* until(crypto.subtle.digest("SHA-256", encoded)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** The same canonical ordering the record encoding uses: keys, sorted. */ +function sorted(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sorted); + } + if (typeof value !== "object" || value === null) { + return value; + } + const entries = Object.keys(value) + .sort() + .map((key) => [key, sorted(Reflect.get(value, key))] as const); + return Object.fromEntries(entries); +} + +/** + * The same history, inherited by another run. + * + * A fork writes its own run record and inherits the effects beneath it, so a + * row that only changed the installed run would be refused by the run record + * itself before any Issue question arose. Rewriting it here is what makes the + * inherited *Issue* prefix the subject. + */ +function forked(events: readonly DurableEvent[], runId: string): DurableEvent[] { + return events + .filter((event) => event.type !== "close") + .map((event) => + event.type === "yield" && event.description.type === WORKFLOW_RUN + ? { + ...event, + result: { status: "ok" as const, value: { runId, bundleHash: RUN.bundleHash } }, + } + : event, + ); +} + +/** Everything before the root Close, which is what a continuation reopens. */ +function unfinished(events: readonly DurableEvent[]): InMemoryStream { + return new InMemoryStream(events.filter((event) => event.type !== "close")); +} + +/** The document the workflow rows run, which is the ordinary public one. */ +const WORKFLOW_ENTRY = `a described issue`; + +const RUN: WorkflowRun = Object.freeze({ + runId: "run-owned-issue", + bundleHash: "a".repeat(64), +}); + +/** The run that inherits the first one's history. */ +const FORK = "run-owned-issue-fork"; + +/** What a workflow run installs for these rows: the run, and its identity table. */ +function workflowProfile(runId: string): readonly ExecutionInstallation[] { + return [ + retainedWorkflowInstallation({ ...RUN, runId }), + // The Git Plugin's own contribution, which is what publishes the identity + // every retained Issue record in this history was written under. + { admissions: [issueIdentityAdmission()] }, + ]; +} + +describe("the durable envelope an owned Issue operation runs inside", () => { + it("E1: retains the answer one live read composed, and names it after the request", function* (): Operation { + const fixture = yield* useFixture(); + const stream = new InMemoryStream(); + const ran = yield* run( + fixture, + '', + stream, + { + read: function* (): Operation { + // A valid normalized result, answered without any transport: this is + // what a live middleware answer is, and the envelope records it. + return { ...DETAILS, tags: [...DETAILS.tags] }; + }, + }, + ); + + expect(ran.refusal).toBe(undefined); + expect(ran.seen.live).toEqual(["read"]); + expect(ran.seen.transport).toEqual([]); + expect(ran.seen.answers).toEqual([{ ...DETAILS, tags: [...DETAILS.tags] }]); + + const records = issueRecords(ran.events); + expect(records.length).toBe(1); + expect(requestOf(records[0])).toEqual({ + operation: "read", + identity: { runId: "owner-1", expansionId: "site-1" }, + url: ISSUE_URL, + provider: null, + }); + expect(valueOf(records[0])).toEqual({ ...DETAILS, tags: [...DETAILS.tags] }); + expect(nameOf(records[0])).toBe(yield* digestOf(requestOf(records[0]))); + }); + + it("E1: replays a completed read without invoking the live operation at all", function* (): Operation { + const fixture = yield* useFixture(); + const source = ''; + const first = yield* run(fixture, source, new InMemoryStream(), { + read: function* (): Operation { + return { ...DETAILS, tags: [...DETAILS.tags] }; + }, + }); + expect(first.refusal).toBe(undefined); + + // Both chains set to fail if invoked: the live callback is absent, which is + // what the component refuses on, and the transport counts and throws. + const again = yield* run(fixture, source, unfinished(first.events), {}); + + expect(again.refusal).toBe(undefined); + expect(again.seen.live).toEqual([]); + expect(again.seen.transport).toEqual([]); + // The exact saved result, not a fresh observation of a live object. + expect(again.seen.answers).toEqual([{ ...DETAILS, tags: [...DETAILS.tags] }]); + expect(issueRecords(again.events).length).toBe(1); + }); + + it("E1: refuses a request the retained record does not answer, and takes nothing from it", function* (): Operation { + const fixture = yield* useFixture(); + const source = ''; + const first = yield* run(fixture, source, new InMemoryStream(), { + read: function* (): Operation { + return { ...DETAILS, tags: [...DETAILS.tags] }; + }, + }); + expect(first.refusal).toBe(undefined); + + // A record for a different question, correctly named for *that* question, + // at the position this document reaches. The source cannot be edited to + // produce this: a continuation executes the root it retained rather than + // whatever the file now says, so a changed request is a history holding an + // answer to something else. + const asked = { + operation: "read", + identity: { runId: "owner-1", expansionId: "site-1" }, + url: "https://github.com/octo/project/issues/8", + provider: null, + }; + const held = { ...DETAILS, url: "https://github.com/octo/project/issues/8", tags: ["other"] }; + const name = yield* digestOf(asked); + const substituted = new InMemoryStream( + first.events + .filter((event) => event.type !== "close") + .map((event) => + event.type === "yield" && event.description.type === ISSUE_EFFECT + ? { + ...event, + description: { type: ISSUE_EFFECT, name, request: asked }, + result: { status: "ok" as const, value: held }, + } + : event, + ), + ); + + const changed = yield* run(fixture, source, substituted, {}); + + // Whatever this execution does about the mismatch, what it must not do is + // answer this question with the result retained for the other one. + expect(changed.seen.answers).toEqual([]); + expect(changed.refusal).not.toBe(undefined); + }); + + it("E1: retains an upsert's URL alone, and replays it without a live attempt", function* (): Operation { + const fixture = yield* useFixture(); + const source = ''; + const filed = yield* run(fixture, source, new InMemoryStream(), { + upsert: function* (): Operation { + // A provider answers with a URL and nothing else, however much it + // knows: everything else is what this boundary keeps on its own side. + return { url: `${TRACKER}/issues/11` }; + }, + }); + + expect(filed.refusal).toBe(undefined); + expect(filed.seen.live).toEqual(["upsert"]); + expect(valueOf(issueRecords(filed.events)[0])).toEqual({ url: `${TRACKER}/issues/11` }); + + const again = yield* run(fixture, source, unfinished(filed.events), {}); + expect(again.refusal).toBe(undefined); + expect(again.seen.live).toEqual([]); + expect(again.seen.answers).toEqual([{ url: `${TRACKER}/issues/11` }]); + expect(issueRecords(again.events).length).toBe(1); + }); + + it("E1: a child's record lands before its parent's, and each call retains one", function* (): Operation { + const fixture = yield* useFixture(); + const ran = yield* run( + fixture, + [ + "", + '', + "", + '', + ].join("\n"), + new InMemoryStream(), + { + read: function* (): Operation { + return { ...DETAILS, tags: [...DETAILS.tags] }; + }, + upsert: function* (): Operation { + return { url: `${TRACKER}/issues/12` }; + }, + }, + ); + + expect(ran.refusal).toBe(undefined); + const records = issueRecords(ran.events); + // One envelope per Issue operation, in the order the document reached them: + // the content's read finished at its own position before the upsert that + // follows it was offered. + expect(records.length).toBe(2); + expect(requestOf(records[0])).toMatchObject({ operation: "read" }); + expect(requestOf(records[1])).toMatchObject({ operation: "upsert" }); + }); + + it("E7: writes a workflow run's record exactly as it has always written it", function* (): Operation { + const fixture = yield* useFixture(); + const stream = new InMemoryStream(); + const ran = yield* run( + fixture, + WORKFLOW_ENTRY, + stream, + {}, + { + retained: true, + installations: workflowProfile(RUN.runId), + transport: { + // deno-lint-ignore require-yield + *upsert(): Operation { + return { url: `${TRACKER}/issues/21` }; + }, + }, + }, + ); + + expect(ran.refusal).toBe(undefined); + expect(ran.seen.transport).toEqual([`upsert ${TRACKER}`]); + + const record = issueRecords(ran.events)[0]; + const asked = requestOf(record); + // The expansion id is the engine's, so it is read back rather than + // predicted. Everything around it is the encoding this row is about. + const site = String(Reflect.get(Object(Reflect.get(Object(asked), "identity")), "expansionId")); + expect(asked).toEqual({ + operation: "upsert", + identity: { runId: RUN.runId, expansionId: site }, + target: TRACKER, + provider: null, + issue: { + title: "Filed", + description: "a described issue", + tags: [], + assignee: null, + }, + }); + expect(valueOf(record)).toEqual({ url: `${TRACKER}/issues/21` }); + expect(nameOf(record)).toBe(yield* digestOf(asked)); + // And the key the provider was handed is the canonical encoding of this + // run's own identity and target, computed here rather than by the code + // that produced it. + expect(ran.seen.keys).toEqual([ + JSON.stringify( + sorted({ + operation: "upsert", + canonicalTargetUrl: TRACKER, + runId: RUN.runId, + expansionId: site, + }), + ), + ]); + }); + + it("E7: replays the run's own record without reaching a provider", function* (): Operation { + const fixture = yield* useFixture(); + const filed = yield* run( + fixture, + WORKFLOW_ENTRY, + new InMemoryStream(), + {}, + { + retained: true, + installations: workflowProfile(RUN.runId), + transport: { + // deno-lint-ignore require-yield + *upsert(): Operation { + return { url: `${TRACKER}/issues/21` }; + }, + }, + }, + ); + expect(filed.refusal).toBe(undefined); + + const again = yield* run( + fixture, + WORKFLOW_ENTRY, + unfinished(filed.events), + {}, + { + retained: true, + installations: workflowProfile(RUN.runId), + }, + ); + + expect(again.refusal).toBe(undefined); + expect(again.seen.transport).toEqual([]); + expect(issueRecords(again.events).length).toBe(1); + }); + + it("E7: a fork replays the inherited prefix under the identity it was created with", function* (): Operation { + const fixture = yield* useFixture(); + const filed = yield* run( + fixture, + WORKFLOW_ENTRY, + new InMemoryStream(), + {}, + { + retained: true, + installations: workflowProfile(RUN.runId), + transport: { + // deno-lint-ignore require-yield + *upsert(): Operation { + return { url: `${TRACKER}/issues/21` }; + }, + }, + }, + ); + expect(filed.refusal).toBe(undefined); + + // Another run, over the first one's history. The record was named by a run + // this execution is not, so without borrowing its name would not be the + // name this request produces and the inherited issue would be filed twice. + const inherited = yield* run( + fixture, + WORKFLOW_ENTRY, + new InMemoryStream(forked(filed.events, FORK)), + {}, + { retained: true, installations: workflowProfile(FORK) }, + ); + + expect(inherited.refusal).toBe(undefined); + expect(inherited.seen.transport).toEqual([]); + const records = issueRecords(inherited.events); + expect(records.length).toBe(1); + expect(requestOf(records[0])).toMatchObject({ identity: { runId: RUN.runId } }); + }); + + it("E7: refuses live work under a borrowed identity, and reaches no provider", function* (): Operation { + const fixture = yield* useFixture(); + const filed = yield* run( + fixture, + WORKFLOW_ENTRY, + new InMemoryStream(), + {}, + { + retained: true, + installations: workflowProfile(RUN.runId), + transport: { + // deno-lint-ignore require-yield + *upsert(): Operation { + return { url: `${TRACKER}/issues/21` }; + }, + }, + }, + ); + expect(filed.refusal).toBe(undefined); + + // The record is in the history the identity table is built from, and it is + // not where positional replay will look for it: a prefix a fork inherited + // whose coroutine this execution does not reach. So the request is named by + // retained history and still arrives live, which is the one case that must + // perform nothing at all. + const moved = new InMemoryStream( + forked(filed.events, FORK).map((event) => + event.type === "yield" && event.description.type === ISSUE_EFFECT + ? { ...event, coroutineId: `${event.coroutineId}-elsewhere` } + : event, + ), + ); + const live = yield* run( + fixture, + WORKFLOW_ENTRY, + moved, + {}, + { + retained: true, + installations: workflowProfile(FORK), + }, + ); + + expect(live.seen.transport).toEqual([]); + expect(live.refusal ?? "").toContain("repeat work that already happened outside this journal"); + }); +}); diff --git a/packages/git/tests/support/issue-tracker-server.ts b/packages/git/tests/support/issue-tracker-server.ts index 720182fd2..80a7886b8 100644 --- a/packages/git/tests/support/issue-tracker-server.ts +++ b/packages/git/tests/support/issue-tracker-server.ts @@ -102,6 +102,15 @@ export interface ServerOptions { * observable while it is still running, and this is what holds one there. */ readonly hold?: () => Operation; + /** + * Run after the store has answered and before the answer is written. + * + * The other side of `hold`, and for the other interruption: a create this + * tracker accepted and the client never learned of. Holding here leaves the + * issue filed with no response on the wire, which is the one state a + * reconciliation key exists for. + */ + readonly settle?: (answer: { readonly status: number }) => Operation; } /** @@ -162,6 +171,9 @@ export function useIssueTrackerServer(options: ServerOptions = {}): Operation { + if (!(yield* exists(BINARY))) { + throw new Error( + `this row drives the compiled binary and ${BINARY} is not there. Run \`deno task build\` ` + + "first; a skipped row is missing evidence rather than a passing behavior.", + ); + } +} + +describe("the compiled binary's REPL entry issues", () => { + it("E8: carries the entry issue host, so a filed issue answers again", function* (): Operation { + yield* requireBinary(); + yield* replIssuesJourney(compiled); + }); + + it("E8: adopts an accepted create this end never learned of", function* (): Operation { + yield* requireBinary(); + yield* replIssuesAdoptionJourney(compiled); + }); +}); diff --git a/scripts/tests/runtime-exclusions.test.ts b/scripts/tests/runtime-exclusions.test.ts index 4daa45e54..d0ad5e638 100644 --- a/scripts/tests/runtime-exclusions.test.ts +++ b/scripts/tests/runtime-exclusions.test.ts @@ -83,6 +83,7 @@ describe("runtime exclusions", () => { "scripts/tests/component-form-dispatch.test.ts", "scripts/tests/plan-component-compiled.test.ts", "scripts/tests/plugin-compiled.test.ts", + "scripts/tests/repl-issues-compiled.test.ts", "scripts/tests/repl-repositories-compiled.test.ts", ]); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e902765d3..3795f6bbe 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10137,11 +10137,28 @@ creation, reuse, conflict and interrupted-adoption policy, and it records what the selector answered: a completed selection is answered from its record without calling a selector, cloning, adding a worktree, reading a sidecar, opening a credential or taking a lock, and only genuinely new work meets the current -filesystem. It installs nothing else the ordinary provider carries — no local -Git, no publication, no issue tracker and no external worker — so those -components refuse there exactly as they do under a host that installs no -provider for them. Constructing it acquires nothing, including no managed root, -and a passive reading of a settled history initializes nothing at all. +filesystem. It installs one external capability beside that: the issue +adapter, so `` files and reads in an entry and its answers are retained +on the same terms — a completed read answers with the snapshot it saw and a +completed upsert with the URL it was filed under, neither of them reaching a +tracker, a configuration or a credential. An interrupted upsert presents the key +it used the first time, which is what lets the provider answer with the issue it +already accepted rather than filing a second one; a read has no key. What the +entry provider still installs nothing of is local Git, publication and the +external worker — so ``, ``, ``, `` +and `` refuse there exactly as they do under a host that installs +no provider for them. Constructing it acquires nothing, including no managed root +and no configuration read, and a passive reading of a settled history +initializes nothing at all. + +The identity an entry's issue work is named by is the entry's own: the execution +and the entry's admission-order position, derived by the host that opened the +session and reaching the component as a trusted closure rather than as a prop, a +Context value, a tracker or a middleware answer. The authored site inside it is +the identity canonical core minted where it selected that implementation. The +`` an entry runs is an ordinary shadowable registration of the name the +Plugin already declares, so a repository component of that name keeps its +ordinary precedence and obtains no identity of the entry's. Node and Bun install none of them. They register the same declarations (§5.3), and every repository operation there reports an absent provider before a lock, a diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 884690f5c..85a2a891c 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -886,8 +886,54 @@ shares, and a new write, deletion or directory takes it too. Neither hold is taken to answer a completed selection or a completed file operation, and a read takes only the ownership of the checkout it is reading in. -Local Git, publication and issues remain unavailable here; the exclusions below -say which components those are. +Local Git and publication remain unavailable here; the exclusions below say +which components those are. Issues are not among them — the section below says +what an entry may file and read. + +### The issue an entry filed, and the one it read + +An entry files and reads issues wherever you started the REPL, inside a checkout +or not. Nothing about it needs one: no repository is discovered, no managed root +is made and no lock is taken. + +```markdown + + + Pasting two lines into the draft submits the first one. + + + + +Filed {found.url}, titled {found.title}. +``` + +`…` files one issue in the enclosing ``, +using its expanded content as the description, and binds `{ url }`. +`` reads one and binds the fields every tracker has: the URL, the +title, the description, the tags and the assignee. Both are the ordinary +components the language already documents, and a REPL entry writes them exactly +as any other document does. + +What is new is that both are retained. Leave the entry at a question and come +back to the printed location: the filed issue answers with the URL it was filed +under and the read answers with the snapshot it saw. No tracker is asked again, +no configuration is read and no credential is opened to answer either of them — +and the read still says what it said even if somebody has edited the issue since. + +An issue lives at a service rather than in your checkout, so the interrupted +case is the one that matters. An entry whose upsert was accepted by the tracker +and interrupted before this end could write it down retains nothing: continue it +and the same attempt is made again, under the same key it used the first time. +The tracker recognizes that key as its own mark and answers with the issue that +is already there. One issue is filed, not two. A read has no key and reconciles +nothing — an interrupted one is simply made again, which is what a second +observation of a live object is. + +A refused tracker stays refused. Whether this deployment may reach a target at +all is the operator's `XMD_WORKFLOW_GITHUB_ISSUES` ceiling, and a target outside +it sends no request and carries no credential. A conflict, an ambiguity or an +unavailable service keeps the provider's own vocabulary here exactly as it does +elsewhere. ## What the head says the execution is doing @@ -934,8 +980,8 @@ recorded output replaces it. ## Who owns what -Repository behavior belongs to the entry that is executing, and to nothing -wider. The command selects it; the entry's own task prepares it, once, inside +Repository and issue behavior belong to the entry that is executing, and to +nothing wider. The command selects it; the entry's own task prepares it, once, inside that task, and everything it takes — the Git session it reads through, where it looked for a checkout, the managed checkouts it selected, the hold on each of those and the hold on the repository it changes — is released when the entry is @@ -948,7 +994,16 @@ text. No second name for the entry appears in a record: which history an entry may continue is the range it was given. Reading is not owning: a settled history, a frozen position and a refused location prepare nothing, look for no checkout and hold nothing. A host that registers no recording runs the same -file work with nothing retained, which is what Node and Bun do. +file work with nothing retained, which is what Node and Bun do; `` there +reports that no provider is installed, exactly as it does under any other host +that installs none. + +An entry's issue work is named by the execution and the entry's own position in +it, and by nothing else. Two entries holding identical text are two entries; an +entry of another execution is somebody else's; and a tracker URL, a prop, a +component answer and anything else a document can write take no part in it. That +is what makes the interrupted upsert above answerable: the key is derived from +facts that survive leaving and coming back. Components receive immutable view data and answer with a typed semantic action. They hold no events, no history, no session, no repository and no host operation, @@ -979,11 +1034,15 @@ schedules no timer. different thing: it says one entry at a time may mutate one repository, and it says nothing about who may write this execution's history. - Local Git and publication are not part of this. ``, ``, - ``, ``, `` and `` refuse in a REPL - entry exactly as they do under any host that installs no provider for them. - What an entry retains is `` and `` selection and the - file and directory work inside those checkouts and the one it was started in; - nothing is staged, committed, pushed or published. + ``, `` and `` refuse in a REPL entry + exactly as they do under any host that installs no provider for them. What an + entry retains is `` and `` selection, the file and + directory work inside those checkouts and the one it was started in, and the + issues it filed and read; nothing is staged, committed, pushed or published. +- `` is an ordinary shadowable default here, as it is under every other + host. A repository component of that name keeps its ordinary precedence over + it, and what such a component does not get is the identity an entry's own + issue work is named by. - Generated selection is unchanged. An admitted generated `` read, write or `` is retained exactly as an authored one is, because both cross the one document filesystem. `` stays ordinary and is retained by @@ -995,9 +1054,13 @@ schedules no timer. deletes one. - Continuing an entry is explicit. Returning to the live head starts the one unfinished entry, once; a frozen position starts nothing at all. -- A continuation does not reconcile. Nothing compares a saved result with what - is on disk, reports a difference or offers to repair one — if what you are - looking at is not what you expected, that is for you to read and act on. +- A continuation does not reconcile what is on disk. Nothing compares a saved + file result with the filesystem, reports a difference or offers to repair one + — if what you are looking at is not what you expected, that is for you to + read and act on. An issue is the one exception, and it is the provider's + rather than this command's: an interrupted upsert presents the key it used + the first time, and the tracker answers with the issue it already filed under + that mark. - `xmd repl` takes one optional location and the five Agent options above. It renders no file and takes no document reference. - `` is unavailable. This command has no terminal to give away — diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 149908941..66558a29c 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -2768,6 +2768,21 @@ with no envelope at all: identity is that execution's own opaque invocation identity together with the engine's expansion identity, an upsert presents an idempotency key derived from them, and nothing is retained. +A REPL entry retains its issue work too, under this same effect type and these +same records, and names it after the entry rather than after a run: a REPL entry +is not a WorkflowRun and installs no synthetic one. What the entry adds is where +the live attempt goes. One durable boundary encloses the operation; inside a +live attempt the trusted implementation composes `IssueOperations` — so a +document's own middleware answers, transforms, refuses and delegates there as it +does anywhere — over a transport-facing default of its own that reaches the +`IssueApi` provider and adds no second envelope. The composed answer is what the +record holds, and a middleware answer is that composition's outcome rather than +proof that a service was touched. A completed replay reaches none of it: not the +middleware, not the transport, not the configuration and not a credential. The +retained wrapper described below is the workflow run's own and is never nested +beneath the entry's, because two envelopes over one operation would retain it +twice. + An **Issue provider** is an external service that owns a collection of issues. GitHub and Atlassian Cloud are Issue-provider adapters. From 1dc2ac59d040ab8ba2fd84281051f5377644cd01 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 14:43:39 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20Close=20the=20three=20frozen=20?= =?UTF-8?q?evidence=20gaps=20an=20entry's=20issues=20left=20open=20(#181,?= =?UTF-8?q?=206/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E1 had no production row where public middleware *answers* a read: the one there delegated, so a replay reaching the read chain was undiscriminated. A read answered by the chain without a transport is now retained and the partial entry is reopened with both IssueOperations and IssueApi set to fail if either is entered. E2 proved two authored sites independent but never one site evaluated twice. A bounded `` of two iterations writes the same text both times, so an identity derived from the element or from what it says would collapse them. E6 observed a successor refusal but not that leaving *waits*. An entry-owned finalizer registered around the live dispatch is held open through the cancellation: leaving stays unfinished while it is held and joins when it is released. The cancelled-append row gains a suffix sentinel and the absence of a root close. --- packages/cli/tests/repl-issues.test.ts | 282 ++++++++++++++++++++++++- 1 file changed, 279 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 5c06d59d5..5b32d37ea 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -16,7 +16,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, suspend, until, withResolvers } from "effection"; +import { ensure, resource, scoped, spawn, suspend, until, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import { exists, writeTextFile } from "@effectionx/fs"; import { rm } from "@effectionx/fs"; @@ -46,6 +46,7 @@ import type { IssueReadInvocation, IssueUpsertInvocation, } from "../../git/src/issue/operations.ts"; +import { IssueApi } from "../../git/src/issue/api.ts"; import type { IssueDetails, IssueReference } from "../../git/src/issue/api.ts"; import { replIssueInstallation } from "../../git/src/deno/retained-repository/issues.ts"; import { gitHubSource } from "../../git/src/deno/composition/github.ts"; @@ -567,9 +568,122 @@ function failingRecording(acquired: Acquisitions, root: string): Operation }); } +/** The normalized read one public middleware answers with, without a transport. */ +const ANSWERED: IssueDetails = Object.freeze({ + url: `${TRACKER}/issues/1`, + title: "What the middleware answered", + description: "answered without reaching a tracker", + tags: Object.freeze(["alpha", "beta"]), + assignee: null, +}); + +/** + * Ordinary public middleware that answers a read itself. + * + * The other half of what the seam is for: a handler may answer instead of + * delegating, and what the envelope then retains is that answer. Nothing below + * it runs, so neither the entry's transport-facing default nor the adapter is + * reached at all. + */ +function answeringChain(chain: Chain, details: IssueDetails): ExecutionInstallation { + return { + *install(): Operation { + yield* IssueOperations.around({ + // deno-lint-ignore require-yield + *read([invocation]): Operation { + chain.calls.push(`read ${invocation.url}`); + return details; + }, + *upsert([invocation], next): Operation { + chain.calls.push(`upsert ${invocation.target}`); + return yield* next(invocation); + }, + }); + }, + }; +} + +/** How many times each chain was entered, which on a replay must be never. */ +interface Chains { + operations: number; + api: number; +} + +/** + * Both chains, set to fail if anything reaches them. + * + * A replay that invoked middleware, or reached a transport before matching its + * record, fails here rather than passing a count that happened to be right. + */ +function refusingChains(counted: Chains): ExecutionInstallation { + return { + *install(): Operation { + yield* IssueOperations.around({ + // deno-lint-ignore require-yield + *read(): Operation { + counted.operations += 1; + throw new Error("a replayed read reached the profile chain"); + }, + // deno-lint-ignore require-yield + *upsert(): Operation { + counted.operations += 1; + throw new Error("a replayed upsert reached the profile chain"); + }, + }); + yield* IssueApi.around({ + // deno-lint-ignore require-yield + *read(): Operation { + counted.api += 1; + throw new Error("a replayed read reached the transport chain"); + }, + // deno-lint-ignore require-yield + *upsert(): Operation { + counted.api += 1; + throw new Error("a replayed upsert reached the transport chain"); + }, + }); + }, + }; +} + +/** One entry-owned cleanup, and the two sides of holding it open. */ +interface Cleanup { + /** Resolved when the entry's teardown has entered the finalizer. */ + readonly entered: { resolve(value: void): void; operation: Operation }; + /** Awaited inside the finalizer, so teardown cannot complete until released. */ + readonly release: { resolve(value: void): void; operation: Operation }; +} + +/** + * A finalizer the entry's own scope owns, registered around the live dispatch. + * + * Registered before the work it guards, so it is released after it — which is + * the ordering that makes it a cleanup rather than a hope. Holding it open is + * how a row observes that leaving an entry *waits*: the request is cancelled, + * this runs, and nothing about the entry is finished until it returns. + */ +function heldCleanup(cleanup: Cleanup): ExecutionInstallation { + return { + *install(): Operation { + yield* IssueOperations.around({ + *upsert([invocation], next): Operation { + yield* ensure(function* (): Operation { + cleanup.entered.resolve(); + yield* cleanup.release.operation; + }); + return yield* next(invocation); + }, + }); + }, + }; +} + /** One entry that files one issue, and one that leaves a question after it. */ const FILES_ONE = `a described issue`; +/** What a row watches for a suffix effect that must not have happened. */ +const SUFFIX = 'continued'; + const QUESTION = 'Continue?'; @@ -814,7 +928,7 @@ describe("how a REPL entry gets its issues", () => { opened( yield* submitReplEntry({ execution: run, - source: FILES_ONE, + source: [FILES_ONE, SUFFIX].join("\n"), installations, generated: ordinaryGeneratedSelection(), entryInstallation: entryHost(fixture, seen), @@ -828,6 +942,10 @@ describe("how a REPL entry gets its issues", () => { expect(fixture.server.issues.length).toBe(1); const mark = bodies(fixture.server)[0]; expect(issueRecords(stream.events).length).toBe(0); + // A cancelled append publishes no outcome: there is no root close in the + // history, and what the document wrote after the issue never ran. + expect(stream.events.some((event) => event.type === "close")).toBe(false); + expect(yield* exists(join(fixture.checkout, "after.md"))).toBe(false); // Reopened. The same execution and the same entry, so the same owner and // the same key — and the adapter finds its own mark and adopts rather than @@ -845,6 +963,8 @@ describe("how a REPL entry gets its issues", () => { expect(paths(fixture.server).filter((path) => path.startsWith("POST"))).toEqual(posts); expect(urls(again.events)).toEqual([`${TRACKER}/issues/1`]); expect(seen.owners[0]).toBe(seen.owners[1]); + // And the suffix runs now, for the first time, on the continuation. + expect(yield* exists(join(fixture.checkout, "after.md"))).toBe(true); }); it("E3: an acknowledged upsert replays with no provider call at all", function* (): Operation { @@ -1048,7 +1168,7 @@ describe("how a REPL entry gets its issues", () => { it("E6: a journal that cannot retain the result allows no suffix effect", function* (): Operation { const fixture = yield* useFixture(); const stream = refusingStream(); - const source = [FILES_ONE, 'continued'].join("\n"); + const source = [FILES_ONE, SUFFIX].join("\n"); const ran = yield* submit( fixture, @@ -1147,6 +1267,162 @@ describe("how a REPL entry gets its issues", () => { expect(paths(fixture.server).length).toBe(before); }); + it("E1: retains what public middleware answered, and replays past both chains", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + const chain: Chain = { calls: [] }; + const source = [ + ``, + 'a described issue', + ``, + "", + QUESTION, + ].join("\n"); + + const run = execution("execution-1"); + const left = yield* submit(fixture, source, run, { + host: entryHost(fixture, seen), + installations: [answeringChain(chain, ANSWERED)], + leave: true, + }); + + // The read was answered by the chain and never reached a transport; the + // upsert delegated through it and did, which is the list-then-create its + // own reconciliation makes. + expect(chain.calls).toEqual([`upsert ${TRACKER}`, `read ${TRACKER}/issues/1`]); + expect(paths(fixture.server).filter((path) => path.endsWith("/issues/1"))).toEqual([]); + const sent = paths(fixture.server); + const retained = issueRecords(left.events).map((event) => + event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, + ); + // Exactly what the middleware answered, normalized and retained. + expect(retained).toEqual([ + { url: `${TRACKER}/issues/1` }, + { ...ANSWERED, tags: [...ANSWERED.tags] }, + ]); + + // Reopened with both chains set to fail if anything reaches them. A replay + // that asked middleware first, or a transport at all, fails here. + const refusing: Chains = { operations: 0, api: 0 }; + const again = yield* reopen( + fixture, + { id: run.id, stream: run.stream }, + { + host: entryHost(fixture, seen), + installations: [refusingChains(refusing)], + leave: true, + }, + ); + + expect(refusing).toEqual({ operations: 0, api: 0 }); + expect( + issueRecords(again.events).map((event) => + event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, + ), + ).toEqual(retained); + // Not one further request of any kind: both records answered from the + // journal, so neither chain and neither destination was asked anything. + expect(paths(fixture.server)).toEqual(sent); + }); + + it("E2: two evaluations of one authored site are two sites", function* (): Operation { + const fixture = yield* useFixture(); + const seen = counted(); + // One element, evaluated twice, with the same text both times: if an + // identity were derived from the element or from what it says, these two + // would collapse into one — one issue filed, one key, one record. + const source = [ + ``, + '', + 'the same described issue', + "", + "", + QUESTION, + ].join("\n"); + + const run = execution("execution-1"); + const left = yield* submit(fixture, source, run, { + host: entryHost(fixture, seen), + leave: true, + }); + + // Two canonical sites, two keys, two issues. + const sites = identities(left.events); + expect(sites.length).toBe(2); + expect(sites[0]).not.toBe(sites[1]); + expect(new Set(sites.map((site) => site.split("\u0000")[0])).size).toBe(1); + expect(fixture.server.issues.length).toBe(2); + expect(new Set(bodies(fixture.server)).size).toBe(2); + const posts = paths(fixture.server).filter((path) => path.startsWith("POST")); + expect(posts.length).toBe(2); + + // And the iteration a record belongs to survives the reopen: the same two + // sites, in the same order, with nothing filed again. + const again = yield* reopen( + fixture, + { id: run.id, stream: run.stream }, + { + host: entryHost(fixture, seen), + leave: true, + }, + ); + expect(identities(again.events)).toEqual(sites); + expect(paths(fixture.server).filter((path) => path.startsWith("POST"))).toEqual(posts); + expect(fixture.server.issues.length).toBe(2); + }); + + it("E6: leaving waits for the request cleanup the entry owns", function* (): Operation { + const blocked = withResolvers(); + const arrived = withResolvers(); + const holding = { count: 0 }; + const fixture = yield* useFixture([], { + *hold(): Operation { + holding.count += 1; + arrived.resolve(); + yield* blocked.operation; + }, + }); + const cleanup: Cleanup = { entered: withResolvers(), release: withResolvers() }; + const run = execution("execution-1"); + + let finished = false; + const leaving = yield* spawn(function* (): Operation { + yield* scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + const installations = [...(yield* replInstallations()), heldCleanup(cleanup)]; + opened( + yield* submitReplEntry({ + execution: run, + source: FILES_ONE, + installations, + generated: ordinaryGeneratedSelection(), + entryInstallation: entryHost(fixture, counted()), + }), + ); + // Left while the request is in flight, which is what makes the cleanup + // below a cancellation rather than an ordinary release. + yield* arrived.operation; + }); + finished = true; + }); + + // The entry's own finalizer has started, so the cancellation reached it. + yield* cleanup.entered.operation; + // And leaving is not finished: the scope cannot complete while a cleanup + // registered inside it has not returned. Nothing is retained either. + expect(finished).toBe(false); + expect(settledIssueRecords(yield* run.stream.readAll()).length).toBe(0); + + // Released, and only now does leaving join. + cleanup.release.resolve(); + yield* leaving; + expect(finished).toBe(true); + expect(holding.count).toBeGreaterThan(0); + expect(settledIssueRecords(yield* run.stream.readAll()).length).toBe(0); + }); + it("E2: shadows the inherited default, and refuses where no host selected one", function* (): Operation { const fixture = yield* useFixture(); const seen = counted(); From 63290a5f1b8b9ddb80bb5ccecdbdf5c9298be1f0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 15:09:22 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9A=A8=20Declare=20the=20two=20new=20?= =?UTF-8?q?Git=20modules,=20and=20reach=20the=20public=20Api=20by=20its=20?= =?UTF-8?q?subpath=20(#181,=206/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `module-partition.test.ts` is in none of this delivery's frozen commands, so both of its rules went unchecked until CI ran them on every runtime. A production module belongs to exactly one declared half: `issue/owned.ts` is provider-neutral and `deno/retained-repository/issues.ts` is the Deno adapter's, beside the file and selection recording it sits with. And a test reaching `../src/issue/api.ts` bypasses the export map, so every name `/api` publishes is a violation there. `IssueApi` and the five contract types now come from `@executablemd/git/api`, which is the route a consumer has. --- packages/cli/tests/repl-issues.test.ts | 4 ++-- packages/git/tests/issue-owned.test.ts | 4 ++-- packages/git/tests/module-partition.test.ts | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 5b32d37ea..3824498f8 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -46,8 +46,8 @@ import type { IssueReadInvocation, IssueUpsertInvocation, } from "../../git/src/issue/operations.ts"; -import { IssueApi } from "../../git/src/issue/api.ts"; -import type { IssueDetails, IssueReference } from "../../git/src/issue/api.ts"; +import { IssueApi } from "@executablemd/git/api"; +import type { IssueDetails, IssueReference } from "@executablemd/git/api"; import { replIssueInstallation } from "../../git/src/deno/retained-repository/issues.ts"; import { gitHubSource } from "../../git/src/deno/composition/github.ts"; import { useIssueTrackerServer } from "../../git/tests/support/issue-tracker-server.ts"; diff --git a/packages/git/tests/issue-owned.test.ts b/packages/git/tests/issue-owned.test.ts index 1cecab613..a4543833d 100644 --- a/packages/git/tests/issue-owned.test.ts +++ b/packages/git/tests/issue-owned.test.ts @@ -39,14 +39,14 @@ import { retainedWorkflowInstallation } from "@executablemd/workflow"; import { WORKFLOW_RUN } from "../../workflow/src/journal.ts"; import type { WorkflowRun } from "@executablemd/workflow"; import { ISSUE_EFFECT } from "../src/issue/effect-type.ts"; -import { IssueApi } from "../src/issue/api.ts"; +import { IssueApi } from "@executablemd/git/api"; import type { IssueDetails, IssueInput, IssueReadOptions, IssueReference, IssueUpsertOptions, -} from "../src/issue/api.ts"; +} from "@executablemd/git/api"; import { readOwnedIssue, upsertOwnedIssue } from "../src/issue/owned.ts"; import { useRetainedIssueOperations } from "../src/issue/effect.ts"; import { useCompositionComponents } from "../src/composition/installation.ts"; diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 2614bc429..762290021 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -88,6 +88,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/issue/errors.ts", "src/issue/identities.ts", "src/issue/operations.ts", + "src/issue/owned.ts", "src/issue/records.ts", "src/issue/tracker.ts", "src/plugin.ts", @@ -160,6 +161,7 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/composition/switch.ts", "src/deno/composition/worktree.ts", "src/deno/retained-repository/files.ts", + "src/deno/retained-repository/issues.ts", "src/deno/retained-repository/leases.ts", "src/deno/retained-repository/local.ts", "src/deno/retained-repository/provider.ts", From 5e413b5ac98745c92a12db0de39974e47494f0a9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 16 Oct 2026 15:18:42 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Select=20a=20REPL=20en?= =?UTF-8?q?try's=20capability=20where=20the=20command=20asks=20for=20one?= =?UTF-8?q?=20(#181,=206/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inert factory was bound unconditionally at the Deno and compiled entrypoints, before any command was dispatched, so `xmd run` and `xmd --help` carried a choice only `xmd repl` has any use for. A host now answers two questions rather than one. `ReplHostAssembly` pairs the selection with the installation, and the REPL command path asks for the selection where it belongs — after every refusal, before the profile that captures it — while installing stays exactly where it was. Reordering the install instead would have inverted two `Execution.around` layers, because profile assembly installs one of its own and composition order is installation order. Node and Bun supply the install half and no selection, which is the same provider absence they had: `` in an entry there reaches its ordinary missing-provider refusal. --- packages/cli/src/bun.ts | 5 ++- packages/cli/src/cli.ts | 50 +++++++++++++++++++--- packages/cli/src/compiled.ts | 13 +++--- packages/cli/src/deno-repositories.ts | 13 ++++++ packages/cli/src/deno.ts | 13 +++--- packages/cli/src/node.ts | 5 ++- packages/cli/tests/repl-boundaries.test.ts | 34 +++++++++++++-- packages/cli/tests/repl-issues.test.ts | 50 +++++++++++++++++++++- 8 files changed, 154 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/bun.ts b/packages/cli/src/bun.ts index e558e9f4d..bd7d40bf8 100644 --- a/packages/cli/src/bun.ts +++ b/packages/cli/src/bun.ts @@ -75,6 +75,9 @@ await main(function* (args) { importPluginModule, unsupportedWorkflowHost, unassembledMachineSessions(), - useBunRepl, + // A terminal and a history, and no entry capability: this runtime names + // no native adapter, so `` in an entry reaches its ordinary + // missing-provider refusal. + { install: useBunRepl }, ); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1379e5383..019a31ec9 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -598,6 +598,34 @@ const REPL_HELP = [ */ export type ReplHostInstaller = () => Operation; +/** + * Select what this command's entries run with, before its profile is built. + * + * Separate from installing, because the two happen at different moments and + * only one of them is a choice. An entry's capability is captured by the + * profile this command assembles once, so it has to be selected before that; + * everything the host *installs* belongs where it already is, beneath the + * middleware the profile puts in place. + * + * Selecting is inert. It names code and this deployment's configuration and + * reaches no provider, credential, network, repository or terminal — so a + * command that selects and then refuses has touched nothing. + */ +export type ReplEntryHostSelector = () => Operation; + +/** + * How a runtime assembles the REPL: what it selects, then what it installs. + * + * One value rather than two parameters, because the two halves are one host's + * answer and a host that supplied only one of them would be half a REPL. A + * runtime that selects nothing leaves its entries with no entry capability, + * which is what Node and Bun do. + */ +export interface ReplHostAssembly { + readonly select?: ReplEntryHostSelector; + readonly install: ReplHostInstaller; +} + /** * What fixed grammar refuses about one `xmd repl` command line. * @@ -2656,7 +2684,7 @@ function* dispatch( readStandardInput: StandardInputReader, workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, - installRepl: ReplHostInstaller | undefined, + replHost: ReplHostAssembly | undefined, /** * What this invocation's Plugins installed. * @@ -2894,7 +2922,7 @@ function* dispatch( if (replStack === undefined) { break; } - if (installRepl === undefined) { + if (replHost === undefined) { console.error( "xmd repl: this host assembles no interactive terminal, so there is nothing to open.", ); @@ -2906,8 +2934,16 @@ function* dispatch( // vocabulary and the ceiling a generated fragment runs under. Nothing is // assembled again per entry, so two entries of one session cannot run // under different rules. + // The host's own selection, made here and for this command alone: an + // entry capability is a fact about `xmd repl`, so no other command line + // selects one, and a line that refuses after this has still selected + // nothing but code. It is made before the profile below, because that is + // what captures it. + if (replHost.select !== undefined) { + yield* replHost.select(); + } const profile = yield* assembleReplProfile(replStack, plugins); - yield* installRepl(); + yield* replHost.install(); const ran = yield* runReplProgram({ profile, ...(command.config.location === undefined ? {} : { location: command.config.location }), @@ -3301,7 +3337,7 @@ export function* runXmd( // which is what keeps raw mode and a data directory off their path. A host that // names none has no REPL, and says so rather than opening a terminal it cannot // restore. - installRepl?: ReplHostInstaller, + replHost?: ReplHostAssembly, ): Operation { // Before every scanner and before anything reads a path: what a command line // selects is read from the argv the caller wrote, and the tokens that @@ -3334,7 +3370,7 @@ export function* runXmd( loadPluginModule, installWorkflowHost, sessions, - installRepl, + replHost, ); } @@ -3395,7 +3431,7 @@ function* runCommand( loadPluginModule: PluginModuleLoader, installWorkflowHost: HostWorkflowInstaller, sessions: MachineSessionAssembly | undefined, - installRepl: ReplHostInstaller | undefined, + replHost: ReplHostAssembly | undefined, ): Operation { // First, so that no later scanner — help, properties, agent flags — can // mistake the inline document's own text for an option. @@ -3459,7 +3495,7 @@ function* runCommand( readStandardInput, workflowHost, sessions, - installRepl, + replHost, plugins, ); diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 6028c3da8..66199faa7 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -16,8 +16,7 @@ import { importPluginModule } from "./host-plugin-modules.ts"; import { compiledUpgradeAssembly } from "./compiled-upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoReplIssues, denoRunRepositories } from "./deno-repositories.ts"; -import { ReplEntryHost } from "./repl-entry-installation.ts"; +import { denoRunRepositories, selectReplEntryHost } from "./deno-repositories.ts"; import { BundledRunPlugin } from "./run-profile.ts"; import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; @@ -91,11 +90,6 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // the value and imports no adapter, and an ordinary execution installs it // in its own scope. yield* BundledRunPlugin.set(denoRunRepositories(HELPER)); - // The issue capability a REPL entry runs with, selected on the same terms - // and for the same reason: the adapter is this runtime's, and the entry that - // owns one is chosen by the session rather than by the host. Node and Bun - // select none, so `` there reaches its missing-provider refusal. - yield* ReplEntryHost.set(denoReplIssues); yield* runXmd( args, useCompiledService, @@ -104,7 +98,10 @@ if (isCredentialHelperMode(process.argv.slice(2))) { importPluginModule, () => useDenoWorkflowHost(HELPER), useMachineSessions(), - () => useCompiledRepl(HELPER), + // What this host selects for a REPL command, and what it installs for + // one. The selection is the issue capability an entry runs with; Node + // and Bun supply neither half. + { select: selectReplEntryHost, install: () => useCompiledRepl(HELPER) }, ); }); } diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index 869b5e18d..ec3ac3f8e 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -36,6 +36,7 @@ import { } from "@executablemd/git/deno"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; +import { ReplEntryHost } from "./repl-entry-installation.ts"; import type { ReplEntryInstallation } from "./repl-entry-installation.ts"; /** @@ -123,6 +124,18 @@ export function denoRunRepositories( export const denoReplIssues: ReplEntryInstallation = (owner: string) => replIssueInstallation(owner); +/** + * Select that value for the REPL command that is about to assemble itself. + * + * Called by the shared command path at the one moment it belongs — before the + * profile that captures it — and reached from the entrypoints that may name + * this adapter. Setting it establishes nothing: the value is inert code, and + * what an entry does with it happens inside that entry. + */ +export function* selectReplEntryHost(): Operation { + yield* ReplEntryHost.set(denoReplIssues); +} + /** * This deployment's helper configuration, copied by value. * diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 3f30b6e60..5a6fa3e9a 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -19,8 +19,7 @@ import { importPluginModule } from "./host-plugin-modules.ts"; import type { UpgradeAssembly } from "./upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoReplIssues, denoRunRepositories } from "./deno-repositories.ts"; -import { ReplEntryHost } from "./repl-entry-installation.ts"; +import { denoRunRepositories, selectReplEntryHost } from "./deno-repositories.ts"; import { BundledRunPlugin } from "./run-profile.ts"; import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; @@ -110,11 +109,6 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // the value and imports no adapter, and an ordinary execution installs it // in its own scope. yield* BundledRunPlugin.set(denoRunRepositories(HELPER)); - // The issue capability a REPL entry runs with, selected on the same terms - // and for the same reason: the adapter is this runtime's, and the entry that - // owns one is chosen by the session rather than by the host. Node and Bun - // select none, so `` there reaches its missing-provider refusal. - yield* ReplEntryHost.set(denoReplIssues); yield* runXmd( args, useDenoService, @@ -123,7 +117,10 @@ if (isCredentialHelperMode(process.argv.slice(2))) { importPluginModule, () => useDenoWorkflowHost(HELPER), useMachineSessions(), - () => useDenoRepl(HELPER), + // What this host selects for a REPL command, and what it installs for + // one. The selection is the issue capability an entry runs with; Node + // and Bun supply neither half. + { select: selectReplEntryHost, install: () => useDenoRepl(HELPER) }, ); }); } diff --git a/packages/cli/src/node.ts b/packages/cli/src/node.ts index adc545d7d..2c8bcbd18 100755 --- a/packages/cli/src/node.ts +++ b/packages/cli/src/node.ts @@ -83,6 +83,9 @@ await main(function* (args) { importPluginModule, unsupportedWorkflowHost, unassembledMachineSessions(), - useNodeRepl, + // A terminal and a history, and no entry capability: this runtime names + // no native adapter, so `` in an entry reaches its ordinary + // missing-provider refusal. + { install: useNodeRepl }, ); }); diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index ae576fa92..6c41fe4d5 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -671,6 +671,8 @@ interface Invocation { readonly status: number; /** How many times the host's REPL assembly was entered. */ readonly installed: number; + /** How many times this host was asked what its entries run with. */ + readonly selected: number; } /** @@ -684,6 +686,7 @@ interface Invocation { function* invoke(args: readonly string[]): Operation { let status = 0; let installed = 0; + let selected = 0; const wrote = console.log; const warned = console.error; return yield* scoped(function* (): Operation { @@ -706,8 +709,13 @@ function* invoke(args: readonly string[]): Operation { refusedPluginModules, unsupportedWorkflowHost, undefined, - function* (): Operation { - installed += 1; + { + select: function* (): Operation { + selected += 1; + }, + install: function* (): Operation { + installed += 1; + }, }, ); } catch { @@ -716,7 +724,7 @@ function* invoke(args: readonly string[]): Operation { // whether it got that far, so the failure is an outcome rather than an end. status = 1; } - return { status, installed }; + return { status, installed, selected }; }); } @@ -736,6 +744,26 @@ describe("REPL command: what runs before the host is reached", () => { expect(ran.status).toBe(0); // Describing a command is not running one. expect(ran.installed).toBe(0); + // Nor is it choosing what its entries would run with. + expect(ran.selected).toBe(0); + } + }); + + it("S1: only a REPL command line asks this host what its entries run with", function* () { + // A command line that really opens the REPL asks once, before the profile + // it assembles captures the answer, and fails afterwards only because this + // host has no terminal to give it. + const opening = yield* invoke(["repl"]); + expect(opening.selected).toBe(1); + expect(opening.installed).toBe(1); + + // Every other command line asks not at all: an entry capability is a fact + // about `xmd repl`, and selecting one anywhere else would be a choice no + // command made. + for (const args of [["--version"], ["run", "--help"], ["syntax", "--help"]]) { + const ran = yield* invoke(args); + expect(ran.selected).toBe(0); + expect(ran.installed).toBe(0); } }); diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 3824498f8..606bf9885 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -58,7 +58,15 @@ import type { import { gitHubAccessFor } from "../../git/tests/support/issue-providers.ts"; import type { GitHubIssuesOptions } from "@executablemd/git/deno"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; -import { useDenoReplRepositories } from "../src/deno-repositories.ts"; +import { assembleReplProfile } from "../src/repl-profile.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; +import { resolveAgentStack } from "../src/agent-stack.ts"; +import { NO_PLUGINS } from "../src/plugin-host.ts"; +import { + denoReplIssues, + selectReplEntryHost, + useDenoReplRepositories, +} from "../src/deno-repositories.ts"; import { ReplHost } from "../src/repl/host.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -1423,6 +1431,46 @@ describe("how a REPL entry gets its issues", () => { expect(settledIssueRecords(yield* run.stream.readAll()).length).toBe(0); }); + it("E5: the REPL profile captures the host's selected entry factory, and absence", function* (): Operation { + const flags = { + agentProvider: "acpx", + defaultAgent: "claude", + approveAll: false, + approveReads: false, + denyAll: true, + }; + const planWriterRoot = yield* until( + realpath(yield* until(mkdtemp(join(tmpdir(), "repl-issues-profile-")))), + ); + + // With the host's selection in scope — made by the production selector, in + // the command scope, before the profile is assembled. + const selected = yield* scoped(function* (): Operation { + const stack = yield* resolveAgentStack(flags, undefined); + if (!stack.ok) { + throw stack.error; + } + yield* selectReplEntryHost(); + return yield* assembleReplProfile(stack.value, NO_PLUGINS, { planWriterRoot }); + }); + expect(selected.entryInstallation).toBe(denoReplIssues); + + // And without it: the same assembly, in a scope nothing selected in, leaves + // the member absent rather than reaching for a default. + const absent = yield* scoped(function* (): Operation { + const stack = yield* resolveAgentStack(flags, undefined); + if (!stack.ok) { + throw stack.error; + } + return yield* assembleReplProfile(stack.value, NO_PLUGINS, { planWriterRoot }); + }); + expect(absent.entryInstallation).toBe(undefined); + + // Selecting reaches nothing: no issue host exists yet, because an + // installation is built per entry and this command has run none. + expect(typeof selected.entryInstallation).toBe("function"); + }); + it("E2: shadows the inherited default, and refuses where no host selected one", function* (): Operation { const fixture = yield* useFixture(); const seen = counted();