From fe56ba1f62b78be4bd64fa10e131cbc867339857 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 13:25:36 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Share=20one=20reposito?= =?UTF-8?q?ry=20component=20across=20every=20host=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``, ``, its three evidence reads and `` had two implementations each: the bundled one every document command registered, and a second authored body a REPL entry installed over it with its own identity behind it. Typing the same element meant different code depending on which host was running, which is the boundary violation this repairs. Each of those six now has one implementation, in the provider-neutral half of `@executablemd/git`. A body interprets its props and forms, expands its own content, normalizes what the document wrote, asks a semantic feature operation — `GitApi["push"]`, `IssueOperationsApi` or `PullRequestOperationsApi` — and projects what came back. None of them receives an authenticator, an owner, a site, native access, a policy object, a record codec or any publication state, and none names a journal record. Between the component and the host is one feature-owned binding. For a host that retains work named after an invocation it authenticates the invocation canonical core issued — once, in the engine frame the component was entered in, before any content is projected — then builds that invocation's own operation facade from the host's policy and runs the one body against it. Revocation is armed before the facade can be used and runs on success, failure and cancellation, so a facade kept past its invocation performs nothing, and one authored element performs one external operation. A host that retains nothing named after an invocation registers those six ordinarily over the same bodies with no identity authenticated, because there is nothing for one to name — so they stay ordinary defaults a nearer registration shadows. A `repl` command registers the other six and receives these as declarations carrying the entry's policy, so no executable default is registered for a declaration to shadow. `deno/retained-repository/{push,issues}` are policy now: `replPushPolicy` and `replIssuePolicy` replace `replPushComponent` and `ownedIssueComponent`, and the retention, dispatch, reconciliation, envelope and transport beneath them are unchanged. Retained-entry `` stays unavailable: its declaration uses the common construction with the ordinary policy, which is its own missing-provider refusal. Nothing observable moves. The owner is still `canonicalJson(["executablemd.repl.issue-owner",1,execution.id,entryKey])`, the site is still the authenticated `expansion.id`, and the Push v1 request, its digest domain, `git_host_effect`, `issue_effect`, the fingerprints, the idempotency keys, the coroutine order and `COMPOSITION_ORIGIN`'s bytes are untouched. There is no migration, alias or journal rewrite. Seven histories the merged implementation wrote are the evidence for that. `packages/git/tests/fixtures/retained-repository-history/` holds their exact sources and journal bytes, captured from old main `7ee526665dcadd9ec79dca9f8b6a4726903d9a4f` before production was edited, with a receipt naming the revision, the command, the owner, the paths each record addresses, where each history was cut and what service state the capture left behind. Each is cut while its root is unfinished, so continuing one re-enters the component rather than replaying a settled root, and the new suite continues all seven through the shared components without regenerating a byte. --- architecture.md | 83 +- packages/cli/src/cli.ts | 16 +- packages/cli/src/plan-component.ts | 2 +- packages/cli/src/plugin-host.ts | 18 + packages/cli/src/repl-profile.ts | 14 +- packages/cli/src/repl/program.ts | 1 + packages/cli/src/repl/session.ts | 30 +- packages/cli/src/run-profile.ts | 36 +- packages/cli/src/syntax.ts | 5 +- .../repl/issue-881/controlled-host.ts | 1 + .../cli/tests/repl-agent-interface.test.ts | 1 + packages/cli/tests/repl-issues.test.ts | 39 +- packages/cli/tests/repl-journey.test.ts | 1 + packages/cli/tests/repl-push.test.ts | 15 +- packages/git/deno.ts | 10 +- packages/git/mod.ts | 22 + .../git/src/composition/components/GitPush.ts | 105 ++- .../git/src/composition/components/Issue.ts | 138 ++- .../src/composition/components/PullRequest.ts | 71 +- .../components/PullRequestReads.ts | 121 ++- packages/git/src/composition/installation.ts | 335 +++++--- .../git/src/composition/invocation-binding.ts | 181 ++++ .../src/deno/retained-repository/issues.ts | 159 ++-- .../src/deno/retained-repository/provider.ts | 42 +- .../git/src/deno/retained-repository/push.ts | 133 +-- packages/git/src/plugin.ts | 38 +- .../composition-component-binding.test.ts | 502 +++++++++++ .../h1-push-completed/journal.json | 538 ++++++++++++ .../h1-push-completed/source.md | 12 + .../h2-push-pending/journal.json | 441 ++++++++++ .../h2-push-pending/source.md | 12 + .../h3-push-unacknowledged/journal.json | 441 ++++++++++ .../h3-push-unacknowledged/source.md | 12 + .../h4-push-failed/journal.json | 463 ++++++++++ .../h4-push-failed/source.md | 12 + .../h5-issue-completed/journal.json | 174 ++++ .../h5-issue-completed/source.md | 9 + .../h6-issue-unacknowledged/journal.json | 62 ++ .../h6-issue-unacknowledged/source.md | 8 + .../h7-issue-failed/journal.json | 99 +++ .../h7-issue-failed/source.md | 8 + .../retained-repository-history/receipt.json | 386 +++++++++ packages/git/tests/module-partition.test.ts | 1 + .../tests/retained-repository-history.test.ts | 798 ++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + specs/executable-mdx-spec.md | 28 +- specs/repl-spec.md | 11 +- specs/workflow-workspace-spec.md | 10 +- 48 files changed, 5185 insertions(+), 465 deletions(-) create mode 100644 packages/git/src/composition/invocation-binding.ts create mode 100644 packages/git/tests/composition-component-binding.test.ts create mode 100644 packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json create mode 100644 packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md create mode 100644 packages/git/tests/fixtures/retained-repository-history/receipt.json create mode 100644 packages/git/tests/retained-repository-history.test.ts diff --git a/architecture.md b/architecture.md index e0c9e5261..b94f68981 100644 --- a/architecture.md +++ b/architecture.md @@ -5181,13 +5181,56 @@ The bundled Git Plugin declares sixteen ordinary shadowable defaults: `Git.Files`, `Git.Changes`, `Git.Diff`, `Git.File`, `PullRequest`, `PullRequest.Reviews`, `PullRequest.Comments`, `PullRequest.Checks`, `IssueTracker` and `Issue`. Dir is core-protected and -remains available without this Plugin. One declaration array serves execution, +remains available without this Plugin. One declaration source serves execution, syntax inspection, Plan validation/generation and executing workflow actions. A Workspace attachment installs providers and durable state, not vocabulary. Declaration alone starts nothing: no provider, repository discovery, lock, Git process or credential read. A repository component may shadow any of these ordinary defaults. +Six of the twelve reach something no local transaction encloses — `Git.Push`, +`PullRequest`, its three evidence reads and `Issue` — and each has exactly one +implementation, which asks a semantic feature operation for its work. A command +whose executions retain nothing named after an invocation registers all twelve; +a command whose executions install their own policy under those six registers +the other six and receives these as declarations carrying that policy, so there +is no executable default for a declaration to shadow. `repl` is the one such +command. Either way the component is the same — one body, one set of forms, one +return contract — and a repository component of the name still outranks it. + +#### One invocation binding for the external repository operations + +`Git.Push`, `PullRequest`, its three evidence reads and `Issue` have one +implementation each, in the provider-neutral half of `@executablemd/git`. Each +of those bodies interprets its own props and forms, expands its own content, +normalizes what the document wrote, asks a semantic feature operation — +`GitApi["push"]`, `IssueOperationsApi` or `PullRequestOperationsApi` — and +projects what came back. None of them receives an authenticator, an owner, a +site, native access, a policy object, a record codec or any publication state, +and none of them names a journal record. + +Between the component and the host is one feature-owned binding. For a host that +retains work named after an invocation it authenticates the actual invocation +canonical core issued — once, in the engine frame the component was entered in, +before the body projects any content, because a projecting ancestor is +deliberately ineligible for one — then builds that invocation's own operation +facade from the host's policy and runs the one body against it. The site it +authenticated is private to the policy it is handed to: no facade method answers +with it, no public request carries it, and no Context holds it. + +Revocation is armed before the facade can be used and runs on success, failure +and cancellation alike, so an invocation halted while its facade is being built +leaves nothing usable behind. A facade kept past its own invocation performs +nothing, and one authored element performs one external operation: a second call +through the same facade is refused rather than repeating work this invocation +has already asked for. + +A host that retains nothing named after an invocation uses the same bodies over +the same kind of facade with no identity authenticated, because there is nothing +for one to name. That is what makes an ordinary run, a workflow run, a +provider-absent runtime and a REPL entry four policies rather than four +components. + #### Command declaration ownership Command declarations contain everything one command declares, in one order and under one @@ -7114,10 +7157,11 @@ flag is retained, and host paths are kept as the bytes they arrived as. Identity is the entry's own owner and the invocation identity canonical core minted, encoded in the same two record fields a workflow run uses for its run -and expansion. The trusted body takes that invocation once and owns the pair -throughout; neither reaches public Git input, so a replacement registration and -a direct `Git.operations.push(...)` name no durable work of this entry's and -reach the ordinary missing-provider refusal. +and expansion. The common invocation binding authenticates the invocation once +and hands the site it authenticated to this host's policy, which owns the pair +throughout; neither reaches public Git input or the shared component, so a +replacement registration and a direct `Git.operations.push(...)` name no durable +work of this entry's and reach the ordinary missing-provider refusal. Composition is ordinary. A fresh invocation dispatches through an operation-local default under the stable Git Api name, so every public handler a @@ -7193,20 +7237,21 @@ 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. +Neither member can be supplied from outside. The owner reaches the host's policy +as a trusted closure built for one entry, the identity authenticator is +delivered to the common binding 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. `` here is +the one shared component with this entry's policy under it, declared rather than +registered because an entry retains work named after each invocation — 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 +composed rather than hard-wired: the entry's policy 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 @@ -8479,7 +8524,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. The local Git and issue work an entry retains have rows of their own beside this one; 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 | +| 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 the one shared component with this entry's policy under it, which 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 | | REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and promises no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. A local result confers no publication evidence, and publication is the row below rather than part of these three [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | | REPL entry retained publication | one executing REPL entry's own ``, retained as a request record written before anything is contacted and the shared `git_host_effect` outcome that answers it. The request is resolved from the checkout — the authenticated selection, its common Git directory, the covering checkout for the received working directory, the admitted credential-free origin held to the identity's own fingerprint, and the branch and commit read inside the entry's native turn — and keeps the question asked, the reading the live dispatch received and what routing resolved, with no selection identifier, source object, credential, Git output, HEAD inventory or publication flag. Identity is the entry's owner and core's minted invocation identity, encoded in the run and expansion fields the workflow profile uses; a replacement registration and a direct `Git.operations.push(...)` reach the ordinary missing-provider refusal. Dispatch is ordinary Git composition through an operation-local default: a synthetic answer retains nothing, a second delegation of one invocation is refused, and a continuation consumes the saved request before any dispatch. A publication takes the managed slot and no mutation lease, reads objects through a bare control repository with a read-only alternate rather than through the checkout, reconciles a pending request against the exact commit it named without fetching, and publishes only after every per-attempt resource has closed. A completed publication reaches no provider, credential or remote; a failed request append contacts nothing and a failed outcome append leaves no false receipt [Reference](#what-one-entry-asked-to-publish-and-what-the-git-host-did-about-it). | 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 | @@ -8509,7 +8554,7 @@ Status is measured against main. | transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) under a workflow run | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation admits anything — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of admission, of retained state or of an unrecognized native condition fails the run instead of publishing a result [Invariant](#workspace-local-transactions); [Interface and evidence](specs/workflow-workspace-spec.md#7-git-operations). | built on the #294 stack, Deno provider only | | `Git.Push` under a workflow run | Publishes or adopts the selected named branch through authenticated non-force reconciliation. [Authored contract](specs/workflow-workspace-spec.md#74-push) and [effect mechanics](#replay-and-effect-transactions). | built on the #370 stack, Deno provider only | | `` under a workflow run | Upserts the exact published branch and returns stable evidence only after this run proves matching Push history. [Props, results and refusals](specs/workflow-workspace-spec.md#75-pull-request); [positive-integer number and reconciliation](#issue-effects). | built on the #295 stack, Deno provider only | -| repository composition vocabulary | Declares sixteen ordinary shadowable defaults without installing an operational provider. [Names and profile ownership](#ordinary-repository-vocabulary). | built on the #643 stack | +| repository composition vocabulary | Declares sixteen ordinary shadowable defaults without installing an operational provider, six of them as declarations wherever a command's executions install their own policy under them. [Names and profile ownership](#ordinary-repository-vocabulary). | built on the #643 stack | | `` / `` composition under an ordinary run | Selects an authenticated persistent checkout under an execution-owned exclusive lock [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | | local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | Performs explicit local transitions against the authenticated checkout without rollback or replay [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | | `Git.Push` under an ordinary run | Publishes or adopts a non-force branch result and keeps invocation-private evidence [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 10a39878e..6952b16be 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -146,7 +146,7 @@ import { syntaxSymbols, useCommandSyntax, } from "./syntax.ts"; -import { assembleRunProfile, BundledRunPlugin } from "./run-profile.ts"; +import { assembleRunProfile, BundledRunPlugin, profileComponents } from "./run-profile.ts"; import type { PluginModuleLoader } from "./plugin-loader.ts"; import { installPlugins, NO_PLUGINS } from "./plugin-host.ts"; import type { CommandPlugins } from "./plugin-host.ts"; @@ -3441,10 +3441,16 @@ function* withPlugins( let plugins: CommandPlugins; try { const loaded = yield* assembleRunProfile(selection, loadPluginModule); - plugins = yield* installPlugins(loaded, { - command: selection.command, - args: selection.args, - }); + // The profile's own repository operations travel with the installations + // its Plugins contributed, because every surface that executes, describes + // or validates this command's vocabulary reads one assembly. + plugins = { + ...(yield* installPlugins(loaded, { + command: selection.command, + args: selection.args, + })), + components: profileComponents(selection), + }; } catch (error) { reportFailure(error instanceof Error ? error : new Error(String(error))); yield* exit(1); diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index d0cb1e150..36c9710b1 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -152,7 +152,7 @@ export function structuralValidation( return yield* validateDocumentStructure({ ...retainedSource(PLAN_IDENTITY, candidate), includes: [...includes], - components: agentIdentityComponents(), + components: [...agentIdentityComponents(), ...plugins.components], // Whatever the installed Plugins declare travels with the profile, so a // Plan is checked against the same vocabulary the later `xmd run` // supplies. Checking it against a narrower one would refuse a program diff --git a/packages/cli/src/plugin-host.ts b/packages/cli/src/plugin-host.ts index c67fab63a..f1c2bede9 100644 --- a/packages/cli/src/plugin-host.ts +++ b/packages/cli/src/plugin-host.ts @@ -25,6 +25,7 @@ import { incompleteStructural } from "@executablemd/core/host"; import type { ExecutionDeclaration, ExecutionInstallation, + IdentityComponent, IncompleteStructural, } from "@executablemd/core/host"; @@ -54,6 +55,19 @@ export interface CommandPlugins { * run that follows it. */ readonly declarations: readonly ExecutionDeclaration[]; + /** + * The components this command's profile declares whose implementations name + * durable work after their own invocation. + * + * Not a Plugin's contribution and deliberately not on `PluginInstallation`: + * that member means exact Markdown. These are the repository operations whose + * one implementation asks a semantic feature operation — ``, + * ``, its three evidence reads and `` — and which host + * policy performs them is settled by the profile that assembled this. They + * travel beside the installations so every surface that executes, describes + * or validates this command's vocabulary reads one list. + */ + readonly components: readonly IdentityComponent[]; /** * The original argv these Plugins were installed from. * @@ -69,6 +83,7 @@ export const NO_PLUGINS: CommandPlugins = Object.freeze({ plugins: Object.freeze([]), installations: Object.freeze([]), declarations: Object.freeze([]), + components: Object.freeze([]), args: Object.freeze([]), }); @@ -184,6 +199,9 @@ export function* installPlugins( plugins, installations: Object.freeze(installations), declarations: Object.freeze(declared), + // Filled by the profile that assembled this command, which is the one place + // that knows whether this command carries the bundled vocabulary at all. + components: Object.freeze([]), args: request.args, }; } diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index e2c363073..2eca3becf 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -17,7 +17,7 @@ import type { AcpxProviderDependencies } from "@executablemd/acp"; import { agentIdentityComponents } from "@executablemd/core"; import type { PermissionMode } from "@executablemd/core"; import type { GeneratedSelection } from "@executablemd/core/api"; -import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { ExecutionInstallation, IdentityComponent } from "@executablemd/core/host"; import { useScope } from "effection"; import type { Operation } from "effection"; @@ -134,6 +134,17 @@ export interface ReplExecutionProfile { * host rather than one with a partly configured one. */ readonly entryInstallation?: ReplEntryInstallation; + /** + * The repository operations this command declares, as metadata. + * + * The preflight that runs before an entry exists validates against these, + * and a session whose host selected no entry capability declares them as they + * stand — the ordinary policy, which is each feature Api's own refusal where + * nothing is installed. An entry with a capability declares its own set + * instead, with this host's policy under the operations it retains, so one + * execution captures one declaration of each name. + */ + readonly entryComponents: readonly IdentityComponent[]; /** * Which agent this command's Sidekick opens a fresh conversation under. * @@ -251,6 +262,7 @@ export function* assembleReplProfile( generated: ordinaryGeneratedSelection(), permissionMode: stack.permissionMode, ...(entryInstallation === undefined ? {} : { entryInstallation }), + entryComponents: plugins.components, // Settled here, with the stack and the Plugins, because it is a fact about // the command a person invoked rather than about any conversation they // later have. Resolved rather than retained as "unstated": a profile that diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index f9fc27037..d4e6c3256 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -271,6 +271,7 @@ export function* runReplProgram(options: ReplProgramOptions): Operation` in such a session is refused by the provider that + * is missing rather than unknown to the language. + * + * An entry with a capability declares its own set instead, so one execution + * captures one declaration of each name. + */ + readonly entryComponents?: readonly IdentityComponent[]; } /** @@ -606,6 +621,7 @@ function* start( selection, permissionMode, entryInstallation, + entryComponents = [], } = options; const stream = execution.stream; @@ -663,7 +679,7 @@ function* start( : undefined; if (submitted !== undefined) { - const validated = yield* preflight(submitted, includes, installations); + const validated = yield* preflight(submitted, includes, installations, entryComponents); if (!validated.ok) { return validated; } @@ -1102,7 +1118,7 @@ function* start( if (!before.ok) { return before; } - const validated = yield* preflight(source, includes, installations); + const validated = yield* preflight(source, includes, installations, entryComponents); if (!validated.ok) { return validated; } @@ -1416,7 +1432,9 @@ function* start( // the entry rather than to a scope that has already closed. const owned = entryInstallation === undefined - ? [] + ? entryComponents.length === 0 + ? [] + : [{ components: entryComponents }] : [yield* 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 @@ -1646,12 +1664,16 @@ function* preflight( source: string, includes: readonly string[] | undefined, installations: readonly ExecutionInstallation[], + entryComponents: readonly IdentityComponent[], ): Operation> { const validation = yield* scoped(function* () { return yield* validateDocument({ ...inlineSource(source), includes, - components: declaredComponents(installations), + // The command's own declarations and the repository operations every + // entry of it declares. Metadata alone: validation resolves a name from a + // declaration and calls no factory, so nothing here initializes policy. + components: [...declaredComponents(installations), ...entryComponents], declarations: declaredMarkdown(installations), }); }); diff --git a/packages/cli/src/run-profile.ts b/packages/cli/src/run-profile.ts index 52afe6bb5..b058ae60c 100644 --- a/packages/cli/src/run-profile.ts +++ b/packages/cli/src/run-profile.ts @@ -30,8 +30,15 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; import { cwd } from "@executablemd/runtime"; -import { gitPlugin, gitPluginDeclaresFor } from "@executablemd/git"; +import { + compositionDeclarations, + gitPlugin, + gitPluginDeclaresFor, + gitPluginDeclaresOperationPolicy, + ordinaryCompositionPolicy, +} from "@executablemd/git"; import type { Plugin } from "@executablemd/core/api"; +import type { IdentityComponent } from "@executablemd/core/host"; import { loadPlugins, type PluginModuleLoader } from "./plugin-loader.ts"; import type { PluginSelection } from "./plugin-selection.ts"; @@ -91,6 +98,33 @@ export function carriesBundledPlugin(selection: PluginSelection): boolean { return gitPluginDeclaresFor({ command: selection.command, args: selection.args }); } +/** + * The repository operations this command's executions declare, where they + * declare any. + * + * ``, ``, its three evidence reads and `` have + * one implementation each, and each asks a semantic feature operation for its + * work. Most commands register them as ordinary defaults, where a nearer + * registration still shadows one; a command whose executions install their own + * policy under them declares them instead, and the Plugin is what says which + * this is. + * + * The policy here is the ordinary one — the public feature Apis — because this + * is what a session declares before any entry supplies its own: an entry that + * retains publications and issues replaces it with its own for exactly those, + * and a runtime that selected no entry capability keeps this and reaches each + * Api's own refusal. + * + * Built per call rather than held as a value: a declaration is closed over the + * policy it was built with, and one execution's declarations are never + * another's. + */ +export function profileComponents(selection: PluginSelection): readonly IdentityComponent[] { + return gitPluginDeclaresOperationPolicy({ command: selection.command, args: selection.args }) + ? compositionDeclarations(ordinaryCompositionPolicy()) + : []; +} + /** * The Plugin values one command runs with, in installation order. * diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 45de9fdc4..b65784d9c 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -100,7 +100,10 @@ export function* useCommandSyntax( const inputs = { includes: [...includes], registry: new Map(yield* Component.operations.registry), - components: agentIdentityComponents(), + // The engine's own invocation-named components, and this command's + // profile's: inspection reads their metadata and calls no factory, so + // describing the repository operations mints nothing. + components: [...agentIdentityComponents(), ...plugins.components], // `` is part of the run profile, so symbols that left it out would // describe a vocabulary no run has. Described from the packaged bytes: // inspection mints nothing, so it reports the Component's identity and diff --git a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts index 208b93934..606bf2082 100644 --- a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts +++ b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts @@ -99,6 +99,7 @@ await main(function* (args) { profile: { includes: [process.cwd()], installations: [holdingTeardown()], + entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", // This host opens no Sidekick conversation, so the selection is the one diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index 247282241..9e76e2116 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -438,6 +438,7 @@ function* useStub(stub: Stub): Operation { const PROFILE: ReplExecutionProfile = { includes: [], installations: [{ components: agentIdentityComponents() }], + entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "approve-reads", // No row here opens a Sidekick conversation, so the selection is the one an diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index ea80d91e0..b69538e3a 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -31,12 +31,16 @@ 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 type { ExecutionInstallation, IdentityComponent } 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 { + compositionDeclarations, + ordinaryCompositionPolicy, +} from "../../git/src/composition/installation.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"; @@ -96,6 +100,18 @@ function useReplRepositories(root: string): Operation { }); } +/** + * The repository operations every entry of this command declares, as the REPL + * profile supplies them. + * + * Metadata for the preflight that runs before an entry exists, and — for a + * session whose host selected no entry capability — the declarations that entry + * runs with, ordinary policy beneath them. + */ +function entryComponents(): readonly IdentityComponent[] { + return compositionDeclarations(ordinaryCompositionPolicy()); +} + /** * What a command contributes to an entry's execution. * @@ -271,6 +287,7 @@ function submit( execution: run, source, installations, + entryComponents: entryComponents(), generated: ordinaryGeneratedSelection(), ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.host === undefined ? {} : { entryInstallation: options.host }), @@ -300,6 +317,7 @@ function reopen(fixture: Fixture, run: ReplExecution, options: RunOptions = {}): yield* openReplSession({ execution: run, installations, + entryComponents: entryComponents(), generated: ordinaryGeneratedSelection(), ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.host === undefined ? {} : { entryInstallation: options.host }), @@ -835,6 +853,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -950,6 +969,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source: [FILES_ONE, SUFFIX].join("\n"), installations, @@ -1033,6 +1053,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source, installations, @@ -1169,6 +1190,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1252,6 +1274,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1275,6 +1298,7 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* openReplSession({ + entryComponents: entryComponents(), execution: run, installations, generated: ordinaryGeneratedSelection(), @@ -1417,6 +1441,7 @@ describe("how a REPL entry gets its issues", () => { const installations = [...(yield* replInstallations()), heldCleanup(cleanup)]; opened( yield* submitReplEntry({ + entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1485,9 +1510,11 @@ describe("how a REPL entry gets its issues", () => { }); expect(absent.entryInstallation).toBe(undefined); - // Selecting reaches nothing, and what it selected declares both halves of - // an entry's external capability: an installation is built per entry, and - // this command has run none. + // Selecting reaches nothing, and what it selected declares the whole common + // vocabulary once: an entry installs one set of these names, with this + // host's policy under the two operations it retains and the ordinary policy + // under the rest. An installation is built per entry, and this command has + // run none. const built = yield* scoped(function* (): Operation { if (factory === undefined) { throw new Error("the production selector selected no entry capability"); @@ -1497,6 +1524,10 @@ describe("how a REPL entry gets its issues", () => { expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); }); diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 62afe9347..28044a7a0 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -139,6 +139,7 @@ const INSTALLATIONS: readonly ExecutionInstallation[] = []; const PROFILE: ReplExecutionProfile = { includes: [REFERENCE_DIRECTORY], installations: INSTALLATIONS, + entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", // No row here opens a Sidekick conversation, so the selection is the one an diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index f00a50917..52367bc27 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -32,6 +32,10 @@ import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; import { gitSession } from "../../git/src/deno/composition/git.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; +import { + compositionDeclarations, + ordinaryCompositionPolicy, +} from "../../git/src/composition/installation.ts"; import { REPL_REPOSITORY_PUSH_REQUEST } from "../../git/src/retained-repository/push-records.ts"; import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; @@ -213,6 +217,7 @@ function submit( execution: run, source, installations: [...(yield* replInstallations())], + entryComponents: compositionDeclarations(ordinaryCompositionPolicy()), generated: ordinaryGeneratedSelection(), ...(options.host === false ? {} @@ -255,6 +260,7 @@ function reopen( yield* openReplSession({ execution: run, installations: [...(yield* replInstallations())], + entryComponents: compositionDeclarations(ordinaryCompositionPolicy()), generated: ordinaryGeneratedSelection(), entryInstallation: (owner: string) => { seen.owners.push(owner); @@ -424,14 +430,19 @@ describe("how a REPL entry gets its publication capability", () => { } // Building one reaches nothing — no directory, no `git`, no credential and - // no managed root — and what it declares is both of an entry's external - // capabilities. + // no managed root — and what it declares is the whole common vocabulary + // once, with this host's policy under the two operations it retains and the + // ordinary policy under the rest. const built = yield* scoped(function* (): Operation { return yield* factory('["executablemd.repl.issue-owner",1,"execution-cli-6","1"]'); }); expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); expect((built.admissions ?? []).length).toBe(2); expect(yield* nothingUnder(fixture.managed)).toBe(true); diff --git a/packages/git/deno.ts b/packages/git/deno.ts index 91545603f..562e85940 100644 --- a/packages/git/deno.ts +++ b/packages/git/deno.ts @@ -64,10 +64,10 @@ export { replRepositoryInstallation } from "./src/deno/retained-repository/provi /** * 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. + * The installation alone. What it builds — the common `` declaration, + * the policy 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/mod.ts b/packages/git/mod.ts index 88c3390ac..4561abe36 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -90,6 +90,15 @@ export { gitDirectorySelection } from "./src/composition/definitions.ts"; * one this package ships. */ export { declaresFor as gitPluginDeclaresFor } from "./src/plugin.ts"; +/** + * Whether this command's executions install their own policy under the six + * operations no local transaction encloses, rather than registering them. + * + * Named for the Plugin it classifies, for the same reason as the predicate + * above: a host assembling a command needs this answer and the vocabulary's own + * gating to agree. + */ +export { declaresOperationPolicy as gitPluginDeclaresOperationPolicy } from "./src/plugin.ts"; export { GitObjectError, GitRepositoryError, GitRevisionError } from "./src/git.ts"; export type { GitObjectFormat } from "./src/git.ts"; export { @@ -228,9 +237,22 @@ export type { export { admitPushEvidence } from "./src/composition/push-evidence.ts"; export { COMPOSITION_REGISTRATIONS, + compositionDeclarations, compositionDocumentation, + compositionNames, + ordinaryCompositionPolicy, + SELECTION_REGISTRATIONS, useCompositionComponents, + useCompositionSelections, } from "./src/composition/installation.ts"; +export type { CompositionPolicy } from "./src/composition/installation.ts"; +export { BoundOperationError } from "./src/composition/invocation-binding.ts"; +export type { + BindOperations, + ComponentBody, + FacadeRefusal, + InvocationGate, +} from "./src/composition/invocation-binding.ts"; export type { IssueDetails, IssueInput, diff --git a/packages/git/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts index f07ce248b..08f908dea 100644 --- a/packages/git/src/composition/components/GitPush.ts +++ b/packages/git/src/composition/components/GitPush.ts @@ -15,16 +15,29 @@ * Git.Switch's failure policy applies. Detached HEAD is a fixed GitOperationError * before remote contact; missing Repository is an admission failure that printing * cannot recover. + * + * The form is settled by canonical dispatch rather than read from the + * invocation: an irreversible publication is not something a composable + * `hasContent()` answer may select. */ import { cwd } from "@executablemd/runtime"; -import { hasContent } from "@executablemd/core"; -import type { FirstPartyDocumentation, PropsSchema } from "@executablemd/core"; +import { documented, formDispatcher } from "@executablemd/core"; +import type { + ComponentRegistration, + FirstPartyDocumentation, + FunctionComponent, + PropsSchema, +} from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { Git } from "../git-api.ts"; +import type { GitApi } from "../git-api.ts"; +import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { GitOperationAdmissionError, GitOperationError } from "../errors.ts"; +import { boundComponent, unboundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; /** The component name, as a document writes it and as a refusal names it. */ export const PUSH = ""; @@ -35,13 +48,7 @@ export const props: PropsSchema = { additionalProperties: false, }; -/** - * What a reader is told about this component, in one place. - * - * Declared here rather than beside a registration because there are two: the - * bundled vocabulary's, and the one a REPL entry installs with its own identity - * behind it. Inspection describes one `Git.Push`, so both read this. - */ +/** What a reader is told about this component, in one place. */ export const documentation: FirstPartyDocumentation = { description: `Publish the current branch. \`\` pushes its current commit to the same branch ` + @@ -50,15 +57,19 @@ export const documentation: FirstPartyDocumentation = { context: null, }; -export default function* GitPush(_props: Record): Operation { - if (yield* hasContent()) { - throw new GitOperationError( - PUSH, - "invalid-invocation", - `${PUSH} renders nothing, so it takes no content. Write it as .`, - ); - } - +/** + * The one implementation, whatever host performs it. + * + * It observes the two facts a document can see — the Repository in scope and + * the contextual working directory — and asks the publication operation this + * invocation was bound to. Which journal that operation writes to, and whether + * it reconciles a request made an hour ago, belongs to the host policy beneath + * it and appears nowhere here. + */ +export const body: ComponentBody = function* GitPush( + _props: Record, + push: GitApi["push"], +): Operation { const repository = yield* selectedRepository(); if (repository === undefined) { throw new GitOperationAdmissionError( @@ -67,10 +78,58 @@ export default function* GitPush(_props: Record): Operation + new GitOperationError( + PUSH, + "invalid-invocation", + `${PUSH} renders nothing, so it takes no content. Write it as .`, + ), }); - return ""; +} + +/** `Git.Push` as an ordinary registered default, over the public Git Api. */ +export function pushRegistration( + operations: (gate: InvocationGate) => GitApi["push"], +): ComponentRegistration { + return { ...METADATA, fn: dispatched(unboundComponent(PUSH, operations, body)) }; +} + +/** `Git.Push` as a declaration, for a host that supplies its own policy. */ +export function pushDeclaration(bind: BindOperations): IdentityComponent { + return { + ...METADATA, + factory: (claim: IdentityClaimant) => + dispatched(boundComponent(claim, { element: PUSH, bind, body })), + }; } diff --git a/packages/git/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts index 24566055f..e6d18e5df 100644 --- a/packages/git/src/composition/components/Issue.ts +++ b/packages/git/src/composition/components/Issue.ts @@ -18,31 +18,45 @@ * 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. + * Which form this is comes from canonical dispatch rather than from the + * invocation: an irreversible external effect is not something a composable + * `hasContent()` answer may select. What a read asks for and what an upsert + * asks for are decided from the props alone, before a tracker is read and + * before the boundary is reached — and the host beneath this component decides + * how the operation it asks for is performed, retained and replayed. */ -import { content, hasContent } from "@executablemd/core"; -import type { FirstPartyDocumentation, PropsSchema, ReturnsSchema } from "@executablemd/core"; +import { content, documented, formDispatcher } from "@executablemd/core"; +import type { + ComponentRegistration, + FirstPartyDocumentation, + FunctionComponent, + PropsSchema, + ReturnsSchema, +} from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; +import { COMPOSITION_ORIGIN } from "../definitions.ts"; +import { boundComponent, unboundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; import { currentIssueTracker } from "../../issue/context.ts"; import { canonicalIssueTarget, issueProviderName, resolveIssueDestination, } from "../../issue/tracker.ts"; -import { IssueOperations } from "../../issue/operations.ts"; -import type { IssueReadInvocation, IssueUpsertInvocation } from "../../issue/operations.ts"; +import type { + IssueOperationsApi, + 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"; /** The component name, as a document writes it and as a refusal names it. */ -export const ISSUE_ELEMENT = ""; +const ISSUE_ELEMENT = ""; export const props: PropsSchema = { type: "object", @@ -111,7 +125,7 @@ export const returns: ReturnsSchema = { }; /** What the shape of this invocation asks for. */ -export type IssueMode = "read" | "upsert"; +type IssueMode = "read" | "upsert"; /** * Which of the two this invocation is, or the refusal that says why neither. @@ -120,12 +134,11 @@ export type IssueMode = "read" | "upsert"; * 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. + * `paired` is how the element was written. Canonical form dispatch enters the + * body for the form the scan recorded, and that body states it here — so the + * fact is established somewhere nothing composable can answer for it. */ -export function issueMode(props: Record, paired: boolean): IssueMode { +function issueMode(props: Record, paired: boolean): IssueMode { const url = typeof props.url === "string" && props.url !== ""; const title = typeof props.title === "string" && props.title !== ""; @@ -179,7 +192,7 @@ export function issueMode(props: Record, paired: boolean): IssueMo * 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 { +function issueReadRequest(props: Record): IssueReadInvocation { const url = canonicalIssueTarget(props.url); if (url === undefined) { throw new IssueTrackerError( @@ -201,7 +214,7 @@ export function issueReadRequest(props: Record): IssueReadInvocati * 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 { +function* issueUpsertRequest(props: Record): Operation { const description = yield* content(); if (description === "") { throw new IssueContentError( @@ -228,14 +241,7 @@ export function* issueUpsertRequest(props: Record): Operation`, 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. - */ +/** What the catalog says about ``. */ export const documentation: FirstPartyDocumentation = { description: `Read or file an issue. \`\` reads one. ` + @@ -246,7 +252,7 @@ export const documentation: FirstPartyDocumentation = { }; /** What a read binds, as a document reads it. */ -export function issueReadBinding(details: IssueDetails): Json { +function issueReadBinding(details: IssueDetails): Json { return { url: details.url, title: details.title, @@ -256,22 +262,76 @@ export function issueReadBinding(details: IssueDetails): Json { }; } -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); +/** + * The one read implementation, whatever host performs it. + * + * Entered by canonical dispatch for the self-closing form, so the props checks + * below are this element's refusals rather than a second decision about which + * of the two questions is being asked. + */ +const readBody: ComponentBody = function* Issue( + props: Record, + operations: IssueOperationsApi, +): Operation { + issueMode(props, false); + const details = yield* operations.read(issueReadRequest(props)); + return issueReadBinding(details); +}; + +/** The one upsert implementation, on the same terms. */ +const upsertBody: ComponentBody = function* Issue( + props: Record, + operations: IssueOperationsApi, +): Operation { + issueMode(props, true); + // Rendered here, so a child that retains work of its own finishes at its own + // journal position before this upsert is offered to the host beneath. + const request = yield* issueUpsertRequest(props); + const reference = yield* operations.upsert(request); + return { url: reference.url }; +}; + +/** What a catalog says about this component, whichever path declares it. */ +const METADATA = { + name: "Issue", + origin: COMPOSITION_ORIGIN, + props, + returns, + forms: ["self-closing", "paired"] as const, + ...documented(documentation), +}; + +/** + * Canonical form dispatch, so which of the two external effects this element + * asks for is settled by the scan before the body runs. + */ +function dispatched(read: FunctionComponent, upsert: FunctionComponent): FunctionComponent { + return formDispatcher({ forms: "both", "self-closing": read, paired: upsert }); } -function* read(props: Record): Operation { - const details = yield* IssueOperations.operations.read(issueReadRequest(props)); - return issueReadBinding(details); +/** `Issue` as an ordinary registered default, over the public IssueOperations Api. */ +export function issueRegistration( + operations: (gate: InvocationGate) => IssueOperationsApi, +): ComponentRegistration { + return { + ...METADATA, + fn: dispatched( + unboundComponent(ISSUE_ELEMENT, operations, readBody), + unboundComponent(ISSUE_ELEMENT, operations, upsertBody), + ), + }; } -function* upsert(props: Record): Operation { - const reference = yield* IssueOperations.operations.upsert(yield* issueUpsertRequest(props)); - return { url: reference.url }; +/** `Issue` as a declaration, for a host that supplies its own policy. */ +export function issueDeclaration(bind: BindOperations): IdentityComponent { + return { + ...METADATA, + factory: (claim: IdentityClaimant) => + dispatched( + boundComponent(claim, { element: ISSUE_ELEMENT, bind, body: readBody }), + boundComponent(claim, { element: ISSUE_ELEMENT, bind, body: upsertBody }), + ), + }; } /** The discriminator this prop names, or `undefined` when it names none. */ diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index 5d0110b29..92baeedbb 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -70,14 +70,23 @@ */ import { cwd } from "@executablemd/runtime"; -import { content, hasContent } from "@executablemd/core"; -import type { PropsSchema, ReturnsSchema } from "@executablemd/core"; +import { content, documented, hasContent } from "@executablemd/core"; +import type { + ComponentRegistration, + FirstPartyDocumentation, + PropsSchema, + ReturnsSchema, +} from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; +import { boundComponent, unboundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; /** The component name, as a document writes it and as a refusal names it. */ export const PULL_REQUEST_ELEMENT = ""; @@ -137,7 +146,28 @@ export const returns: ReturnsSchema = { additionalProperties: false, }; -export default function* PullRequest(props: Record): Operation { +/** What the catalog says about ``. */ +export const documentation: FirstPartyDocumentation = { + description: + `Open or update a pull request. \`…\` uses its ` + + `content as the body after \`\`.`, + as: "Optional. The pull request's repository, number, url, state and head and base commits.", + context: "The pull request's body.", +}; + +/** + * The one implementation, whatever host performs it. + * + * It expands its own content, observes the Repository in scope and the + * contextual working directory, normalizes what the document wrote and asks the + * upsert operation this invocation was bound to. Whether that operation saves + * its request, proves a publication or restores a completed one belongs to the + * host policy beneath it. + */ +export const body: ComponentBody = function* PullRequest( + props: Record, + operations: PullRequestOperationsApi, +): Operation { // Before anything of this element's own exists — before a Repository is // observed, before a checkout is selected, before retained Push evidence is // read and long before a Git host is contacted. The body is what the pull @@ -154,7 +184,7 @@ export default function* PullRequest(props: Record): Operation): Operation PullRequestOperationsApi, +): ComponentRegistration { + return { + ...METADATA, + fn: unboundComponent(PULL_REQUEST_ELEMENT, operations, body), + }; +} + +/** `PullRequest` as a declaration, for a host that supplies its own policy. */ +export function pullRequestDeclaration( + bind: BindOperations, +): IdentityComponent { + return { + ...METADATA, + factory: (claim: IdentityClaimant) => + boundComponent(claim, { element: PULL_REQUEST_ELEMENT, bind, body }), + }; } diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index ded014b39..17d645948 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -18,20 +18,28 @@ * into an evidence read. */ +import { documented, formDispatcher } from "@executablemd/core"; import type { + ComponentRegistration, + FirstPartyDocumentation, FormDeclaration, + FunctionComponent, InvocationForm, JsonObject, PropsSchema, ReturnsSchema, } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; +import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { PullRequestReadError } from "../errors.ts"; import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; -import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { boundComponent, unboundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; /** The component names, as a document writes them and a refusal names them. */ export const REVIEWS_ELEMENT = ""; @@ -163,8 +171,11 @@ export const checksReturns: ReturnsSchema = array({ }), ], }); -function read(kind: PullRequestReadKind, element: string) { - return function* PullRequestRead(props: Record): Operation { +function read(kind: PullRequestReadKind, element: string): ComponentBody { + return function* PullRequestRead( + props: Record, + operations: PullRequestOperationsApi, + ): Operation { // The props schema refuses a missing or empty `url` before this function is // entered, and therefore before any provider is asked anything. // Canonicalized before any provider sees it, so the component, the durable @@ -188,7 +199,7 @@ function read(kind: PullRequestReadKind, element: string) { ); } - const result = yield* PullRequestOperations.operations.read({ url, kind, provider }); + const result = yield* operations.read({ url, kind, provider }); if (result.kind !== kind) { throw new PullRequestReadError( "protocol", @@ -201,10 +212,10 @@ function read(kind: PullRequestReadKind, element: string) { }; } -function declare(kind: PullRequestReadKind, element: string): FormDeclaration { +function declare(fn: FunctionComponent, element: string): FormDeclaration { return { forms: "self-closing", - fn: read(kind, element), + fn, refuse: (_props: Record, written: InvocationForm | undefined) => new PullRequestReadError( "unexpected-content", @@ -218,6 +229,98 @@ function declare(kind: PullRequestReadKind, element: string): FormDeclaration { }; } -export const reviewsForm: FormDeclaration = declare("reviews", REVIEWS_ELEMENT); -export const commentsForm: FormDeclaration = declare("comments", COMMENTS_ELEMENT); -export const checksForm: FormDeclaration = declare("checks", CHECKS_ELEMENT); +/** What the catalog says about each of the three reads. */ +const DOCUMENTATION: Readonly> = Object.freeze( + { + reviews: { + description: + `Read pull request reviews. \`\` ` + + `reads reviews from the specified pull request.`, + as: "Required. Each review's author, state, body, submission time, commit and url.", + context: null, + }, + comments: { + description: + `Read pull request comments. \`\` ` + + `reads both conversation and review comments.`, + as: + "Required. Each comment's kind, author, body, timestamps and url, and a review " + + "comment's file, hunk and line.", + context: null, + }, + checks: { + description: + `Read pull request checks. \`\` ` + + `reads check runs and commit statuses for its head.`, + as: "Required. Each check's kind, name, head commit and outcome.", + context: null, + }, + }, +); + +/** The three evidence reads, as the names a document writes. */ +const NAMES: Readonly> = Object.freeze({ + reviews: "PullRequest.Reviews", + comments: "PullRequest.Comments", + checks: "PullRequest.Checks", +}); + +const ELEMENTS: Readonly> = Object.freeze({ + reviews: REVIEWS_ELEMENT, + comments: COMMENTS_ELEMENT, + checks: CHECKS_ELEMENT, +}); + +const RETURNS: Readonly> = Object.freeze({ + reviews: reviewsReturns, + comments: commentsReturns, + checks: checksReturns, +}); + +/** What a catalog says about one read, whichever path declares it. */ +function metadata(kind: PullRequestReadKind) { + return { + name: NAMES[kind], + origin: COMPOSITION_ORIGIN, + props, + returns: RETURNS[kind], + forms: ["self-closing"] as const, + ...documented(DOCUMENTATION[kind]), + }; +} + +/** One evidence read as an ordinary registered default, over the public Api. */ +export function pullRequestReadRegistration( + kind: PullRequestReadKind, + operations: (gate: InvocationGate) => PullRequestOperationsApi, +): ComponentRegistration { + const element = ELEMENTS[kind]; + return { + ...metadata(kind), + fn: formDispatcher( + declare(unboundComponent(element, operations, read(kind, element)), element), + ), + }; +} + +/** One evidence read as a declaration, for a host that supplies its own policy. */ +export function pullRequestReadDeclaration( + kind: PullRequestReadKind, + bind: BindOperations, +): IdentityComponent { + const element = ELEMENTS[kind]; + return { + ...metadata(kind), + factory: (claim: IdentityClaimant) => + formDispatcher( + declare(boundComponent(claim, { element, bind, body: read(kind, element) }), element), + ), + }; +} + +/** The three kinds, in the order a catalog lists them. */ +export const PULL_REQUEST_READ_KINDS: readonly PullRequestReadKind[] = Object.freeze([ + "reviews", + "comments", + "checks", +]); diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index 609ca2712..00bbb952e 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -1,6 +1,5 @@ /** - * The sixteen repository-composition components, as one array of ordinary - * declarations. + * The sixteen repository-composition components, declared once for every host. * * Repository, Worktree, the four Git operations, the four Git reads, * PullRequest and its three evidence reads, IssueTracker and Issue are @@ -10,14 +9,34 @@ * name (`core/src/components/fixed-filesystem.ts`), and a document reaches it * with or without this package. * - * One array, three consumers, because three descriptions of one vocabulary - * would drift. `useCompositionComponents()` registers it inside a workflow - * attachment; `useCommandComponents()` registers it for `xmd syntax` and for - * `xmd plan`'s validation and generation; `installDocumentComponents()` - * registers it for an ordinary run. Registering it installs no provider, - * performs no repository discovery, acquires no lock and reaches no network: - * what a name *does* is the installed provider's, and describing the - * environment mints none. + * ## One implementation, two ways of carrying it + * + * Ten of the sixteen reach nothing beyond the selected checkout: Repository and + * Worktree choose one, Git.Switch, Git.Add and Git.Commit change one, the four + * Git reads observe one, and IssueTracker names a destination. + * + * The other six — ``, ``, its three evidence reads and + * `` — reach something no local transaction encloses. Each of those has + * exactly one implementation, here, and it asks a semantic feature operation + * for its work. How that operation is performed, retained and replayed is the + * host's: an ordinary run and a workflow run forward to the public feature + * Apis, a runtime with no provider keeps each Api's own refusing default, and a + * REPL entry retains and reconciles beneath them. + * + * A host that retains nothing named after an invocation registers them + * ordinarily, through {@link useCompositionComponents}, so a nearer + * registration still shadows one. A host that does — a REPL entry — declares + * the same six through {@link compositionDeclarations} with its own policy, and + * registers the rest through {@link useCompositionSelections} so there is no + * executable default for its declaration to shadow. Either way the component is + * the same: one body, one set of forms, one return contract, because typing + * `` means the same authored operation wherever it is typed. + * + * Nothing here installs a provider, performs repository discovery, acquires a + * lock or reaches a network: what a name *does* is the installed policy's, and + * describing the environment selects none. Inspection and validation read the + * declarations' metadata without calling a factory, which is what lets + * `xmd syntax` and `` describe this vocabulary. * * A completed root replay attaches no provider and registers nothing, so a * document that already ran through completion re-registers none of these. @@ -36,7 +55,15 @@ import type { DocumentationContribution, DocumentationReader, } from "@executablemd/core"; +import type { IdentityComponent } from "@executablemd/core/host"; import { COMPOSITION_ORIGIN } from "./definitions.ts"; +import { Git } from "./git-api.ts"; +import type { GitApi } from "./git-api.ts"; +import { IssueOperations } from "../issue/operations.ts"; +import type { IssueOperationsApi } from "../issue/operations.ts"; +import { PullRequestOperations } from "./pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "./pull-request-operations.ts"; +import type { BindOperations, InvocationGate } from "./invocation-binding.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; import GitSwitch, { props as gitSwitchProps } from "./components/GitSwitch.ts"; @@ -45,28 +72,14 @@ import GitCommit, { props as gitCommitProps, returns as gitCommitReturns, } from "./components/GitCommit.ts"; -import GitPush, { - documentation as gitPushDocumentation, - props as gitPushProps, -} from "./components/GitPush.ts"; -import PullRequest, { - props as pullRequestProps, - returns as pullRequestReturns, -} from "./components/PullRequest.ts"; -import Issue, { - documentation as issueDocumentation, - props as issueProps, - returns as issueReturns, -} from "./components/Issue.ts"; +import { pushDeclaration, pushRegistration } from "./components/GitPush.ts"; +import { pullRequestDeclaration, pullRequestRegistration } from "./components/PullRequest.ts"; import { - checksForm, - checksReturns, - commentsForm, - commentsReturns, - props as pullRequestReadProps, - reviewsForm, - reviewsReturns, + PULL_REQUEST_READ_KINDS, + pullRequestReadDeclaration, + pullRequestReadRegistration, } from "./components/PullRequestReads.ts"; +import { issueDeclaration, issueRegistration } from "./components/Issue.ts"; import IssueTracker, { props as issueTrackerProps } from "./components/IssueTracker.ts"; import { changesForm, diffForm, fileForm, filesForm } from "./components/GitReads.ts"; import { @@ -80,11 +93,109 @@ import { filesReturns, } from "./observation-api.ts"; +/** + * How one host performs the six operations no local transaction encloses. + * + * One member per authored operation, each a constructor rather than an + * operation: it is called with the site that authenticated one invocation, and + * what it answers with is that invocation's own operations. Nothing a document, + * a prop, a tracker, a Context value or middleware holds takes part in + * selecting it — a host states its policy as direct code before any document + * runs. + * + * The two pull-request members are separate because the policies are: an upsert + * owns its delegated result and a read retains an admitted provider snapshot + * beneath live transformations, and a host that shared one member between them + * would be making those two policies equal. + */ +export interface CompositionPolicy { + readonly push: BindOperations; + readonly issue: BindOperations; + readonly pullRequest: BindOperations; + readonly pullRequestRead: BindOperations; +} + +/** + * The operations an ordinary run, a workflow run and a provider-absent runtime + * perform through. + * + * Each one is the public feature Api, so every handler a document, a plugin or + * a host composed answers exactly as it did before any of this was declared — + * and where nothing is installed, each Api's own default refuses. No site is + * taken because none is authenticated: a profile that retains no + * invocation-named work has nothing for a site to name, and inventing one would + * be a name for work nobody is doing. + */ +const ORDINARY = { + push: + (gate: InvocationGate): GitApi["push"] => + (invocation) => + gate.once(() => Git.operations.push(invocation)), + issue: (gate: InvocationGate): IssueOperationsApi => ({ + read: (invocation) => gate.once(() => IssueOperations.operations.read(invocation)), + upsert: (invocation) => gate.once(() => IssueOperations.operations.upsert(invocation)), + }), + pullRequest: (gate: InvocationGate): PullRequestOperationsApi => ({ + read: (invocation) => gate.once(() => PullRequestOperations.operations.read(invocation)), + upsert: (invocation) => gate.once(() => PullRequestOperations.operations.upsert(invocation)), + }), +}; + +/** + * That same wiring, as the policy a declaring host would supply. + * + * What a host declaring these operations gets where it retains none of them + * itself: a REPL entry that records publications and issues still leaves + * `` and its reads their own unchanged missing-provider refusal, + * and a runtime with no provider at all declares every one of them this way. + */ +export function ordinaryCompositionPolicy(): CompositionPolicy { + return { + push: (_site, gate) => ORDINARY.push(gate), + issue: (_site, gate) => ORDINARY.issue(gate), + pullRequest: (_site, gate) => ORDINARY.pullRequest(gate), + pullRequestRead: (_site, gate) => ORDINARY.pullRequest(gate), + }; +} + +/** + * The six declarations whose implementations name their own durable work. + * + * Built in one place, from one policy, so an ordinary run, a workflow run, a + * REPL entry, a provider-absent runtime and a descriptive command all declare + * the same names, origins, props, forms, return contracts and prose. A host + * that wanted a different implementation would have to supply a different + * policy, which is the only thing a host is allowed to differ on. + */ +export function compositionDeclarations(policy: CompositionPolicy): readonly IdentityComponent[] { + return Object.freeze([ + pushDeclaration(policy.push), + pullRequestDeclaration(policy.pullRequest), + ...PULL_REQUEST_READ_KINDS.map((kind) => + pullRequestReadDeclaration(kind, policy.pullRequestRead), + ), + issueDeclaration(policy.issue), + ]); +} + +/** + * Every name this boundary carries, in catalog order. + * + * Derived from the one registration array rather than listed again, so a + * component added to it cannot disappear from the documentation this package + * contributes or from an inventory held to it. The declaring path carries the + * same six operations under the same names, which is what makes one catalog + * true of both. + */ +export function compositionNames(): readonly string[] { + return Object.freeze(COMPOSITION_REGISTRATIONS.map((registration) => registration.name)); +} + /** * This boundary's long-form documentation, and the components it must cover. * - * Derived from `COMPOSITION_REGISTRATIONS` below rather than listed again, so a - * component added to that array cannot be left undocumented here. + * Derived from the two declaration paths rather than listed again, so a + * component added to either cannot be left undocumented here. */ export function* compositionDocumentation( read?: DocumentationReader, @@ -95,12 +206,13 @@ export function* compositionDocumentation( owner: COMPOSITION_ORIGIN, asset: "packages/git/src/composition/components.md", }, - COMPOSITION_REGISTRATIONS.map((registration) => registration.name), + compositionNames(), read, ); } -export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ +/** Repository and Worktree: which checkout the Markdown inside them expands in. */ +const SELECTIONS: readonly ComponentRegistration[] = [ { name: "Repository", origin: COMPOSITION_ORIGIN, @@ -123,6 +235,16 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ context: "The Markdown expanded in that checkout.", }), }, +]; + +/** + * What one selected checkout answers about itself, or changes inside itself. + * + * Git.Switch, Git.Add and Git.Commit change one and the four reads observe one; + * either way a local transaction encloses the work, so none of them is an + * operation a declaring execution names an invocation of. + */ +const LOCAL_OPERATIONS: readonly ComponentRegistration[] = [ { name: "Git.Switch", origin: COMPOSITION_ORIGIN, @@ -157,13 +279,6 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ context: "The message body, expanded before the commit is made.", }), }, - { - name: "Git.Push", - origin: COMPOSITION_ORIGIN, - props: gitPushProps, - fn: GitPush, - ...documented(gitPushDocumentation), - }, { name: "Git.Files", origin: COMPOSITION_ORIGIN, @@ -212,85 +327,77 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ context: null, }), }, - { - name: "PullRequest", - origin: COMPOSITION_ORIGIN, - props: pullRequestProps, - returns: pullRequestReturns, - fn: PullRequest, - ...documented({ - description: `Open or update a pull request. \`…\` uses its content as the body after \`\`.`, - as: "Optional. The pull request's repository, number, url, state and head and base commits.", - context: "The pull request's body.", - }), - }, - { - name: "PullRequest.Reviews", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: reviewsReturns, - fn: formDispatcher(reviewsForm), - ...documented({ - description: `Read pull request reviews. \`\` reads reviews from the specified pull request.`, - as: "Required. Each review's author, state, body, submission time, commit and url.", - context: null, - }), - }, - { - name: "PullRequest.Comments", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: commentsReturns, - fn: formDispatcher(commentsForm), - ...documented({ - description: `Read pull request comments. \`\` reads both conversation and review comments.`, - as: "Required. Each comment's kind, author, body, timestamps and url, and a review comment's file, hunk and line.", - context: null, - }), - }, - { - name: "PullRequest.Checks", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: checksReturns, - fn: formDispatcher(checksForm), - ...documented({ - description: `Read pull request checks. \`\` reads check runs and commit statuses for its head.`, - as: "Required. Each check's kind, name, head commit and outcome.", - context: null, - }), - }, - { - name: "IssueTracker", - origin: COMPOSITION_ORIGIN, - props: issueTrackerProps, - fn: IssueTracker, - ...documented({ - description: `Select an issue tracker. \`…\` expands its content with the specified tracker in scope.`, - as: null, - context: "The Markdown whose issues are filed there.", - }), - }, - { - name: "Issue", - origin: COMPOSITION_ORIGIN, - props: issueProps, - returns: issueReturns, - fn: Issue, - ...documented(issueDocumentation), - }, ]; +/** The destination an `` inside it is filed at. */ +const TRACKER: ComponentRegistration = { + name: "IssueTracker", + origin: COMPOSITION_ORIGIN, + props: issueTrackerProps, + fn: IssueTracker, + ...documented({ + description: `Select an issue tracker. \`…\` expands its content with the specified tracker in scope.`, + as: null, + context: "The Markdown whose issues are filed there.", + }), +}; + /** - * Register the composition vocabulary as ordinary defaults for this scope. + * The six operations no local transaction encloses, as ordinary registrations. * - * The registrations and the documentation that describes them, installed - * together so a scope that has one has the other. Both are declarations: this - * installs no provider, discovers no ambient repository, acquires no lock, - * spawns no Git and reads no credential, which is what lets `xmd syntax` enter - * it to describe the profile. + * The same bodies the declaring path carries, over the public feature Apis. + * Registered rather than declared, because a host that retains nothing named + * after an invocation has no identity for one to name — and because these stay + * ordinary defaults a nearer registration shadows. + */ +const EXTERNAL_OPERATIONS: readonly ComponentRegistration[] = [ + pushRegistration(ORDINARY.push), + pullRequestRegistration(ORDINARY.pullRequest), + ...PULL_REQUEST_READ_KINDS.map((kind) => pullRequestReadRegistration(kind, ORDINARY.pullRequest)), + issueRegistration(ORDINARY.issue), +]; + +/** + * The selection, local-operation and tracker half: everything a host that + * installs its own policy under the six operations still registers ordinarily. + */ +export const SELECTION_REGISTRATIONS: readonly ComponentRegistration[] = Object.freeze([ + ...SELECTIONS, + ...LOCAL_OPERATIONS, + TRACKER, +]); + +/** All sixteen, in catalog order. */ +export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = Object.freeze([ + ...SELECTIONS, + ...LOCAL_OPERATIONS, + ...EXTERNAL_OPERATIONS, + TRACKER, +]); + +/** + * Register the whole vocabulary as ordinary defaults for this scope. + * + * Every one of them is a declaration: this installs no provider, discovers no + * ambient repository, acquires no lock, spawns no Git and reads no credential, + * which is what lets `xmd syntax` enter it to describe the profile. */ export function* useCompositionComponents(): Operation { yield* registerComponents(COMPOSITION_REGISTRATIONS); yield* contributeDocumentation(compositionDocumentation); } + +/** + * Register everything except the six operations, for a command whose + * executions install their own policy under them. + * + * A REPL entry declares those six through {@link compositionDeclarations} with + * the policy it retains them under, so registering an executable default here + * would be registering a body for the entry's declaration to shadow. The + * documentation still describes all sixteen: it is the same vocabulary either + * way, and a reader asking about `` is asking about one component. + */ +export function* useCompositionSelections(): Operation { + yield* registerComponents(SELECTION_REGISTRATIONS); + yield* contributeDocumentation(compositionDocumentation); +} diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts new file mode 100644 index 000000000..36be31401 --- /dev/null +++ b/packages/git/src/composition/invocation-binding.ts @@ -0,0 +1,181 @@ +/** + * The one handoff between a repository component's invocation and the host + * policy that performs it. + * + * Every authored repository operation — ``, ``, + * `` and the three pull-request evidence reads — has one + * implementation, and that implementation asks a semantic feature operation for + * its work. What differs between an ordinary run, a workflow run and a REPL + * entry is not the component: it is how the operation it asks is performed, + * retained and admitted. This is where those two meet. + * + * ## What the binding owns, and what it never touches + * + * It authenticates the actual invocation canonical core issued, once, in the + * engine frame the component was entered in — before the component projects any + * content, because a projecting ancestor is deliberately ineligible for one. + * It then builds this invocation's own operation facade from the host's policy + * and runs the one shared body against it. + * + * It interprets no prop, expands no content, names no durable record and reads + * nothing of the document. The site it authenticated is private to the policy + * it hands it to: no facade method answers with it, no request carries it and + * no Context holds it. + * + * ## The facade ends with its invocation + * + * Revocation is armed before the facade can be used, so an invocation halted + * while it is being built leaves nothing usable behind, and it runs on success, + * failure and cancellation alike. A facade kept past its own invocation + * performs nothing, and one authored operation performs one retained + * operation — a second call through the same facade is refused rather than + * repeating external work this invocation has already asked for. + */ + +import { ensure } from "effection"; +import type { Operation } from "effection"; +import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; +import type { IdentityClaimant } from "@executablemd/core/host"; +import type { Json } from "@executablemd/durable-streams"; + +/** Why a facade refused rather than performing. */ +export type FacadeRefusal = + /** The invocation that owns this facade has finished. */ + | "ended" + /** This invocation already performed its one retained operation. */ + | "spent"; + +/** + * A facade asked to perform where it names no invocation to perform for. + * + * One sentence for one boundary, parameterized by the element that was written, + * because this is the binding's own refusal rather than any feature's: neither + * condition is something a document can arrange, a provider can answer or a + * tracker can explain. Every feature keeps its own vocabulary for every refusal + * it does decide. + */ +export class BoundOperationError extends Error { + override name = "BoundOperationError"; + + readonly reason: FacadeRefusal; + + constructor(element: string, reason: FacadeRefusal) { + super( + reason === "ended" + ? `${element} belongs to an invocation that has finished, so there is no invocation for ` + + "this operation to be performed under. Nothing was asked of the host" + : `${element} has already performed its one external operation, so performing again ` + + "would repeat work this invocation acknowledged. Nothing was asked of the host", + ); + this.reason = reason; + } +} + +/** + * What a host policy performs one invocation's work through. + * + * Handed to the policy rather than applied around it, because only the policy + * knows which of its members is the operation an authored element asks for: a + * facade of two operations where the document wrote one is still one + * invocation, and the second member exists to be refused. + */ +export interface InvocationGate { + /** Perform this invocation's one retained operation. */ + once(operation: () => Operation): Operation; +} + +/** How one host builds the operations one authenticated invocation may use. */ +export type BindOperations = (site: string, gate: InvocationGate) => Operations; + +/** The one authored implementation of a repository operation. */ +export type ComponentBody = ( + props: Record, + operations: Operations, +) => Operation; + +/** One authored operation, as its declaration states it. */ +export interface BoundComponent { + /** The element a refusal names, as a document writes it. */ + readonly element: string; + /** The host policy this execution selected for this operation. */ + readonly bind: BindOperations; + /** The one implementation, which receives only semantic operations. */ + readonly body: ComponentBody; +} + +/** Whether this invocation's facade is usable, and whether it has been used. */ +interface Live { + open: boolean; + used: boolean; +} + +/** + * The implementation an ordinary registration carries for one authored + * operation. + * + * The same body, over the same kind of facade, for a host that retains nothing + * named after an invocation. No identity is authenticated because there is + * nothing for one to name: the operations are the public feature Apis, and whichever + * provider the host installed is what answers. The facade still ends with the + * invocation, so a body that kept one performs nothing afterwards. + */ +export function unboundComponent( + element: string, + operations: (gate: InvocationGate) => Operations, + body: ComponentBody, +): FunctionComponent { + return function* (props: Record): Operation { + const live: Live = { open: false, used: false }; + yield* ensure(() => { + live.open = false; + }); + const facade = operations({ + once: (operation: () => Operation): Operation => performed(live, element, operation), + }); + live.open = true; + return yield* body(props, facade); + }; +} + +/** + * The implementation a declaration registers for one authored operation. + * + * Built once per execution, from the authenticator that execution minted for + * this component name, and invoked by the engine with the invocation it issued. + */ +export function boundComponent( + claim: IdentityClaimant, + bound: BoundComponent, +): FunctionComponent { + return function* ( + props: Record, + invocation: ComponentInvocation, + ): Operation { + // Authenticated here, in the frame the engine entered, and before the body + // reaches any content of its own. + const site = yield* claim(invocation); + const live: Live = { open: false, used: false }; + // Armed before the facade exists, because an invocation halted while it is + // registering this would unwind with no revocation on it at all. + yield* ensure(() => { + live.open = false; + }); + const operations = bound.bind(site, { + once: (operation: () => Operation): Operation => + performed(live, bound.element, operation), + }); + live.open = true; + return yield* bound.body(props, operations); + }; +} + +function* performed(live: Live, element: string, operation: () => Operation): Operation { + if (!live.open) { + throw new BoundOperationError(element, "ended"); + } + if (live.used) { + throw new BoundOperationError(element, "spent"); + } + live.used = true; + return yield* operation(); +} diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/git/src/deno/retained-repository/issues.ts index 80743b55b..4ceb2498d 100644 --- a/packages/git/src/deno/retained-repository/issues.ts +++ b/packages/git/src/deno/retained-repository/issues.ts @@ -12,16 +12,15 @@ * * 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. + * site the common invocation binding authenticated. The policy below closes + * over the owner and receives that site as an argument, 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 + * This policy 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 @@ -45,27 +44,15 @@ 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 { ExecutionInstallation, 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"; + compositionDeclarations, + ordinaryCompositionPolicy, +} from "../../composition/installation.ts"; +import type { BindOperations } from "../../composition/invocation-binding.ts"; import { IssueApi } from "../../issue/api.ts"; import type { IssueDetails, IssueReference } from "../../issue/api.ts"; import { IssueOperations } from "../../issue/operations.ts"; @@ -131,93 +118,59 @@ export function replIssueInstallation( 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 whole common vocabulary, declared once with this entry's issue policy + // under it. The other five keep the ordinary policy, so `` and + // `` in an entry whose host recorded only issues reach their + // own unchanged missing-provider refusals. + components: compositionDeclarations({ + ...ordinaryCompositionPolicy(), + issue: replIssuePolicy(owner), + }), + admissions: [replIssueAdmission(owner)], + install: () => useEntryIssueTransport(options), }; } /** - * The entry's own `Issue`, declared as an ordinary shadowable component. + * The platform's transport, handed to an adapter that knows the protocol and + * not the platform. * - * 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. + * Naming it is not building one: it is a factory the adapter calls if and when + * a request turns out to be its own. */ -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), - }), - }; +export function useEntryIssueTransport(options: GitHubIssuesOptions): Operation { + return useGitHubIssues({ host: denoGitHubSource, ...options }); } -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), +/** + * How this entry performs one ``, in either of its two forms. + * + * The policy the common declaration is built with. It is called once per + * invocation, with the site the binding authenticated, and what it answers with + * is that invocation's own pair of issue operations. Which of the two the + * document asked for was settled by canonical form dispatch before the body + * ran, so this answers both and the invocation performs exactly one. + * + * The key one upsert attempt presents is derived here, from this 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 and changes + * nothing about which attempt this is. + */ +export function replIssuePolicy(owner: string): BindOperations { + return (site: string, gate) => ({ + read: (invocation: IssueReadInvocation) => + gate.once(() => readOwnedIssue({ owner, site, request: invocation }, liveRead(invocation))), + upsert: (invocation: IssueUpsertInvocation) => + gate.once(() => + upsertOwnedIssue( + { owner, site, request: invocation }, + liveUpsert( + invocation, + issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", invocation.target), + ), + ), ), - ); - return { url: reference.url }; - }; + }); } /** @@ -306,7 +259,7 @@ function transportUpsert(key: string): Partial> { * 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 { +export function replIssueAdmission(owner: string): JournalAdmission { return function* (retained: readonly DurableEvent[]): Operation { for (const event of retained) { if (event.type !== "yield" || event.description.type !== ISSUE_EFFECT) { diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index a2466dcbd..f55fade8c 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -26,11 +26,12 @@ * ``, and, for an entry whose capability was built per entry below, * the publication an authored `` asks for. * - * A pull request and an Issue tracker are not here: each has its own protocol - * and its own evidence, and a local result and a publication authorize neither. - * `` stays declared where it already is and refuses through its - * own unchanged missing-provider default, and what is installed here is not the - * ordinary all-feature provider, which would enable it. + * A pull request is not here: it has its own protocol and its own evidence, and + * a local result and a publication authorize neither. `` and its + * three evidence reads are declared with the ordinary policy beneath them, so + * they refuse through their own unchanged missing-provider default — and what + * is installed here is not the ordinary all-feature provider, which would + * enable them. * * ## Installing it reaches nothing * @@ -80,9 +81,13 @@ import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; import { replObservationMiddleware } from "./observations.ts"; import type { ReplObservationEntry } from "./observations.ts"; import { GitQuery } from "../../git.ts"; -import { replPushAdmission, replPushComponent } from "./push.ts"; +import { replPushAdmission, replPushPolicy } from "./push.ts"; import type { ReplPushEntry } from "./push.ts"; -import { replIssueInstallation } from "./issues.ts"; +import { replIssueAdmission, replIssuePolicy, useEntryIssueTransport } from "./issues.ts"; +import { + compositionDeclarations, + ordinaryCompositionPolicy, +} from "../../composition/installation.ts"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { IdentityComponent, JournalAdmission } from "@executablemd/core/host"; @@ -168,9 +173,11 @@ export function useReplRepositoryRecording(options: ReplRecordingOptions = {}): * never returned to the CLI, published through Context or reachable from a * document. * - * The issue capability an entry runs with is composed in whole, declarations, - * admissions and installation together, because one entry installs one - * capability and neither feature's internals belong to the other. + * One entry declares the common vocabulary once, with this host's policy under + * the two operations it retains and the ordinary policy under the rest. There + * is no inherited executable default here for an entry registration to shadow: + * the declarations an execution captures are these, and a host that recorded + * nothing for an operation leaves that operation its own refusal. */ export function* replRepositoryInstallation( owner: string, @@ -196,17 +203,16 @@ export function* replRepositoryInstallation( }, }); - const issues = replIssueInstallation(owner); - const components: IdentityComponent[] = [ - ...(issues.components ?? []), - replPushComponent(owner, () => (usable ? held.entry : undefined)), - ]; - const admissions: JournalAdmission[] = [...(issues.admissions ?? []), replPushAdmission(owner)]; - const install = issues.install; + const components: readonly IdentityComponent[] = compositionDeclarations({ + ...ordinaryCompositionPolicy(), + issue: replIssuePolicy(owner), + push: replPushPolicy(owner, () => (usable ? held.entry : undefined)), + }); + const admissions: JournalAdmission[] = [replIssueAdmission(owner), replPushAdmission(owner)]; return { components, admissions, - ...(install === undefined ? {} : { install: () => install.call(issues) }), + install: () => useEntryIssueTransport({}), }; } diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts index f41a4a515..5914e4b7f 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/git/src/deno/retained-repository/push.ts @@ -17,14 +17,13 @@ * * ## What a document writes, and what it cannot reach * - * `` stays an ordinary shadowable component with the origin, props - * and prose it already had. What the entry's own declaration adds is the - * identity canonical core mints for each invocation: the trusted body takes that - * identity from the invocation once, snapshots the Repository in scope and the - * contextual working directory, and owns its private owner and site throughout. None of - * them reaches public Git input, so a replacement registration — or a direct - * `Git.operations.push(...)` from anywhere — names no durable work of this - * entry's and reaches the ordinary missing-provider refusal. + * `` is the one shared component, with the origin, props and prose + * it already had. What this host adds is policy beneath it: the common + * invocation binding authenticates each invocation and hands this policy the + * site it authenticated, and the policy owns its private owner and site + * throughout. Neither reaches public Git input, so a replacement registration — + * or a direct `Git.operations.push(...)` from anywhere — names no durable work + * of this entry's and reaches the ordinary missing-provider refusal. * * A fresh invocation dispatches through an operation-local default under the * stable Git Api name, so every public handler a document or a plugin composed @@ -67,30 +66,17 @@ import type { LiveDurableOperationCoordinator, Result as ProtocolResult, } from "@executablemd/durable-streams"; -import { documented, formDispatcher } from "@executablemd/core"; -import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; -import type { - IdentityClaimant, - IdentityComponent, - JournalAdmission, -} from "@executablemd/core/host"; -import { cwd } from "@executablemd/runtime"; -import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; -import { - documentation as gitPushDocumentation, - PUSH, - props as gitPushProps, -} from "../../composition/components/GitPush.ts"; -import { selectedRepository } from "../../composition/context.ts"; +import type { JournalAdmission } from "@executablemd/core/host"; +import { PUSH } from "../../composition/components/GitPush.ts"; import { GitCompositionProviderError, GitOperationAdmissionError, - GitOperationError, GitOperationInfrastructureError, GitOperationProtocolError, } from "../../composition/errors.ts"; import { GIT_API } from "../../composition/git-api.ts"; import type { GitApi, GitPushInvocation } from "../../composition/git-api.ts"; +import type { BindOperations } from "../../composition/invocation-binding.ts"; import { destinationRefFor, parseGitPushRecord, @@ -183,76 +169,32 @@ export function replPushAdmission(owner: string): JournalAdmission { } /** - * The entry's own `Git.Push`, declared as an ordinary shadowable component. + * How this entry performs one ``. * - * It shadows the inherited bundled registration for this execution's scope - * without touching it, 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. + * The policy the common declaration is built with. It is called once per + * invocation, with the site the binding authenticated, and what it answers with + * is that invocation's own publication operation: the shared component asks it, + * and everything about retention, dispatch and reconciliation happens below + * this line. * - * Inspection describes one `Git.Push` — the same origin, props, forms and prose - * the bundled registration carries — because it is the same component with this - * entry's identity behind it. + * The site reaches no public Git input and no journal name of its own. Its + * parsed facts are what the saved request carries, because the opaque + * identifier this entry minted names nothing once the entry ends. */ -export function replPushComponent(owner: string, access: ReplPushAccess): IdentityComponent { - return { - name: "Git.Push", - origin: COMPOSITION_ORIGIN, - props: gitPushProps, - forms: ["self-closing"], - // The bundled registration's own prose, read from the component module both - // of them read: inspection describes one `Git.Push`, so the two cannot - // drift into describing two. - ...documented(gitPushDocumentation), - // Canonical form dispatch, so the form is settled by the scan before the - // body runs. A contextual `hasContent()` answer cannot admit a paired - // publication: the chain that answers one is composed by whoever installed - // a handler, and an irreversible external effect is not something a handler - // may choose. - factory: (claim: IdentityClaimant) => - formDispatcher({ - forms: "self-closing", - fn: publishingBody(owner, access, claim), - refuse: () => - new GitOperationError( - PUSH, - "invalid-invocation", - `${PUSH} renders nothing, so it takes no content. Write it as .`, - ), - }), - }; -} - -function publishingBody( +export function replPushPolicy( owner: string, access: ReplPushAccess, - claim: IdentityClaimant, -): FunctionComponent { - return function* GitPush( - _props: Record, - invocation: ComponentInvocation, - ): Operation { - // The same lexical refusal the ordinary component makes, in the same order - // and with the same sentence: there is nowhere to publish to before there - // is anything to publish. - const repository = yield* selectedRepository(); - if (repository === undefined) { - throw new GitOperationAdmissionError( - PUSH, - "it is written outside a lexical , so there is no repository in scope whose " + - "origin a branch could be published to", - ); - } - const site = yield* claim(invocation); - yield* publish({ - owner, - site, - access, - repository, - workingDirectory: yield* cwd(), - }); - return ""; - }; +): BindOperations { + return (site: string, gate) => (request: GitPushInvocation) => + gate.once(() => + publish({ + owner, + site, + access, + repository: request.repository, + workingDirectory: request.workingDirectory, + }), + ); } interface Invocation { @@ -271,7 +213,7 @@ interface Invocation { * request, and only an invocation with nothing saved dispatches, resolves and * writes one down. */ -function* publish(invocation: Invocation): Operation { +function* publish(invocation: Invocation): Operation { const entry = held(invocation.access); // Authenticated before anything else, and its *parsed* facts are what the // request carries. The opaque identifier this entry minted never appears: it @@ -294,8 +236,7 @@ function* publish(invocation: Invocation): Operation { if (yield* retainedRecordAhead(REPL_REPOSITORY_PUSH_REQUEST, name)) { const saved = yield* restored(entry, invocation, requested, name); - yield* reconcile(entry, invocation, saved); - return; + return yield* reconcile(entry, invocation, saved); } // One operation-local default under the stable Api name: the shared @@ -316,10 +257,10 @@ function* publish(invocation: Invocation): Operation { commit: refusing(""), }); try { - // What a handler answers with is not read. A synthetic answer has answered - // the document; it is not a receipt, and this operation retains nothing for - // it. - yield* local.operations.push({ + // What a handler answers with is not read as evidence. A synthetic answer + // has answered the document; it is not a receipt, and this operation + // retains nothing for it. + return yield* local.operations.push({ repository: invocation.repository, workingDirectory: invocation.workingDirectory, }); diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts index 85ce6bb56..d7b5f8815 100644 --- a/packages/git/src/plugin.ts +++ b/packages/git/src/plugin.ts @@ -33,7 +33,7 @@ import type { PluginInstallRequest, PluginInstallation, } from "@executablemd/core/api"; -import { useCompositionComponents } from "./composition/installation.ts"; +import { useCompositionComponents, useCompositionSelections } from "./composition/installation.ts"; import { admitReplRepositoryHistory } from "./retained-repository/admission.ts"; import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.ts"; @@ -187,6 +187,31 @@ export function declaresFor(request: PluginInstallRequest): boolean { return request.command === "workflow" && executesDocument(request.args); } +/** + * The commands whose executions install their own policy under the six + * operations no local transaction encloses. + * + * `repl` alone: an entry retains what it published and what it filed, so each + * of those operations is declared with that entry's policy under it rather than + * registered with the ordinary one. Every other command registers them + * ordinarily, where a nearer registration still shadows one. + */ +const RETAINING_COMMANDS: ReadonlySet = new Set(["repl"]); + +/** + * Whether this command's executions declare those six rather than register + * them. + * + * Asked of the Plugin, because the answer is the Plugin's: it is the value that + * decides which commands carry this vocabulary at all, and a host assembling + * one needs the two halves to agree. A command answering true registers the + * selection half and declares the operations; one answering false registers all + * twelve. + */ +export function declaresOperationPolicy(request: PluginInstallRequest): boolean { + return declaresFor(request) && RETAINING_COMMANDS.has(request.command); +} + export const gitPlugin: Plugin = Plugin({ name: "@executablemd/git", *install(request: PluginInstallRequest): Operation { @@ -196,7 +221,16 @@ export const gitPlugin: Plugin = Plugin({ // Declarations only. Every one of these is a name and a description; the // provider that performs what they name is attached by the host, lazily, // when a run actually has a Workspace to perform it in. - yield* useCompositionComponents(); + // + // A command whose executions retain their own external work registers the + // selection half alone: the six operations arrive as declarations carrying + // that host's policy, and registering an executable default for them here + // would be registering a body for those declarations to shadow. + if (declaresOperationPolicy(request)) { + yield* useCompositionSelections(); + } else { + yield* useCompositionComponents(); + } return { admissions: [ gitHostIdentityAdmission(), diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts new file mode 100644 index 000000000..91c2b63be --- /dev/null +++ b/packages/git/tests/composition-component-binding.test.ts @@ -0,0 +1,502 @@ +/** + * One declaration source, one body, and a facade that ends with its invocation. + * + * Every row here drives the actual shared components through canonical + * execution. What differs between rows is the policy underneath: these are + * neutral constructors and fake policies, so what is proven is the binding and + * the bodies rather than any host's retention. The real native assembly is + * covered by the CLI and native suites. + * + * These rows carry A1, A2 and the binding half of A5 of the component-boundary + * repair matrix. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +import { writeTextFile } from "@effectionx/fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collect, Component, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration, Json } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { ExecutionInstallation, IdentityComponent } from "@executablemd/core/host"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { + compositionDeclarations, + COMPOSITION_REGISTRATIONS, + ordinaryCompositionPolicy, + SELECTION_REGISTRATIONS, +} from "../src/composition/installation.ts"; +import type { CompositionPolicy } from "../src/composition/installation.ts"; +import { BoundOperationError } from "../src/composition/invocation-binding.ts"; +import type { InvocationGate } from "../src/composition/invocation-binding.ts"; +// From the subpath that publishes them rather than from the source modules: +// these are public types, and reaching them by relative path would bypass the +// export map the partition test holds every consumer to. +import type { GitApi, IssueDetails, IssueReference } from "@executablemd/git/api"; +import type { IssueOperationsApi } from "../src/issue/operations.ts"; +import type { PullRequestOperationsApi } from "../src/composition/pull-request-operations.ts"; +import { COMPOSITION_ORIGIN } from "../src/composition/definitions.ts"; + +/** The six operations no local transaction encloses, by the names a document writes. */ +const OPERATIONS: readonly string[] = [ + "Git.Push", + "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", +]; + +/** The tracker every upsert row files in. */ +const TRACKER = "https://github.com/octo/project"; + +/** What one row watched its policy be asked for. */ +interface Watched { + /** Each authenticated site a facade was built for, in the order built. */ + readonly sites: string[]; + /** Each operation performed, in the order the bodies asked for them. */ + readonly performed: string[]; + /** The issue facade of the most recent invocation, kept past its own life. */ + kept?: IssueOperationsApi; +} + +function watched(): Watched { + return { sites: [], performed: [] }; +} + +/** One issue reference, built out of nothing: these rows retain no history. */ +function reference(url: string): IssueReference { + return { url }; +} + +function details(url: string): IssueDetails { + return { url, title: "fake", description: "fake", tags: [], assignee: null }; +} + +/** + * A policy that answers both issue operations itself and records what it was + * asked. + * + * Fake on purpose: what these rows are about is the handoff, so the policy + * below reaches no service, no journal and no provider. A real one is the + * entry's, and the native suites drive that. + */ +function fakeIssuePolicy(seen: Watched, hold = false) { + return (site: string, gate: InvocationGate): IssueOperationsApi => { + seen.sites.push(site); + const operations: IssueOperationsApi = { + read: (invocation) => + gate.once(function* (): Operation { + seen.performed.push(`read ${invocation.url}`); + return details(invocation.url); + }), + upsert: (invocation) => + gate.once(function* (): Operation { + seen.performed.push(`upsert ${invocation.target}`); + return reference(`${invocation.target}/issues/1`); + }), + }; + if (hold) { + seen.kept = operations; + } + return operations; + }; +} + +/** A policy whose push suspends until this row releases it. */ +function suspendingPushPolicy(seen: Watched, reached: { resolve(): void }) { + return (site: string, gate: InvocationGate): GitApi["push"] => { + seen.sites.push(site); + return () => + gate.once(function* (): Operation { + seen.performed.push("push"); + reached.resolve(); + yield* suspend(); + throw new Error("a suspended publication resumed"); + }); + }; +} + +/** The policy a row builds its declarations with. */ +function policy(overrides: Partial): CompositionPolicy { + return { ...ordinaryCompositionPolicy(), ...overrides }; +} + +/** The declarations one row installs, as a host installs them. */ +function declaring(overrides: Partial): ExecutionInstallation { + return { components: compositionDeclarations(policy(overrides)) }; +} + +interface Ran { + readonly output: string; + readonly refusal: string | undefined; +} + +/** One document, through canonical execution, under this row's declarations. */ +function run( + root: string, + source: string, + installations: readonly ExecutionInstallation[], + before?: () => Operation, +): Operation { + return scoped(function* (): Operation { + const path = join(root, `entry-${crypto.randomUUID()}.md`); + yield* writeTextFile(path, source); + // The selection half, as a host that declares the operations registers it. + yield* registerComponents(SELECTION_REGISTRATIONS); + if (before !== undefined) { + yield* before(); + } + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream: new InMemoryStream(), includes: [root] }, [ + ...installations, + ]), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, refusal }; + }); +} + +/** This suite's own directory, for the documents it writes. */ +function useRoot(): Operation { + return resource(function* (provide) { + const root = yield* until(mkdtemp(join(tmpdir(), "composition-binding-"))); + yield* ensure(() => until(rm(root, { recursive: true, force: true }))); + yield* provide(root); + }); +} + +/** The registration and the declaration of one name, for comparison. */ +function paired(name: string): { + registration: ComponentRegistration | undefined; + declaration: IdentityComponent | undefined; +} { + return { + registration: COMPOSITION_REGISTRATIONS.find((entry) => entry.name === name), + declaration: compositionDeclarations(ordinaryCompositionPolicy()).find( + (entry) => entry.name === name, + ), + }; +} + +describe("one declaration source for every repository operation", () => { + it("A1: describes one component whichever path carries it", function* (): Operation { + // The whole vocabulary is registered ordinarily, and the six operations are + // also offered as declarations. What a reader is told is the same either + // way: same origin, same props, same return contract, same forms, same + // prose. A metadata difference here would be two components under one name. + for (const name of OPERATIONS) { + const { registration, declaration } = paired(name); + expect(registration).toBeDefined(); + expect(declaration).toBeDefined(); + expect(declaration?.origin).toBe(registration?.origin); + expect(declaration?.origin).toBe(COMPOSITION_ORIGIN); + expect(declaration?.props).toEqual(registration?.props); + expect(declaration?.returns).toEqual(registration?.returns); + expect(declaration?.forms).toEqual(registration?.forms); + expect(declaration?.description).toBe(registration?.description); + } + }); + + it("A1: the selection half carries none of the six, and all twelve names exist once", function* (): Operation { + const registered = COMPOSITION_REGISTRATIONS.map((entry) => entry.name); + expect(registered).toHaveLength(12); + expect(new Set(registered).size).toBe(12); + // A host that installs its own policy under the six registers everything + // else, so there is no executable default for its declarations to shadow. + const selections = SELECTION_REGISTRATIONS.map((entry) => entry.name); + expect(selections).toHaveLength(6); + for (const name of OPERATIONS) { + expect(selections).not.toContain(name); + expect(registered).toContain(name); + } + // And the two halves together are the whole vocabulary, in catalog order. + expect([...selections, ...OPERATIONS].sort()).toEqual([...registered].sort()); + }); + + it("A1: building declarations calls no factory and initializes no policy", function* (): Operation { + let built = 0; + const declarations = compositionDeclarations( + policy({ + issue: (site: string, gate: InvocationGate): IssueOperationsApi => { + built += 1; + return fakeIssuePolicy(watched())(site, gate); + }, + }), + ); + expect(declarations).toHaveLength(6); + // Inspection and validation read these. Nothing has been claimed, nothing + // has been bound, and no policy exists yet. + expect(built).toBe(0); + expect(declarations.map((entry) => entry.name).sort()).toEqual([...OPERATIONS].sort()); + }); + + it("A1: canonical dispatch decides the form, whatever a contextual answer says", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + + // A handler that reports the opposite shape to anything that asks. The form + // this element was written as is the scan's, so the read body runs and the + // upsert body does not. + const lying = function* (): Operation { + yield* Component.around({ + // deno-lint-ignore require-yield + *hasContent(): Operation { + return true; + }, + }); + }; + + const ran = yield* run( + root, + [ + ``, + ``, + ``, + "", + ].join("\n"), + [declaring({ issue: fakeIssuePolicy(seen) })], + lying, + ); + + expect(ran.refusal).toBe(undefined); + expect(seen.performed).toEqual([`read ${TRACKER}/issues/7`]); + }); + + it("A2: a nested read finishes before the upsert around it is performed", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + + const ran = yield* run( + root, + [ + ``, + ``, + `what the inner read found: {inner.url}`, + ``, + ``, + "", + ].join("\n"), + [declaring({ issue: fakeIssuePolicy(seen) })], + ); + + expect(ran.refusal).toBe(undefined); + // The child's own operation first: an upsert renders its description before + // it is offered, so a child that retains work of its own finishes first. + expect(seen.performed).toEqual([`read ${TRACKER}/issues/3`, `upsert ${TRACKER}`]); + // Two invocations, two facades, two sites — the outer one built first. + expect(seen.sites).toHaveLength(2); + expect(new Set(seen.sites).size).toBe(2); + }); + + it("A2: two concurrent invocations keep their own sites", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + + const ran = yield* run( + root, + [ + ``, + ``, + `first`, + `second`, + ``, + ``, + "", + ].join("\n"), + [declaring({ issue: fakeIssuePolicy(seen) })], + ); + + expect(ran.refusal).toBe(undefined); + expect(seen.sites).toHaveLength(2); + expect(new Set(seen.sites).size).toBe(2); + }); + + it("A2: a facade kept past its invocation performs nothing", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + + const ran = yield* run( + root, + [ + ``, + `described`, + ``, + "", + ].join("\n"), + [declaring({ issue: fakeIssuePolicy(seen, true) })], + ); + + expect(ran.refusal).toBe(undefined); + expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + + const kept = seen.kept; + expect(kept).toBeDefined(); + // The invocation is over, so this names nothing to perform under. Both + // members refuse, and the policy beneath was never reached again. + const refusals: unknown[] = []; + for (const attempt of [ + () => kept?.read({ url: `${TRACKER}/issues/1`, provider: undefined }), + () => kept?.upsert({ target: TRACKER, provider: undefined, issue: ISSUE_INPUT }), + ]) { + try { + yield* attempt() ?? suspend(); + } catch (error) { + refusals.push(error); + } + } + expect(refusals).toHaveLength(2); + for (const refusal of refusals) { + expect(refusal).toBeInstanceOf(BoundOperationError); + expect((refusal as BoundOperationError).reason).toBe("ended"); + } + expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + }); + + it("A2: one invocation performs one external operation", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + const second: unknown[] = []; + + // A policy that performs its upsert and then asks the same facade again, + // which is what a duplicate delegation would look like from underneath. + const twice = (site: string, gate: InvocationGate): IssueOperationsApi => { + seen.sites.push(site); + return { + read: (invocation) => + gate.once(function* (): Operation { + return details(invocation.url); + }), + *upsert(invocation) { + const first = yield* gate.once(function* (): Operation { + seen.performed.push(`upsert ${invocation.target}`); + return reference(`${invocation.target}/issues/1`); + }); + try { + yield* gate.once(function* (): Operation { + seen.performed.push("repeated"); + return first; + }); + } catch (error) { + second.push(error); + } + return first; + }, + }; + }; + + const ran = yield* run( + root, + [ + ``, + `described`, + ``, + "", + ].join("\n"), + [declaring({ issue: twice })], + ); + + expect(ran.refusal).toBe(undefined); + expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + expect(second).toHaveLength(1); + expect(second[0]).toBeInstanceOf(BoundOperationError); + expect((second[0] as BoundOperationError).reason).toBe("spent"); + }); + + it("A5: a cancelled invocation revokes its facade", function* (): Operation { + const root = yield* useRoot(); + const seen = watched(); + const reached = withResolvers(); + const held: { kept?: IssueOperationsApi } = {}; + + // The upsert suspends inside the facade, and this row halts the whole + // execution while it is standing there. + const suspending = (site: string, gate: InvocationGate): IssueOperationsApi => { + seen.sites.push(site); + const operations: IssueOperationsApi = { + read: (invocation) => + gate.once(function* (): Operation { + return details(invocation.url); + }), + upsert: (invocation) => + gate.once(function* (): Operation { + seen.performed.push(`upsert ${invocation.target}`); + reached.resolve(); + yield* suspend(); + throw new Error("a suspended upsert resumed"); + }), + }; + held.kept = operations; + return operations; + }; + + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run( + root, + [ + ``, + `described`, + ``, + "", + ].join("\n"), + [declaring({ issue: suspending })], + ), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the upsert never reached its policy"); + })(), + ]); + yield* task.halt(); + }); + + expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + + // Nothing of that invocation survives it. Revocation was armed before the + // facade could be used, so a cancellation part-way through leaves a facade + // that performs nothing rather than one that still reaches the policy. + const kept = held.kept; + expect(kept).toBeDefined(); + let refusal: unknown; + try { + yield* kept?.read({ url: `${TRACKER}/issues/1`, provider: undefined }) ?? suspend(); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(BoundOperationError); + expect((refusal as BoundOperationError).reason).toBe("ended"); + expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + }); +}); + +/** The issue one refused attempt would have filed, had it been performed. */ +const ISSUE_INPUT = Object.freeze({ + title: "refused", + description: "refused", + tags: Object.freeze([]), + assignee: null, +}); diff --git a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json new file mode 100644 index 000000000..22b96b404 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json @@ -0,0 +1,538 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "content": "\n \n ready\n \n \n \n \nThe branch was published. Continue?\n \n continued\n\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Repository", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_placement", + "name": "[\"executablemd.git.repl-repository\",\"placement\",1,\"repository\",\"a11c187456d5257ffca2eaed19edd562b1e7b22ba0a67629e100b3dc89c93b5d\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "kind": "repository", + "fingerprint": "a11c187456d5257ffca2eaed19edd562b1e7b22ba0a67629e100b3dc89c93b5d", + "request": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "base": null + }, + "placement": { + "managedRoot": "/private/tmp/xmd-retained-history/h1-push-completed/repositories", + "checkoutRoot": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "identity": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "displayName": "project", + "ownerCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Switch", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 99, + "line": 2, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"switch\",\"880af1c7a2a3a2c20754022bac3ef106b8a659e4c6602aaee10f2c2881f93a8e\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "switch", + "fingerprint": "880af1c7a2a3a2c20754022bac3ef106b8a659e4c6602aaee10f2c2881f93a8e", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "branch": "review/completed", + "base": null + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + }, + "requestedBranch": "review/completed", + "resolvedBranch": "review/completed", + "requestedBase": null, + "resolvedBase": "785495034b274fb9c8521e92105001eed072f916", + "before": { + "branch": "main", + "commit": "785495034b274fb9c8521e92105001eed072f916", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/completed", + "commit": "785495034b274fb9c8521e92105001eed072f916", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "File", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 142, + "line": 3, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "protected" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_local", + "name": "[\"executablemd.git.repl-repository\",\"local\",1,\"file-write\",\"80b0867e3d0168bb06e69e581f9ccb9b5e663a15ad0380769f5322a4cce4408c\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "file-write", + "fingerprint": "80b0867e3d0168bb06e69e581f9ccb9b5e663a15ad0380769f5322a4cce4408c", + "request": { + "cwd": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "path": "notes.md", + "content": "ready" + }, + "success": { + "type": "executablemd.runtime.files-write-success/v1", + "publication": "host-committed" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Add", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 179, + "line": 4, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"add\",\"3fc2169288cf2b9bce669f261a406697223f68547067ff0c3314c992bed64333\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "add", + "fingerprint": "3fc2169288cf2b9bce669f261a406697223f68547067ff0c3314c992bed64333", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "paths": ["notes.md"] + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + }, + "paths": ["notes.md"], + "before": { + "branch": "review/completed", + "commit": "785495034b274fb9c8521e92105001eed072f916", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/completed", + "commit": "785495034b274fb9c8521e92105001eed072f916", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Commit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 210, + "line": 5, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"commit\",\"d31cd38eb4739b5977e47fa19bd9ff85a2542dee29fa0bf1d948be530ef38937\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "commit", + "fingerprint": "d31cd38eb4739b5977e47fa19bd9ff85a2542dee29fa0bf1d948be530ef38937", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14 + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + }, + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14, + "parent": "785495034b274fb9c8521e92105001eed072f916", + "tree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "commit": "953925a9da72933365d566ee724593c97b7e939b", + "committedAt": 1792337425, + "before": { + "branch": "review/completed", + "commit": "785495034b274fb9c8521e92105001eed072f916", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + }, + "after": { + "branch": "review/completed", + "commit": "953925a9da72933365d566ee724593c97b7e939b", + "headTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Push", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 263, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_push_request", + "name": "c3db77f72e355c281b62e7dcd862837027f679d6279501797dc29e7c031dca24" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "owner": "[\"executablemd.repl.issue-owner\",1,\"h1\",\"1\"]", + "site": "974906d21df50bec66c33ccad4b16bab9da9743275ad3d12240d7132ffa42b9b", + "requested": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + }, + "received": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" + }, + "resolved": { + "checkoutRoot": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", + "origin": "/private/tmp/xmd-retained-history/h1-push-completed/origin.git", + "inputs": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/completed", + "destinationRef": "refs/heads/review/completed", + "sourceCommit": "953925a9da72933365d566ee724593c97b7e939b" + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "git_host_effect", + "name": "730d9587e32009d4b1f34002ed66a26f320841fee3e934f9163e4023e6a95401", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 263, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "request": { + "identity": { + "runId": "[\"executablemd.repl.issue-owner\",1,\"h1\",\"1\"]", + "expansionId": "974906d21df50bec66c33ccad4b16bab9da9743275ad3d12240d7132ffa42b9b" + }, + "kind": "git-push", + "inputs": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/completed", + "destinationRef": "refs/heads/review/completed", + "sourceCommit": "953925a9da72933365d566ee724593c97b7e939b" + }, + "naturalKey": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "destinationRef": "refs/heads/review/completed" + } + }, + "preState": { + "remoteCommit": null + }, + "observations": { + "remoteCommit": "953925a9da72933365d566ee724593c97b7e939b" + }, + "decision": "performed", + "result": { + "repository": { + "name": "project", + "locatorFingerprint": "dff3cfc11f18b2dacddf94962cac900c96fa6eb791888f1c491d795e7d74ea5a", + "requestedBase": null, + "creationCommit": "785495034b274fb9c8521e92105001eed072f916", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/completed", + "destinationRef": "refs/heads/review/completed", + "refspec": "953925a9da72933365d566ee724593c97b7e939b:refs/heads/review/completed", + "sourceCommit": "953925a9da72933365d566ee724593c97b7e939b", + "observedRemoteCommit": "953925a9da72933365d566ee724593c97b7e939b" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Elicit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "offset": 278, + "line": 7, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/core", + "reserved": false + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md new file mode 100644 index 000000000..d7799543e --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/source.md @@ -0,0 +1,12 @@ + + + ready + + + + +The branch was published. Continue? + + continued + diff --git a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json new file mode 100644 index 000000000..84c5ce0da --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json @@ -0,0 +1,441 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "content": "\n \n ready\n \n \n \n \nThe branch was published. Continue?\n \n continued\n\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Repository", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_placement", + "name": "[\"executablemd.git.repl-repository\",\"placement\",1,\"repository\",\"e8b94a37898295599aaacc48ea75f9d81d9cd992fd7be6abe714d0a15d3c688c\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "kind": "repository", + "fingerprint": "e8b94a37898295599aaacc48ea75f9d81d9cd992fd7be6abe714d0a15d3c688c", + "request": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "base": null + }, + "placement": { + "managedRoot": "/private/tmp/xmd-retained-history/h2-push-pending/repositories", + "checkoutRoot": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "identity": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "displayName": "project", + "ownerCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Switch", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 97, + "line": 2, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"switch\",\"d43fe962f69ffb482b3935a1c5feb19c48e4aec69d363969f2d3ddb67b07f226\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "switch", + "fingerprint": "d43fe962f69ffb482b3935a1c5feb19c48e4aec69d363969f2d3ddb67b07f226", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "branch": "review/pending", + "base": null + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + }, + "requestedBranch": "review/pending", + "resolvedBranch": "review/pending", + "requestedBase": null, + "resolvedBase": "68dde90e63566966c4b3a51b254211f44894a21b", + "before": { + "branch": "main", + "commit": "68dde90e63566966c4b3a51b254211f44894a21b", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/pending", + "commit": "68dde90e63566966c4b3a51b254211f44894a21b", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "File", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 138, + "line": 3, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "protected" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_local", + "name": "[\"executablemd.git.repl-repository\",\"local\",1,\"file-write\",\"97c8b89196fee15328fc0418f85d79c42827ef3ad8b9abce68201866d4d0dd68\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "file-write", + "fingerprint": "97c8b89196fee15328fc0418f85d79c42827ef3ad8b9abce68201866d4d0dd68", + "request": { + "cwd": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "path": "notes.md", + "content": "ready" + }, + "success": { + "type": "executablemd.runtime.files-write-success/v1", + "publication": "host-committed" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Add", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 175, + "line": 4, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"add\",\"320a65867fe565a759e5e4a6af961808dab79163f788470be1b16329dafaa412\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "add", + "fingerprint": "320a65867fe565a759e5e4a6af961808dab79163f788470be1b16329dafaa412", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "paths": ["notes.md"] + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + }, + "paths": ["notes.md"], + "before": { + "branch": "review/pending", + "commit": "68dde90e63566966c4b3a51b254211f44894a21b", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/pending", + "commit": "68dde90e63566966c4b3a51b254211f44894a21b", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Commit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 206, + "line": 5, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"commit\",\"6f829a1a8a1ee464163de3014b6ca8c4878739231a77b8023bcd34408e7b40c5\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "commit", + "fingerprint": "6f829a1a8a1ee464163de3014b6ca8c4878739231a77b8023bcd34408e7b40c5", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14 + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + }, + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14, + "parent": "68dde90e63566966c4b3a51b254211f44894a21b", + "tree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "commit": "3d7500e397cde7a3e3b2e54d61638583b399dd3d", + "committedAt": 1792337426, + "before": { + "branch": "review/pending", + "commit": "68dde90e63566966c4b3a51b254211f44894a21b", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + }, + "after": { + "branch": "review/pending", + "commit": "3d7500e397cde7a3e3b2e54d61638583b399dd3d", + "headTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Push", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "offset": 259, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_push_request", + "name": "8bbdd12933043ff35a6a73dd9998c5a207f129a534231bffd30235c726e2a4c7" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "owner": "[\"executablemd.repl.issue-owner\",1,\"h2\",\"1\"]", + "site": "1391dd54773007cbd258b6c673f38fa44536906472b34a69d064d438b9e23ea0", + "requested": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + }, + "received": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" + }, + "resolved": { + "checkoutRoot": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", + "origin": "/private/tmp/xmd-retained-history/h2-push-pending/origin.git", + "inputs": { + "repository": { + "name": "project", + "locatorFingerprint": "3381fbae1def29f0c9e4f75d6ee98db4ddcf8329afe1a472fa826e89ab4c8db3", + "requestedBase": null, + "creationCommit": "68dde90e63566966c4b3a51b254211f44894a21b", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/pending", + "destinationRef": "refs/heads/review/pending", + "sourceCommit": "3d7500e397cde7a3e3b2e54d61638583b399dd3d" + } + } + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md new file mode 100644 index 000000000..b09e7eb23 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/source.md @@ -0,0 +1,12 @@ + + + ready + + + + +The branch was published. Continue? + + continued + diff --git a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json new file mode 100644 index 000000000..9d62f06d0 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json @@ -0,0 +1,441 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "content": "\n \n ready\n \n \n \n \nThe branch was published. Continue?\n \n continued\n\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Repository", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_placement", + "name": "[\"executablemd.git.repl-repository\",\"placement\",1,\"repository\",\"b5b15bcdb981a05bedbef7b6f3b6cf2aa11be0aad3d140f7356225db9b7029b8\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "kind": "repository", + "fingerprint": "b5b15bcdb981a05bedbef7b6f3b6cf2aa11be0aad3d140f7356225db9b7029b8", + "request": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "base": null + }, + "placement": { + "managedRoot": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories", + "checkoutRoot": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "identity": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "displayName": "project", + "ownerCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Switch", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 104, + "line": 2, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"switch\",\"0172e6c6560ad5798d060239c5fd1f54b121dd052d5528d974a591735be66aeb\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "switch", + "fingerprint": "0172e6c6560ad5798d060239c5fd1f54b121dd052d5528d974a591735be66aeb", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "branch": "review/adopt", + "base": null + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + }, + "requestedBranch": "review/adopt", + "resolvedBranch": "review/adopt", + "requestedBase": null, + "resolvedBase": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "before": { + "branch": "main", + "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/adopt", + "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "File", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 143, + "line": 3, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "protected" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_local", + "name": "[\"executablemd.git.repl-repository\",\"local\",1,\"file-write\",\"a1f719e4102a1347d6c29b2a17184fc9b6ac5e97564f2dae047944e7a052336e\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "file-write", + "fingerprint": "a1f719e4102a1347d6c29b2a17184fc9b6ac5e97564f2dae047944e7a052336e", + "request": { + "cwd": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "path": "notes.md", + "content": "ready" + }, + "success": { + "type": "executablemd.runtime.files-write-success/v1", + "publication": "host-committed" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Add", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 180, + "line": 4, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"add\",\"d3a1f237217281bd2cb5bb47240769b8bdf536a92e2d6346feb2b7c3e154a0aa\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "add", + "fingerprint": "d3a1f237217281bd2cb5bb47240769b8bdf536a92e2d6346feb2b7c3e154a0aa", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "paths": ["notes.md"] + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + }, + "paths": ["notes.md"], + "before": { + "branch": "review/adopt", + "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb" + }, + "after": { + "branch": "review/adopt", + "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Commit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 211, + "line": 5, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"commit\",\"c34fb246cdb3336e115db3e438d6e6a3f2b685c36a7e24f5e72dfb22089e2a3e\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "commit", + "fingerprint": "c34fb246cdb3336e115db3e438d6e6a3f2b685c36a7e24f5e72dfb22089e2a3e", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14 + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + }, + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14, + "parent": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "tree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "commit": "ae88e480853fe1ab3e600c085c0ecf2519f1fa0d", + "committedAt": 1792337427, + "before": { + "branch": "review/adopt", + "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "headTree": "15587c8d6ad8e073e7178240a8117246bdfba4eb", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + }, + "after": { + "branch": "review/adopt", + "commit": "ae88e480853fe1ab3e600c085c0ecf2519f1fa0d", + "headTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f", + "indexTree": "17b0bdfbe7c7c448d7214f8910598edd1321ea6f" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Push", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "offset": 264, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_push_request", + "name": "22cd3e9f6ef80e9f7db277de8a67c39c1655bc4f601ea41dc953038394ac7b17" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "owner": "[\"executablemd.repl.issue-owner\",1,\"h3\",\"1\"]", + "site": "97a2ec6c878610f58a0b2262d6f57e80213251ef590379f2de89ac0cb6ceadfc", + "requested": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + }, + "received": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" + }, + "resolved": { + "checkoutRoot": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", + "origin": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/origin.git", + "inputs": { + "repository": { + "name": "project", + "locatorFingerprint": "04891fd8d3054394dea246f089541b4482c804645175ffe315b47d6567f554df", + "requestedBase": null, + "creationCommit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/adopt", + "destinationRef": "refs/heads/review/adopt", + "sourceCommit": "ae88e480853fe1ab3e600c085c0ecf2519f1fa0d" + } + } + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md new file mode 100644 index 000000000..7947e362c --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/source.md @@ -0,0 +1,12 @@ + + + ready + + + + +The branch was published. Continue? + + continued + diff --git a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json new file mode 100644 index 000000000..7cf2f23c1 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json @@ -0,0 +1,463 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "content": "\n \n ready\n \n \n \n \nThe branch was published. Continue?\n \n continued\n\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Repository", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_placement", + "name": "[\"executablemd.git.repl-repository\",\"placement\",1,\"repository\",\"bdc7b9d0a31b6eeb807528dcedd423fe46fe173eefa8bf545b59474ff10a232a\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "kind": "repository", + "fingerprint": "bdc7b9d0a31b6eeb807528dcedd423fe46fe173eefa8bf545b59474ff10a232a", + "request": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "base": null + }, + "placement": { + "managedRoot": "/private/tmp/xmd-retained-history/h4-push-failed/repositories", + "checkoutRoot": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "identity": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "displayName": "project", + "ownerCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Switch", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 96, + "line": 2, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"switch\",\"18c51820273363169dec3febab2f7dc4218c098ee2a16fd68e1cc15f8efb0100\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "switch", + "fingerprint": "18c51820273363169dec3febab2f7dc4218c098ee2a16fd68e1cc15f8efb0100", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "branch": "review/divergent", + "base": null + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + }, + "requestedBranch": "review/divergent", + "resolvedBranch": "review/divergent", + "requestedBase": null, + "resolvedBase": null, + "before": { + "branch": "review/divergent", + "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "headTree": "7a866ef129320f8135d0914db9cd3e55c7f60951", + "indexTree": "7a866ef129320f8135d0914db9cd3e55c7f60951" + }, + "after": { + "branch": "review/divergent", + "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "headTree": "7a866ef129320f8135d0914db9cd3e55c7f60951", + "indexTree": "7a866ef129320f8135d0914db9cd3e55c7f60951" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "File", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 139, + "line": 3, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "protected" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_local", + "name": "[\"executablemd.git.repl-repository\",\"local\",1,\"file-write\",\"2db6f78cb8ea8583beb61c55312a6fd3f90b2b3a7336fbc780d495039dd8646f\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "file-write", + "fingerprint": "2db6f78cb8ea8583beb61c55312a6fd3f90b2b3a7336fbc780d495039dd8646f", + "request": { + "cwd": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "path": "notes.md", + "content": "ready" + }, + "success": { + "type": "executablemd.runtime.files-write-success/v1", + "publication": "host-committed" + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Add", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 176, + "line": 4, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"add\",\"ad0ffd1b71b214c11911ff7d1bd7c8a59b6c354fa0e7dfa26faad954ac66d8fa\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "add", + "fingerprint": "ad0ffd1b71b214c11911ff7d1bd7c8a59b6c354fa0e7dfa26faad954ac66d8fa", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "paths": ["notes.md"] + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + }, + "paths": ["notes.md"], + "before": { + "branch": "review/divergent", + "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "headTree": "7a866ef129320f8135d0914db9cd3e55c7f60951", + "indexTree": "7a866ef129320f8135d0914db9cd3e55c7f60951" + }, + "after": { + "branch": "review/divergent", + "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "headTree": "7a866ef129320f8135d0914db9cd3e55c7f60951", + "indexTree": "5554eaaccabd1fc76ed5b370a80034cfb1db92d6" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Commit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 207, + "line": 5, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_git", + "name": "[\"executablemd.git.repl-repository\",\"git\",1,\"commit\",\"692e8e11c03f9738badd26c07629f8614f5105101d9c1289e89dde22fcd75378\"]" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "operation": "commit", + "fingerprint": "692e8e11c03f9738badd26c07629f8614f5105101d9c1289e89dde22fcd75378", + "request": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14 + }, + "outcome": { + "ok": true, + "value": { + "checkout": { + "repositoryName": "project", + "worktreeName": null, + "checkoutPath": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + }, + "messageSource": "prop", + "messageDigest": "352f8f95e3173a75a2f287e20aa909e8207d03e6d9fac50a5cef8aa14b28ef88", + "messageLength": 14, + "parent": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "tree": "5554eaaccabd1fc76ed5b370a80034cfb1db92d6", + "commit": "e87288a89f54e0e7927649039656af7918e20a2e", + "committedAt": 1792337430, + "before": { + "branch": "review/divergent", + "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", + "headTree": "7a866ef129320f8135d0914db9cd3e55c7f60951", + "indexTree": "5554eaaccabd1fc76ed5b370a80034cfb1db92d6" + }, + "after": { + "branch": "review/divergent", + "commit": "e87288a89f54e0e7927649039656af7918e20a2e", + "headTree": "5554eaaccabd1fc76ed5b370a80034cfb1db92d6", + "indexTree": "5554eaaccabd1fc76ed5b370a80034cfb1db92d6" + } + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Git.Push", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 260, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "repl_repository_push_request", + "name": "5494bb45fc35876d97584c0582dae9bf7d6789fb0edeea3991d094e04b55174d" + }, + "result": { + "status": "ok", + "value": { + "version": 1, + "owner": "[\"executablemd.repl.issue-owner\",1,\"h4\",\"1\"]", + "site": "5f1ae2248c6f42f5e28db560ea25100ae707a4ee9dca21af22cd0213e2a8d811", + "requested": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + }, + "received": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" + }, + "resolved": { + "checkoutRoot": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", + "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", + "origin": "/private/tmp/xmd-retained-history/h4-push-failed/origin.git", + "inputs": { + "repository": { + "name": "project", + "locatorFingerprint": "63795a9624d8c13b2b3c59f9603c65e945ef841d0a239704d052388372d9c9d9", + "requestedBase": null, + "creationCommit": "21e1b841c8c804e61b2a1f3f931c486d5d765eb9", + "primaryBranch": "main", + "objectFormat": "sha1" + }, + "remote": "origin", + "branch": "review/divergent", + "destinationRef": "refs/heads/review/divergent", + "sourceCommit": "e87288a89f54e0e7927649039656af7918e20a2e" + } + } + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "git_host_effect", + "name": "4686933e0a1055e6a444d7985549d8a4b4d58d16cd41cde9253e5f73d01da9b1", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "offset": 260, + "line": 6, + "column": 3 + } + }, + "result": { + "status": "err", + "error": { + "message": "the Git host already holds state this effect conflicts with. Nothing was performed: conflicting external state is diagnosed rather than overwritten or duplicated.", + "name": "GitHostConflictError", + "stack": "GitHostConflictError: the Git host already holds state this effect conflicts with. Nothing was performed: conflicting external state is diagnosed rather than overwritten or duplicated.\n at reconcile (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/git-host/effect.ts:429:30)\n at reconcile.next ()\n at validated (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/git-host/effect.ts:826:27)\n at validated.next ()\n at file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/git-host/effect.ts:184:5\n at Generator.next ()\n at trap (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/node_modules/.deno/effection@4.1.0/node_modules/effection/esm/lib/trap.js:13:28)\n at trap.next ()\n at Object.scoped (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/node_modules/.deno/effection@4.1.0/node_modules/effection/esm/lib/scoped.js:38:44)\n at scoped.next ()" + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md new file mode 100644 index 000000000..cc8719ee9 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/source.md @@ -0,0 +1,12 @@ + + + ready + + + + +The branch was published. Continue? + + continued + diff --git a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json new file mode 100644 index 000000000..e094a4077 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json @@ -0,0 +1,174 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "content": "\na described issue\n\n\n\nContinue?\n\ncontinued\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "IssueTracker", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Issue", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 53, + "line": 2, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "issue_effect", + "name": "98d2a35d8f3f04011ad86bcdff746499d26ab5f7be99e67b32cc5829ae59d23c", + "request": { + "operation": "upsert", + "identity": { + "runId": "[\"executablemd.repl.issue-owner\",1,\"h5\",\"1\"]", + "expansionId": "4e08c2338224512e2ad865715d1b98d7af2000a0f5c40ec1b730c8eabff4dd18" + }, + "target": "https://github.com/octo/project", + "provider": null, + "issue": { + "title": "Filed", + "description": "a described issue", + "tags": [], + "assignee": null + } + }, + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 53, + "line": 2, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "url": "https://github.com/octo/project/issues/1" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Issue", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 111, + "line": 3, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "issue_effect", + "name": "19d1eeb0fb33b427db6f0dbb7bc9d2ebc51dd4391c09f94b3500526deeb6f2af", + "request": { + "operation": "read", + "identity": { + "runId": "[\"executablemd.repl.issue-owner\",1,\"h5\",\"1\"]", + "expansionId": "8239678a5e2945ecb9c53ff66eed177aeb4232d66d1d3ca18c47d3816f790957" + }, + "url": "https://github.com/octo/project/issues/1", + "provider": null + }, + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 111, + "line": 3, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "url": "https://github.com/octo/project/issues/1", + "title": "What the middleware answered", + "description": "answered without reaching a tracker", + "tags": ["alpha", "beta"], + "assignee": null + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Elicit", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "offset": 195, + "line": 5, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/core", + "reserved": false + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md new file mode 100644 index 000000000..59d0bd732 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/source.md @@ -0,0 +1,9 @@ + +a described issue + + + +Continue? + +continued diff --git a/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json b/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json new file mode 100644 index 000000000..fbde0de73 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/journal.json @@ -0,0 +1,62 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/entry.md", + "content": "\na described issue\n\n\nContinue?\n\ncontinued\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "IssueTracker", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Issue", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/entry.md", + "offset": 53, + "line": 2, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md b/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md new file mode 100644 index 000000000..5afb60f3c --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h6-issue-unacknowledged/source.md @@ -0,0 +1,8 @@ + +a described issue + + +Continue? + +continued diff --git a/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json new file mode 100644 index 000000000..960f07d42 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/journal.json @@ -0,0 +1,99 @@ +[ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "/private/tmp/xmd-retained-history/h7-issue-failed/entry.md", + "content": "\na described issue\n\n\nContinue?\n\ncontinued\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "IssueTracker", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h7-issue-failed/entry.md", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "Issue", + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h7-issue-failed/entry.md", + "offset": 55, + "line": 2, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "issue_effect", + "name": "2766aaca6683d5ea4405752dd0070163af48dc7ba865b58c445944f5525a98a6", + "request": { + "operation": "upsert", + "identity": { + "runId": "[\"executablemd.repl.issue-owner\",1,\"h7\",\"1\"]", + "expansionId": "a369ea1cb4ae5100e19fe60c9819e31b736d641d7c6972888358936b51004eee" + }, + "target": "https://github.com/octo/elsewhere", + "provider": null, + "issue": { + "title": "Filed", + "description": "a described issue", + "tags": [], + "assignee": null + } + }, + "executablemd.source-position": { + "path": "/private/tmp/xmd-retained-history/h7-issue-failed/entry.md", + "offset": 55, + "line": 2, + "column": 1 + } + }, + "result": { + "status": "err", + "error": { + "message": "the issue tracker is temporarily unavailable, so this request's state is unknown. Nothing was created on this answer, and nothing was retried: an explicit retry observes again before it may create.", + "name": "IssueUnavailableError", + "stack": "IssueUnavailableError: the issue tracker is temporarily unavailable, so this request's state is unknown. Nothing was created on this answer, and nothing was retried: an explicit retry observes again before it may create.\n at upsert (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/deno/issue/github.ts:442:17)\n at upsert.next ()\n at Object.[Symbol.iterator] (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/node_modules/.deno/@effectionx+context-api@0.6.0/node_modules/@effectionx/context-api/dist/mod.js:20:61)\n at [Symbol.iterator].next ()\n at upsert (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/deno/retained-repository/issues.ts:277:51)\n at upsert.next ()\n at Object.[Symbol.iterator] (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/node_modules/.deno/@effectionx+context-api@0.6.0/node_modules/@effectionx/context-api/dist/mod.js:20:61)\n at [Symbol.iterator].next ()\n at file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-boundary-a/packages/git/src/deno/retained-repository/issues.ts:244:48\n at Generator.next ()" + } + } + } +] diff --git a/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md b/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md new file mode 100644 index 000000000..81d95e524 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/h7-issue-failed/source.md @@ -0,0 +1,8 @@ + +a described issue + + +Continue? + +continued diff --git a/packages/git/tests/fixtures/retained-repository-history/receipt.json b/packages/git/tests/fixtures/retained-repository-history/receipt.json new file mode 100644 index 000000000..0da1636e8 --- /dev/null +++ b/packages/git/tests/fixtures/retained-repository-history/receipt.json @@ -0,0 +1,386 @@ +{ + "capturedFrom": { + "commit": "7ee526665dcadd9ec79dca9f8b6a4726903d9a4f", + "tree": "942739ff98195a0113ad6508da48b05874deaf97", + "describedAs": "merged deliveries 4 (#937) and 6 (#926), before the component-boundary repair" + }, + "command": "deno run --allow-all --frozen .capture/capture-history.ts", + "captureScriptSha256": "5b100a485ef784a32d85a22393da89eb8d22ae498b9ea7ee2a5ad212e5b1ba3e", + "captureRoot": "/private/tmp/xmd-retained-history", + "trackerTarget": "https://github.com/octo/project", + "continuation": "Every recorded path is kept exactly as captured. A continuation recreates only the document at documentPath and addresses every other recorded path through a deterministic leaf host or service substitution. No fixture is regenerated by verification.", + "scenarios": [ + { + "fixture": "h1-push-completed", + "cut": "suspended inside the Elicit that follows the publication, so the root is unfinished and the publication's request and receipt are both acknowledged", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h1\",\"1\"]", + "events": 15, + "journalSha256": "643585abfdea1dc2098932d5c89c7d56d09ef49e98eb2cae0b576c64a26d0f22", + "sourceSha256": "014f11f0110af3d9a5eab83694b4c6e66db14899530a325a3574d23c695cbd73", + "documentPath": "/private/tmp/xmd-retained-history/h1-push-completed/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h1-push-completed", + "origin": "/private/tmp/xmd-retained-history/h1-push-completed/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h1-push-completed/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h1-push-completed/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h1-push-completed/entered" + }, + "service": { + "branch": "review/completed", + "remoteCommit": "953925a9da72933365d566ee724593c97b7e939b" + }, + "nativeSubcommands": [ + "clone", + "symbolic-ref", + "rev-parse", + "checkout", + "symbolic-ref", + "rev-parse", + "rev-parse", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "rev-parse", + "rev-parse", + "rev-parse", + "switch", + "symbolic-ref", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "add", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "commit", + "rev-parse", + "log", + "log", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "init", + "ls-remote", + "push" + ] + }, + { + "fixture": "h2-push-pending", + "cut": "suspended inside the first ls-remote, so the publication request is acknowledged and the remote was never contacted", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h2\",\"1\"]", + "events": 13, + "journalSha256": "939a714728d8ec3d9ab2bb7932668f5bfecccfe9df5a0c019778e170df0d765d", + "sourceSha256": "e5290d1984b63b5f54470abf32178a978c7c375b54d3c39a7e20ca2750eda6eb", + "documentPath": "/private/tmp/xmd-retained-history/h2-push-pending/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h2-push-pending", + "origin": "/private/tmp/xmd-retained-history/h2-push-pending/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h2-push-pending/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h2-push-pending/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h2-push-pending/entered" + }, + "service": { + "branch": "review/pending", + "remoteCommit": null, + "movedCheckoutHead": "1d36b4fa3dcd9a6ab2f7563be00acf2e83052dc2" + }, + "nativeSubcommands": [ + "clone", + "symbolic-ref", + "rev-parse", + "checkout", + "symbolic-ref", + "rev-parse", + "rev-parse", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "rev-parse", + "rev-parse", + "rev-parse", + "switch", + "symbolic-ref", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "add", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "commit", + "rev-parse", + "log", + "log", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "init", + "ls-remote" + ] + }, + { + "fixture": "h3-push-unacknowledged", + "cut": "the journal refused the git_host_effect append after the remote had accepted the branch, so the request stands acknowledged with no outcome and no root close", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h3\",\"1\"]", + "events": 13, + "journalSha256": "d26fa13ab164f5c9a7c322f84d748b8162e6dc1f13227601dd72ee82931b57ee", + "sourceSha256": "6e8f525ca35b8255fe993b128260d0601b63306cb37a5bd68dbdad3626de2c97", + "documentPath": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h3-push-unacknowledged", + "origin": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/entered" + }, + "service": { + "branch": "review/adopt", + "remoteCommit": "ae88e480853fe1ab3e600c085c0ecf2519f1fa0d" + }, + "nativeSubcommands": [ + "clone", + "symbolic-ref", + "rev-parse", + "checkout", + "symbolic-ref", + "rev-parse", + "rev-parse", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "rev-parse", + "rev-parse", + "rev-parse", + "switch", + "symbolic-ref", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "add", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "commit", + "rev-parse", + "log", + "log", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "init", + "ls-remote", + "push" + ] + }, + { + "fixture": "h4-push-failed", + "cut": "the shared worker acknowledged a definitive publication failure over a destination this commit does not descend from, and the document failed on it, so the root has no close", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h4\",\"1\"]", + "events": 14, + "journalSha256": "b7a735af972e0e78f8a15b92fc1eec0dca0f511396edd014f40a878323852a97", + "sourceSha256": "c7b8535ebd860d98543452571a1cd4ff8023259f35ae1669fd7ed539ab9d3d34", + "documentPath": "/private/tmp/xmd-retained-history/h4-push-failed/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h4-push-failed", + "origin": "/private/tmp/xmd-retained-history/h4-push-failed/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h4-push-failed/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h4-push-failed/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h4-push-failed/entered" + }, + "service": { + "branch": "review/divergent", + "remoteCommit": "630b930030715d9755ed643b9ae8cb22e4ae4887", + "refusal": "the Git host already holds state this effect conflicts with. Nothing was performed: conflicting external state is diagnosed rather than overwritten or duplicated." + }, + "nativeSubcommands": [ + "rev-parse", + "rev-parse", + "rev-parse", + "rev-parse", + "config", + "rev-parse", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "rev-parse", + "switch", + "symbolic-ref", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "add", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "commit", + "rev-parse", + "log", + "log", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "write-tree", + "symbolic-ref", + "rev-parse", + "rev-parse", + "init", + "ls-remote", + "rev-parse" + ] + }, + { + "fixture": "h5-issue-completed", + "cut": "suspended inside the Elicit after both issue operations were acknowledged, so the root is unfinished and the read holds the answer public middleware composed", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h5\",\"1\"]", + "events": 7, + "journalSha256": "eefe786d065636199078f76dc38357fe19111d4f2f71d53b748bcaa3e0c2bae7", + "sourceSha256": "ecf6dbae55492eb9ea0771462c3a3120f7e6e67bad07249caf4062d179240443", + "documentPath": "/private/tmp/xmd-retained-history/h5-issue-completed/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h5-issue-completed", + "origin": "/private/tmp/xmd-retained-history/h5-issue-completed/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h5-issue-completed/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h5-issue-completed/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h5-issue-completed/entered" + }, + "service": { + "issues": [ + { + "number": 1, + "state": "open", + "title": "Filed", + "body": "a described issue\n\n\n", + "labels": [], + "assignee": null + } + ], + "requests": 2 + }, + "nativeSubcommands": [] + }, + { + "fixture": "h6-issue-unacknowledged", + "cut": "suspended with the issue_effect append in flight, so the tracker holds the issue this entry filed and the journal holds no record of it", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h6\",\"1\"]", + "events": 3, + "journalSha256": "94143e7d286447cbd9631cec71c25e9181d2e3ba518b36663b6686733fff356e", + "sourceSha256": "22741c593a9e0db72573a41fb28725ff44ab0284a0b85e01d9783b8e8bbd16ae", + "documentPath": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged", + "origin": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h6-issue-unacknowledged/entered" + }, + "service": { + "issues": [ + { + "number": 1, + "state": "open", + "title": "Filed", + "body": "a described issue\n\n\n", + "labels": [], + "assignee": null + } + ], + "requests": 2 + }, + "nativeSubcommands": [] + }, + { + "fixture": "h7-issue-failed", + "cut": "the issue effect acknowledged a definitive ceiling refusal and the document failed on it, so the root has no close", + "owner": "[\"executablemd.repl.issue-owner\",1,\"h7\",\"1\"]", + "events": 4, + "journalSha256": "bbf1eea08409cc6c986cb2ec02db77e0b9f5c958e363116179dbdf3fc9672c16", + "sourceSha256": "902ed7fdb2f80639041a4667fedccf9884d75af4ff18e37d668bf2ab685f4253", + "documentPath": "/private/tmp/xmd-retained-history/h7-issue-failed/entry.md", + "paths": { + "root": "/private/tmp/xmd-retained-history/h7-issue-failed", + "origin": "/private/tmp/xmd-retained-history/h7-issue-failed/origin.git", + "checkout": "/private/tmp/xmd-retained-history/h7-issue-failed/checkout", + "managedRoot": "/private/tmp/xmd-retained-history/h7-issue-failed/repositories", + "enteredThrough": "/private/tmp/xmd-retained-history/h7-issue-failed/entered" + }, + "service": { + "issues": [], + "requests": 0, + "refusal": "the issue tracker is temporarily unavailable, so this request's state is unknown. Nothing was created on this answer, and nothing was retried: an explicit retry observes again before it may create." + }, + "nativeSubcommands": [] + } + ] +} diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 5e7161c93..a95cb93b8 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -64,6 +64,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/composition/git-push-records.ts", "src/composition/git-records.ts", "src/composition/installation.ts", + "src/composition/invocation-binding.ts", "src/composition/locator.ts", "src/composition/observation-api.ts", "src/composition/observation-records.ts", diff --git a/packages/git/tests/retained-repository-history.test.ts b/packages/git/tests/retained-repository-history.test.ts new file mode 100644 index 000000000..7f0e16c58 --- /dev/null +++ b/packages/git/tests/retained-repository-history.test.ts @@ -0,0 +1,798 @@ +/** + * Seven histories the merged implementation wrote, continued through the shared + * components that replaced it. + * + * The fixtures beside this file were emitted by old `main` + * `7ee526665dcadd9ec79dca9f8b6a4726903d9a4f` before the component-boundary + * repair touched production, and their receipt records the revision, the + * command, the owner, the paths each record addresses, where each history was + * cut and what service state the capture left behind. Nothing here regenerates + * one: a fixture a verification rewrote would prove only that the new code + * agrees with itself. + * + * Every history is cut while its root is unfinished, so a continuation has to + * re-enter the component rather than replay a settled root. Every recorded path + * is used exactly as captured; where a continuation reaches a host or a service + * at all, it reaches a deterministic substitute addressing those same paths, + * which is what the receipt's `continuation` note says. + * + * These rows carry A3 of the component-boundary repair matrix. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { API, hostFilesHandler } from "@executablemd/runtime"; +import type { FilePathInput, FilesHandler, FileWriteInput } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import { collect, Elicitation } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { gitPlugin } from "../src/plugin.ts"; +import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; +import { replIssueInstallation } from "../src/deno/retained-repository/issues.ts"; +import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; +import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; +import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; +import { ISSUE_EFFECT } from "../src/issue/effect-type.ts"; +import { + parseReplPushRequestRecord, + REPL_REPOSITORY_PUSH_REQUEST, +} from "../src/retained-repository/push-records.ts"; +import type { ReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; +import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import { useIssueTrackerServer } from "./support/issue-tracker-server.ts"; +import type { ServedIssue } from "./support/issue-tracker-server.ts"; +import { gitHubAccessFor } from "./support/issue-providers.ts"; + +/** Where the immutable fixtures live, relative to this file. */ +const FIXTURES = new URL("./fixtures/retained-repository-history/", import.meta.url); + +/** The tracker every captured Issue history filed in. */ +const TRACKER = "https://github.com/octo/project"; + +/** One captured history, as its receipt records it. */ +interface Recorded { + readonly fixture: string; + readonly cut: string; + readonly owner: string; + readonly events: number; + readonly journalSha256: string; + readonly sourceSha256: string; + readonly documentPath: string; + readonly paths: { + readonly root: string; + readonly origin: string; + readonly checkout: string; + readonly managedRoot: string; + readonly enteredThrough: string; + }; + readonly service: Json; + readonly nativeSubcommands: readonly string[]; +} + +interface Receipt { + readonly capturedFrom: { readonly commit: string; readonly tree: string }; + readonly command: string; + readonly scenarios: readonly Recorded[]; +} + +/** The receipt, parsed rather than asserted: it is a file on disk. */ +function* receipt(): Operation { + const text = yield* readTextFile(new URL("receipt.json", FIXTURES).pathname); + const value: unknown = JSON.parse(text); + if (typeof value !== "object" || value === null || !("scenarios" in value)) { + throw new Error("the history receipt does not describe its scenarios"); + } + const scenarios = (value as { scenarios: unknown }).scenarios; + if (!Array.isArray(scenarios)) { + throw new Error("the history receipt's scenarios are not a list"); + } + const parsed: Recorded[] = []; + for (const scenario of scenarios) { + parsed.push(scenario as Recorded); + } + const captured = (value as { capturedFrom?: unknown }).capturedFrom; + const command = (value as { command?: unknown }).command; + if (typeof command !== "string" || typeof captured !== "object" || captured === null) { + throw new Error("the history receipt records no provenance"); + } + return { + capturedFrom: captured as Receipt["capturedFrom"], + command, + scenarios: parsed, + }; +} + +/** One scenario's recorded facts, by fixture name. */ +function* recorded(fixture: string): Operation { + const found = (yield* receipt()).scenarios.find((scenario) => scenario.fixture === fixture); + if (found === undefined) { + throw new Error(`the history receipt records no ${fixture}`); + } + return found; +} + +/** The exact source bytes that history ran. */ +function* source(fixture: string): Operation { + return yield* readTextFile(new URL(`${fixture}/source.md`, FIXTURES).pathname); +} + +/** The exact journal bytes that history left behind. */ +function* journal(fixture: string): Operation { + const text = yield* readTextFile(new URL(`${fixture}/journal.json`, FIXTURES).pathname); + const events: unknown = JSON.parse(text); + if (!Array.isArray(events)) { + throw new Error(`${fixture} holds no journal`); + } + const parsed: DurableEvent[] = []; + for (const event of events) { + parsed.push(event as DurableEvent); + } + return parsed; +} + +/** + * The document, written back at the path its own history records. + * + * Recreated rather than relocated. A root import holds the path and the content + * it was read from, so a continuation standing somewhere else would be a + * different run of a different document — and the point of these rows is that + * it is the same one. + */ +function placeDocument(scenario: Recorded, text: string): Operation { + return resource(function* (provide) { + yield* until(mkdir(dirname(scenario.documentPath), { recursive: true })); + yield* writeTextFile(scenario.documentPath, text); + yield* provide(); + }); +} + +/** The host filesystem, as production reads it. */ +function hostFiles(): FilesHandler { + const host = hostFilesHandler(); + return { + checkFilePath: (input: FilePathInput) => host.checkFilePath(input), + readTextFile: (input: FilePathInput) => host.readTextFile(input), + globFiles: (input) => host.globFiles(input), + temporaryDirectory: () => host.temporaryDirectory(), + writeTextFile: (input: FileWriteInput) => host.writeTextFile(input), + deleteFile: (input) => host.deleteFile(input), + ensureDirectory: (input) => host.ensureDirectory(input), + }; +} + +/** What a continuation asked of its substituted leaves. */ +interface Asked { + /** Every native invocation, in order, as its argument list. */ + readonly commands: string[][]; + /** Every isolation directory acquired. */ + directories: number; + /** Every authentication session opened, by locator. */ + readonly sessions: string[]; +} + +function asked(): Asked { + return { commands: [], directories: 0, sessions: [] }; +} + +/** The subcommand of each native invocation, in order. */ +function subcommands(seen: Asked): string[] { + return seen.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +} + +/** + * A native host that answers nothing and records everything. + * + * What the four provider-free rows below need: any invocation at all defeats + * what those rows assert, so the refusal says so rather than pretending to be + * `git`. + */ +function refusingHost(seen: Asked): RepositoryHost { + return { + // deno-lint-ignore require-yield + *git(invocation: GitInvocation): Operation { + seen.commands.push([...invocation.args]); + throw new Error(`a restored history ran git ${invocation.args.join(" ")}`); + }, + // deno-lint-ignore require-yield + *useDirectory(): Operation { + seen.directories += 1; + throw new Error("a restored history acquired an isolation directory"); + }, + // deno-lint-ignore require-yield + *useAuthentication(locator: string): Operation { + seen.sessions.push(locator); + throw new Error("a restored history opened an authentication session"); + }, + }; +} + +interface Continued { + readonly output: string; + readonly refusal: string | undefined; + readonly events: readonly DurableEvent[]; +} + +/** + * Continue one captured history through the shared components. + * + * The production entry assembly, for the owner the capture recorded, over the + * exact journal bytes it left behind and the exact document it ran. + */ +function continuePush( + scenario: Recorded, + text: string, + stream: DurableStream, + host: RepositoryHost, + options: { readonly answer?: Json } = {}, +): Operation { + return scoped(function* (): Operation { + yield* placeDocument(scenario, text); + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return scenario.paths.enteredThrough; + }, + }, + { at: "min" }, + ); + const files = hostFiles(); + yield* API.Files.around( + { + checkFilePath: ([input]) => files.checkFilePath(input), + readTextFile: ([input]) => files.readTextFile(input), + globFiles: ([input]) => files.globFiles(input), + temporaryDirectory: () => files.temporaryDirectory(), + writeTextFile: ([input]) => files.writeTextFile(input), + deleteFile: ([input]) => files.deleteFile(input), + ensureDirectory: ([input]) => files.ensureDirectory(input), + }, + { at: "min" }, + ); + if (options.answer !== undefined) { + const answer = options.answer; + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit(): Operation { + return answer; + }, + }, + { at: "min" }, + ); + } + const contributed = yield* replContribution(); + const installation = yield* replRepositoryInstallation(scenario.owner, { + host, + identity: statedIdentity, + root: scenario.paths.managedRoot, + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled( + { path: scenario.documentPath, stream, includes: [scenario.paths.root] }, + [contributed, installation], + ), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, refusal, events: yield* stream.readAll() }; + }); +} + +/** The same, for the histories whose capability was issues alone. */ +function continueIssue( + scenario: Recorded, + text: string, + stream: DurableStream, + issues: readonly ServedIssue[], + options: { readonly answer?: Json; readonly endpoint?: string } = {}, +): Operation { + return scoped(function* (): Operation { + yield* placeDocument(scenario, text); + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return scenario.paths.checkout; + }, + }, + { at: "min" }, + ); + const files = hostFiles(); + yield* API.Files.around( + { + checkFilePath: ([input]) => files.checkFilePath(input), + readTextFile: ([input]) => files.readTextFile(input), + globFiles: ([input]) => files.globFiles(input), + temporaryDirectory: () => files.temporaryDirectory(), + writeTextFile: ([input]) => files.writeTextFile(input), + deleteFile: ([input]) => files.deleteFile(input), + ensureDirectory: ([input]) => files.ensureDirectory(input), + }, + { at: "min" }, + ); + if (options.answer !== undefined) { + const answer = options.answer; + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit(): Operation { + return answer; + }, + }, + { at: "min" }, + ); + } + const endpoint = options.endpoint; + const contributed = yield* replContribution(); + const installation = replIssueInstallation(scenario.owner, { + ...(endpoint === undefined + ? {} + : { access: gitHubSource(gitHubAccessFor(endpoint, { credential: "valid" })) }), + configuration: { ceiling: [TRACKER] }, + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled( + { path: scenario.documentPath, stream, includes: [scenario.paths.root] }, + [contributed, installation], + ), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, refusal, events: yield* stream.readAll() }; + }); +} + +/** What the Git Plugin contributes to a `repl` entry's execution. */ +function* replContribution(): Operation<{ + readonly admissions: readonly ((retained: readonly DurableEvent[]) => 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 ?? [])] }; +} + +/** The identity the capture stated, restated here so a replay matches it. */ +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", +}; + +// deno-lint-ignore require-yield +function* statedIdentity(variable: string): Operation { + return STATED_IDENTITY[variable]; +} + +/** Every saved publication request this history holds, parsed. */ +function requests(events: readonly DurableEvent[]): ReplPushRequestRecord[] { + const found: ReplPushRequestRecord[] = []; + for (const event of events) { + if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PUSH_REQUEST) { + continue; + } + if (event.result.status !== "ok") { + continue; + } + const record = parseReplPushRequestRecord(event.result.value); + if (record !== undefined) { + found.push(record); + } + } + return found; +} + +/** Every Git-host outcome this history holds, as the journal holds it. */ +function outcomes(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === GIT_HOST_EFFECT, + ); +} + +/** Every Issue effect this history holds, in order. */ +function issues(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === ISSUE_EFFECT, + ); +} + +/** The prefix a continuation inherited, which it may not rewrite. */ +function prefix(events: readonly DurableEvent[], length: number): readonly DurableEvent[] { + return events.slice(0, length); +} + +/** A scratch directory a substituted host hands out as its isolation root. */ +function useScratch(): Operation { + return resource(function* (provide) { + const root = yield* until(mkdtemp(join(tmpdir(), "retained-history-"))); + yield* ensure(() => until(rm(root, { recursive: true, force: true }))); + yield* provide(root); + }); +} + +describe("histories the merged implementation wrote, continued through the shared components", () => { + it("A3: the fixtures are the ones old main emitted, and verification rewrites none", function* (): Operation { + const record = yield* receipt(); + expect(record.capturedFrom.commit).toBe("7ee526665dcadd9ec79dca9f8b6a4726903d9a4f"); + expect(record.capturedFrom.tree).toBe("942739ff98195a0113ad6508da48b05874deaf97"); + expect(record.scenarios).toHaveLength(7); + for (const scenario of record.scenarios) { + const events = yield* journal(scenario.fixture); + expect(events).toHaveLength(scenario.events); + // No root close: every one of these was cut while its root was + // unfinished, so a continuation re-enters the component rather than + // replaying a settled root. + expect(events.some((event) => event.type === "close" && event.coroutineId === "root")).toBe( + false, + ); + // The owner every record in it was written under is the one the receipt + // names, which is what a continuation presents again. + expect(scenario.owner).toContain("executablemd.repl.issue-owner"); + } + }); + + it("H1: a completed publication restores with no native work at all", function* (): Operation { + const scenario = yield* recorded("h1-push-completed"); + const text = yield* source("h1-push-completed"); + const inherited = yield* journal("h1-push-completed"); + const seen = asked(); + const stream = new InMemoryStream([...inherited]); + + const again = yield* continuePush(scenario, text, stream, refusingHost(seen), { + answer: { choice: "continue" }, + }); + + expect(again.refusal).toBe(undefined); + // Nothing native, no provider, no configuration and no credential. + expect(seen.commands).toEqual([]); + expect(seen.directories).toBe(0); + expect(seen.sessions).toEqual([]); + // The prefix is untouched, and the publication is still one request and one + // outcome at the positions the old implementation wrote them at. + expect(prefix(again.events, inherited.length)).toEqual(inherited); + expect(requests(again.events)).toHaveLength(1); + expect(outcomes(again.events)).toHaveLength(1); + expect(requests(again.events)[0]?.owner).toBe(scenario.owner); + }); + + it("H4: a definitive publication failure restores as itself, provider-free", function* (): Operation { + const scenario = yield* recorded("h4-push-failed"); + const text = yield* source("h4-push-failed"); + const inherited = yield* journal("h4-push-failed"); + const seen = asked(); + const stream = new InMemoryStream([...inherited]); + + const again = yield* continuePush(scenario, text, stream, refusingHost(seen), { + answer: { choice: "continue" }, + }); + + // The failure the old implementation acknowledged is the failure this run + // reports, and it reaches no provider to find that out. + expect(again.refusal).not.toBe(undefined); + expect(seen.commands).toEqual([]); + expect(seen.directories).toBe(0); + expect(seen.sessions).toEqual([]); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + const failed = outcomes(again.events); + expect(failed).toHaveLength(1); + expect(failed[0]?.type === "yield" ? failed[0].result.status : undefined).toBe("err"); + }); + + it("H5: a completed read and upsert restore their bindings past both chains", function* (): Operation { + const scenario = yield* recorded("h5-issue-completed"); + const text = yield* source("h5-issue-completed"); + const inherited = yield* journal("h5-issue-completed"); + const stream = new InMemoryStream([...inherited]); + + // No endpoint at all: a transport reached on this continuation has nowhere + // to go, so reaching one would fail rather than quietly succeed. + const again = yield* continueIssue(scenario, text, stream, [], { + answer: { choice: "continue" }, + }); + + expect(again.refusal).toBe(undefined); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + const records = issues(again.events); + expect(records).toHaveLength(2); + // The composed answer the old run retained for its read is the binding this + // one restores, including the middleware answer that never reached a tracker. + const restored = records.map((event) => + event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, + ); + const original = issues(inherited).map((event) => + event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, + ); + expect(restored).toEqual(original); + }); + + it("H7: a definitive issue failure restores without a transport", function* (): Operation { + const scenario = yield* recorded("h7-issue-failed"); + const text = yield* source("h7-issue-failed"); + const inherited = yield* journal("h7-issue-failed"); + const stream = new InMemoryStream([...inherited]); + + const again = yield* continueIssue(scenario, text, stream, [], { + answer: { choice: "continue" }, + }); + + expect(again.refusal).not.toBe(undefined); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + const failed = issues(again.events); + expect(failed).toHaveLength(1); + expect(failed[0]?.type === "yield" ? failed[0].result.status : undefined).toBe("err"); + }); + + it("H6: an unacknowledged upsert presents its original key and adopts one issue", function* (): Operation { + const scenario = yield* recorded("h6-issue-unacknowledged"); + const text = yield* source("h6-issue-unacknowledged"); + const inherited = yield* journal("h6-issue-unacknowledged"); + const stream = new InMemoryStream([...inherited]); + + // The service state the capture left behind: the tracker already holds the + // issue this entry filed, carrying the mark the adapter wrote under the + // original key. A deterministic substitute addressing the same tracker. + const served = seededIssues(scenario); + expect(served).toHaveLength(1); + const server = yield* useIssueTrackerServer({ issues: served.map((issue) => ({ ...issue })) }); + + const again = yield* continueIssue(scenario, text, stream, served, { + answer: { choice: "continue" }, + endpoint: server.url, + }); + + expect(again.refusal).toBe(undefined); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + // One issue at the tracker, not two: the continuation presented the key the + // interrupted attempt presented, and the adapter found its own mark. + expect(server.issues).toHaveLength(1); + expect(server.requests.some((request) => request.method === "POST")).toBe(false); + const records = issues(again.events); + expect(records).toHaveLength(1); + }); +}); + +/** The issues the capture's receipt says its tracker was left holding. */ +function seededIssues(scenario: Recorded): ServedIssue[] { + const service = scenario.service; + if (typeof service !== "object" || service === null || Array.isArray(service)) { + throw new Error(`${scenario.fixture} records no service state`); + } + const held = (service as Record)["issues"]; + if (!Array.isArray(held)) { + throw new Error(`${scenario.fixture} records no served issues`); + } + const parsed: ServedIssue[] = []; + for (const issue of held) { + parsed.push(issue as unknown as ServedIssue); + } + return parsed; +} + +/** + * What a substituted remote holds at the destination a saved request names. + * + * The one fact a pending continuation observes about the outside world. A row + * states it, because that is what distinguishes the two pending histories: a + * destination that never received the branch, and one that received it before + * the journal could record so. + */ +interface Destination { + /** The commit the destination ref holds, or absence. */ + readonly commit: string | undefined; +} + +/** + * A native host that answers only the four questions a pending continuation + * asks, addressing the paths the captured request recorded. + * + * Deterministic and leaf-level: the objects a captured checkout held are not + * reproducible from a fixture, so what stands in for them is an answer about + * the one object the saved request names. Everything else — the destination, + * the control repository, the push — is answered from the row's stated + * destination, and every argument list is recorded so a row can show which + * facts were used. + */ +function savedObjectHost( + seen: Asked, + scratch: string, + saved: ReplPushRequestRecord, + destination: Destination, +): RepositoryHost { + const inputs = saved.resolved.inputs; + return { + // deno-lint-ignore require-yield + *git(invocation: GitInvocation): Operation { + seen.commands.push([...invocation.args]); + const args = invocation.args; + if (args[0] === "rev-parse") { + // The exact object the saved request published, which has to still be + // readable. Anything else is absent rather than invented. + const asked = (args[args.length - 1] ?? "").replace("^{commit}", ""); + return asked === inputs.sourceCommit + ? { code: 0, stdout: `${inputs.sourceCommit}\n`, stderr: "" } + : { code: 1, stdout: "", stderr: "" }; + } + if (args[0] === "init") { + return { code: 0, stdout: "", stderr: "" }; + } + if (args[0] === "ls-remote") { + return { + code: 0, + stdout: + destination.commit === undefined + ? "" + : `${destination.commit}\t${inputs.destinationRef}\n`, + stderr: "", + }; + } + if (args[0] === "push") { + return { + code: 0, + stdout: + `To ${inputs.repository.name}\n` + + `*\t${inputs.sourceCommit}:${inputs.destinationRef}\t[new branch]\nDone\n`, + stderr: "", + }; + } + throw new Error(`a pending continuation ran git ${args.join(" ")}`); + }, + // deno-lint-ignore require-yield + *useDirectory(): Operation { + seen.directories += 1; + return scratch; + }, + // deno-lint-ignore require-yield + *useAuthentication(locator: string): Operation { + seen.sessions.push(locator); + return UNAUTHENTICATED; + }, + }; +} + +/** The commit a push invocation named as its source, and the ref it named. */ +function pushed(seen: Asked): { source: string; ref: string } | undefined { + const invocation = seen.commands.find((args) => args[0] === "push"); + const refspec = invocation?.[invocation.length - 1]; + if (refspec === undefined) { + return undefined; + } + const [source, ref] = refspec.split(":"); + return source === undefined || ref === undefined ? undefined : { source, ref }; +} + +/** The decision one retained Git-host outcome records. */ +function decisionOf(event: DurableEvent | undefined): string | undefined { + if (event?.type !== "yield" || event.result.status !== "ok") { + return undefined; + } + return parseGitHostReconciliationRecord(event.result.value)?.decision; +} + +describe("pending publications the merged implementation left behind", () => { + it("H2: continues with the saved branch, commit, ref and origin after HEAD moved", function* (): Operation { + const scenario = yield* recorded("h2-push-pending"); + const text = yield* source("h2-push-pending"); + const inherited = yield* journal("h2-push-pending"); + const saved = requests(inherited)[0]; + expect(saved).toBeDefined(); + if (saved === undefined) { + throw new Error("h2 holds no saved publication request"); + } + // The capture advanced that checkout's branch after cutting the entry, so + // today's HEAD and the saved commit are different objects. + const moved = movedHead(scenario); + expect(moved).not.toBe(saved.resolved.inputs.sourceCommit); + + const seen = asked(); + const scratch = yield* useScratch(); + const stream = new InMemoryStream([...inherited]); + const again = yield* continuePush( + scenario, + text, + stream, + savedObjectHost(seen, scratch, saved, { commit: undefined }), + { answer: { choice: "continue" } }, + ); + + expect(again.refusal).toBe(undefined); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + // One request still, written once by the old implementation and consumed + // rather than rewritten. + expect(requests(again.events)).toHaveLength(1); + expect(requests(again.events)[0]).toEqual(saved); + // The destination it observed and the refspec it published are the saved + // ones. The moved HEAD was never read: no live routing happens here. + expect(seen.commands.filter((args) => args[0] === "ls-remote")).toEqual([ + ["ls-remote", "--", saved.resolved.origin, saved.resolved.inputs.destinationRef], + ]); + expect(pushed(seen)).toEqual({ + source: saved.resolved.inputs.sourceCommit, + ref: saved.resolved.inputs.destinationRef, + }); + expect(subcommands(seen)).not.toContain("symbolic-ref"); + // And one outcome, at the position the old history left empty. + expect(outcomes(again.events)).toHaveLength(1); + expect(decisionOf(outcomes(again.events)[0])).toBe("performed"); + }); + + it("H3: adopts a destination the remote already holds, without mutating it again", function* (): Operation { + const scenario = yield* recorded("h3-push-unacknowledged"); + const text = yield* source("h3-push-unacknowledged"); + const inherited = yield* journal("h3-push-unacknowledged"); + const saved = requests(inherited)[0]; + expect(saved).toBeDefined(); + if (saved === undefined) { + throw new Error("h3 holds no saved publication request"); + } + // The capture's remote accepted this exact commit and the journal refused + // to record it, which is the window this protocol exists for. + expect(remoteCommitOf(scenario)).toBe(saved.resolved.inputs.sourceCommit); + + const seen = asked(); + const scratch = yield* useScratch(); + const stream = new InMemoryStream([...inherited]); + const again = yield* continuePush( + scenario, + text, + stream, + savedObjectHost(seen, scratch, saved, { + commit: saved.resolved.inputs.sourceCommit, + }), + { answer: { choice: "continue" } }, + ); + + expect(again.refusal).toBe(undefined); + expect(prefix(again.events, inherited.length)).toEqual(inherited); + expect(requests(again.events)).toHaveLength(1); + // Observed, recognized and adopted. Nothing was published a second time. + expect(subcommands(seen)).toContain("ls-remote"); + expect(subcommands(seen)).not.toContain("push"); + expect(outcomes(again.events)).toHaveLength(1); + expect(decisionOf(outcomes(again.events)[0])).toBe("adopted"); + }); +}); + +/** The commit the capture advanced that checkout to after cutting the entry. */ +function movedHead(scenario: Recorded): string | undefined { + return stringField(scenario, "movedCheckoutHead"); +} + +/** The commit the capture's remote was left holding at the destination. */ +function remoteCommitOf(scenario: Recorded): string | undefined { + return stringField(scenario, "remoteCommit"); +} + +function stringField(scenario: Recorded, name: string): string | undefined { + const service = scenario.service; + if (typeof service !== "object" || service === null || Array.isArray(service)) { + return undefined; + } + const value = (service as Record)[name]; + return typeof value === "string" ? value : undefined; +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index c31df3d7c..869ac10c5 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -748,6 +748,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "the subject is a REPL entry's native publication: it clones a managed checkout, reads and pushes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and cuts an entry mid-command. The saved request, its admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-push-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses before any native operation is proven by packages/cli/tests/repl-push-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, + { + path: "packages/git/tests/retained-repository-history.test.ts", + reason: + "continues seven histories the merged implementation wrote through the production per-entry capability, so its module graph reaches @executablemd/git/deno — the native repository host, the GitHub issue adapter and Workflow's storage adapter with node:sqlite. The construction those histories are continued through is provider-neutral and is proven on every runtime by packages/git/tests/composition-component-binding.test.ts; the records themselves are proven everywhere by packages/git/tests/retained-repository-push-records.test.ts and packages/git/tests/issue-owned.test.ts", + issue: "https://github.com/taras/executable.md/issues/181", + }, { path: "packages/cli/tests/repl-push.test.ts", reason: diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index bc28bf38d..8ba6d2990 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3062,9 +3062,20 @@ Markdown or TypeScript component of any of those names is chosen ahead of them, for its own scope alone. `Dir` is a separate protected core component and is available without the Git Plugin. -They are one array declared by the bundled Git Plugin and read wherever its -profile is assembled: an ordinary document execution, `xmd syntax`, `xmd plan`'s -validation and generation, and a workflow action that executes a document. A +They are one declaration source, read wherever the bundled Git Plugin's profile +is assembled: an ordinary document execution, `xmd syntax`, `xmd plan`'s +validation and generation, and a workflow action that executes a document. + +Six of them reach something no local transaction encloses — `Git.Push`, +`PullRequest`, its three evidence reads and `Issue` — and each has exactly one +implementation, which asks a semantic feature operation for its work and +receives nothing else. A command whose executions retain nothing named after an +invocation registers all twelve. A command whose executions install their own +policy under those six registers the other six and receives these as +declarations carrying that policy, so no executable default is registered for a +declaration to shadow; `repl` is the one such command. The names, props, forms, +return contracts, origins and prose are the same either way, and a +repository-local component of one of those names is still chosen ahead of it. A Workspace attachment installs that run's providers and durable state and declares none of them. Registering them installs no provider, discovers no repository, acquires no @@ -10441,11 +10452,12 @@ 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 `` and the `` an entry runs are ordinary -shadowable registrations of names the Plugin already declares, so a repository -component of either name keeps its ordinary precedence and obtains no identity -of the entry's — and a direct `Git.operations.push(...)` outside the trusted -invocation reaches the ordinary missing-provider refusal. +implementation. The `` and the `` an entry runs are the same +shared components every other host runs, declared to this execution with the +entry's policy beneath them, so a repository component of either name keeps its +ordinary precedence and obtains no identity of the entry's — and a direct +`Git.operations.push(...)` outside that invocation reaches the ordinary +missing-provider refusal. 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 7cf4f18f3..bbc03999d 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -2252,10 +2252,13 @@ resolved against the frame that published its coordinates. as it promises: nothing locks the repository, watches it or compares every write that happened while it ran. What continuation checks is whether the *answer* changed, not whether the filesystem did. -- `` 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. +- `` is the same component here as under every other host: one + implementation, the same props, forms and return contract, asking a semantic + issue operation for its work. What an entry supplies is the policy beneath it, + so it is declared to the execution rather than registered as a default — and a + repository component of that name keeps its ordinary precedence over it. 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 diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 9290fdd9f..e0c928fcc 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -206,6 +206,14 @@ it: the objects a publication reads come from the caller's own checkout through read-only door rather than from an exported Workspace family, and no Workspace root, savepoint or frontier takes part. +The components themselves are the same in all three environments. ``, `` and `` have one implementation each: it interprets +its props and forms, expands its own content, normalizes what the document wrote +and asks a semantic feature operation. What differs between a workflow run, an +ordinary run and a REPL entry is the policy beneath that operation — which +journal it writes to, what it reconciles and what it replays — and nothing a +document can see. + `` is not available to a REPL entry and refuses there as it does under any host that installs no provider for it; neither a retained local result nor a retained publication authorizes one. `` is available on the same @@ -2926,7 +2934,7 @@ 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 +live attempt the entry's own policy 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 From c324601cc0bb83bbc208911d0d500908b2fca5c7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 16:26:51 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=85=20Hold=20the=20retained=20histori?= =?UTF-8?q?es=20to=20their=20own=20captured=20bytes=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in delivery A's historical evidence, found in review of `6166fffc189585779c2536a49477de825e630d5e`. **The journals were not the bytes the capture emitted.** `deno task fmt` runs `oxfmt --write packages scripts`, and oxfmt formats JSON, so the pass that followed the capture reserialized five of the seven committed journals. Their parsed values never changed, but the bytes are the evidence: A3 preserves the old representation, not an equal reading of it. Each journal is restored with the capture harness's own serializer, which reproduces the SHA-256 its receipt already recorded, and the fixtures directory is excluded from oxfmt so a later format pass cannot rewrite them again. The provenance row now verifies all fourteen file hashes against that receipt, so this drift fails here rather than in a reviewer's audit. **The continuation depended on the capture machine and wrote shared paths.** It wrote each source back to a fixed capture path with no cleanup and ran the suffix against the real host filesystem there. Now every invocation owns everything physical it needs: the root source is supplied by value at the path its own record holds, so nothing is written to disk; the document filesystem is this invocation's own, keyed by the recorded logical paths, so the retained middleware records what it always recorded and two continuations cannot see each other's work; and the component search path is an owned empty directory rather than the capture's, which is what made an `` import read a directory only this machine has. A new row continues two histories at once and shows their written paths are disjoint. With every capture directory denied for the four issue and failure fixtures, and the clone, bare origin, seed and entry document denied for the three publication fixtures, all nine rows pass. What remains is one directory: the managed placement's own durable location. Denying just that fails exactly the three rows whose continuation performs new covered file work or a pending publication, because production takes that placement's slot lock there and proves that checkout's object database there, through `@effectionx/fs`, `node:fs` and the advisory-lock file rather than through a contextual Api. That consumer is stated in the suite's own prose and returned for architecture resolution rather than worked around with a new production seam. **H5 compared the inherited records with themselves.** The old source binds `filed` and `found` and consumes neither, so the row read the retained events out of the output stream and compared them with those same events. It now states what the components must restore — the upsert's URL, and the complete answer the public middleware composed for the read — as values of its own, parses the restored records through the production parsers and asserts against those, so a schema-valid value that is not this one fails. Both public Issue chains are installed to fail if entered, and the row shows neither is. --- .oxfmtrc.json | 5 + .../h1-push-completed/journal.json | 8 +- .../h2-push-pending/journal.json | 8 +- .../h3-push-unacknowledged/journal.json | 8 +- .../h4-push-failed/journal.json | 8 +- .../h5-issue-completed/journal.json | 5 +- .../tests/retained-repository-history.test.ts | 483 ++++++++++++------ 7 files changed, 357 insertions(+), 168 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5e2da66e6..76809d287 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -4,9 +4,14 @@ // the exact indentation the rule tests assert on. // `packages/*/npm` is generated dnt output, written by a test while the // battery runs; format-checking it fails on a file nobody wrote by hand. + // The retained-repository history fixtures are journals an older revision + // emitted, held to the SHA-256 their own receipt records: reserializing one + // changes the bytes the evidence is, and the suite that continues them + // verifies those hashes. "ignorePatterns": [ "**/*.md", "scripts/tests/fixtures/**", + "packages/git/tests/fixtures/retained-repository-history/**", "**/npm/**", "packages/workflow/vendor/cloudflare-computer-dofs/**", "packages/acp/vendor/acpx/**", diff --git a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json index 22b96b404..f3fdb61d4 100644 --- a/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json +++ b/packages/git/tests/fixtures/retained-repository-history/h1-push-completed/journal.json @@ -244,7 +244,9 @@ "selectionCheckout": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", "commonDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout/.git", "workingDirectory": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout", - "paths": ["notes.md"] + "paths": [ + "notes.md" + ] }, "outcome": { "ok": true, @@ -254,7 +256,9 @@ "worktreeName": null, "checkoutPath": "/private/tmp/xmd-retained-history/h1-push-completed/repositories/repositories/66df1c09a6887c9b3de07b3b710ea14b86f91570b55ec069c9340004c4a43b4a/checkout" }, - "paths": ["notes.md"], + "paths": [ + "notes.md" + ], "before": { "branch": "review/completed", "commit": "785495034b274fb9c8521e92105001eed072f916", diff --git a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json index 84c5ce0da..316f8832b 100644 --- a/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json +++ b/packages/git/tests/fixtures/retained-repository-history/h2-push-pending/journal.json @@ -244,7 +244,9 @@ "selectionCheckout": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", "commonDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout/.git", "workingDirectory": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout", - "paths": ["notes.md"] + "paths": [ + "notes.md" + ] }, "outcome": { "ok": true, @@ -254,7 +256,9 @@ "worktreeName": null, "checkoutPath": "/private/tmp/xmd-retained-history/h2-push-pending/repositories/repositories/85e770fa023edae002ab459e9fcaf91b1e79b2addaf2d89ba6104ae3fe3e9f90/checkout" }, - "paths": ["notes.md"], + "paths": [ + "notes.md" + ], "before": { "branch": "review/pending", "commit": "68dde90e63566966c4b3a51b254211f44894a21b", diff --git a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json index 9d62f06d0..4ad56cb73 100644 --- a/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json +++ b/packages/git/tests/fixtures/retained-repository-history/h3-push-unacknowledged/journal.json @@ -244,7 +244,9 @@ "selectionCheckout": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", "commonDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout/.git", "workingDirectory": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout", - "paths": ["notes.md"] + "paths": [ + "notes.md" + ] }, "outcome": { "ok": true, @@ -254,7 +256,9 @@ "worktreeName": null, "checkoutPath": "/private/tmp/xmd-retained-history/h3-push-unacknowledged/repositories/repositories/d6a48dd563a972b779463a1dc6c62060aba1f1861af4ebbe6110d52658e2c3d9/checkout" }, - "paths": ["notes.md"], + "paths": [ + "notes.md" + ], "before": { "branch": "review/adopt", "commit": "c3556c9f8e2a8e7ec144abd7ab116282ae120839", diff --git a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json index 7cf2f23c1..4c27852cd 100644 --- a/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json +++ b/packages/git/tests/fixtures/retained-repository-history/h4-push-failed/journal.json @@ -244,7 +244,9 @@ "selectionCheckout": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", "commonDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout/.git", "workingDirectory": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout", - "paths": ["notes.md"] + "paths": [ + "notes.md" + ] }, "outcome": { "ok": true, @@ -254,7 +256,9 @@ "worktreeName": null, "checkoutPath": "/private/tmp/xmd-retained-history/h4-push-failed/repositories/repositories/efe1211d5a599a3dae1e51da703e2a8e3903ba4c77e3a093913b30bca3cc1033/checkout" }, - "paths": ["notes.md"], + "paths": [ + "notes.md" + ], "before": { "branch": "review/divergent", "commit": "d0868e870b9b02aae353c4a5a59ffd905c405787", diff --git a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json index e094a4077..f476c7df2 100644 --- a/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json +++ b/packages/git/tests/fixtures/retained-repository-history/h5-issue-completed/journal.json @@ -144,7 +144,10 @@ "url": "https://github.com/octo/project/issues/1", "title": "What the middleware answered", "description": "answered without reaching a tracker", - "tags": ["alpha", "beta"], + "tags": [ + "alpha", + "beta" + ], "assignee": null } } diff --git a/packages/git/tests/retained-repository-history.test.ts b/packages/git/tests/retained-repository-history.test.ts index 7f0e16c58..db0f07f5a 100644 --- a/packages/git/tests/retained-repository-history.test.ts +++ b/packages/git/tests/retained-repository-history.test.ts @@ -12,26 +12,47 @@ * * Every history is cut while its root is unfinished, so a continuation has to * re-enter the component rather than replay a settled root. Every recorded path - * is used exactly as captured; where a continuation reaches a host or a service - * at all, it reaches a deterministic substitute addressing those same paths, - * which is what the receipt's `continuation` note says. + * is used exactly as captured, and every physical thing a continuation needs + * belongs to that invocation: its own temporary directory, its own filesystem + * holding the recorded logical paths, its own component search path and its own + * entry installation. The root source is supplied by value at the path its + * record holds, so no invocation writes a file another invocation shares. + * + * What a continuation still reaches outside itself is the managed placement's + * own durable location — the slot lock under the root the placement recorded, + * and that checkout's object database. Both are production's, read and taken + * through `@effectionx/fs`, `node:fs` and the advisory-lock file rather than + * through a contextual Api, so no supported substitution stands in for them. + * Denying reads of everything else the capture left behind — the clone, the + * bare origin, the seed and the entry document — leaves all nine rows passing; + * denying that one directory fails exactly the three rows whose continuation + * performs new covered file work or a pending publication. That remaining + * consumer is recorded for architecture resolution rather than worked around + * here. * * These rows carry A3 of the component-boundary repair matrix. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, until } from "effection"; -import type { Operation } from "effection"; -import { readTextFile, writeTextFile } from "@effectionx/fs"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { all, Err, ensure, Ok, resource, scoped, until } from "effection"; +import type { Operation, Result } from "effection"; +import { readTextFile } from "@effectionx/fs"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { API, hostFilesHandler } from "@executablemd/runtime"; -import type { FilePathInput, FilesHandler, FileWriteInput } from "@executablemd/runtime"; +import { join } from "node:path"; +import { API, FILES_WRITE_SUCCESS } from "@executablemd/runtime"; +import type { + FilePathInput, + FilesHandler, + FileWriteInput, + FileWriteSuccess, +} from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; -import { collect, Elicitation } from "@executablemd/core"; +import { collect, Elicitation, retainedSource } from "@executablemd/core"; +import type { RetainedRootDocument } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { gitPlugin } from "../src/plugin.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; @@ -48,6 +69,13 @@ import { import type { ReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; +// From the subpath that publishes them rather than from the source module: +// these are public, and reaching them by relative path would bypass the export +// map the partition test holds every consumer to. +import { IssueApi } from "@executablemd/git/api"; +import type { IssueDetails, IssueReference } from "@executablemd/git/api"; +import { IssueOperations } from "../src/issue/operations.ts"; +import { parseIssueDetails, parseIssueRecord } from "../src/issue/records.ts"; import { useIssueTrackerServer } from "./support/issue-tracker-server.ts"; import type { ServedIssue } from "./support/issue-tracker-server.ts"; import { gitHubAccessFor } from "./support/issue-providers.ts"; @@ -125,6 +153,12 @@ function* source(fixture: string): Operation { return yield* readTextFile(new URL(`${fixture}/source.md`, FIXTURES).pathname); } +/** The SHA-256 of one fixture file, over its bytes as they are on disk. */ +function* fileDigest(fixture: string, name: string): Operation { + const bytes = yield* until(readFile(new URL(`${fixture}/${name}`, FIXTURES).pathname)); + return createHash("sha256").update(bytes).digest("hex"); +} + /** The exact journal bytes that history left behind. */ function* journal(fixture: string): Operation { const text = yield* readTextFile(new URL(`${fixture}/journal.json`, FIXTURES).pathname); @@ -140,33 +174,80 @@ function* journal(fixture: string): Operation { } /** - * The document, written back at the path its own history records. + * The source each history ran, supplied by value at the path its own record + * holds. * - * Recreated rather than relocated. A root import holds the path and the content - * it was read from, so a continuation standing somewhere else would be a - * different run of a different document — and the point of these rows is that - * it is the same one. + * A root import record is the path and the exact bytes it was read from, so a + * continuation standing somewhere else would be a different run of a different + * document. `retainedSource` states both without reading or writing a file, so + * the record matches and no invocation writes into a path another invocation + * shares. */ -function placeDocument(scenario: Recorded, text: string): Operation { - return resource(function* (provide) { - yield* until(mkdir(dirname(scenario.documentPath), { recursive: true })); - yield* writeTextFile(scenario.documentPath, text); - yield* provide(); - }); +function recordedRoot(scenario: Recorded, text: string): RetainedRootDocument { + return retainedSource(scenario.documentPath, text); } -/** The host filesystem, as production reads it. */ -function hostFiles(): FilesHandler { - const host = hostFilesHandler(); - return { - checkFilePath: (input: FilePathInput) => host.checkFilePath(input), - readTextFile: (input: FilePathInput) => host.readTextFile(input), - globFiles: (input) => host.globFiles(input), - temporaryDirectory: () => host.temporaryDirectory(), - writeTextFile: (input: FileWriteInput) => host.writeTextFile(input), - deleteFile: (input) => host.deleteFile(input), - ensureDirectory: (input) => host.ensureDirectory(input), +/** + * One continuation's own filesystem, holding the logical paths its history + * records. + * + * Every write, read, deletion and directory this history's document asks for + * is keyed by the absolute logical path the recorded run used, and lives here + * for this invocation alone. The retained file middleware above it records the + * path it was asked for, which is what makes the record identical to the + * recorded run's — and nothing reaches the directory the capture left behind, + * so two of these running at once cannot see each other's work. + */ +interface InvocationFiles { + readonly handler: FilesHandler; + /** Each logical path written, in order, with what was written to it. */ + readonly written: Map; + /** Each logical directory this document asked to exist. */ + readonly directories: Set; +} + +function invocationFiles(temporary: string): InvocationFiles { + const written = new Map(); + const directories = new Set(); + const resolved = (input: FilePathInput): string => + input.path.startsWith("/") ? input.path : join(input.cwd, input.path); + const handler: FilesHandler = { + // deno-lint-ignore require-yield + *checkFilePath(): Operation> { + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *readTextFile(input: FilePathInput): Operation> { + const held = written.get(resolved(input)); + return held === undefined + ? Err(new Error(`this invocation holds no ${resolved(input)}`)) + : Ok(held); + }, + // deno-lint-ignore require-yield + *writeTextFile(input: FileWriteInput): Operation> { + written.set(resolved(input), input.content); + return Ok({ type: FILES_WRITE_SUCCESS, publication: "host-committed" }); + }, + // deno-lint-ignore require-yield + *deleteFile(input: FilePathInput): Operation> { + written.delete(resolved(input)); + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *ensureDirectory(input: FilePathInput): Operation> { + directories.add(resolved(input)); + return Ok(undefined); + }, + // deno-lint-ignore require-yield + *globFiles(): Operation> { + return Ok([]); + }, + // deno-lint-ignore require-yield + *temporaryDirectory(): Operation> { + return Ok(temporary); + }, }; + return { handler, written, directories }; } /** What a continuation asked of its substituted leaves. */ @@ -219,115 +300,70 @@ interface Continued { readonly output: string; readonly refusal: string | undefined; readonly events: readonly DurableEvent[]; + /** What this invocation's own filesystem holds afterwards. */ + readonly files: InvocationFiles; +} + +/** What one row arranges beneath a continuation. */ +interface Arrangement { + /** The native host, for a history whose continuation may reach one. */ + readonly host?: RepositoryHost; + /** The entry's issue capability, for a history whose work was issues. */ + readonly issues?: { readonly endpoint?: string }; + /** The answer a question in this history receives. */ + readonly answer?: Json; + /** What this row installs between the entry and the document. */ + readonly between?: () => Operation; } /** * Continue one captured history through the shared components. * * The production entry assembly, for the owner the capture recorded, over the - * exact journal bytes it left behind and the exact document it ran. + * exact journal bytes it left behind and the exact document it ran. Everything + * physical belongs to this invocation: its own temporary directory, its own + * filesystem holding the recorded logical paths, and its own entry + * installation. Nothing is written at a path another invocation shares, and + * nothing is read from the directories the capture left behind. */ -function continuePush( +function continueHistory( scenario: Recorded, text: string, stream: DurableStream, - host: RepositoryHost, - options: { readonly answer?: Json } = {}, + arrangement: Arrangement = {}, ): Operation { return scoped(function* (): Operation { - yield* placeDocument(scenario, text); - yield* API.Env.around( - { - // deno-lint-ignore require-yield - *cwd(): Operation { - return scenario.paths.enteredThrough; - }, - }, - { at: "min" }, - ); - const files = hostFiles(); - yield* API.Files.around( - { - checkFilePath: ([input]) => files.checkFilePath(input), - readTextFile: ([input]) => files.readTextFile(input), - globFiles: ([input]) => files.globFiles(input), - temporaryDirectory: () => files.temporaryDirectory(), - writeTextFile: ([input]) => files.writeTextFile(input), - deleteFile: ([input]) => files.deleteFile(input), - ensureDirectory: ([input]) => files.ensureDirectory(input), - }, - { at: "min" }, - ); - if (options.answer !== undefined) { - const answer = options.answer; - yield* Elicitation.around( - { - // deno-lint-ignore require-yield - *elicit(): Operation { - return answer; - }, - }, - { at: "min" }, - ); - } - const contributed = yield* replContribution(); - const installation = yield* replRepositoryInstallation(scenario.owner, { - host, - identity: statedIdentity, - root: scenario.paths.managedRoot, - }); - let refusal: string | undefined; - let output = ""; - try { - output = String( - yield* collect( - yield* executeInstalled( - { path: scenario.documentPath, stream, includes: [scenario.paths.root] }, - [contributed, installation], - ), - ), - ); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - return { output, refusal, events: yield* stream.readAll() }; - }); -} - -/** The same, for the histories whose capability was issues alone. */ -function continueIssue( - scenario: Recorded, - text: string, - stream: DurableStream, - issues: readonly ServedIssue[], - options: { readonly answer?: Json; readonly endpoint?: string } = {}, -): Operation { - return scoped(function* (): Operation { - yield* placeDocument(scenario, text); + const temporary = yield* useScratch(); + const files = invocationFiles(temporary); yield* API.Env.around( { // deno-lint-ignore require-yield *cwd(): Operation { + // The checkout the recorded run was standing in, as its own records + // address it. A Push capture was entered through a symbolic link and + // the recorded request carries the directory that link resolved to, + // so supplying that directory reproduces the routing the capture + // computed without a continuation having to resolve a link only the + // capture machine holds. The receipt records both spellings. return scenario.paths.checkout; }, }, { at: "min" }, ); - const files = hostFiles(); yield* API.Files.around( { - checkFilePath: ([input]) => files.checkFilePath(input), - readTextFile: ([input]) => files.readTextFile(input), - globFiles: ([input]) => files.globFiles(input), - temporaryDirectory: () => files.temporaryDirectory(), - writeTextFile: ([input]) => files.writeTextFile(input), - deleteFile: ([input]) => files.deleteFile(input), - ensureDirectory: ([input]) => files.ensureDirectory(input), + checkFilePath: ([input]) => files.handler.checkFilePath(input), + readTextFile: ([input]) => files.handler.readTextFile(input), + globFiles: ([input]) => files.handler.globFiles(input), + temporaryDirectory: () => files.handler.temporaryDirectory(), + writeTextFile: ([input]) => files.handler.writeTextFile(input), + deleteFile: ([input]) => files.handler.deleteFile(input), + ensureDirectory: ([input]) => files.handler.ensureDirectory(input), }, { at: "min" }, ); - if (options.answer !== undefined) { - const answer = options.answer; + if (arrangement.answer !== undefined) { + const answer = arrangement.answer; yield* Elicitation.around( { // deno-lint-ignore require-yield @@ -338,21 +374,41 @@ function continueIssue( { at: "min" }, ); } - const endpoint = options.endpoint; + if (arrangement.between !== undefined) { + yield* arrangement.between(); + } const contributed = yield* replContribution(); - const installation = replIssueInstallation(scenario.owner, { - ...(endpoint === undefined - ? {} - : { access: gitHubSource(gitHubAccessFor(endpoint, { credential: "valid" })) }), - configuration: { ceiling: [TRACKER] }, - }); + const host = arrangement.host; + const endpoint = arrangement.issues?.endpoint; + const installation = + host === undefined + ? replIssueInstallation(scenario.owner, { + ...(endpoint === undefined + ? {} + : { access: gitHubSource(gitHubAccessFor(endpoint, { credential: "valid" })) }), + configuration: { ceiling: [TRACKER] }, + }) + : yield* replRepositoryInstallation(scenario.owner, { + host, + identity: statedIdentity, + root: scenario.paths.managedRoot, + }); let refusal: string | undefined; let output = ""; try { output = String( yield* collect( yield* executeInstalled( - { path: scenario.documentPath, stream, includes: [scenario.paths.root] }, + { + ...recordedRoot(scenario, text), + stream, + // This invocation's own search path, which holds no component — + // exactly as the capture's did, so every name falls through to + // the same registered default and every recorded selection + // matches. Pointing it at the capture's directory would make a + // continuation depend on a machine that still has it. + includes: [join(temporary, "components")], + }, [contributed, installation], ), ), @@ -360,7 +416,7 @@ function continueIssue( } catch (error) { refusal = error instanceof Error ? error.message : String(error); } - return { output, refusal, events: yield* stream.readAll() }; + return { output, refusal, events: yield* stream.readAll(), files }; }); } @@ -415,6 +471,51 @@ function outcomes(events: readonly DurableEvent[]): DurableEvent[] { ); } +/** How many times each public chain was entered, which on a replay is never. */ +interface Chains { + operations: number; + api: number; +} + +/** + * Both Issue chains, set to fail if anything reaches them. + * + * Installed between the entry capability and the document, where a document's + * own middleware goes, so a completed replay that asked either of them is a + * failure of this row rather than a number nobody checked. + */ +function* refusingChains(counted: Chains): Operation { + yield* IssueOperations.around({ + // deno-lint-ignore require-yield + *read(): Operation { + counted.operations += 1; + throw new Error("a replayed read reached the composed Issue chain"); + }, + // deno-lint-ignore require-yield + *upsert(): Operation { + counted.operations += 1; + throw new Error("a replayed upsert reached the composed Issue chain"); + }, + }); + yield* IssueApi.around({ + // deno-lint-ignore require-yield + *read(): Operation { + counted.api += 1; + throw new Error("a replayed read reached the Issue transport"); + }, + // deno-lint-ignore require-yield + *upsert(): Operation { + counted.api += 1; + throw new Error("a replayed upsert reached the Issue transport"); + }, + }); +} + +/** The value one retained record holds, as the journal holds it. */ +function valueOf(event: DurableEvent | undefined): unknown { + return event?.type === "yield" && event.result.status === "ok" ? event.result.value : undefined; +} + /** Every Issue effect this history holds, in order. */ function issues(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( @@ -427,11 +528,19 @@ function prefix(events: readonly DurableEvent[], length: number): readonly Durab return events.slice(0, length); } -/** A scratch directory a substituted host hands out as its isolation root. */ +/** + * One invocation's own physical directory, removed with it. + * + * Everything this continuation needs a real path for lives here: the + * substituted host's isolation root, the temporary directory the document + * filesystem hands out, and the component search path. Two continuations + * running at once hold two of these and share none of it. + */ function useScratch(): Operation { return resource(function* (provide) { const root = yield* until(mkdtemp(join(tmpdir(), "retained-history-"))); yield* ensure(() => until(rm(root, { recursive: true, force: true }))); + yield* until(mkdir(join(root, "components"), { recursive: true })); yield* provide(root); }); } @@ -443,6 +552,12 @@ describe("histories the merged implementation wrote, continued through the share expect(record.capturedFrom.tree).toBe("942739ff98195a0113ad6508da48b05874deaf97"); expect(record.scenarios).toHaveLength(7); for (const scenario of record.scenarios) { + // The bytes, first and by hash. These files are the evidence, so a + // reserialized journal is a different artifact however equal its parsed + // value is — and a formatter that rewrote one would be caught here + // rather than in a later reviewer's audit. + expect(yield* fileDigest(scenario.fixture, "journal.json")).toBe(scenario.journalSha256); + expect(yield* fileDigest(scenario.fixture, "source.md")).toBe(scenario.sourceSha256); const events = yield* journal(scenario.fixture); expect(events).toHaveLength(scenario.events); // No root close: every one of these was cut while its root was @@ -464,7 +579,8 @@ describe("histories the merged implementation wrote, continued through the share const seen = asked(); const stream = new InMemoryStream([...inherited]); - const again = yield* continuePush(scenario, text, stream, refusingHost(seen), { + const again = yield* continueHistory(scenario, text, stream, { + host: refusingHost(seen), answer: { choice: "continue" }, }); @@ -488,7 +604,8 @@ describe("histories the merged implementation wrote, continued through the share const seen = asked(); const stream = new InMemoryStream([...inherited]); - const again = yield* continuePush(scenario, text, stream, refusingHost(seen), { + const again = yield* continueHistory(scenario, text, stream, { + host: refusingHost(seen), answer: { choice: "continue" }, }); @@ -504,31 +621,44 @@ describe("histories the merged implementation wrote, continued through the share expect(failed[0]?.type === "yield" ? failed[0].result.status : undefined).toBe("err"); }); - it("H5: a completed read and upsert restore their bindings past both chains", function* (): Operation { + it("H5: restores the exact composed answer and URL, and enters neither chain", function* (): Operation { const scenario = yield* recorded("h5-issue-completed"); const text = yield* source("h5-issue-completed"); const inherited = yield* journal("h5-issue-completed"); const stream = new InMemoryStream([...inherited]); - - // No endpoint at all: a transport reached on this continuation has nowhere - // to go, so reaching one would fail rather than quietly succeed. - const again = yield* continueIssue(scenario, text, stream, [], { + const entered: Chains = { operations: 0, api: 0 }; + + // No endpoint at all, and both public chains set to fail if anything + // reaches them. A replay that asked middleware first, or a transport before + // matching its record, fails here rather than passing a count that happened + // to be right. + const again = yield* continueHistory(scenario, text, stream, { + issues: {}, answer: { choice: "continue" }, + between: () => refusingChains(entered), }); expect(again.refusal).toBe(undefined); expect(prefix(again.events, inherited.length)).toEqual(inherited); + expect(entered).toEqual({ operations: 0, api: 0 }); + const records = issues(again.events); expect(records).toHaveLength(2); - // The composed answer the old run retained for its read is the binding this - // one restores, including the middleware answer that never reached a tracker. - const restored = records.map((event) => - event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, - ); - const original = issues(inherited).map((event) => - event.type === "yield" && event.result.status === "ok" ? event.result.value : undefined, - ); - expect(restored).toEqual(original); + + // What the components restore, stated here rather than read back out of + // the same events: the upsert's URL, and the complete answer the public + // middleware composed for the read without reaching a tracker. A + // schema-valid value that was not this one fails these two assertions. + const upsert = parseIssueRecord(valueOf(records[0])); + expect(upsert).toEqual({ url: `${TRACKER}/issues/1` }); + const read = parseIssueDetails(valueOf(records[1])); + expect(read).toEqual({ + url: `${TRACKER}/issues/1`, + title: "What the middleware answered", + description: "answered without reaching a tracker", + tags: ["alpha", "beta"], + assignee: null, + }); }); it("H7: a definitive issue failure restores without a transport", function* (): Operation { @@ -537,7 +667,8 @@ describe("histories the merged implementation wrote, continued through the share const inherited = yield* journal("h7-issue-failed"); const stream = new InMemoryStream([...inherited]); - const again = yield* continueIssue(scenario, text, stream, [], { + const again = yield* continueHistory(scenario, text, stream, { + issues: {}, answer: { choice: "continue" }, }); @@ -548,6 +679,46 @@ describe("histories the merged implementation wrote, continued through the share expect(failed[0]?.type === "yield" ? failed[0].result.status : undefined).toBe("err"); }); + it("A3: two continuations at once own separate state and share none of it", function* (): Operation { + const completed = yield* recorded("h5-issue-completed"); + const unacknowledged = yield* recorded("h6-issue-unacknowledged"); + const server = yield* useIssueTrackerServer({ + issues: seededIssues(unacknowledged).map((issue) => ({ ...issue })), + }); + + // Two histories continued at the same time. Each writes its suffix at the + // logical path its own record addresses, into its own filesystem, so what + // either one did is invisible to the other and neither needs a path the + // other owns. + const [one, two] = yield* all([ + continueHistory( + completed, + yield* source(completed.fixture), + new InMemoryStream([...(yield* journal(completed.fixture))]), + { issues: {}, answer: { choice: "continue" } }, + ), + continueHistory( + unacknowledged, + yield* source(unacknowledged.fixture), + new InMemoryStream([...(yield* journal(unacknowledged.fixture))]), + { issues: { endpoint: server.url }, answer: { choice: "continue" } }, + ), + ]); + + expect(one?.refusal).toBe(undefined); + expect(two?.refusal).toBe(undefined); + const wrote = (continued: Continued | undefined): string[] => [ + ...(continued?.files.written.keys() ?? []), + ]; + expect(wrote(one)).toEqual([`${completed.paths.checkout}/after.md`]); + expect(wrote(two)).toEqual([`${unacknowledged.paths.checkout}/after.md`]); + // Disjoint, which is what owning its own state means: neither map holds a + // path the other wrote, and no third party holds either. + for (const path of wrote(one)) { + expect(wrote(two)).not.toContain(path); + } + }); + it("H6: an unacknowledged upsert presents its original key and adopts one issue", function* (): Operation { const scenario = yield* recorded("h6-issue-unacknowledged"); const text = yield* source("h6-issue-unacknowledged"); @@ -561,9 +732,9 @@ describe("histories the merged implementation wrote, continued through the share expect(served).toHaveLength(1); const server = yield* useIssueTrackerServer({ issues: served.map((issue) => ({ ...issue })) }); - const again = yield* continueIssue(scenario, text, stream, served, { + const again = yield* continueHistory(scenario, text, stream, { + issues: { endpoint: server.url }, answer: { choice: "continue" }, - endpoint: server.url, }); expect(again.refusal).toBe(undefined); @@ -712,13 +883,10 @@ describe("pending publications the merged implementation left behind", () => { const seen = asked(); const scratch = yield* useScratch(); const stream = new InMemoryStream([...inherited]); - const again = yield* continuePush( - scenario, - text, - stream, - savedObjectHost(seen, scratch, saved, { commit: undefined }), - { answer: { choice: "continue" } }, - ); + const again = yield* continueHistory(scenario, text, stream, { + host: savedObjectHost(seen, scratch, saved, { commit: undefined }), + answer: { choice: "continue" }, + }); expect(again.refusal).toBe(undefined); expect(prefix(again.events, inherited.length)).toEqual(inherited); @@ -757,15 +925,12 @@ describe("pending publications the merged implementation left behind", () => { const seen = asked(); const scratch = yield* useScratch(); const stream = new InMemoryStream([...inherited]); - const again = yield* continuePush( - scenario, - text, - stream, - savedObjectHost(seen, scratch, saved, { + const again = yield* continueHistory(scenario, text, stream, { + host: savedObjectHost(seen, scratch, saved, { commit: saved.resolved.inputs.sourceCommit, }), - { answer: { choice: "continue" } }, - ); + answer: { choice: "continue" }, + }); expect(again.refusal).toBe(undefined); expect(prefix(again.events, inherited.length)).toEqual(inherited); From 5e232c4f62c0447d8f3b2ffaf807f9bc3eca9690 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 21:38:17 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=85=20Continue=20the=20four=20old=20h?= =?UTF-8?q?istories=20a=20clean=20machine=20can=20continue=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accepted A3 amendment narrows the historical continuation contract. H1, H2 and H3 — a completed publication, a pending one, and one the remote accepted before the journal could record it — are no longer continued here. Continuing any of them performs new covered file work or resumes a pending publication, which takes the recorded placement's slot lock and proves that checkout's object database at the absolute paths the journal holds. Both belong to the real native host and stay there, so no invocation-owned substitute reaches them, and the proposed native-host seam was withdrawn rather than built. Their three cases and the helpers only they used are deleted rather than skipped: a skipped test reads as a gate somebody means to reopen, and this is a decision instead. Their fixtures, hashes, receipt and the capture harness are untouched, so a later continuation has unchanged bytes to work from. What remains is six steps. All seven captured histories are still held to the fourteen source and journal hashes their receipt records, to their event counts, to their unfinished roots and to their recorded owners. Four are continued: the definitive publication failure restores provider-free; the completed Issue read and upsert restore their exact composed answer and URL with neither public chain entered; the unacknowledged upsert presents its original key and adopts the issue already at the tracker without a second create; the definitive Issue failure restores without a transport. Two continuations still run at once and own separate state. The suite now says what it does not prove. It does not show that the three old publication histories continue through the extraction, and it names the current-code replay and reconciliation evidence that remains required without standing in for that. --- .../tests/retained-repository-history.test.ts | 311 ++---------------- 1 file changed, 35 insertions(+), 276 deletions(-) diff --git a/packages/git/tests/retained-repository-history.test.ts b/packages/git/tests/retained-repository-history.test.ts index db0f07f5a..63c1ccf5b 100644 --- a/packages/git/tests/retained-repository-history.test.ts +++ b/packages/git/tests/retained-repository-history.test.ts @@ -1,36 +1,44 @@ /** - * Seven histories the merged implementation wrote, continued through the shared - * components that replaced it. + * Seven histories the merged implementation wrote, four of them continued + * through the shared components that replaced it. * * The fixtures beside this file were emitted by old `main` * `7ee526665dcadd9ec79dca9f8b6a4726903d9a4f` before the component-boundary * repair touched production, and their receipt records the revision, the * command, the owner, the paths each record addresses, where each history was - * cut and what service state the capture left behind. Nothing here regenerates + * cut and what service state the capture left behind. All seven are held to the + * bytes that receipt names, source and journal alike. Nothing here regenerates * one: a fixture a verification rewrote would prove only that the new code * agrees with itself. * - * Every history is cut while its root is unfinished, so a continuation has to - * re-enter the component rather than replay a settled root. Every recorded path - * is used exactly as captured, and every physical thing a continuation needs - * belongs to that invocation: its own temporary directory, its own filesystem - * holding the recorded logical paths, its own component search path and its own - * entry installation. The root source is supplied by value at the path its - * record holds, so no invocation writes a file another invocation shares. + * Four are continued: the definitive publication failure, the completed Issue + * read and upsert, the unacknowledged upsert and the definitive Issue failure. + * Each is cut while its root is unfinished, so continuing one re-enters the + * component rather than replaying a settled root. Every recorded path is used + * exactly as captured, and every physical thing a continuation needs belongs to + * that invocation: its own temporary directory, its own filesystem holding the + * recorded logical paths, its own component search path and its own entry + * installation. The root source is supplied by value at the path its record + * holds, so no invocation writes a file another invocation shares, and none of + * them needs anything the capture left on disk. * - * What a continuation still reaches outside itself is the managed placement's - * own durable location — the slot lock under the root the placement recorded, - * and that checkout's object database. Both are production's, read and taken - * through `@effectionx/fs`, `node:fs` and the advisory-lock file rather than - * through a contextual Api, so no supported substitution stands in for them. - * Denying reads of everything else the capture left behind — the clone, the - * bare origin, the seed and the entry document — leaves all nine rows passing; - * denying that one directory fails exactly the three rows whose continuation - * performs new covered file work or a pending publication. That remaining - * consumer is recorded for architecture resolution rather than worked around - * here. + * ## What this suite does not prove * - * These rows carry A3 of the component-boundary repair matrix. + * The three publication histories — a completed publication, a pending one and + * one the remote accepted before the journal could record it — are held to their + * captured bytes here and are not continued. Continuing any of them performs + * new covered file work or resumes a pending publication, which takes the + * recorded placement's slot lock and proves that checkout's object database at + * the absolute paths the journal holds. Both belong to the real native host and + * stay there, so no invocation-owned substitute reaches them. + * + * So this suite does not show that those three old histories continue through + * the extraction. The current-code replay and reconciliation evidence — + * `retained-repository-push.test.ts`, `retained-repository-push-records.test.ts` + * and `git-host-owned.test.ts` — remains required, and does not stand in for + * that omitted historical proof. + * + * These rows carry A3 of the component-boundary repair matrix, as amended. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -57,17 +65,10 @@ import { executeInstalled } from "@executablemd/core/host"; import { gitPlugin } from "../src/plugin.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { replIssueInstallation } from "../src/deno/retained-repository/issues.ts"; -import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; import { ISSUE_EFFECT } from "../src/issue/effect-type.ts"; -import { - parseReplPushRequestRecord, - REPL_REPOSITORY_PUSH_REQUEST, -} from "../src/retained-repository/push-records.ts"; -import type { ReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; -import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; // From the subpath that publishes them rather than from the source module: // these are public, and reaching them by relative path would bypass the export @@ -264,11 +265,6 @@ function asked(): Asked { return { commands: [], directories: 0, sessions: [] }; } -/** The subcommand of each native invocation, in order. */ -function subcommands(seen: Asked): string[] { - return seen.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); -} - /** * A native host that answers nothing and records everything. * @@ -446,24 +442,6 @@ function* statedIdentity(variable: string): Operation { return STATED_IDENTITY[variable]; } -/** Every saved publication request this history holds, parsed. */ -function requests(events: readonly DurableEvent[]): ReplPushRequestRecord[] { - const found: ReplPushRequestRecord[] = []; - for (const event of events) { - if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PUSH_REQUEST) { - continue; - } - if (event.result.status !== "ok") { - continue; - } - const record = parseReplPushRequestRecord(event.result.value); - if (record !== undefined) { - found.push(record); - } - } - return found; -} - /** Every Git-host outcome this history holds, as the journal holds it. */ function outcomes(events: readonly DurableEvent[]): DurableEvent[] { return events.filter( @@ -545,8 +523,11 @@ function useScratch(): Operation { }); } -describe("histories the merged implementation wrote, continued through the shared components", () => { - it("A3: the fixtures are the ones old main emitted, and verification rewrites none", function* (): Operation { +describe("histories the merged implementation wrote, held to their bytes and continued", () => { + it("A3: all seven fixtures are the ones old main emitted, and verification rewrites none", function* (): Operation { + // Every captured history, including the three this suite no longer + // continues: what they are held to here is their bytes, which is what makes + // them available to a later continuation unchanged. const record = yield* receipt(); expect(record.capturedFrom.commit).toBe("7ee526665dcadd9ec79dca9f8b6a4726903d9a4f"); expect(record.capturedFrom.tree).toBe("942739ff98195a0113ad6508da48b05874deaf97"); @@ -572,31 +553,6 @@ describe("histories the merged implementation wrote, continued through the share } }); - it("H1: a completed publication restores with no native work at all", function* (): Operation { - const scenario = yield* recorded("h1-push-completed"); - const text = yield* source("h1-push-completed"); - const inherited = yield* journal("h1-push-completed"); - const seen = asked(); - const stream = new InMemoryStream([...inherited]); - - const again = yield* continueHistory(scenario, text, stream, { - host: refusingHost(seen), - answer: { choice: "continue" }, - }); - - expect(again.refusal).toBe(undefined); - // Nothing native, no provider, no configuration and no credential. - expect(seen.commands).toEqual([]); - expect(seen.directories).toBe(0); - expect(seen.sessions).toEqual([]); - // The prefix is untouched, and the publication is still one request and one - // outcome at the positions the old implementation wrote them at. - expect(prefix(again.events, inherited.length)).toEqual(inherited); - expect(requests(again.events)).toHaveLength(1); - expect(outcomes(again.events)).toHaveLength(1); - expect(requests(again.events)[0]?.owner).toBe(scenario.owner); - }); - it("H4: a definitive publication failure restores as itself, provider-free", function* (): Operation { const scenario = yield* recorded("h4-push-failed"); const text = yield* source("h4-push-failed"); @@ -764,200 +720,3 @@ function seededIssues(scenario: Recorded): ServedIssue[] { } return parsed; } - -/** - * What a substituted remote holds at the destination a saved request names. - * - * The one fact a pending continuation observes about the outside world. A row - * states it, because that is what distinguishes the two pending histories: a - * destination that never received the branch, and one that received it before - * the journal could record so. - */ -interface Destination { - /** The commit the destination ref holds, or absence. */ - readonly commit: string | undefined; -} - -/** - * A native host that answers only the four questions a pending continuation - * asks, addressing the paths the captured request recorded. - * - * Deterministic and leaf-level: the objects a captured checkout held are not - * reproducible from a fixture, so what stands in for them is an answer about - * the one object the saved request names. Everything else — the destination, - * the control repository, the push — is answered from the row's stated - * destination, and every argument list is recorded so a row can show which - * facts were used. - */ -function savedObjectHost( - seen: Asked, - scratch: string, - saved: ReplPushRequestRecord, - destination: Destination, -): RepositoryHost { - const inputs = saved.resolved.inputs; - return { - // deno-lint-ignore require-yield - *git(invocation: GitInvocation): Operation { - seen.commands.push([...invocation.args]); - const args = invocation.args; - if (args[0] === "rev-parse") { - // The exact object the saved request published, which has to still be - // readable. Anything else is absent rather than invented. - const asked = (args[args.length - 1] ?? "").replace("^{commit}", ""); - return asked === inputs.sourceCommit - ? { code: 0, stdout: `${inputs.sourceCommit}\n`, stderr: "" } - : { code: 1, stdout: "", stderr: "" }; - } - if (args[0] === "init") { - return { code: 0, stdout: "", stderr: "" }; - } - if (args[0] === "ls-remote") { - return { - code: 0, - stdout: - destination.commit === undefined - ? "" - : `${destination.commit}\t${inputs.destinationRef}\n`, - stderr: "", - }; - } - if (args[0] === "push") { - return { - code: 0, - stdout: - `To ${inputs.repository.name}\n` + - `*\t${inputs.sourceCommit}:${inputs.destinationRef}\t[new branch]\nDone\n`, - stderr: "", - }; - } - throw new Error(`a pending continuation ran git ${args.join(" ")}`); - }, - // deno-lint-ignore require-yield - *useDirectory(): Operation { - seen.directories += 1; - return scratch; - }, - // deno-lint-ignore require-yield - *useAuthentication(locator: string): Operation { - seen.sessions.push(locator); - return UNAUTHENTICATED; - }, - }; -} - -/** The commit a push invocation named as its source, and the ref it named. */ -function pushed(seen: Asked): { source: string; ref: string } | undefined { - const invocation = seen.commands.find((args) => args[0] === "push"); - const refspec = invocation?.[invocation.length - 1]; - if (refspec === undefined) { - return undefined; - } - const [source, ref] = refspec.split(":"); - return source === undefined || ref === undefined ? undefined : { source, ref }; -} - -/** The decision one retained Git-host outcome records. */ -function decisionOf(event: DurableEvent | undefined): string | undefined { - if (event?.type !== "yield" || event.result.status !== "ok") { - return undefined; - } - return parseGitHostReconciliationRecord(event.result.value)?.decision; -} - -describe("pending publications the merged implementation left behind", () => { - it("H2: continues with the saved branch, commit, ref and origin after HEAD moved", function* (): Operation { - const scenario = yield* recorded("h2-push-pending"); - const text = yield* source("h2-push-pending"); - const inherited = yield* journal("h2-push-pending"); - const saved = requests(inherited)[0]; - expect(saved).toBeDefined(); - if (saved === undefined) { - throw new Error("h2 holds no saved publication request"); - } - // The capture advanced that checkout's branch after cutting the entry, so - // today's HEAD and the saved commit are different objects. - const moved = movedHead(scenario); - expect(moved).not.toBe(saved.resolved.inputs.sourceCommit); - - const seen = asked(); - const scratch = yield* useScratch(); - const stream = new InMemoryStream([...inherited]); - const again = yield* continueHistory(scenario, text, stream, { - host: savedObjectHost(seen, scratch, saved, { commit: undefined }), - answer: { choice: "continue" }, - }); - - expect(again.refusal).toBe(undefined); - expect(prefix(again.events, inherited.length)).toEqual(inherited); - // One request still, written once by the old implementation and consumed - // rather than rewritten. - expect(requests(again.events)).toHaveLength(1); - expect(requests(again.events)[0]).toEqual(saved); - // The destination it observed and the refspec it published are the saved - // ones. The moved HEAD was never read: no live routing happens here. - expect(seen.commands.filter((args) => args[0] === "ls-remote")).toEqual([ - ["ls-remote", "--", saved.resolved.origin, saved.resolved.inputs.destinationRef], - ]); - expect(pushed(seen)).toEqual({ - source: saved.resolved.inputs.sourceCommit, - ref: saved.resolved.inputs.destinationRef, - }); - expect(subcommands(seen)).not.toContain("symbolic-ref"); - // And one outcome, at the position the old history left empty. - expect(outcomes(again.events)).toHaveLength(1); - expect(decisionOf(outcomes(again.events)[0])).toBe("performed"); - }); - - it("H3: adopts a destination the remote already holds, without mutating it again", function* (): Operation { - const scenario = yield* recorded("h3-push-unacknowledged"); - const text = yield* source("h3-push-unacknowledged"); - const inherited = yield* journal("h3-push-unacknowledged"); - const saved = requests(inherited)[0]; - expect(saved).toBeDefined(); - if (saved === undefined) { - throw new Error("h3 holds no saved publication request"); - } - // The capture's remote accepted this exact commit and the journal refused - // to record it, which is the window this protocol exists for. - expect(remoteCommitOf(scenario)).toBe(saved.resolved.inputs.sourceCommit); - - const seen = asked(); - const scratch = yield* useScratch(); - const stream = new InMemoryStream([...inherited]); - const again = yield* continueHistory(scenario, text, stream, { - host: savedObjectHost(seen, scratch, saved, { - commit: saved.resolved.inputs.sourceCommit, - }), - answer: { choice: "continue" }, - }); - - expect(again.refusal).toBe(undefined); - expect(prefix(again.events, inherited.length)).toEqual(inherited); - expect(requests(again.events)).toHaveLength(1); - // Observed, recognized and adopted. Nothing was published a second time. - expect(subcommands(seen)).toContain("ls-remote"); - expect(subcommands(seen)).not.toContain("push"); - expect(outcomes(again.events)).toHaveLength(1); - expect(decisionOf(outcomes(again.events)[0])).toBe("adopted"); - }); -}); - -/** The commit the capture advanced that checkout to after cutting the entry. */ -function movedHead(scenario: Recorded): string | undefined { - return stringField(scenario, "movedCheckoutHead"); -} - -/** The commit the capture's remote was left holding at the destination. */ -function remoteCommitOf(scenario: Recorded): string | undefined { - return stringField(scenario, "remoteCommit"); -} - -function stringField(scenario: Recorded, name: string): string | undefined { - const service = scenario.service; - if (typeof service !== "object" || service === null || Array.isArray(service)) { - return undefined; - } - const value = (service as Record)[name]; - return typeof value === "string" ? value : undefined; -} From ce3bcdb6ae6b72916c036834013cb5f01ac9f4b3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 03:48:02 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Register=20the=20repos?= =?UTF-8?q?itory=20vocabulary=20once,=20for=20every=20command=20(#181,=20A?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REPL entry no longer carries a component catalog of its own. The Git Plugin registers all twelve names for every command it declares for, `repl` included, and that registry is what says which names exist: an execution that retains one of the external operations declares that same name again in its own scope with its own policy under it, where a nearer declaration shadows the registered default. So the entry declares what it owns and nothing more — `` and `` for the repository installation, `` alone for the issue-only one. `` and its three evidence reads keep the registered defaults and their own missing-provider refusals, until the delivery that retains them owns their policy too. Gone with it: `entryComponents` on the REPL profile and session, the preflight parameter and component merge, the no-host fallback that turned that catalog into an installation, `profileComponents`, `CommandPlugins.components`, the `plugins.components` reads in Syntax and Plan validation, the Plugin's `RETAINING_COMMANDS` vocabulary split and its root export, and the bulk six-operation policy and declaration catalog the split needed. Syntax inspection, Plan validation and REPL preflight read the installed registry they already stood in. --- architecture.md | 32 ++-- packages/cli/src/cli.ts | 16 +- packages/cli/src/plan-component.ts | 2 +- packages/cli/src/plugin-host.ts | 17 --- packages/cli/src/repl-profile.ts | 14 +- packages/cli/src/repl/program.ts | 1 - packages/cli/src/repl/session.ts | 35 ++--- packages/cli/src/run-profile.ts | 36 +---- packages/cli/src/syntax.ts | 8 +- .../repl/issue-881/controlled-host.ts | 1 - .../cli/tests/repl-agent-interface.test.ts | 1 - packages/cli/tests/repl-issues.test.ts | 46 ++---- packages/cli/tests/repl-journey.test.ts | 1 - packages/cli/tests/repl-push.test.ts | 24 ++- packages/git/mod.ts | 7 - .../src/composition/components/PullRequest.ts | 16 +- .../components/PullRequestReads.ts | 20 +-- packages/git/src/composition/installation.ts | 139 ++++------------- .../src/deno/retained-repository/issues.ts | 18 +-- .../src/deno/retained-repository/provider.ts | 21 +-- packages/git/src/plugin.ts | 41 +---- .../composition-component-binding.test.ts | 143 ++++++++++-------- specs/executable-mdx-spec.md | 15 +- specs/repl-spec.md | 11 +- 24 files changed, 207 insertions(+), 458 deletions(-) diff --git a/architecture.md b/architecture.md index b94f68981..a7c1b7e00 100644 --- a/architecture.md +++ b/architecture.md @@ -5188,15 +5188,18 @@ Declaration alone starts nothing: no provider, repository discovery, lock, Git process or credential read. A repository component may shadow any of these ordinary defaults. -Six of the twelve reach something no local transaction encloses — `Git.Push`, +Six of the sixteen reach something no local transaction encloses — `Git.Push`, `PullRequest`, its three evidence reads and `Issue` — and each has exactly one -implementation, which asks a semantic feature operation for its work. A command -whose executions retain nothing named after an invocation registers all twelve; -a command whose executions install their own policy under those six registers -the other six and receives these as declarations carrying that policy, so there -is no executable default for a declaration to shadow. `repl` is the one such -command. Either way the component is the same — one body, one set of forms, one -return contract — and a repository component of the name still outranks it. +implementation, which asks a semantic feature operation for its work. Every +command that carries this vocabulary registers all sixteen, `repl` included: one +registry is what says which names exist, and it is what syntax inspection, Plan +validation and preflight read. An execution that *retains* one of those +operations — a REPL entry, for what it publishes and what it files — declares +that same name again from inside its own scope with its own policy under it, +through the ordinary trusted installation and the same component module, so a +nearer declaration shadows the registered default in the one place it applies. +The component is the same either way — one body, one set of forms, one return +contract — and a repository component of the name still outranks the default. #### One invocation binding for the external repository operations @@ -7242,10 +7245,11 @@ as a trusted closure built for one entry, the identity authenticator is delivered to the common binding 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. `` here is -the one shared component with this entry's policy under it, declared rather than -registered because an entry retains work named after each invocation — 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 +the one shared component with this entry's policy under it: the name is the +registered default the command installed, and the entry declares it again in its +own execution because it retains work named after each invocation — 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. @@ -8524,7 +8528,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. The local Git and issue work an entry retains have rows of their own beside this one; 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 the one shared component with this entry's policy under it, which 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 | +| 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 the one shared component with this entry's policy declared over the command's registered default, which 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 | | REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and promises no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. A local result confers no publication evidence, and publication is the row below rather than part of these three [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | | REPL entry retained publication | one executing REPL entry's own ``, retained as a request record written before anything is contacted and the shared `git_host_effect` outcome that answers it. The request is resolved from the checkout — the authenticated selection, its common Git directory, the covering checkout for the received working directory, the admitted credential-free origin held to the identity's own fingerprint, and the branch and commit read inside the entry's native turn — and keeps the question asked, the reading the live dispatch received and what routing resolved, with no selection identifier, source object, credential, Git output, HEAD inventory or publication flag. Identity is the entry's owner and core's minted invocation identity, encoded in the run and expansion fields the workflow profile uses; a replacement registration and a direct `Git.operations.push(...)` reach the ordinary missing-provider refusal. Dispatch is ordinary Git composition through an operation-local default: a synthetic answer retains nothing, a second delegation of one invocation is refused, and a continuation consumes the saved request before any dispatch. A publication takes the managed slot and no mutation lease, reads objects through a bare control repository with a read-only alternate rather than through the checkout, reconciles a pending request against the exact commit it named without fetching, and publishes only after every per-attempt resource has closed. A completed publication reaches no provider, credential or remote; a failed request append contacts nothing and a failed outcome append leaves no false receipt [Reference](#what-one-entry-asked-to-publish-and-what-the-git-host-did-about-it). | 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 | @@ -8554,7 +8558,7 @@ Status is measured against main. | transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) under a workflow run | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation admits anything — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of admission, of retained state or of an unrecognized native condition fails the run instead of publishing a result [Invariant](#workspace-local-transactions); [Interface and evidence](specs/workflow-workspace-spec.md#7-git-operations). | built on the #294 stack, Deno provider only | | `Git.Push` under a workflow run | Publishes or adopts the selected named branch through authenticated non-force reconciliation. [Authored contract](specs/workflow-workspace-spec.md#74-push) and [effect mechanics](#replay-and-effect-transactions). | built on the #370 stack, Deno provider only | | `` under a workflow run | Upserts the exact published branch and returns stable evidence only after this run proves matching Push history. [Props, results and refusals](specs/workflow-workspace-spec.md#75-pull-request); [positive-integer number and reconciliation](#issue-effects). | built on the #295 stack, Deno provider only | -| repository composition vocabulary | Declares sixteen ordinary shadowable defaults without installing an operational provider, six of them as declarations wherever a command's executions install their own policy under them. [Names and profile ownership](#ordinary-repository-vocabulary). | built on the #643 stack | +| repository composition vocabulary | Declares sixteen ordinary shadowable defaults for every command that carries it, without installing an operational provider; an execution that retains one of the six external operations declares that name again with its own policy over the default. [Names and profile ownership](#ordinary-repository-vocabulary). | built on the #643 stack | | `` / `` composition under an ordinary run | Selects an authenticated persistent checkout under an execution-owned exclusive lock [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | | local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | Performs explicit local transitions against the authenticated checkout without rollback or replay [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | | `Git.Push` under an ordinary run | Publishes or adopts a non-force branch result and keeps invocation-private evidence [Full contract](#ordinary-repository-ownership). | built on the #643 stack, Deno and compiled only | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6952b16be..10a39878e 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -146,7 +146,7 @@ import { syntaxSymbols, useCommandSyntax, } from "./syntax.ts"; -import { assembleRunProfile, BundledRunPlugin, profileComponents } from "./run-profile.ts"; +import { assembleRunProfile, BundledRunPlugin } from "./run-profile.ts"; import type { PluginModuleLoader } from "./plugin-loader.ts"; import { installPlugins, NO_PLUGINS } from "./plugin-host.ts"; import type { CommandPlugins } from "./plugin-host.ts"; @@ -3441,16 +3441,10 @@ function* withPlugins( let plugins: CommandPlugins; try { const loaded = yield* assembleRunProfile(selection, loadPluginModule); - // The profile's own repository operations travel with the installations - // its Plugins contributed, because every surface that executes, describes - // or validates this command's vocabulary reads one assembly. - plugins = { - ...(yield* installPlugins(loaded, { - command: selection.command, - args: selection.args, - })), - components: profileComponents(selection), - }; + plugins = yield* installPlugins(loaded, { + command: selection.command, + args: selection.args, + }); } catch (error) { reportFailure(error instanceof Error ? error : new Error(String(error))); yield* exit(1); diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 36c9710b1..d0cb1e150 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -152,7 +152,7 @@ export function structuralValidation( return yield* validateDocumentStructure({ ...retainedSource(PLAN_IDENTITY, candidate), includes: [...includes], - components: [...agentIdentityComponents(), ...plugins.components], + components: agentIdentityComponents(), // Whatever the installed Plugins declare travels with the profile, so a // Plan is checked against the same vocabulary the later `xmd run` // supplies. Checking it against a narrower one would refuse a program diff --git a/packages/cli/src/plugin-host.ts b/packages/cli/src/plugin-host.ts index f1c2bede9..ce3b5c2c0 100644 --- a/packages/cli/src/plugin-host.ts +++ b/packages/cli/src/plugin-host.ts @@ -55,19 +55,6 @@ export interface CommandPlugins { * run that follows it. */ readonly declarations: readonly ExecutionDeclaration[]; - /** - * The components this command's profile declares whose implementations name - * durable work after their own invocation. - * - * Not a Plugin's contribution and deliberately not on `PluginInstallation`: - * that member means exact Markdown. These are the repository operations whose - * one implementation asks a semantic feature operation — ``, - * ``, its three evidence reads and `` — and which host - * policy performs them is settled by the profile that assembled this. They - * travel beside the installations so every surface that executes, describes - * or validates this command's vocabulary reads one list. - */ - readonly components: readonly IdentityComponent[]; /** * The original argv these Plugins were installed from. * @@ -83,7 +70,6 @@ export const NO_PLUGINS: CommandPlugins = Object.freeze({ plugins: Object.freeze([]), installations: Object.freeze([]), declarations: Object.freeze([]), - components: Object.freeze([]), args: Object.freeze([]), }); @@ -199,9 +185,6 @@ export function* installPlugins( plugins, installations: Object.freeze(installations), declarations: Object.freeze(declared), - // Filled by the profile that assembled this command, which is the one place - // that knows whether this command carries the bundled vocabulary at all. - components: Object.freeze([]), args: request.args, }; } diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index 2eca3becf..e2c363073 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -17,7 +17,7 @@ import type { AcpxProviderDependencies } from "@executablemd/acp"; import { agentIdentityComponents } from "@executablemd/core"; import type { PermissionMode } from "@executablemd/core"; import type { GeneratedSelection } from "@executablemd/core/api"; -import type { ExecutionInstallation, IdentityComponent } from "@executablemd/core/host"; +import type { ExecutionInstallation } from "@executablemd/core/host"; import { useScope } from "effection"; import type { Operation } from "effection"; @@ -134,17 +134,6 @@ export interface ReplExecutionProfile { * host rather than one with a partly configured one. */ readonly entryInstallation?: ReplEntryInstallation; - /** - * The repository operations this command declares, as metadata. - * - * The preflight that runs before an entry exists validates against these, - * and a session whose host selected no entry capability declares them as they - * stand — the ordinary policy, which is each feature Api's own refusal where - * nothing is installed. An entry with a capability declares its own set - * instead, with this host's policy under the operations it retains, so one - * execution captures one declaration of each name. - */ - readonly entryComponents: readonly IdentityComponent[]; /** * Which agent this command's Sidekick opens a fresh conversation under. * @@ -262,7 +251,6 @@ export function* assembleReplProfile( generated: ordinaryGeneratedSelection(), permissionMode: stack.permissionMode, ...(entryInstallation === undefined ? {} : { entryInstallation }), - entryComponents: plugins.components, // Settled here, with the stack and the Plugins, because it is a fact about // the command a person invoked rather than about any conversation they // later have. Resolved rather than retained as "unstated": a profile that diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index d4e6c3256..f9fc27037 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -271,7 +271,6 @@ export function* runReplProgram(options: ReplProgramOptions): Operation` in such a session is refused by the provider that - * is missing rather than unknown to the language. - * - * An entry with a capability declares its own set instead, so one execution - * captures one declaration of each name. - */ - readonly entryComponents?: readonly IdentityComponent[]; } /** @@ -621,7 +606,6 @@ function* start( selection, permissionMode, entryInstallation, - entryComponents = [], } = options; const stream = execution.stream; @@ -679,7 +663,7 @@ function* start( : undefined; if (submitted !== undefined) { - const validated = yield* preflight(submitted, includes, installations, entryComponents); + const validated = yield* preflight(submitted, includes, installations); if (!validated.ok) { return validated; } @@ -1118,7 +1102,7 @@ function* start( if (!before.ok) { return before; } - const validated = yield* preflight(source, includes, installations, entryComponents); + const validated = yield* preflight(source, includes, installations); if (!validated.ok) { return validated; } @@ -1432,9 +1416,7 @@ function* start( // the entry rather than to a scope that has already closed. const owned = entryInstallation === undefined - ? entryComponents.length === 0 - ? [] - : [{ components: entryComponents }] + ? [] : [yield* 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 @@ -1664,16 +1646,17 @@ function* preflight( source: string, includes: readonly string[] | undefined, installations: readonly ExecutionInstallation[], - entryComponents: readonly IdentityComponent[], ): Operation> { const validation = yield* scoped(function* () { return yield* validateDocument({ ...inlineSource(source), includes, - // The command's own declarations and the repository operations every - // entry of it declares. Metadata alone: validation resolves a name from a - // declaration and calls no factory, so nothing here initializes policy. - components: [...declaredComponents(installations), ...entryComponents], + // The command's own declarations. A Plugin's vocabulary is registered in + // the scope this session was opened in, so the names an entry may write + // are already resolvable here — and metadata alone: validation resolves a + // name from a declaration and calls no factory, so nothing initializes + // policy. + components: declaredComponents(installations), declarations: declaredMarkdown(installations), }); }); diff --git a/packages/cli/src/run-profile.ts b/packages/cli/src/run-profile.ts index b058ae60c..52afe6bb5 100644 --- a/packages/cli/src/run-profile.ts +++ b/packages/cli/src/run-profile.ts @@ -30,15 +30,8 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; import { cwd } from "@executablemd/runtime"; -import { - compositionDeclarations, - gitPlugin, - gitPluginDeclaresFor, - gitPluginDeclaresOperationPolicy, - ordinaryCompositionPolicy, -} from "@executablemd/git"; +import { gitPlugin, gitPluginDeclaresFor } from "@executablemd/git"; import type { Plugin } from "@executablemd/core/api"; -import type { IdentityComponent } from "@executablemd/core/host"; import { loadPlugins, type PluginModuleLoader } from "./plugin-loader.ts"; import type { PluginSelection } from "./plugin-selection.ts"; @@ -98,33 +91,6 @@ export function carriesBundledPlugin(selection: PluginSelection): boolean { return gitPluginDeclaresFor({ command: selection.command, args: selection.args }); } -/** - * The repository operations this command's executions declare, where they - * declare any. - * - * ``, ``, its three evidence reads and `` have - * one implementation each, and each asks a semantic feature operation for its - * work. Most commands register them as ordinary defaults, where a nearer - * registration still shadows one; a command whose executions install their own - * policy under them declares them instead, and the Plugin is what says which - * this is. - * - * The policy here is the ordinary one — the public feature Apis — because this - * is what a session declares before any entry supplies its own: an entry that - * retains publications and issues replaces it with its own for exactly those, - * and a runtime that selected no entry capability keeps this and reaches each - * Api's own refusal. - * - * Built per call rather than held as a value: a declaration is closed over the - * policy it was built with, and one execution's declarations are never - * another's. - */ -export function profileComponents(selection: PluginSelection): readonly IdentityComponent[] { - return gitPluginDeclaresOperationPolicy({ command: selection.command, args: selection.args }) - ? compositionDeclarations(ordinaryCompositionPolicy()) - : []; -} - /** * The Plugin values one command runs with, in installation order. * diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index b65784d9c..f7340b542 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -100,10 +100,10 @@ export function* useCommandSyntax( const inputs = { includes: [...includes], registry: new Map(yield* Component.operations.registry), - // The engine's own invocation-named components, and this command's - // profile's: inspection reads their metadata and calls no factory, so - // describing the repository operations mints nothing. - components: [...agentIdentityComponents(), ...plugins.components], + // The engine's own invocation-named components. A Plugin's vocabulary is + // in the registry this captured above, so describing it reaches no + // identity: inspection reads metadata and calls no factory. + components: agentIdentityComponents(), // `` is part of the run profile, so symbols that left it out would // describe a vocabulary no run has. Described from the packaged bytes: // inspection mints nothing, so it reports the Component's identity and diff --git a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts index 606bf2082..208b93934 100644 --- a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts +++ b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts @@ -99,7 +99,6 @@ await main(function* (args) { profile: { includes: [process.cwd()], installations: [holdingTeardown()], - entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", // This host opens no Sidekick conversation, so the selection is the one diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index 9e76e2116..247282241 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -438,7 +438,6 @@ function* useStub(stub: Stub): Operation { const PROFILE: ReplExecutionProfile = { includes: [], installations: [{ components: agentIdentityComponents() }], - entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "approve-reads", // No row here opens a Sidekick conversation, so the selection is the one an diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index b69538e3a..6ea275e97 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -31,16 +31,12 @@ 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, IdentityComponent } from "@executablemd/core/host"; +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 { - compositionDeclarations, - ordinaryCompositionPolicy, -} from "../../git/src/composition/installation.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"; @@ -100,23 +96,14 @@ function useReplRepositories(root: string): Operation { }); } -/** - * The repository operations every entry of this command declares, as the REPL - * profile supplies them. - * - * Metadata for the preflight that runs before an entry exists, and — for a - * session whose host selected no entry capability — the declarations that entry - * runs with, ordinary policy beneath them. - */ -function entryComponents(): readonly IdentityComponent[] { - return compositionDeclarations(ordinaryCompositionPolicy()); -} - /** * 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. + * than written out here, because that contribution is the production path — and + * called in the scope the session is opened in, because installing it is what + * registers the vocabulary. `` resolves in preflight and in the entry + * through that registry, with no component catalog supplied beside it. */ function* replInstallations(): Operation { const install = gitPlugin.install; @@ -287,7 +274,6 @@ function submit( execution: run, source, installations, - entryComponents: entryComponents(), generated: ordinaryGeneratedSelection(), ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.host === undefined ? {} : { entryInstallation: options.host }), @@ -317,7 +303,6 @@ function reopen(fixture: Fixture, run: ReplExecution, options: RunOptions = {}): yield* openReplSession({ execution: run, installations, - entryComponents: entryComponents(), generated: ordinaryGeneratedSelection(), ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.host === undefined ? {} : { entryInstallation: options.host }), @@ -853,7 +838,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -969,7 +953,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source: [FILES_ONE, SUFFIX].join("\n"), installations, @@ -1053,7 +1036,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source, installations, @@ -1190,7 +1172,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1274,7 +1255,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); const session = opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1298,7 +1278,6 @@ describe("how a REPL entry gets its issues", () => { const installations = yield* replInstallations(); opened( yield* openReplSession({ - entryComponents: entryComponents(), execution: run, installations, generated: ordinaryGeneratedSelection(), @@ -1441,7 +1420,6 @@ describe("how a REPL entry gets its issues", () => { const installations = [...(yield* replInstallations()), heldCleanup(cleanup)]; opened( yield* submitReplEntry({ - entryComponents: entryComponents(), execution: run, source: FILES_ONE, installations, @@ -1510,11 +1488,11 @@ describe("how a REPL entry gets its issues", () => { }); expect(absent.entryInstallation).toBe(undefined); - // Selecting reaches nothing, and what it selected declares the whole common - // vocabulary once: an entry installs one set of these names, with this - // host's policy under the two operations it retains and the ordinary policy - // under the rest. An installation is built per entry, and this command has - // run none. + // Selecting reaches nothing, and what it selected declares exactly the + // operations an entry retains — publication and filing, and neither + // `` nor its reads, which stay the defaults the command's + // scope registered. An installation is built per entry, and this command + // has run none. const built = yield* scoped(function* (): Operation { if (factory === undefined) { throw new Error("the production selector selected no entry capability"); @@ -1524,10 +1502,6 @@ describe("how a REPL entry gets its issues", () => { expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", - "PullRequest", - "PullRequest.Checks", - "PullRequest.Comments", - "PullRequest.Reviews", ]); }); diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 28044a7a0..62afe9347 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -139,7 +139,6 @@ const INSTALLATIONS: readonly ExecutionInstallation[] = []; const PROFILE: ReplExecutionProfile = { includes: [REFERENCE_DIRECTORY], installations: INSTALLATIONS, - entryComponents: [], generated: ordinaryGeneratedSelection(), permissionMode: "deny-all", // No row here opens a Sidekick conversation, so the selection is the one an diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index 52367bc27..4e6eb1cf5 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -32,10 +32,6 @@ import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; import { gitSession } from "../../git/src/deno/composition/git.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; -import { - compositionDeclarations, - ordinaryCompositionPolicy, -} from "../../git/src/composition/installation.ts"; import { REPL_REPOSITORY_PUSH_REQUEST } from "../../git/src/retained-repository/push-records.ts"; import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; @@ -137,7 +133,14 @@ function useWorkingDirectory(directory: string): Operation { ); } -/** What the Git Plugin contributes for the `repl` command. */ +/** + * What the Git Plugin contributes for the `repl` command. + * + * Called in the scope the session is opened in, because installing is what + * registers the vocabulary: `` resolves in preflight and in the + * entry through this registry, exactly as it does under the real command, and + * this harness supplies no component catalog of its own. + */ function* replInstallations(): Operation { const install = gitPlugin.install; if (install === undefined) { @@ -217,7 +220,6 @@ function submit( execution: run, source, installations: [...(yield* replInstallations())], - entryComponents: compositionDeclarations(ordinaryCompositionPolicy()), generated: ordinaryGeneratedSelection(), ...(options.host === false ? {} @@ -260,7 +262,6 @@ function reopen( yield* openReplSession({ execution: run, installations: [...(yield* replInstallations())], - entryComponents: compositionDeclarations(ordinaryCompositionPolicy()), generated: ordinaryGeneratedSelection(), entryInstallation: (owner: string) => { seen.owners.push(owner); @@ -430,19 +431,14 @@ describe("how a REPL entry gets its publication capability", () => { } // Building one reaches nothing — no directory, no `git`, no credential and - // no managed root — and what it declares is the whole common vocabulary - // once, with this host's policy under the two operations it retains and the - // ordinary policy under the rest. + // no managed root — and what it declares is the two operations this entry + // retains, under names the command's scope has already registered. const built = yield* scoped(function* (): Operation { return yield* factory('["executablemd.repl.issue-owner",1,"execution-cli-6","1"]'); }); expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", - "PullRequest", - "PullRequest.Checks", - "PullRequest.Comments", - "PullRequest.Reviews", ]); expect((built.admissions ?? []).length).toBe(2); expect(yield* nothingUnder(fixture.managed)).toBe(true); diff --git a/packages/git/mod.ts b/packages/git/mod.ts index 4561abe36..fba5cbe40 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -98,7 +98,6 @@ export { declaresFor as gitPluginDeclaresFor } from "./src/plugin.ts"; * above: a host assembling a command needs this answer and the vocabulary's own * gating to agree. */ -export { declaresOperationPolicy as gitPluginDeclaresOperationPolicy } from "./src/plugin.ts"; export { GitObjectError, GitRepositoryError, GitRevisionError } from "./src/git.ts"; export type { GitObjectFormat } from "./src/git.ts"; export { @@ -237,15 +236,9 @@ export type { export { admitPushEvidence } from "./src/composition/push-evidence.ts"; export { COMPOSITION_REGISTRATIONS, - compositionDeclarations, compositionDocumentation, - compositionNames, - ordinaryCompositionPolicy, - SELECTION_REGISTRATIONS, useCompositionComponents, - useCompositionSelections, } from "./src/composition/installation.ts"; -export type { CompositionPolicy } from "./src/composition/installation.ts"; export { BoundOperationError } from "./src/composition/invocation-binding.ts"; export type { BindOperations, diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index 92baeedbb..1f670f3ba 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -77,7 +77,6 @@ import type { PropsSchema, ReturnsSchema, } from "@executablemd/core"; -import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; @@ -85,8 +84,8 @@ import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; -import { boundComponent, unboundComponent } from "../invocation-binding.ts"; -import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { unboundComponent } from "../invocation-binding.ts"; +import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; /** The component name, as a document writes it and as a refusal names it. */ export const PULL_REQUEST_ELEMENT = ""; @@ -227,14 +226,3 @@ export function pullRequestRegistration( fn: unboundComponent(PULL_REQUEST_ELEMENT, operations, body), }; } - -/** `PullRequest` as a declaration, for a host that supplies its own policy. */ -export function pullRequestDeclaration( - bind: BindOperations, -): IdentityComponent { - return { - ...METADATA, - factory: (claim: IdentityClaimant) => - boundComponent(claim, { element: PULL_REQUEST_ELEMENT, bind, body }), - }; -} diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 17d645948..05d29bad6 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -29,7 +29,6 @@ import type { PropsSchema, ReturnsSchema, } from "@executablemd/core"; -import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; @@ -38,8 +37,8 @@ import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; -import { boundComponent, unboundComponent } from "../invocation-binding.ts"; -import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { unboundComponent } from "../invocation-binding.ts"; +import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; /** The component names, as a document writes them and a refusal names them. */ export const REVIEWS_ELEMENT = ""; @@ -303,21 +302,6 @@ export function pullRequestReadRegistration( }; } -/** One evidence read as a declaration, for a host that supplies its own policy. */ -export function pullRequestReadDeclaration( - kind: PullRequestReadKind, - bind: BindOperations, -): IdentityComponent { - const element = ELEMENTS[kind]; - return { - ...metadata(kind), - factory: (claim: IdentityClaimant) => - formDispatcher( - declare(boundComponent(claim, { element, bind, body: read(kind, element) }), element), - ), - }; -} - /** The three kinds, in the order a catalog lists them. */ export const PULL_REQUEST_READ_KINDS: readonly PullRequestReadKind[] = Object.freeze([ "reviews", diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index 00bbb952e..ae60fe905 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -9,7 +9,7 @@ * name (`core/src/components/fixed-filesystem.ts`), and a document reaches it * with or without this package. * - * ## One implementation, two ways of carrying it + * ## One registry, and the policy an execution attaches underneath it * * Ten of the sixteen reach nothing beyond the selected checkout: Repository and * Worktree choose one, Git.Switch, Git.Add and Git.Commit change one, the four @@ -23,14 +23,15 @@ * Apis, a runtime with no provider keeps each Api's own refusing default, and a * REPL entry retains and reconciles beneath them. * - * A host that retains nothing named after an invocation registers them - * ordinarily, through {@link useCompositionComponents}, so a nearer - * registration still shadows one. A host that does — a REPL entry — declares - * the same six through {@link compositionDeclarations} with its own policy, and - * registers the rest through {@link useCompositionSelections} so there is no - * executable default for its declaration to shadow. Either way the component is - * the same: one body, one set of forms, one return contract, because typing - * `` means the same authored operation wherever it is typed. + * All sixteen are registered here, for every command that carries this + * vocabulary, because this is what says the names exist: it is what `xmd + * syntax` describes, what validation resolves against and what an execution + * inherits. A host that retains one of these operations does not register a + * different component for it — it declares this same body with its own policy + * from inside its own execution's scope, where a nearer registration shadows a + * farther one. Either way the component is the same: one body, one set of + * forms, one return contract, because typing `` means the same authored + * operation wherever it is typed. * * Nothing here installs a provider, performs repository discovery, acquires a * lock or reaches a network: what a name *does* is the installed policy's, and @@ -55,7 +56,6 @@ import type { DocumentationContribution, DocumentationReader, } from "@executablemd/core"; -import type { IdentityComponent } from "@executablemd/core/host"; import { COMPOSITION_ORIGIN } from "./definitions.ts"; import { Git } from "./git-api.ts"; import type { GitApi } from "./git-api.ts"; @@ -63,7 +63,7 @@ import { IssueOperations } from "../issue/operations.ts"; import type { IssueOperationsApi } from "../issue/operations.ts"; import { PullRequestOperations } from "./pull-request-operations.ts"; import type { PullRequestOperationsApi } from "./pull-request-operations.ts"; -import type { BindOperations, InvocationGate } from "./invocation-binding.ts"; +import type { InvocationGate } from "./invocation-binding.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; import GitSwitch, { props as gitSwitchProps } from "./components/GitSwitch.ts"; @@ -72,14 +72,13 @@ import GitCommit, { props as gitCommitProps, returns as gitCommitReturns, } from "./components/GitCommit.ts"; -import { pushDeclaration, pushRegistration } from "./components/GitPush.ts"; -import { pullRequestDeclaration, pullRequestRegistration } from "./components/PullRequest.ts"; +import { pushRegistration } from "./components/GitPush.ts"; +import { pullRequestRegistration } from "./components/PullRequest.ts"; import { PULL_REQUEST_READ_KINDS, - pullRequestReadDeclaration, pullRequestReadRegistration, } from "./components/PullRequestReads.ts"; -import { issueDeclaration, issueRegistration } from "./components/Issue.ts"; +import { issueRegistration } from "./components/Issue.ts"; import IssueTracker, { props as issueTrackerProps } from "./components/IssueTracker.ts"; import { changesForm, diffForm, fileForm, filesForm } from "./components/GitReads.ts"; import { @@ -93,28 +92,6 @@ import { filesReturns, } from "./observation-api.ts"; -/** - * How one host performs the six operations no local transaction encloses. - * - * One member per authored operation, each a constructor rather than an - * operation: it is called with the site that authenticated one invocation, and - * what it answers with is that invocation's own operations. Nothing a document, - * a prop, a tracker, a Context value or middleware holds takes part in - * selecting it — a host states its policy as direct code before any document - * runs. - * - * The two pull-request members are separate because the policies are: an upsert - * owns its delegated result and a read retains an admitted provider snapshot - * beneath live transformations, and a host that shared one member between them - * would be making those two policies equal. - */ -export interface CompositionPolicy { - readonly push: BindOperations; - readonly issue: BindOperations; - readonly pullRequest: BindOperations; - readonly pullRequestRead: BindOperations; -} - /** * The operations an ordinary run, a workflow run and a provider-absent runtime * perform through. @@ -141,61 +118,24 @@ const ORDINARY = { }), }; -/** - * That same wiring, as the policy a declaring host would supply. - * - * What a host declaring these operations gets where it retains none of them - * itself: a REPL entry that records publications and issues still leaves - * `` and its reads their own unchanged missing-provider refusal, - * and a runtime with no provider at all declares every one of them this way. - */ -export function ordinaryCompositionPolicy(): CompositionPolicy { - return { - push: (_site, gate) => ORDINARY.push(gate), - issue: (_site, gate) => ORDINARY.issue(gate), - pullRequest: (_site, gate) => ORDINARY.pullRequest(gate), - pullRequestRead: (_site, gate) => ORDINARY.pullRequest(gate), - }; -} - -/** - * The six declarations whose implementations name their own durable work. - * - * Built in one place, from one policy, so an ordinary run, a workflow run, a - * REPL entry, a provider-absent runtime and a descriptive command all declare - * the same names, origins, props, forms, return contracts and prose. A host - * that wanted a different implementation would have to supply a different - * policy, which is the only thing a host is allowed to differ on. - */ -export function compositionDeclarations(policy: CompositionPolicy): readonly IdentityComponent[] { - return Object.freeze([ - pushDeclaration(policy.push), - pullRequestDeclaration(policy.pullRequest), - ...PULL_REQUEST_READ_KINDS.map((kind) => - pullRequestReadDeclaration(kind, policy.pullRequestRead), - ), - issueDeclaration(policy.issue), - ]); -} - /** * Every name this boundary carries, in catalog order. * * Derived from the one registration array rather than listed again, so a * component added to it cannot disappear from the documentation this package - * contributes or from an inventory held to it. The declaring path carries the - * same six operations under the same names, which is what makes one catalog - * true of both. + * contributes or from an inventory held to it. An execution that declares one + * of these operations with policy of its own declares it under this same name, + * which is what makes one catalog true of every host. */ -export function compositionNames(): readonly string[] { +function compositionNames(): readonly string[] { return Object.freeze(COMPOSITION_REGISTRATIONS.map((registration) => registration.name)); } /** * This boundary's long-form documentation, and the components it must cover. * - * Derived from the two declaration paths rather than listed again, so a - * component added to either cannot be left undocumented here. + * Derived from the registration array rather than listed again, so a component + * added to it cannot be left undocumented here. */ export function* compositionDocumentation( read?: DocumentationReader, @@ -345,10 +285,11 @@ const TRACKER: ComponentRegistration = { /** * The six operations no local transaction encloses, as ordinary registrations. * - * The same bodies the declaring path carries, over the public feature Apis. - * Registered rather than declared, because a host that retains nothing named - * after an invocation has no identity for one to name — and because these stay - * ordinary defaults a nearer registration shadows. + * The same bodies an owning execution declares, over the public feature Apis. + * Registered, because this is where the names come from, and because an + * ordinary default is what a nearer registration — a repository-local + * component, an enclosing host, an execution that owns the operation — is + * entitled to shadow. */ const EXTERNAL_OPERATIONS: readonly ComponentRegistration[] = [ pushRegistration(ORDINARY.push), @@ -357,16 +298,6 @@ const EXTERNAL_OPERATIONS: readonly ComponentRegistration[] = [ issueRegistration(ORDINARY.issue), ]; -/** - * The selection, local-operation and tracker half: everything a host that - * installs its own policy under the six operations still registers ordinarily. - */ -export const SELECTION_REGISTRATIONS: readonly ComponentRegistration[] = Object.freeze([ - ...SELECTIONS, - ...LOCAL_OPERATIONS, - TRACKER, -]); - /** All sixteen, in catalog order. */ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = Object.freeze([ ...SELECTIONS, @@ -378,6 +309,11 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = Objec /** * Register the whole vocabulary as ordinary defaults for this scope. * + * Every command that carries this vocabulary calls this, `repl` included: one + * registry is what says which names exist, and an execution that owns one of + * these operations attaches its policy to the same body from inside its own + * scope rather than arriving with a vocabulary of its own. + * * Every one of them is a declaration: this installs no provider, discovers no * ambient repository, acquires no lock, spawns no Git and reads no credential, * which is what lets `xmd syntax` enter it to describe the profile. @@ -386,18 +322,3 @@ export function* useCompositionComponents(): Operation { yield* registerComponents(COMPOSITION_REGISTRATIONS); yield* contributeDocumentation(compositionDocumentation); } - -/** - * Register everything except the six operations, for a command whose - * executions install their own policy under them. - * - * A REPL entry declares those six through {@link compositionDeclarations} with - * the policy it retains them under, so registering an executable default here - * would be registering a body for the entry's declaration to shadow. The - * documentation still describes all sixteen: it is the same vocabulary either - * way, and a reader asking about `` is asking about one component. - */ -export function* useCompositionSelections(): Operation { - yield* registerComponents(SELECTION_REGISTRATIONS); - yield* contributeDocumentation(compositionDocumentation); -} diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/git/src/deno/retained-repository/issues.ts index 4ceb2498d..7724be7c3 100644 --- a/packages/git/src/deno/retained-repository/issues.ts +++ b/packages/git/src/deno/retained-repository/issues.ts @@ -48,10 +48,7 @@ import type { ExecutionInstallation, JournalAdmission } from "@executablemd/core import type { DurableEvent } from "@executablemd/durable-streams"; import { StaleInputError } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; -import { - compositionDeclarations, - ordinaryCompositionPolicy, -} from "../../composition/installation.ts"; +import { issueDeclaration } from "../../composition/components/Issue.ts"; import type { BindOperations } from "../../composition/invocation-binding.ts"; import { IssueApi } from "../../issue/api.ts"; import type { IssueDetails, IssueReference } from "../../issue/api.ts"; @@ -118,14 +115,11 @@ export function replIssueInstallation( options: GitHubIssuesOptions = {}, ): ExecutionInstallation { return { - // The whole common vocabulary, declared once with this entry's issue policy - // under it. The other five keep the ordinary policy, so `` and - // `` in an entry whose host recorded only issues reach their - // own unchanged missing-provider refusals. - components: compositionDeclarations({ - ...ordinaryCompositionPolicy(), - issue: replIssuePolicy(owner), - }), + // The one operation this entry retains. Every other name stays the + // registered default, so `` and `` in an entry + // whose host recorded only issues reach their own unchanged + // missing-provider refusals. + components: [issueDeclaration(replIssuePolicy(owner))], admissions: [replIssueAdmission(owner)], install: () => useEntryIssueTransport(options), }; diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index f55fade8c..61b7c9bfa 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -84,10 +84,8 @@ import { GitQuery } from "../../git.ts"; import { replPushAdmission, replPushPolicy } from "./push.ts"; import type { ReplPushEntry } from "./push.ts"; import { replIssueAdmission, replIssuePolicy, useEntryIssueTransport } from "./issues.ts"; -import { - compositionDeclarations, - ordinaryCompositionPolicy, -} from "../../composition/installation.ts"; +import { pushDeclaration } from "../../composition/components/GitPush.ts"; +import { issueDeclaration } from "../../composition/components/Issue.ts"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { IdentityComponent, JournalAdmission } from "@executablemd/core/host"; @@ -203,11 +201,16 @@ export function* replRepositoryInstallation( }, }); - const components: readonly IdentityComponent[] = compositionDeclarations({ - ...ordinaryCompositionPolicy(), - issue: replIssuePolicy(owner), - push: replPushPolicy(owner, () => (usable ? held.entry : undefined)), - }); + // The two operations this entry retains, and only those. Every name this + // vocabulary has is already registered in the command's scope; what an entry + // adds here is the policy it owns, attached to those same two bodies from + // inside its own execution. ``, its three reads and everything + // local keep the registered defaults, because this entry retains nothing of + // them to answer with. + const components: readonly IdentityComponent[] = [ + pushDeclaration(replPushPolicy(owner, () => (usable ? held.entry : undefined))), + issueDeclaration(replIssuePolicy(owner)), + ]; const admissions: JournalAdmission[] = [replIssueAdmission(owner), replPushAdmission(owner)]; return { components, diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts index d7b5f8815..cbbe2fc2e 100644 --- a/packages/git/src/plugin.ts +++ b/packages/git/src/plugin.ts @@ -33,7 +33,7 @@ import type { PluginInstallRequest, PluginInstallation, } from "@executablemd/core/api"; -import { useCompositionComponents, useCompositionSelections } from "./composition/installation.ts"; +import { useCompositionComponents } from "./composition/installation.ts"; import { admitReplRepositoryHistory } from "./retained-repository/admission.ts"; import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.ts"; @@ -187,31 +187,6 @@ export function declaresFor(request: PluginInstallRequest): boolean { return request.command === "workflow" && executesDocument(request.args); } -/** - * The commands whose executions install their own policy under the six - * operations no local transaction encloses. - * - * `repl` alone: an entry retains what it published and what it filed, so each - * of those operations is declared with that entry's policy under it rather than - * registered with the ordinary one. Every other command registers them - * ordinarily, where a nearer registration still shadows one. - */ -const RETAINING_COMMANDS: ReadonlySet = new Set(["repl"]); - -/** - * Whether this command's executions declare those six rather than register - * them. - * - * Asked of the Plugin, because the answer is the Plugin's: it is the value that - * decides which commands carry this vocabulary at all, and a host assembling - * one needs the two halves to agree. A command answering true registers the - * selection half and declares the operations; one answering false registers all - * twelve. - */ -export function declaresOperationPolicy(request: PluginInstallRequest): boolean { - return declaresFor(request) && RETAINING_COMMANDS.has(request.command); -} - export const gitPlugin: Plugin = Plugin({ name: "@executablemd/git", *install(request: PluginInstallRequest): Operation { @@ -222,15 +197,11 @@ export const gitPlugin: Plugin = Plugin({ // provider that performs what they name is attached by the host, lazily, // when a run actually has a Workspace to perform it in. // - // A command whose executions retain their own external work registers the - // selection half alone: the six operations arrive as declarations carrying - // that host's policy, and registering an executable default for them here - // would be registering a body for those declarations to shadow. - if (declaresOperationPolicy(request)) { - yield* useCompositionSelections(); - } else { - yield* useCompositionComponents(); - } + // Every command that carries this vocabulary carries all of it, `repl` + // included: what a name means is the same wherever it is typed, and an + // execution that retains one of these operations attaches its policy to + // this same body from inside its own scope. + yield* useCompositionComponents(); return { admissions: [ gitHostIdentityAdmission(), diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index 91c2b63be..32bb79b43 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -1,11 +1,12 @@ /** - * One declaration source, one body, and a facade that ends with its invocation. + * One registry, one body, and a facade that ends with its invocation. * * Every row here drives the actual shared components through canonical - * execution. What differs between rows is the policy underneath: these are - * neutral constructors and fake policies, so what is proven is the binding and - * the bodies rather than any host's retention. The real native assembly is - * covered by the CLI and native suites. + * execution, over the whole vocabulary registered as the Plugin registers it. + * What differs between rows is the policy an owning execution declares + * underneath: these are neutral constructors and fake policies, so what is + * proven is the binding and the bodies rather than any host's retention. The + * real native assembly is covered by the CLI and native suites. * * These rows carry A1, A2 and the binding half of A5 of the component-boundary * repair matrix. @@ -34,23 +35,22 @@ import type { ComponentRegistration, Json } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionInstallation, IdentityComponent } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { - compositionDeclarations, - COMPOSITION_REGISTRATIONS, - ordinaryCompositionPolicy, - SELECTION_REGISTRATIONS, -} from "../src/composition/installation.ts"; -import type { CompositionPolicy } from "../src/composition/installation.ts"; +import { COMPOSITION_REGISTRATIONS } from "../src/composition/installation.ts"; +import { pushDeclaration } from "../src/composition/components/GitPush.ts"; +import { issueDeclaration } from "../src/composition/components/Issue.ts"; import { BoundOperationError } from "../src/composition/invocation-binding.ts"; -import type { InvocationGate } from "../src/composition/invocation-binding.ts"; +import type { BindOperations, InvocationGate } from "../src/composition/invocation-binding.ts"; // From the subpath that publishes them rather than from the source modules: // these are public types, and reaching them by relative path would bypass the // export map the partition test holds every consumer to. -import type { GitApi, IssueDetails, IssueReference } from "@executablemd/git/api"; +import type { IssueDetails, IssueReference } from "@executablemd/git/api"; import type { IssueOperationsApi } from "../src/issue/operations.ts"; import type { PullRequestOperationsApi } from "../src/composition/pull-request-operations.ts"; import { COMPOSITION_ORIGIN } from "../src/composition/definitions.ts"; +/** The two operations A's retained entry owns, by the names a document writes. */ +const OWNED: readonly string[] = ["Git.Push", "Issue"]; + /** The six operations no local transaction encloses, by the names a document writes. */ const OPERATIONS: readonly string[] = [ "Git.Push", @@ -117,28 +117,17 @@ function fakeIssuePolicy(seen: Watched, hold = false) { }; } -/** A policy whose push suspends until this row releases it. */ -function suspendingPushPolicy(seen: Watched, reached: { resolve(): void }) { - return (site: string, gate: InvocationGate): GitApi["push"] => { - seen.sites.push(site); - return () => - gate.once(function* (): Operation { - seen.performed.push("push"); - reached.resolve(); - yield* suspend(); - throw new Error("a suspended publication resumed"); - }); - }; -} - -/** The policy a row builds its declarations with. */ -function policy(overrides: Partial): CompositionPolicy { - return { ...ordinaryCompositionPolicy(), ...overrides }; -} - -/** The declarations one row installs, as a host installs them. */ -function declaring(overrides: Partial): ExecutionInstallation { - return { components: compositionDeclarations(policy(overrides)) }; +/** + * The declarations one row installs, as an owning execution installs them. + * + * One constructor per operation, the same ones the retained entry calls: an + * execution declares what it owns and nothing else, and every other name in + * this vocabulary stays the ordinary registration {@link run} makes below. + */ +function declaring(owned: { + readonly issue: BindOperations; +}): ExecutionInstallation { + return { components: [issueDeclaration(owned.issue)] }; } interface Ran { @@ -156,8 +145,12 @@ function run( return scoped(function* (): Operation { const path = join(root, `entry-${crypto.randomUUID()}.md`); yield* writeTextFile(path, source); - // The selection half, as a host that declares the operations registers it. - yield* registerComponents(SELECTION_REGISTRATIONS); + // The whole vocabulary, as the Plugin registers it for every command. What + // a row declares below is attached inside the execution, over the ordinary + // registration of that same name — so a declaration that failed to reach + // its policy would reach the registered default's missing provider here + // rather than silently resolving to nothing. + yield* registerComponents(COMPOSITION_REGISTRATIONS); if (before !== undefined) { yield* before(); } @@ -187,26 +180,41 @@ function useRoot(): Operation { }); } -/** The registration and the declaration of one name, for comparison. */ +/** + * One declaration of each operation A's retained entry owns. + * + * Built with policy nothing reaches: these rows are about what a declaration + * says, and a constructor that had to perform in order to describe would be + * telling an inspector something only a run can know. + */ +function ownedDeclarations(): readonly IdentityComponent[] { + return [ + pushDeclaration(() => () => { + throw new Error("a described declaration performed"); + }), + issueDeclaration(fakeIssuePolicy(watched())), + ]; +} + +/** The registration and the owned declaration of one name, for comparison. */ function paired(name: string): { registration: ComponentRegistration | undefined; declaration: IdentityComponent | undefined; } { return { registration: COMPOSITION_REGISTRATIONS.find((entry) => entry.name === name), - declaration: compositionDeclarations(ordinaryCompositionPolicy()).find( - (entry) => entry.name === name, - ), + declaration: ownedDeclarations().find((entry) => entry.name === name), }; } -describe("one declaration source for every repository operation", () => { +describe("one registry for every repository operation, and the policy an execution owns", () => { it("A1: describes one component whichever path carries it", function* (): Operation { - // The whole vocabulary is registered ordinarily, and the six operations are - // also offered as declarations. What a reader is told is the same either - // way: same origin, same props, same return contract, same forms, same - // prose. A metadata difference here would be two components under one name. - for (const name of OPERATIONS) { + // The whole vocabulary is registered ordinarily, and an execution that owns + // one of these operations declares that same name again with its own policy + // under it. What a reader is told is the same either way: same origin, same + // props, same return contract, same forms, same prose. A metadata + // difference here would be two components under one name. + for (const name of OWNED) { const { registration, declaration } = paired(name); expect(registration).toBeDefined(); expect(declaration).toBeDefined(); @@ -219,37 +227,38 @@ describe("one declaration source for every repository operation", () => { } }); - it("A1: the selection half carries none of the six, and all twelve names exist once", function* (): Operation { + it("A1: one registry carries all twelve names, the six operations included", function* (): Operation { + // There is no second catalog and no reduced one. Every command that carries + // this vocabulary registers the same twelve, which is what says the names + // exist — to a run, to validation and to `xmd syntax` alike. const registered = COMPOSITION_REGISTRATIONS.map((entry) => entry.name); expect(registered).toHaveLength(12); expect(new Set(registered).size).toBe(12); - // A host that installs its own policy under the six registers everything - // else, so there is no executable default for its declarations to shadow. - const selections = SELECTION_REGISTRATIONS.map((entry) => entry.name); - expect(selections).toHaveLength(6); for (const name of OPERATIONS) { - expect(selections).not.toContain(name); expect(registered).toContain(name); } - // And the two halves together are the whole vocabulary, in catalog order. - expect([...selections, ...OPERATIONS].sort()).toEqual([...registered].sort()); + // What an owning execution adds is policy for the operations it retains, + // under names this registry already has. It establishes no name of its own. + const owned = ownedDeclarations().map((entry) => entry.name); + expect(owned).toEqual([...OWNED]); + for (const name of owned) { + expect(registered).toContain(name); + } }); it("A1: building declarations calls no factory and initializes no policy", function* (): Operation { let built = 0; - const declarations = compositionDeclarations( - policy({ - issue: (site: string, gate: InvocationGate): IssueOperationsApi => { - built += 1; - return fakeIssuePolicy(watched())(site, gate); - }, + const declarations = [ + issueDeclaration((site: string, gate: InvocationGate): IssueOperationsApi => { + built += 1; + return fakeIssuePolicy(watched())(site, gate); }), - ); - expect(declarations).toHaveLength(6); - // Inspection and validation read these. Nothing has been claimed, nothing - // has been bound, and no policy exists yet. + ]; + expect(declarations).toHaveLength(1); + // Inspection and validation read these. Nothing has been authenticated, + // nothing has been bound, and no policy exists yet. expect(built).toBe(0); - expect(declarations.map((entry) => entry.name).sort()).toEqual([...OPERATIONS].sort()); + expect(declarations.map((entry) => entry.name)).toEqual(["Issue"]); }); it("A1: canonical dispatch decides the form, whatever a contextual answer says", function* (): Operation { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 8ba6d2990..5a1cba34e 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3069,13 +3069,14 @@ validation and generation, and a workflow action that executes a document. Six of them reach something no local transaction encloses — `Git.Push`, `PullRequest`, its three evidence reads and `Issue` — and each has exactly one implementation, which asks a semantic feature operation for its work and -receives nothing else. A command whose executions retain nothing named after an -invocation registers all twelve. A command whose executions install their own -policy under those six registers the other six and receives these as -declarations carrying that policy, so no executable default is registered for a -declaration to shadow; `repl` is the one such command. The names, props, forms, -return contracts, origins and prose are the same either way, and a -repository-local component of one of those names is still chosen ahead of it. A +receives nothing else. Every command that carries this vocabulary registers all +sixteen, `repl` included: that registry is what says which names exist, and it is +what a run, `xmd syntax`, `xmd plan` and a REPL entry's preflight all read. An +execution that retains one of those six operations declares that same name again +in its own scope with its own policy under it, where a nearer declaration +shadows the registered default. The names, props, forms, return contracts, +origins and prose are the same either way, and a repository-local component of +one of those names is still chosen ahead of the default. A Workspace attachment installs that run's providers and durable state and declares none of them. Registering them installs no provider, discovers no repository, acquires no diff --git a/specs/repl-spec.md b/specs/repl-spec.md index bbc03999d..e94b2ba65 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -2254,11 +2254,12 @@ resolved against the frame that published its coordinates. *answer* changed, not whether the filesystem did. - `` is the same component here as under every other host: one implementation, the same props, forms and return contract, asking a semantic - issue operation for its work. What an entry supplies is the policy beneath it, - so it is declared to the execution rather than registered as a default — and a - repository component of that name keeps its ordinary precedence over it. What - such a component does not get is the identity an entry's own issue work is - named by. + issue operation for its work. The `repl` command registers it as an ordinary + default like every other command does; what an entry supplies is the policy + beneath it, declared to that entry's own execution over the default — and a + repository component of that name keeps its ordinary precedence over both. + 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 From f31b434d73258a0e39c096e5ce92920bd8af4b1a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 04:08:07 -0400 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=8E=A8=20Say=20the=20surprising=20thi?= =?UTF-8?q?ngs=20once,=20and=20nothing=20obvious=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five modules the component boundary changed had grown a comment above almost every declaration, a module opening that reproduced the specification and an architecture paragraph at each constructor. Repeating the canonical form-dispatch rule four times per file made the one constraint that matters harder to find, not stronger. What stays is what a routine-looking refactor would break: authenticating the invocation in the engine's own frame before the body projects content, arming revocation before the facade can be used, finishing child content before the parent operation or retained envelope, and the pinned initial branch a base falls back to. Each form-dispatch boundary explains itself once, where the dispatch is. Comments only, with one exception: `FacadeRefusal`'s two members no longer carry a doc comment each, so the union fits on one line. Same type, same members. Every `documented()` value, description, prop schema, return schema and refusal sentence is untouched — those are program data that Syntax and the documentation read, not commentary. --- .../git/src/composition/components/GitPush.ts | 50 ++-------- .../git/src/composition/components/Issue.ts | 61 +++--------- .../src/composition/components/PullRequest.ts | 97 ++++--------------- .../components/PullRequestReads.ts | 24 ++--- .../git/src/composition/invocation-binding.ts | 88 ++++------------- 5 files changed, 70 insertions(+), 250 deletions(-) diff --git a/packages/git/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts index 08f908dea..0c2cf51f2 100644 --- a/packages/git/src/composition/components/GitPush.ts +++ b/packages/git/src/composition/components/GitPush.ts @@ -2,23 +2,11 @@ * Publish the contextual checkout's current branch to its Repository origin * (specs/workflow-workspace-spec.md §7.4). * - * `` takes no props or content, renders nothing and binds nothing. * The enclosing Repository and cwd select the checkout, including a linked - * worktree reached through Dir. Remote, branch, commit, refspec and force are not - * authored inputs; reconciliation evidence stays in the journal. - * - * The remote cannot join the local transaction. Shared Git-host reconciliation - * observes first, adopts this exact commit, publishes a proven absence or advances - * a proven ancestor once, and refuses unproven compatibility. It never force-pushes - * or changes upstream tracking. - * - * Git.Switch's failure policy applies. Detached HEAD is a fixed GitOperationError - * before remote contact; missing Repository is an admission failure that printing - * cannot recover. - * - * The form is settled by canonical dispatch rather than read from the - * invocation: an irreversible publication is not something a composable - * `hasContent()` answer may select. + * worktree reached through Dir. Remote, branch, commit, refspec and force are + * not authored inputs, and the remote cannot join the local transaction: what + * reconciliation proves before it publishes belongs to the host policy beneath + * this body and stays in the journal. */ import { cwd } from "@executablemd/runtime"; @@ -39,7 +27,6 @@ import { GitOperationAdmissionError, GitOperationError } from "../errors.ts"; import { boundComponent, unboundComponent } from "../invocation-binding.ts"; import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; -/** The component name, as a document writes it and as a refusal names it. */ export const PUSH = ""; export const props: PropsSchema = { @@ -48,7 +35,6 @@ export const props: PropsSchema = { additionalProperties: false, }; -/** What a reader is told about this component, in one place. */ export const documentation: FirstPartyDocumentation = { description: `Publish the current branch. \`\` pushes its current commit to the same branch ` + @@ -57,15 +43,6 @@ export const documentation: FirstPartyDocumentation = { context: null, }; -/** - * The one implementation, whatever host performs it. - * - * It observes the two facts a document can see — the Repository in scope and - * the contextual working directory — and asks the publication operation this - * invocation was bound to. Which journal that operation writes to, and whether - * it reconciles a request made an hour ago, belongs to the host policy beneath - * it and appears nowhere here. - */ export const body: ComponentBody = function* GitPush( _props: Record, push: GitApi["push"], @@ -82,13 +59,6 @@ export const body: ComponentBody = function* GitPush( return ""; }; -/** - * What a catalog says about this component, whichever path declares it. - * - * One object, because one component is described one way: a host that - * registers it ordinarily and a host that declares it with policy of its own - * are the same `Git.Push` to a reader. - */ const METADATA = { name: "Git.Push", origin: COMPOSITION_ORIGIN, @@ -98,12 +68,8 @@ const METADATA = { }; /** - * Canonical form dispatch, so the form is settled by the scan before the body - * runs. - * - * A contextual `hasContent()` answer cannot admit a paired publication: the - * chain that answers one is composed by whoever installed a handler, and an - * irreversible external effect is not something a handler may choose. + * The form comes from the scan, not from a contextual `hasContent()` answer: an + * irreversible publication is not something an installed handler may select. */ function dispatched(fn: FunctionComponent): FunctionComponent { return formDispatcher({ @@ -118,14 +84,14 @@ function dispatched(fn: FunctionComponent): FunctionComponent { }); } -/** `Git.Push` as an ordinary registered default, over the public Git Api. */ +/** An ordinary registered default, over whichever provider the host installed. */ export function pushRegistration( operations: (gate: InvocationGate) => GitApi["push"], ): ComponentRegistration { return { ...METADATA, fn: dispatched(unboundComponent(PUSH, operations, body)) }; } -/** `Git.Push` as a declaration, for a host that supplies its own policy. */ +/** A declaration, for an execution that owns this operation's policy. */ export function pushDeclaration(bind: BindOperations): IdentityComponent { return { ...METADATA, diff --git a/packages/git/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts index e6d18e5df..fd6095212 100644 --- a/packages/git/src/composition/components/Issue.ts +++ b/packages/git/src/composition/components/Issue.ts @@ -18,12 +18,8 @@ * refusals; provider conflicts, ambiguity, availability and ceiling failures keep * the provider's own vocabulary. * - * Which form this is comes from canonical dispatch rather than from the - * invocation: an irreversible external effect is not something a composable - * `hasContent()` answer may select. What a read asks for and what an upsert - * asks for are decided from the props alone, before a tracker is read and - * before the boundary is reached — and the host beneath this component decides - * how the operation it asks for is performed, retained and replayed. + * The host beneath this component decides how the operation it asks for is + * performed, retained and replayed. */ import { content, documented, formDispatcher } from "@executablemd/core"; @@ -55,7 +51,6 @@ import type { IssueDetails, IssueInput } from "../../issue/api.ts"; import { normalizedTags } from "../../issue/records.ts"; import { IssueContentError, IssueProtocolError, IssueTrackerError } from "../../issue/errors.ts"; -/** The component name, as a document writes it and as a refusal names it. */ const ISSUE_ELEMENT = ""; export const props: PropsSchema = { @@ -124,19 +119,12 @@ export const returns: ReturnsSchema = { ], }; -/** What the shape of this invocation asks for. */ type IssueMode = "read" | "upsert"; /** - * Which of the two this invocation is, or the refusal that says why neither. - * - * 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. Canonical form dispatch enters the - * body for the form the scan recorded, and that body states it here — so the - * fact is established somewhere nothing composable can answer for it. + * Decided from the props and from `paired` — the form the scan recorded, which + * the dispatched body states here — before a tracker is read or the boundary is + * reached. */ function issueMode(props: Record, paired: boolean): IssueMode { const url = typeof props.url === "string" && props.url !== ""; @@ -186,12 +174,8 @@ function issueMode(props: Record, paired: boolean): IssueMode { ); } -/** - * 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. - */ +/** No tracker is consulted: the URL is the identity, and one written around a + * read has nothing to add to it. */ function issueReadRequest(props: Record): IssueReadInvocation { const url = canonicalIssueTarget(props.url); if (url === undefined) { @@ -205,14 +189,8 @@ function issueReadRequest(props: Record): IssueReadInvocation { } /** - * 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. + * The description renders before the destination is read, so a body that failed + * to render stops this element before a tracker is reached. */ function* issueUpsertRequest(props: Record): Operation { const description = yield* content(); @@ -241,7 +219,6 @@ function* issueUpsertRequest(props: Record): Operation`. */ export const documentation: FirstPartyDocumentation = { description: `Read or file an issue. \`\` reads one. ` + @@ -251,7 +228,6 @@ export const documentation: FirstPartyDocumentation = { context: "The issue's description, for the form that files one.", }; -/** What a read binds, as a document reads it. */ function issueReadBinding(details: IssueDetails): Json { return { url: details.url, @@ -262,13 +238,6 @@ function issueReadBinding(details: IssueDetails): Json { }; } -/** - * The one read implementation, whatever host performs it. - * - * Entered by canonical dispatch for the self-closing form, so the props checks - * below are this element's refusals rather than a second decision about which - * of the two questions is being asked. - */ const readBody: ComponentBody = function* Issue( props: Record, operations: IssueOperationsApi, @@ -278,7 +247,6 @@ const readBody: ComponentBody = function* Issue( return issueReadBinding(details); }; -/** The one upsert implementation, on the same terms. */ const upsertBody: ComponentBody = function* Issue( props: Record, operations: IssueOperationsApi, @@ -291,7 +259,6 @@ const upsertBody: ComponentBody = function* Issue( return { url: reference.url }; }; -/** What a catalog says about this component, whichever path declares it. */ const METADATA = { name: "Issue", origin: COMPOSITION_ORIGIN, @@ -302,14 +269,15 @@ const METADATA = { }; /** - * Canonical form dispatch, so which of the two external effects this element - * asks for is settled by the scan before the body runs. + * Which of the two external effects this element asks for comes from the scan, + * not from a contextual `hasContent()` answer: an irreversible effect is not + * something an installed handler may select. */ function dispatched(read: FunctionComponent, upsert: FunctionComponent): FunctionComponent { return formDispatcher({ forms: "both", "self-closing": read, paired: upsert }); } -/** `Issue` as an ordinary registered default, over the public IssueOperations Api. */ +/** An ordinary registered default, over whichever provider the host installed. */ export function issueRegistration( operations: (gate: InvocationGate) => IssueOperationsApi, ): ComponentRegistration { @@ -322,7 +290,7 @@ export function issueRegistration( }; } -/** `Issue` as a declaration, for a host that supplies its own policy. */ +/** A declaration, for an execution that owns this operation's policy. */ export function issueDeclaration(bind: BindOperations): IdentityComponent { return { ...METADATA, @@ -334,7 +302,6 @@ export function issueDeclaration(bind: BindOperations): Iden }; } -/** The discriminator this prop names, or `undefined` when it names none. */ function named(value: Json | undefined): string | undefined { if (value === undefined) { return undefined; diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index 1f670f3ba..fa922e943 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -14,61 +14,21 @@ * * ``` * - * `title` is required, `number` defaults to none, `base` defaults to the - * Repository's recorded initial branch, `draft` defaults to false, and the - * rendered content is the body. The head is the branch the selected checkout is - * on — decided the way §7.1 decides every Git operation's place, by the - * enclosing `` and the contextual working directory — so there is no - * `head`, `repository`, `remote` or `provider` prop to say it a second way. + * The head is the branch the selected checkout is on, decided the way §7.1 + * decides every Git operation's place, so there is no `head`, `repository`, + * `remote` or `provider` prop to say it a second way. Whether the document + * named a number is the document's own word rather than a search: without one + * it asks for a pull request from this head to this base, with one it asks for + * that pull request, and a number belonging elsewhere is a conflict rather than + * a rewrite onto unrelated state. * - * ## An upsert over one explicit identity - * - * Without a number the document is asking for *a pull request from this head to - * this base*: one is created, or the compatible one an interrupted earlier - * attempt already created is adopted. With a number it is asking for *that pull - * request*: its title, body, draft state and base are brought to what this - * invocation says, and if they already say it, nothing is performed and the - * no-op is recorded. - * - * Which of the two it is, is the document's own word rather than a search. A - * number that names a pull request belonging to another Repository, opened from - * another head, or no longer open is a conflict — never a rewrite onto - * unrelated state. The head is never rewritten either: an update moves the four - * fields it names and nothing else. - * - * ## It publishes nothing - * - * A pull request is a statement about a branch that already exists on the Git - * host, and this component never makes one exist. `` is written - * explicitly, and the run must already hold that Push's own successful result - * for this exact Repository, head branch, destination ref and commit — before a - * creation and before an update alike. Direct remote observation is not a - * substitute: a branch this run finds at the host may have been put there by - * anything, and opening or repointing a pull request for it would be publishing - * somebody else's work under this run's name. - * - * ## What it produces - * - * Stable evidence of what this effect settled on, bound through `as`: the - * filtered Repository identity, the provider's own stable identity for the pull - * request, its number, its URL, its open state, and the head and base commits - * of the snapshot the reconciliation finished at. Reviews, comments, checks, - * labels, merge state and later edits are separate reads rather than freshness - * smuggled into the result — a replayed run hands back exactly what the run - * recorded, whatever the pull request looks like now. - * - * ## Failure - * - * Content expands first and completely, so a body that failed to render stops - * everything this component owns before a Repository is even observed. After - * that, a missing Repository context, a checkout whose HEAD names no branch and - * missing, conflicting or unreadable Push evidence are all decided locally, - * before the Git host exists in the story, and each fails the run rather than - * printing: a later sibling must not run as though a pull request had been - * opened. What the Git host itself answers — a conflict, an ambiguity, an - * unavailability — travels under §10.2's shared vocabulary. + * It publishes nothing. A pull request is a statement about a branch that + * already exists at the Git host, and the run must already hold ``'s + * own successful result for this exact Repository, head, destination ref and + * commit. Direct remote observation is not a substitute: a branch found at the + * host may have been put there by anything, and opening a pull request for it + * would be publishing somebody else's work under this run's name. */ - import { cwd } from "@executablemd/runtime"; import { content, documented, hasContent } from "@executablemd/core"; import type { @@ -87,7 +47,6 @@ import { pullRequestResultJson } from "../pull-request-records.ts"; import { unboundComponent } from "../invocation-binding.ts"; import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; -/** The component name, as a document writes it and as a refusal names it. */ export const PULL_REQUEST_ELEMENT = ""; export const props: PropsSchema = { @@ -145,7 +104,6 @@ export const returns: ReturnsSchema = { additionalProperties: false, }; -/** What the catalog says about ``. */ export const documentation: FirstPartyDocumentation = { description: `Open or update a pull request. \`…\` uses its ` + @@ -154,24 +112,14 @@ export const documentation: FirstPartyDocumentation = { context: "The pull request's body.", }; -/** - * The one implementation, whatever host performs it. - * - * It expands its own content, observes the Repository in scope and the - * contextual working directory, normalizes what the document wrote and asks the - * upsert operation this invocation was bound to. Whether that operation saves - * its request, proves a publication or restores a completed one belongs to the - * host policy beneath it. - */ export const body: ComponentBody = function* PullRequest( props: Record, operations: PullRequestOperationsApi, ): Operation { - // Before anything of this element's own exists — before a Repository is - // observed, before a checkout is selected, before retained Push evidence is - // read and long before a Git host is contacted. The body is what the pull - // request would say, and a body that never finished rendering must not become - // one that was published. + // Before a Repository is observed, a checkout selected or retained Push + // evidence read: a body that never finished rendering must not become one + // that was published, and a child that retains work of its own finishes at + // its own journal position first. const body = (yield* hasContent()) ? yield* content() : ""; const repository = yield* selectedRepository(); @@ -185,13 +133,11 @@ export const body: ComponentBody = function* PullReque const result = yield* operations.upsert({ pullRequest: { - // Normalized once, here: absence is `null` from this point on, because - // the durable request is JSON and a member that is sometimes missing - // would be a second shape rather than a value. + // Absence is `null` from here on: the durable request is JSON, and a + // member that is sometimes missing would be a second shape. number: typeof props.number === "number" ? props.number : null, title: typeof props.title === "string" ? props.title : "", - // Verbatim. What a document wrote is the pull request's body, and - // trimming or reflowing it here would publish something nobody authored. + // Verbatim: trimming or reflowing it would publish something nobody wrote. body, draft: props.draft === true, // The Repository's own initial branch, pinned when it was selected, @@ -207,7 +153,6 @@ export const body: ComponentBody = function* PullReque return pullRequestResultJson(result); }; -/** What a catalog says about this component, whichever path declares it. */ const METADATA = { name: "PullRequest", origin: COMPOSITION_ORIGIN, @@ -217,7 +162,7 @@ const METADATA = { ...documented(documentation), }; -/** `PullRequest` as an ordinary registered default, over the public Api. */ +/** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestRegistration( operations: (gate: InvocationGate) => PullRequestOperationsApi, ): ComponentRegistration { diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 05d29bad6..5dece9085 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -8,14 +8,8 @@ * the host owns endpoint, credential and pagination configuration. Each requires * `as` so an Agent prompt can explicitly render the evidence it receives. * - * Providers compose ordinary PullRequestAPI middleware. Once a provider matches, - * its normalized answer, validation and refusal are final. Each collection has its - * own durable effect, replay and failure, so a partial answer cannot masquerade as - * all three collections. - * - * Self-closing form is declared to canonical dispatch. Neither replaceable - * `hasContent()` nor a caller-made invocation object can turn a paired element - * into an evidence read. + * Each collection has its own durable effect, replay and failure, so a partial + * answer cannot masquerade as all three collections. */ import { documented, formDispatcher } from "@executablemd/core"; @@ -40,7 +34,6 @@ import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; import { unboundComponent } from "../invocation-binding.ts"; import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; -/** The component names, as a document writes them and a refusal names them. */ export const REVIEWS_ELEMENT = ""; export const COMMENTS_ELEMENT = ""; export const CHECKS_ELEMENT = ""; @@ -175,8 +168,6 @@ function read(kind: PullRequestReadKind, element: string): ComponentBody, operations: PullRequestOperationsApi, ): Operation { - // The props schema refuses a missing or empty `url` before this function is - // entered, and therefore before any provider is asked anything. // Canonicalized before any provider sees it, so the component, the durable // request and every adapter read one answer. const url = canonicalPullRequestUrl(props.url); @@ -211,6 +202,11 @@ function read(kind: PullRequestReadKind, element: string): ComponentBody> = Object.freeze( { reviews: { @@ -257,7 +252,6 @@ const DOCUMENTATION: Readonly> = Object.freeze({ reviews: "PullRequest.Reviews", comments: "PullRequest.Comments", @@ -276,7 +270,6 @@ const RETURNS: Readonly> = Object.fre checks: checksReturns, }); -/** What a catalog says about one read, whichever path declares it. */ function metadata(kind: PullRequestReadKind) { return { name: NAMES[kind], @@ -288,7 +281,7 @@ function metadata(kind: PullRequestReadKind) { }; } -/** One evidence read as an ordinary registered default, over the public Api. */ +/** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestReadRegistration( kind: PullRequestReadKind, operations: (gate: InvocationGate) => PullRequestOperationsApi, @@ -302,7 +295,6 @@ export function pullRequestReadRegistration( }; } -/** The three kinds, in the order a catalog lists them. */ export const PULL_REQUEST_READ_KINDS: readonly PullRequestReadKind[] = Object.freeze([ "reviews", "comments", diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index 36be31401..d9b0d3af4 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -1,59 +1,26 @@ /** - * The one handoff between a repository component's invocation and the host - * policy that performs it. + * Two wrappers around one authored repository operation's body: `boundComponent` + * for a host that retains work named after the invocation, `unboundComponent` + * for one that does not. * - * Every authored repository operation — ``, ``, - * `` and the three pull-request evidence reads — has one - * implementation, and that implementation asks a semantic feature operation for - * its work. What differs between an ordinary run, a workflow run and a REPL - * entry is not the component: it is how the operation it asks is performed, - * retained and admitted. This is where those two meet. + * Both hand the body a facade of semantic operations and nothing else — no + * authenticator, no owner, no site, no native access, no record codec. The site + * `boundComponent` authenticates stays private to the policy it is handed to. * - * ## What the binding owns, and what it never touches - * - * It authenticates the actual invocation canonical core issued, once, in the - * engine frame the component was entered in — before the component projects any - * content, because a projecting ancestor is deliberately ineligible for one. - * It then builds this invocation's own operation facade from the host's policy - * and runs the one shared body against it. - * - * It interprets no prop, expands no content, names no durable record and reads - * nothing of the document. The site it authenticated is private to the policy - * it hands it to: no facade method answers with it, no request carries it and - * no Context holds it. - * - * ## The facade ends with its invocation - * - * Revocation is armed before the facade can be used, so an invocation halted - * while it is being built leaves nothing usable behind, and it runs on success, - * failure and cancellation alike. A facade kept past its own invocation - * performs nothing, and one authored operation performs one retained - * operation — a second call through the same facade is refused rather than - * repeating external work this invocation has already asked for. + * The facade is single use and ends with its invocation. That is a boundary on + * what one authored element may ask the host for, not a receipt: it is marked + * used before the work runs, so it never says an external effect was + * acknowledged. */ - import { ensure } from "effection"; import type { Operation } from "effection"; import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; import type { IdentityClaimant } from "@executablemd/core/host"; import type { Json } from "@executablemd/durable-streams"; -/** Why a facade refused rather than performing. */ -export type FacadeRefusal = - /** The invocation that owns this facade has finished. */ - | "ended" - /** This invocation already performed its one retained operation. */ - | "spent"; +export type FacadeRefusal = "ended" | "spent"; -/** - * A facade asked to perform where it names no invocation to perform for. - * - * One sentence for one boundary, parameterized by the element that was written, - * because this is the binding's own refusal rather than any feature's: neither - * condition is something a document can arrange, a provider can answer or a - * tracker can explain. Every feature keeps its own vocabulary for every refusal - * it does decide. - */ +/** The binding's own refusal: neither condition is one a document can arrange. */ export class BoundOperationError extends Error { override name = "BoundOperationError"; @@ -72,52 +39,37 @@ export class BoundOperationError extends Error { } /** - * What a host policy performs one invocation's work through. - * * Handed to the policy rather than applied around it, because only the policy - * knows which of its members is the operation an authored element asks for: a - * facade of two operations where the document wrote one is still one - * invocation, and the second member exists to be refused. + * knows which of its members the authored element asked for: a facade of two + * operations where the document wrote one is still one invocation. */ export interface InvocationGate { - /** Perform this invocation's one retained operation. */ once(operation: () => Operation): Operation; } -/** How one host builds the operations one authenticated invocation may use. */ export type BindOperations = (site: string, gate: InvocationGate) => Operations; -/** The one authored implementation of a repository operation. */ export type ComponentBody = ( props: Record, operations: Operations, ) => Operation; -/** One authored operation, as its declaration states it. */ export interface BoundComponent { /** The element a refusal names, as a document writes it. */ readonly element: string; - /** The host policy this execution selected for this operation. */ readonly bind: BindOperations; - /** The one implementation, which receives only semantic operations. */ readonly body: ComponentBody; } -/** Whether this invocation's facade is usable, and whether it has been used. */ interface Live { open: boolean; used: boolean; } /** - * The implementation an ordinary registration carries for one authored - * operation. - * - * The same body, over the same kind of facade, for a host that retains nothing - * named after an invocation. No identity is authenticated because there is - * nothing for one to name: the operations are the public feature Apis, and whichever - * provider the host installed is what answers. The facade still ends with the - * invocation, so a body that kept one performs nothing afterwards. + * No identity is authenticated here because there is nothing for one to name: + * the operations are the public feature Apis, and whichever provider the host + * installed answers them. */ export function unboundComponent( element: string, @@ -138,8 +90,6 @@ export function unboundComponent( } /** - * The implementation a declaration registers for one authored operation. - * * Built once per execution, from the authenticator that execution minted for * this component name, and invoked by the engine with the invocation it issued. */ @@ -151,8 +101,8 @@ export function boundComponent( props: Record, invocation: ComponentInvocation, ): Operation { - // Authenticated here, in the frame the engine entered, and before the body - // reaches any content of its own. + // In the frame the engine entered, and before the body reaches any content + // of its own: a projecting ancestor is ineligible for an invocation. const site = yield* claim(invocation); const live: Live = { open: false, used: false }; // Armed before the facade exists, because an invocation halted while it is From 3d1c8bd16e2c5da19a013bf8e3c4b71f1c0cd17f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 04:21:19 -0400 Subject: [PATCH 6/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Authenticate=20the=20i?= =?UTF-8?q?nvocation,=20and=20guard=20nothing=20else=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding had grown a facade framework around the one thing it is for. It built a gate per invocation, tracked `open` and `used` on it, armed an `ensure` to close it, and refused a late or second call with its own error type — and every ordinary registration, which retains nothing and has no identity to name, was wrapped in the same machinery. None of that was guarding a real escape. An operation runs in the scope of the invocation that asked for it, and Effection already ends it there; the single-use rule was marked before the work ran, so it never said an external effect had been acknowledged. What it did do was make the interesting line — authenticate the invocation core issued, in the engine's own frame, before the body projects content — one of thirty. So the binding is now that line plus the handoff: authenticate, ask the feature-owned policy for this invocation's operations, run the shared body. Ordinary registrations call the public feature operations directly, through a three-line adapter that supplies the body's second argument. Gone: `InvocationGate`, `FacadeRefusal`, `BoundOperationError`, `performed()`, the `open`/`used` state, `unboundComponent` and the four retired exports. The two rows that manufactured a saved facade to watch it refuse are gone with them — they established the guard's implementation, not a production escape. The cancellation row keeps the part that is about the product: the halt stops the suspended upsert, joins it, and nothing performs twice. Real retention, delegation, reconciliation and native cancellation stay where they are proved, in the owned-worker and native suites. Owners, sites, record bytes, keys, fingerprints and coroutine positions are unchanged, and so is every `documented()` value, schema and refusal sentence. --- architecture.md | 30 ++- packages/git/mod.ts | 7 - .../git/src/composition/components/GitPush.ts | 13 +- .../git/src/composition/components/Issue.ts | 17 +- .../src/composition/components/PullRequest.ts | 11 +- .../components/PullRequestReads.ts | 10 +- packages/git/src/composition/installation.ts | 33 ++- .../git/src/composition/invocation-binding.ts | 123 +++-------- .../src/deno/retained-repository/issues.ts | 16 +- .../git/src/deno/retained-repository/push.ts | 18 +- .../composition-component-binding.test.ts | 202 ++++-------------- 11 files changed, 129 insertions(+), 351 deletions(-) diff --git a/architecture.md b/architecture.md index a7c1b7e00..3ec4fb8a6 100644 --- a/architecture.md +++ b/architecture.md @@ -5212,28 +5212,26 @@ projects what came back. None of them receives an authenticator, an owner, a site, native access, a policy object, a record codec or any publication state, and none of them names a journal record. -Between the component and the host is one feature-owned binding. For a host that +Between the component and the host is one feature-owned handoff. For a host that retains work named after an invocation it authenticates the actual invocation canonical core issued — once, in the engine frame the component was entered in, before the body projects any content, because a projecting ancestor is -deliberately ineligible for one — then builds that invocation's own operation -facade from the host's policy and runs the one body against it. The site it -authenticated is private to the policy it is handed to: no facade method answers -with it, no public request carries it, and no Context holds it. - -Revocation is armed before the facade can be used and runs on success, failure -and cancellation alike, so an invocation halted while its facade is being built -leaves nothing usable behind. A facade kept past its own invocation performs -nothing, and one authored element performs one external operation: a second call -through the same facade is refused rather than repeating work this invocation -has already asked for. - -A host that retains nothing named after an invocation uses the same bodies over -the same kind of facade with no identity authenticated, because there is nothing -for one to name. That is what makes an ordinary run, a workflow run, a +deliberately ineligible for one — hands that site to the host's own policy, and +runs the one body against the operations the policy answers with. The site is +private to that policy: no operation answers with it, no public request carries +it, and no Context holds it. + +A host that retains nothing named after an invocation runs the same bodies over +the public feature operations, with no identity authenticated, because there is +nothing for one to name. That is what makes an ordinary run, a workflow run, a provider-absent runtime and a REPL entry four policies rather than four components. +The lifetime of what a policy hands over is the ordinary one: an operation runs +in the scope of the invocation that asked for it, and cancellation, teardown and +the joined cleanup of any attempt beneath it are owned by that scope rather than +by a guard around the handoff. + #### Command declaration ownership Command declarations contain everything one command declares, in one order and under one diff --git a/packages/git/mod.ts b/packages/git/mod.ts index fba5cbe40..d709ab2b4 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -239,13 +239,6 @@ export { compositionDocumentation, useCompositionComponents, } from "./src/composition/installation.ts"; -export { BoundOperationError } from "./src/composition/invocation-binding.ts"; -export type { - BindOperations, - ComponentBody, - FacadeRefusal, - InvocationGate, -} from "./src/composition/invocation-binding.ts"; export type { IssueDetails, IssueInput, diff --git a/packages/git/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts index 0c2cf51f2..44db2cf06 100644 --- a/packages/git/src/composition/components/GitPush.ts +++ b/packages/git/src/composition/components/GitPush.ts @@ -24,8 +24,8 @@ import type { GitApi } from "../git-api.ts"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { GitOperationAdmissionError, GitOperationError } from "../errors.ts"; -import { boundComponent, unboundComponent } from "../invocation-binding.ts"; -import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { boundComponent, registeredComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; export const PUSH = ""; @@ -85,17 +85,14 @@ function dispatched(fn: FunctionComponent): FunctionComponent { } /** An ordinary registered default, over whichever provider the host installed. */ -export function pushRegistration( - operations: (gate: InvocationGate) => GitApi["push"], -): ComponentRegistration { - return { ...METADATA, fn: dispatched(unboundComponent(PUSH, operations, body)) }; +export function pushRegistration(push: GitApi["push"]): ComponentRegistration { + return { ...METADATA, fn: dispatched(registeredComponent(push, body)) }; } /** A declaration, for an execution that owns this operation's policy. */ export function pushDeclaration(bind: BindOperations): IdentityComponent { return { ...METADATA, - factory: (claim: IdentityClaimant) => - dispatched(boundComponent(claim, { element: PUSH, bind, body })), + factory: (claim: IdentityClaimant) => dispatched(boundComponent(claim, bind, body)), }; } diff --git a/packages/git/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts index fd6095212..8aeed2e4b 100644 --- a/packages/git/src/composition/components/Issue.ts +++ b/packages/git/src/composition/components/Issue.ts @@ -34,8 +34,8 @@ import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/hos import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; -import { boundComponent, unboundComponent } from "../invocation-binding.ts"; -import type { BindOperations, ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { boundComponent, registeredComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; import { currentIssueTracker } from "../../issue/context.ts"; import { canonicalIssueTarget, @@ -278,14 +278,12 @@ function dispatched(read: FunctionComponent, upsert: FunctionComponent): Functio } /** An ordinary registered default, over whichever provider the host installed. */ -export function issueRegistration( - operations: (gate: InvocationGate) => IssueOperationsApi, -): ComponentRegistration { +export function issueRegistration(operations: IssueOperationsApi): ComponentRegistration { return { ...METADATA, fn: dispatched( - unboundComponent(ISSUE_ELEMENT, operations, readBody), - unboundComponent(ISSUE_ELEMENT, operations, upsertBody), + registeredComponent(operations, readBody), + registeredComponent(operations, upsertBody), ), }; } @@ -295,10 +293,7 @@ export function issueDeclaration(bind: BindOperations): Iden return { ...METADATA, factory: (claim: IdentityClaimant) => - dispatched( - boundComponent(claim, { element: ISSUE_ELEMENT, bind, body: readBody }), - boundComponent(claim, { element: ISSUE_ELEMENT, bind, body: upsertBody }), - ), + dispatched(boundComponent(claim, bind, readBody), boundComponent(claim, bind, upsertBody)), }; } diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index fa922e943..b7419c49a 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -44,8 +44,8 @@ import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; -import { unboundComponent } from "../invocation-binding.ts"; -import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { registeredComponent } from "../invocation-binding.ts"; +import type { ComponentBody } from "../invocation-binding.ts"; export const PULL_REQUEST_ELEMENT = ""; @@ -164,10 +164,7 @@ const METADATA = { /** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestRegistration( - operations: (gate: InvocationGate) => PullRequestOperationsApi, + operations: PullRequestOperationsApi, ): ComponentRegistration { - return { - ...METADATA, - fn: unboundComponent(PULL_REQUEST_ELEMENT, operations, body), - }; + return { ...METADATA, fn: registeredComponent(operations, body) }; } diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 5dece9085..bb49775ea 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -31,8 +31,8 @@ import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; -import { unboundComponent } from "../invocation-binding.ts"; -import type { ComponentBody, InvocationGate } from "../invocation-binding.ts"; +import { registeredComponent } from "../invocation-binding.ts"; +import type { ComponentBody } from "../invocation-binding.ts"; export const REVIEWS_ELEMENT = ""; export const COMMENTS_ELEMENT = ""; @@ -284,14 +284,12 @@ function metadata(kind: PullRequestReadKind) { /** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestReadRegistration( kind: PullRequestReadKind, - operations: (gate: InvocationGate) => PullRequestOperationsApi, + operations: PullRequestOperationsApi, ): ComponentRegistration { const element = ELEMENTS[kind]; return { ...metadata(kind), - fn: formDispatcher( - declare(unboundComponent(element, operations, read(kind, element)), element), - ), + fn: formDispatcher(declare(registeredComponent(operations, read(kind, element)), element)), }; } diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index ae60fe905..547b4867b 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -63,7 +63,6 @@ import { IssueOperations } from "../issue/operations.ts"; import type { IssueOperationsApi } from "../issue/operations.ts"; import { PullRequestOperations } from "./pull-request-operations.ts"; import type { PullRequestOperationsApi } from "./pull-request-operations.ts"; -import type { InvocationGate } from "./invocation-binding.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; import GitSwitch, { props as gitSwitchProps } from "./components/GitSwitch.ts"; @@ -99,23 +98,23 @@ import { * Each one is the public feature Api, so every handler a document, a plugin or * a host composed answers exactly as it did before any of this was declared — * and where nothing is installed, each Api's own default refuses. No site is - * taken because none is authenticated: a profile that retains no - * invocation-named work has nothing for a site to name, and inventing one would - * be a name for work nobody is doing. + * authenticated, because a profile that retains no invocation-named work has + * nothing for one to name. */ -const ORDINARY = { - push: - (gate: InvocationGate): GitApi["push"] => - (invocation) => - gate.once(() => Git.operations.push(invocation)), - issue: (gate: InvocationGate): IssueOperationsApi => ({ - read: (invocation) => gate.once(() => IssueOperations.operations.read(invocation)), - upsert: (invocation) => gate.once(() => IssueOperations.operations.upsert(invocation)), - }), - pullRequest: (gate: InvocationGate): PullRequestOperationsApi => ({ - read: (invocation) => gate.once(() => PullRequestOperations.operations.read(invocation)), - upsert: (invocation) => gate.once(() => PullRequestOperations.operations.upsert(invocation)), - }), +const ORDINARY: { + readonly push: GitApi["push"]; + readonly issue: IssueOperationsApi; + readonly pullRequest: PullRequestOperationsApi; +} = { + push: (invocation) => Git.operations.push(invocation), + issue: { + read: (invocation) => IssueOperations.operations.read(invocation), + upsert: (invocation) => IssueOperations.operations.upsert(invocation), + }, + pullRequest: { + read: (invocation) => PullRequestOperations.operations.read(invocation), + upsert: (invocation) => PullRequestOperations.operations.upsert(invocation), + }, }; /** diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index d9b0d3af4..0cfcf2d01 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -1,101 +1,50 @@ /** - * Two wrappers around one authored repository operation's body: `boundComponent` - * for a host that retains work named after the invocation, `unboundComponent` - * for one that does not. + * The handoff between a repository component's invocation and the host policy + * that performs it, for the six operations no local transaction encloses. * - * Both hand the body a facade of semantic operations and nothing else — no - * authenticator, no owner, no site, no native access, no record codec. The site - * `boundComponent` authenticates stays private to the policy it is handed to. + * `boundComponent` is for a host that retains work named after the invocation: + * it authenticates the invocation core issued and hands the site to + * feature-owned policy, which answers with this invocation's own semantic + * operations. An ordinary registration needs none of that and calls the public + * feature operations directly. * - * The facade is single use and ends with its invocation. That is a boundary on - * what one authored element may ask the host for, not a receipt: it is marked - * used before the work runs, so it never says an external effect was - * acknowledged. + * Either way the body receives semantic operations and nothing else — no + * authenticator, no owner, no site, no native access, no record codec — and the + * site stays private to the policy it was handed to. */ -import { ensure } from "effection"; + import type { Operation } from "effection"; import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; import type { IdentityClaimant } from "@executablemd/core/host"; import type { Json } from "@executablemd/durable-streams"; -export type FacadeRefusal = "ended" | "spent"; - -/** The binding's own refusal: neither condition is one a document can arrange. */ -export class BoundOperationError extends Error { - override name = "BoundOperationError"; - - readonly reason: FacadeRefusal; - - constructor(element: string, reason: FacadeRefusal) { - super( - reason === "ended" - ? `${element} belongs to an invocation that has finished, so there is no invocation for ` + - "this operation to be performed under. Nothing was asked of the host" - : `${element} has already performed its one external operation, so performing again ` + - "would repeat work this invocation acknowledged. Nothing was asked of the host", - ); - this.reason = reason; - } -} - -/** - * Handed to the policy rather than applied around it, because only the policy - * knows which of its members the authored element asked for: a facade of two - * operations where the document wrote one is still one invocation. - */ -export interface InvocationGate { - once(operation: () => Operation): Operation; -} - -export type BindOperations = (site: string, gate: InvocationGate) => Operations; - +/** The one authored body of a repository operation. */ export type ComponentBody = ( props: Record, operations: Operations, ) => Operation; -export interface BoundComponent { - /** The element a refusal names, as a document writes it. */ - readonly element: string; - readonly bind: BindOperations; - readonly body: ComponentBody; -} - -interface Live { - open: boolean; - used: boolean; -} +/** How a host answers with one authenticated invocation's own operations. */ +export type BindOperations = (site: string) => Operations; -/** - * No identity is authenticated here because there is nothing for one to name: - * the operations are the public feature Apis, and whichever provider the host - * installed answers them. - */ -export function unboundComponent( - element: string, - operations: (gate: InvocationGate) => Operations, +/** The body an ordinary registration carries, over the public feature operations. */ +export function registeredComponent( + operations: Operations, body: ComponentBody, ): FunctionComponent { - return function* (props: Record): Operation { - const live: Live = { open: false, used: false }; - yield* ensure(() => { - live.open = false; - }); - const facade = operations({ - once: (operation: () => Operation): Operation => performed(live, element, operation), - }); - live.open = true; - return yield* body(props, facade); - }; + return (props: Record) => body(props, operations); } /** - * Built once per execution, from the authenticator that execution minted for - * this component name, and invoked by the engine with the invocation it issued. + * The body a declaration carries, over the policy the declaring execution owns. + * + * `claim` comes from `IdentityComponent.factory`, so it is the authenticator + * that execution minted for this component name. */ export function boundComponent( claim: IdentityClaimant, - bound: BoundComponent, + bind: BindOperations, + body: ComponentBody, ): FunctionComponent { return function* ( props: Record, @@ -104,28 +53,6 @@ export function boundComponent( // In the frame the engine entered, and before the body reaches any content // of its own: a projecting ancestor is ineligible for an invocation. const site = yield* claim(invocation); - const live: Live = { open: false, used: false }; - // Armed before the facade exists, because an invocation halted while it is - // registering this would unwind with no revocation on it at all. - yield* ensure(() => { - live.open = false; - }); - const operations = bound.bind(site, { - once: (operation: () => Operation): Operation => - performed(live, bound.element, operation), - }); - live.open = true; - return yield* bound.body(props, operations); + return yield* body(props, bind(site)); }; } - -function* performed(live: Live, element: string, operation: () => Operation): Operation { - if (!live.open) { - throw new BoundOperationError(element, "ended"); - } - if (live.used) { - throw new BoundOperationError(element, "spent"); - } - live.used = true; - return yield* operation(); -} diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/git/src/deno/retained-repository/issues.ts index 7724be7c3..5a0df96c3 100644 --- a/packages/git/src/deno/retained-repository/issues.ts +++ b/packages/git/src/deno/retained-repository/issues.ts @@ -151,17 +151,15 @@ export function useEntryIssueTransport(options: GitHubIssuesOptions): Operation< * nothing about which attempt this is. */ export function replIssuePolicy(owner: string): BindOperations { - return (site: string, gate) => ({ + return (site: string) => ({ read: (invocation: IssueReadInvocation) => - gate.once(() => readOwnedIssue({ owner, site, request: invocation }, liveRead(invocation))), + readOwnedIssue({ owner, site, request: invocation }, liveRead(invocation)), upsert: (invocation: IssueUpsertInvocation) => - gate.once(() => - upsertOwnedIssue( - { owner, site, request: invocation }, - liveUpsert( - invocation, - issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", invocation.target), - ), + upsertOwnedIssue( + { owner, site, request: invocation }, + liveUpsert( + invocation, + issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", invocation.target), ), ), }); diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts index 5914e4b7f..abbdd71fa 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/git/src/deno/retained-repository/push.ts @@ -185,16 +185,14 @@ export function replPushPolicy( owner: string, access: ReplPushAccess, ): BindOperations { - return (site: string, gate) => (request: GitPushInvocation) => - gate.once(() => - publish({ - owner, - site, - access, - repository: request.repository, - workingDirectory: request.workingDirectory, - }), - ); + return (site: string) => (request: GitPushInvocation) => + publish({ + owner, + site, + access, + repository: request.repository, + workingDirectory: request.workingDirectory, + }); } interface Invocation { diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index 32bb79b43..1f5517602 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -1,12 +1,14 @@ /** - * One registry, one body, and a facade that ends with its invocation. + * One registry, one authored body, and the invocation an owning execution + * authenticates for it. * - * Every row here drives the actual shared components through canonical - * execution, over the whole vocabulary registered as the Plugin registers it. - * What differs between rows is the policy an owning execution declares - * underneath: these are neutral constructors and fake policies, so what is - * proven is the binding and the bodies rather than any host's retention. The - * real native assembly is covered by the CLI and native suites. + * Every row drives the actual shared components through canonical execution, + * over the whole vocabulary registered as the Plugin registers it. What differs + * between rows is the policy an owning execution declares underneath: these are + * neutral constructors and fake policies, so what is proven is the authored + * behavior and the identity each invocation runs under rather than any host's + * retention. The real native assembly, its retention and its cancellation are + * covered by the CLI and native suites. * * These rows carry A1, A2 and the binding half of A5 of the component-boundary * repair matrix. @@ -38,8 +40,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { COMPOSITION_REGISTRATIONS } from "../src/composition/installation.ts"; import { pushDeclaration } from "../src/composition/components/GitPush.ts"; import { issueDeclaration } from "../src/composition/components/Issue.ts"; -import { BoundOperationError } from "../src/composition/invocation-binding.ts"; -import type { BindOperations, InvocationGate } from "../src/composition/invocation-binding.ts"; +import type { BindOperations } from "../src/composition/invocation-binding.ts"; // From the subpath that publishes them rather than from the source modules: // these are public types, and reaching them by relative path would bypass the // export map the partition test holds every consumer to. @@ -70,8 +71,6 @@ interface Watched { readonly sites: string[]; /** Each operation performed, in the order the bodies asked for them. */ readonly performed: string[]; - /** The issue facade of the most recent invocation, kept past its own life. */ - kept?: IssueOperationsApi; } function watched(): Watched { @@ -95,25 +94,19 @@ function details(url: string): IssueDetails { * below reaches no service, no journal and no provider. A real one is the * entry's, and the native suites drive that. */ -function fakeIssuePolicy(seen: Watched, hold = false) { - return (site: string, gate: InvocationGate): IssueOperationsApi => { +function fakeIssuePolicy(seen: Watched) { + return (site: string): IssueOperationsApi => { seen.sites.push(site); - const operations: IssueOperationsApi = { - read: (invocation) => - gate.once(function* (): Operation { - seen.performed.push(`read ${invocation.url}`); - return details(invocation.url); - }), - upsert: (invocation) => - gate.once(function* (): Operation { - seen.performed.push(`upsert ${invocation.target}`); - return reference(`${invocation.target}/issues/1`); - }), + return { + read: function* (invocation): Operation { + seen.performed.push(`read ${invocation.url}`); + return details(invocation.url); + }, + upsert: function* (invocation): Operation { + seen.performed.push(`upsert ${invocation.target}`); + return reference(`${invocation.target}/issues/1`); + }, }; - if (hold) { - seen.kept = operations; - } - return operations; }; } @@ -249,9 +242,9 @@ describe("one registry for every repository operation, and the policy an executi it("A1: building declarations calls no factory and initializes no policy", function* (): Operation { let built = 0; const declarations = [ - issueDeclaration((site: string, gate: InvocationGate): IssueOperationsApi => { + issueDeclaration((site: string): IssueOperationsApi => { built += 1; - return fakeIssuePolicy(watched())(site, gate); + return fakeIssuePolicy(watched())(site); }), ]; expect(declarations).toHaveLength(1); @@ -342,124 +335,28 @@ describe("one registry for every repository operation, and the policy an executi expect(new Set(seen.sites).size).toBe(2); }); - it("A2: a facade kept past its invocation performs nothing", function* (): Operation { - const root = yield* useRoot(); - const seen = watched(); - - const ran = yield* run( - root, - [ - ``, - `described`, - ``, - "", - ].join("\n"), - [declaring({ issue: fakeIssuePolicy(seen, true) })], - ); - - expect(ran.refusal).toBe(undefined); - expect(seen.performed).toEqual([`upsert ${TRACKER}`]); - - const kept = seen.kept; - expect(kept).toBeDefined(); - // The invocation is over, so this names nothing to perform under. Both - // members refuse, and the policy beneath was never reached again. - const refusals: unknown[] = []; - for (const attempt of [ - () => kept?.read({ url: `${TRACKER}/issues/1`, provider: undefined }), - () => kept?.upsert({ target: TRACKER, provider: undefined, issue: ISSUE_INPUT }), - ]) { - try { - yield* attempt() ?? suspend(); - } catch (error) { - refusals.push(error); - } - } - expect(refusals).toHaveLength(2); - for (const refusal of refusals) { - expect(refusal).toBeInstanceOf(BoundOperationError); - expect((refusal as BoundOperationError).reason).toBe("ended"); - } - expect(seen.performed).toEqual([`upsert ${TRACKER}`]); - }); - - it("A2: one invocation performs one external operation", function* (): Operation { + it("A5: cancelling an invocation stops the operation it was performing", function* (): Operation { const root = yield* useRoot(); const seen = watched(); - const second: unknown[] = []; + const reached = withResolvers(); - // A policy that performs its upsert and then asks the same facade again, - // which is what a duplicate delegation would look like from underneath. - const twice = (site: string, gate: InvocationGate): IssueOperationsApi => { + // The upsert suspends where a slow service would, and this row halts the + // whole execution while it is standing there. + const suspending = (site: string): IssueOperationsApi => { seen.sites.push(site); return { - read: (invocation) => - gate.once(function* (): Operation { - return details(invocation.url); - }), - *upsert(invocation) { - const first = yield* gate.once(function* (): Operation { - seen.performed.push(`upsert ${invocation.target}`); - return reference(`${invocation.target}/issues/1`); - }); - try { - yield* gate.once(function* (): Operation { - seen.performed.push("repeated"); - return first; - }); - } catch (error) { - second.push(error); - } - return first; + read: function* (invocation): Operation { + return details(invocation.url); + }, + upsert: function* (invocation): Operation { + seen.performed.push(`upsert ${invocation.target}`); + reached.resolve(); + yield* suspend(); + throw new Error("a suspended upsert resumed"); }, }; }; - const ran = yield* run( - root, - [ - ``, - `described`, - ``, - "", - ].join("\n"), - [declaring({ issue: twice })], - ); - - expect(ran.refusal).toBe(undefined); - expect(seen.performed).toEqual([`upsert ${TRACKER}`]); - expect(second).toHaveLength(1); - expect(second[0]).toBeInstanceOf(BoundOperationError); - expect((second[0] as BoundOperationError).reason).toBe("spent"); - }); - - it("A5: a cancelled invocation revokes its facade", function* (): Operation { - const root = yield* useRoot(); - const seen = watched(); - const reached = withResolvers(); - const held: { kept?: IssueOperationsApi } = {}; - - // The upsert suspends inside the facade, and this row halts the whole - // execution while it is standing there. - const suspending = (site: string, gate: InvocationGate): IssueOperationsApi => { - seen.sites.push(site); - const operations: IssueOperationsApi = { - read: (invocation) => - gate.once(function* (): Operation { - return details(invocation.url); - }), - upsert: (invocation) => - gate.once(function* (): Operation { - seen.performed.push(`upsert ${invocation.target}`); - reached.resolve(); - yield* suspend(); - throw new Error("a suspended upsert resumed"); - }), - }; - held.kept = operations; - return operations; - }; - yield* scoped(function* (): Operation { const task = yield* spawn(() => run( @@ -480,32 +377,13 @@ describe("one registry for every repository operation, and the policy an executi throw new Error("the upsert never reached its policy"); })(), ]); + // Joined here: the halt returns once the suspended upsert has been torn + // down, so what follows cannot be read before cancellation finished. yield* task.halt(); }); + // The upsert was entered once, never resumed and never performed again. expect(seen.performed).toEqual([`upsert ${TRACKER}`]); - - // Nothing of that invocation survives it. Revocation was armed before the - // facade could be used, so a cancellation part-way through leaves a facade - // that performs nothing rather than one that still reaches the policy. - const kept = held.kept; - expect(kept).toBeDefined(); - let refusal: unknown; - try { - yield* kept?.read({ url: `${TRACKER}/issues/1`, provider: undefined }) ?? suspend(); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(BoundOperationError); - expect((refusal as BoundOperationError).reason).toBe("ended"); - expect(seen.performed).toEqual([`upsert ${TRACKER}`]); + expect(seen.sites).toHaveLength(1); }); }); - -/** The issue one refused attempt would have filed, had it been performed. */ -const ISSUE_INPUT = Object.freeze({ - title: "refused", - description: "refused", - tags: Object.freeze([]), - assignee: null, -}); From b3f4df2da6aae244be909e1ed85635315367271a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 04:50:35 -0400 Subject: [PATCH 7/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Ask=20the=20feature=20?= =?UTF-8?q?Api,=20and=20inject=20only=20where=20a=20host=20retains=20(#181?= =?UTF-8?q?,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the six external operations were being handed an operations object that was only ever the feature Api's own. `ORDINARY` built forwarding arrows for it, `registeredComponent` adapted the body to it, and `PullRequest` and its three evidence reads took it as a parameter — all so that a host could substitute policy underneath. No host does: A retains publication and filing, and nothing else. So those four now ask their Api the way every other component in the repository does. `pullRequestRegistration` is a value rather than a constructor, `pullRequestReadRegistration` takes the kind alone, their bodies are module-private again, and `ORDINARY` and `registeredComponent` are gone — `Git.operations.push` and `IssueOperations.operations` are passed where the registration still needs one, because a contextual Api's operations resolve their chain from the scope at call time, not from where the reference was taken. `` and `` keep the argument, and the reason is the one thing worth a comment: a REPL entry's durable boundary has to *enclose* the public chain, and a body that reached the Api itself could not be enclosed by anything above it. That is also why neither retained policy can move to the Api seam — `retained-repository-push.test.ts` P18 proves a document's own component calling `Git.operations.push(...)` inside an entry reaches the ordinary missing-provider refusal and retains nothing, and `repl-issues.test.ts` proves a public handler that answers a read is retained as the entry's answer while a completed replay enters no handler at all. Installing either lifecycle as Api middleware inverts both. Retained Issue construction is consolidated into the envelope the two profiles already shared. `issue/owned.ts` now owns the live transport call and the idempotency-key derivation — identical in both profiles all along — so `effect.ts` calls them directly beneath the public chain, the entry composes the chain over them, and `transportRead`/`transportUpsert`, their duplicate parses and the entry's own key derivation are deleted. What is left of `replIssuePolicy` is the owner it closes over, the authenticated site it is handed, and which side of the public chain its envelope sits on. `replPushPolicy` keeps its ten lines for the same reason, and `publish()` beneath it stays entry-specific: it resolves this entry's placement, slot and saved request, none of which a workflow run has. --- architecture.md | 5 +- .../git/src/composition/components/GitPush.ts | 4 +- .../git/src/composition/components/Issue.ts | 6 +- .../src/composition/components/PullRequest.ts | 16 +- .../components/PullRequestReads.ts | 23 +-- packages/git/src/composition/installation.ts | 38 +--- .../git/src/composition/invocation-binding.ts | 27 +-- .../src/deno/retained-repository/issues.ts | 175 ++++++------------ packages/git/src/issue/effect.ts | 31 ++-- packages/git/src/issue/owned.ts | 45 ++++- 10 files changed, 140 insertions(+), 230 deletions(-) diff --git a/architecture.md b/architecture.md index 3ec4fb8a6..3bdfe29a5 100644 --- a/architecture.md +++ b/architecture.md @@ -5223,7 +5223,10 @@ it, and no Context holds it. A host that retains nothing named after an invocation runs the same bodies over the public feature operations, with no identity authenticated, because there is -nothing for one to name. That is what makes an ordinary run, a workflow run, a +nothing for one to name. Only an operation some host actually retains takes its +operation as an argument, and that is `Git.Push` and `Issue`: the four reads and +upsert no profile retains ask their feature Api directly, as any other component +does. That is what makes an ordinary run, a workflow run, a provider-absent runtime and a REPL entry four policies rather than four components. diff --git a/packages/git/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts index 44db2cf06..c491d5da8 100644 --- a/packages/git/src/composition/components/GitPush.ts +++ b/packages/git/src/composition/components/GitPush.ts @@ -24,7 +24,7 @@ import type { GitApi } from "../git-api.ts"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { GitOperationAdmissionError, GitOperationError } from "../errors.ts"; -import { boundComponent, registeredComponent } from "../invocation-binding.ts"; +import { boundComponent } from "../invocation-binding.ts"; import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; export const PUSH = ""; @@ -86,7 +86,7 @@ function dispatched(fn: FunctionComponent): FunctionComponent { /** An ordinary registered default, over whichever provider the host installed. */ export function pushRegistration(push: GitApi["push"]): ComponentRegistration { - return { ...METADATA, fn: dispatched(registeredComponent(push, body)) }; + return { ...METADATA, fn: dispatched((props) => body(props, push)) }; } /** A declaration, for an execution that owns this operation's policy. */ diff --git a/packages/git/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts index 8aeed2e4b..c5eb26e25 100644 --- a/packages/git/src/composition/components/Issue.ts +++ b/packages/git/src/composition/components/Issue.ts @@ -34,7 +34,7 @@ import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/hos import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; -import { boundComponent, registeredComponent } from "../invocation-binding.ts"; +import { boundComponent } from "../invocation-binding.ts"; import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; import { currentIssueTracker } from "../../issue/context.ts"; import { @@ -282,8 +282,8 @@ export function issueRegistration(operations: IssueOperationsApi): ComponentRegi return { ...METADATA, fn: dispatched( - registeredComponent(operations, readBody), - registeredComponent(operations, upsertBody), + (props) => readBody(props, operations), + (props) => upsertBody(props, operations), ), }; } diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index b7419c49a..e5ad6110a 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -34,18 +34,17 @@ import { content, documented, hasContent } from "@executablemd/core"; import type { ComponentRegistration, FirstPartyDocumentation, + FunctionComponent, PropsSchema, ReturnsSchema, } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { PullRequestOperations } from "../pull-request-operations.ts"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; -import { registeredComponent } from "../invocation-binding.ts"; -import type { ComponentBody } from "../invocation-binding.ts"; export const PULL_REQUEST_ELEMENT = ""; @@ -112,9 +111,8 @@ export const documentation: FirstPartyDocumentation = { context: "The pull request's body.", }; -export const body: ComponentBody = function* PullRequest( +const body: FunctionComponent = function* PullRequest( props: Record, - operations: PullRequestOperationsApi, ): Operation { // Before a Repository is observed, a checkout selected or retained Push // evidence read: a body that never finished rendering must not become one @@ -131,7 +129,7 @@ export const body: ComponentBody = function* PullReque ); } - const result = yield* operations.upsert({ + const result = yield* PullRequestOperations.operations.upsert({ pullRequest: { // Absence is `null` from here on: the durable request is JSON, and a // member that is sometimes missing would be a second shape. @@ -163,8 +161,4 @@ const METADATA = { }; /** An ordinary registered default, over whichever provider the host installed. */ -export function pullRequestRegistration( - operations: PullRequestOperationsApi, -): ComponentRegistration { - return { ...METADATA, fn: registeredComponent(operations, body) }; -} +export const pullRequestRegistration: ComponentRegistration = { ...METADATA, fn: body }; diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index bb49775ea..029dd2e31 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -30,9 +30,7 @@ import { PullRequestReadError } from "../errors.ts"; import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; -import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; -import { registeredComponent } from "../invocation-binding.ts"; -import type { ComponentBody } from "../invocation-binding.ts"; +import { PullRequestOperations } from "../pull-request-operations.ts"; export const REVIEWS_ELEMENT = ""; export const COMMENTS_ELEMENT = ""; @@ -163,11 +161,8 @@ export const checksReturns: ReturnsSchema = array({ }), ], }); -function read(kind: PullRequestReadKind, element: string): ComponentBody { - return function* PullRequestRead( - props: Record, - operations: PullRequestOperationsApi, - ): Operation { +function read(kind: PullRequestReadKind, element: string): FunctionComponent { + return function* PullRequestRead(props: Record): Operation { // Canonicalized before any provider sees it, so the component, the durable // request and every adapter read one answer. const url = canonicalPullRequestUrl(props.url); @@ -189,7 +184,7 @@ function read(kind: PullRequestReadKind, element: string): ComponentBody Git.operations.push(invocation), - issue: { - read: (invocation) => IssueOperations.operations.read(invocation), - upsert: (invocation) => IssueOperations.operations.upsert(invocation), - }, - pullRequest: { - read: (invocation) => PullRequestOperations.operations.read(invocation), - upsert: (invocation) => PullRequestOperations.operations.upsert(invocation), - }, -}; - /** * Every name this boundary carries, in catalog order. * @@ -291,10 +261,10 @@ const TRACKER: ComponentRegistration = { * entitled to shadow. */ const EXTERNAL_OPERATIONS: readonly ComponentRegistration[] = [ - pushRegistration(ORDINARY.push), - pullRequestRegistration(ORDINARY.pullRequest), - ...PULL_REQUEST_READ_KINDS.map((kind) => pullRequestReadRegistration(kind, ORDINARY.pullRequest)), - issueRegistration(ORDINARY.issue), + pushRegistration(Git.operations.push), + pullRequestRegistration, + ...PULL_REQUEST_READ_KINDS.map(pullRequestReadRegistration), + issueRegistration(IssueOperations.operations), ]; /** All sixteen, in catalog order. */ diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index 0cfcf2d01..ab50e6ba7 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -1,15 +1,16 @@ /** - * The handoff between a repository component's invocation and the host policy - * that performs it, for the six operations no local transaction encloses. + * How a declaring execution attaches its own policy to a shared component body. * - * `boundComponent` is for a host that retains work named after the invocation: - * it authenticates the invocation core issued and hands the site to - * feature-owned policy, which answers with this invocation's own semantic - * operations. An ordinary registration needs none of that and calls the public - * feature operations directly. + * `` and `` are the two operations a REPL entry retains, so + * their bodies take the operation they ask as an argument: an ordinary + * registration passes the feature Api's own operations, and the entry's + * declaration passes an envelope that encloses that Api. A body that reached + * the Api itself could not be enclosed by a durable boundary above it, which is + * what retention here means. The other four — `` and its three + * evidence reads — retain nothing in this delivery and ask their Api directly. * - * Either way the body receives semantic operations and nothing else — no - * authenticator, no owner, no site, no native access, no record codec — and the + * Either way the body receives semantic operations and nothing else: no + * authenticator, no owner, no site, no native access and no record codec. The * site stays private to the policy it was handed to. */ @@ -27,14 +28,6 @@ export type ComponentBody = ( /** How a host answers with one authenticated invocation's own operations. */ export type BindOperations = (site: string) => Operations; -/** The body an ordinary registration carries, over the public feature operations. */ -export function registeredComponent( - operations: Operations, - body: ComponentBody, -): FunctionComponent { - return (props: Record) => body(props, operations); -} - /** * The body a declaration carries, over the policy the declaring execution owns. * diff --git a/packages/git/src/deno/retained-repository/issues.ts b/packages/git/src/deno/retained-repository/issues.ts index 5a0df96c3..2f3464fbd 100644 --- a/packages/git/src/deno/retained-repository/issues.ts +++ b/packages/git/src/deno/retained-repository/issues.ts @@ -2,46 +2,19 @@ * 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 - * site the common invocation binding authenticated. The policy below closes - * over the owner and receives that site as an argument, 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 - * - * This policy 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. + * checkouts. This one 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 + * shared durable envelope in `issue/owned.ts` that makes one entry's issue work + * answerable again after the person walks away from it. + * + * 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 `` 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 type { ExecutionInstallation, JournalAdmission } from "@executablemd/core/host"; @@ -50,25 +23,27 @@ import { StaleInputError } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; import { issueDeclaration } from "../../composition/components/Issue.ts"; import type { BindOperations } from "../../composition/invocation-binding.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 { + liveIssueRead, + liveIssueUpsert, + ownedUpsertKey, + 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"; @@ -139,100 +114,54 @@ export function useEntryIssueTransport(options: GitHubIssuesOptions): Operation< /** * How this entry performs one ``, in either of its two forms. * - * The policy the common declaration is built with. It is called once per - * invocation, with the site the binding authenticated, and what it answers with - * is that invocation's own pair of issue operations. Which of the two the - * document asked for was settled by canonical form dispatch before the body - * ran, so this answers both and the invocation performs exactly one. + * The owner is closed over and the site arrives as the argument the authenticated + * binding hands in, so neither reaches here 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. * - * The key one upsert attempt presents is derived here, from this 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 and changes - * nothing about which attempt this is. + * This is a policy rather than `IssueOperations` middleware — which is where the + * workflow profile installs the same envelope — because an entry's envelope has + * to enclose the public chain rather than sit beneath it. A handler that answers + * a read itself is retained as this entry's answer, and a completed replay + * invokes no handler at all. */ export function replIssuePolicy(owner: string): BindOperations { return (site: string) => ({ read: (invocation: IssueReadInvocation) => - readOwnedIssue({ owner, site, request: invocation }, liveRead(invocation)), - upsert: (invocation: IssueUpsertInvocation) => - upsertOwnedIssue( - { owner, site, request: invocation }, - liveUpsert( - invocation, - issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", invocation.target), + readOwnedIssue({ owner, site, request: invocation }, () => + attempted({ read: ([request]) => liveIssueRead(request) }, () => + IssueOperations.operations.read(invocation), ), ), + upsert: (invocation: IssueUpsertInvocation) => { + const key = ownedUpsertKey(owner, site, invocation.target); + return upsertOwnedIssue({ owner, site, request: invocation }, () => + attempted({ upsert: ([request]) => liveIssueUpsert(request, key) }, () => + IssueOperations.operations.upsert(invocation), + ), + ); + }, }); } /** - * One live read attempt: the composed `IssueOperations` question, answered - * beneath by this entry's transport-facing default. + * One live attempt: the public `IssueOperations` chain, over a transport-facing + * default of this attempt's own. * * 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. + * still wraps the dispatch. The default closes over this attempt's key, so two + * concurrent `` elements each dispatch through their own and neither can + * present the other's. */ -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; - }, - }; +function attempted( + transport: Partial>, + ask: () => Operation, +): Operation { + return scoped(function* (): Operation { + yield* IssueOperations.around(transport, { at: "min" }); + return yield* ask(); + }); } /** diff --git a/packages/git/src/issue/effect.ts b/packages/git/src/issue/effect.ts index f87290b10..82b05fec8 100644 --- a/packages/git/src/issue/effect.ts +++ b/packages/git/src/issue/effect.ts @@ -41,13 +41,18 @@ import { getExpansion } from "@executablemd/core"; import { getWorkflowRun } from "@executablemd/workflow"; import { retainedIssueIdentitiesHere } from "../identities.ts"; import { claimRetainedIssueIdentity, exhaustRetainedIssueIdentities } from "./identities.ts"; -import { IssueApi } from "./api.ts"; import { IssueOperations } from "./operations.ts"; import type { IssueReadInvocation, IssueUpsertInvocation } from "./operations.ts"; import type { IssueDetails, IssueReference } from "./api.ts"; import { IssueProtocolError } from "./errors.ts"; -import { issueIdempotencyKey } from "./records.ts"; -import { ownedUpsertRequest, readOwnedIssue, upsertOwnedIssue } from "./owned.ts"; +import { + liveIssueRead, + liveIssueUpsert, + ownedUpsertKey, + ownedUpsertRequest, + readOwnedIssue, + upsertOwnedIssue, +} from "./owned.ts"; import type { IssueReadEffectRequest, IssueUpsertEffectRequest } from "./owned.ts"; export type { IssueReadEffectRequest, IssueUpsertEffectRequest } from "./owned.ts"; @@ -63,11 +68,9 @@ export function* readIssue(request: IssueReadEffectRequest): Operation { - return yield* IssueApi.operations.read(request.url, { - ...(request.provider === undefined ? {} : { provider: request.provider }), - }); - }, + // Directly: this profile installs the envelope at the `IssueOperations` + // seam, so the public chain has already wrapped it. + () => liveIssueRead(request), ); } @@ -95,11 +98,7 @@ export function* upsertIssue(request: IssueUpsertEffectRequest): Operation { + return IssueApi.operations.read(request.url, { + ...(request.provider === undefined ? {} : { provider: request.provider }), + }); +} + +/** The live transport call one upsert ends in, under this attempt's own key. */ +export function liveIssueUpsert( + request: IssueUpsertEffectRequest, + key: string, +): Operation { + return IssueApi.operations.upsert(request.issue, { + url: request.target, + ...(request.provider === undefined ? {} : { provider: request.provider }), + idempotencyKey: key, + }); +} + +/** + * What makes a second upsert attempt the same attempt. + * + * 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 and changes nothing about which attempt + * this is. + */ +export function ownedUpsertKey(owner: string, site: string, target: string): string { + return issueIdempotencyKey({ runId: owner, expansionId: site }, "upsert", target); +} + /** * The complete read request this owner and site name. * From ba595857046f79e5ae1a54e71652d2c52efdd202 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 08:12:06 -0400 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=93=9D=20Say=20what=20a=20workflow=20?= =?UTF-8?q?retains=20beneath=20PullRequest,=20not=20that=20nothing=20does?= =?UTF-8?q?=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow run does retain `` and its three evidence reads — the upsert through the Git-host reconciliation beneath it, each read as policy on `PullRequestReadExecution`. Saying they "retain nothing" was wrong about the profile that does. What is true of them is narrower: no host owns a retained boundary *above* their feature Api, so none of the four needs a binding of its own and each asks its Api directly. --- architecture.md | 16 ++++++++++------ .../git/src/composition/invocation-binding.ts | 6 +++++- specs/executable-mdx-spec.md | 15 ++++++++------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/architecture.md b/architecture.md index 3bdfe29a5..f39eba3b3 100644 --- a/architecture.md +++ b/architecture.md @@ -5223,12 +5223,16 @@ it, and no Context holds it. A host that retains nothing named after an invocation runs the same bodies over the public feature operations, with no identity authenticated, because there is -nothing for one to name. Only an operation some host actually retains takes its -operation as an argument, and that is `Git.Push` and `Issue`: the four reads and -upsert no profile retains ask their feature Api directly, as any other component -does. That is what makes an ordinary run, a workflow run, a -provider-absent runtime and a REPL entry four policies rather than four -components. +nothing for one to name. Only an operation a host retains *above* the public +chain, named after an invocation it authenticates, takes its operation as an +argument, and that is `Git.Push` and `Issue`. A workflow run retains +`PullRequest` and its three evidence reads too — the upsert through the Git-host +reconciliation beneath it, each read as policy on `PullRequestReadExecution` — +but it installs that retention under their feature Api instead of owning a +boundary over it, and no REPL entry owns one either, so those four ask their Api +directly as any other component does. That is what makes an ordinary run, a +workflow run, a provider-absent runtime and a REPL entry four policies rather +than four components. The lifetime of what a policy hands over is the ordinary one: an operation runs in the scope of the invocation that asked for it, and cancellation, teardown and diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index ab50e6ba7..db62b7a1d 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -7,7 +7,11 @@ * declaration passes an envelope that encloses that Api. A body that reached * the Api itself could not be enclosed by a durable boundary above it, which is * what retention here means. The other four — `` and its three - * evidence reads — retain nothing in this delivery and ask their Api directly. + * evidence reads — are retained as well, but underneath their Api rather than + * above it: a workflow run makes the upsert durable in the Git-host + * reconciliation beneath it, and each read durable as policy on + * `PullRequestReadExecution`. No host owns a boundary over the chain for them, + * so they need no binding of their own here and ask their Api directly. * * Either way the body receives semantic operations and nothing else: no * authenticator, no owner, no site, no native access and no record codec. The diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5a1cba34e..e6539eae0 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3072,13 +3072,14 @@ implementation, which asks a semantic feature operation for its work and receives nothing else. Every command that carries this vocabulary registers all sixteen, `repl` included: that registry is what says which names exist, and it is what a run, `xmd syntax`, `xmd plan` and a REPL entry's preflight all read. An -execution that retains one of those six operations declares that same name again -in its own scope with its own policy under it, where a nearer declaration -shadows the registered default. The names, props, forms, return contracts, -origins and prose are the same either way, and a repository-local component of -one of those names is still chosen ahead of the default. A -Workspace attachment installs that run's providers and durable state and -declares none of them. +execution that owns a retained boundary over one of those six operations, +authenticated per invocation, declares that same name again in its own scope +with its own policy under it, where a nearer declaration shadows the registered +default. The names, props, forms, return contracts, origins and prose are the +same either way, and a repository-local component of one of those names is still +chosen ahead of the default. A Workspace attachment installs that run's +providers and durable state — the retention its pull-request lifecycle puts +beneath the feature operation among them — and declares none of them. Registering them installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential — describing an environment mints nothing. What each name *does* is decided by whichever repository provider the From 218b5e1d1dd09b948b3335485a462b0ae5519b20 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 08:38:22 -0400 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=94=80=20Carry=20the=20one-body=20rep?= =?UTF-8?q?air=20over=20#944's=20four=20Git=20reads=20(#181,=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry is sixteen names now, not twelve, and ``, ``, `` and `` join the half a selected checkout answers for. Every count, every catalog sentence and the binding suite's A1 row say sixteen. `publish()` takes the shared `nativeGitTurn` that arrived with those reads, so one entry still serializes its native work through one turn. They also settle the question this delivery kept answering about `` from the other side: a REPL entry retains every page those four answer with, as `GitQuery` middleware under the component, and declares none of their names. Retaining an operation is not what makes a body take its operation as an argument — owning a boundary that has to enclose the public chain is, and that is still `` and `` alone. --- architecture.md | 9 ++++++--- packages/git/src/composition/installation.ts | 12 ++++++------ .../git/src/deno/retained-repository/provider.ts | 13 +++++++++---- .../git/tests/composition-component-binding.test.ts | 8 ++++---- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/architecture.md b/architecture.md index f39eba3b3..c5a4174ef 100644 --- a/architecture.md +++ b/architecture.md @@ -5230,9 +5230,12 @@ argument, and that is `Git.Push` and `Issue`. A workflow run retains reconciliation beneath it, each read as policy on `PullRequestReadExecution` — but it installs that retention under their feature Api instead of owning a boundary over it, and no REPL entry owns one either, so those four ask their Api -directly as any other component does. That is what makes an ordinary run, a -workflow run, a provider-absent runtime and a REPL entry four policies rather -than four components. +directly as any other component does. A REPL entry retains the four Git reads +the same way — `GitQuery` middleware at its own implementation slot — which is +what lets those four stay ordinary registrations under a host that retains every +page they answer with. That is what makes an ordinary run, a workflow run, a +provider-absent runtime and a REPL entry four policies rather than four +components. The lifetime of what a policy hands over is the ordinary one: an operation runs in the scope of the invocation that asked for it, and cancellation, teardown and diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index ea804e0e4..8c9f25cf0 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -2,12 +2,12 @@ * The sixteen repository-composition components, declared once for every host. * * Repository, Worktree, the four Git operations, the four Git reads, - * PullRequest and its three evidence reads, IssueTracker and Issue are - * ordinary registered defaults — not - * reserved and not structural — so a repository-local component may shadow one - * for its own scope. `` is not among them: canonical core reserves that - * name (`core/src/components/fixed-filesystem.ts`), and a document reaches it - * with or without this package. + * PullRequest and its three evidence reads, IssueTracker and Issue are ordinary + * registered defaults — not reserved and not structural — so a + * repository-local component may shadow one for its own scope. `` is not + * among them: canonical core reserves that name + * (`core/src/components/fixed-filesystem.ts`), and a document reaches it with + * or without this package. * * ## One registry, and the policy an execution attaches underneath it * diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index 61b7c9bfa..4e21f6351 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -201,12 +201,17 @@ export function* replRepositoryInstallation( }, }); - // The two operations this entry retains, and only those. Every name this + // The two names this entry declares, and only those. Every name this // vocabulary has is already registered in the command's scope; what an entry // adds here is the policy it owns, attached to those same two bodies from - // inside its own execution. ``, its three reads and everything - // local keep the registered defaults, because this entry retains nothing of - // them to answer with. + // inside its own execution. + // + // Retaining an operation is not by itself a reason to declare its name. This + // entry retains the pages its four Git reads answer with and the file work + // beneath them too, and both are middleware under the component rather than a + // policy named after an invocation — so they keep the registered default and + // appear nowhere here. These two need the invocation: a durable boundary that + // has to enclose the public chain cannot be installed inside it. const components: readonly IdentityComponent[] = [ pushDeclaration(replPushPolicy(owner, () => (usable ? held.entry : undefined))), issueDeclaration(replIssuePolicy(owner)), diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index 1f5517602..8d54a9bc8 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -220,13 +220,13 @@ describe("one registry for every repository operation, and the policy an executi } }); - it("A1: one registry carries all twelve names, the six operations included", function* (): Operation { + it("A1: one registry carries all sixteen names, the six operations included", function* (): Operation { // There is no second catalog and no reduced one. Every command that carries - // this vocabulary registers the same twelve, which is what says the names + // this vocabulary registers the same sixteen, which is what says the names // exist — to a run, to validation and to `xmd syntax` alike. const registered = COMPOSITION_REGISTRATIONS.map((entry) => entry.name); - expect(registered).toHaveLength(12); - expect(new Set(registered).size).toBe(12); + expect(registered).toHaveLength(16); + expect(new Set(registered).size).toBe(16); for (const name of OPERATIONS) { expect(registered).toContain(name); }