diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ad007d38..8a52d96a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,9 @@ jobs: - name: A REPL entry's issues across the compiled binary run: deno test --allow-all --frozen scripts/tests/repl-issues-compiled.test.ts + - name: A REPL entry's publications across the compiled binary + run: deno test --allow-all --frozen scripts/tests/repl-push-compiled.test.ts + # Each package's `components.md` is a packaged asset on the same footing, # and the long-form documentation is the only thing that reports whether # one shipped — a binary missing them still lists every component and diff --git a/architecture.md b/architecture.md index 2d7c325ca..bfa767725 100644 --- a/architecture.md +++ b/architecture.md @@ -6741,9 +6741,11 @@ managed one it selected. A read is retained because a document binds its text and a continuation has to offer the same binding; a glob, a path check and a temporary directory bind nothing a continuation must answer again, and are left alone. Three local Git operations are retained beside them — ``, -`` and `` — and those counts are separate: the file -rules below are about the four, and the Git rules further down are about the -three. A call outside every such checkout is ordinary filesystem behavior, +`` and `` — and one publication, ``, whose +request and whose external outcome are two records of their own. Those counts +are separate: the file rules below are about the four, the local Git rules +further down are about the three, and the publication rules after them are about +the one. A call outside every such checkout is ordinary filesystem behavior, recorded by nothing — a record would place the call in a checkout it is not in — so it runs again during a partial continuation. A managed placement is looked for first and the most specific one wins; the ambient route answers everything no @@ -6759,7 +6761,10 @@ display name and the owner checkout. A root-level `` records the Repository it belongs to as its own answer, so a continuation learns that without asking Git. Nothing else is retained: no origin, no publication evidence, no object source, no Git session, no authentication, no lock and no -opaque selection identifier. +opaque selection identifier. A publication therefore reauthenticates its +destination from what the entry can see now — the locator the document in front +of it wrote, or the ambient checkout's own admitted origin — and holds it to the +fingerprint the retained identity already carries. Answering a completed selection calls no selector, clones nothing, adds no worktree, reads no sidecar, opens no credential and takes no lock. The parsed @@ -6997,12 +7002,106 @@ persistence failure with its first cause: no success binding is offered, no later sibling runs as though the commit succeeded, and no compensating Close is appended. -Publication is not here: `` and `` stay declared where -they already are and refuse through their own unchanged missing-provider -defaults, and a local result confers no publication evidence. `` is the -one external effect an entry does retain, and it is the next section's subject -because it belongs to no checkout. Nothing inventories the managed root, -snapshots a checkout's contents or restores one. +A pull request is not here: `` stays declared where it already is +and refuses through its own unchanged missing-provider default, and a local +result confers no publication evidence. Publication itself is the next +subsection's subject, and `` the section's after it — the first because +it belongs to a checkout and to a remote at once, the second because it belongs +to no checkout at all. Nothing inventories the managed root, snapshots a +checkout's contents or restores one. + +#### What one entry asked to publish, and what the Git host did about it + +`` publishes the branch the selected checkout is on to that +Repository's origin, and it is the one operation here whose outcome no local +record can settle: the branch belongs to a Git host. So it is retained as two +records at two journal positions. The first is the **request** — written down +before anything is contacted — and the second is the Git host's own +reconciliation outcome, under the same `git_host_effect` contract a workflow +run's Push uses. + +That order is the whole feature. An entry that published a branch, left at a +question and is reopened an hour later reconciles the request it already made: +the same branch, the same commit and the same destination, however far the +checkout has moved since. A completed publication answers from its saved +receipt and reaches no provider, no credential and no remote. A *new* `` after the question resolves today's branch and commit, which is what makes +this retention rather than a cache. + +The request is resolved from the checkout rather than from a document. The +authenticated Repository selection, the checkout it named and its common Git +directory come from this entry's own private registry; the covering checkout is +chosen by the ordinary selector over the received working directory; the origin +is the admitted credential-free locator, held to the identity's own fingerprint; +and the branch and the commit are read inside this entry's existing native turn. +A detached HEAD is the existing actionable refusal and a checkout recording no +usable origin is an admission refusal — both before a request exists and before +anything is contacted. The record keeps three readings of one place: the +question the invocation asked, the reading the live dispatch received, and what +routing resolved. Equal readings still occupy both fields. No opaque selection +identifier, source object, credential, Git output, HEAD inventory or publication +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. + +Composition is ordinary. 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 still answers, transforms, refuses and delegates. +A handler that answers without delegating has answered the document: no request +is written down, no Git host is asked, and a returned value is never read as +publication. A second delegation of one invocation is refused rather than +published twice, and the default is revoked when the invocation ends. On a +continuation the saved request is consumed *before* any dispatch, because a +changed handler must not retarget external work this entry has already +admitted. + +Ownership differs from local Git by exactly one hold. A publication takes the +selected placement's managed slot — on a fresh request before it reads the +checkout, and on a saved one before it reads an object — and takes no mutation +lease at all: it changes no checkout, index, branch or tracking configuration, +so another entry holding that lease does not stop it. Contention is decided +before the request exists and is never written down. + +What the provider may read is the object database this entry authenticated, and +it reads it through a bare repository this run created with a read-only +alternate — never the selected checkout, whose own configuration could name +another destination or another program. The authenticated common directory +covers a primary checkout, a linked worktree and a repository whose +administration lives elsewhere alike. A pending request is reconciled against +the exact commit it named: a missing object fails rather than being fetched, a +moved HEAD or branch alone refuses nothing, and a wrong origin, common +directory or unreadable source stops without remote contact. + +The decisions are the shared engine's, unchanged: a destination proven absent +and one holding a proven ancestor are published once with an exact non-force +refspec, a destination already holding this commit is adopted without pushing, +and divergence, permanent ambiguity and temporary unavailability are the +existing definitive failures that replay as themselves. A nonzero push earns +exactly one more observation and nothing else; Git's own output is never read as +evidence. + +Both appends are fenced. A failed request append contacts nothing and fences +every later operation; a failed outcome append leaves the remote as it is, with +no false receipt and no compensating Close — the next attempt observes the +destination and adopts the completion rather than repeating it. Every +per-attempt resource — the scratch transport, the control repository and the +credential session — belongs to the attempt's own scope and is closed and joined +before publication may acknowledge anything, so a cleanup that failed publishes +neither a success nor a refusal the worker had already decided. + +An entry's retained publication history is admitted before any of it is +replayed: every saved request parses, states this version, is named by its own +question and names a checkout a placement before it described; every outcome +stands after the request it answers, carries that request's own fingerprint and +reads back as a Push of exactly what was asked. A trailing request with no +outcome is valid partial history — it is the state this protocol exists for — and +an orphan outcome, a request answered twice and a receipt this build cannot read +are fatal unreadable history. ### What one entry retains about the issue it filed @@ -8310,7 +8409,8 @@ Status is measured against 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 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. Nothing external is here, and a local result confers no publication evidence [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | 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 | | Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation [Invariant](#live-effect-coordination). | built on the #365 stack; the Deno provider installs an adapter-private atomic handler | | shared Git-host reconciliation | Reconciles Git push and pull-request upsert through one request-only boundary whose private terminal alone settles the retained outcome. [Invariant](#external-effects-and-git-host-mutations); [Invariant](#issue-effects). | Built on #297; Push (#370) and PullRequest (#295) consume it. Issue (#296) has its own middleware boundary. | diff --git a/packages/cli/src/compiled-repl.ts b/packages/cli/src/compiled-repl.ts index bac27db23..5e839e7ce 100644 --- a/packages/cli/src/compiled-repl.ts +++ b/packages/cli/src/compiled-repl.ts @@ -14,13 +14,11 @@ import { homedir } from "node:os"; import { sep } from "node:path"; import process from "node:process"; import type { Operation } from "effection"; -import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { installReplHost, platformDataRoot, replHostFacts } from "./repl-assembly.ts"; import { XMD_VERSION } from "./cli.ts"; import { useCompiledReplTerminal } from "./compiled-repl-terminal.ts"; -import { useDenoReplRepositories } from "./deno-repositories.ts"; -export function* useCompiledRepl(helper: HelperAssembly): Operation { +export function* useCompiledRepl(): Operation { yield* installReplHost({ dataRoot: () => platformDataRoot(process.platform, homedir(), process.env, sep), // What this process is, stated here because this is where it is known. @@ -39,13 +37,5 @@ export function* useCompiledRepl(helper: HelperAssembly): Operation { createExclusive: (path) => open(path, "wx").then((handle) => handle.close()), appendRecord: (path, record) => appendFile(path, record), }); - // What an entry's execution initializes: the recording of the File and Dir - // work it does, and of the managed Repository and Worktree selections that - // say where it does it. Registered here, in the runtime scope the selected - // REPL host owns, because only a runtime-named module may name the native - // adapter and this runtime's own credential-helper assembly — and registering - // it acquires nothing, so a session that only reads history discovers no - // checkout, creates no managed root and takes no lease. - yield* useDenoReplRepositories(helper); yield* useCompiledReplTerminal(); } diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 66199faa7..32af929b1 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -99,9 +99,10 @@ if (isCredentialHelperMode(process.argv.slice(2))) { () => useDenoWorkflowHost(HELPER), useMachineSessions(), // What this host selects for a REPL command, and what it installs for - // one. The selection is the issue capability an entry runs with; Node - // and Bun supply neither half. - { select: selectReplEntryHost, install: () => useCompiledRepl(HELPER) }, + // one. The selection is the capability an entry runs with — its + // checkouts, its local Git, its publications and its issues; Node and Bun + // supply neither half. + { select: () => selectReplEntryHost(HELPER), install: () => useCompiledRepl() }, ); }); } diff --git a/packages/cli/src/deno-repl.ts b/packages/cli/src/deno-repl.ts index a3cc91f70..fbbc47a53 100644 --- a/packages/cli/src/deno-repl.ts +++ b/packages/cli/src/deno-repl.ts @@ -14,13 +14,11 @@ import { homedir } from "node:os"; import { sep } from "node:path"; import process from "node:process"; import type { Operation } from "effection"; -import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { installReplHost, platformDataRoot, replHostFacts } from "./repl-assembly.ts"; import { XMD_VERSION } from "./cli.ts"; import { useDenoReplTerminal } from "./deno-repl-terminal.ts"; -import { useDenoReplRepositories } from "./deno-repositories.ts"; -export function* useDenoRepl(helper: HelperAssembly): Operation { +export function* useDenoRepl(): Operation { yield* installReplHost({ dataRoot: () => platformDataRoot(process.platform, homedir(), process.env, sep), // What this process is, stated here because this is where it is known. @@ -39,13 +37,5 @@ export function* useDenoRepl(helper: HelperAssembly): Operation { createExclusive: (path) => open(path, "wx").then((handle) => handle.close()), appendRecord: (path, record) => appendFile(path, record), }); - // What an entry's execution initializes: the recording of the File and Dir - // work it does, and of the managed Repository and Worktree selections that - // say where it does it. Registered here, in the runtime scope the selected - // REPL host owns, because only a runtime-named module may name the native - // adapter and this runtime's own credential-helper assembly — and registering - // it acquires nothing, so a session that only reads history discovers no - // checkout, creates no managed root and takes no lease. - yield* useDenoReplRepositories(helper); yield* useDenoReplTerminal(); } diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index 95d2c6ea0..660c74470 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -29,11 +29,7 @@ import type { Plugin, PluginInstallRequest, PluginInstallation } from "@executab import type { Operation } from "effection"; import { cwd } from "@executablemd/runtime"; import { gitPlugin } from "@executablemd/git"; -import { - replIssueInstallation, - useReplRepositoryRecording, - useRunComposition, -} from "@executablemd/git/deno"; +import { replRepositoryInstallation, useRunComposition } from "@executablemd/git/deno"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; import { ReplEntryHost } from "./repl-entry-installation.ts"; @@ -109,20 +105,29 @@ export function denoRunRepositories( } /** - * The issue capability a REPL entry runs with on this host. + * What a REPL entry runs with on this host: its checkouts, its local Git, its + * publications and its issues. * * Inert, and selected by the runtime entrypoint beside the bundled Plugin: - * naming the native GitHub adapter is a runtime-named module's business, and - * this is the value the shared command path carries without importing one. It - * reads no configuration and holds no credential — what an entry's issue work - * is allowed to reach is read by the invoked operation that needs it. + * naming the native adapter and this runtime's own credential-helper assembly + * is a runtime-named module's business, and this is the value the shared + * command path carries without importing one. It reads no configuration and + * holds no credential — what an entry's work is allowed to reach is read by the + * invoked operation that needs it. * * The owner is supplied per entry by the session that derived it, which is why * this is a factory rather than an installation: one value per host, one - * installation per entry. + * capability per entry. One factory, because an entry initializes its + * repository state once; two registrations of it would be two providers. */ -export const denoReplIssues: ReplEntryInstallation = (owner: string) => - replIssueInstallation(owner); +export function denoReplEntry(helper: HelperAssembly, root: string): ReplEntryInstallation { + // Copied here, at selection, rather than held as the caller's object. What an + // entry initializes with has to be what this host selected: a caller that + // kept a reference and changed a field afterwards would be configuring every + // later entry from outside the selection the command already made. + const configured = snapshot(helper); + return (owner: string) => replRepositoryInstallation(owner, { helper: configured, root }); +} /** * Select that value for the REPL command that is about to assemble itself. @@ -132,8 +137,11 @@ export const denoReplIssues: ReplEntryInstallation = (owner: string) => * this adapter. Setting it establishes nothing: the value is inert code, and * what an entry does with it happens inside that entry. */ -export function* selectReplEntryHost(): Operation { - yield* ReplEntryHost.set(denoReplIssues); +export function* selectReplEntryHost( + helper: HelperAssembly, + root: string = DEFAULT_REPOSITORY_ROOT, +): Operation { + yield* ReplEntryHost.set(denoReplEntry(helper, root)); } /** @@ -156,36 +164,3 @@ function snapshot(helper: HelperAssembly): HelperAssembly { : { launcherEnvironment: Object.freeze({ ...helper.launcherEnvironment }) }), }); } - -/** - * What this registration gives a REPL entry's execution: recording, selection - * and local Git. - * - * Registered by the selected REPL host rather than by the bundled Plugin, - * because an entry is not an ordinary run: it retains the file work it does, the - * managed checkouts it selects to do that work in, and the staging, branch - * switches and commits it performs in them — and it installs no publication and - * no external worker. The issue capability an entry runs with is selected - * separately, above, because it belongs to no checkout. Each executing entry initializes its own, in the scope - * below its Agent attachment, so a passive view — a settled history, a frozen - * position — initializes nothing, creates no managed root, reads no commit - * identity and takes no lease. - * - * The root and the helper are copied here, at registration, rather than held as - * the caller's object. What an entry initializes with has to be what this host - * selected: a caller that kept a reference and changed a field afterwards would - * be configuring every later entry from outside the selection the command - * already made. - */ -export function* useDenoReplRepositories( - helper: HelperAssembly, - root: string = DEFAULT_REPOSITORY_ROOT, -): Operation { - const configured = snapshot(helper); - yield* Execution.around({ - *init(_args, next): Operation { - yield* useReplRepositoryRecording({ helper: configured, root }); - yield* next(); - }, - }); -} diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 5a6fa3e9a..8fbc412c7 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -118,9 +118,10 @@ if (isCredentialHelperMode(process.argv.slice(2))) { () => useDenoWorkflowHost(HELPER), useMachineSessions(), // What this host selects for a REPL command, and what it installs for - // one. The selection is the issue capability an entry runs with; Node - // and Bun supply neither half. - { select: selectReplEntryHost, install: () => useDenoRepl(HELPER) }, + // one. The selection is the capability an entry runs with — its + // checkouts, its local Git, its publications and its issues; Node and Bun + // supply neither half. + { select: () => selectReplEntryHost(HELPER), install: () => useDenoRepl() }, ); }); } diff --git a/packages/cli/src/repl-entry-installation.ts b/packages/cli/src/repl-entry-installation.ts index bb25f0da7..c3f5e6449 100644 --- a/packages/cli/src/repl-entry-installation.ts +++ b/packages/cli/src/repl-entry-installation.ts @@ -8,33 +8,42 @@ * per *entry*, for the exact execution and entry key the entry belongs to. * * What crosses is a factory, and nothing else. It holds code and this - * deployment's configuration; it holds no owner, no site, no provider, no - * credential, no Git session and no identity, so nothing read from here is - * authority over permission, durable identity or reconciliation. The owner is + * deployment's configuration — the managed root and the credential-helper + * assembly; it holds no owner, no site, no provider, no credential, no Git + * session and no identity, so nothing read from here is authority over + * permission, durable identity or reconciliation. The owner is * derived by the session for the entry it is about to run, and the identity * inside the installation is minted by canonical core. * * Deno and the compiled binary select a value; Node and Bun leave the default - * absence, which is what makes `` on those runtimes reach its ordinary - * missing-provider refusal rather than a half-configured host. + * absence, which is what makes `` and `` on those runtimes + * reach their ordinary missing-provider refusals rather than a half-configured + * host. * * It is CLI-private. No package member exports it and no selector names it: * this is the host saying what an entry runs with. */ import { createContext } from "effection"; -import type { Context } from "effection"; +import type { Context, Operation } from "effection"; import type { ExecutionInstallation } from "@executablemd/core/host"; /** * What one entry's capability is built from: the owner of that entry's retained * work. * - * Called inside the entry task, once, for the entry about to execute. Calling - * it performs nothing — what it answers with is a declaration, an admission and - * an installation callback the execution runs in its own scope. + * Called inside the entry task, once, for the entry about to execute. What it + * answers with is declarations, admissions and an installation callback the + * execution runs in its own scope. + * + * It is an operation because an entry's capability may have to *register* + * something in that task before the execution starts — the repository + * capability registers the `Execution.init` its own state is created by. It + * still acquires nothing: no directory, no lease, no `git`, no credential and + * no initialization of its own happens here, and the registration belongs to + * the entry task rather than to a scope that closes when this returns. */ -export type ReplEntryInstallation = (owner: string) => ExecutionInstallation; +export type ReplEntryInstallation = (owner: string) => Operation; /** The factory this host runs REPL entries with, or absence. */ export const ReplEntryHost: Context = createContext< diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index a2ebc5928..23580777e 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -1324,8 +1324,15 @@ function* start( // what a host attaches is copied by value before anything installs: // an entry that got one afterwards would be running under a capability // the execution never admitted. + // + // Yielded in this task, after the Agent attachment above and before the + // execution below, so what the capability registers for the execution — + // the initialization its own private state is created by — belongs to + // the entry rather than to a scope that has already closed. const owned = - entryInstallation === undefined ? [] : [entryInstallation(entryOwner(execution.id, key))]; + entryInstallation === undefined + ? [] + : [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 // answer once per execution, and a chain assembled in a scope that has 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 7aa809611..208b93934 100644 --- a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts +++ b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts @@ -93,7 +93,7 @@ await main(function* (args) { yield* useHostFiles(); // The assembly this host selects, stated here as an entrypoint states it. // Inert: this fixture selects no repository, so nothing ever reaches it. - yield* useDenoRepl({ runtime: "source", platform: "unix", execPath: process.execPath }); + yield* useDenoRepl(); const ran = yield* runReplProgram({ profile: { diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 606bf9885..ea80d91e0 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -62,11 +62,8 @@ import { assembleReplProfile } from "../src/repl-profile.ts"; import type { ReplExecutionProfile } from "../src/repl-profile.ts"; import { resolveAgentStack } from "../src/agent-stack.ts"; import { NO_PLUGINS } from "../src/plugin-host.ts"; -import { - denoReplIssues, - selectReplEntryHost, - useDenoReplRepositories, -} from "../src/deno-repositories.ts"; +import { selectReplEntryHost } from "../src/deno-repositories.ts"; +import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; import { ReplHost } from "../src/repl/host.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -76,6 +73,29 @@ import type { ReplExecution, RetainedStream } from "../src/repl/journal.ts"; /** The tracker every row files in, as a document writes it. */ const TRACKER = "https://github.com/octo/project"; +/** The credential-helper assembly these rows' host selects. Never invoked. */ +const SUITE_HELPER: HelperAssembly = Object.freeze({ + runtime: "source", + platform: "unix", + execPath: "/nonexistent/xmd", +}); + +/** + * The repository recording an entry's execution initializes. + * + * Registered on the same seam the per-entry factory registers it on, because + * what these rows are about is an entry's issue work beside its checkouts + * rather than which host assembled either. + */ +function useReplRepositories(root: string): Operation { + return Execution.around({ + *init(_args, next): Operation { + yield* useReplRepositoryRecording({ helper: SUITE_HELPER, root }); + yield* next(); + }, + }); +} + /** * What a command contributes to an entry's execution. * @@ -175,7 +195,8 @@ function entryHost( seen: Counted, options: GitHubIssuesOptions = {}, ): ReplEntryInstallation { - return (owner: string) => { + // deno-lint-ignore require-yield + return function* (owner: string): Operation { seen.owners.push(owner); return replIssueInstallation(owner, { access: gitHubSource(gitHubAccessFor(fixture.server.url, { credential: "valid" })), @@ -242,10 +263,7 @@ function submit( yield* useHostFiles(); yield* useWorkingDirectory(options.cwd ?? fixture.checkout); if (options.repositories !== undefined) { - yield* useDenoReplRepositories( - { runtime: "source", platform: "unix", execPath: "/nonexistent/xmd" }, - options.repositories, - ); + yield* useReplRepositories(options.repositories); } const installations = [...(yield* replInstallations()), ...(options.installations ?? [])]; const session = opened( @@ -275,10 +293,7 @@ function reopen(fixture: Fixture, run: ReplExecution, options: RunOptions = {}): yield* useHostFiles(); yield* useWorkingDirectory(options.cwd ?? fixture.checkout); if (options.repositories !== undefined) { - yield* useDenoReplRepositories( - { runtime: "source", platform: "unix", execPath: "/nonexistent/xmd" }, - options.repositories, - ); + yield* useReplRepositories(options.repositories); } const installations = [...(yield* replInstallations()), ...(options.installations ?? [])]; const session = opened( @@ -1450,10 +1465,14 @@ describe("how a REPL entry gets its issues", () => { if (!stack.ok) { throw stack.error; } - yield* selectReplEntryHost(); + yield* selectReplEntryHost(SUITE_HELPER, planWriterRoot); return yield* assembleReplProfile(stack.value, NO_PLUGINS, { planWriterRoot }); }); - expect(selected.entryInstallation).toBe(denoReplIssues); + // Behavioural rather than by identity: the production selector builds the + // factory from this host's own configuration, so what the profile carries + // is a closure over it rather than a module-level value to compare against. + const factory = selected.entryInstallation; + expect(typeof factory).toBe("function"); // And without it: the same assembly, in a scope nothing selected in, leaves // the member absent rather than reaching for a default. @@ -1466,9 +1485,19 @@ describe("how a REPL entry gets its issues", () => { }); expect(absent.entryInstallation).toBe(undefined); - // Selecting reaches nothing: no issue host exists yet, because an - // installation is built per entry and this command has run none. - expect(typeof selected.entryInstallation).toBe("function"); + // 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. + const built = yield* scoped(function* (): Operation { + if (factory === undefined) { + throw new Error("the production selector selected no entry capability"); + } + return yield* factory('["executablemd.repl.issue-owner",1,"execution-1","1"]'); + }); + expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ + "Git.Push", + "Issue", + ]); }); it("E2: shadows the inherited default, and refuses where no host selected one", function* (): Operation { diff --git a/packages/cli/tests/repl-push-pty.test.ts b/packages/cli/tests/repl-push-pty.test.ts new file mode 100644 index 000000000..ba92698f2 --- /dev/null +++ b/packages/cli/tests/repl-push-pty.test.ts @@ -0,0 +1,46 @@ +/** + * The whole public behavior of a publication, on a real terminal, from source + * (#181 E1, E9). + * + * Every other row of this feature's evidence installs its own host, which is + * what makes those rows portable and deterministic. None of them can show the + * thing a person actually does: type an entry that commits and publishes a + * branch, walk away from it at a question, advance the branch from a shell, and + * reopen the printed location. So this launches the production entrypoint for + * the host runtime on a pty, against a bare remote it seeded, and reads the + * answer off the screen, off the checkout and off that remote. + * + * ## One suite, two behaviors, by runtime + * + * Deno is where the entry capability exists, so the Deno branch runs the + * journey. Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not — so their branch runs the other behavior + * that matters, which is that `` there refuses before it contacts + * anything. Neither branch silently skips its own behavior. + * + * The journey itself is `support/repl-push-journey.ts`, shared with the + * compiled row so the two cannot drift. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { cliRuntime, cliShellCommand } from "@executablemd/test-support/launch"; +import type { Operation } from "effection"; +import { replPushJourney, replUnsupportedPush } from "./support/repl-push-journey.ts"; + +const JOURNEY = cliRuntime() === "deno"; + +/** How this suite invokes `xmd`: the production entrypoint for this runtime. */ +function sourceCommand(args: readonly string[]): string { + return cliShellCommand([...args]); +} + +describe("a REPL entry's publication, across leaving and coming back", () => { + it( + JOURNEY + ? "E1: publishes once, answers again on reopening, and a new entry publishes today's branch" + : "E9: this runtime refuses before it contacts a remote", + function* (): Operation { + yield* JOURNEY ? replPushJourney(sourceCommand) : replUnsupportedPush(sourceCommand); + }, + ); +}); diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts new file mode 100644 index 000000000..f00a50917 --- /dev/null +++ b/packages/cli/tests/repl-push.test.ts @@ -0,0 +1,450 @@ +/** + * How a REPL entry gets its publication capability, and who never gets one. + * + * The entry host is the one thing this command prepares per *entry*, and with + * publication it prepares two things at once: the declarations and admissions + * an entry runs with, and the private initialization its own checkouts, local + * Git and publications are created by. These rows hold the assembly to that + * shape — one initialization per executing entry, nothing at all for a session + * that only reads, and the existing capabilities intact beside the new one. + * + * Every row drives the production path: the real selector, the real per-entry + * factory, the real session. Nothing here reaches a Git host, because none of + * what these rows are about happens after a request exists. + * + * They carry E9 of the delivery-4 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, Result } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { appendFile, mkdtemp, open, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { Execution } from "@executablemd/core/api"; +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 { gitPlugin } from "../../git/src/plugin.ts"; +import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.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"; +import { selectReplEntryHost } from "../src/deno-repositories.ts"; +import { assembleReplProfile } from "../src/repl-profile.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; +import { resolveAgentStack } from "../src/agent-stack.ts"; +import { NO_PLUGINS } from "../src/plugin-host.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; +import { ReplHost } from "../src/repl/host.ts"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; + +/** The credential-helper assembly these rows' host selects. Never invoked. */ +const SUITE_HELPER: HelperAssembly = Object.freeze({ + runtime: "source", + platform: "unix", + execPath: "/nonexistent/xmd", +}); + +interface Fixture { + readonly root: string; + readonly checkout: string; + readonly origin: string; + readonly managed: string; +} + +/** One checkout and one bare remote, which is all these rows need. */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-push-cli-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const git = gitSession(denoRepositoryHost(), root); + const checkout = join(root, "checkout"); + const origin = join(root, "origin.git"); + yield* git.run(["init", "--bare", "-b", "main", origin], root); + yield* git.run(["init", "-b", "main", checkout], root); + yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); + yield* git.run(["add", "seed.md"], checkout); + yield* git.run(["commit", "-m", "seed"], checkout); + yield* git.run(["push", origin, "main:refs/heads/main"], checkout); + yield* provide({ root, checkout, origin, managed: join(root, "repositories") }); + }); +} + +/** + * The author and committer this suite's host states. + * + * `` borrows the *invoking person's* identity, which the production + * reader asks native Git for in the environment the command was started in. On + * a developer's machine Git auto-detects one from the operating-system user; a + * continuous-integration runner whose hostname carries no domain can detect + * nothing, `git var` fails, and the commit in these entries would report its + * ordinary missing-identity refusal — failing the row for a reason that is + * about the runner rather than about the assembly these rows are measuring. + */ +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", +}; + +/** That identity, as the host reads one. */ +// deno-lint-ignore require-yield +function* statedIdentity(variable: string): Operation { + return STATED_IDENTITY[variable]; +} + +/** The host half a runtime entrypoint owns, installed here by the suite. */ +function useNodeReplHost(name: string): Operation { + return ReplHost.around( + { + // deno-lint-ignore require-yield + *identify(): Operation { + return name; + }, + *createExclusive([path]: [string]): Operation { + const handle = yield* until(open(path, "wx")); + yield* until(handle.close()); + }, + *appendRecord([path, record]: [string, string]): Operation { + yield* until(appendFile(path, record)); + }, + }, + { at: "min" }, + ); +} + +/** Where every authored path in these entries resolves. */ +function useWorkingDirectory(directory: string): Operation { + return API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return directory; + }, + }, + { at: "min" }, + ); +} + +/** What the Git Plugin contributes for the `repl` command. */ +function* replInstallations(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installs nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + if (installed === undefined) { + throw new Error("the Git Plugin declared nothing for the repl command"); + } + return [{ admissions: [...(installed.admissions ?? [])] }]; +} + +/** What one run's assembly did, as these rows read it. */ +interface Counted { + /** Every owner the session derived and handed to the factory. */ + readonly owners: string[]; + /** How many times core invoked `init` for an execution. */ + initialized: number; +} + +function counted(): Counted { + return { owners: [], initialized: 0 }; +} + +function opened(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function execution(id: string, events: readonly DurableEvent[] = []): ReplExecution { + return { id, stream: new InMemoryStream([...events]) }; +} + +interface Ran { + readonly events: readonly DurableEvent[]; + readonly outcome: Result | undefined; +} + +interface RunOptions { + /** Whether this host selects a managed root at all. */ + readonly root?: string | undefined; + /** Whether this host selects an entry capability at all. */ + readonly host?: boolean; +} + +/** + * One submitted entry, run through the production session. + * + * The factory is the one the production selector selects, and the owner is the + * one the session derives: what these rows are about is that assembly, so none + * of it is substituted. + */ +function submit( + fixture: Fixture, + source: string, + run: ReplExecution, + seen: Counted, + options: RunOptions = {}, +): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + // Counted on the same seam core invokes, so "one initialization per + // executing entry" is measured rather than inferred. + yield* Execution.around({ + *init(_args, next): Operation { + seen.initialized += 1; + yield* next(); + }, + }); + const managed = "root" in options ? options.root : fixture.managed; + const session = opened( + yield* submitReplEntry({ + execution: run, + source, + installations: [...(yield* replInstallations())], + generated: ordinaryGeneratedSelection(), + ...(options.host === false + ? {} + : { + entryInstallation: (owner: string) => { + seen.owners.push(owner); + return replRepositoryInstallation(owner, { + host: denoRepositoryHost(), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + }, + }), + }), + ); + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** One reopened session over a history nobody is continuing. */ +function reopen( + fixture: Fixture, + run: ReplExecution, + seen: Counted, + options: RunOptions = {}, +): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + yield* Execution.around({ + *init(_args, next): Operation { + seen.initialized += 1; + yield* next(); + }, + }); + const managed = "root" in options ? options.root : fixture.managed; + const session = opened( + yield* openReplSession({ + execution: run, + installations: [...(yield* replInstallations())], + generated: ordinaryGeneratedSelection(), + entryInstallation: (owner: string) => { + seen.owners.push(owner); + return replRepositoryInstallation(owner, { + host: denoRepositoryHost(), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + }, + }), + ); + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** The durable types one history holds, in order. */ +function types(events: readonly DurableEvent[]): string[] { + return events.flatMap((event) => (event.type === "yield" ? [event.description.type] : [])); +} + +const PUBLISHING = (fixture: Fixture): string => + [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ``, + "", + ].join("\n"); + +describe("how a REPL entry gets its publication capability", () => { + it("C1: one executing entry initializes once, and publishes under its own owner", function* () { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-cli-1"); + + const ran = yield* submit(fixture, PUBLISHING(fixture), run, seen); + expect(ran.outcome?.ok).toBe(true); + + // Exactly one owner, derived by the session, and exactly one + // initialization: the factory registers it and core calls it once. + expect(seen.owners).toHaveLength(1); + expect(seen.owners[0]).toContain("executablemd.repl"); + expect(seen.initialized).toBe(1); + // The capability its entry ran with is the whole of what this host selects: + // the checkouts, the local Git and the publication, in one history. + const held = types(ran.events); + expect(held).toContain("repl_repository_placement"); + expect(held).toContain("repl_repository_git"); + expect(held).toContain("repl_repository_local"); + expect(held).toContain(REPL_REPOSITORY_PUSH_REQUEST); + expect(held).toContain(GIT_HOST_EFFECT); + }); + + it("C2: a session that only reads a history initializes nothing", function* () { + const fixture = yield* useFixture(); + const first = counted(); + const run = execution("execution-cli-2"); + const ran = yield* submit(fixture, PUBLISHING(fixture), run, first); + expect(ran.outcome?.ok).toBe(true); + expect(first.initialized).toBe(1); + + // Reopened over the settled history: a passive view runs no entry, so the + // factory is never called, nothing is initialized, and no managed root or + // publication is reached. + const passive = counted(); + const again = yield* reopen(fixture, execution(run.id, ran.events), passive); + expect(again.outcome?.ok).toBe(true); + expect(passive.owners).toEqual([]); + expect(passive.initialized).toBe(0); + }); + + it("C3: two entries of one execution are two owners and two initializations", function* () { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-cli-3"); + + const first = yield* submit(fixture, PUBLISHING(fixture), run, seen); + expect(first.outcome?.ok).toBe(true); + const second = yield* submit( + fixture, + [ + ``, + ` second`, + ` `, + ` `, + ` `, + ``, + "", + ].join("\n"), + execution(run.id, first.events), + seen, + ); + expect(second.outcome?.ok).toBe(true); + + expect(seen.initialized).toBe(2); + expect(seen.owners).toHaveLength(2); + expect(new Set(seen.owners).size).toBe(2); + }); + + it("C4: with no entry capability selected, publishing reaches the ordinary refusal", function* () { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-cli-4"); + + // What Node and Bun leave: the components are declared by the Plugin for + // this command, and reaching one is the missing-provider refusal. + const ran = yield* submit(fixture, PUBLISHING(fixture), run, seen, { host: false }); + expect(ran.outcome?.ok).toBe(false); + expect(seen.owners).toEqual([]); + expect(types(ran.events)).not.toContain(REPL_REPOSITORY_PUSH_REQUEST); + expect(types(ran.events)).not.toContain(GIT_HOST_EFFECT); + }); + + it("C5: an entry with no managed root declares the component and publishes nothing", function* () { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-cli-5"); + + const ran = yield* submit( + fixture, + [ + ``, + ` `, + ``, + "", + ].join("\n"), + run, + seen, + { root: undefined }, + ); + expect(ran.outcome?.ok).toBe(false); + // It initialized — an entry with no managed root still records its file + // work — and no publication exists to retain. + expect(seen.initialized).toBe(1); + expect(types(ran.events)).not.toContain(REPL_REPOSITORY_PUSH_REQUEST); + }); + + it("C6: the production selector carries one factory for both capabilities", function* () { + const fixture = yield* useFixture(); + const flags = { + agentProvider: "acpx", + defaultAgent: "claude", + approveAll: false, + approveReads: false, + denyAll: true, + }; + + const selected = yield* scoped(function* (): Operation { + const stack = yield* resolveAgentStack(flags, undefined); + if (!stack.ok) { + throw stack.error; + } + yield* selectReplEntryHost(SUITE_HELPER, fixture.managed); + return yield* assembleReplProfile(stack.value, NO_PLUGINS, { + planWriterRoot: fixture.root, + }); + }); + + const factory = selected.entryInstallation; + expect(typeof factory).toBe("function"); + if (factory === undefined) { + throw new Error("the production selector selected no entry 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. + 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", + ]); + expect((built.admissions ?? []).length).toBe(2); + expect(yield* nothingUnder(fixture.managed)).toBe(true); + }); +}); + +/** Whether this path still holds nothing, which is what inertness looks like. */ +function* nothingUnder(path: string): Operation { + try { + yield* writeTextFile(join(path, "probe"), "probe"); + return false; + } catch { + // The directory does not exist, which is the answer: nothing created it. + return true; + } +} diff --git a/packages/cli/tests/repl-repositories.test.ts b/packages/cli/tests/repl-repositories.test.ts index a5655c590..fc600cc4a 100644 --- a/packages/cli/tests/repl-repositories.test.ts +++ b/packages/cli/tests/repl-repositories.test.ts @@ -43,7 +43,7 @@ import { import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; -import { useDenoReplRepositories } from "../src/deno-repositories.ts"; +import { useReplRepositoryRecording } from "../../git/src/deno/retained-repository/provider.ts"; import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import { ReplHost } from "../src/repl/host.ts"; @@ -149,7 +149,14 @@ function* useCountedRecording(counted: Counted, root: string): Operation { yield* next(); }, }); - yield* useDenoReplRepositories(SUITE_HELPER, root); + // The same seam the per-entry factory registers on, because what this suite + // is about is what an entry records rather than which host assembled it. + yield* Execution.around({ + *init(_args, next): Operation { + yield* useReplRepositoryRecording({ helper: SUITE_HELPER, root }); + yield* next(); + }, + }); } /** diff --git a/packages/cli/tests/support/repl-push-journey.ts b/packages/cli/tests/support/repl-push-journey.ts new file mode 100644 index 000000000..ecd966a52 --- /dev/null +++ b/packages/cli/tests/support/repl-push-journey.ts @@ -0,0 +1,506 @@ +/** + * The one real-terminal journey an entry's publications rest on. + * + * Shared by two rows that differ in exactly one thing: which `xmd` runs. The + * Deno source row proves the entry capability this repository builds; the + * compiled row proves the binary carries it. Writing the journey twice would + * let the two drift, and a compiled row asserting something slightly weaker + * than the source row is how a packaging defect survives. + * + * Every other row of this feature's evidence installs its own host, which is + * what makes those rows portable and deterministic. None of them can show the + * thing a person actually does: type an entry that commits and publishes a + * branch, walk away at a question, advance the checkout from a shell, and come + * back to the printed location. So this launches the production entrypoint on a + * pty against a bare remote on disk, and reads the answer off the screen, off + * the checkout and off that remote. + * + * Nothing is substituted but `git` itself, and that only to count invocations: + * the shim forwards unchanged to the real Git, which is the only way to ask a + * *packaged* binary whether a reopened publication ran anything at all. + */ + +import { expect } from "@executablemd/test-support/expect"; +import { cliRuntime, shellQuote } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { scoped, sleep, until } from "effection"; +import type { Operation } from "effection"; +import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; +import { execFile } from "node:child_process"; +import { chmod, mkdir, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import { allocate, showing, showingAcross } from "./pty.ts"; +import type { Pty } from "./pty.ts"; + +/** + * How long reaching the screen may take, and how long one interaction may take. + * + * Reaching the screen compiles or loads an entrypoint, which is slow and + * uninteresting. Everything after that is the subject. + */ +const REACH_MS = 180_000; +const ACT_MS = 60_000; + +/** How this row's `xmd` is invoked, as one shell command. */ +export type ReplCommand = (args: readonly string[]) => string; + +/** The author and committer this journey's entry commits under. */ +const JOURNEY_IDENTITY = { name: "Journey Author", email: "journey-author@example.test" }; + +/** The branch this journey publishes, which is not the repository's primary one. */ +const BRANCH = "review/push"; + +const QUESTION = + 'Published commit: {commit}. Continue this experiment?'; + +/** + * The entry this journey types, as the specification shows it. + * + * A managed Repository, a Worktree whose captured path a `` renders at, a + * commit inside that linked checkout, the publication, the question that shows + * what was committed, and a write after it. The publication is written from + * inside the captured path, which is how a document reaches a linked checkout. + */ +function entry(origin: string, file: string): string { + return [ + ``, + '', + "", + `ready`, + ``, + '', + "", + QUESTION, + 'continued', + "", + "", + ].join("\n"); +} + +/** A second entry, which publishes whatever that worktree holds now. */ +function republishing(origin: string): string { + return [ + ``, + '', + "", + "", + "published again", + "", + "", + ].join("\n"); +} + +/** + * Run the whole journey, and answer with nothing. + * + * Takes the command rather than building one, so the only difference between + * the two rows that call this is which `xmd` they name. + */ +export function replPushJourney(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-push-pty-"); + const shim = yield* gitShim(home); + yield* seedIdentity(home); + const origin = yield* seedBareOrigin(home); + // Typed from a directory inside no checkout, so what the entry works in is + // the placement it selected. + const away = join(home, "away"); + yield* until(mkdir(away, { recursive: true })); + const open = (args: readonly string[]): string => + `cd ${shellQuote(away)} && ${shim.prefix} ${repl(args)}`; + + const first = yield* allocate(open(["repl", "--deny-all"]), home); + yield* showing(first, "[exit]", REACH_MS); + yield* first.type(entry(origin, "notes.md")); + yield* sleep(500); + yield* first.type("\r"); + + const working = yield* managedCheckout(home, "worktrees"); + + // The prefix ran: the file is there, the commit was made, the branch was + // published, and the write after the question is not there. + yield* holding(join(working, "notes.md"), "ready"); + yield* showing(first, "waiting for an answer", ACT_MS); + expect(yield* exists(join(working, "after.md"))).toBe(false); + const published = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(published).toMatch(/^[0-9a-f]{40}$/); + yield* remoteHolding(origin, BRANCH, published, home); + // One publication, and the repository's primary branch is untouched: a + // Push publishes the branch the checkout is on and nothing else. + expect(yield* pushes(shim.log)).toHaveLength(1); + const primary = yield* remoteRef(origin, "main", home); + expect(primary).toMatch(/^[0-9a-f]{40}$/); + expect(primary).not.toBe(published); + + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + + // Changed from outside: the branch this entry published advances while the + // person is away, which is exactly what this execution model allows. + yield* writeTextFile(join(working, "later.md"), "later\n"); + yield* gitSaid(working, ["add", "later.md"], home); + yield* gitSaid(working, ["commit", "-m", "theirs"], home); + const advanced = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(advanced).not.toBe(published); + + // Forgotten here, so what the log holds afterwards is what *reopening* ran. + yield* writeTextFile(shim.log, ""); + + const second = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(second, "[exit]", REACH_MS); + yield* showing(second, "waiting for an answer", ACT_MS); + + // The acknowledged publication did not happen again: no `git` ran at all, + // and the destination still holds the commit that was published rather + // than the one the checkout has moved to. + expect(yield* logged(shim.log)).toEqual([]); + expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); + expect(yield* gitSaid(working, ["rev-parse", "HEAD"], home)).toBe(advanced); + expect(yield* exists(join(working, "after.md"))).toBe(false); + + yield* reach(second, "[answer]"); + yield* second.type("\r"); + // The saved commit, which describes what was published rather than where + // the branch is now. + yield* showing(second, `Published commit: ${published}`, ACT_MS); + yield* click(second, "( ) continue"); + yield* showing(second, "[ok] entry-1", ACT_MS); + + // The suffix write executed, for the first time, on the answer — and + // answering published nothing further. + yield* holding(join(working, "after.md"), "continued"); + expect(yield* pushes(shim.log)).toEqual([]); + expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + + // A separate new entry: its publication is new work, so it resolves today's + // branch and advances the destination to what the checkout holds now. + const third = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(third, "[exit]", REACH_MS); + yield* third.type(republishing(origin)); + yield* sleep(500); + yield* third.type("\r"); + yield* showing(third, "published again", ACT_MS); + yield* remoteHolding(origin, BRANCH, advanced, home); + expect(yield* pushes(shim.log)).toHaveLength(1); + expect(yield* remoteRef(origin, "main", home)).toBe(primary); + + yield* reach(third, "[exit]"); + yield* third.type("\r"); + expect(yield* ended(third)).toBe(0); + }); +} + +/** + * What a publication does on a runtime with no entry capability. + * + * Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not got — so `` there says no + * provider is installed rather than reaching a remote, and says it before + * anything is contacted or any managed root is made. + */ +export function replUnsupportedPush(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-push-pty-"); + const shim = yield* gitShim(home); + yield* seedIdentity(home); + const origin = yield* seedBareOrigin(home); + const head = yield* remoteRef(origin, "main", home); + yield* writeTextFile(shim.log, ""); + + const pty = yield* allocate( + `cd ${shellQuote(home)} && ${shim.prefix} ${repl(["repl", "--deny-all"])}`, + home, + ); + yield* showing(pty, "[exit]", REACH_MS); + yield* pty.type( + [ + "", + ``, + "", + "", + "", + 'continued', + ].join("\n"), + ); + yield* sleep(500); + yield* pty.type("\r"); + + // Refused before a managed checkout exists, which is before a remote exists + // in the story at all. The refusal is a sentence, wrapped where the + // Transcript's own width cuts it, so it is read across those rows. + yield* showingAcross(pty, "composition provider is installed", ACT_MS); + yield* holding(join(home, "after.md"), "continued"); + expect(yield* logged(shim.log)).toEqual([]); + expect(yield* remoteRef(origin, "main", home)).toBe(head); + expect(yield* exists(join(home, ".xmd", "repositories"))).toBe(false); + + yield* reach(pty, "[exit]"); + yield* pty.type("\r"); + expect(yield* ended(pty)).toBe(0); + }); +} + +/** A bare repository with one commit on its primary branch. */ +function* seedBareOrigin(home: string): Operation { + const origin = join(home, "origin.git"); + const seed = join(home, "seed"); + yield* until(mkdir(seed, { recursive: true })); + yield* git(home, ["init", "--bare", "-b", "main", origin]); + yield* git(home, ["init", "-b", "main", seed]); + yield* writeTextFile(join(seed, "README.md"), "origin\n"); + yield* git(seed, ["add", "README.md"], home); + yield* git(seed, ["commit", "-m", "first"], home); + yield* git(seed, ["push", origin, "main:refs/heads/main"], home); + return origin; +} + +/** The commit a ref on the bare remote holds, or the empty string for absence. */ +function* remoteRef(origin: string, branch: string, home: string): Operation { + const shown = yield* gitSaid(origin, ["for-each-ref", "--format=%(refname)|%(objectname)"], home); + for (const line of shown.split("\n")) { + const [name, commit] = line.split("|"); + if (name === `refs/heads/${branch}` && commit !== undefined) { + return commit; + } + } + return ""; +} + +/** Wait until the remote's branch holds this exact commit. */ +function* remoteHolding( + origin: string, + branch: string, + commit: string, + home: string, +): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const held = yield* remoteRef(origin, branch, home); + if (held === commit) { + return; + } + if (Date.now() > deadline) { + throw new Error( + `${origin} never held ${commit} at refs/heads/${branch}; it holds ` + + JSON.stringify(held === "" ? "" : held), + ); + } + yield* sleep(250); + } +} + +/** Every `git push` the shim has seen since it was last forgotten. */ +function* pushes(log: string): Operation { + return (yield* logged(log)).filter((line) => line.split(" ").includes("push")); +} + +/** + * A `git` on `PATH` that logs every invocation and forwards to the real one. + * + * Counting from outside the process is the only way to ask a *packaged* binary + * whether a reopened publication ran anything, because a binary has no seam a + * suite can substitute. Only the `xmd` invocation gets it on `PATH`; this + * journey's own fixture commands keep the host's own Git, so the log holds what + * the document did and nothing this harness did. + */ +function* gitShim(home: string): Operation<{ readonly prefix: string; readonly log: string }> { + const real = yield* until( + new Promise((resolve, reject) => { + execFile("sh", ["-c", "command -v git"], (error, stdout) => + error === null ? resolve(stdout.trim()) : reject(error), + ); + }), + ); + if (real === "") { + throw new Error("this host has no git on PATH, so there is nothing to forward to"); + } + const directory = join(home, "git-shim"); + yield* until(mkdir(directory, { recursive: true })); + const log = join(home, "git-commands.log"); + const shim = join(directory, "git"); + yield* writeTextFile( + shim, + `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(log)}\nexec ${shellQuote(real)} "$@"\n`, + ); + yield* until(chmod(shim, 0o755)); + yield* writeTextFile(log, ""); + return { prefix: `PATH=${shellQuote(directory)}:$PATH`, log }; +} + +/** Every `git` the shim has seen since it was last forgotten. */ +function* logged(log: string): Operation { + const held = (yield* exists(log)) ? yield* readTextFile(log) : ""; + return held.split("\n").filter((line) => line !== ""); +} + +/** Give this journey's isolated home a Git identity to commit under. */ +function* seedIdentity(home: string): Operation { + yield* writeTextFile( + join(home, ".gitconfig"), + `[user]\n\tname = ${JOURNEY_IDENTITY.name}\n\temail = ${JOURNEY_IDENTITY.email}\n`, + ); +} + +/** One `git` command, in this journey's own isolated home. */ +function git(cwd: string, args: readonly string[], home = cwd): Operation { + return until( + new Promise((resolve, reject) => { + execFile("git", [...args], { cwd, env: environment(home) }, (error) => + error === null ? resolve() : reject(error), + ); + }), + ); +} + +/** What one `git` command printed, for a fixture that reads a repository. */ +function gitSaid(cwd: string, args: readonly string[], home: string): Operation { + return until( + new Promise((resolve, reject) => { + execFile("git", [...args], { cwd, env: environment(home) }, (error, stdout) => + error === null ? resolve(stdout.trim()) : reject(error), + ); + }), + ); +} + +function environment(home: string): Record { + return { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_AUTHOR_NAME: "Journey", + GIT_AUTHOR_EMAIL: "journey@example.test", + GIT_COMMITTER_NAME: "Journey", + GIT_COMMITTER_EMAIL: "journey@example.test", + }; +} + +/** + * The one managed checkout this journey's entry created, once it is there. + * + * Found rather than predicted. A slot is a digest of the whole identity under a + * root this journey never names — the command decides where + * `~/.xmd/repositories` is from the home it was given — so looking for the one + * checkout beneath that layout is what asks the question the person asks. + */ +function* managedCheckout(home: string, kind: "repositories" | "worktrees"): Operation { + const base = join(home, ".xmd", "repositories", kind); + const depth = kind === "repositories" ? 1 : 2; + const deadline = Date.now() + ACT_MS; + while (true) { + const found = yield* slotsBeneath(base, depth); + if (found.length === 1 && (yield* exists(join(found[0] ?? "", "checkout")))) { + return join(found[0] ?? "", "checkout"); + } + if (Date.now() > deadline) { + throw new Error( + `no single managed ${kind} slot appeared beneath ${base}; found ${JSON.stringify(found)}`, + ); + } + yield* sleep(200); + } +} + +/** Every slot directory at this depth beneath a managed layout root. */ +function* slotsBeneath(base: string, depth: number): Operation { + if (!(yield* exists(base))) { + return []; + } + let level = [base]; + for (let step = 0; step < depth; step += 1) { + const next: string[] = []; + for (const directory of level) { + for (const name of yield* until(readdir(directory))) { + next.push(join(directory, name)); + } + } + level = next; + } + return level; +} + +/** Walk the focus ring until this control is the marked one. */ +function* reach(pty: Pty, label: string): Operation { + const deadline = Date.now() + ACT_MS; + while (!(yield* pty.shows()).some((line) => line.includes(`> ${label}`))) { + if (Date.now() > deadline) { + throw new Error( + `focus never reached ${label} under ${cliRuntime()}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* pty.type("\t"); + yield* sleep(250); + } +} + +/** Activate the control holding this text with a pointer. */ +function* click(pty: Pty, text: string): Operation { + const screen = yield* pty.shows(); + const row = screen.findIndex((line) => line.includes(text)); + if (row < 0) { + throw new Error( + `nothing on this screen holds ${JSON.stringify(text)} to click. screen=` + + JSON.stringify(screen.map((one) => one.trimEnd())), + ); + } + const column = (screen[row] ?? "").indexOf(text) + 1; + yield* pty.type(`\u001b[<0;${column};${row + 1}M`); + yield* pty.type(`\u001b[<0;${column};${row + 1}m`); + yield* sleep(300); +} + +/** Wait until this file holds this exact text. */ +function* holding(path: string, content: string): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + if ((yield* exists(path)) && (yield* readTextFile(path)) === content) { + return; + } + if (Date.now() > deadline) { + const found = (yield* exists(path)) ? yield* readTextFile(path) : ""; + throw new Error( + `${path} never held ${JSON.stringify(content)}; it holds ${JSON.stringify(found)}`, + ); + } + yield* sleep(200); + } +} + +/** Wait until this command finished, and answer with its exit status. */ +function* ended(pty: Pty): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const status = yield* pty.status(); + if (status !== undefined) { + return status; + } + if (Date.now() > deadline) { + throw new Error( + `the command never finished under ${cliRuntime()}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* sleep(150); + } +} + +/** The one location this command printed for a caller to come back with. */ +function printedLocation(screen: string): string { + const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); + if (found === null || found[1] === undefined) { + throw new Error( + `this command printed no reopenable location. screen=${JSON.stringify(screen)}`, + ); + } + return found[1]; +} diff --git a/packages/git/deno.ts b/packages/git/deno.ts index 203197227..91545603f 100644 --- a/packages/git/deno.ts +++ b/packages/git/deno.ts @@ -50,6 +50,17 @@ export { useRunComposition } from "./src/deno/run-composition/provider.ts"; export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; export { useReplRepositoryRecording } from "./src/deno/retained-repository/provider.ts"; export type { ReplRecordingOptions } from "./src/deno/retained-repository/provider.ts"; +/** + * One REPL entry's repository capability, as the per-entry factory the CLI + * calls inside that entry's own task. + * + * The factory alone. What it establishes — the recorder, the selection + * registry, the registered checkouts, the managed slots, the native turn and + * the publication's own request records — stays inside the closure it delivers + * to: a package that could reach one of those could publish a branch under an + * entry it does not own, or read back the receipt of one. + */ +export { replRepositoryInstallation } from "./src/deno/retained-repository/provider.ts"; /** * One REPL entry's issue capability, as the inert value the CLI selects. * diff --git a/packages/git/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts index 82840c9c8..f07ce248b 100644 --- a/packages/git/src/composition/components/GitPush.ts +++ b/packages/git/src/composition/components/GitPush.ts @@ -19,7 +19,7 @@ import { cwd } from "@executablemd/runtime"; import { hasContent } from "@executablemd/core"; -import type { PropsSchema } from "@executablemd/core"; +import type { FirstPartyDocumentation, PropsSchema } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { Git } from "../git-api.ts"; @@ -35,6 +35,21 @@ 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. + */ +export const documentation: FirstPartyDocumentation = { + description: + `Publish the current branch. \`\` pushes its current commit to the same branch ` + + `on \`origin\` without forcing.`, + as: null, + context: null, +}; + export default function* GitPush(_props: Record): Operation { if (yield* hasContent()) { throw new GitOperationError( diff --git a/packages/git/src/composition/git-api.ts b/packages/git/src/composition/git-api.ts index 63bbb82ab..dfd5f5d59 100644 --- a/packages/git/src/composition/git-api.ts +++ b/packages/git/src/composition/git-api.ts @@ -96,7 +96,10 @@ export interface GitApi { push(invocation: GitPushInvocation): Operation; } -export const Git: Api = createApi("executablemd.git", { +/** The stable name every loaded copy composes Git operations through. */ +export const GIT_API = "executablemd.git"; + +export const Git: Api = createApi(GIT_API, { // deno-lint-ignore require-yield *switch(_invocation: GitSwitchInvocation): Operation { throw new GitCompositionProviderError(""); diff --git a/packages/git/src/composition/installation.ts b/packages/git/src/composition/installation.ts index 7d912ea35..8e7b30796 100644 --- a/packages/git/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -44,7 +44,10 @@ import GitCommit, { props as gitCommitProps, returns as gitCommitReturns, } from "./components/GitCommit.ts"; -import GitPush, { props as gitPushProps } from "./components/GitPush.ts"; +import GitPush, { + documentation as gitPushDocumentation, + props as gitPushProps, +} from "./components/GitPush.ts"; import PullRequest, { props as pullRequestProps, returns as pullRequestReturns, @@ -147,11 +150,7 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ origin: COMPOSITION_ORIGIN, props: gitPushProps, fn: GitPush, - ...documented({ - description: `Publish the current branch. \`\` pushes its current commit to the same branch on \`origin\` without forcing.`, - as: null, - context: null, - }), + ...documented(gitPushDocumentation), }, { name: "PullRequest", diff --git a/packages/git/src/composition/locator.ts b/packages/git/src/composition/locator.ts new file mode 100644 index 000000000..457d8db52 --- /dev/null +++ b/packages/git/src/composition/locator.ts @@ -0,0 +1,103 @@ +/** + * Admitting a Git locator, and naming one without publishing it. + * + * A locator is ordinary document input: it arrives from a prop or an + * expression, and nothing about it has been checked. Two questions are asked + * here and they are different questions. **Admission**, which is this module, + * decides whether this provider will hand the string to Git at all. + * **Fingerprinting** produces the stable name the journal, the record and every + * compatibility comparison use, so a changed locator diverges without the bytes + * of either one being retained outside the single column that holds them. + * + * Admission is pure string arithmetic over one closed grammar, which is why it + * lives beside the records rather than inside an adapter: the native providers + * admit a locator a document wrote, and the retained Push codec holds a saved + * origin to the same grammar without importing a provider to do it. + * + * Fingerprinting is next door, in `../deno/composition/locator.ts`, and not + * because it is a provider's question. It is the digest: this surface names no + * runtime, and the only digest a surface like that can reach is Web Crypto's, + * which is asynchronous — while the retained paths that recompute a fingerprint + * to check a path or a locator answer without performing anything at all. One + * implementation of one digest, in the half that has a synchronous one, and the + * codec here decides every question that does not need it. + * + * Admission is a closed allowlist rather than a search for bad shapes. Git's + * locator grammar reaches well past URLs — `ext::sh -c …` runs a command, a + * leading `-` is read as an option, and a transport helper is whatever is on + * `PATH` — so anything not recognized as one of the five admitted forms is + * refused. Credentials in the string are refused rather than stripped: a + * locator that carries one is a secret the caller put in a durable input, and + * quietly editing it would retain a run nobody asked for. + */ + +/** Schemes this provider hands to Git. Everything else is refused. */ +const SCHEMES = new Set(["https", "http", "ssh", "git", "file"]); + +/** `user@host:path`, Git's scp-like form. A colon in the userinfo is a password. */ +const SCP_LIKE = /^([^/@:]+)@([^/@:]+):(.+)$/; + +function hasControlCharacters(value: string): boolean { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) { + return true; + } + } + return false; +} + +function admitUrl(locator: string): string | undefined { + let url: URL; + try { + url = new URL(locator); + } catch { + return undefined; + } + const scheme = url.protocol.replace(/:$/, ""); + if (!SCHEMES.has(scheme)) { + return undefined; + } + if (url.username !== "" || url.password !== "") { + return undefined; + } + // A query or a fragment is refused whole rather than searched for credentials. + // `?access_token=…` is the ordinary way a token is written into a URL, and a + // rule that named the parameters worth refusing would be a list of the ones + // somebody thought of — the same open-ended guessing this module rejects + // everywhere else. Git is given a repository's location, and neither part + // carries any of that location for the transports admitted here. + if (url.search !== "" || url.hash !== "") { + return undefined; + } + return locator; +} + +/** + * The locator this string is, or `undefined` when this provider will not use it. + * + * The answer is the original bytes, never a rewritten form: what is admitted is + * what Git is given and what the fingerprint names, so the three cannot drift. + */ +export function admitLocator(locator: string): string | undefined { + if (locator === "" || hasControlCharacters(locator) || /\s/.test(locator)) { + return undefined; + } + if (locator.startsWith("-")) { + return undefined; + } + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(locator)) { + return admitUrl(locator); + } + const scpLike = SCP_LIKE.exec(locator); + if (scpLike !== null) { + return locator; + } + // A local path. Absolute only: a relative one would name a different + // repository depending on which directory the host happened to run in, and a + // workflow's retained identity must not depend on that. + if (locator.startsWith("/")) { + return locator; + } + return undefined; +} diff --git a/packages/git/src/deno/composition/locator.ts b/packages/git/src/deno/composition/locator.ts index 7b9198d60..3d0de105d 100644 --- a/packages/git/src/deno/composition/locator.ts +++ b/packages/git/src/deno/composition/locator.ts @@ -1,95 +1,24 @@ /** - * Admitting a Git locator, and naming one without publishing it. + * The stable name an admitted locator is known by. * - * A locator is ordinary document input: it arrives from a prop or an - * expression, and nothing about it has been checked. Two questions are asked - * here and they are different questions. **Admission** decides whether this - * provider will hand the string to Git at all. **Fingerprinting** produces the - * stable name the journal, the record and every compatibility comparison use, - * so a changed locator diverges without the bytes of either one being retained - * outside the single column that holds them. + * The grammar a locator is admitted through is provider-neutral and lives in + * `../../composition/locator.ts`; this is the other half of that module's + * prose. A fingerprint is the digest of the admitted bytes, and the retained + * paths that recompute one — holding a stored row to the identity that names + * it, holding a saved origin to the Repository it belongs to — answer without + * performing anything at all, so the digest they use has to be synchronous. + * `node:crypto` has one and Web Crypto does not, which is why this is here, + * where naming a runtime is what the module is for, rather than beside the + * grammar, where naming one is what that surface refuses. * - * Admission is a closed allowlist rather than a search for bad shapes. Git's - * locator grammar reaches well past URLs — `ext::sh -c …` runs a command, a - * leading `-` is read as an option, and a transport helper is whatever is on - * `PATH` — so anything not recognized as one of the five admitted forms is - * refused. Credentials in the string are refused rather than stripped: a - * locator that carries one is a secret the caller put in a durable input, and - * quietly editing it would retain a run nobody asked for. + * Admission is re-exported so a caller that needs both asks one module for + * them: the two questions are about one string and the answer to the second has + * to be the digest of what the first admitted. */ import { createHash } from "node:crypto"; -/** Schemes this provider hands to Git. Everything else is refused. */ -const SCHEMES = new Set(["https", "http", "ssh", "git", "file"]); - -/** `user@host:path`, Git's scp-like form. A colon in the userinfo is a password. */ -const SCP_LIKE = /^([^/@:]+)@([^/@:]+):(.+)$/; - -function hasControlCharacters(value: string): boolean { - for (const character of value) { - const code = character.codePointAt(0) ?? 0; - if (code < 0x20 || code === 0x7f) { - return true; - } - } - return false; -} - -function admitUrl(locator: string): string | undefined { - let url: URL; - try { - url = new URL(locator); - } catch { - return undefined; - } - const scheme = url.protocol.replace(/:$/, ""); - if (!SCHEMES.has(scheme)) { - return undefined; - } - if (url.username !== "" || url.password !== "") { - return undefined; - } - // A query or a fragment is refused whole rather than searched for credentials. - // `?access_token=…` is the ordinary way a token is written into a URL, and a - // rule that named the parameters worth refusing would be a list of the ones - // somebody thought of — the same open-ended guessing this module rejects - // everywhere else. Git is given a repository's location, and neither part - // carries any of that location for the transports admitted here. - if (url.search !== "" || url.hash !== "") { - return undefined; - } - return locator; -} - -/** - * The locator this string is, or `undefined` when this provider will not use it. - * - * The answer is the original bytes, never a rewritten form: what is admitted is - * what Git is given and what the fingerprint names, so the three cannot drift. - */ -export function admitLocator(locator: string): string | undefined { - if (locator === "" || hasControlCharacters(locator) || /\s/.test(locator)) { - return undefined; - } - if (locator.startsWith("-")) { - return undefined; - } - if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(locator)) { - return admitUrl(locator); - } - const scpLike = SCP_LIKE.exec(locator); - if (scpLike !== null) { - return locator; - } - // A local path. Absolute only: a relative one would name a different - // repository depending on which directory the host happened to run in, and a - // workflow's retained identity must not depend on that. - if (locator.startsWith("/")) { - return locator; - } - return undefined; -} +export { admitLocator } from "../../composition/locator.ts"; /** The stable name an admitted locator is known by everywhere but its own column. */ export function locatorFingerprint(locator: string): string { diff --git a/packages/git/src/deno/composition/object-source.ts b/packages/git/src/deno/composition/object-source.ts index abe009c36..c848c76fe 100644 --- a/packages/git/src/deno/composition/object-source.ts +++ b/packages/git/src/deno/composition/object-source.ts @@ -406,17 +406,14 @@ function* containedAlternates( } /** - * The object database this checkout publishes from, once it is contained. + * The Git administration directory an exported checkout family shares. * - * Three questions in one order, and the order is what makes each answerable. - * The database has to be the retained Repository's own — a linked worktree - * shares it, which is why one proof covers both kinds of checkout. It has to be - * a real directory that resolves to itself, so the walk below is walking the - * export rather than somewhere a link led. And then everything Git may traverse - * from it — every entry beneath it, and every object directory the alternates - * chain names — has to resolve inside it. + * The Workflow proof, kept with the caller that needs it: a workflow run + * materializes a checkout under the repository directory it exported, so the + * directory Git reports as common has to be that export's own. A linked + * worktree shares it, which is why one proof covers both kinds of checkout. */ -function* containedObjectDatabase(checkout: GitCheckout, operation: string): Operation { +function* exportedFamily(checkout: GitCheckout, operation: string): Operation { const administration = `${checkout.repositoryDirectory}/.git`; if ((yield* commonDirectory(checkout.git, checkout.directory)) !== administration) { uncontained( @@ -424,7 +421,27 @@ function* containedObjectDatabase(checkout: GitCheckout, operation: string): Ope "the checkout it ran in does not share the retained repository's object database", ); } + return administration; +} +/** + * The object database beneath one authenticated administration directory, once + * it is contained. + * + * Two questions in one order, and the order is what makes each answerable. The + * database has to be a real directory that resolves to itself, so the walk + * below is walking the administration this run authenticated rather than + * somewhere a link led. And then everything Git may traverse from it — every + * entry beneath it, and every object directory the alternates chain names — has + * to resolve inside it. + * + * Which administration directory that is belongs to the caller. A workflow run + * proves its exported family above; a REPL entry hands over the canonical + * common directory its own private placement already authenticated, which is + * the same answer for a primary checkout, a linked worktree and a repository + * whose administration lives somewhere else entirely. + */ +function* containedObjectDatabase(administration: string, operation: string): Operation { const objects = `${administration}/objects`; if ((yield* entry(objects))?.isDirectory() !== true) { uncontained(operation, "the retained repository holds no object database to publish from"); @@ -455,6 +472,50 @@ export function objectSource( checkout: GitCheckout, format: GitObjectFormat, operation: string, +): PushObjectSource { + return controlPlaneSource( + host, + root, + () => exportedFamily(checkout, operation), + format, + operation, + ); +} + +/** + * A source for one push from an administration directory the caller + * authenticated. + * + * The same control repository, the same read-only alternate and the same + * containment proof as above. What differs is who decided which administration + * directory this is: here it arrives already authenticated, so nothing asks + * Git where the checkout's common directory is. + */ +export function objectSourceAt( + host: RepositoryHost, + root: string, + administration: string, + format: GitObjectFormat, + operation: string, +): PushObjectSource { + // deno-lint-ignore require-yield + return controlPlaneSource( + host, + root, + function* () { + return administration; + }, + format, + operation, + ); +} + +function controlPlaneSource( + host: RepositoryHost, + root: string, + administration: () => Operation, + format: GitObjectFormat, + operation: string, ): PushObjectSource { const git = gitSession(host, root); const directory = `${root}/control`; @@ -463,7 +524,7 @@ export function objectSource( git, *ready(): Operation { if (!built) { - const objects = yield* containedObjectDatabase(checkout, operation); + const objects = yield* containedObjectDatabase(yield* administration(), operation); yield* initControlPlane(git, root, directory, format); yield* ensureDir(`${directory}/objects/info`); yield* writeTextFile(`${directory}/objects/info/alternates`, `${objects}\n`); diff --git a/packages/git/src/deno/composition/push.ts b/packages/git/src/deno/composition/push.ts index ecdd2e1ed..84fc88096 100644 --- a/packages/git/src/deno/composition/push.ts +++ b/packages/git/src/deno/composition/push.ts @@ -255,18 +255,19 @@ function prepareRequestSource( /** * The provider that answers this exact reconciliation, and no other. * - * Installed around one `reconcileGitHostEffect()` call and reachable only from - * inside it. Its closure holds the retained locator and the object source; what + * Installed around one reconciliation and reachable only from inside it — a + * workflow run's, or a REPL entry's, which reuses this exact provider rather + * than writing a second Push state machine. Its closure holds the retained locator and the object source; what * it receives from the engine is the frozen request, which it parses and holds * to the inputs this invocation admitted. A request naming another Repository, * branch or commit is not this invocation's, and answering one would publish a * completion for something this operation never admitted. */ -function pushProvider( +export function pushProvider( source: PushObjectSource, locator: string, admitted: GitPushInputs, - session: GitAuthenticationSession, + session: () => Operation, ): GitHostProvider { function admit(request: CompleteGitHostEffectRequest): GitPushInputs { const inputs = parseGitPushInputs(request.inputs); @@ -302,7 +303,7 @@ function pushProvider( locator, inputs.destinationRef, inputs.repository.objectFormat, - session, + yield* session(), ); if (observed.state === "unreachable") { @@ -354,7 +355,8 @@ function pushProvider( const directory = yield* source.ready(); const refspec = refspecFor(inputs.sourceCommit, inputs.destinationRef); - if (yield* pushRefspec(source.git, directory, locator, refspec, session)) { + const authenticated = yield* session(); + if (yield* pushRefspec(source.git, directory, locator, refspec, authenticated)) { return Ok(completion(inputs.sourceCommit)); } @@ -368,7 +370,7 @@ function pushProvider( locator, inputs.destinationRef, inputs.repository.objectFormat, - session, + authenticated, ); if (observed.state === "present" && observed.commit === inputs.sourceCommit) { return Ok(completion(observed.commit)); @@ -381,6 +383,18 @@ function pushProvider( }; } +/** + * The session this operation already holds, as the provider's lazy accessor. + * + * A workflow run opens one for the whole reconciliation before the provider + * exists; a REPL entry opens one the first time a phase needs it. The provider + * asks either way, which is what keeps one implementation for both. + */ +// deno-lint-ignore require-yield +function* acquired(session: GitAuthenticationSession): Operation { + return session; +} + function resultOf(inputs: GitPushInputs, observedRemoteCommit: string): GitPushResult { return { repository: inputs.repository, @@ -484,7 +498,7 @@ export function* createGitPush( const session = yield* useGitAuthentication(host, locator); const record = yield* withGitHostProvider( - pushProvider(source, locator, inputs, session), + pushProvider(source, locator, inputs, () => acquired(session)), reconcileGitHostEffect({ kind: GIT_PUSH, inputs: gitPushInputsJson(inputs), diff --git a/packages/git/src/deno/retained-repository/git.ts b/packages/git/src/deno/retained-repository/git.ts index f22a8ee47..67a32f3ec 100644 --- a/packages/git/src/deno/retained-repository/git.ts +++ b/packages/git/src/deno/retained-repository/git.ts @@ -190,9 +190,10 @@ export interface ReplGitEntry { * the request recorded. A call answered wholly by a replacement never reaches * this provider and acquires none of its retention. * - * Push is absent. Publication has its own protocol and its own evidence, and a - * local result authorizes none of it, so `` reaches the ordinary - * missing-provider refusal it reaches today. + * Push is not one of these. Publication has its own protocol and its own + * evidence, and a local result authorizes none of it, so it is recorded by + * `push.ts` beside this: one request written down before a remote is contacted, + * and the Git host's own answer reconciled afterwards. */ export function replGitMiddleware(entry: ReplGitEntry): Partial> { return { diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index 5470440f9..44fbbb20e 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -18,19 +18,23 @@ * ## What it is not * * This records file and directory work, the managed checkouts a document - * selects to do it in, and the three local Git operations: authored and - * generated writes, authored deletions, the directory an authored `` - * ensures, the `` and `` selections that say where, and - * ``, `` and `` in the checkout those select. - * There is nothing external — no ``, no pull request, no Issue. Those - * components stay declared where they already are and refuse through their own - * unchanged missing-provider defaults, and what is installed here is not the - * ordinary all-feature provider, which would enable them. + * selects to do it in, the three local Git operations — authored and generated + * writes, authored deletions, the directory an authored `` ensures, the + * `` and `` selections that say where, and ``, + * `` and `` in the checkout those select — 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. * * ## Installing it reaches nothing * - * No directory, no lease, no `git`, no filesystem read, no commit identity and - * no managed root. The ambient checkout is discovered by the first mutation that + * No directory, no lease, no `git`, no filesystem read, no commit identity, no + * managed root, no credential and no remote. The ambient checkout is discovered by the first mutation that * needs to know whether it is inside one, and an entry that mutates nothing * never looks. The managed root is created by the first selection, and an entry * that selects nothing never makes one. The invoking person's author and @@ -41,7 +45,7 @@ * would anywhere else. */ -import { until, useScope } from "effection"; +import { ensure, until, useScope } from "effection"; import type { Operation, Scope } from "effection"; import { realpath } from "node:fs/promises"; import { ensureDir } from "@effectionx/fs"; @@ -72,6 +76,12 @@ import { replFileMiddleware } from "./files.ts"; import type { ReplLocalRecorder, ReplManagedPlacement } from "./files.ts"; import { replGitMiddleware } from "./git.ts"; import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; +import { replPushAdmission, replPushComponent } from "./push.ts"; +import type { ReplPushEntry } from "./push.ts"; +import { replIssueInstallation } from "./issues.ts"; +import { Execution } from "@executablemd/core/api"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { IdentityComponent, JournalAdmission } from "@executablemd/core/host"; import { placementSubject, selectReplAmbientOwner, @@ -125,7 +135,98 @@ export interface ReplRecordingOptions { * working in that one, and a nested execution that composed its own working * directory is discovered from that. */ -export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): Operation { +export function useReplRepositoryRecording(options: ReplRecordingOptions = {}): Operation { + return initializeReplRepository(options); +} + +/** + * The repository capability one REPL entry runs with, as a per-entry factory. + * + * Called by the CLI inside the entry task, for the exact execution and entry + * key the owner was derived from. Calling it registers this entry's + * `Execution.init` middleware in that task and answers with the declarations, + * the admissions and the installation callback the execution runs in its own + * scope. It acquires nothing and initializes nothing: no directory, no lease, + * no `git`, no credential and no managed root. + * + * Registration belongs to the entry task and initialized state belongs to the + * execution scope beneath it, which is why nothing here is wrapped in a scope + * of its own: a scope that closed when this factory returned would take the + * registration with it before the execution that needs it had started. + * + * ## One initializer, and one holder of what it makes + * + * The init middleware establishes the recording this host has always + * established, and delivers the minimal private entry state into this closure. + * Revocation is armed before that state can be used and runs on success, + * failure and cancellation alike, including a partial initialization — so a + * body that kept the accessor past its own entry reaches nothing. The state is + * 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. + */ +export function* replRepositoryInstallation( + owner: string, + options: ReplRecordingOptions = {}, +): Operation { + const held: { entry?: ReplPushEntry } = {}; + let usable = false; + + yield* Execution.around({ + *init(_args, next): Operation { + // Armed before anything is delivered, and before `next()`: an execution + // halted while it initializes revokes what it had, and one that failed + // halfway through leaves nothing usable behind. + yield* ensure(() => { + usable = false; + delete held.entry; + }); + yield* initializeReplRepository(options, (entry) => { + held.entry = entry; + usable = true; + }); + yield* next(); + }, + }); + + 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; + return { + components, + admissions, + ...(install === undefined ? {} : { install: () => install.call(issues) }), + }; +} + +/** + * Establish one entry's recording, and hand its own private state to whoever + * initialized it. + * + * The one initializer both routes use. `useReplRepositoryRecording()` above + * takes no delivery and keeps its `void` contract, which is what the host-only + * identity seam and every existing consumer are written against. The per-entry + * factory below states one, and what it receives is the minimal set of + * functions a publication needs: this entry's Git session and native host, its + * private selection registry and registered checkouts, its single-flight slot + * acquisition and its native turn. + * + * The delivery is a call rather than a value anything can read. Nothing is + * returned, published through Context, or registered anywhere a document, a + * prop, a plugin or middleware could reach: the only holder is the closure that + * asked for it, and that closure is revoked with this execution. + */ +function* initializeReplRepository( + options: ReplRecordingOptions, + deliver?: (entry: ReplPushEntry) => void, +): Operation { const host = options.host ?? denoRepositoryHost({ @@ -410,4 +511,18 @@ export function* useReplRepositoryRecording(options: ReplRecordingOptions = {}): // middleware a document or a host puts above this composes exactly as it does // in an ordinary run. yield* Git.around(replGitMiddleware(gitEntry), { at: "min" }); + + // Last, and only for the route that asked: everything above is in place, so + // what a publication reaches is an initialized entry rather than a + // half-assembled one. An entry with no managed root returns before this, + // which is what leaves `` the same missing-provider refusal it + // reaches when nothing selected a checkout. + deliver?.({ + host, + git: useGit, + holdSlot, + serialize, + selections, + checkouts: () => gitCheckouts, + }); } diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts new file mode 100644 index 000000000..2c1627fdf --- /dev/null +++ b/packages/git/src/deno/retained-repository/push.ts @@ -0,0 +1,846 @@ +/** + * One REPL entry's ``: asked once, reconciled once, answered from + * what the Git host said. + * + * `git.ts` beside this records Add, Switch and Commit, whose outcomes a local + * transaction encloses. A publication's outcome belongs to a remote, and + * nothing here reaches it, so this operation is built the other way round: it + * resolves one complete request out of the checkout, writes that request down, + * and only then reconciles the external effect through the shared Git-host + * state machine. + * + * That order is the whole feature. An entry that publishes a branch, leaves at + * a question and is reopened an hour later finds the request it already made — + * the same branch, the same commit, the same destination — and reconciles *that* + * rather than whatever HEAD has become since. A completed publication answers + * from its saved receipt and contacts nobody. + * + * ## 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. + * + * 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 + * still answers, transforms, refuses and delegates exactly as it does in an + * ordinary run. What that default adds is retention, and it adds it once: a + * second delegation of the same invocation is refused rather than published + * twice, and the default is revoked when the invocation ends. + * + * A handler that answers without delegating has answered the document, and + * nothing more: no request is written down, no Git host is asked, and no + * receipt exists. A middleware return is never read as publication. + * + * ## A saved request bypasses the middleware it already passed + * + * On a continuation the saved request is consumed before anything is + * dispatched. The transformation a handler applied the first time is already + * inside that record; re-dispatching would let a changed handler retarget + * external work this entry has already admitted, which is the one thing a + * reconciliation must not allow. + * + * ## Resources, and what the window is honest about + * + * A live attempt opens its scratch transport, its object source and its + * credential session inside the attempt's own scope: what observation opened is + * still open when the push performs, and all of it is closed and joined before + * publication may acknowledge anything. The publication window stays explicit — + * a remote can accept a branch and the journal can fail to record it — and the + * next attempt observes the destination and adopts the completion rather than + * repeating it. + */ + +import { createApi } from "@effectionx/context-api"; +import { until, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import { realpath } from "node:fs/promises"; +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { + ActivateDurabilityFailure, + Json, + 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 { + 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 { + destinationRefFor, + parseGitPushRecord, + pushExpectation, + PUSH_REMOTE, + type GitPushInputs, + type GitPushOutcome, +} from "../../composition/git-push-records.ts"; +import { locatorFingerprint } from "../composition/locator.ts"; +import { sameRepositoryIdentity } from "../../composition/selection.ts"; +import type { RepositorySelection } from "../../composition/selection.ts"; +import { reconcileOwnedGitHostEffect } from "../../git-host/effect.ts"; +import type { GitHostProvider } from "../../git-host/api.ts"; +import type { GitHostReconciliationRecord } from "../../git-host/records.ts"; +import { + admitReplPushHistory, + replPushEffectRequest, + replPushPlacesHeld, + type ReplPushPlacement, +} from "../../retained-repository/push-admission.ts"; +import { + parseReplPushRequestRecord, + replPushRequestJson, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + type ReplPushPlace, + type ReplPushRequestRecord, + type ReplPushResolution, +} from "../../retained-repository/push-records.ts"; +import { currentBranch, commitPresent, resolveCommit } from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; +import { useGitAuthentication } from "../composition/host.ts"; +import type { RepositoryHost } from "../composition/host.ts"; +import type { GitAuthenticationSession } from "../composition/authentication.ts"; +import { objectSourceAt } from "../composition/object-source.ts"; +import { pushProvider } from "../composition/push.ts"; +import { gitRefusal } from "../composition/refusals.ts"; +import { lazily } from "../run-composition/lazy.ts"; +import type { SlotKind } from "../run-composition/leases.ts"; +import { liveCheckout, selectCheckout } from "../run-composition/operations.ts"; +import type { SelectionRegistry } from "../selections.ts"; +import { holdManagedPlacement } from "./leases.ts"; +import type { ReplGitCheckout } from "./git.ts"; +import { retainedRecordAhead } from "./local.ts"; +import { placementSubject } from "./selections.ts"; +import type { ReplPlacementRegistration } from "./selections.ts"; + +/** + * What recorded publication needs from the entry that owns it. + * + * The private state one initialized entry delivers into this feature's own + * closure. Every member is a function or a registry this provider created: none + * of it is published through Context, returned to a host, or reachable from a + * document, a prop or middleware. + * + * There is no mutation lease here, and that is deliberate. A publication reads + * authenticated objects and changes a branch on a remote; it changes no + * checkout, index, branch or tracking configuration, so it takes the managed + * slot that says which entry owns a checkout and nothing that excludes another + * entry from changing a repository. + */ +export interface ReplPushEntry { + /** The native Git host, which also lends a locator's authentication. */ + readonly host: RepositoryHost; + /** This entry's Git session, acquired once into the entry's own scope. */ + git(): Operation; + /** Hold a managed slot for the entry, single-flight per root and slot. */ + holdSlot(root: string, kind: SlotKind, path: string, subject: string): Operation; + /** Run live native Git for one shared Git directory in order within this entry. */ + serialize(key: string, work: () => Operation): Operation; + /** This entry's private map from the selections it minted to what it holds. */ + readonly selections: SelectionRegistry; + /** The checkouts this entry has registered, as they stand now. */ + checkouts(): readonly ReplGitCheckout[]; +} + +/** + * This execution's private entry state, or the ordinary refusal. + * + * Revoked with the execution that initialized it, so a body that kept the + * accessor past its own entry reaches nothing. An entry with no managed root + * delivers none at all, which is what leaves `` exactly the + * missing-provider refusal it reaches when no checkout was ever selected. + */ +export type ReplPushAccess = () => ReplPushEntry | undefined; + +/** What this entry requires of the publication records its history holds. */ +export function replPushAdmission(owner: string): JournalAdmission { + return (retained) => admitReplPushHistory(owner, retained); +} + +/** + * The entry's own `Git.Push`, declared as an ordinary shadowable component. + * + * 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. + * + * 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. + */ +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( + 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 ""; + }; +} + +interface Invocation { + readonly owner: string; + readonly site: string; + readonly access: ReplPushAccess; + readonly repository: RepositorySelection; + readonly workingDirectory: string; +} + +/** + * Publish this invocation's branch, or reconcile the request it already made. + * + * The saved request is looked for first, and found without consuming anything: + * a position holding this invocation's own request is answered from that + * request, and only an invocation with nothing saved dispatches, resolves and + * writes one down. + */ +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 + // names nothing once the entry ends, so a durable name built from one would + // differ on every reopen of the same question. + const registration = authenticated(entry, invocation.repository); + const requested: ReplPushPlace = Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + // Exactly as received. Resolving it is live routing, which a continuation + // does not do, so the name cannot depend on it. + workingDirectory: invocation.workingDirectory, + }); + const name = yield* replPushRequestName({ + owner: invocation.owner, + site: invocation.site, + requested, + }); + + if (yield* retainedRecordAhead(REPL_REPOSITORY_PUSH_REQUEST, name)) { + const saved = yield* restored(entry, invocation, requested, name); + yield* reconcile(entry, invocation, saved); + return; + } + + // One operation-local default under the stable Api name: the shared + // middleware chain applies, and the chain ends in this invocation's own + // terminal rather than in the public refusing default. Nothing but this + // dispatch holds it, and it is revoked below. + const live: Dispatch = { open: true, delegated: false, settled: undefined }; + const local = createApi(GIT_API, { + push: (request: GitPushInvocation): Operation => + delegated(entry, invocation, requested, name, live, request), + // The three local operations keep the public refusal. They are unreachable + // through this descriptor — the only call made on it is the publication + // below, and middleware receives a continuation rather than the descriptor + // — and declaring them any other way would make this invocation's private + // terminal answer for operations it owns nothing about. + switch: refusing(""), + add: refusing(""), + 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({ + repository: invocation.repository, + workingDirectory: invocation.workingDirectory, + }); + } finally { + live.open = false; + } +} + +/** What one invocation's own default has done, where only it can reach. */ +interface Dispatch { + /** Whether the invocation that captured this default is still running. */ + open: boolean; + /** Whether a delegation has already reached it. */ + delegated: boolean; + /** The outcome the one delegation produced, for a handler that asks again. */ + settled: GitPushOutcome | undefined; +} + +/** + * The one delegation this invocation's default accepts. + * + * It snapshots the received request once, authenticates it, resolves and + * publishes the complete request, and reconciles it. A second use refuses + * before another request or another worker call: the first delegation may have + * already published a branch, and nothing here cancels or repeats acknowledged + * external work. + */ +function* delegated( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPushPlace, + name: string, + live: Dispatch, + request: GitPushInvocation, +): Operation { + if (!live.open) { + throw new GitOperationAdmissionError( + PUSH, + "the publication it belongs to has finished, so this dispatch names no invocation. " + + "Nothing was asked of the Git host", + ); + } + if (live.delegated) { + throw new GitOperationAdmissionError( + PUSH, + "this publication was already dispatched once. A second delegation of one " + + "would ask the Git host again about work this invocation has already acknowledged, so " + + "nothing was asked of it", + ); + } + live.delegated = true; + + // Read once, here. The invocation is the caller's own object and everything + // below suspends, so reading it again later would let one Repository be + // authenticated and another published for. + const receivedSelection = request.repository; + const receivedDirectory = request.workingDirectory; + const registration = authenticated(entry, receivedSelection); + const received: ReplPushPlace = Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + workingDirectory: receivedDirectory, + }); + + const resolved = yield* resolve(entry, received); + const record = yield* publishRequest(entry, invocation, requested, received, resolved, name); + const outcome = yield* reconcile(entry, invocation, record); + live.settled = outcome; + return outcome; +} + +/** + * The complete request this invocation publishes, resolved from the checkout. + * + * Everything a later step trusts is proven before it: the checkout covering the + * received directory, the destination this repository publishes to, the managed + * slot that says this entry owns that checkout, and only then — inside the + * entry's own native turn — the branch it is on and the commit that branch + * holds. + * + * Nothing contacts a remote and nothing reads a credential. A detached HEAD and + * a checkout with no usable origin both stop here, before a request exists. + */ +function* resolve(entry: ReplPushEntry, received: ReplPushPlace): Operation { + const routed = yield* route(entry, received); + const placement = routed.registration.placement; + const origin = yield* destination(routed.registration); + + yield* own(entry, routed); + + const git = yield* entry.git(); + // Serialized with this entry's own native Git, because reading HEAD beside a + // `` of the same repository would describe a moment neither of + // them is at. The turn is held for these two short reads and released before + // anything is published: a network round trip never holds it. + const read = yield* entry.serialize(`git ${placement.commonDirectory}`, function* () { + const checkout = liveCheckout(git, routed.checkout, received.workingDirectory); + const branch = yield* currentBranch(checkout.git, checkout.directory); + if (branch === undefined) { + throw gitRefusal(PUSH, "unnamed-branch"); + } + const sourceCommit = yield* resolveCommit(checkout.git, checkout.directory, "HEAD"); + if (sourceCommit === undefined) { + throw new GitOperationInfrastructureError( + PUSH, + "the checkout it ran in did not report the commit its branch holds", + ); + } + return { branch, sourceCommit }; + }); + + const inputs: GitPushInputs = Object.freeze({ + repository: received.repository, + remote: PUSH_REMOTE, + branch: read.branch, + destinationRef: destinationRefFor(read.branch), + sourceCommit: read.sourceCommit, + }); + return Object.freeze({ + checkoutRoot: routed.checkout.root, + commonDirectory: placement.commonDirectory, + origin, + inputs, + }); +} + +/** + * Where this checkout's branch is published to, as this entry can see it now. + * + * The locator the entry admitted, held to the fingerprint the Repository + * identity carries. A checkout that records no usable origin has nowhere to + * publish to, and one whose origin does not name this repository is not a + * destination this invocation may use — both before a request exists and + * before anything is contacted. + */ +function* destination(registration: ReplPlacementRegistration): Operation { + const origin = yield* registration.origin(); + if (origin === undefined) { + throw new GitOperationAdmissionError( + PUSH, + "the checkout it selected records no usable origin, so there is nowhere for this branch " + + "to be published to. No credential was read and nothing was contacted", + ); + } + if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { + throw new GitOperationAdmissionError( + PUSH, + "the origin this checkout records is not the one the Repository in scope was selected " + + "from, so a branch published to it would go somewhere this entry never authenticated", + ); + } + return origin; +} + +/** + * Write this request down, and read it back before it is acknowledged. + * + * The request is one durable position and the reconciliation below is another: + * they are two inline operations rather than one wrapped in the other, because + * a request that was acknowledged and an outcome that was not is exactly the + * history a continuation has to be able to read. + * + * The payload is parsed and held to this entry's own placements before the + * coordinator publishes it. A payload this build cannot read, or one naming a + * checkout this entry holds nothing for, takes the fatal path here — so nothing + * is appended, and a continuation meets today's checkout instead of a record it + * could never use. + */ +function* publishRequest( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPushPlace, + received: ReplPushPlace, + resolved: ReplPushResolution, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PUSH_REQUEST, name }, + function* () { + const payload = replPushRequestJson({ + owner: invocation.owner, + site: invocation.site, + requested, + received, + resolved, + }); + readback(entry, invocation, requested, payload); + return payload; + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, invocation, requested, stored); +} + +/** + * The saved request standing at this position, read back for this invocation. + * + * Reached only when the cursor already holds this invocation's own request. The + * callback below is the engine's to run and this position's history says it will + * not: a live attempt here would publish a request nothing resolved, so it + * refuses rather than inventing one. + */ +function* restored( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPushPlace, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PUSH_REQUEST, name }, + // deno-lint-ignore require-yield + function* (): Operation { + throw new GitOperationInfrastructureError( + PUSH, + "its retained history named a saved publication request at this position and then had " + + "none, so what this entry asked to publish is unknown", + ); + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, invocation, requested, stored); +} + +/** + * The record this value is for this invocation, or the fatal failure it is not. + * + * The same five questions on both paths, which is the point: a resolved request + * is held to them before it is published, and a restored one before it is + * reconciled. The payload parses, it is this entry's and this invocation's, it + * asks the question this position is asking, every place it names is one this + * entry's own registry holds, and the origin it says it asked for is the one + * the Repository identity in it was fingerprinted from. + * + * That last question is asked here rather than in the codec because the digest + * is this half of the package's: a surface naming no runtime has no synchronous + * one, and a restored request is reconciled against the origin it recorded + * rather than against today's — so an edited one would publish this branch to a + * repository nothing authenticated, and nothing later would notice. + */ +function readback( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPushPlace, + stored: unknown, +): ReplPushRequestRecord { + const record = parseReplPushRequestRecord(stored); + if (record === undefined) { + throw new GitOperationProtocolError(PUSH); + } + if ( + record.owner !== invocation.owner || + record.site !== invocation.site || + !sameReplPushRequest(record.requested, requested) + ) { + throw new GitOperationProtocolError(PUSH); + } + if (!replPushPlacesHeld(record, placements(entry))) { + throw new GitOperationProtocolError(PUSH); + } + if ( + locatorFingerprint(record.resolved.origin) !== + record.resolved.inputs.repository.locatorFingerprint + ) { + throw new GitOperationProtocolError(PUSH); + } + return record; +} + +/** Whether a saved question is the question this invocation is asking. */ +function sameReplPushRequest(saved: ReplPushPlace, asking: ReplPushPlace): boolean { + return ( + saved.selectionCheckout === asking.selectionCheckout && + saved.commonDirectory === asking.commonDirectory && + saved.workingDirectory === asking.workingDirectory && + sameRepositoryIdentity(saved.repository, asking.repository) + ); +} + +/** The places this entry's reconstructed registry holds, as parsed facts. */ +function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { + return entry.checkouts().map((checkout) => ({ + identity: checkout.registration.placement.identity, + checkoutRoot: checkout.registration.placement.checkoutRoot, + commonDirectory: checkout.registration.placement.commonDirectory, + })); +} + +/** + * Reconcile this request's external effect, and hold the result to the request. + * + * A completed outcome comes back before any native work, any resource and any + * credential: the shared worker replays it without constructing a provider at + * all. A pending one reaches the live provider below. + */ +function* reconcile( + entry: ReplPushEntry, + invocation: Invocation, + record: ReplPushRequestRecord, +): Operation { + const inputs = record.resolved.inputs; + const expected = pushExpectation(inputs); + const reconciliation = yield* reconcileOwnedGitHostEffect( + { + owner: invocation.owner, + site: invocation.site, + request: replPushEffectRequest(inputs), + }, + { + provider: () => publication(entry, record), + validate: (produced: GitHostReconciliationRecord) => { + if (parseGitPushRecord(produced, expected) === undefined) { + throw new GitOperationProtocolError(PUSH); + } + }, + }, + ); + const outcome = parseGitPushRecord(reconciliation, expected); + if (outcome === undefined) { + throw new GitOperationProtocolError(PUSH); + } + return outcome; +} + +/** + * The provider that answers this exact publication, built inside the attempt. + * + * Reached only by a live attempt, and everything it opens belongs to that + * attempt's own scope: the scratch transport, the control repository the + * objects are read through, and the credential session. So what the observation + * opened is still open when the push performs, and all of it is closed and + * joined before publication may acknowledge a result. + * + * Construction itself reads no credential and no configuration. The session is + * acquired by the first phase that needs one, which is what keeps a replayed + * publication — and an attempt that stops before observing — from reading one at + * all. + */ +function* publication( + entry: ReplPushEntry, + record: ReplPushRequestRecord, +): Operation { + const resolved = record.resolved; + const inputs = resolved.inputs; + // The saved checkout, authenticated against this entry's own registry rather + // than against the filesystem. A root this entry holds no placement for is + // not somewhere it may publish from, however the record came to name it. + const routed = entry + .checkouts() + .find( + (checkout) => + checkout.registration.placement.checkoutRoot === resolved.checkoutRoot && + checkout.registration.placement.commonDirectory === resolved.commonDirectory && + sameRepositoryIdentity(checkout.registration.placement.identity, inputs.repository), + ); + if (routed === undefined) { + throw new GitOperationAdmissionError( + PUSH, + "the checkout its saved request names is not one this entry holds a placement for, so " + + "there is nowhere for this publication to read its objects from. Nothing was contacted", + ); + } + // The slot this publication's checkout belongs to, reacquired before any + // native use of it. A restored placement took none when it replayed. + yield* own(entry, routed); + + const git = yield* entry.git(); + // The exact object this request named, which has to still be readable. A + // missing one fails rather than being fetched: fetching it would manufacture + // the very thing the publication is about. + if (!(yield* commitPresent(git, resolved.checkoutRoot, inputs.sourceCommit))) { + throw new GitOperationInfrastructureError( + PUSH, + "the commit its saved request published is no longer readable in that checkout, so this " + + "publication cannot be reconciled. Nothing was fetched and nothing was contacted", + ); + } + + const attempt: Scope = yield* useScope(); + const source = objectSourceAt( + entry.host, + yield* entry.host.useDirectory(), + resolved.commonDirectory, + inputs.repository.objectFormat, + PUSH, + ); + // One session for this whole attempt, opened by the first phase that needs + // it and released with the attempt's own scope. + const session = lazily(attempt, function* (): Operation { + return yield* useGitAuthentication(entry.host, resolved.origin); + }); + return pushProvider(source, resolved.origin, inputs, session); +} + +/** + * Which registered checkout this repository and working directory select. + * + * The ordinary selector over this entry's own registrations, restricted by the + * whole authenticated Repository identity and resolving the received directory + * first. The longest covering checkout wins, so a subdirectory, a linked + * worktree and a captured path entered through `` each select the checkout + * their own paths belong to. + */ +function* route(entry: ReplPushEntry, received: ReplPushPlace): Operation { + const registered = entry.checkouts(); + const selected = selectCheckout( + registered.map((checkout) => checkout.checkout), + received.repository, + yield* canonical(received.workingDirectory), + PUSH, + ); + const found = registered.find((checkout) => checkout.checkout.root === selected.root); + if (found === undefined) { + throw new GitOperationAdmissionError( + PUSH, + "the checkout it selected is not one this entry holds a placement for", + ); + } + return found; +} + +/** + * Take ownership of the checkout this publication reads from. + * + * The managed slot alone. It says which entry owns a managed checkout, which is + * what a publication needs in order to read its objects; the mutation lease + * says which entry is changing a repository, and a publication changes none of + * it. An ambient target has no slot: it is the checkout the person was already + * standing in. + */ +function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { + const registration = target.registration; + const slot = registration.slot; + const root = registration.placement.managedRoot; + if (slot === undefined || root === undefined) { + return; + } + yield* holdManagedPlacement(() => + entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), + ); +} + +/** + * The canonical directory this path resolves to, or the path as written. + * + * Routing resolves the received directory exactly as recorded local Git does, + * because a working directory reached through a link is the same place under + * another name. Only live routing resolves anything: a saved request's roots + * are authenticated against parsed placements instead. + */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +/** The public default's own refusal, for a member this descriptor never routes. */ +function refusing(element: string): () => Operation { + // deno-lint-ignore require-yield + return function* (): Operation { + throw new GitCompositionProviderError(element); + }; +} + +/** The private entry state this execution holds, or the ordinary refusal. */ +function held(access: ReplPushAccess): ReplPushEntry { + const entry = access(); + if (entry === undefined) { + // The same failure the public Api's own default raises, because that is + // what this condition is: no publication capability was established for + // this execution, so nothing performed anything. + throw new GitCompositionProviderError(PUSH); + } + return entry; +} + +/** What this selection names in this entry's registry, or an admission refusal. */ +function authenticated( + entry: ReplPushEntry, + selection: RepositorySelection, +): ReplPlacementRegistration { + return entry.selections.authenticate( + selection, + () => + new GitOperationAdmissionError( + PUSH, + "the Repository in scope is not one this entry selected, so it names no checkout", + ), + ); +} + +/** + * The live boundary one published request is written through. + * + * The default coordinator would serialize whatever was thrown and publish it as + * this operation's outcome, which is the one thing that must not happen here: a + * request published for work nobody resolved would send a continuation to + * reconcile a branch and a commit this entry never read. + * + * A throw therefore activates the run's existing fail-stop state and is + * re-raised with its identity intact, publishing nothing. A valid request is + * published once and returned only after the append is acknowledged, which is + * what makes "no remote work before the request exists" a property of the + * ordering rather than a promise. + */ +const requestCoordinator: LiveDurableOperationCoordinator = { + *run( + execute: () => Operation, + publish: (result: ProtocolResult) => Operation, + activateFailure: ActivateDurabilityFailure, + ): Operation { + let value: T; + try { + value = yield* execute(); + } catch (error) { + throw activateFailure(error); + } + const result: ProtocolResult = { status: "ok", value }; + yield* publish(result); + return result; + }, +}; diff --git a/packages/git/src/deno/retained-repository/selections.ts b/packages/git/src/deno/retained-repository/selections.ts index 95919c7eb..18af8f51f 100644 --- a/packages/git/src/deno/retained-repository/selections.ts +++ b/packages/git/src/deno/retained-repository/selections.ts @@ -127,6 +127,23 @@ export interface ReplPlacementRegistration { * recorded root, never read out of the journal. */ readonly slot: { readonly kind: SlotKind; readonly path: string } | undefined; + /** + * Where a branch of this checkout would be published, or absence. + * + * Private entry state, and deliberately not part of any record: a saved + * placement carries a locator *fingerprint* and never the bytes, so a + * publication reauthenticates its destination from what this entry can see + * now rather than from what a journal says. A managed selection answers with + * the locator the document wrote and this provider admitted; a linked + * checkout answers with its owner's; an ambient owner answers from the + * discovery this entry already shares, which is why this is an operation + * rather than a value — a replayed publication asks it nothing. + * + * Absence is an ordinary answer. A checkout that records no usable origin has + * nowhere to publish to, and that is a refusal a document can act on rather + * than something to invent a destination for. + */ + origin(): Operation; } /** What a recorded selection needs from the entry that owns it. */ @@ -184,6 +201,9 @@ export function* selectReplRepository( const registration = yield* recordedPlacement(entry, recorded, { slot: (root) => repositorySlot(root, locator, request.name), + // The locator this invocation admitted, which is where a branch of this + // Repository publishes to. Nothing is read for it and nothing is recorded. + origin: () => admitted(locator), // The slot lease, before anything inspects or creates the checkout, and // held until this entry tears down. acquire: (root, slot) => @@ -236,10 +256,11 @@ export function* selectReplWorktree( repository: RepositorySelection, request: WorktreeRequest, ): Operation { - const owner = entry.selections.authenticate( + const held = entry.selections.authenticate( repository, () => new RepositorySelectionError(""), - ).placement; + ); + const owner = held.placement; const recorded: ReplPlacementRequest = Object.freeze({ kind: "worktree" as const, name: request.name, @@ -252,6 +273,9 @@ export function* selectReplWorktree( const registration = yield* recordedPlacement(entry, recorded, { slot: (root) => worktreeSlot(root, owner.commonDirectory, request.name), + // A linked checkout publishes where its own repository does, so this is + // the owner's answer and never a second admission of anything. + origin: () => held.origin(), *acquire(root, slot) { yield* entry.holdSlot(root, "worktree", slot, `worktree ${JSON.stringify(request.name)}`); // Before the selector, not after: `worktree add` writes refs and linked @@ -314,6 +338,12 @@ export function* selectReplAmbientOwner( slot: () => undefined, // Nothing is held for it: this is the checkout the person was already in. acquire: () => nothing(), + // The ambient repository's own recorded origin, admitted when it was + // discovered. Asked through the entry's shared discovery and only when a + // publication needs it, so a replayed selection reaches no `git`. + *origin(): Operation { + return (yield* entry.ambient())?.origin; + }, *perform() { const ambient = yield* entry.ambient(); if (ambient === undefined) { @@ -383,7 +413,7 @@ function* recordedPlacement( if (!parsed.outcome.ok) { throw placementFailure(request, refusalOf(parsed.outcome.error)); } - return registered(request, parsed.outcome.value, selector.slot); + return registered(request, parsed.outcome.value, selector.slot, selector.origin); } /** @@ -401,6 +431,15 @@ interface ReplPlacementSelector { acquire(root: string, slot: string): Operation; /** Call the ordinary selector, and describe what it selected. */ perform(root: string, slot: string): Operation; + /** + * Where a publication from this checkout would go, asked only when one is. + * + * Live and replayed selections share it, because it is not a fact about + * either: it is what this entry can see about the destination now, and a + * selection that was answered from a record still knows the locator the + * document in front of it wrote. + */ + origin(): Operation; } /** @@ -605,6 +644,7 @@ function registered( request: ReplPlacementRequest, placement: ReplPlacement, slotOf: (root: string) => string | undefined, + origin: () => Operation, ): ReplPlacementRegistration { if (placement.kind !== request.kind) { throw mismatch(request); @@ -613,7 +653,7 @@ function registered( if (placement.managedRoot !== undefined || placement.ownerCheckout !== placement.checkoutRoot) { throw mismatch(request); } - return Object.freeze({ placement, slot: undefined }); + return Object.freeze({ placement, slot: undefined, origin }); } const root = placement.managedRoot; if (root === undefined || placement.displayName !== request.name) { @@ -645,9 +685,16 @@ function registered( return Object.freeze({ placement, slot: Object.freeze({ kind: request.kind, path }), + origin, }); } +/** A locator this provider already admitted, as an answer rather than a read. */ +// deno-lint-ignore require-yield +function* admitted(locator: string): Operation { + return locator; +} + function mismatch(request: ReplPlacementRequest): ReplPlacementRecordError { return new ReplPlacementRecordError( `the saved ${describe(request)} describes a checkout that is not the one this selection ` + diff --git a/packages/git/src/git-host/effect.ts b/packages/git/src/git-host/effect.ts index 83b0b7d73..d8f9b8a4b 100644 --- a/packages/git/src/git-host/effect.ts +++ b/packages/git/src/git-host/effect.ts @@ -9,10 +9,19 @@ * remote mutation and local publication, the same external identity lets observation * adopt completion without repeating it. * - * Run and expansion identify the effect; the complete detached request's digest - * names the durable operation so changed inputs cannot consume an earlier answer. - * Retained identities and abandoned-history enforcement remain checked on the live - * path before provider work. + * Two entrypoints reach that machinery, and they differ only in where the + * identity comes from and what a live attempt is. + * {@link reconcileGitHostEffect} derives it from the workflow run and the + * engine's expansion, and borrows a retained identity where this run already + * holds a record. {@link reconcileOwnedGitHostEffect} uses exactly the owner + * and site its caller states, borrows nothing, and runs that caller's provider + * and validation inside one scope joined before publication. Both share the + * complete-request parse, the attempt, the decision, the coordinator and the + * readback; abandoned-history enforcement is checked on either live path before + * provider work. + * + * The complete detached request's digest names the durable operation, so changed + * inputs cannot consume an earlier answer. * * {@link GitHost} exposes one frozen, one-use routing request. Public middleware * may observe, refuse, narrow policy or delegate; it receives no credential, @@ -538,18 +547,44 @@ function errorOf(value: unknown): Error { function* attempt( description: EffectDescription, - request: CompleteGitHostEffectRequest, borrowed: BorrowedIdentity | undefined, + execute: (settlement: GitHostSettlement) => Operation, ): Workflow { // One settlement per attempt, created here and reachable only from the two // halves of this operation. Nothing a document, a provider or a handler holds // can put a decision in it. const settlement: GitHostSettlement = {}; - return yield createDurableOperation(description, () => reconcile(request, settlement), { + return yield createDurableOperation(description, () => execute(settlement), { coordinator: gitHostCoordinator(settlement, borrowed), }); } +/** + * The record this retained value is for this request, or the refusal it is not. + * + * The same two questions on both entrypoints: the journal's value parses as a + * reconciliation record, and the request inside it is the request being made + * now. A retained result is consumed by the effect that produced it rather than + * by whatever arrives at the same journal position. + */ +function readback( + retained: unknown, + request: CompleteGitHostEffectRequest, +): GitHostReconciliationRecord { + const record = parseGitHostReconciliationRecord(retained); + if (record === undefined) { + throw new GitHostProtocolError( + "the journal holds a value that does not describe a Git-host reconciliation record", + ); + } + if (!sameGitHostEffectRequest(record.request, request)) { + throw new GitHostProtocolError( + "the journal holds a Git-host reconciliation record for a different request", + ); + } + return record; +} + /** * The complete request one call makes, or a refusal naming what is missing. * @@ -560,7 +595,7 @@ function* attempt( function completeRequest( runId: string, expansionId: string, - request: GitHostEffectRequest, + request: unknown, ): CompleteGitHostEffectRequest { const complete = parseCompleteGitHostEffectRequest({ identity: { runId, expansionId }, @@ -570,7 +605,7 @@ function completeRequest( }); if (complete === undefined) { throw new Error( - "reconcileGitHostEffect() needs a non-empty kind and JSON inputs and natural key. A " + + "a Git-host effect needs a non-empty kind and JSON inputs and natural key. A " + "request missing one of them, or holding a value JSON cannot express, names no " + "Git-host effect.", ); @@ -619,21 +654,207 @@ export function* reconcileGitHostEffect( let retained: unknown; try { - retained = yield* attempt(description, complete, borrowed); + retained = yield* attempt(description, borrowed, (settlement) => + reconcile(complete, settlement), + ); } catch (error) { throw gitHostFailure(error); } + return readback(retained, complete); +} + +/** + * Whose retained history one external effect belongs to, and which invocation + * inside it this is. + * + * Named for what the two members are rather than for the profile that first + * supplied them, exactly as the Issue envelope beside this names them. A + * workflow run supplies its run id and the engine's expansion; a REPL entry + * supplies the owner its host derived and the invocation identity canonical + * core minted. The `runId`/`expansionId` field spellings in the record and the + * fingerprint are an encoding of exactly those two, so one reader reads a + * history written under either profile. + * + * Neither member comes from a document, a prop, a Context value, middleware or + * a provider, for the reason neither ever did: a party that could name the + * identity could reconcile somebody else's external work. + */ +export interface OwnedGitHostEffect { + readonly owner: string; + readonly site: string; + readonly request: GitHostEffectRequest; +} + +/** + * What one live owned attempt is, as private trusted code. + * + * Both members are the caller's own closure rather than anything composable. + * `provider` is invoked once, inside the attempt's live execution, and the + * handler it answers with is installed at the terminal end for the whole + * reconciliation. `validate` is the caller's effect-specific reading of the + * complete record, run before the coordinator publishes it, so a completion the + * caller cannot account for is never acknowledged. + * + * A completed replay invokes neither. No provider is constructed, no credential + * is read, nothing is contacted, and the record comes back through the ordinary + * parsed readback below. + */ +export interface OwnedGitHostLive { + provider(): Operation; + validate(record: GitHostReconciliationRecord): void; +} + +/** + * Reconcile one external effect under the identity its owner states. + * + * The same complete-request, attempt, decision and readback machinery + * {@link reconcileGitHostEffect} uses, with two differences and no others. The + * identity is exactly the supplied owner and site — nothing is derived from a + * workflow run and no retained identity is borrowed — and the live attempt + * installs the caller's provider and runs the caller's validation inside one + * scope that is torn down and joined before publication can acknowledge + * anything. + * + * That ordering is the whole of what this entrypoint adds. A successful + * outcome, and an ordinary conflict, ambiguity or unavailability the worker + * authored, are published only after every per-attempt resource has closed: a + * cleanup that fails replaces what was raised, so the attempt is no longer + * holding the object it authored and nothing is published at all. + */ +export function* reconcileOwnedGitHostEffect( + input: OwnedGitHostEffect, + live: OwnedGitHostLive, +): Operation { + // Read before the first suspension, and through the same total parse a + // journal value gets. The caller's object is its own, and this operation + // suspends — hashing the request, and then reconciling it — across which + // whoever handed it over could still change what it says. + const owner = ownedMember(input, "owner"); + const site = ownedMember(input, "site"); + const complete = completeRequest(owner, site, readMember(input, "request")); + + const expansion = yield* getExpansion(); + const description: EffectDescription = { + type: GIT_HOST_EFFECT, + name: yield* gitHostRequestFingerprint(complete), + // Where the authored component asking for this effect was written — + // diagnostic journal data, never part of the fingerprint, the request, or + // the reconciliation record. + ...sourceDescription(expansion.position), + }; + + let retained: unknown; + try { + // No borrowed identity: this owner and site are the ones supplied, and a + // name taken from a retained record is not something this entrypoint can + // be asked to perform under. The abandoned-history guard inside the + // coordinator still applies. + retained = yield* attempt(description, undefined, (settlement) => + owned(complete, settlement, live), + ); + } catch (error) { + throw gitHostFailure(error); + } + + return readback(retained, complete); +} + +/** + * One live owned attempt: the caller's provider, the shared decision, and the + * caller's own reading of what it produced. + * + * Scoped around the whole reconciliation, so a resource the provider opened to + * observe is still open when it performs and every one of them is closed before + * this operation returns to the coordinator that publishes. + * + * The attempt's outcome is *held* across that teardown rather than raised + * through it. Both halves of this are deliberate. A successful outcome and an + * ordinary conflict, ambiguity or unavailability are only handed on once the + * scope has finished unwinding, so publication acknowledges work whose + * resources have actually finished. And teardown that fails raises from the + * scope instead, before anything held here is handed on: that failure is + * nobody's authored outcome, so a cleanup that failed publishes neither a + * success nor a refusal the worker had already decided. + * + * Cancellation stays cancellation. A halted scope unwinds without raising, so + * nothing is held, nothing is re-raised and nothing is published. + */ +function* owned( + request: CompleteGitHostEffectRequest, + settlement: GitHostSettlement, + live: OwnedGitHostLive, +): Operation { + let held: Result | undefined; + yield* scoped(function* (): Operation { + const provider = yield* live.provider(); + held = yield* trapped(withGitHostProvider(provider, validated(request, settlement, live))); + }); + if (held === undefined) { + throw new GitHostProviderError( + "this attempt's own scope ended before it reached an answer, so nothing was reconciled", + ); + } + if (!held.ok) { + // The exact object the attempt authored, so the coordinator still reads its + // authorship by identity and publishes only what the Git host said. + throw held.error; + } + return held.value; +} + +/** + * The attempt's own outcome as a value, so teardown happens before it is acted + * on. + * + * Only a raised failure is caught. Cancellation unwinds a coroutine rather than + * raising into it, so a halted attempt produces no held outcome at all. + */ +function* trapped(operation: Operation): Operation> { + try { + return Ok(yield* operation); + } catch (error) { + return Err(errorOf(error)); + } +} + +function* validated( + request: CompleteGitHostEffectRequest, + settlement: GitHostSettlement, + live: OwnedGitHostLive, +): Operation { + const retained = yield* reconcile(request, settlement); + // Parsed and held to the caller's own expectation here, where there is still + // nothing published. A completion whose shape this boundary cannot read, or + // that the caller cannot account for, is the boundary failing: it is authored + // as this attempt's own non-outcome, so its exact sentence survives and no + // outcome is written down. const record = parseGitHostReconciliationRecord(retained); if (record === undefined) { - throw new GitHostProtocolError( - "the journal holds a value that does not describe a Git-host reconciliation record", + throw author( + settlement, + new GitHostProtocolError( + "this attempt produced a value that does not describe a Git-host reconciliation record", + ), + false, ); } - if (!sameGitHostEffectRequest(record.request, complete)) { - throw new GitHostProtocolError( - "the journal holds a Git-host reconciliation record for a different request", + try { + live.validate(record); + } catch (error) { + throw author(settlement, errorOf(error), false); + } + return retained; +} + +/** One stated identity member, read exactly once, or a refusal naming it. */ +function ownedMember(input: unknown, member: "owner" | "site"): string { + const read = readMember(input, member); + if (typeof read !== "string" || read === "") { + throw new Error( + `reconcileOwnedGitHostEffect() needs a non-empty ${member}. An effect with no ${member} ` + + "names no retained history, so nothing about it could be reconciled.", ); } - return record; + return read; } diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts new file mode 100644 index 000000000..a28017213 --- /dev/null +++ b/packages/git/src/retained-repository/push-admission.ts @@ -0,0 +1,434 @@ +/** + * Whether one entry's retained publication history may be continued at all. + * + * Three questions, and only three. **Integrity**: every saved Push request + * parses, states this version, and is published under the name its own question + * produces. **Ownership**: every request and every Push outcome names this + * entry's owner, because the owner is what the durable name is derived from — a + * record written under another one would replay under a name this entry never + * presented. **Pairing**: an outcome stands at the position after the request it + * answers, carries that request's own fingerprint, and reads back as a Push of + * exactly what was asked. + * + * Nothing else. No checkout is inspected, no origin is contacted, no object is + * read, no path is resolved and no result is handed out: replaying a completed + * operation is the durable engine's own positional matching, and this runs + * before any of it. Whether the branch a record names is still where it was is + * not this reader's question, and not a question a continuation asks. + * + * ## A trailing request is history, not damage + * + * An entry interrupted between publishing its request and obtaining the host's + * answer leaves exactly one unanswered request. That is the state this whole + * feature exists for, so it is admitted: the continuation reconciles that saved + * request rather than resolving a new one. What is refused is an outcome with no + * request before it, a request answered twice, and a receipt this build cannot + * read — each of which would let a continuation believe something was published + * that this history cannot account for. + * + * ## Coroutines are independent + * + * Pairing is per coroutine, because a position belongs to one. Two `` + * branches each publishing a branch interleave in the stream and neither one's + * outcome answers the other's request. Nothing is matched by searching the + * stream for a name: equal requests at two positions are two occurrences, and a + * reader that deduplicated them would refuse a document that is allowed. + */ + +import { StaleInputError } from "@executablemd/durable-streams"; +import type { DurableEvent, Json, Result, Yield } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; +import { canonicalJson } from "@executablemd/workflow"; +import { + GIT_PUSH, + gitPushInputsJson, + gitPushNaturalKeyJson, + parseGitPushRecord, + pushExpectation, + type GitPushInputs, +} from "../composition/git-push-records.ts"; +import { sameRepositoryIdentity, type RepositoryIdentity } from "../composition/selection.ts"; +import { GIT_HOST_EFFECT } from "../git-host/effect-type.ts"; +import { + gitHostRequestFingerprint, + parseGitHostReconciliationRecord, +} from "../git-host/records.ts"; +import type { GitHostReconciliationRecord } from "../git-host/records.ts"; +import { parseReplPlacementRecord, REPL_REPOSITORY_PLACEMENT } from "./placement-records.ts"; +import { + parseReplPushRequestRecord, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + type ReplPushRequestRecord, +} from "./push-records.ts"; +import { REPL_REPOSITORY_VERSION, statedReplRecordVersion } from "./records.ts"; + +/** Why a publication history cannot be continued. */ +export type ReplPushRefusal = + /** A Push record of this feature's type that this version cannot read. */ + | "malformed-record" + /** A record stating a version this host does not implement. */ + | "unknown-version" + /** A record whose name is not the name of the request it carries. */ + | "request-fingerprint" + /** A record naming a history that is not this entry's. */ + | "foreign-owner" + /** A saved request naming a checkout this entry holds no placement for. */ + | "unknown-placement" + /** A Git-host outcome standing where no request asked for one. */ + | "orphan-outcome" + /** A second outcome for a request one already answered. */ + | "duplicate-outcome" + /** A settled outcome that is not the result a Push produces. */ + | "malformed-receipt"; + +/** + * A retained publication history this entry cannot continue from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. + */ +export class ReplPushHistoryError extends StaleInputError { + override name = "ReplPushHistoryError"; + + readonly reason: ReplPushRefusal; + + constructor(reason: ReplPushRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * One place this entry holds, as parsed facts alone. + * + * What a saved request is checked against, on both paths and with the same + * arithmetic: during admission these come from the placement records the + * snapshot holds, and at an invocation they come from the private registry this + * execution rebuilt. Neither reading resolves a path or opens anything. + */ +export interface ReplPushPlacement { + readonly identity: RepositoryIdentity; + readonly checkoutRoot: string; + readonly commonDirectory: string; +} + +/** + * Whether every place a saved request names is one these placements hold. + * + * Three readings and three questions. The selection the invocation + * authenticated, the selection the live dispatch received, and the checkout + * routing resolved all have to be placements of the Repository the record + * names — a request whose saved root belongs to nothing this entry holds does + * not say where the publication was asked for. + */ +export function replPushPlacesHeld( + record: ReplPushRequestRecord, + placements: readonly ReplPushPlacement[], +): boolean { + const places: readonly (readonly [RepositoryIdentity, string, string])[] = [ + [ + record.requested.repository, + record.requested.selectionCheckout, + record.requested.commonDirectory, + ], + [ + record.received.repository, + record.received.selectionCheckout, + record.received.commonDirectory, + ], + [record.received.repository, record.resolved.checkoutRoot, record.resolved.commonDirectory], + ]; + return places.every(([identity, checkoutRoot, commonDirectory]) => + placements.some( + (placement) => + placement.checkoutRoot === checkoutRoot && + placement.commonDirectory === commonDirectory && + sameRepositoryIdentity(placement.identity, identity), + ), + ); +} + +/** The Git-host request one saved Push request asks the worker to reconcile. */ +export function replPushEffectRequest(inputs: GitPushInputs): { + readonly kind: string; + readonly inputs: Json; + readonly naturalKey: Json; +} { + return { + kind: GIT_PUSH, + inputs: gitPushInputsJson(inputs), + naturalKey: gitPushNaturalKeyJson({ + repository: inputs.repository, + remote: inputs.remote, + destinationRef: inputs.destinationRef, + }), + }; +} + +/** The name the worker publishes one saved request's outcome under. */ +export function replPushOutcomeName(record: ReplPushRequestRecord): Operation { + return gitHostRequestFingerprint({ + identity: { runId: record.owner, expansionId: record.site }, + ...replPushEffectRequest(record.resolved.inputs), + }); +} + +/** The three words a Push outcome may fail under, as the journal holds them. */ +const CLOSED_FAILURES: ReadonlySet = new Set([ + "GitHostConflictError", + "GitHostAmbiguousError", + "GitHostUnavailableError", +]); + +/** + * Hold this entry's retained publication history to what this host can read. + * + * `retained` is the exact snapshot canonical execution admitted, which for an + * entry is its own segment. Records of other features and other durable types + * are not this reader's business and are passed over. + */ +export function* admitReplPushHistory( + owner: string, + retained: readonly DurableEvent[], +): Operation { + /** Every place a successful placement record before this point described. */ + const placements: ReplPushPlacement[] = []; + /** Per coroutine, the requests no outcome has answered yet. */ + const pending = new Map(); + /** Per coroutine, the requests an outcome already answered. */ + const answered = new Map(); + + for (const event of retained) { + if (event.type !== "yield") { + continue; + } + const type = event.description.type; + if (type === REPL_REPOSITORY_PLACEMENT) { + remember(placements, event); + continue; + } + if (type === REPL_REPOSITORY_PUSH_REQUEST) { + const record = yield* admitRequest(owner, event, placements); + queue(pending, event.coroutineId).push(record); + continue; + } + if (type !== GIT_HOST_EFFECT) { + continue; + } + yield* admitOutcome(owner, event, pending, answered); + } +} + +/** Remember one successful placement's parsed facts, or pass over the record. */ +function remember(placements: ReplPushPlacement[], event: DurableEvent): void { + if (event.type !== "yield" || event.result.status !== "ok") { + return; + } + // The placement admission beside this one owns whether a placement record is + // readable at all. What is wanted here is only the places a readable one + // describes, so an unreadable record contributes nothing and is refused + // there rather than twice. + const record = parseReplPlacementRecord(event.result.value); + if (record === undefined || !record.outcome.ok) { + return; + } + const placement = record.outcome.value; + placements.push({ + identity: placement.identity, + checkoutRoot: placement.checkoutRoot, + commonDirectory: placement.commonDirectory, + }); +} + +function queue( + queues: Map, + coroutineId: string, +): ReplPushRequestRecord[] { + const existing = queues.get(coroutineId); + if (existing !== undefined) { + return existing; + } + const created: ReplPushRequestRecord[] = []; + queues.set(coroutineId, created); + return created; +} + +/** One saved request, held to this version, this owner, its name and its places. */ +function* admitRequest( + owner: string, + event: Yield, + placements: readonly ReplPushPlacement[], +): Operation { + if (event.result.status !== "ok") { + // A request publication has no failure side: its outcome is the appended + // record itself, so a failed envelope under this type is not a record this + // host ever wrote. + throw malformed(); + } + const payload = event.result.value; + const stated = statedReplRecordVersion(payload); + if (stated !== undefined && stated !== REPL_REPOSITORY_VERSION) { + throw refusal( + "unknown-version", + `a ${REPL_REPOSITORY_PUSH_REQUEST} record states version ${stated}, and this host ` + + `implements version ${REPL_REPOSITORY_VERSION} only. Nothing is assumed about what the ` + + "rest of it means.", + ); + } + const record = parseReplPushRequestRecord(payload); + if (record === undefined) { + throw malformed(); + } + if (record.owner !== owner) { + throw refusal( + "foreign-owner", + "a saved publication request in this entry's history was written under another owner, so " + + "the branch it asked to publish was not asked for by this entry and its request is not " + + "one this entry can present again.", + ); + } + if (event.description.name !== (yield* replPushRequestName(record))) { + throw refusal( + "request-fingerprint", + "a saved publication request is journaled under a name that is not the name its own " + + "request produces, so the record and the publication it claims to be do not match.", + ); + } + if (!replPushPlacesHeld(record, placements)) { + throw refusal( + "unknown-placement", + "a saved publication request names a checkout no selection before it placed, so this " + + "history does not say where the branch it published came from.", + ); + } + return record; +} + +/** + * One Git-host outcome, paired with the request it answers. + * + * Only this entry's own Push effects are this reader's business. A successful + * Git-host record of another kind belongs to a protocol this feature does not + * retain and is passed over; one naming another owner is refused, because an + * entry's segment holds its own work and borrowing somebody else's receipt is + * exactly what must not be possible. + * + * A failed envelope names no kind — a refusal retains no request of its own — + * so every one of them in an entry's segment is read as a publication. That is + * exactly what they are: publication is the one external protocol an entry + * retains through this boundary, and the name the failure is journaled under + * still has to be the one the request before it produces. + */ +function* admitOutcome( + owner: string, + event: Yield, + pending: Map, + answered: Map, +): Operation { + const settled = event.result.status === "ok" ? successOf(event.result.value) : undefined; + if (event.result.status === "ok" && settled === undefined) { + // A successful Git-host envelope this host cannot read as a reconciliation + // record says nothing trustworthy about external work. + throw refusal( + "malformed-receipt", + "a Git-host record in this entry's history is not one this host can read, so what it says " + + "about work already published is unknown.", + ); + } + if (settled !== undefined && settled.request.kind !== GIT_PUSH) { + // Another external protocol's record. This reader owns publication only. + return; + } + if (settled !== undefined && settled.request.identity.runId !== owner) { + throw refusal( + "foreign-owner", + "a publication outcome in this entry's history was reconciled under another owner, so the " + + "work it describes was not published by this entry.", + ); + } + + const queued = pending.get(event.coroutineId) ?? []; + const request = queued.pop(); + if (request === undefined) { + const spent = answered.get(event.coroutineId) ?? []; + throw spent.length > 0 + ? refusal( + "duplicate-outcome", + "a publication request in this entry's history is answered by more than one Git-host " + + "outcome, so this history does not say which of them describes what was published.", + ) + : refusal( + "orphan-outcome", + "a publication outcome in this entry's history stands where no saved request asked for " + + "one, so this history does not say what was asked of the Git host.", + ); + } + queue(answered, event.coroutineId).push(request); + + if (event.description.name !== (yield* replPushOutcomeName(request))) { + throw refusal( + "request-fingerprint", + "a publication outcome is journaled under a name that is not the name the request before " + + "it produces, so the outcome and the request it claims to answer do not match.", + ); + } + if (settled === undefined) { + const failed = failureOf(event.result); + if (failed === undefined || !CLOSED_FAILURES.has(failed)) { + throw refusal( + "malformed-receipt", + "a refused publication is recorded under a condition this host has no word for, so what " + + "it says about the branch is unknown.", + ); + } + return; + } + if (settled.request.identity.expansionId !== request.site) { + throw refusal( + "foreign-owner", + "a publication outcome names an invocation other than the one the request before it was " + + "made at, so the two describe different publications.", + ); + } + const asked = replPushEffectRequest(request.resolved.inputs); + if ( + canonicalJson(settled.request.inputs) !== canonicalJson(asked.inputs) || + canonicalJson(settled.request.naturalKey) !== canonicalJson(asked.naturalKey) + ) { + throw refusal( + "malformed-receipt", + "a publication outcome describes a push other than the one the request before it asked " + + "for, so this history does not say what was published.", + ); + } + if (parseGitPushRecord(settled, pushExpectation(request.resolved.inputs)) === undefined) { + throw refusal( + "malformed-receipt", + "a publication outcome holds a result that is not the one a Push of that branch produces, " + + "so what it says was published is unknown.", + ); + } +} + +/** The reconciliation record this retained value describes, or `undefined`. */ +function successOf(value: unknown): GitHostReconciliationRecord | undefined { + return parseGitHostReconciliationRecord(value); +} + +/** The closed name a failed envelope carries, or `undefined` when it carries none. */ +function failureOf(result: Result): string | undefined { + return result.status === "err" ? result.error.name : undefined; +} + +function malformed(): ReplPushHistoryError { + return refusal( + "malformed-record", + `a ${REPL_REPOSITORY_PUSH_REQUEST} record in this entry's history is not one this host can ` + + "read, so what it says about work already asked for is unknown.", + ); +} + +function refusal(reason: ReplPushRefusal, sentence: string): ReplPushHistoryError { + return new ReplPushHistoryError(reason, sentence); +} diff --git a/packages/git/src/retained-repository/push-records.ts b/packages/git/src/retained-repository/push-records.ts new file mode 100644 index 000000000..7a9ae85f4 --- /dev/null +++ b/packages/git/src/retained-repository/push-records.ts @@ -0,0 +1,303 @@ +/** + * What one REPL entry's `` asked to publish, on the wire. + * + * An entry that publishes a branch writes this down *before* a remote is + * contacted, and the external outcome is reconciled afterwards under the shared + * Git-host contract. Those are two records: this one says what was asked, and + * the Git-host record beside it says what the host did about it. Reopen the + * entry and the saved request is what the next attempt reconciles — the branch + * and the commit it originally named, however far HEAD has moved since. + * + * It is a request publication and nothing else. A record here is not evidence + * that anything was published: an entry interrupted between this append and the + * remote leaves exactly this record and no outcome, which is the window the + * reconciliation exists to close. + * + * ## Three readings of one place + * + * `requested` is what the invocation observed: the Repository identity it + * authenticated, the checkout that selection named, its common Git directory + * and the working directory exactly as it arrived. `received` is the same four + * as they stood when the live dispatch reached this provider, so an intentional + * middleware transformation is visible rather than hidden — when nothing + * transformed them the two readings are equal and both are still written. + * `resolved` is the answer: the covering checkout, its common directory, the + * admitted credential-free origin and the complete Push inputs. + * + * Only `requested` names the record. A replay matches the question, not the + * answer, so `received` and `resolved` are read back and held to each other + * rather than re-resolved: resolving them again would ask today's filesystem + * what a saved request meant. + * + * ## What is deliberately absent + * + * No opaque selection identifier, no source object, no token, no Git output, no + * HEAD inventory and no publication flag. A selection identifier names nothing + * once the entry ends; everything else is either a secret, a snapshot this + * feature does not take, or an assertion this record is not entitled to make. + * + * Host paths are kept as the bytes they arrived as. The Workflow codecs + * normalize a logical Workspace path because a Workspace owns its own layout; + * an entry's paths are places on somebody's disk, and rewriting one would make + * a saved request name a different directory than the one that was asked for. + */ + +import type { Operation } from "effection"; +import type { Json } from "@executablemd/durable-streams"; +import { canonicalJson } from "@executablemd/workflow"; +import { members, text } from "../composition/parse.ts"; +import { admitLocator } from "../composition/locator.ts"; +import { + gitPushInputsJson, + parseGitPushInputs, + type GitPushInputs, +} from "../composition/git-push-records.ts"; +import { + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, + type RepositoryIdentity, +} from "../composition/selection.ts"; +import { digest, REPL_REPOSITORY_VERSION } from "./records.ts"; + +/** The durable type one entry Push request is published under. */ +export const REPL_REPOSITORY_PUSH_REQUEST = "repl_repository_push_request"; + +/** The prefix the request's own name is built from. */ +const PUSH_REQUEST_PREFIX = "executablemd.repl.push-request"; + +/** + * Where one Push was asked for, as one reading of it. + * + * The same four facts the local Git records carry, and for the same reason: a + * Push belongs to the Repository the invocation authenticated, to the checkout + * that selection named, to the repository that checkout shares, and to the + * directory the element was written in. + */ +export interface ReplPushPlace { + /** All six members of the authenticated Repository identity. */ + readonly repository: RepositoryIdentity; + /** The checkout root the authenticated selection named. */ + readonly selectionCheckout: string; + /** That selection's canonical common Git directory. */ + readonly commonDirectory: string; + /** The working directory exactly as it was received, uncanonicalized. */ + readonly workingDirectory: string; +} + +/** What routing answered, and the complete request it produced. */ +export interface ReplPushResolution { + /** The checkout actually covering the received working directory. */ + readonly checkoutRoot: string; + /** That checkout's canonical common Git directory. */ + readonly commonDirectory: string; + /** The admitted credential-free locator this branch is published to. */ + readonly origin: string; + /** The complete inputs the Git-host effect acts on. */ + readonly inputs: GitPushInputs; +} + +/** One entry Push request, as a continuation reads it back. */ +export interface ReplPushRequestRecord { + readonly version: typeof REPL_REPOSITORY_VERSION; + /** The entry whose retained work this is. */ + readonly owner: string; + /** The invocation inside that entry, as canonical core minted it. */ + readonly site: string; + readonly requested: ReplPushPlace; + readonly received: ReplPushPlace; + readonly resolved: ReplPushResolution; +} + +/** What names one request: the owner, the site and the question it asked. */ +export interface ReplPushRequestName { + readonly owner: string; + readonly site: string; + readonly requested: ReplPushPlace; +} + +const RECORD_MEMBERS = ["version", "owner", "site", "requested", "received", "resolved"] as const; + +const PLACE_MEMBERS = [ + "repository", + "selectionCheckout", + "commonDirectory", + "workingDirectory", +] as const; + +const RESOLUTION_MEMBERS = ["checkoutRoot", "commonDirectory", "origin", "inputs"] as const; + +/** + * The stable name one Push request is published under. + * + * A digest of the version, the owner, the site and every member of the + * question. Two equal questions at two positions produce one name, which is + * allowed: telling them apart is the durable engine's own positional matching, + * and nothing here searches history by name. + * + * The answer takes no part in it. A name derived from what routing resolved + * would have to be recomputed to find the record — which means resolving a + * saved request again, against a filesystem that has since moved. + */ +export function replPushRequestName(request: ReplPushRequestName): Operation { + return digest([ + PUSH_REQUEST_PREFIX, + REPL_REPOSITORY_VERSION, + request.owner, + request.site, + placeJson(request.requested), + ]); +} + +/** The payload one published Push request carries. */ +export function replPushRequestJson(record: { + readonly owner: string; + readonly site: string; + readonly requested: ReplPushPlace; + readonly received: ReplPushPlace; + readonly resolved: ReplPushResolution; +}): Json { + return { + version: REPL_REPOSITORY_VERSION, + owner: record.owner, + site: record.site, + requested: placeJson(record.requested), + received: placeJson(record.received), + resolved: { + checkoutRoot: record.resolved.checkoutRoot, + commonDirectory: record.resolved.commonDirectory, + origin: record.resolved.origin, + inputs: gitPushInputsJson(record.resolved.inputs), + }, + }; +} + +/** + * The Push request this saved value describes, or `undefined`. + * + * Total, and exact about every member. Beyond the shape it refuses a request + * whose origin is not a locator this host would hand to Git, whose origin does + * not name the Repository the inputs name, and whose resolved inputs belong to + * a Repository other than the one the live dispatch received. Each of those + * would make a saved request name work this entry cannot account for. + * + * Whether the saved roots are places this entry holds is not asked here. That + * is a question about parsed placements and about this entry's own registry, + * and it is answered by the admission and the readback beside this codec. + */ +export function parseReplPushRequestRecord(value: unknown): ReplPushRequestRecord | undefined { + // Totally: enumeration and every property read are the value's own to + // refuse, and a refusal of any of them is the same answer. A revoked proxy + // and a throwing getter both mean this value describes no saved request, and + // answering that is what keeps an untrusted value from carrying its own text + // out through an exception. + try { + return readRequestRecord(value); + } catch { + return undefined; + } +} + +function readRequestRecord(value: unknown): ReplPushRequestRecord | undefined { + const read = members(value, RECORD_MEMBERS); + if (read === undefined || read["version"] !== REPL_REPOSITORY_VERSION) { + return undefined; + } + const owner = text(read["owner"]); + const site = text(read["site"]); + const requested = parsePlace(read["requested"]); + const received = parsePlace(read["received"]); + if ( + owner === undefined || + site === undefined || + requested === undefined || + received === undefined + ) { + return undefined; + } + const resolved = parseResolution(read["resolved"], received); + if (resolved === undefined) { + return undefined; + } + return Object.freeze({ + version: REPL_REPOSITORY_VERSION, + owner, + site, + requested, + received, + resolved, + }); +} + +/** One reading of where a Push was asked for, or `undefined`. */ +function parsePlace(value: unknown): ReplPushPlace | undefined { + const read = members(value, PLACE_MEMBERS); + if (read === undefined) { + return undefined; + } + const repository = parseRepositoryIdentity(read["repository"]); + const selectionCheckout = text(read["selectionCheckout"]); + const commonDirectory = text(read["commonDirectory"]); + const workingDirectory = text(read["workingDirectory"]); + if ( + repository === undefined || + selectionCheckout === undefined || + commonDirectory === undefined || + workingDirectory === undefined + ) { + return undefined; + } + return Object.freeze({ repository, selectionCheckout, commonDirectory, workingDirectory }); +} + +/** + * What routing resolved, held to the reading it resolved from. + * + * The origin is admitted through the same closed grammar a document's locator + * passes, so a saved origin this host would not hand to Git is refused here. + * Whether an admitted one is the origin the Repository identity was + * fingerprinted from is the other half of that question, and the digest it + * needs belongs to the runtime-named half of this package: the provider asks it + * of every record it reads back, before the record may be reconciled. `remote` + * stays the literal label `origin` and the destination stays the branch's own + * ref, both of which the shared Push parser decides. + */ +function parseResolution(value: unknown, received: ReplPushPlace): ReplPushResolution | undefined { + const read = members(value, RESOLUTION_MEMBERS); + if (read === undefined) { + return undefined; + } + const checkoutRoot = text(read["checkoutRoot"]); + const commonDirectory = text(read["commonDirectory"]); + const origin = text(read["origin"]); + const inputs = parseGitPushInputs(read["inputs"]); + if ( + checkoutRoot === undefined || + commonDirectory === undefined || + origin === undefined || + inputs === undefined + ) { + return undefined; + } + if (admitLocator(origin) !== origin) { + return undefined; + } + if (!sameRepositoryIdentity(inputs.repository, received.repository)) { + return undefined; + } + return Object.freeze({ checkoutRoot, commonDirectory, origin, inputs }); +} + +function placeJson(place: ReplPushPlace): Json { + return { + repository: repositoryIdentityJson(place.repository), + selectionCheckout: place.selectionCheckout, + commonDirectory: place.commonDirectory, + workingDirectory: place.workingDirectory, + }; +} + +/** Whether two readings of one Push's place describe the same place. */ +export function sameReplPushPlace(left: ReplPushPlace, right: ReplPushPlace): boolean { + return canonicalJson(placeJson(left)) === canonicalJson(placeJson(right)); +} diff --git a/packages/git/tests/git-host-owned.test.ts b/packages/git/tests/git-host-owned.test.ts new file mode 100644 index 000000000..de894032a --- /dev/null +++ b/packages/git/tests/git-host-owned.test.ts @@ -0,0 +1,634 @@ +/** + * Tier OW — reconciling an external Git-host effect under a stated owner. + * + * The shared engine's other entrypoint derives its identity from a workflow run + * and the engine's expansion. This one is told who owns the history and which + * invocation inside it is asking, which is what lets a REPL entry retain + * external work the way a workflow run does without being one. + * + * What each case measures is the order: whose identity reached the provider, + * how many times a phase ran, when the attempt's own resources closed, and what + * the journal holds afterwards. Nothing here reaches a Git host, and nothing + * here is GitHub. + * + * These cases carry the provider-neutral half of the delivery-4 matrix: E2's + * provider-free completed replay, E4's decisions, E5's identity separation, + * E6's refusal of a completion the caller cannot account for, E7's persistence + * fence, and E8's joined teardown before publication. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Err, ensure, Ok, scoped } from "effection"; +import type { Operation, Result } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import { collect, getExpansion, inlineSource, registerComponents } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { GIT_HOST_EFFECT, reconcileOwnedGitHostEffect } from "../src/git-host/effect.ts"; +import type { OwnedGitHostLive } from "../src/git-host/effect.ts"; +import { + GitHostConflictError, + GitHostProviderError, + GitHostUnavailableError, +} from "../src/git-host/errors.ts"; +import { gitHostRequestFingerprint } from "../src/git-host/records.ts"; +import type { GitHostEffectRequest, GitHostReconciliationRecord } from "../src/git-host/records.ts"; +// The public contracts a provider is written against arrive on the subpath that +// publishes them, exactly as any other consumer reaches them. +import type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostObservation, + GitHostProvider, +} from "@executablemd/git/api"; + +const OWNER = '["executablemd.repl.issue-owner",1,"execution-7","entry-2"]'; +const SITE = "site-9f2c"; + +const SOURCE = "\n"; + +const PUSH: GitHostEffectRequest = Object.freeze({ + kind: "git-push", + inputs: { remote: "origin", branch: "review/push", commit: "9fceb02" }, + naturalKey: { ref: "refs/heads/review/push" }, +}); + +const ABSENT: GitHostObservation = Object.freeze({ + state: "absent", + preState: { remoteCommit: null }, +}); +const CONFLICT: GitHostObservation = Object.freeze({ + state: "conflict", + preState: { remoteCommit: "0000001" }, +}); +const AMBIGUOUS: GitHostObservation = Object.freeze({ + state: "ambiguous", + preState: { remoteCommit: null }, +}); +const COMPATIBLE: GitHostObservation = Object.freeze({ + state: "compatible", + preState: { remoteCommit: "9fceb02" }, + observations: { remoteCommit: "9fceb02" }, + result: { commit: "9fceb02" }, +}); +const PERFORMED: GitHostCompletion = Object.freeze({ + observations: { remoteCommit: "9fceb02" }, + result: { commit: "9fceb02" }, +}); + +type Observe = (request: CompleteGitHostEffectRequest) => Operation>; +type Perform = ( + request: CompleteGitHostEffectRequest, + observation: GitHostObservation, +) => Operation>; + +interface Recorded { + readonly provider: GitHostProvider; + readonly observed: CompleteGitHostEffectRequest[]; + readonly performed: CompleteGitHostEffectRequest[]; + readonly evidence: GitHostObservation[]; +} + +function recordingProvider(observe: Observe, perform?: Perform): Recorded { + const observed: CompleteGitHostEffectRequest[] = []; + const performed: CompleteGitHostEffectRequest[] = []; + const evidence: GitHostObservation[] = []; + return { + observed, + performed, + evidence, + provider: { + *observe(request): Operation> { + observed.push(request); + return yield* observe(request); + }, + *perform(request, observation): Operation> { + performed.push(request); + evidence.push(observation); + if (perform === undefined) { + throw new Error("the engine performed where nothing may be performed"); + } + return yield* perform(request, observation); + }, + }, + }; +} + +function answering(value: Result): () => Operation> { + // deno-lint-ignore require-yield + return function* (): Operation> { + return value; + }; +} + +/** What one owned attempt did, as the trusted body saw it. */ +interface Attempt { + readonly records: GitHostReconciliationRecord[]; + readonly failures: unknown[]; + readonly validated: GitHostReconciliationRecord[]; + /** How many times the live provider was constructed, replay included. */ + constructions: number; +} + +function attempt(): Attempt { + return { records: [], failures: [], validated: [], constructions: 0 }; +} + +interface OwnedOptions { + readonly stream: DurableStream; + readonly owner?: string; + readonly site?: string; + readonly request?: GitHostEffectRequest; + readonly provider?: GitHostProvider; + /** Reached with the complete record, before the coordinator publishes it. */ + readonly validate?: (record: GitHostReconciliationRecord) => void; + /** Acquired inside the attempt's own scope, around the whole reconciliation. */ + readonly resource?: () => Operation; + readonly seen?: Attempt; + /** Invocations the one `` makes, which is one by default. */ + readonly calls?: number; +} + +/** + * One document execution whose element reconciles an owned effect. + * + * No workflow run is installed, and that is a property under test rather than a + * shortcut: this entrypoint's identity is the one its caller states, so an + * execution that is not a workflow run reconciles external work all the same. + */ +function* runOwned(options: OwnedOptions): Operation { + const seen = options.seen ?? attempt(); + const owner = options.owner ?? OWNER; + const site = options.site ?? SITE; + const request = options.request ?? PUSH; + const calls = options.calls ?? 1; + + yield* scoped(function* () { + yield* registerComponents([ + { + name: "Effect", + origin: "tier-ow", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + // Read so a site derived from the engine stays available to a case + // that wants one; the identity this entrypoint uses is the stated + // pair and nothing else. + yield* getExpansion(); + for (let index = 0; index < calls; index += 1) { + try { + seen.records.push( + yield* reconcileOwnedGitHostEffect({ owner, site, request }, live(options, seen)), + ); + } catch (error) { + seen.failures.push(error); + throw error; + } + } + return ""; + }, + }, + ]); + try { + yield* collect( + yield* executeInstalled({ ...inlineSource(SOURCE), stream: options.stream }, []), + ); + } catch { + // The component recorded the exact failure; a document that fails is one + // of the outcomes under test rather than an error in the harness. + } + }); + return seen; +} + +/** + * The private trusted half one case supplies. + * + * `provider()` is where a live attempt's resources are opened, so a case that + * states one acquires it here — inside the attempt's own scope, where the + * engine joins its teardown before publication. + */ +function live(options: OwnedOptions, seen: Attempt): OwnedGitHostLive { + return { + *provider(): Operation { + seen.constructions += 1; + if (options.resource !== undefined) { + yield* options.resource(); + } + const provider = options.provider; + if (provider === undefined) { + throw new Error("the attempt constructed a provider where none may be constructed"); + } + return provider; + }, + validate(record: GitHostReconciliationRecord): void { + seen.validated.push(record); + options.validate?.(record); + }, + }; +} + +function gitHostYields(events: DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === GIT_HOST_EFFECT, + ); +} + +/** The history a run leaves behind when it was interrupted before its root closed. */ +function partial(events: DurableEvent[]): DurableEvent[] { + return events.filter((event) => !(event.type === "close" && event.coroutineId === "root")); +} + +function recordOf(seen: Attempt): GitHostReconciliationRecord { + const record = seen.records[0]; + if (record === undefined) { + throw new Error("the effect produced no reconciliation record"); + } + return record; +} + +/** The names the journal holds for this effect's type, in append order. */ +function names(events: DurableEvent[]): string[] { + return gitHostYields(events).map((event) => + event.type === "yield" ? event.description.name : "", + ); +} + +/** A provider that fails the case if any phase reaches it. */ +function forbiddenProvider(): GitHostProvider { + return recordingProvider( + // deno-lint-ignore require-yield + function* (): Operation> { + throw new Error("the Git host was observed where no observation may happen"); + }, + ).provider; +} + +function refusingGitHostAppends(inner: InMemoryStream): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === GIT_HOST_EFFECT) { + throw new Error("the journal backend refused this append"); + } + yield* inner.append(event); + }, + }; +} + +describe("Tier OW — owner-specified external Git-host reconciliation", () => { + it("OW1: reconciles under exactly the stated owner and site, with no workflow run", function* () { + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + + const seen = yield* runOwned({ stream, provider: host.provider }); + + expect(seen.failures).toEqual([]); + expect(host.observed).toHaveLength(1); + expect(host.performed).toHaveLength(1); + // The pair the caller stated, carried in the field spellings the record + // already had. Nothing was derived from a run this execution is not. + const identity = { runId: OWNER, expansionId: SITE }; + expect(host.observed[0]?.identity).toEqual(identity); + expect(host.performed[0]?.identity).toEqual(identity); + expect(host.evidence).toEqual([ABSENT]); + + expect(recordOf(seen)).toEqual({ + request: { identity, kind: PUSH.kind, inputs: PUSH.inputs, naturalKey: PUSH.naturalKey }, + preState: ABSENT.preState, + observations: PERFORMED.observations, + decision: "performed", + result: PERFORMED.result, + }); + // The complete record reached the caller's own reading before it was + // published, and the journal holds exactly what was validated. + expect(seen.validated).toEqual([recordOf(seen)]); + const yields = gitHostYields(stream.snapshot()); + expect(yields).toHaveLength(1); + expect(yields[0]).toEqual( + expect.objectContaining({ result: { status: "ok", value: recordOf(seen) } }), + ); + expect(names(stream.snapshot())).toEqual([ + yield* gitHostRequestFingerprint({ + identity, + kind: PUSH.kind, + inputs: PUSH.inputs, + naturalKey: PUSH.naturalKey, + }), + ]); + }); + + it("OW2: another owner, another site and a second occurrence are separate work", function* () { + const identity = (owner: string, site: string) => ({ runId: owner, expansionId: site }); + const complete = (owner: string, site: string): CompleteGitHostEffectRequest => ({ + identity: identity(owner, site), + kind: PUSH.kind, + inputs: PUSH.inputs, + naturalKey: PUSH.naturalKey, + }); + + const mine = yield* gitHostRequestFingerprint(complete(OWNER, SITE)); + const elsewhere = yield* gitHostRequestFingerprint(complete(OWNER, "site-other")); + const somebody = yield* gitHostRequestFingerprint(complete("another-owner", SITE)); + expect(new Set([mine, elsewhere, somebody]).size).toBe(3); + + // Two invocations of one element, with equal inputs at one owner and site. + // They are two occurrences at two positions, which is allowed: telling them + // apart is the engine's positional matching, and nothing deduplicates by + // name. + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + const seen = yield* runOwned({ stream, provider: host.provider, calls: 2 }); + + expect(seen.failures).toEqual([]); + expect(seen.records).toHaveLength(2); + expect(host.observed).toHaveLength(2); + expect(host.performed).toHaveLength(2); + expect(names(stream.snapshot())).toEqual([mine, mine]); + }); + + it("OW3: a completed replay constructs no provider, validates nothing and appends nothing", function* () { + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + const first = yield* runOwned({ stream, provider: host.provider }); + expect(first.constructions).toBe(1); + + const replayed = new InMemoryStream(partial(stream.snapshot())); + // Every append this execution makes, so "nothing was appended for the + // effect" is measured rather than inferred: the root's own close is still + // written, which is what makes `appendCount` the wrong question. + const appended: string[] = []; + replayed.onAppend = (event) => { + appended.push(event.type === "yield" ? event.description.type : event.type); + }; + const second = yield* runOwned({ stream: replayed, provider: forbiddenProvider() }); + + expect(second.failures).toEqual([]); + expect(recordOf(second)).toEqual(recordOf(first)); + // Neither half of the live attempt is reached: no provider is constructed, + // so no credential, transport or resource of any kind is either, and the + // caller's validation is not re-run over a record it already accepted. + expect(second.constructions).toBe(0); + expect(second.validated).toEqual([]); + expect(host.observed).toHaveLength(1); + expect(host.performed).toHaveLength(1); + expect(appended).not.toContain(GIT_HOST_EFFECT); + expect(gitHostYields(replayed.snapshot())).toHaveLength(1); + }); + + it("OW4: a completion the caller cannot account for is fatal and publishes nothing", function* () { + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + const refusal = new Error("this completion names a push this invocation did not describe"); + + const seen = yield* runOwned({ + stream, + provider: host.provider, + validate: () => { + throw refusal; + }, + }); + + expect(seen.records).toEqual([]); + // The caller's own sentence survives: a validation refusal is this + // attempt's own non-outcome rather than an anonymous boundary failure. + expect(seen.failures).toEqual([refusal]); + expect(host.performed).toHaveLength(1); + // Validated before publication, so a completion this reading refuses is + // never acknowledged — however far the remote work got. + expect(gitHostYields(stream.snapshot())).toEqual([]); + }); + + it("OW5: conflict, ambiguity and unavailability publish as themselves and replay provider-free", function* () { + const cases: readonly { readonly observation: GitHostObservation; readonly name: string }[] = [ + { observation: CONFLICT, name: "GitHostConflictError" }, + { observation: AMBIGUOUS, name: "GitHostAmbiguousError" }, + ]; + for (const { observation, name } of cases) { + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(observation))); + const seen = yield* runOwned({ stream, provider: host.provider }); + + expect(seen.records).toEqual([]); + expect(host.performed).toEqual([]); + // A decision the Git host made is this effect's failed result, and the + // caller's validation is not asked about a record there is none of. + expect(seen.validated).toEqual([]); + expect(gitHostYields(stream.snapshot())).toHaveLength(1); + expect(gitHostYields(stream.snapshot())[0]).toEqual( + expect.objectContaining({ + result: expect.objectContaining({ + status: "err", + error: expect.objectContaining({ name }), + }), + }), + ); + + const replayed = new InMemoryStream(partial(stream.snapshot())); + const second = yield* runOwned({ stream: replayed, provider: forbiddenProvider() }); + expect(second.constructions).toBe(0); + expect(gitHostYields(replayed.snapshot())).toHaveLength(1); + } + + // Unavailability is the provider's own answer rather than an observation. + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Err(new GitHostUnavailableError()))); + const seen = yield* runOwned({ stream, provider: host.provider }); + expect(seen.failures[0]).toBeInstanceOf(GitHostUnavailableError); + expect(gitHostYields(stream.snapshot())).toHaveLength(1); + + const replayed = new InMemoryStream(partial(stream.snapshot())); + const second = yield* runOwned({ stream: replayed, provider: forbiddenProvider() }); + expect(second.failures[0]).toBeInstanceOf(GitHostUnavailableError); + expect(second.constructions).toBe(0); + }); + + it("OW6: attempt resources survive both phases and close before publication", function* () { + const stream = new InMemoryStream(); + const order: string[] = []; + stream.onAppend = (event) => { + if (event.type === "yield" && event.description.type === GIT_HOST_EFFECT) { + order.push("appended"); + } + }; + const host = recordingProvider( + // deno-lint-ignore require-yield + function* (): Operation> { + order.push(`observe:${order.includes("closed") ? "closed" : "open"}`); + return Ok(ABSENT); + }, + // deno-lint-ignore require-yield + function* (): Operation> { + order.push(`perform:${order.includes("closed") ? "closed" : "open"}`); + return Ok(PERFORMED); + }, + ); + + const seen = yield* runOwned({ + stream, + provider: host.provider, + *resource(): Operation { + order.push("opened"); + yield* ensure(function* () { + order.push("closed"); + }); + }, + validate: () => { + order.push(`validate:${order.includes("closed") ? "closed" : "open"}`); + }, + }); + + expect(seen.failures).toEqual([]); + // One resource, opened once around a whole reconciliation: what observe + // opened is still open when perform runs, and the validation that decides + // whether anything may be published runs while it is too. Teardown is + // joined before the append, so publication acknowledges only work whose + // resources have finished. + expect(order).toEqual([ + "opened", + "observe:open", + "perform:open", + "validate:open", + "closed", + "appended", + ]); + }); + + it("OW7: a cleanup failure publishes neither a success nor an authored refusal", function* () { + const broken = new Error("the attempt's own scratch directory could not be removed"); + + // A successful reconciliation whose teardown then fails. + const succeeded = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + const success = yield* runOwned({ + stream: succeeded, + provider: host.provider, + *resource(): Operation { + yield* ensure(function* () { + throw broken; + }); + }, + }); + + expect(success.records).toEqual([]); + expect(host.performed).toHaveLength(1); + // The outcome object survived unwinding, and surviving is not enough: the + // attempt is no longer holding what it authored, so nothing is published. + expect(gitHostYields(succeeded.snapshot())).toEqual([]); + + // And an ordinary conflict the worker authored, on the same terms. + const refused = new InMemoryStream(); + const conflicted = recordingProvider(answering(Ok(CONFLICT))); + const second = yield* runOwned({ + stream: refused, + provider: conflicted.provider, + *resource(): Operation { + yield* ensure(function* () { + throw broken; + }); + }, + }); + + expect(second.records).toEqual([]); + expect(second.failures[0]).not.toBeInstanceOf(GitHostConflictError); + expect(gitHostYields(refused.snapshot())).toEqual([]); + }); + + it("OW8: a refused append is the first failure, and nothing is published after it", function* () { + const backing = new InMemoryStream(); + const host = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); + + const seen = yield* runOwned({ + stream: refusingGitHostAppends(backing), + provider: host.provider, + }); + + expect(seen.records).toEqual([]); + expect(seen.failures).toHaveLength(1); + // The remote work happened and the journal refused it, which is the window + // this boundary is honest about: no record, and nothing compensating. + expect(host.performed).toHaveLength(1); + expect(gitHostYields(backing.snapshot())).toEqual([]); + }); + + it("OW9: the stated identity and request are read once, before anything suspends", function* () { + const stream = new InMemoryStream(); + const host = recordingProvider(answering(Ok(COMPATIBLE))); + + // A request object the caller keeps and edits while the attempt runs. What + // was reconciled is the reading taken before the first suspension. + const mutable: { kind: string; inputs: Json; naturalKey: Json } = { + kind: PUSH.kind, + inputs: { remote: "origin", branch: "review/push", commit: "9fceb02" }, + naturalKey: { ref: "refs/heads/review/push" }, + }; + const seen = yield* runOwned({ + stream, + request: mutable, + provider: host.provider, + validate: () => { + mutable.kind = "git-pull-request"; + mutable.inputs = { remote: "elsewhere" }; + }, + }); + + expect(seen.failures).toEqual([]); + expect(recordOf(seen).request.kind).toBe(PUSH.kind); + expect(recordOf(seen).request.inputs).toEqual({ + remote: "origin", + branch: "review/push", + commit: "9fceb02", + }); + + // And an identity member that names no history refuses before any of it. + const empty = new InMemoryStream(); + const refused = yield* runOwned({ + stream: empty, + owner: "", + provider: forbiddenProvider(), + }); + expect(refused.failures).toHaveLength(1); + expect(refused.constructions).toBe(0); + expect(gitHostYields(empty.snapshot())).toEqual([]); + }); + + it("OW10: an unauthored throw inside the attempt withholds what it raised", function* () { + const stream = new InMemoryStream(); + const secret = "ghp_0123456789abcdefghijklmnopqrstuvwx"; + const host = recordingProvider( + // deno-lint-ignore require-yield + function* (): Operation> { + throw new Error(`the transport failed with ${secret}`); + }, + ); + + const seen = yield* runOwned({ stream, provider: host.provider }); + + expect(seen.failures).toHaveLength(1); + expect(seen.failures[0]).toBeInstanceOf(GitHostProviderError); + expect(JSON.stringify(seen.failures[0])).not.toContain(secret); + expect(`${seen.failures[0]}`).not.toContain(secret); + expect(gitHostYields(stream.snapshot())).toEqual([]); + }); + + it("OW11: a provider the attempt cannot construct publishes nothing", function* () { + const stream = new InMemoryStream(); + const unavailable = new Error("this host lends no transport for that locator"); + + const seen = yield* runOwned({ + stream, + provider: forbiddenProvider(), + *resource(): Operation { + // Construction is where a live attempt opens what it needs, and a + // failure there is infrastructure: no phase runs, so the Git host is + // never asked and no outcome exists to publish. + yield* ensure(function* () {}); + throw unavailable; + }, + }); + + expect(seen.records).toEqual([]); + expect(seen.failures).toHaveLength(1); + expect(gitHostYields(stream.snapshot())).toEqual([]); + }); +}); diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 184bb09b9..9f06f9283 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -63,6 +63,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/composition/git-push-records.ts", "src/composition/git-records.ts", "src/composition/installation.ts", + "src/composition/locator.ts", "src/composition/parse.ts", "src/composition/pull-request-api.ts", "src/composition/pull-request-operations.ts", @@ -95,6 +96,8 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/retained-repository/admission.ts", "src/retained-repository/git-records.ts", "src/retained-repository/placement-records.ts", + "src/retained-repository/push-admission.ts", + "src/retained-repository/push-records.ts", "src/retained-repository/records.ts", ]; @@ -167,6 +170,7 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/retained-repository/leases.ts", "src/deno/retained-repository/local.ts", "src/deno/retained-repository/provider.ts", + "src/deno/retained-repository/push.ts", "src/deno/retained-repository/selections.ts", "src/deno/run-composition/ambient.ts", "src/deno/run-composition/checkouts.ts", diff --git a/packages/git/tests/retained-repository-push-records.test.ts b/packages/git/tests/retained-repository-push-records.test.ts new file mode 100644 index 000000000..1c2d4f97f --- /dev/null +++ b/packages/git/tests/retained-repository-push-records.test.ts @@ -0,0 +1,770 @@ +/** + * Tier RP — what a REPL entry's saved publication request says, and what a + * history holding one is allowed to look like. + * + * Two boundaries, both provider-neutral and both pure. The **codec** decides + * what a saved request is: the shape, the closed origin grammar, and the facts + * three readings of one place have to agree about. The **admission** decides + * whether a whole history may be continued: integrity, this entry's ownership, + * and the pairing of each request with the outcome that answers it. + * + * Nothing here opens a checkout, resolves a path or contacts anything, because + * neither boundary may: a continuation whose managed worktree was deleted while + * the person was away answers from what it already wrote down. + * + * These cases carry E6 of the delivery-4 matrix, and the request half of E3's + * trailing-request history. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Ok } from "effection"; +import type { Operation } from "effection"; +import { serializeError } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { + destinationRefFor, + gitPushObservationsJson, + gitPushPreStateJson, + gitPushResultJson, + PUSH_REMOTE, + refspecFor, + type GitPushInputs, +} from "../src/composition/git-push-records.ts"; +import { locatorFingerprint } from "../src/deno/composition/locator.ts"; +import type { RepositoryIdentity } from "../src/composition/selection.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; +import { + gitHostReconciliationRecordJson, + gitHostRequestFingerprint, +} from "../src/git-host/records.ts"; +import { + admitReplPushHistory, + replPushEffectRequest, + replPushOutcomeName, + replPushPlacesHeld, + ReplPushHistoryError, + type ReplPushPlacement, +} from "../src/retained-repository/push-admission.ts"; +import { + parseReplPushRequestRecord, + replPushRequestJson, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + sameReplPushPlace, + type ReplPushPlace, + type ReplPushRequestRecord, +} from "../src/retained-repository/push-records.ts"; +import { + replPlacementFingerprint, + replPlacementRecordJson, + replPlacementRecordName, + REPL_REPOSITORY_PLACEMENT, + type ReplPlacement, +} from "../src/retained-repository/placement-records.ts"; + +const OWNER = '["executablemd.repl.issue-owner",1,"execution-4","entry-1"]'; +const SITE = "expansion-5a7"; + +const ORIGIN = "/srv/remotes/project.git"; +const CHECKOUT = "/home/dev/.xmd/repositories/project/checkout"; +const COMMON = "/home/dev/.xmd/repositories/project/checkout/.git"; +const BRANCH = "review/push"; +const COMMIT = "9fceb02d8c5a1b4e7f03a6d9c2b8e5174a0f3b6d"; +const OTHER_COMMIT = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"; + +const IDENTITY: RepositoryIdentity = Object.freeze({ + name: "project", + locatorFingerprint: locatorFingerprint(ORIGIN), + requestedBase: null, + creationCommit: "c0ffee00000000000000000000000000deadbeef", + primaryBranch: "main", + objectFormat: "sha1", +}); + +const PLACE: ReplPushPlace = Object.freeze({ + repository: IDENTITY, + selectionCheckout: CHECKOUT, + commonDirectory: COMMON, + workingDirectory: CHECKOUT, +}); + +const INPUTS: GitPushInputs = Object.freeze({ + repository: IDENTITY, + remote: PUSH_REMOTE, + branch: BRANCH, + destinationRef: destinationRefFor(BRANCH), + sourceCommit: COMMIT, +}); + +const HELD: readonly ReplPushPlacement[] = [ + { identity: IDENTITY, checkoutRoot: CHECKOUT, commonDirectory: COMMON }, +]; + +const PLACEMENT: ReplPlacement = Object.freeze({ + kind: "repository", + managedRoot: "/home/dev/.xmd/repositories", + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + identity: IDENTITY, + displayName: "project", + ownerCheckout: CHECKOUT, +}); + +/** One saved request, with whatever this case wants to be different about it. */ +function request( + overrides: { + readonly owner?: string; + readonly site?: string; + readonly requested?: ReplPushPlace; + readonly received?: ReplPushPlace; + readonly resolved?: { + readonly checkoutRoot?: string; + readonly commonDirectory?: string; + readonly origin?: string; + readonly inputs?: GitPushInputs; + }; + } = {}, +): Json { + return replPushRequestJson({ + owner: overrides.owner ?? OWNER, + site: overrides.site ?? SITE, + requested: overrides.requested ?? PLACE, + received: overrides.received ?? PLACE, + resolved: { + checkoutRoot: overrides.resolved?.checkoutRoot ?? CHECKOUT, + commonDirectory: overrides.resolved?.commonDirectory ?? COMMON, + origin: overrides.resolved?.origin ?? ORIGIN, + inputs: overrides.resolved?.inputs ?? INPUTS, + }, + }); +} + +function parsed(payload: Json): ReplPushRequestRecord { + const record = parseReplPushRequestRecord(payload); + if (record === undefined) { + throw new Error("the saved request this case builds does not parse"); + } + return record; +} + +/** The journal event one published request is held by. */ +function* requestEvent( + payload: Json, + options: { readonly coroutineId?: string; readonly name?: string } = {}, +): Operation { + const record = parseReplPushRequestRecord(payload); + const name = + options.name ?? (record === undefined ? "unreadable" : yield* replPushRequestName(record)); + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { type: REPL_REPOSITORY_PUSH_REQUEST, name }, + result: { status: "ok", value: payload }, + }; +} + +/** The successful Git-host outcome one saved request's publication produces. */ +function* outcomeEvent( + record: ReplPushRequestRecord, + options: { + readonly coroutineId?: string; + readonly name?: string; + readonly inputs?: GitPushInputs; + readonly decision?: "performed" | "adopted"; + readonly observed?: string; + } = {}, +): Operation { + const inputs = options.inputs ?? record.resolved.inputs; + const decision = options.decision ?? "performed"; + const observed = options.observed ?? inputs.sourceCommit; + const complete = { + identity: { runId: record.owner, expansionId: record.site }, + ...replPushEffectRequest(inputs), + }; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: options.name ?? (yield* gitHostRequestFingerprint(complete)), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: complete.identity, + kind: complete.kind, + inputs: complete.inputs, + naturalKey: complete.naturalKey, + }, + preState: gitPushPreStateJson( + decision === "performed" ? { remoteCommit: null } : { remoteCommit: observed }, + ), + observations: gitPushObservationsJson({ remoteCommit: observed }), + decision, + result: gitPushResultJson({ + repository: inputs.repository, + remote: inputs.remote, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + refspec: refspecFor(inputs.sourceCommit, inputs.destinationRef), + sourceCommit: inputs.sourceCommit, + observedRemoteCommit: observed, + }), + }), + }, + }; +} + +/** The failed Git-host outcome a refused publication leaves. */ +function* refusedEvent( + record: ReplPushRequestRecord, + name: string, + options: { readonly coroutineId?: string; readonly journaled?: string } = {}, +): Operation { + const failure = new Error("refused"); + failure.name = name; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: options.journaled ?? (yield* replPushOutcomeName(record)), + }, + result: { status: "err", error: serializeError(failure) }, + }; +} + +/** The placement record a selection of this checkout leaves behind. */ +function* placementEvent(placement: ReplPlacement = PLACEMENT): Operation { + const selection = { + kind: "repository" as const, + name: placement.displayName, + locatorFingerprint: placement.identity.locatorFingerprint, + base: null, + }; + const fingerprint = yield* replPlacementFingerprint(selection); + return { + type: "yield", + coroutineId: "root", + description: { + type: REPL_REPOSITORY_PLACEMENT, + name: replPlacementRecordName({ kind: "repository", fingerprint }), + }, + result: { + status: "ok", + value: replPlacementRecordJson({ + fingerprint, + request: selection, + outcome: Ok(placement), + }), + }, + }; +} + +/** What admitting this history refused, or `undefined` when it admitted it. */ +function* refusalOf(events: readonly DurableEvent[]): Operation { + try { + yield* admitReplPushHistory(OWNER, events); + return undefined; + } catch (error) { + if (error instanceof ReplPushHistoryError) { + return error; + } + throw error; + } +} + +describe("Tier RP — a REPL entry's saved publication request", () => { + it("RP1: holds the question, the received reading and what routing resolved", function* () { + const record = parsed(request()); + + expect(record.owner).toBe(OWNER); + expect(record.site).toBe(SITE); + expect(record.requested).toEqual(PLACE); + expect(record.received).toEqual(PLACE); + expect(record.resolved.inputs).toEqual(INPUTS); + expect(record.resolved.origin).toBe(ORIGIN); + // Equal readings still occupy both fields, so a record always says which + // of the two it is talking about. + expect(sameReplPushPlace(record.requested, record.received)).toBe(true); + + // Nothing else is retained, and the member sets say so exactly: no opaque + // selection identifier, no source object, no token, no Git output, no HEAD + // inventory and no flag claiming anything was published. + const payload = request() as Record; + expect(Object.keys(payload).sort()).toEqual([ + "owner", + "received", + "requested", + "resolved", + "site", + "version", + ]); + for (const reading of ["requested", "received"]) { + expect(Object.keys(payload[reading] as Record).sort()).toEqual([ + "commonDirectory", + "repository", + "selectionCheckout", + "workingDirectory", + ]); + } + expect(Object.keys(payload["resolved"] as Record).sort()).toEqual([ + "checkoutRoot", + "commonDirectory", + "inputs", + "origin", + ]); + // The provider mints an opaque identifier per selection, and no part of it + // reaches the record: it names nothing once the entry ends. + expect(JSON.stringify(payload)).not.toMatch( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, + ); + }); + + it("RP2: the name covers the question alone, and every member of it", function* () { + const base = parsed(request()); + const mine = yield* replPushRequestName(base); + + const elsewhere = yield* replPushRequestName(parsed(request({ site: "expansion-other" }))); + const somebody = yield* replPushRequestName(parsed(request({ owner: "another-owner" }))); + const moved = yield* replPushRequestName( + parsed( + request({ + requested: { ...PLACE, workingDirectory: `${CHECKOUT}/notes` }, + }), + ), + ); + expect(new Set([mine, elsewhere, somebody, moved]).size).toBe(4); + + // The answer takes no part in it. A replay looks for the question it is + // asking now, and the question is all it knows before it finds the record. + const answered = yield* replPushRequestName( + parsed(request({ resolved: { inputs: { ...INPUTS, sourceCommit: OTHER_COMMIT } } })), + ); + expect(answered).toBe(mine); + }); + + it("RP3: refuses an unknown version, a hostile value and a missing member", function* () { + expect(parseReplPushRequestRecord(undefined)).toBe(undefined); + expect(parseReplPushRequestRecord(null)).toBe(undefined); + expect(parseReplPushRequestRecord("a saved request")).toBe(undefined); + expect(parseReplPushRequestRecord([request()])).toBe(undefined); + + const payload = request() as Record; + expect(parseReplPushRequestRecord({ ...payload, version: 2 })).toBe(undefined); + expect(parseReplPushRequestRecord({ ...payload, version: "1" })).toBe(undefined); + expect(parseReplPushRequestRecord({ ...payload, owner: "" })).toBe(undefined); + expect(parseReplPushRequestRecord({ ...payload, site: 7 })).toBe(undefined); + expect(parseReplPushRequestRecord({ ...payload, extra: true })).toBe(undefined); + const { resolved: _dropped, ...without } = payload; + expect(parseReplPushRequestRecord(without)).toBe(undefined); + + // A member whose read throws is a value that describes nothing. + const hostile = { ...payload }; + Object.defineProperty(hostile, "requested", { + enumerable: true, + get() { + throw new Error("this value refuses to be read"); + }, + }); + expect(parseReplPushRequestRecord(hostile)).toBe(undefined); + }); + + it("RP4: refuses an origin this host would not hand to Git", function* () { + // Each of these is a locator the closed grammar refuses, so a saved request + // carrying one is not one this host can act on. Whether an *admitted* origin + // is the one the identity was fingerprinted from needs the provider's own + // digest, so the provider asks it of every record it reads back: P8 in + // packages/git/tests/retained-repository-push.test.ts is that row. + for (const origin of [ + "ext::sh -c 'id'", + "-upload-pack=payload", + "https://token@example.invalid/project.git", + "https://example.invalid/project.git?access_token=secret", + "relative/path.git", + "", + ]) { + const fingerprinted: GitPushInputs = { + ...INPUTS, + repository: { ...IDENTITY, locatorFingerprint: locatorFingerprint(origin) }, + }; + expect( + parseReplPushRequestRecord( + request({ + requested: { ...PLACE, repository: fingerprinted.repository }, + received: { ...PLACE, repository: fingerprinted.repository }, + resolved: { origin, inputs: fingerprinted }, + }), + ), + ).toBe(undefined); + } + }); + + it("RP5: refuses inputs that do not belong to the Repository the dispatch received", function* () { + const elsewhere: RepositoryIdentity = { ...IDENTITY, name: "other" }; + expect( + parseReplPushRequestRecord( + request({ resolved: { inputs: { ...INPUTS, repository: elsewhere } } }), + ), + ).toBe(undefined); + + // The shared Push parser owns the rest: the remote is the literal label, + // the destination is the branch's own ref, and the commit is an object id + // in this repository's format. + for (const inputs of [ + { ...INPUTS, remote: "upstream" }, + { ...INPUTS, destinationRef: "refs/heads/elsewhere" }, + { ...INPUTS, destinationRef: BRANCH }, + { ...INPUTS, sourceCommit: "HEAD" }, + { ...INPUTS, sourceCommit: COMMIT.toUpperCase() }, + { ...INPUTS, sourceCommit: COMMIT.slice(0, 39) }, + ]) { + expect(parseReplPushRequestRecord(request({ resolved: { inputs } }))).toBe(undefined); + } + }); + + it("RP6: both object-id formats round-trip", function* () { + const sha256 = "a".repeat(64); + const identity: RepositoryIdentity = { + ...IDENTITY, + objectFormat: "sha256", + creationCommit: sha256, + }; + const inputs: GitPushInputs = { ...INPUTS, repository: identity, sourceCommit: sha256 }; + const place: ReplPushPlace = { ...PLACE, repository: identity }; + const record = parsed(request({ requested: place, received: place, resolved: { inputs } })); + expect(record.resolved.inputs.sourceCommit).toBe(sha256); + expect(record.resolved.inputs.repository.objectFormat).toBe("sha256"); + // And a sha1-width commit is not an id in a sha256 repository. + expect( + parseReplPushRequestRecord( + request({ + requested: place, + received: place, + resolved: { inputs: { ...inputs, sourceCommit: COMMIT } }, + }), + ), + ).toBe(undefined); + }); + + it("RP7: a saved place has to be one a placement before it described", function* () { + const record = parsed(request()); + expect(replPushPlacesHeld(record, HELD)).toBe(true); + expect(replPushPlacesHeld(record, [])).toBe(false); + // Each of the three readings is checked, so a record agreeing about two of + // them and naming somewhere else in the third does not pass. + expect( + replPushPlacesHeld(record, [ + { identity: IDENTITY, checkoutRoot: `${CHECKOUT}-copy`, commonDirectory: COMMON }, + ]), + ).toBe(false); + expect( + replPushPlacesHeld(record, [ + { + identity: { ...IDENTITY, name: "other" }, + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + }, + ]), + ).toBe(false); + expect( + replPushPlacesHeld( + parsed(request({ resolved: { checkoutRoot: `${CHECKOUT}/nested` } })), + HELD, + ), + ).toBe(false); + }); +}); + +describe("Tier RP — admitting a history that published", () => { + it("RP8: admits a request answered by its own outcome, and one still unanswered", function* () { + const payload = request(); + const record = parsed(payload); + const published = [ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record), + ]; + expect(yield* refusalOf(published)).toBe(undefined); + + // The state this feature exists for: the request was acknowledged and the + // host's answer never arrived. It is history, not damage. + expect(yield* refusalOf(published.slice(0, 2))).toBe(undefined); + + // An adopted completion is the same history with another decision. + expect( + yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record, { decision: "adopted" }), + ]), + ).toBe(undefined); + }); + + it("RP9: refuses a request that is misnamed, foreign or unplaced", function* () { + const payload = request(); + const record = parsed(payload); + + const misnamed = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload, { name: "a name its own question does not produce" }), + ]); + expect(misnamed?.reason).toBe("request-fingerprint"); + + const foreign = request({ owner: "another-owner" }); + const borrowed = yield* refusalOf([yield* placementEvent(), yield* requestEvent(foreign)]); + expect(borrowed?.reason).toBe("foreign-owner"); + + const unplaced = yield* refusalOf([yield* requestEvent(payload)]); + expect(unplaced?.reason).toBe("unknown-placement"); + + const malformed = yield* refusalOf([ + yield* placementEvent(), + { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PUSH_REQUEST, name: "whatever" }, + result: { status: "ok", value: { version: 1, owner: OWNER } }, + }, + ]); + expect(malformed?.reason).toBe("malformed-record"); + + const stated = yield* refusalOf([ + yield* placementEvent(), + { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PUSH_REQUEST, name: "whatever" }, + result: { status: "ok", value: { ...(payload as Record), version: 4 } }, + }, + ]); + expect(stated?.reason).toBe("unknown-version"); + + // A request publication has no failure side of its own. + const failed = yield* refusalOf([ + yield* placementEvent(), + { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PUSH_REQUEST, name: "whatever" }, + result: { status: "err", error: serializeError(new Error("refused")) }, + }, + ]); + expect(failed?.reason).toBe("malformed-record"); + + // And `record` is the reading every one of those was built against. + expect(record.owner).toBe(OWNER); + }); + + it("RP10: refuses an orphan outcome, a duplicate pairing and a receipt it cannot read", function* () { + const payload = request(); + const record = parsed(payload); + + const orphan = yield* refusalOf([yield* placementEvent(), yield* outcomeEvent(record)]); + expect(orphan?.reason).toBe("orphan-outcome"); + + const twice = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record), + yield* outcomeEvent(record), + ]); + expect(twice?.reason).toBe("duplicate-outcome"); + + const misnamed = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record, { name: "a name the request does not produce" }), + ]); + expect(misnamed?.reason).toBe("request-fingerprint"); + + const unreadable = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + { + type: "yield", + coroutineId: "root", + description: { type: GIT_HOST_EFFECT, name: yield* replPushOutcomeName(record) }, + result: { status: "ok", value: { published: true } }, + }, + ]); + expect(unreadable?.reason).toBe("malformed-receipt"); + }); + + it("RP11: refuses an outcome describing another push, or one this host cannot name", function* () { + const payload = request(); + const record = parsed(payload); + + // Named by this request, and describing another commit. The name is the + // request's own, so only reading the receipt catches it. + const elsewhere = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record, { + inputs: { ...INPUTS, sourceCommit: OTHER_COMMIT }, + name: yield* replPushOutcomeName(record), + }), + ]); + expect(elsewhere?.reason).toBe("malformed-receipt"); + + // A decision its own pre-state does not support. + const unsupported = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record, { decision: "adopted", observed: OTHER_COMMIT }), + ]); + expect(unsupported?.reason).toBe("malformed-receipt"); + + // A refusal under a condition this host has a word for, and one under a + // condition it does not. + for (const name of [ + "GitHostConflictError", + "GitHostAmbiguousError", + "GitHostUnavailableError", + ]) { + expect( + yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* refusedEvent(record, name), + ]), + ).toBe(undefined); + } + const invented = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* refusedEvent(record, "GitHostSomethingElseError"), + ]); + expect(invented?.reason).toBe("malformed-receipt"); + }); + + it("RP12: coroutines pair independently, and equal requests are separate occurrences", function* () { + const payload = request(); + const record = parsed(payload); + const other = request({ site: "expansion-second" }); + const second = parsed(other); + + // Two branches, interleaved. Neither outcome answers the other's request. + expect( + yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload, { coroutineId: "root.0" }), + yield* requestEvent(other, { coroutineId: "root.1" }), + yield* outcomeEvent(second, { coroutineId: "root.1" }), + yield* outcomeEvent(record, { coroutineId: "root.0" }), + ]), + ).toBe(undefined); + + // An outcome in a coroutine whose request lives in another is an orphan. + const crossed = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload, { coroutineId: "root.0" }), + yield* outcomeEvent(record, { coroutineId: "root.1" }), + ]); + expect(crossed?.reason).toBe("orphan-outcome"); + + // Two equal requests at two positions, each answered. Nothing is matched by + // searching for a name, and nothing is deduplicated. + expect( + yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + yield* outcomeEvent(record), + yield* requestEvent(payload), + yield* outcomeEvent(record), + ]), + ).toBe(undefined); + }); + + it("RP13: passes over records of other features and other external protocols", function* () { + const payload = request(); + const record = parsed(payload); + const complete = { + identity: { runId: OWNER, expansionId: SITE }, + kind: "git-pull-request", + inputs: { title: "Ready" }, + naturalKey: { ref: destinationRefFor(BRANCH) }, + }; + + expect( + yield* refusalOf([ + yield* placementEvent(), + // Another feature's durable type, which this reader does not own. + { + type: "yield", + coroutineId: "root", + description: { type: "repl_repository_local", name: "a file write" }, + result: { status: "ok", value: { version: 1 } }, + }, + // Another external protocol's successful record, which names its kind. + { + type: "yield", + coroutineId: "root", + description: { + type: GIT_HOST_EFFECT, + name: yield* gitHostRequestFingerprint(complete), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: complete.identity, + kind: complete.kind, + inputs: complete.inputs, + naturalKey: complete.naturalKey, + }, + preState: { pullRequest: null }, + observations: { pullRequest: 7 }, + decision: "performed", + result: { number: 7 }, + }), + }, + }, + yield* requestEvent(payload), + yield* outcomeEvent(record), + ]), + ).toBe(undefined); + + // A Push outcome reconciled under another owner is refused rather than + // passed over: an entry's own segment is where its own work is. + const borrowed = yield* refusalOf([ + yield* placementEvent(), + yield* requestEvent(payload), + { + type: "yield", + coroutineId: "root", + description: { + type: GIT_HOST_EFFECT, + name: yield* gitHostRequestFingerprint({ + identity: { runId: "another-owner", expansionId: SITE }, + ...replPushEffectRequest(INPUTS), + }), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: { runId: "another-owner", expansionId: SITE }, + ...replPushEffectRequest(INPUTS), + }, + preState: gitPushPreStateJson({ remoteCommit: null }), + observations: gitPushObservationsJson({ remoteCommit: COMMIT }), + decision: "performed", + result: gitPushResultJson({ + repository: INPUTS.repository, + remote: INPUTS.remote, + branch: INPUTS.branch, + destinationRef: INPUTS.destinationRef, + refspec: refspecFor(COMMIT, INPUTS.destinationRef), + sourceCommit: COMMIT, + observedRemoteCommit: COMMIT, + }), + }), + }, + }, + ]); + expect(borrowed?.reason).toBe("foreign-owner"); + }); +}); diff --git a/packages/git/tests/retained-repository-push.test.ts b/packages/git/tests/retained-repository-push.test.ts new file mode 100644 index 000000000..22a4111c1 --- /dev/null +++ b/packages/git/tests/retained-repository-push.test.ts @@ -0,0 +1,2117 @@ +/** + * What one REPL entry's `` asks for, publishes once, and answers + * from afterwards. + * + * Every row runs a document through canonical execution with the production + * entry capability installed, against a real checkout and a real bare remote. + * What is proven is the whole path: the authored component asks the composition + * Api, the request is written down, the shared Git-host engine observes, real + * `git` publishes at most once, and the destination is read back from the + * remote itself rather than from what the run believes. + * + * The contract these rows hold the host to is the REPL's execution model. A + * publication request is retained before anything is contacted, and a + * continuation reconciles *that* request — the branch and the commit it named — + * however far the checkout has moved since. So the rows that matter most are + * the ones where the two disagree: HEAD advanced behind a pending request, a + * remote that accepted the branch before the journal recorded it, and a + * completed publication whose checkout is gone. + * + * These rows carry E2, E3, E4, E5, E6's history half, E7 and E8 of the + * delivery-4 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, WithResolvers } from "effection"; +import { exists, rm, writeTextFile } from "@effectionx/fs"; +import { chmod, mkdir, mkdtemp, realpath, symlink, unlink, writeFile } from "node:fs/promises"; +import process from "node:process"; +import { tmpdir } from "node:os"; +import { 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 { Execution } from "@executablemd/core/api"; +import { executeInstalled } from "@executablemd/core/host"; +import { collect, Elicitation, registerComponents } from "@executablemd/core"; +import type { ElicitationRequest } from "@executablemd/core"; +import { cwd } from "@executablemd/runtime"; +import { Git, GitHost } from "@executablemd/git/api"; +import { denoRepositoryHost } from "../src/deno/composition/host.ts"; +import type { + GitInvocation, + GitOutcome, + RepositoryHost, + RepositoryHostOptions, +} from "../src/deno/composition/host.ts"; +import { gitSession } from "../src/deno/composition/git.ts"; +import type { GitAuthenticationSession } from "../src/deno/composition/authentication.ts"; +import { UNAUTHENTICATED } from "../src/deno/composition/authentication.ts"; +import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; +import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; +import { useCompositionComponents } from "../src/composition/installation.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; +import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; +import { + parseReplPushRequestRecord, + REPL_REPOSITORY_PUSH_REQUEST, +} from "../src/retained-repository/push-records.ts"; +import { + parseReplPlacementRecord, + REPL_REPOSITORY_PLACEMENT, +} from "../src/retained-repository/placement-records.ts"; +import type { ReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; +import { parseGitPushInputs } from "../src/composition/git-push-records.ts"; +import { useLeases } from "../src/deno/run-composition/leases.ts"; +import { useCommonDirectoryLease } from "../src/deno/retained-repository/leases.ts"; +import type { SlotKind } from "../src/deno/run-composition/leases.ts"; +import { repositorySlot } from "../src/deno/run-composition/checkout-layout.ts"; +import type { GitPushInputs } from "../src/composition/git-push-records.ts"; +import { selectedRepository } from "../src/composition/context.ts"; +// From the subpath that publishes it rather than from the source module: this +// result type is public, and reaching it by relative path would bypass the +// export map the partition test holds every consumer to. +import type { GitPushInvocation, GitPushOutcome } from "@executablemd/git/api"; + +/** The owner the CLI derives for an entry, as this suite states one. */ +const OWNER = '["executablemd.repl.issue-owner",1,"execution-push","1"]'; + +interface Fixture { + /** The scenario's own directory: checkouts, managed root and HOME. */ + readonly root: string; + /** The bare repository every managed Repository is cloned from. */ + readonly origin: string; + /** An ordinary checkout an entry is opened in. */ + readonly checkout: string; + /** Where this scenario's managed checkouts live. */ + readonly managed: string; + /** A symbolic link an entry is opened through. */ + readonly link: string; +} + +/** One bare remote with a commit in it, plus this scenario's own root. */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-push-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const git = gitSession(denoRepositoryHost(), root); + const origin = join(root, "origin.git"); + const seed = join(root, "seed"); + yield* until(mkdir(seed, { recursive: true })); + yield* git.run(["init", "--bare", "-b", "main", origin], root); + yield* git.run(["init", "-b", "main", seed], root); + yield* until(writeFile(join(seed, "seed.md"), "seed\n")); + yield* git.run(["add", "seed.md"], seed); + yield* git.run(["commit", "-m", "seed"], seed); + yield* git.run(["push", origin, "main:refs/heads/main"], seed); + + const checkout = join(root, "checkout"); + yield* git.run(["clone", origin, checkout], root); + const link = join(root, "entered"); + yield* until(symlink(checkout, link)); + yield* provide({ root, origin, checkout, managed: join(root, "repositories"), link }); + }); +} + +/** Point the link an entry is opened through at another directory. */ +function* repoint(fixture: Fixture, target: string): Operation { + yield* until(unlink(fixture.link)); + yield* until(symlink(target, fixture.link)); +} + +/** What the host beneath the recorder was asked to do. */ +interface Counters { + commands: string[][]; + directories: number; + sessions: string[]; +} + +function counters(): Counters { + return { commands: [], directories: 0, sessions: [] }; +} + +/** The Git subcommands one entry issued, in order. */ +function subcommands(counted: Counters): string[] { + return counted.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +} + +/** + * The author and committer this suite's host states. + * + * These rows commit before they publish, and `` borrows the + * *invoking person's* identity: the production reader asks native Git for it in + * the environment the command was started in. A developer's machine + * auto-detects one from the operating-system user; a continuous-integration + * runner whose hostname carries no domain detects nothing, `git var` fails, and + * every row here would report the ordinary missing-identity refusal instead of + * reaching the publication it is about. Stating it is also what keeps the + * commits these rows make from carrying whoever happened to run them. + */ +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", +}; + +/** That identity, as the host reads one. */ +// deno-lint-ignore require-yield +function* statedIdentity(variable: string): Operation { + return STATED_IDENTITY[variable]; +} + +/** The host filesystem, as the production one. */ +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 row may do to one `git` invocation before the real one runs. */ +type GitBarrier = (invocation: GitInvocation) => Operation; + +/** + * The production host, counted, and interruptible at a named subcommand. + * + * Every leaf is wrapped rather than replaced: what these rows need to know is + * whether a publication really happened, and the only honest way to answer is + * to let the real `ls-remote` and the real `push` happen and then read the + * remote. + */ +function countingHost(counted: Counters, options: RunOptions = {}): RepositoryHost { + const host = denoRepositoryHost(options.hostOptions ?? {}); + const barrier = options.barrier; + return { + *git(invocation: GitInvocation): Operation { + counted.commands.push([...invocation.args]); + if (barrier !== undefined) { + yield* barrier(invocation); + } + return yield* host.git({ ...invocation, ...(options.intercept?.(invocation) ?? {}) }); + }, + *useDirectory(): Operation { + counted.directories += 1; + if (options.directories !== undefined) { + return yield* options.directories(); + } + return yield* host.useDirectory(); + }, + *useAuthentication(locator: string): Operation { + counted.sessions.push(locator); + if (options.sessions !== undefined) { + return yield* options.sessions(locator); + } + return host.useAuthentication === undefined + ? UNAUTHENTICATED + : yield* host.useAuthentication(locator); + }, + }; +} + +interface Run { + readonly output: string; + readonly events: readonly DurableEvent[]; + readonly refusal: string | undefined; +} + +interface RunOptions { + /** The directory the entry is opened in. Defaults to the fixture's link. */ + readonly cwd?: string; + /** What this row installs between the host and the recording. */ + readonly between?: () => Operation; + /** A barrier inside one `git` invocation. */ + readonly barrier?: GitBarrier; + /** What this row changes about one invocation before the real host runs it. */ + readonly intercept?: (invocation: GitInvocation) => Partial | undefined; + /** What this row states about the native host it drives. */ + readonly hostOptions?: RepositoryHostOptions; + /** What this row does instead of acquiring an isolation directory. */ + readonly directories?: () => Operation; + /** What this row does instead of opening an authentication session. */ + readonly sessions?: (locator: string) => Operation; + /** The journal this entry runs over, when a row seeds one. */ + readonly stream?: DurableStream; + /** Whether this entry installs a managed root at all. */ + readonly root?: string | undefined; + /** The owner this entry's work is retained under. */ + readonly owner?: string; + /** The answer a question in this entry receives, when a row supplies one. */ + readonly answer?: Json; +} + +/** One entry, over one journal, in one checkout. */ +function run( + fixture: Fixture, + source: string, + stream: DurableStream, + counted: Counters, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const path = join(fixture.root, "entry.md"); + yield* writeTextFile(path, source); + const working = options.cwd ?? fixture.link; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return working; + }, + }, + { 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" }, + ); + yield* useCompositionComponents(); + if (options.answer !== undefined) { + const answer = options.answer; + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit(): Operation { + return answer; + }, + }, + { at: "min" }, + ); + } + if (options.between !== undefined) { + yield* options.between(); + } + const managed = "root" in options ? options.root : fixture.managed; + // Built the way the session builds it: one per-entry factory, called in the + // entry's own task, for the owner the CLI derived. + const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { + host: countingHost(counted, options), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ + // What the Git Plugin contributes for `repl`, attached directly: + // this suite assembles no command Plugins. + { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + installation, + ]), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, events: yield* stream.readAll(), refusal }; + }); +} + +/** + * The prefix an entry left behind when it was left at a question. + * + * A real unfinished history, not a complete one with its closes removed: a + * journal whose run finished replays every position it holds and runs no + * component, so a continuation with a new suffix would never reach it. + */ +function left( + fixture: Fixture, + source: string, + counted: Counters, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const stream = options.stream ?? new InMemoryStream(); + const reached = withResolvers(); + const task = yield* spawn(() => + run(fixture, source, stream, counted, { + ...options, + *between(): Operation { + if (options.between !== undefined) { + yield* options.between(); + } + yield* Elicitation.around({ + *elicit(): Operation { + reached.resolve(); + return yield* suspend(); + }, + }); + }, + }), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the entry never reached its question"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** + * The history an entry left behind when it was cut at a named invocation. + * + * The honest shape of an interrupted publication: the barrier stops the entry + * before the real `git` runs, so what the journal holds is whatever had already + * been acknowledged and nothing else. The cut is the halt below, not a + * fabricated prefix. + */ +function interrupted( + fixture: Fixture, + source: string, + counted: Counters, + at: (invocation: GitInvocation) => boolean, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const stream = new InMemoryStream(); + const reached = withResolvers(); + const task = yield* spawn(() => + run(fixture, source, stream, counted, { + ...options, + *barrier(invocation): Operation { + if (options.barrier !== undefined) { + yield* options.barrier(invocation); + } + if (at(invocation)) { + reached.resolve(); + yield* suspend(); + } + }, + }), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the entry never reached the invocation it was cut at"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** The commit a ref on the bare remote holds, or `undefined` for absence. */ +function* remoteCommit(fixture: Fixture, branch: string): Operation { + const git = gitSession(denoRepositoryHost(), fixture.root); + const outcome = yield* git.run(["rev-parse", "--verify", `refs/heads/${branch}`], fixture.origin); + return outcome.code === 0 ? outcome.stdout.trim() : undefined; +} + +/** The commit a checkout's branch holds. */ +function* localCommit(fixture: Fixture, checkout: string, revision = "HEAD"): Operation { + const git = gitSession(denoRepositoryHost(), fixture.root); + const outcome = yield* git.run(["rev-parse", revision], checkout); + if (outcome.code !== 0) { + throw new Error(`${revision} does not resolve in ${checkout}`); + } + return outcome.stdout.trim(); +} + +/** Commit one more file in a managed checkout, as a person at a shell would. */ +function* commitThere(checkout: string, name: string): Operation { + const git = gitSession(denoRepositoryHost(), checkout); + yield* writeTextFile(join(checkout, name), `${name}\n`); + yield* git.run(["add", name], checkout); + yield* git.run( + [ + "-c", + "user.name=Shell", + "-c", + "user.email=shell@example.test", + "commit", + "-m", + `outside ${name}`, + ], + checkout, + ); + const outcome = yield* git.run(["rev-parse", "HEAD"], checkout); + return outcome.stdout.trim(); +} + +/** Hold one managed slot's own lock from outside, as another entry would. */ +function useForeignSlot(root: string, slot: string, kind: SlotKind): Operation { + return resource(function* (provide) { + const leases = yield* useLeases(root); + yield* leases.hold(kind, slot, "another entry"); + yield* provide(); + }); +} + +/** 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; +} + +/** A promise this row resolves when it decides a pending acquisition may finish. */ +function gate(): { readonly released: Promise; release(): void } { + let open = (): void => {}; + const released = new Promise((resolve) => { + open = () => resolve(); + }); + return { released, release: () => open() }; +} + +/** Let every ready task run, so "reached its wait" is about turns. */ +function* settled(turns = 8): Operation { + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } +} + +/** What a watched `git` child reported about its own lifetime. */ +interface WatchedChildren { + /** Resolves once a watched child has been spawned. */ + readonly spawned: Operation; + /** Handed to the host, which forwards it to the one place a child exists. */ + readonly observe: NonNullable; + observed(): number; + /** Whether every observed child has closed both of its pipes. */ + closed(): boolean; + /** Whether any observed process is still running. */ + alive(): boolean; +} + +/** + * Watch the real children a row's chosen invocation spawns. + * + * The production host forwards this to the one point at which a row can see the + * process a command really started. A wrapper around the host sees the call and + * its answer and never the child between them, so observing residue afterwards + * could not tell an interrupted command from one that never began. + */ +function watchChildren(): WatchedChildren { + const pids: number[] = []; + let count = 0; + let closes = 0; + const first = withResolvers(); + return { + spawned: first.operation, + observe(child): void { + count += 1; + if (child.pid !== undefined) { + pids.push(child.pid); + } + child.on("close", () => { + closes += 1; + }); + first.resolve(); + }, + observed: () => count, + closed: () => count > 0 && closes === count, + alive: () => + pids.some((pid) => { + try { + // Signal 0 asks the operating system whether the process is there. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }), + }; +} + +/** + * A script that reports it ran and then waits. + * + * What makes a child cut deterministic: the marker says the command really + * reached this point, and the wait keeps it there until the halt arrives. + */ +function* waitingScript(path: string, marker: string): Operation { + yield* writeTextFile(path, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nsleep 120\n`); + yield* until(chmod(path, 0o755)); +} + +/** Wait until this path is there, or say what never appeared. */ +function* appears(path: string): Operation { + const deadline = Date.now() + 30_000; + while (!(yield* exists(path))) { + if (Date.now() > deadline) { + throw new Error(`${path} never appeared`); + } + yield* sleep(25); + } +} + +/** The journal events this history's saved requests stand at. */ +function requestEvents(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === REPL_REPOSITORY_PUSH_REQUEST, + ); +} + +/** 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, + ); +} + +/** The Push inputs one retained outcome names. */ +function outcomeInputs(event: DurableEvent): GitPushInputs { + if (event.type !== "yield" || event.result.status !== "ok") { + throw new Error("that outcome holds no successful reconciliation record"); + } + const record = parseGitHostReconciliationRecord(event.result.value); + const inputs = record === undefined ? undefined : parseGitPushInputs(record.request.inputs); + if (inputs === undefined) { + throw new Error("that outcome does not describe a publication"); + } + return inputs; +} + +/** The managed checkout a `` row selects. */ +function managedCheckout(events: readonly DurableEvent[]): string { + const saved = requests(events)[0]; + if (saved === undefined) { + throw new Error("this history holds no saved publication request"); + } + return saved.resolved.checkoutRoot; +} + +/** A document that publishes a branch and then asks a question. */ +function publishing(fixture: Fixture, branch: string, file: string, after?: string): string { + return [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + after ?? "", + ``, + "", + ].join("\n"); +} + +/** A question with nothing after it, so the next live work is the publication. */ +const QUESTION_ONLY = [ + ` `, + `The branch was published. Continue?`, + ` `, +].join("\n"); + +/** The question half every left-at-a-question row writes. */ +const QUESTION = [ + ` `, + `The branch was published. Continue?`, + ` `, + ` continued`, +].join("\n"); + +describe("one entry's publication", () => { + it("P1: publishes once, and answers a completed publication with no Git at all", function* () { + const fixture = yield* useFixture(); + const first = counters(); + const stream = new InMemoryStream(); + + const ran = yield* run(fixture, publishing(fixture, "review/push", "notes.md"), stream, first); + expect(ran.refusal).toBe(undefined); + + // One request, one outcome, and the remote holds exactly what was asked. + const saved = requests(ran.events); + expect(saved).toHaveLength(1); + expect(outcomes(ran.events)).toHaveLength(1); + const checkout = managedCheckout(ran.events); + const published = yield* localCommit(fixture, checkout); + expect(saved[0]?.resolved.inputs.branch).toBe("review/push"); + expect(saved[0]?.resolved.inputs.sourceCommit).toBe(published); + expect(saved[0]?.resolved.origin).toBe(fixture.origin); + expect(yield* remoteCommit(fixture, "review/push")).toBe(published); + expect(subcommands(first)).toContain("push"); + + // The same entry again, over the history it left. Nothing native happens: + // no `git`, no isolation directory, no authentication session. + const second = counters(); + const replayed = new InMemoryStream([...ran.events].filter(terminal)); + const again = yield* run( + fixture, + publishing(fixture, "review/push", "notes.md"), + replayed, + second, + ); + expect(again.refusal).toBe(undefined); + expect(second.commands).toEqual([]); + expect(second.directories).toBe(0); + expect(second.sessions).toEqual([]); + expect(requests(yield* replayed.readAll())).toHaveLength(1); + expect(outcomes(yield* replayed.readAll())).toHaveLength(1); + }); + + it("P2: a pending request publishes what it named, after HEAD moved", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + + // Cut with the request acknowledged and the remote never contacted: the + // entry is stopped before its first `ls-remote` runs. + const prefix = yield* interrupted( + fixture, + publishing(fixture, "review/pending", "notes.md", QUESTION), + counted, + (invocation) => invocation.args.includes("ls-remote"), + ); + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toEqual([]); + expect(yield* remoteCommit(fixture, "review/pending")).toBe(undefined); + + const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; + const checkout = managedCheckout(prefix); + + // Somebody advances the branch at a shell while the entry is away. + const moved = yield* commitThere(checkout, "later.md"); + expect(moved).not.toBe(asked); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run( + fixture, + publishing(fixture, "review/pending", "notes.md", QUESTION), + stream, + resumed, + { answer: { choice: "continue" } }, + ); + expect(again.refusal).toBe(undefined); + + // The commit the request named is what was published, not today's HEAD. + expect(yield* remoteCommit(fixture, "review/pending")).toBe(asked); + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + expect(outcomes(events)).toHaveLength(1); + expect(outcomeInputs(outcomes(events)[0] as DurableEvent).sourceCommit).toBe(asked); + }); + it("P3: a remote that accepted the branch is adopted, never pushed twice", function* () { + const fixture = yield* useFixture(); + const first = counters(); + const backing = new InMemoryStream(); + + // The remote accepts the publication and the journal refuses to record it, + // which is the window this whole protocol is about. + const ran = yield* run( + fixture, + publishing(fixture, "review/adopt", "notes.md", QUESTION), + refusing(backing, GIT_HOST_EFFECT), + first, + { answer: { choice: "continue" } }, + ); + expect(ran.refusal).not.toBe(undefined); + const prefix = yield* backing.readAll(); + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toEqual([]); + const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; + expect(yield* remoteCommit(fixture, "review/adopt")).toBe(asked); + expect(subcommands(first).filter((name) => name === "push")).toHaveLength(1); + + // The continuation observes the destination, finds this exact commit, and + // adopts it. Nothing is published a second time. + const second = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run( + fixture, + publishing(fixture, "review/adopt", "notes.md", QUESTION), + stream, + second, + { answer: { choice: "continue" } }, + ); + expect(again.refusal).toBe(undefined); + expect(subcommands(second).filter((name) => name === "push")).toEqual([]); + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + const settled = outcomes(events)[0]; + expect(settled).not.toBe(undefined); + expect(decisionOf(settled as DurableEvent)).toBe("adopted"); + expect(yield* remoteCommit(fixture, "review/adopt")).toBe(asked); + }); + + it("P4: advances a proven ancestor once, and refuses a divergent destination", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + + // One publication, then a second commit on the same branch in the same + // managed checkout: the destination holds an ancestor of what is published. + const stream = new InMemoryStream(); + const first = yield* run( + fixture, + publishing(fixture, "review/advance", "one.md"), + stream, + counted, + ); + expect(first.refusal).toBe(undefined); + const base = yield* remoteCommit(fixture, "review/advance"); + + const advanced = new InMemoryStream(); + const second = yield* run( + fixture, + publishing(fixture, "review/advance", "two.md"), + advanced, + counted, + ); + expect(second.refusal).toBe(undefined); + const head = yield* remoteCommit(fixture, "review/advance"); + expect(head).not.toBe(base); + const ancestor = outcomes(yield* advanced.readAll())[0]; + expect(decisionOf(ancestor as DurableEvent)).toBe("performed"); + + // Somebody else publishes an unrelated commit over that branch. + const elsewhere = join(fixture.root, "elsewhere"); + const git = gitSession(denoRepositoryHost(), fixture.root); + yield* git.run(["clone", fixture.origin, elsewhere], fixture.root); + yield* git.run(["checkout", "-B", "review/advance"], elsewhere); + yield* commitThere(elsewhere, "theirs.md"); + yield* git.run(["push", "--force", fixture.origin, "review/advance"], elsewhere); + const theirs = yield* remoteCommit(fixture, "review/advance"); + + const refused = counters(); + const conflicted = new InMemoryStream(); + const third = yield* run( + fixture, + publishing(fixture, "review/advance", "three.md"), + conflicted, + refused, + ); + expect(third.refusal).not.toBe(undefined); + // Nothing was published, and the destination is exactly where the other + // party left it. + expect(subcommands(refused).filter((name) => name === "push")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/advance")).toBe(theirs); + const events = yield* conflicted.readAll(); + expect(requests(events)).toHaveLength(1); + const failure = outcomes(events)[0]; + expect(failure?.type === "yield" ? failure.result.status : undefined).toBe("err"); + + // And it replays as itself, reaching no provider: no `git` at all. + const replaying = counters(); + const replayed = new InMemoryStream([...events].filter(terminal)); + const fourth = yield* run( + fixture, + publishing(fixture, "review/advance", "three.md"), + replayed, + replaying, + ); + expect(fourth.refusal).not.toBe(undefined); + expect(replaying.commands).toEqual([]); + }); + + it("P5: public middleware composes, and only a delegation retains anything", function* () { + const fixture = yield* useFixture(); + + // A handler that answers without delegating has answered the document. + // Nothing is written down and nothing is contacted. + const synthetic = counters(); + const stream = new InMemoryStream(); + const answered = yield* run( + fixture, + publishing(fixture, "review/synthetic", "synthetic.md"), + stream, + synthetic, + { + *between(): Operation { + yield* Git.around({ + // deno-lint-ignore require-yield + *push(): Operation { + // A shape a handler can build and offer as a completion. The + // component ignores it, and this operation retains nothing for + // it: nothing was asked of a Git host. + return SYNTHETIC; + }, + }); + }, + }, + ); + expect(answered.refusal).toBe(undefined); + const events = yield* stream.readAll(); + expect(requests(events)).toEqual([]); + expect(outcomes(events)).toEqual([]); + expect(subcommands(synthetic).filter((name) => name === "push")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/synthetic")).toBe(undefined); + + // A handler that transforms and delegates is retained as both readings: the + // question the invocation asked, and what the dispatch received. + const transformed = counters(); + const delegated = new InMemoryStream(); + const published = yield* run( + fixture, + publishing(fixture, "review/delegated", "delegated.md"), + delegated, + transformed, + { + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + return yield* next({ + repository: request.repository, + workingDirectory: `${request.workingDirectory}/.`, + }); + }, + }); + }, + }, + ); + expect(published.refusal).toBe(undefined); + const saved = requests(yield* delegated.readAll())[0]; + expect(saved).not.toBe(undefined); + expect(saved?.requested.workingDirectory).not.toBe(saved?.received.workingDirectory); + expect(saved?.received.workingDirectory.endsWith("/.")).toBe(true); + // Both readings still name the same checkout, which is what the resolved + // answer is about. + expect(saved?.resolved.checkoutRoot).toBe(saved?.requested.selectionCheckout); + expect(yield* remoteCommit(fixture, "review/delegated")).toBe( + saved?.resolved.inputs.sourceCommit, + ); + + // A second delegation of one invocation is refused, and the first one's + // acknowledged work stands. + const doubled = counters(); + const twice = new InMemoryStream(); + const refused = yield* run( + fixture, + publishing(fixture, "review/doubled", "doubled.md"), + twice, + doubled, + { + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + yield* next(request); + return yield* next(request); + }, + }); + }, + }, + ); + expect(refused.refusal).not.toBe(undefined); + const after = yield* twice.readAll(); + expect(requests(after)).toHaveLength(1); + expect(outcomes(after)).toHaveLength(1); + expect(subcommands(doubled).filter((name) => name === "push")).toHaveLength(1); + }); + + it("P6: a saved request bypasses the middleware it already passed", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const prefix = yield* interrupted( + fixture, + publishing(fixture, "review/bypass", "notes.md", QUESTION), + counted, + (invocation) => invocation.args.includes("ls-remote"), + ); + const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; + expect(asked).not.toBe(undefined); + + // The continuation installs a handler that would retarget the publication. + // It is never asked: the saved request is consumed before any dispatch. + const asks: number[] = []; + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run( + fixture, + publishing(fixture, "review/bypass", "notes.md", QUESTION), + stream, + resumed, + { + answer: { choice: "continue" }, + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + asks.push(1); + return yield* next({ + repository: request.repository, + workingDirectory: fixture.root, + }); + }, + }); + }, + }, + ); + expect(again.refusal).toBe(undefined); + expect(asks).toEqual([]); + expect(yield* remoteCommit(fixture, "review/bypass")).toBe(asked); + }); + + it("P7: a refused request append contacts nothing, and fences what follows", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const backing = new InMemoryStream(); + + const ran = yield* run( + fixture, + publishing(fixture, "review/fenced", "notes.md", QUESTION), + refusing(backing, REPL_REPOSITORY_PUSH_REQUEST), + counted, + { answer: { choice: "continue" } }, + ); + expect(ran.refusal).not.toBe(undefined); + const events = yield* backing.readAll(); + expect(requests(events)).toEqual([]); + expect(outcomes(events)).toEqual([]); + // No remote was read and nothing was published: the request had to be + // acknowledged first. + expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); + expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/fenced")).toBe(undefined); + // And the work after the question never ran. + expect(ran.output).not.toContain("continued"); + }); + + it("P8: refuses a history it cannot read, before any native work", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const prefix = yield* interrupted( + fixture, + publishing(fixture, "review/unreadable", "notes.md", QUESTION), + counted, + (invocation) => invocation.args.includes("ls-remote"), + ); + + // The saved request, with its answer edited after the fact. + const edited = [...prefix].map((event) => { + if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PUSH_REQUEST) { + return event; + } + if (event.result.status !== "ok") { + return event; + } + const payload = event.result.value as Record; + const resolved = payload["resolved"] as Record; + return { + ...event, + result: { + status: "ok" as const, + value: { ...payload, resolved: { ...resolved, origin: "ext::sh -c id" } }, + }, + }; + }); + + const refused = counters(); + const stream = new InMemoryStream(edited); + const again = yield* run( + fixture, + publishing(fixture, "review/unreadable", "notes.md", QUESTION), + stream, + refused, + { answer: { choice: "continue" } }, + ); + expect(again.refusal).not.toBe(undefined); + // Admission runs before replay policy and before anything native. + expect(refused.commands).toEqual([]); + expect(refused.sessions).toEqual([]); + + // And the same history with an origin this host *would* hand to Git, which + // is not the one the Repository identity was fingerprinted from: the codec + // admits the grammar and the provider asks the digest, because reconciling a + // restored request uses the origin it recorded rather than today's. + const elsewhere = [...prefix].map((event) => { + if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PUSH_REQUEST) { + return event; + } + if (event.result.status !== "ok") { + return event; + } + const payload = event.result.value as Record; + const resolved = payload["resolved"] as Record; + return { + ...event, + result: { + status: "ok" as const, + value: { ...payload, resolved: { ...resolved, origin: join(fixture.root, "other.git") } }, + }, + }; + }); + const foreign = counters(); + const named = yield* run( + fixture, + publishing(fixture, "review/unreadable", "notes.md", QUESTION), + new InMemoryStream(elsewhere), + foreign, + { answer: { choice: "continue" } }, + ); + expect(named.refusal).not.toBe(undefined); + // Refused before the request is reconciled: nothing was pushed and the + // origin it named was never contacted. + expect(subcommands(foreign)).not.toContain("push"); + expect(foreign.sessions).toEqual([]); + + // And an outcome standing where no request asked for one. + const orphaned = new InMemoryStream( + [...prefix].filter( + (event) => + !(event.type === "yield" && event.description.type === REPL_REPOSITORY_PUSH_REQUEST), + ), + ); + const settled = yield* run( + fixture, + publishing(fixture, "review/unreadable", "notes.md", QUESTION), + orphaned, + counters(), + { answer: { choice: "continue" } }, + ); + // Nothing in that prefix is a Push outcome, so this history is readable: + // what it holds is a selection and a commit, and the publication is new. + expect(settled.refusal).toBe(undefined); + }); + + it("P9: contention appends nothing, and hostile checkout settings reach nothing", function* () { + const fixture = yield* useFixture(); + + // A publication whose managed slot another entry is holding. Contention is + // decided before the request exists, so nothing is written down. + const slot = repositorySlot(fixture.managed, fixture.origin, "project"); + const contended = counters(); + const stream = new InMemoryStream(); + const refused = yield* scoped(function* (): Operation { + yield* useForeignSlot(fixture.managed, slot, "repository"); + return yield* run( + fixture, + publishing(fixture, "review/contended", "notes.md"), + stream, + contended, + ); + }); + expect(refused.refusal).not.toBe(undefined); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(subcommands(contended).filter((name) => name === "push")).toEqual([]); + + // A publication from a checkout whose own configuration names another + // destination and another program. The transport runs in a repository this + // run created, so neither is read. + const published = new InMemoryStream(); + const counted = counters(); + const first = yield* run( + fixture, + publishing(fixture, "review/hostile", "notes.md"), + published, + counted, + ); + expect(first.refusal).toBe(undefined); + const checkout = managedCheckout(yield* published.readAll()); + const hostile = join(fixture.root, "hostile.git"); + const git = gitSession(denoRepositoryHost(), fixture.root); + yield* git.run(["init", "--bare", hostile], fixture.root); + yield* git.run(["config", "remote.origin.pushurl", hostile], checkout); + const marker = join(fixture.root, "hook-ran"); + yield* writeTextFile( + join(checkout, ".git/hooks/pre-push"), + `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, + ); + yield* until(chmod(join(checkout, ".git/hooks/pre-push"), 0o755)); + + const second = new InMemoryStream(); + const after = yield* run( + fixture, + publishing(fixture, "review/hostile2", "second.md"), + second, + counted, + ); + expect(after.refusal).toBe(undefined); + const saved = requests(yield* second.readAll())[0]; + expect(yield* remoteCommit(fixture, "review/hostile2")).toBe( + saved?.resolved.inputs.sourceCommit, + ); + expect(yield* exists(marker)).toBe(false); + const diverted = gitSession(denoRepositoryHost(), fixture.root); + const elsewhere = yield* diverted.run( + ["rev-parse", "--verify", "refs/heads/review/hostile2"], + hostile, + ); + expect(elsewhere.code).not.toBe(0); + }); + + it("P11: a publication after the question takes the slot a restored placement left", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // The question comes *before* the publication here, so a continuation's + // first new operation is the Push itself: the selection and the commit + // replay, and neither of them takes a managed slot on that path. + const source = [ + ``, + ` `, + ` ready`, + ` `, + ` `, + QUESTION_ONLY, + // Nothing between the question and the publication: a continuation's + // first live work is the Push itself, so what refuses under contention + // is this operation rather than a file write beside it. + ` `, + ``, + "", + ].join("\n"); + + const prefix = yield* left(fixture, source, counted); + expect(requests(prefix)).toEqual([]); + + // Another entry holds that checkout's managed slot while this one + // continues. Contention is decided before the request exists. + const slot = repositorySlot(fixture.managed, fixture.origin, "project"); + const contended = counters(); + const stream = new InMemoryStream([...prefix]); + const refused = yield* scoped(function* (): Operation { + yield* useForeignSlot(fixture.managed, slot, "repository"); + return yield* run(fixture, source, stream, contended, { + answer: { choice: "continue" }, + }); + }); + expect(refused.refusal).not.toBe(undefined); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(outcomes(yield* stream.readAll())).toEqual([]); + expect(subcommands(contended).filter((name) => name === "ls-remote")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/after")).toBe(undefined); + + // Released, the same continuation publishes: the slot is reacquired by the + // publication itself rather than inherited from the replayed selection. + const released = counters(); + const resumed = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, resumed, released, { + answer: { choice: "continue" }, + }); + expect(again.refusal).toBe(undefined); + const saved = requests(yield* resumed.readAll())[0]; + expect(yield* remoteCommit(fixture, "review/after")).toBe(saved?.resolved.inputs.sourceCommit); + }); + + it("P13: a pending request reacquires its saved slot before it reads anything", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const prefix = yield* interrupted( + fixture, + publishing(fixture, "review/pendlock", "notes.md", QUESTION), + counted, + (invocation) => invocation.args.includes("ls-remote"), + ); + const asked = requests(prefix)[0]?.resolved.inputs.sourceCommit; + expect(asked).not.toBe(undefined); + + // The saved request is reconciled by a continuation while another entry + // holds that checkout's slot. The slot is what says which entry owns a + // managed checkout, and a publication reads its objects, so it is taken + // before anything is read — and contention writes nothing down. + const slot = repositorySlot(fixture.managed, fixture.origin, "project"); + const contended = counters(); + const stream = new InMemoryStream([...prefix]); + const refused = yield* scoped(function* (): Operation { + yield* useForeignSlot(fixture.managed, slot, "repository"); + return yield* run( + fixture, + publishing(fixture, "review/pendlock", "notes.md", QUESTION), + stream, + contended, + { answer: { choice: "continue" } }, + ); + }); + expect(refused.refusal).not.toBe(undefined); + const after = yield* stream.readAll(); + // The pending request it could not reconcile is still exactly one, and no + // outcome was invented for it. + expect(requests(after)).toHaveLength(1); + expect(requests(after)[0]?.resolved.inputs.sourceCommit).toBe(asked); + expect(outcomes(after)).toEqual([]); + expect(subcommands(contended).filter((name) => name === "ls-remote")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/pendlock")).toBe(undefined); + + // Released, that same saved request publishes what it named. + const released = counters(); + const resumed = new InMemoryStream([...prefix]); + const again = yield* run( + fixture, + publishing(fixture, "review/pendlock", "notes.md", QUESTION), + resumed, + released, + { answer: { choice: "continue" } }, + ); + expect(again.refusal).toBe(undefined); + expect(yield* remoteCommit(fixture, "review/pendlock")).toBe(asked); + }); + + it("P12: publishes while another entry holds only the mutation lease", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + + // A publication reads authenticated objects and changes a branch on a + // remote. It changes nothing in the repository, so the lease that says + // which entry is changing one is not its to take — and another holder of + // that lease does not stop it. + const first = yield* run( + fixture, + publishing(fixture, "review/leased", "one.md"), + stream, + counted, + ); + expect(first.refusal).toBe(undefined); + const common = join(managedCheckout(yield* stream.readAll()), ".git"); + + const second = new InMemoryStream(); + const held = counters(); + const published = yield* scoped(function* (): Operation { + yield* useCommonDirectoryLease(common); + return yield* run( + fixture, + [ + ``, + ` `, + ``, + "", + ].join("\n"), + second, + held, + ); + }); + expect(published.refusal).toBe(undefined); + const saved = requests(yield* second.readAll())[0]; + expect(saved).not.toBe(undefined); + expect(yield* remoteCommit(fixture, "review/leased")).toBe(saved?.resolved.inputs.sourceCommit); + }); + + it("P14: a real unfinished prefix replays after the checkout and the remote are gone", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = publishing(fixture, "review/removed", "notes.md", QUESTION); + + // A genuine unfinished entry: the publication is acknowledged and the + // question is standing there, with nothing removed from the journal. + const prefix = yield* left(fixture, source, counted); + expect(requests(prefix)).toHaveLength(1); + expect(outcomes(prefix)).toHaveLength(1); + const published = requests(prefix)[0]?.resolved.inputs.sourceCommit; + const checkout = managedCheckout(prefix); + expect(yield* remoteCommit(fixture, "review/removed")).toBe(published); + + // Both resources this publication read are now unavailable. + yield* rm(checkout, { recursive: true, force: true }); + yield* rm(fixture.origin, { recursive: true, force: true }); + expect(yield* exists(checkout)).toBe(false); + expect(yield* exists(fixture.origin)).toBe(false); + + // The continuation reaches the question again. Nothing native, nothing of + // the source, no authentication and no Git-host middleware is reached, and + // no second publication is appended. + const replaying = counters(); + const middleware: string[] = []; + const resumed = new InMemoryStream([...prefix]); + const again = yield* left(fixture, source, replaying, { + stream: resumed, + *between(): Operation { + yield* GitHost.around({ + *route(): Operation { + middleware.push("routed"); + throw new Error("a completed publication reached the Git-host surface"); + }, + }); + }, + }); + expect(replaying.commands).toEqual([]); + expect(replaying.directories).toBe(0); + expect(replaying.sessions).toEqual([]); + expect(middleware).toEqual([]); + expect(requests(again)).toHaveLength(1); + expect(outcomes(again)).toHaveLength(1); + expect(requests(again)[0]?.resolved.inputs.sourceCommit).toBe(published); + }); + + it("P15: a cut before the request is acknowledged resolves today's commit", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const source = publishing(fixture, "review/precut", "notes.md", QUESTION); + + // Cut with the request append in flight: the journal holds the selection + // and the local Git of commit A, and no publication at all. + const backing = new InMemoryStream(); + const held = withResolvers(); + const prefix = yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, source, holdingAppend(backing, REPL_REPOSITORY_PUSH_REQUEST, held), counted, { + answer: { choice: "continue" }, + }), + ); + yield* race([ + held.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the entry never reached its request append"); + })(), + ]); + const events = [...(yield* backing.readAll())]; + yield* task.halt(); + return events; + }); + expect(requests(prefix)).toEqual([]); + expect(outcomes(prefix)).toEqual([]); + // Nothing was contacted: the request had to be acknowledged first. + expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/precut")).toBe(undefined); + + const checkout = (yield* placements(prefix))[0]; + expect(checkout).not.toBe(undefined); + const a = yield* localCommit(fixture, checkout ?? ""); + + // Somebody advances the branch while the entry is away. The publication was + // never asked for, so the continuation resolves it now — at B. + const b = yield* commitThere(checkout ?? "", "later.md"); + expect(b).not.toBe(a); + + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + }); + expect(again.refusal).toBe(undefined); + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + expect(requests(events)[0]?.resolved.inputs.sourceCommit).toBe(b); + expect(yield* remoteCommit(fixture, "review/precut")).toBe(b); + }); + + it("P16: two spawned publications are two occurrences, resumed in either order", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + // Two branches, two managed repositories, two questions. Each publishes at + // its own position in its own coroutine. + const branch = (name: string) => + [ + ``, + ``, + `${name}`, + ``, + ``, + "", + `Question for ${name}`, + `continued`, + "", + ].join("\n"); + const source = ["", branch("alpha"), branch("beta"), "", ""].join("\n"); + + const prefix = yield* scoped(function* (): Operation { + const stream = new InMemoryStream(); + const waiting = withResolvers(); + let asked = 0; + const task = yield* spawn(() => + run(fixture, source, stream, counted, { + *between(): Operation { + yield* Elicitation.around({ + *elicit(): Operation { + asked += 1; + if (asked === 2) { + waiting.resolve(); + } + return yield* suspend(); + }, + }); + }, + }), + ); + yield* race([ + waiting.operation, + (function* (): Operation { + yield* sleep(60_000); + throw new Error("both branches never reached their questions"); + })(), + ]); + const held = [...(yield* stream.readAll())]; + yield* task.halt(); + return held; + }); + + // Two publications, each named by its own canonical invocation, each + // standing in its own coroutine with its own outcome beside it. + const asked = requests(prefix); + expect(asked).toHaveLength(2); + expect(new Set(asked.map((record) => record.site)).size).toBe(2); + expect(new Set(asked.map((record) => record.owner)).size).toBe(1); + expect(new Set(asked.map((record) => record.resolved.inputs.branch)).size).toBe(2); + const published = outcomes(prefix); + expect(published).toHaveLength(2); + const coroutines = new Set( + [...requestEvents(prefix), ...published].map((event) => + event.type === "yield" ? event.coroutineId : "", + ), + ); + expect(coroutines.size).toBe(2); + for (const event of requestEvents(prefix)) { + const paired = published.find( + (outcome) => + outcome.type === "yield" && + event.type === "yield" && + outcome.coroutineId === event.coroutineId, + ); + expect(paired).not.toBe(undefined); + } + for (const name of ["alpha", "beta"]) { + expect(yield* remoteCommit(fixture, `review/${name}`)).not.toBe(undefined); + } + + // Continued with the answers arriving in the other order. Each branch + // replays its own publication and runs its own suffix once. + const replaying = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, replaying, { + *between(): Operation { + yield* Elicitation.around({ + *elicit([request]: [ElicitationRequest]): Operation { + yield* sleep(request.message.includes("alpha") ? 40 : 0); + return {}; + }, + }); + }, + }); + expect(again.refusal).toBe(undefined); + const events = yield* stream.readAll(); + // No publication happened twice, and neither branch consumed the other's. + expect(requests(events)).toHaveLength(2); + expect(outcomes(events)).toHaveLength(2); + expect(subcommands(replaying).filter((name) => name === "push")).toEqual([]); + expect(replaying.sessions).toEqual([]); + for (const name of ["alpha", "beta"]) { + const checkout = asked.find((record) => record.resolved.inputs.branch === `review/${name}`) + ?.resolved.checkoutRoot; + expect(checkout).not.toBe(undefined); + expect(yield* exists(join(checkout ?? "", `after-${name}.md`))).toBe(true); + } + }); + + it("P17: one authored site evaluated twice is two publications", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + + const ran = yield* run( + fixture, + [ + ``, + '', + 'ready', + '', + '', + '', + "", + "", + "", + "", + ].join("\n"), + stream, + counted, + ); + expect(ran.refusal).toBe(undefined); + + // Two occurrences of one authored element: two requests under two + // canonical sites, and two outcomes. The second observes the destination + // this run just published and adopts it rather than pushing again. + const asked = requests(ran.events); + expect(asked).toHaveLength(2); + expect(new Set(asked.map((record) => record.site)).size).toBe(2); + expect(new Set(asked.map((record) => record.resolved.inputs.sourceCommit)).size).toBe(1); + const published = outcomes(ran.events); + expect(published).toHaveLength(2); + expect(published.map((event) => decisionOf(event))).toEqual(["performed", "adopted"]); + expect(subcommands(counted).filter((name) => name === "push")).toHaveLength(1); + }); + + it("P18: a kept delegation, a later answer and a standalone dispatch reach nothing", function* () { + const fixture = yield* useFixture(); + + // A handler that keeps its continuation, delegates once, and then uses the + // kept one again after that invocation has ended. The first publication + // stands; the second use reaches a revoked default. + const kept: ((invocation: GitPushInvocation) => Operation)[] = []; + const counted = counters(); + const stream = new InMemoryStream(); + const reused = yield* run( + fixture, + [ + ``, + '', + 'ready', + '', + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + stream, + counted, + { + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + if (kept.length > 0) { + // The continuation the *first* invocation handed over, used + // from inside the second one. + const stale = kept[0]; + return yield* (stale ?? next)(request); + } + kept.push(next); + return yield* next(request); + }, + }); + }, + }, + ); + const published = requests(yield* stream.readAll()); + // One publication was retained and the stale continuation retained + // nothing: the second element reported rather than publishing. + expect(published).toHaveLength(1); + expect(outcomes(yield* stream.readAll())).toHaveLength(1); + // The stale continuation names no invocation, and that is admission rather + // than an outcome: it is fatal, nothing was asked of the Git host, and the + // printing region around it does not make it an answer. + expect(reused.refusal).toContain("names no invocation"); + expect(reused.refusal).toContain("Nothing was asked of the Git host"); + expect(yield* remoteCommit(fixture, "review/kept")).toBe( + published[0]?.resolved.inputs.sourceCommit, + ); + + // A handler that delegates and then throws cannot replace the receipt the + // worker already published. + const raising = counters(); + const second = new InMemoryStream(); + const thrown = yield* run( + fixture, + publishing(fixture, "review/raised", "raised.md"), + second, + raising, + { + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + yield* next(request); + throw new Error("a handler's own failure after delegating"); + }, + }); + }, + }, + ); + expect(thrown.refusal).toContain("after delegating"); + const receipt = requests(yield* second.readAll()); + expect(receipt).toHaveLength(1); + expect(outcomes(yield* second.readAll())).toHaveLength(1); + expect(yield* remoteCommit(fixture, "review/raised")).toBe( + receipt[0]?.resolved.inputs.sourceCommit, + ); + + // A handler that delegates and then answers with something else of its own + // cannot replace the receipt either: a return value is not evidence. + const replaced = counters(); + const fourth = new InMemoryStream(); + const answered = yield* run( + fixture, + publishing(fixture, "review/replaced", "replaced.md"), + fourth, + replaced, + { + *between(): Operation { + yield* Git.around({ + *push([request], next): Operation { + yield* next(request); + return SYNTHETIC; + }, + }); + }, + }, + ); + expect(answered.refusal).toBe(undefined); + const definitive = requests(yield* fourth.readAll()); + expect(definitive).toHaveLength(1); + expect(definitive[0]?.resolved.inputs.branch).toBe("review/replaced"); + expect(outcomes(yield* fourth.readAll())).toHaveLength(1); + expect(yield* remoteCommit(fixture, "review/replaced")).toBe( + definitive[0]?.resolved.inputs.sourceCommit, + ); + + // And a dispatch made outside the trusted invocation reaches the ordinary + // missing-provider refusal, retaining nothing. + const standalone = counters(); + const third = new InMemoryStream(); + const direct = yield* run( + fixture, + [ + ``, + "", + "", + "", + "", + "", + ].join("\n"), + third, + standalone, + { + *between(): Operation { + yield* registerComponents([ + { + name: "Standalone", + origin: "tier-push", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const repository = yield* selectedRepository(); + if (repository === undefined) { + throw new Error("this row needs a Repository in scope"); + } + yield* Git.operations.push({ repository, workingDirectory: yield* cwd() }); + return ""; + }, + }, + ]); + }, + }, + ); + expect(direct.output).toContain("composition provider is installed"); + expect(requests(yield* third.readAll())).toEqual([]); + expect(outcomes(yield* third.readAll())).toEqual([]); + expect(subcommands(standalone).filter((name) => name === "push")).toEqual([]); + }); + + it("P19: a synthetic answer leaves nothing to restore, so a continuation asks again", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + let asked = 0; + const synthetic = function* (): Operation { + yield* Git.around({ + // deno-lint-ignore require-yield + *push(): Operation { + asked += 1; + return SYNTHETIC; + }, + }); + }; + const source = publishing(fixture, "review/resynthetic", "notes.md", QUESTION); + + const prefix = yield* left(fixture, source, counted, { between: synthetic }); + expect(asked).toBe(1); + expect(requests(prefix)).toEqual([]); + expect(outcomes(prefix)).toEqual([]); + + // Nothing was retained for it, so the continuation reaches the chain again + // — and still retains nothing. + const resumed = counters(); + const stream = new InMemoryStream([...prefix]); + const again = yield* run(fixture, source, stream, resumed, { + answer: { choice: "continue" }, + between: synthetic, + }); + expect(again.refusal).toBe(undefined); + expect(asked).toBe(2); + const events = yield* stream.readAll(); + expect(requests(events)).toEqual([]); + expect(outcomes(events)).toEqual([]); + expect(yield* remoteCommit(fixture, "review/resynthetic")).toBe(undefined); + }); + + it("P20: halting a pending scratch delivery waits for it, and removes it", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + + // The publication's own scratch space, and nothing earlier. Every + // acquisition before the request is acknowledged — the entry's Git session, + // the selector's — is delivered normally; the barrier is armed by the + // request append itself, so what this row holds pending is the attempt's. + const made: string[] = []; + const asked = withResolvers(); + const held = gate(); + let armed = false; + const create = (prefix: string): Promise => { + if (!armed) { + return mkdtemp(prefix); + } + asked.resolve(); + return held.released + .then(() => mkdtemp(prefix)) + .then((directory) => { + made.push(directory); + return directory; + }); + }; + + const stream = new InMemoryStream(); + stream.onAppend = (event) => { + if (event.type === "yield" && event.description.type === REPL_REPOSITORY_PUSH_REQUEST) { + armed = true; + } + }; + + let returned = false; + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, publishing(fixture, "review/scratch", "notes.md"), stream, counted, { + hostOptions: { directories: { create } }, + }), + ); + yield* race([ + asked.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the publication never asked for its own scratch space"); + })(), + ]); + + // The boundary this row is about: the request is acknowledged, the Git + // host has not been asked anything, and the attempt is waiting for the + // scratch space it will read its objects through. + const atBarrier = yield* stream.readAll(); + expect(requests(atBarrier)).toHaveLength(1); + expect(outcomes(atBarrier)).toEqual([]); + expect(subcommands(counted).filter((name) => name === "ls-remote")).toEqual([]); + expect(made).toEqual([]); + + const halting = yield* spawn(function* (): Operation { + yield* task.halt(); + returned = true; + }); + yield* settled(); + // Teardown is waiting for the acquisition the attempt armed its release + // before: nothing has been delivered, and the halt has not finished. + expect(returned).toBe(false); + expect(made).toEqual([]); + + held.release(); + yield* halting; + }); + + // It waited for the delivery and removed what arrived. + expect(returned).toBe(true); + expect(made).toHaveLength(1); + expect(yield* exists(made[0] ?? "")).toBe(false); + // And the publication itself published nothing, while the request it had + // already acknowledged is still history for the next attempt. + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + expect(outcomes(events)).toEqual([]); + expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/scratch")).toBe(undefined); + }); + + it("P20b: a scratch space the attempt received is removed before the halt settles", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const delivered: { path: string | undefined; removed: boolean } = { + path: undefined, + removed: false, + }; + const observing = withResolvers(); + const stream = new InMemoryStream(); + + // The cut is inside the first remote observation, which is after delivery + // and before any outcome. + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, publishing(fixture, "review/scratch2", "notes.md"), stream, counted, { + directories: () => + resource(function* (provide) { + const path = yield* until(mkdtemp(join(fixture.root, "delivered-"))); + delivered.path = path; + yield* ensure(function* () { + delivered.removed = true; + yield* rm(path, { recursive: true, force: true }); + }); + yield* provide(path); + }), + *barrier(invocation): Operation { + if (invocation.args.includes("ls-remote")) { + observing.resolve(); + yield* suspend(); + } + }, + }), + ); + yield* race([ + observing.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the publication never observed its destination"); + })(), + ]); + yield* task.halt(); + }); + + expect(delivered.path).not.toBe(undefined); + expect(delivered.removed).toBe(true); + expect(yield* exists(delivered.path ?? "")).toBe(false); + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + expect(outcomes(events)).toEqual([]); + expect(yield* remoteCommit(fixture, "review/scratch2")).toBe(undefined); + }); + + it("P21: cancelling a pending authentication releases it, and keeps the request", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const held = withResolvers(); + const reached = withResolvers(); + const session: { opened: boolean; released: boolean } = { opened: false, released: false }; + + // The credential session a live attempt opens for its destination, held + // pending until this row releases it. The managed clone opens one of its + // own first, so only the publication's is held: holding the clone's would + // stop the entry before a publication existed at all. + let opened = 0; + const sessions = (): Operation => + resource(function* (provide) { + opened += 1; + if (opened === 1) { + yield* provide(UNAUTHENTICATED); + return; + } + reached.resolve(); + yield* held.operation; + session.opened = true; + yield* ensure(() => { + session.released = true; + }); + yield* provide(UNAUTHENTICATED); + }); + + const stream = new InMemoryStream(); + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, publishing(fixture, "review/credential", "notes.md"), stream, counted, { + sessions, + }), + ); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the publication never opened a session for its destination"); + })(), + ]); + const halting = yield* spawn(() => task.halt()); + held.resolve(); + yield* halting; + }); + + const events = yield* stream.readAll(); + // The request this attempt acknowledged is history; its outcome is not. + expect(requests(events)).toHaveLength(1); + expect(outcomes(events)).toEqual([]); + // Both sessions were for the admitted origin, and the publication's is the + // one this row held. + expect(new Set(counted.sessions)).toEqual(new Set([fixture.origin])); + expect(counted.sessions.length).toBeGreaterThan(1); + // The delivery this attempt's own scope was still owed arrived after the + // halt began, and the halt did not settle before it was released. + expect(session.opened).toBe(true); + expect(session.released).toBe(true); + expect(subcommands(counted).filter((name) => name === "push")).toEqual([]); + expect(yield* remoteCommit(fixture, "review/credential")).toBe(undefined); + + // And that acknowledged request is what the next attempt reconciles. + const resumed = counters(); + const again = yield* run( + fixture, + publishing(fixture, "review/credential", "notes.md"), + new InMemoryStream([...events].filter(terminal)), + resumed, + ); + expect(again.refusal).toBe(undefined); + expect(yield* remoteCommit(fixture, "review/credential")).toBe( + requests(events)[0]?.resolved.inputs.sourceCommit, + ); + }); + + it("P22: cancelling a blocked native child joins that child before settlement", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const watched = watchChildren(); + const script = join(fixture.root, "slow-upload-pack.sh"); + const marker = join(fixture.root, "upload-pack-ran"); + yield* waitingScript(script, marker); + + // The first remote contact a publication makes is an exact observation of + // the destination. Against a local remote that spawns an upload-pack, and + // a program that reports it ran and then waits is a child holding the + // pipes this entry's own process inherited. + const stream = new InMemoryStream(); + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, publishing(fixture, "review/blocked", "notes.md"), stream, counted, { + intercept: (invocation) => + invocation.args.includes("ls-remote") + ? { + args: ["ls-remote", `--upload-pack=${script}`, ...invocation.args.slice(1)], + observe: watched.observe, + } + : undefined, + }), + ); + yield* appears(marker); + yield* watched.spawned; + yield* task.halt(); + }); + + // The child this entry started closed before the entry settled, and no + // outcome was invented for the observation it never finished. + expect(watched.observed() > 0).toBe(true); + expect(watched.closed()).toBe(true); + expect(watched.alive()).toBe(false); + const events = yield* stream.readAll(); + expect(requests(events)).toHaveLength(1); + expect(outcomes(events)).toEqual([]); + expect(yield* remoteCommit(fixture, "review/blocked")).toBe(undefined); + }); + + it("P10: an entry with no managed root refuses to publish anything", function* () { + const fixture = yield* useFixture(); + const counted = counters(); + const stream = new InMemoryStream(); + + const ran = yield* run( + fixture, + [ + ``, + ` `, + ``, + "", + ].join("\n"), + stream, + counted, + { root: undefined }, + ); + expect(ran.refusal).not.toBe(undefined); + expect(requests(yield* stream.readAll())).toEqual([]); + expect(counted.commands).toEqual([]); + }); +}); + +/** The decision one retained outcome records. */ +function decisionOf(event: DurableEvent): string | undefined { + if (event.type !== "yield" || event.result.status !== "ok") { + return undefined; + } + return parseGitHostReconciliationRecord(event.result.value)?.decision; +} + +/** + * A completion a handler can build out of nothing. + * + * Every member is a shape the public contract declares, and none of it is + * evidence: a value returned to a component is not a receipt, and this is what + * the rows about synthetic answers offer in place of one. + */ +const SYNTHETIC: GitPushOutcome = Object.freeze({ + decision: "performed" as const, + result: Object.freeze({ + repository: Object.freeze({ + name: "project", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), + remote: "origin", + branch: "review/synthetic", + destinationRef: "refs/heads/review/synthetic", + refspec: `${"0".repeat(40)}:refs/heads/review/synthetic`, + sourceCommit: "0".repeat(40), + observedRemoteCommit: "0".repeat(40), + }), +}); + +/** + * A journal that holds every append of one durable type, and says when it did. + * + * What makes a cut before acknowledgement deterministic: the append is in + * flight when the row halts the entry, so what the journal holds is everything + * up to the publication request and nothing of it. + */ +function holdingAppend( + inner: InMemoryStream, + type: string, + reached: WithResolvers, +): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + reached.resolve(); + yield* suspend(); + } + yield* inner.append(event); + }, + }; +} + +/** Every managed checkout a history's placement records name. */ +function* placements(events: readonly DurableEvent[]): Operation { + const found: string[] = []; + for (const event of events) { + if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PLACEMENT) { + continue; + } + if (event.result.status !== "ok") { + continue; + } + const record = parseReplPlacementRecord(event.result.value); + if (record !== undefined && record.outcome.ok) { + found.push(record.outcome.value.checkoutRoot); + } + } + return found; +} + +/** A journal that refuses every append of one durable type. */ +function refusing(inner: InMemoryStream, type: string): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + throw new Error("the journal backend refused this append"); + } + yield* inner.append(event); + }, + }; +} + +/** Every event but the root's own close, which is what an unfinished run has. */ +function terminal(event: DurableEvent): boolean { + return !(event.type === "close" && event.coroutineId === "root"); +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 0b641c4b0..739afb3c1 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -614,6 +614,12 @@ const COMPILED_BINARY: RuntimeExclusion[] = [ "drives the compiled `dist/xmd` through a real terminal: it files an issue at a loopback tracker, leaves at a question and reopens the printed location to prove the binary carries the same entry issue host the source does. Only `deno compile` produces it and no test shard builds it, so it belongs beside the other compiled-binary evidence the `smoke` job builds for through README.md#Build", issue: "https://github.com/taras/executable.md/issues/567", }, + { + path: "scripts/tests/repl-push-compiled.test.ts", + reason: + "drives the compiled `dist/xmd` through a real terminal: it publishes a branch to a bare remote, leaves at a question, advances the checkout from a shell and reopens the printed location to prove the binary publishes the commit its saved request named. Only `deno compile` produces it and no test shard builds it, so it belongs beside the other compiled-binary evidence the `smoke` job builds for through README.md#Build", + issue: "https://github.com/taras/executable.md/issues/567", + }, { path: "scripts/tests/repl-repositories-compiled.test.ts", reason: @@ -724,6 +730,18 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "drives the production entry issue host, which only Deno and the compiled binary select: its module graph reaches @executablemd/git/deno and so Workflow's storage adapter and node:sqlite. The durable envelope and its record encodings are provider-neutral and are proven on every runtime by packages/git/tests/issue-owned.test.ts; that a runtime selecting no entry host reports no Issue provider is proven by packages/cli/tests/repl-issues-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, + { + path: "packages/git/tests/retained-repository-push.test.ts", + reason: + "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/cli/tests/repl-push.test.ts", + reason: + "drives the production per-entry capability, which only Deno and the compiled binary select: its module graph reaches @executablemd/git/deno and so Workflow's storage adapter and node:sqlite, and its rows publish to a real bare remote with real `git` children. That a runtime selecting no entry capability reports no Git provider for is proven by packages/cli/tests/repl-push-pty.test.ts, which runs everywhere", + issue: DERIVED_SCOPE, + }, ]; const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ diff --git a/scripts/tests/repl-push-compiled.test.ts b/scripts/tests/repl-push-compiled.test.ts new file mode 100644 index 000000000..55de2a06d --- /dev/null +++ b/scripts/tests/repl-push-compiled.test.ts @@ -0,0 +1,50 @@ +/** + * The same publication journey, against the compiled binary (#181 E1). + * + * A compiled `xmd` has no checkout to read from and no module graph to lose a + * module out of — it has whatever `deno compile` embedded. The entry capability + * is assembled by `compiled.ts`, which is a different entrypoint from the one + * the source row runs, so a binary built without it would start a REPL, refuse + * the publication, and the defect would appear at a person's first attempt to + * publish rather than here. + * + * It runs the *same* journey as the source row, from one shared module, so the + * two cannot drift into asserting different things. + * + * It runs against `dist/xmd`, so `deno task build` has to have happened. A + * missing binary is reported as the setup it is rather than as a failure of the + * behavior. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { shellQuote } from "@executablemd/test-support/launch"; +import { exists } from "@effectionx/fs"; +import type { Operation } from "effection"; +import { fileURLToPath as fromFileUrl } from "node:url"; +import { join } from "node:path"; +import { replPushJourney } from "../../packages/cli/tests/support/repl-push-journey.ts"; + +const ROOT = fromFileUrl(new URL("../..", import.meta.url)); +const BINARY = join(ROOT, "dist", "xmd"); + +/** How this suite invokes the compiled `xmd`. */ +function compiled(args: readonly string[]): string { + return [shellQuote(BINARY), ...args].join(" "); +} + +/** Refuse the row rather than skip it: a missing binary is missing evidence. */ +function* requireBinary(): Operation { + if (!(yield* exists(BINARY))) { + throw new Error( + `this row drives the compiled binary and ${BINARY} is not there. Run \`deno task build\` ` + + "first; a skipped row is missing evidence rather than a passing behavior.", + ); + } +} + +describe("the compiled binary's REPL publications", () => { + it("E1: carries the publication capability, so a branch is published once", function* (): Operation { + yield* requireBinary(); + yield* replPushJourney(compiled); + }); +}); diff --git a/scripts/tests/runtime-exclusions.test.ts b/scripts/tests/runtime-exclusions.test.ts index d0ad5e638..29a9bd47b 100644 --- a/scripts/tests/runtime-exclusions.test.ts +++ b/scripts/tests/runtime-exclusions.test.ts @@ -84,6 +84,7 @@ describe("runtime exclusions", () => { "scripts/tests/plan-component-compiled.test.ts", "scripts/tests/plugin-compiled.test.ts", "scripts/tests/repl-issues-compiled.test.ts", + "scripts/tests/repl-push-compiled.test.ts", "scripts/tests/repl-repositories-compiled.test.ts", ]); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 8a24d4da7..cf4ead85e 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10202,6 +10202,30 @@ one. A live operation takes the selected placement's managed slot and then the hold on the repository it changes, keeps both until the entry is torn down, and takes neither to answer a completed one. +It answers one publication beside them. `` publishes the branch the +selected checkout is on to that Repository's origin through the shared Git-host +reconciliation, and what the entry adds beneath it is the request it asked for. +That request — the authenticated Repository identity, the checkout the selection +named and 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 own native turn — +is published before anything is contacted, so a continuation reconciles that +exact request rather than resolving a new one and a push the remote accepted +before the journal recorded it is adopted rather than repeated. A completed +publication is answered from its receipt with no provider constructed, no +credential read and nothing contacted. A detached HEAD and a checkout recording +no usable origin refuse before a request exists. The objects a publication reads +come from the authenticated common directory through a control repository with a +read-only alternate, so the checkout's own `pushurl`, URL rewrites, hooks and +credential helpers reach nothing, and a missing saved source commit refuses +rather than being fetched. A publication takes the selected placement's managed +slot and no mutation lease, because it changes nothing in the repository. +Dispatch is ordinary Git composition through an operation-local default under +the stable Api name: a handler that answers without delegating retains nothing +and is not publication evidence, a second delegation of one invocation is +refused, the default is revoked when the invocation ends, and a saved request is +consumed before any dispatch. + It installs one external capability beside those: the issue adapter, so `` files and reads in an entry and its answers are retained on the same terms — a completed read answers with the snapshot it saw and a completed @@ -10210,21 +10234,23 @@ configuration or a credential. An interrupted upsert presents the key it used the first time, which is what lets the provider answer with the issue it already accepted rather than filing a second one; a read has no key. -What the entry provider installs nothing of is publication and the external -worker, so `` and `` refuse there exactly as they do -under a host that installs no provider for them, and a retained local result -authorizes neither. Constructing it acquires nothing, including no managed -root, no commit identity and no configuration read, and a passive reading of a -settled history initializes nothing at all. - -The identity an entry's issue work is named by is the entry's own: the execution -and the entry's admission-order position, derived by the host that opened the -session and reaching the component as a trusted closure rather than as a prop, a -Context value, a tracker or a middleware answer. The authored site inside it is -the identity canonical core minted where it selected that implementation. The -`` an entry runs is an ordinary shadowable registration of the name the -Plugin already declares, so a repository component of that name keeps its -ordinary precedence and obtains no identity of the entry's. +What the entry provider installs nothing of is pull requests, so +`` refuses there exactly as it does under a host that installs no +provider for it, and neither a retained local result nor a retained publication +authorizes one. Constructing it acquires nothing, including no managed root, no +commit identity, no credential and no configuration read, and a passive reading +of a settled history initializes nothing at all. + +The identity an entry's publication and issue work are named by is the entry's +own: the execution and the entry's admission-order position, derived by the host +that opened the session and reaching the component as a trusted closure rather +than as a prop, a Context value, a tracker or a middleware answer. The authored +site inside it is the identity canonical core minted where it selected that +implementation. The `` 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. 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 @@ -14219,6 +14245,28 @@ native completion and acknowledgement. | ERC13 | Local-Git acknowledgement | A live Commit gated before its append and then cancelled leaves no completion and no later sentinel, and the continuation runs live and reports ordinary `empty-index` rather than adopting the object id that was really written; one acknowledged and then cancelled replays with no native work; a rejected result append preserves the first persistence failure with no success binding, no later work and no compensating Close; an interrupted Add whose descendant holds the inherited pipes is joined before the entry settles; an unclassified performer failure is fatal and unrecorded | | ERC14 | Local-Git admission | Portable success and refusal codec rows refuse a wrong, extra or missing member, an unknown version, a mismatched fingerprint or name, an object id of the wrong width, a transition the operation does not make, mismatched operation arguments and a refusal word outside the closed set, and refuse a failed protocol envelope for this type; admission accepts repeated equal names and histories holding only the older record types | +### Tier EPB — Publication inside a REPL entry (§5.3, §8.1) + +Bounded to what one executing entry asks to publish and what the Git host does +about it. Everything the shared reconciliation itself proves — the observe, +adopt, fast-forward and refuse decisions, the one-use routing request and the +closed provider answers — stays in the Git-host effect tier: the rows below are +about the saved request, the identity it is named by, the dispatch that produces +it, the resources around it and the window between remote completion and +acknowledgement. + +| # | Test | Verify | +|---|------|--------| +| EPB1 | The public journey | A real-terminal source and compiled `xmd repl` each select a managed Repository and a Worktree, commit inside the captured path through ``, publish the branch to a bare remote, leave at the question, have that branch advanced from a shell, reopen the printed location and reach the question with the remote still holding the commit that was published and no second push, then answer and run the suffix once; a private `git` on `PATH` forwarding unchanged to the real one records that the reopened prefix ran no command at all, the repository's primary branch is untouched throughout, and a new entry's `` advances the destination to what the checkout holds now | +| EPB2 | Completed publication reaches nothing | A real unfinished prefix left at a question, holding an acknowledged request and outcome, reaches that question again after the managed checkout and the remote are removed, with zero `git` invocations, isolation-directory acquisitions, authentication sessions, object-source builds, provider constructions and live Git-host middleware calls, and appends no second publication; a genuinely new publication at another position takes the live path | +| EPB3 | A pending request keeps what it asked for | A request acknowledged before any remote contact is reconciled after HEAD and the branch have moved, publishing the commit it named and not today's; a remote that accepted the branch before the outcome was recorded is adopted with zero second push; a cut taken before the request was acknowledged resolves live inputs instead, so a branch advanced while the entry was away is what the first saved request and publication then name | +| EPB4 | The decisions, unchanged | Absent, equal, proven-ancestor and divergent destinations keep the shared engine's decisions; permanent ambiguity and temporary unavailability stay distinct definitive failures that replay as themselves without reaching a provider; a nonzero push earns exactly one further observation and an inconclusive completion is fatal; every assertion reads the remote's own refs and the full retained record | +| EPB5 | Identity and dispatch | The owner and the invocation identity name the work, are stable across a reopen and distinct for repeated invocations, concurrent branches and another entry; a synthetic middleware answer retains no request and no receipt; a transforming delegation is retained as both readings; a second delegation of one invocation is refused without repeating acknowledged work; a saved request bypasses the chain it already passed; an independent `Git.operations.push(...)` keeps the ordinary missing-provider refusal | +| EPB6 | Admission | Portable codec rows refuse an unknown version, a hostile or malformed value, an origin outside the closed locator grammar, inputs belonging to another Repository and an object id of the wrong width, and round-trip both object-id formats; the provider holds every record it reads back to the origin the Repository identity in it was fingerprinted from, on the resolved path and the restored one alike, because that digest is the runtime-named half's; history admission refuses a misnamed request, a foreign owner, a saved place no placement described, an orphan outcome, a request answered twice, a mismatched receipt and a refusal word outside the closed set, while accepting a trailing request with no outcome; a live validation refusal rejects a completion before its append | +| EPB7 | Acknowledgement, not completion | A refused request append contacts nothing, publishes nothing and fences the work after it; a refused outcome append leaves the remote as it is with no false receipt and no compensating Close, and the next attempt adopts that completion; accepted definitive failures replay provider-free; an unauthored throw is withheld rather than published | +| EPB8 | Resources and ownership | A fresh and a pending publication each take the selected placement's managed slot before any native read, and contention appends neither a request nor an outcome while preserving a previously saved pending one; a publication proceeds while another entry holds only the shared Git directory's mutation lease; the checkout's own `pushurl`, hooks and credential helpers reach nothing because the transport runs in a control repository with a read-only alternate; one attempt's scratch transport, object source and credential session survive both phases and are closed and joined before publication, and a cleanup that failed acknowledges neither a success nor a worker-authored refusal; a publication cancelled while its scratch space or its credential session is still pending delivers nothing it then leaves behind, releases what it did receive before the halt settles, publishes no outcome and preserves any acknowledged request, and a cancelled blocked native child closes before the entry settles | +| EPB9 | Assembly and unsupported runtimes | The per-entry factory is inert — no directory, no `git`, no credential and no managed root — and declares exactly this entry's publication and issue components; exactly one core initialization happens per executing entry; a passive reading, a frozen position and a refused location initialize nothing; the file work, selections, local Git and issues of the same entry survive the combined assembly; an entry with no managed root and a runtime that installs no entry capability each report an absent provider for `` before any remote contact or managed root | + --- ## 14. Walked example: diagnostic journal diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 50a52c21f..e5ccc2b39 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1346,6 +1346,68 @@ completed Git operation takes neither. A no-op Add, a same-branch Switch and a Commit that will refuse take them too: whether `git` will change anything is not predicted. +### The branch an entry published + +Inside a lexical `` — or a `` of one, including a +self-closing Worktree's captured path entered through `` — type this: + +```xmd + + + Ready for review. + + + + + The branch containing {commit} was published. Continue? + + Continued. + +``` + +`` takes no props and no content, renders nothing and binds nothing. +It publishes the branch the selected checkout is on to that Repository's origin, +without forcing and without changing upstream tracking. + +Leave at the question, make another commit on that branch from a shell, then come +back to the printed location. The branch on the remote still holds the commit +the entry published, nothing was pushed a second time, and no `git` ran at all. +Answering performs the write after the question for the first time. A *new* +`` after that publishes what the checkout holds now, which is what +makes this retention rather than a cache. + +What is written down before anything is contacted is the *request*: the branch, +the commit, the destination ref and the origin this publication asked for. So an +entry interrupted between asking and hearing back publishes that same request +when you continue it — the commit it named, not today's HEAD — and an entry whose +push the remote accepted and whose result was never written down adopts that +completion rather than repeating it. One publication happens, not two. + +The decisions are the ones publication has everywhere else. A destination that +does not exist yet, and one holding a commit this branch already contains, are +published once with an exact non-force refspec. A destination already holding +this exact commit is adopted and nothing is pushed. A destination holding work +this branch does not contain is a conflict: nothing is published, nothing is +forced and nothing is fetched. Permanent ambiguity and a host that could not +answer are their own refusals, and an acknowledged refusal replays as itself +rather than being retried for you. + +A detached HEAD refuses before anything is contacted, because a commit is not +somewhere to publish it. A checkout that records no usable origin refuses on the +same terms. Neither writes a request down, and neither reads a credential. + +Your checkout's own configuration cannot redirect a publication: the transport +runs in a repository this run created, reading your objects through a read-only +door, so a `pushurl`, a URL rewrite, a `pre-push` hook or a credential helper +written into the checkout reaches nothing. The commit a saved request named has +to still be readable — a missing object refuses rather than being fetched — and a +moved branch or HEAD alone refuses nothing. + +A publication takes ownership of the managed checkout it reads from, and takes +no hold on the repository itself: it changes no file, index, branch or +configuration there. Another entry committing in that repository does not stop +it; another entry holding the checkout does, before a request exists. + ### The issue an entry filed, and the one it read An entry files and reads issues wherever you started the REPL, inside a checkout @@ -1450,16 +1512,16 @@ text. No second name for the entry appears in a record: which history an entry may continue is the range it was given. Reading is not owning: a settled history, a frozen position and a refused location prepare nothing, look for no checkout and hold nothing. A host that registers no recording runs the same -file work with nothing retained, which is what Node and Bun do; `` there -reports that no provider is installed, exactly as it does under any other host -that installs none. +file work with nothing retained, which is what Node and Bun do; `` and +`` there report that no provider is installed, exactly as they do +under any other host that installs none. -An entry's issue work is named by the execution and the entry's own position in -it, and by nothing else. Two entries holding identical text are two entries; an -entry of another execution is somebody else's; and a tracker URL, a prop, a -component answer and anything else a document can write take no part in it. That -is what makes the interrupted upsert above answerable: the key is derived from -facts that survive leaving and coming back. +An entry's publication and issue work are named by the execution and the +entry's own position in it, and by nothing else. Two entries holding identical +text are two entries; an entry of another execution is somebody else's; and a +tracker URL, a prop, a component answer and anything else a document can write +take no part in it. That is what makes the interrupted upsert above answerable: +the key is derived from facts that survive leaving and coming back. Components receive immutable view data and answer with a typed semantic action. They hold no events, no history, no session, no repository and no host operation, @@ -2014,15 +2076,16 @@ resolved against the frame that published its coordinates. do not open the same location twice for writing. The hold on a checkout is a different thing: it says one entry at a time may mutate one repository, and it says nothing about who may write this execution's history. -- Publication is not part of this. `` and `` refuse in a - REPL entry exactly as they do under any host that installs no provider for - them, and a retained local result authorizes neither. What an entry retains is - `` and `` selection, the file and directory work inside - those checkouts and the one it was started in, ``, `` and - `` in them, and the issues it filed and read; nothing is pushed or - published. A runtime that installs no repository provider — Node and Bun — - refuses the Git components before any native operation, while ordinary - `` and `` work keeps working there. +- Pull requests are not part of this. `` refuses in a REPL entry + exactly as it does under any host that installs no provider for it, and + neither a retained local result nor a retained publication authorizes one. + What an entry retains is `` and `` selection, the file + and directory work inside those checkouts and the one it was started in, + ``, `` and `` in them, the branch + `` published, and the issues it filed and read. A runtime that + installs no repository provider — Node and Bun — refuses the Git components, + publication included, before any native operation or remote contact, while + ordinary `` and `` work keeps working there. - `` 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 diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 53c38afea..2e3cb6580 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -177,12 +177,26 @@ workflow run's mutations are Workspace effects inside a transaction, ordered by the frontier §9 and §10 describe, and the REPL has none of that: no Workspace, no transaction, no frontier and no logical filesystem — what it has is a historical record of each completed operation, which is neither of the other -two. `` and `` are not available to a REPL entry and -refuse there as they do under any host that installs no provider for them, and a -retained local result authorizes neither. `` is available, and what it -retains is `specs/repl-spec.md`'s subject rather than this document's: it lives -at a service rather than in a Workspace, so none of the transaction, frontier or -materialization rules here reach it. +two. + +`` is available to a REPL entry, and what it retains is +`specs/repl-spec.md`'s subject rather than this document's. The reconciliation +is the same one §7.4 and the Git-host effect contract describe — observe, adopt, +publish a proven absence or a proven ancestor once, refuse divergence and +permanent ambiguity, report unavailability — reached under the entry's own owner +and invocation identity instead of a run and an expansion. What an entry adds +beneath it is the *request* it published before contacting anything: the branch, +the commit, the destination ref and the admitted origin it asked for, so an +interrupted publication reconciles that exact request rather than resolving a +new one. None of the transaction, frontier or materialization rules here reach +it: the objects a publication reads come from the caller's own checkout through a +read-only door rather than from an exported Workspace family, and no Workspace +root, savepoint or frontier takes part. + +`` 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 +terms as publication, and lives at a service rather than in a Workspace. ## 3. Workflow lifecycle @@ -3618,6 +3632,7 @@ fetch operation requires its own language and durability contract. | ``, `` and the ambient Repository under an ordinary run | built by #643, Deno and compiled only: managed checkouts under `~/.xmd/repositories` with version 1 sidecars and execution-owned non-blocking locks, and the checkout the command was run in as the default Repository. Node and Bun install no operational provider | | `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary managed selector above, with what it answered retained as a small routing record so a completed selection restores without a selector, a clone, a `worktree add`, a sidecar read, a credential or a lock — and only new work meets the current filesystem. One entry at a time owns a managed slot, held until the entry is torn down and reacquired lazily by the first new operation inside a restored placement; that hold is distinct from the shared Git directory's mutation lease. Nothing is inventoried, snapshotted, restored or deleted, and Node and Bun install no operational provider | | ``, `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary performers above against the checkout the entry is working in, with what each produced retained as fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products. A completed operation is answered from that record with no `git` invocation of any kind, and a completed Commit's object id names the commit that was made then rather than today's HEAD. A live Commit reads the invoking person's own author and committer once per entry and only when a commit runs, captures the clock after deciding the index is nonempty, and commits exactly that index. A live operation takes the selected placement's slot and then the shared Git directory's mutation lease and keeps both until teardown; a completed one takes neither. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add keeps what it staged and claims no rollback; native completion and acknowledgement are separate, so an unacknowledged operation may run again and answer differently. No transaction, no rollback, no exactly-once local effect, and no publication evidence | +| `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the same observe/adopt/fast-forward/refuse reconciliation §7.4 describes, reached under the entry's own owner and core's minted invocation identity rather than a run and an expansion. The request the entry asked for — branch, commit, destination ref and admitted credential-free origin — is published before anything is contacted, so an interrupted publication reconciles that exact request and an accepted push whose result was never recorded is adopted rather than repeated. Objects are read from the caller's own checkout through a control repository with a read-only alternate rather than from an exported Workspace family; no Workspace root, savepoint or frontier takes part, a completed publication reaches no provider, credential or remote, and the publication takes the managed slot and no mutation lease | | local Git operations and `Git.Push` evidence under an ordinary run | built by #643, Deno and compiled only: the same authored transitions with no transaction and no replay, commits recorded under the invoking user's own captured Git identity with an actionable refusal when the host can name none, and a private per-execution Push evidence entry that authorizes `` and crosses no run | | `` and pull-request reads under an ordinary run | built by #643, Deno and compiled only: the same transports and ceilings with no durable envelope, keyed by this execution's own invocation identity | | transactional Git components (`Git.Switch`, `Git.Add`, `Git.Commit`) | built by #294, Deno provider only |