From 0e9bece44dd17b5d0ba75f9df33a9e03a2e79582 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:00:23 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Give=20an=20ordinary=20run=20th?= =?UTF-8?q?e=20repository=20vocabulary=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the complete repository-composition vocabulary available to an ordinary `xmd run`, with a live Deno provider and the current Git repository as the ambient Repository, while the retained workflow implementation keeps its own provider of the same component contract. The thirteen components — Repository, Worktree, Dir, the four Git operations, PullRequest and its three evidence reads, IssueTracker and Issue — become one shadowable declaration array that `xmd syntax`, `xmd plan`, a workflow attachment and an ordinary document execution all consume, so one language is described and resolved everywhere. What a name does is the installed provider's. Components now observe a profile-neutral `RepositorySelection`: an opaque provider-minted identifier, a display name, the credential-free repository identity and the selected checkout path, and no authority at all. Every operation authenticates the selection it was handed against private provider state, so a replaced contextual Repository can misname a checkout and be refused but can never reach one. The ordinary provider discovers the ambient repository once before root expansion, keeps managed checkouts under `~/.xmd/repositories` behind version 1 sidecars and execution-owned non-blocking advisory locks, performs local Git directly with no transaction and no replay, and authorizes `` from private Push evidence that crosses no execution. Node and Bun register the same declarations and install no operational provider. --- architecture.md | 34 +- packages/cli/src/bun.ts | 14 +- packages/cli/src/cli.ts | 65 +- packages/cli/src/compiled.ts | 5 + packages/cli/src/deno.ts | 15 +- packages/cli/src/node.ts | 14 +- packages/cli/src/run-repositories.ts | 87 +++ packages/cli/src/syntax.ts | 10 +- packages/cli/src/testing-host.ts | 13 + packages/cli/tests/plan-cli.test.ts | 3 + packages/cli/tests/run-composition.test.ts | 117 ++++ packages/cli/tests/run-deadline.test.ts | 27 +- .../cli/tests/support/run-markdown-tier.ts | 4 + packages/cli/tests/syntax-cli.test.ts | 69 ++ packages/cli/tests/targets-cli.test.ts | 33 +- .../cli/tests/testing-execution-host.test.ts | 2 + packages/workflow/deno.ts | 11 + packages/workflow/mod.ts | 17 +- packages/workflow/src/composition/api.ts | 117 ++-- .../src/composition/components/GitAdd.ts | 4 +- .../src/composition/components/GitCommit.ts | 4 +- .../src/composition/components/GitPush.ts | 4 +- .../src/composition/components/GitSwitch.ts | 4 +- .../src/composition/components/Issue.ts | 6 +- .../src/composition/components/PullRequest.ts | 21 +- .../components/PullRequestReads.ts | 7 +- .../src/composition/components/Repository.ts | 32 +- .../src/composition/components/Worktree.ts | 34 +- packages/workflow/src/composition/context.ts | 45 +- packages/workflow/src/composition/errors.ts | 25 + packages/workflow/src/composition/git-api.ts | 109 +-- .../src/composition/git-push-records.ts | 133 +--- .../workflow/src/composition/installation.ts | 343 +++++++--- .../src/composition/pull-request-api.ts | 10 +- .../composition/pull-request-operations.ts | 108 +++ .../src/composition/pull-request-records.ts | 38 +- .../workflow/src/composition/push-evidence.ts | 2 +- .../workflow/src/composition/selection.ts | 192 ++++++ .../workflow/src/deno/composition/commit.ts | 4 +- .../workflow/src/deno/composition/provider.ts | 231 +++++-- .../composition/pull-request-operations.ts | 165 +++++ .../deno/composition/pull-request-reads.ts | 271 +++----- .../src/deno/composition/pull-request.ts | 6 +- .../workflow/src/deno/composition/push.ts | 7 +- .../workflow/src/deno/composition/switch.ts | 4 +- .../src/deno/run-composition/ambient.ts | 168 +++++ .../src/deno/run-composition/checkouts.ts | 631 ++++++++++++++++++ .../src/deno/run-composition/errors.ts | 90 +++ .../src/deno/run-composition/leases.ts | 77 +++ .../src/deno/run-composition/metadata.ts | 165 +++++ .../src/deno/run-composition/operations.ts | 422 ++++++++++++ .../src/deno/run-composition/placement.ts | 83 +++ .../src/deno/run-composition/provider.ts | 490 ++++++++++++++ .../src/deno/run-composition/pull-request.ts | 153 +++++ packages/workflow/src/deno/selections.ts | 105 +++ packages/workflow/src/deno/workspace/host.ts | 13 + packages/workflow/src/issue/effect.ts | 21 + packages/workflow/src/issue/operations.ts | 81 +++ .../workflow/tests/git-add-durability.test.ts | 3 +- packages/workflow/tests/git-add.test.ts | 19 +- .../tests/git-commit-durability.test.ts | 3 +- packages/workflow/tests/git-commit.test.ts | 19 +- .../tests/git-push-durability.test.ts | 28 +- packages/workflow/tests/git-push.test.ts | 17 +- .../tests/git-switch-durability.test.ts | 119 ++-- packages/workflow/tests/git-switch.test.ts | 48 +- .../tests/pull-request-github.test.ts | 5 +- .../workflow/tests/pull-request-read.test.ts | 2 +- .../tests/pull-request-records.test.ts | 9 +- packages/workflow/tests/pull-request.test.ts | 6 +- .../workflow/tests/run-composition.test.ts | 537 +++++++++++++++ .../workflow/tests/support/issue-scenario.ts | 5 + .../workflow/tests/support/pull-requests.ts | 19 +- .../workflow/tests/support/run-composition.ts | 198 ++++++ specs/executable-mdx-spec.md | 111 +++ specs/workflow-workspace-spec.md | 101 ++- 76 files changed, 5463 insertions(+), 751 deletions(-) create mode 100644 packages/cli/src/run-repositories.ts create mode 100644 packages/cli/tests/run-composition.test.ts create mode 100644 packages/workflow/src/composition/pull-request-operations.ts create mode 100644 packages/workflow/src/composition/selection.ts create mode 100644 packages/workflow/src/deno/composition/pull-request-operations.ts create mode 100644 packages/workflow/src/deno/run-composition/ambient.ts create mode 100644 packages/workflow/src/deno/run-composition/checkouts.ts create mode 100644 packages/workflow/src/deno/run-composition/errors.ts create mode 100644 packages/workflow/src/deno/run-composition/leases.ts create mode 100644 packages/workflow/src/deno/run-composition/metadata.ts create mode 100644 packages/workflow/src/deno/run-composition/operations.ts create mode 100644 packages/workflow/src/deno/run-composition/placement.ts create mode 100644 packages/workflow/src/deno/run-composition/provider.ts create mode 100644 packages/workflow/src/deno/run-composition/pull-request.ts create mode 100644 packages/workflow/src/deno/selections.ts create mode 100644 packages/workflow/src/issue/operations.ts create mode 100644 packages/workflow/tests/run-composition.test.ts create mode 100644 packages/workflow/tests/support/run-composition.ts diff --git a/architecture.md b/architecture.md index 8b3b7b430..64300e716 100644 --- a/architecture.md +++ b/architecture.md @@ -32,7 +32,11 @@ Existing documents and code get aligned to this section retroactively. | stop reason | why a workflow run or a document execution stopped: a categorical host code, or a reference to an already-filtered journal event | | run ID | an opaque stable public identifier generated by the host or selected by an authorized caller; it associates the run's durable records and effects, remains unchanged for the life of the run, and has no semantics beyond equality and lifecycle addressing | | definition base | the Git revision supplied to choose a workflow definition's pinned commit | -| Repository base | the optional Git revision from which one named Workspace Repository initializes its primary checkout | +| Repository base | the optional Git revision from which one named Repository initializes its primary checkout | +| Repository selection | plain structural composition data naming the repository one component invocation acts on: an opaque provider-minted selection identifier, the display name, the credential-free repository identity, and the selected checkout path. It carries no credential, provider handle, lock, database, run ID or authority — the installed provider authenticates every selection against private state before it touches Git or a service, so a copied, replaced or rebuilt one can misname a target and be refused but can never reach one | +| ambient Repository | the repository an ordinary `xmd run` was started inside, discovered once before root expansion from the invocation's starting directory. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so starting in a linked worktree names the same repository as starting in the primary checkout while Git operations still act on the worktree. A workflow run has none | +| managed checkout | a Repository or Worktree an ordinary `xmd run` created under the host root `~/.xmd/repositories`, addressed by a digest of its whole identity, described by a closed version 1 sidecar written beside it, and held for one document execution by an exclusive non-blocking advisory lock. It survives every execution: nothing deletes, resets, cleans, fetches or repairs one | +| ordinary invocation identity | a fresh opaque random value an ordinary document execution's repository provider mints for itself and keeps in its own closure. It is not a prop, a Context value, a component result, a middleware answer, a lifecycle ID or a retained record, and it is neither addressable nor reusable; live Issue and pull-request idempotency and reconciliation keys are derived from it together with the engine's own expansion identity | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | | document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | | Prompt | a person's original request, in ordinary natural language. `xmd plan` takes exactly one | @@ -2376,6 +2380,17 @@ hidden inside library objects that accumulate. One exception: metadata an author declares at module evaluation, about a value the author owns, may live on that value. +A Repository selection is composition data and is therefore replaceable: a +document may bind one, render one, hand one to a child, and construct one that +looks exactly like it. Nothing a repository provider does is authorized by the +value it was handed. What stays provider-owned, in the provider's own closure +for one document execution, is everything a selection is *not*: the advisory +locks on managed checkouts, the canonical Git identity each selection resolves +to, an ordinary run's live Push evidence, its invocation identity, and every +reconciliation key derived from them. A selection that the provider did not +mint, or one whose name, checkout path or identity was edited after it did, is +refused before Git or a service is touched. + ### Definition-owned return state A value body — a value root, or a Markdown component that declares `returns` — @@ -3696,11 +3711,18 @@ Status is measured against main. | `useWorkflowServiceDenial()` | provides a non-delegating workflow service denial provider, installed inside every start and resume execution scope | built on the #366 stack | | `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI, under the Deno entrypoints only | built on the #366 stack; both acquire #367's executor lock before any lifecycle transition | | implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | document filesystem built on the #366 stack and Repository/Worktree composition on the #293 stack; process capabilities unbuilt (#218) | -| `` / `` / `` composition | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes | built on the #293 stack, Deno provider only | -| transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation carries authority — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of authority, of retained state or of an unrecognized native condition fails the run instead of publishing a result | built on the #294 stack, Deno provider only | -| `Git.Push` | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator. A destination proven absent is published to once and one already naming this exact commit is adopted; one naming a distinct commit that same authenticated source proves is in this commit's ancestry is a performable pre-state, published over by the same exact non-force refspec and retained as the predecessor with the attested relation, while a divergent commit and one the source cannot read are both conflicts and nothing is fetched to decide either; a completed Push is reconstructed from the Workspace root its own journal event was appended against, read without publishing it or moving the run's frontier, so a branch published more than once resumes | built on the #370 stack, Deno provider only | -| `` | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` — the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit — proven by a scan of the whole successful history that requires each relevant record's natural key, inputs and result to describe one publication; a branch is published repeatedly, so the whole history is read in order and the run's last publication of that branch decides — an earlier one behind it is history rather than disagreement, while a last one naming another commit is the branch having moved on; that is conflicting, no relevant record at all is missing, and a relevant record that cannot be read whole is unreadable, each failing locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN`, then `GITHUB_TOKEN`, then the machine's own `gh` login, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only | -| `` | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not authority: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The Deno workflow host installs configured GitHub middleware and installs none otherwise, so absence of configuration is fail-closed | built on the #296 stack; GitHub middleware, Deno host | +| `` / `` composition under a workflow run | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes | built on the #293 stack, Deno provider only | +| transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) under a workflow run | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation carries authority — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of authority, of retained state or of an unrecognized native condition fails the run instead of publishing a result | built on the #294 stack, Deno provider only | +| `Git.Push` under a workflow run | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator. A destination proven absent is published to once and one already naming this exact commit is adopted; one naming a distinct commit that same authenticated source proves is in this commit's ancestry is a performable pre-state, published over by the same exact non-force refspec and retained as the predecessor with the attested relation, while a divergent commit and one the source cannot read are both conflicts and nothing is fetched to decide either; a completed Push is reconstructed from the Workspace root its own journal event was appended against, read without publishing it or moving the run's frontier, so a branch published more than once resumes | built on the #370 stack, Deno provider only | +| `` under a workflow run | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` — the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit — proven by a scan of the whole successful history that requires each relevant record's natural key, inputs and result to describe one publication; a branch is published repeatedly, so the whole history is read in order and the run's last publication of that branch decides — an earlier one behind it is history rather than disagreement, while a last one naming another commit is the branch having moved on; that is conflicting, no relevant record at all is missing, and a relevant record that cannot be read whole is unreadable, each failing locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN`, then `GITHUB_TOKEN`, then the machine's own `gh` login, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only | +| repository composition vocabulary | one array of thirteen ordinary, shadowable registrations — `Repository`, `Worktree`, `Dir`, the four `Git.*` operations, `PullRequest` and its three evidence reads, `IssueTracker` and `Issue` — consumed by the workflow attachment, by `xmd syntax` and `xmd plan`'s validation and generation, and by an ordinary document execution, so one vocabulary is described and resolved everywhere. Registering it installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential; what a name does is the installed provider's. A repository-local Markdown or TypeScript component of the same name is chosen ahead of any of them | built on the #643 stack | +| `` / `` composition under an ordinary run | selects a managed checkout under `~/.xmd/repositories`, addressed by a digest of its whole identity and described by a closed version 1 sidecar written by exclusive temporary sibling plus atomic rename only after the checkout is complete and verified. The slot is entered under an exclusive non-blocking advisory lock held for the whole document execution, so a second process is refused rather than made to wait and a self-closing Worktree captured with `as` stays protected while a later sibling `` and an interactive Session use it. Reuse compares creation identity alone — the immutable request, the recorded creation facts, the canonical checkout and common directory, the object format, the admitted `origin` and the creation commit still being present — and never HEAD, the current branch, the index or the working tree, which are the mutable work the checkout exists to preserve; a conflict refuses and leaves every byte where it was, and nothing resets, switches, cleans, fetches, moves, replaces, repairs or deletes. A metadata-free slot is adopted only after the stricter pre-exposure state is proved — exact owner and locator, the branch and base this request resolves to, the creation commit still being HEAD, the object format, linked-worktree registration where applicable, and nothing in the slot but the checkout — and refuses otherwise. Written outside a lexical ``, a Worktree belongs to the ambient Repository; outside a Git checkout it refuses locally and names how to run inside one. `` is unchanged and needs no provider at all | built on the #643 stack, Deno and compiled only | +| local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | perform the same authored transitions the workflow performers perform — named branches only, explicit Add pathspecs, index-only Commit, no implicit stage or push, and the same fixed provider Git configuration that disables hooks, signing, file-system monitors and repository-supplied helper programs — directly against the authenticated selected checkout. They enlist in no transaction, roll back nothing and replay nothing, and a failure claims neither. Which checkout one runs in is decided by the Repository selection in scope and the contextual working directory, resolved through the provider's own invocation-owned checkout registry rather than through anything the selection says about itself | built on the #643 stack, Deno and compiled only | +| `Git.Push` under an ordinary run | keeps the same observe/adopt/fast-forward/refuse rules and the same isolated transport aimed at the checkout's admitted `origin`: a destination proven absent is published once, one already naming this exact commit is adopted, one holding a proven ancestor is published over by the same exact non-force refspec, and a divergent or unreadable one is a conflict, with an unreachable host never read as absence. It reconciles no Git-host effect and retains nothing. After a verified performed or adopted publication it stores one private evidence entry — the authenticated Repository identity, canonical checkout root, origin, named branch, destination ref and exact commit — in the provider instance's own closure. A checkout with no admitted `origin` refuses before a credential, a session or a transport exists | built on the #643 stack, Deno and compiled only | +| `` and its evidence reads under an ordinary run | share the URL matching, host ceiling, response normalization and low-level GitHub reconciliation, and differ in lifecycle and authority. A read is performed afresh every execution and retained nowhere. An upsert authenticates the Repository selection and the contextual checkout, reads the current named branch and commit, and requires the exact matching entry this provider instance already holds — a Push for another checkout, Repository, origin, destination, branch or commit is irrelevant, a later Push of the same destination supersedes the earlier entry, and missing or conflicting evidence is a local refusal before a credential is opened. Nothing crosses executions: a new run and a new `--journal` run each start with a new invocation identity and empty evidence, and copying a Context value, a component result or a previous trace file grants nothing. Within one invocation the attempt happens at most once; across a process interruption there is no exactly-once claim | built on the #643 stack, Deno and compiled only | +| `` under an ordinary run | reaches the same configured transport under the same host ceiling, with no durable envelope: identity is this execution's own opaque invocation identity together with the engine's expansion identity, so an upsert presents an idempotency key a provider can carry and a second run is a new request rather than a resumption. Absent or out-of-ceiling configuration installs no matching provider and sends no credential and no request | built on the #643 stack, Deno and compiled only | +| ordinary repository provider assembly | the Deno source entrypoint and the compiled binary install the live provider for `xmd run` and for an approved `xmd plan --run`, parameterized by the same credential-helper assembly the workflow host uses and by the two existing host configurations, `XMD_WORKFLOW_GITHUB_ISSUES` and `XMD_WORKFLOW_GITHUB_PULL_REQUESTS`, both read and validated before a document runs. A nested `` child receives a fresh instance — its own invocation identity, leases and Push evidence — so nothing it publishes authorizes its parent or a sibling. The outer `xmd test` command and a workflow execution install none. Node and Bun register the vocabulary and install no operational provider, so every repository operation reports an absent provider before a lock, a credential, a subprocess or a request exists | built on the #643 stack | +| `` under a workflow run | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not authority: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The Deno workflow host installs configured GitHub middleware and installs none otherwise, so absence of configuration is fail-closed | built on the #296 stack; GitHub middleware, Deno host | | workflow lifecycle inspection and control | reads status/list/history without advancing a run, recovering a private copy when a crashed source needs rollback; enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | direct read-only inspection and control built on the #367 stack; coordinated recovered inspection built on the #513 stack, Deno provider only | | XMD artifact export, inspection and fork source | seals one run's committed retained state, Workspace roots and workflow definition source closure into one immutable `.xmd` evidence file; opens that file read-only for status/history and admits continuation only by creating a new history fork whose lineage names the artifact identity | specified by `specs/xmd-artifact-spec.md`; the version-1 sealed container, its total read-only verifier, `xmd workflow export` and artifact `status`/`history` are built, Deno provider only — the artifact-source fork remains unbuilt. Inspection is two sibling lifecycle operations, `inspectArtifact()` and `historyArtifact()`, taking a path rather than a run id: a run id names live lifecycle authority and a path names immutable evidence, so neither is a mode of the other. They reach no run store, lock, Workspace, definition reader or external provider, and the artifact path never enters the structural answer | | Agent session portability evidence in an XMD artifact | classifies every logical Agent session that contributed a retained Prompt as portable — with ordered provider checkpoint tokens and an opaque Agent session bundle — or as explicitly unavailable, as two content kinds inside the existing version-1 manifest and identity | specified by `specs/xmd-artifact-spec.md` §2.5; the closed union, both content kinds and the complete post-identity profile verifier are built on the #621 stack, Deno provider only. Provider bundle capture, Agent-aware export, intrinsic Agent-aware inspection and artifact-backed fork are unbuilt | diff --git a/packages/cli/src/bun.ts b/packages/cli/src/bun.ts index 4b1e4a3bc..6737b532f 100644 --- a/packages/cli/src/bun.ts +++ b/packages/cli/src/bun.ts @@ -13,6 +13,7 @@ import { compileTempFile } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { unassembledMachineSessions } from "./session-coordinator.ts"; import { unsupportedWorkflowHost } from "./workflow.ts"; +import { unsupportedRepositories } from "./run-repositories.ts"; import { useBunService } from "./bun-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); @@ -44,5 +45,16 @@ await main(function* (args) { // build either. Advertising the same names is what makes the refusal say so: // every advertised operation stops before provider work, while ordinary ACP // work is unaffected. - yield* runXmd(args, useBunService, unsupportedWorkflowHost, unassembledMachineSessions()); + // The same thirteen repository components, and no provider that operates + // any of them. This runtime has no kernel-released advisory lock to hold a + // managed checkout with, so a Repository, Worktree, Git, Issue or PullRequest + // operation reports an absent provider before a local or remote change could + // happen. `xmd syntax` still describes one language everywhere. + yield* runXmd( + args, + useBunService, + unsupportedRepositories, + unsupportedWorkflowHost, + unassembledMachineSessions(), + ); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 50a73568d..8d389acbb 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -64,6 +64,7 @@ import { inspectDocument, agentIdentityComponents, installAgentComponents, + registerComponents, retainedSource, rootSourcePath, useNormalizedOutput, @@ -126,6 +127,8 @@ import type { PlanExecution } from "./plan.ts"; import { componentSearchPath, resolveTestTarget } from "./test-target.ts"; import { renderSyntaxJson, renderSyntaxMarkdown, syntaxCatalog } from "./syntax.ts"; import { testingExecutionHost } from "./testing-host.ts"; +import { unsupportedRepositories } from "./run-repositories.ts"; +import type { RepositoryInstaller } from "./run-repositories.ts"; import { EVAL_ALIAS, EVAL_OPTION, evalGrammarError, readEvalFlags } from "./eval-source.ts"; import type { EvalFlags } from "./eval-source.ts"; import { @@ -139,7 +142,7 @@ import type { HostWorkflowInstaller, WorkflowHost, WorkflowStart } from "./workf import { runWorkflowManagement } from "./workflow-management.ts"; import { establishDefinition } from "./workflow-definition.ts"; import type { EstablishedDefinition } from "./workflow-definition.ts"; -import { useWorkflowServiceDenial } from "@executablemd/workflow"; +import { COMPOSITION_REGISTRATIONS, useWorkflowServiceDenial } from "@executablemd/workflow"; import denoJson from "../deno.json" with { type: "json" }; const SECRET_DETECTION_OPTION = "--secret-detection"; @@ -682,6 +685,13 @@ export type HostServiceInstaller = () => Operation; * value root's stdout — stays with the command that owns those streams. */ export function* installDocumentComponents(mode: DocumentMode, verbose: boolean): Operation { + // The repository-composition vocabulary, as ordinary shadowable defaults. + // Registering it installs no provider, discovers no repository, acquires no + // lock and reaches no network: what a name *does* is decided by whichever + // provider the command installed, and a runtime that installs none still + // resolves every one of these. + yield* registerComponents(COMPOSITION_REGISTRATIONS); + // Compose testing around the single core execution entrypoint: both // commands register the components (assertions work in regular documents, // explicit boundaries affect the outcome), while `xmd test` @@ -732,6 +742,7 @@ function* runDocument( config: DocumentConfig, mode: DocumentMode, installService: HostServiceInstaller, + installRepositories: RepositoryInstaller, ): Operation> { const { root, include, verbose, journal, raw, secretDetection, retainProcessOutput } = config; @@ -840,6 +851,15 @@ function* runDocument( // the provider for a service. yield* installService(); + // Repository authority belongs to document execution too, and it is this + // execution's own: the provider it installs holds an invocation identity, the + // leases on the checkouts this document selects, and the evidence of what it + // published. `xmd run` and an approved `xmd plan --run` supply the live one; + // `xmd test` and every runtime without an operational provider supply the one + // that installs nothing, and every repository operation then reports an + // absent provider before touching anything. + yield* installRepositories(); + // What a `` in this document runs a nested execution under. Captured // before document code begins, so a child is offered exactly what this // command assembled — and never a second description of it. @@ -852,6 +872,11 @@ function* runDocument( includes: include, secretDetection, installService, + // Passed rather than inherited: a child runs in an isolated scope, and what + // it needs is a *fresh* provider instance of its own. Handing it the + // installer is what gives an isolated `host="run"` child its own invocation + // identity, its own leases and its own Push evidence. + installRepositories, testAgentWorker: yield* readWorkerCommand(), plan, }); @@ -953,9 +978,10 @@ function* runScopedDocument( config: DocumentConfig, mode: DocumentMode, installService: HostServiceInstaller, + installRepositories: RepositoryInstaller, ): Operation> { try { - return yield* scoped(() => runDocument(config, mode, installService)); + return yield* scoped(() => runDocument(config, mode, installService, installRepositories)); } catch (error) { return Err(error instanceof Error ? error : new Error(String(error))); } @@ -985,6 +1011,7 @@ export function planExecutor( stack: AgentStack, sessions: MachineSessionAssembly | undefined, installService: HostServiceInstaller, + installRepositories: RepositoryInstaller, ): (approved: PlanExecution) => Operation> { return (approved) => scoped(function* (): Operation> { @@ -1011,6 +1038,10 @@ export function planExecutor( agent: stack, }, installService, + // An approved plan's second execution is an ordinary run, so it gets + // the ordinary provider — a fresh one, since the authorship profile's + // scope is already gone. + installRepositories, ); }); } @@ -1098,6 +1129,11 @@ function* test( { ...config, root: { path } }, { testing: true }, installService, + // The outer `xmd test` command installs no operational repository + // provider. A test that needs the production behavior exercises an + // explicit `` child, which constructs one of its + // own. + unsupportedRepositories, ); if (!result.ok) { reportFailure(result.error); @@ -1138,6 +1174,7 @@ function* test( }, { testing: true }, installService, + unsupportedRepositories, ); if (!result.ok) { reportFailure(result.error, document.relativePath); @@ -1864,6 +1901,7 @@ function* dispatch( evalFlags: EvalFlags, helpRequest: { requested: boolean; args: string[] }, installService: HostServiceInstaller, + installRepositories: RepositoryInstaller, workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, ): Operation { @@ -1965,6 +2003,7 @@ function* dispatch( agent: runStack, }, installService, + installRepositories, ); }); if (!result.ok) { @@ -2022,7 +2061,7 @@ function* dispatch( // A host that answers installs a provider; one that does not installs // none, and nothing downstream reads a profile to find out which. installElicitation: installWebElicitation, - execute: planExecutor(config, planStack, sessions, installService), + execute: planExecutor(config, planStack, sessions, installService, installRepositories), }, ); if (exitCode !== 0) { @@ -2167,6 +2206,9 @@ function* dispatch( // service adapter would: installed inside the execution scope, // before the root document is imported. useWorkflowServiceDenial, + // A workflow run's repositories are the retained ones its Workspace + // attachment installs, so this path installs none of its own. + unsupportedRepositories, ), ), ); @@ -2179,6 +2221,12 @@ function* dispatch( export function* runXmd( args: string[], installService: HostServiceInstaller, + // What an ordinary document execution installs for ``, + // ``, the Git operations, `` and ``. Deno and + // the compiled binary supply the live provider; Node and Bun supply the one + // that installs nothing, so those runtimes describe the same vocabulary and + // operate none of it. + installRepositories: RepositoryInstaller, // Defaults to the host that refuses. A caller driving this without naming a // workflow host has no run store, and inheriting one by omission is the // failure mode the whole boundary exists to prevent — so the default is the @@ -2249,7 +2297,14 @@ export function* runXmd( (selected.name === "run" || selected.name === "plan"); if (!executes) { - return yield* dispatch(evalFlags, helpRequest, installService, workflowHost, sessions); + return yield* dispatch( + evalFlags, + helpRequest, + installService, + installRepositories, + workflowHost, + sessions, + ); } const timeouts = resolveRunTimeouts(evalFlags.rest); @@ -2260,6 +2315,6 @@ export function* runXmd( } yield* underRunDeadline(timeouts, () => - dispatch(evalFlags, helpRequest, installService, workflowHost, sessions), + dispatch(evalFlags, helpRequest, installService, installRepositories, workflowHost, sessions), ); } diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index b2bdf9cba..68837112a 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -12,6 +12,7 @@ import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; +import { denoRunRepositories } from "./run-repositories.ts"; import { isCredentialHelperMode, runCredentialHelper, @@ -64,9 +65,13 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // two owners of one conversation. // Helper mode receives neither this nor the workflow host: it is not the // public CLI and assembles none of it. + // The ordinary repository provider, on the same terms the Deno entrypoint + // installs it: the binary is Deno, and the helper assembly it hands over is + // the one that names this executable rather than a module path. yield* runXmd( args, useCompiledService, + denoRunRepositories(HELPER), () => useDenoWorkflowHost(HELPER), useMachineSessions(), ); diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 0b5ae355c..ef7444aef 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -15,6 +15,7 @@ import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; +import { denoRunRepositories } from "./run-repositories.ts"; import { isCredentialHelperMode, runCredentialHelper, @@ -78,6 +79,18 @@ if (isCredentialHelperMode(process.argv.slice(2))) { // two owners of one conversation. // Helper mode receives neither this nor the workflow host: it is not the // public CLI and assembles none of it. - yield* runXmd(args, useDenoService, () => useDenoWorkflowHost(HELPER), useMachineSessions()); + // The ordinary repository provider: managed checkouts under + // `~/.xmd/repositories`, the ambient repository this command was run in, + // and the two GitHub configurations this deployment authorizes. It is + // parameterized by the same credential-helper assembly the workflow host + // uses, because the program that is running is what knows how to re-invoke + // itself as one. + yield* runXmd( + args, + useDenoService, + denoRunRepositories(HELPER), + () => useDenoWorkflowHost(HELPER), + useMachineSessions(), + ); }); } diff --git a/packages/cli/src/node.ts b/packages/cli/src/node.ts index d23dd7b3b..8b2038184 100755 --- a/packages/cli/src/node.ts +++ b/packages/cli/src/node.ts @@ -19,6 +19,7 @@ import { compileTempFile } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { unassembledMachineSessions } from "./session-coordinator.ts"; import { unsupportedWorkflowHost } from "./workflow.ts"; +import { unsupportedRepositories } from "./run-repositories.ts"; import { useNodeService } from "./node-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); @@ -51,5 +52,16 @@ await main(function* (args) { // build either. Advertising the same names is what makes the refusal say so: // every advertised operation stops before provider work, while ordinary ACP // work is unaffected. - yield* runXmd(args, useNodeService, unsupportedWorkflowHost, unassembledMachineSessions()); + // The same thirteen repository components, and no provider that operates + // any of them. This runtime has no kernel-released advisory lock to hold a + // managed checkout with, so a Repository, Worktree, Git, Issue or PullRequest + // operation reports an absent provider before a local or remote change could + // happen. `xmd syntax` still describes one language everywhere. + yield* runXmd( + args, + useNodeService, + unsupportedRepositories, + unsupportedWorkflowHost, + unassembledMachineSessions(), + ); }); diff --git a/packages/cli/src/run-repositories.ts b/packages/cli/src/run-repositories.ts new file mode 100644 index 000000000..663969276 --- /dev/null +++ b/packages/cli/src/run-repositories.ts @@ -0,0 +1,87 @@ +/** + * Where an ordinary `xmd run` keeps the repositories it manages, and what it is + * allowed to reach. + * + * This is the only module in the CLI that names the managed root, exactly as + * `deno-workflow.ts` is the only one that names the run store. What a document + * writes decides which repository it wants; this decides where a clone of it + * lands, which issue trackers and pull requests this deployment authorizes, and + * how the host writes its own credential helper. + * + * Managed checkouts live beneath `~/.xmd/repositories` and survive every + * execution: what is in one is somebody's work — a branch, a worktree an agent + * is still editing, an uncommitted change — and nothing deletes one. There is + * no environment variable naming a different root, because the only caller that + * needs one is a test, and a test is handed the root directly. + * + * Node and Bun install none of this. They register the same thirteen + * declarations, so `xmd syntax` describes one language and a document resolves + * the same names everywhere, and every operation then reaches a clear + * provider-absence error before anything local or remote is touched. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Operation } from "effection"; +import { cwd } from "@executablemd/runtime"; +import { useRunComposition } from "@executablemd/workflow/deno"; +import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; +import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; + +/** Where managed repositories and worktrees live. */ +export const DEFAULT_REPOSITORY_ROOT: string = join(homedir(), ".xmd", "repositories"); + +/** + * How one document execution obtains repository operations, or does not. + * + * A function rather than a value, because the provider is installed *inside* + * the execution scope and holds that execution's own invocation identity, + * leases and Push evidence. A nested `` calls it again + * and gets a fresh instance, which is what keeps a child's evidence and locks + * out of its parent and its siblings. + */ +export type RepositoryInstaller = () => Operation; + +/** + * The runtimes that register the vocabulary and operate none of it. + * + * Installing nothing is the whole implementation: ``, ``, + * the Git operations, `` and `` each reach their own Api's + * default, which reports an absent provider before a lock, a credential, a + * subprocess or a request exists. `` is unaffected — it needs no provider. + */ +export function unsupportedRepositories(): Operation { + return noRepositories(); +} + +// deno-lint-ignore require-yield +function* noRepositories(): Operation {} + +/** + * The live provider Deno and the compiled binary install. + * + * The two GitHub configurations are read once, when the installer is built, so + * an operator who wrote something this host cannot use learns it before a + * document runs rather than in the middle of one. + */ +export function denoRunRepositories( + helper: HelperAssembly, + root: string = DEFAULT_REPOSITORY_ROOT, +): RepositoryInstaller { + return function* (): Operation { + const gitHubIssues = yield* gitHubIssuesConfiguration(); + const gitHubPullRequests = yield* gitHubPullRequestsConfiguration(); + yield* useRunComposition({ + root, + // The directory this execution starts in, which is where the ambient + // repository is discovered from. Read through the contextual Api rather + // than from the process, so a nested execution that composed its own + // working directory is discovered from that one. + cwd: yield* cwd(), + helper, + ...(gitHubIssues === undefined ? {} : { gitHubIssues }), + ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), + }); + }; +} diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 2eea2d68f..9fef5999d 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -32,14 +32,16 @@ import type { } from "@executablemd/core"; import { TESTING_REGISTRATIONS } from "@executablemd/testing"; import { WEB_REGISTRATIONS } from "@executablemd/web"; +import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; /** * The catalog for the production `run` profile, in the contextual working * directory. * * The registrations are the ones `installTestingComponents()`, - * `installWebComponents()` and `installAgentComponents()` register, read as - * values so this cannot drift from what a run installs. What those installers + * `installWebComponents()`, `installAgentComponents()` and the + * repository-composition installer register, read as values so this cannot + * drift from what a run installs. What those installers * *also* do — testing activation and its execution middleware, the elicitation * provider, the agent provider, the permission mode, the foreground launcher — * is operational and belongs to a run, so none of it happens here. @@ -80,6 +82,10 @@ export function* useRunProfileRegistry(): Operation { ...AGENT_REGISTRATIONS, ...TESTING_REGISTRATIONS, ...WEB_REGISTRATIONS, + // The repository-composition vocabulary. Registering it is all that happens + // here: catalog construction installs no provider, discovers no ambient + // repository, acquires no lock, spawns no Git and reads no credential. + ...COMPOSITION_REGISTRATIONS, ]); } diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index 3ba748f56..e8ccfe8ca 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -49,6 +49,7 @@ import type { } from "@executablemd/testing"; import { installDocumentComponents } from "./cli.ts"; import type { HostServiceInstaller } from "./cli.ts"; +import type { RepositoryInstaller } from "./run-repositories.ts"; /** What the entrypoint already decided, and a child must not decide again. */ export interface TestingHostSettings { @@ -69,6 +70,15 @@ export interface TestingHostSettings { readonly secretDetection: boolean; /** The native service adapter this entrypoint supplies. */ readonly installService: HostServiceInstaller; + /** + * How this entrypoint installs repository operations for one execution. + * + * Called again for every child, so an isolated `` + * constructs a provider instance of its own: its own invocation identity, its + * own leases and its own Push evidence. Nothing it publishes authorizes its + * parent or a sibling, and nothing they published authorizes it. + */ + readonly installRepositories: RepositoryInstaller; /** * How this entrypoint re-invokes itself as the test-agent worker, or why it * cannot. @@ -203,6 +213,9 @@ function* runProfileChild( // Native service authority belongs only to document execution, here as in the // command that owns it. yield* settings.installService(); + // And repository authority the same way, from the same installer the command + // used — a fresh instance for this child alone. + yield* settings.installRepositories(); const execution = yield* executeInstalled( { diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index 7291cecfa..17342931e 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -44,6 +44,7 @@ import { } from "./support/plan-harness.ts"; import type { PlanHarness } from "./support/plan-harness.ts"; +import { unsupportedRepositories } from "../src/run-repositories.ts"; const REQUEST = "write a greeting"; /** A document that declares props and writes what it resolved. */ @@ -165,6 +166,8 @@ function executor( stack ?? STACK, undefined, function* () {}, + // No repository provider: this suite drives the executor, not a checkout. + unsupportedRepositories, ); } diff --git a/packages/cli/tests/run-composition.test.ts b/packages/cli/tests/run-composition.test.ts new file mode 100644 index 000000000..a87d9cf75 --- /dev/null +++ b/packages/cli/tests/run-composition.test.ts @@ -0,0 +1,117 @@ +/** + * Tier ORC — how the command line assembles repository operations. + * + * Two claims, and they are about opposite things. One runtime *operates* the + * vocabulary and one only *describes* it, and both have to be true at once: a + * document written for `xmd run` resolves the same thirteen names everywhere, + * and on a runtime that operates none of them every one reports an absent + * provider before a lock, a credential, a subprocess or a request exists. + * + * The declarations are the same array in both cases, which is why there is no + * third thing to keep in agreement. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, type Operation } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; +import { useRunProfileRegistry } from "../src/syntax.ts"; +import { DEFAULT_REPOSITORY_ROOT, unsupportedRepositories } from "../src/run-repositories.ts"; + +/** Every element an author can write that needs a repository provider. */ +const OPERATIONS: readonly { readonly name: string; readonly source: string }[] = [ + { + name: "Repository", + source: ``, + }, + { name: "Worktree", source: `` }, + { name: "Git.Switch", source: `` }, + { name: "Git.Add", source: `` }, + { name: "Git.Commit", source: `` }, + { name: "Git.Push", source: `` }, + { name: "PullRequest", source: `` }, + { + name: "PullRequest.Reviews", + source: ``, + }, + { name: "Issue", source: `` }, +]; + +/** + * Run one element with the declarations registered and no provider installed — + * which is exactly what Node and Bun assemble. + */ +function ordinaryWithoutProvider(source: string, cwd: string): Operation { + return scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return cwd; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* registerComponents(COMPOSITION_REGISTRATIONS); + yield* unsupportedRepositories(); + return yield* collect( + yield* execute({ ...inlineSource(source), stream: new InMemoryStream() }), + ); + }); +} + +describe("ORC2 — one language, described everywhere and operated somewhere", () => { + it("registers the same thirteen declarations the syntax catalog describes", function* () { + // The array itself, rather than a second list: `useRunProfileRegistry()`, + // `installDocumentComponents()` and `useCompositionComponents()` all + // consume this one, so there is nothing for a runtime to disagree about. + expect(COMPOSITION_REGISTRATIONS).toHaveLength(13); + yield* scoped(function* () { + yield* useRunProfileRegistry(); + }); + }); + + it("reports an absent provider for every repository operation, and mutates nothing", function* () { + for (const operation of OPERATIONS) { + // The working directory is this repository's own checkout, so a provider + // that *did* discover an ambient repository would find one — and the + // refusal below would then be about something else. + const failure = yield* raisedValue(ordinaryWithoutProvider(operation.source, ".")); + // The element's name travels with the assertion, so a failure says which + // of the nine reported something else. + const reported = `${operation.name}: ${String(failure)}`; + expect(reported).toMatch( + /provider is not installed|no Repository composition provider|no Git composition provider|no Issue provider|no pull-request provider/, + ); + } + }); + + it("leaves working, because it needs no provider at all", function* () { + const rendered = yield* ordinaryWithoutProvider( + ['', "", "inside", "", ""].join("\n"), + ".", + ); + expect(String(rendered)).toContain("inside"); + }); +}); + +describe("ORC2 — where the managed root is", () => { + it("names ~/.xmd/repositories and nothing a document can influence", function* () { + expect(DEFAULT_REPOSITORY_ROOT.endsWith("/.xmd/repositories")).toBe(true); + yield* scoped(function* () {}); + }); +}); + +/** Whatever this operation raised, as a value. */ +function* raisedValue(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error; + } + throw new Error("the operation did not fail"); +} diff --git a/packages/cli/tests/run-deadline.test.ts b/packages/cli/tests/run-deadline.test.ts index fcd97f501..5e13f1bf7 100644 --- a/packages/cli/tests/run-deadline.test.ts +++ b/packages/cli/tests/run-deadline.test.ts @@ -20,6 +20,7 @@ import * as net from "node:net"; import { API, Config, Service, fetch, useHostFiles } from "@executablemd/runtime"; import { runXmd } from "../src/cli.ts"; +import { unsupportedRepositories } from "../src/run-repositories.ts"; /** * The exit continuation `exit()` reaches for. `main()` installs one under this * name; a suite that drives `runXmd` directly installs its own so a command's @@ -109,17 +110,21 @@ function* drive(args: string[], options: DriveOptions = {}): Operation { yield* useHostFiles(); - yield* runXmd(args, function* () { - serviceInstalled = true; - yield* Service.around({ - *start() { - throw new Error("the run started a service"); - }, - }); - if (options.inScope) { - yield* options.inScope(); - } - }); + yield* runXmd( + args, + function* () { + serviceInstalled = true; + yield* Service.around({ + *start() { + throw new Error("the run started a service"); + }, + }); + if (options.inScope) { + yield* options.inScope(); + } + }, + unsupportedRepositories, + ); return { status, stderr, reads, events, serviceInstalled, deadlineReads }; }); diff --git a/packages/cli/tests/support/run-markdown-tier.ts b/packages/cli/tests/support/run-markdown-tier.ts index e420761f8..f63638bcf 100644 --- a/packages/cli/tests/support/run-markdown-tier.ts +++ b/packages/cli/tests/support/run-markdown-tier.ts @@ -35,6 +35,7 @@ import { useBunService } from "../../src/bun-service.ts"; import { useDenoService } from "../../src/deno-service.ts"; import { useNodeService } from "../../src/node-service.ts"; +import { unsupportedRepositories } from "../../src/run-repositories.ts"; /** The native service adapter the entrypoint for this runtime installs. */ const SERVICES = { bun: useBunService, @@ -81,6 +82,9 @@ export function runMarkdownTier(document: string): Operation { return renderSyntaxMarkdown(yield* syntaxCatalog(["components", "."])); }, }), + // This harness runs Markdown tiers, not repository work: a child that + // asked for a checkout is told there is no provider. + installRepositories: unsupportedRepositories, }); const execution = yield* executeInstalled({ path: document, stream: new InMemoryStream() }, [ testHarnessInstallation(testingHost), diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index cd3273259..909b7b7e7 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -132,6 +132,23 @@ function catalogWith(props: PropsSchema): SyntaxCatalog { }; } +/** The thirteen names #643 settled, exactly as a document writes them. */ +const COMPOSITION_NAMES = [ + "Repository", + "Worktree", + "Dir", + "Git.Switch", + "Git.Add", + "Git.Commit", + "Git.Push", + "PullRequest", + "PullRequest.Reviews", + "PullRequest.Comments", + "PullRequest.Checks", + "IssueTracker", + "Issue", +] as const; + describe("Tier SX — the run profile the command describes", () => { it("SX1: names core, Agent, testing and web defaults, and ", function* () { const catalog = yield* syntaxCatalog([]); @@ -158,6 +175,58 @@ describe("Tier SX — the run profile the command describes", () => { expect(catalog.categories[2].entries).toEqual([]); }); + it("ORC1: names all thirteen repository-composition components, with contracts", function* () { + const catalog = yield* syntaxCatalog([]); + const entries = catalog.categories[1].entries; + const builtIn = names(entries); + + for (const name of COMPOSITION_NAMES) { + expect(builtIn).toContain(name); + } + + // A complete contract, not a bare name: every one of them says what it is + // for, which forms it takes, and what its props are. + for (const name of COMPOSITION_NAMES) { + const entry = entries.find((candidate) => candidate.name === name); + expect(entry?.description ?? "").not.toBe(""); + expect(entry?.forms?.length ?? 0).toBeGreaterThan(0); + // Registered rather than reserved, which is what makes a repository + // component of the same name win. + expect(entry?.origin).toEqual({ + kind: "registered", + origin: "@executablemd/workflow/composition", + reserved: false, + }); + } + + // The ones that produce a value say what `as` binds; the ones that render + // nothing and produce nothing do not pretend to. + expect(entries.find((entry) => entry.name === "Git.Commit")?.as).toContain("object id"); + expect(entries.find((entry) => entry.name === "PullRequest.Reviews")?.as).toContain("Required"); + expect(entries.find((entry) => entry.name === "Git.Push")?.as).toBe(undefined); + }); + + it("ORC1: a repository component of the same name shadows the default", function* () { + yield* useWorkspace( + { + "Worktree.md": [ + "---", + "description: the repository's own Worktree", + "---", + "", + "shadowed", + "", + ].join("\n"), + }, + function* (dir) { + const catalog = yield* syntaxCatalog([dir]); + const provided = catalog.categories[2].entries.find((entry) => entry.name === "Worktree"); + expect(provided).toBeDefined(); + expect(names(catalog.categories[1].entries)).not.toContain("Worktree"); + }, + ); + }); + it("SX2: documents every complete built-in in the profile", function* () { const catalog = yield* syntaxCatalog([]); const undocumented = catalog.categories[1].entries.filter( diff --git a/packages/cli/tests/targets-cli.test.ts b/packages/cli/tests/targets-cli.test.ts index ad039bbe3..aebb54223 100644 --- a/packages/cli/tests/targets-cli.test.ts +++ b/packages/cli/tests/targets-cli.test.ts @@ -21,6 +21,7 @@ import { API, Service, useHostFiles } from "@executablemd/runtime"; import { runCli } from "@executablemd/test-support/launch"; import { runXmd } from "../src/cli.ts"; +import { unsupportedRepositories } from "../src/run-repositories.ts"; function* useFixture( files: Record, body: (dir: string) => Operation, @@ -574,15 +575,19 @@ function* replacingRun( // `` read in the replacement really does reach the recorder above. yield* useHostFiles(); - yield* runXmd(args, function* () { - serviceInstalled = true; - yield* Service.around({ - *start() { - serviceStarted = true; - throw new Error("the run started a service"); - }, - }); - }); + yield* runXmd( + args, + function* () { + serviceInstalled = true; + yield* Service.around({ + *start() { + serviceStarted = true; + throw new Error("the run started a service"); + }, + }); + }, + unsupportedRepositories, + ); return { status, stderr, serviceInstalled, serviceStarted, documentReads, reads }; }); @@ -750,9 +755,13 @@ function* helpRun(args: string[], cwd: string): Operation { }); yield* useHostFiles(); - yield* runXmd(args, function* () { - serviceInstalled = true; - }); + yield* runXmd( + args, + function* () { + serviceInstalled = true; + }, + unsupportedRepositories, + ); return { status, stdout, stderr, serviceInstalled }; }); diff --git a/packages/cli/tests/testing-execution-host.test.ts b/packages/cli/tests/testing-execution-host.test.ts index de3e2fa64..4a5d4ad1d 100644 --- a/packages/cli/tests/testing-execution-host.test.ts +++ b/packages/cli/tests/testing-execution-host.test.ts @@ -27,6 +27,7 @@ import { runCli } from "@executablemd/test-support/launch"; import { testingExecutionHost } from "../src/testing-host.ts"; import { planComponentDescription } from "../src/plan-component.ts"; +import { unsupportedRepositories } from "../src/run-repositories.ts"; function doc(...lines: string[]): string { return `${lines.join("\n")}\n`; } @@ -501,6 +502,7 @@ describe("deterministic dependencies declared for a nested run", () => { secretDetection: true, // deno-lint-ignore require-yield installService: function* (): Operation {}, + installRepositories: unsupportedRepositories, testAgentWorker: Err(new Error("xmd command not installed")), // The run profile's own Component travels to every child, and this case is // about the relaunch it cannot perform rather than about ``. diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 1e356dbc8..3ab0e1195 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -141,3 +141,14 @@ export type { SuspensionControllerOptions, SuspensionNotice, } from "./src/deno/suspension.ts"; +/** + * The ordinary run's repository provider. + * + * The installer alone, and the options a trusted entrypoint supplies to it. + * What the provider holds — the leases, the credential assembly, the selection + * registry, the live Push evidence and the metadata writer — stays inside it: + * a package that could reach one of those could authorize a publication this + * execution never made. + */ +export { useRunComposition } from "./src/deno/run-composition/provider.ts"; +export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index 85f09ea39..af9fb96a7 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -146,25 +146,21 @@ export type { } from "./src/composition/git-records.ts"; export { destinationRefFor, - filteredRepositoryIdentity, GIT_PUSH, gitPushInputsJson, gitPushNaturalKeyJson, gitPushObservationsJson, gitPushPreStateJson, - gitPushRepositoryIdentityJson, gitPushResultJson, parseGitPushInputs, parseGitPushNaturalKey, parseGitPushObservations, parseGitPushPreState, parseGitPushRecord, - parseGitPushRepositoryIdentity, parseGitPushResult, PUSH_REMOTE, pushExpectation, refspecFor, - sameRepositoryIdentity, } from "./src/composition/git-push-records.ts"; export type { GitPushExpectation, @@ -173,7 +169,6 @@ export type { GitPushObservations, GitPushOutcome, GitPushPreState, - GitPushRepositoryIdentity, GitPushRequest, GitPushResult, } from "./src/composition/git-push-records.ts"; @@ -216,7 +211,10 @@ export type { PullRequestUpdateKey, } from "./src/composition/pull-request-records.ts"; export { admitPushEvidence } from "./src/composition/push-evidence.ts"; -export { useCompositionComponents } from "./src/composition/installation.ts"; +export { + COMPOSITION_REGISTRATIONS, + useCompositionComponents, +} from "./src/composition/installation.ts"; export { ISSUE_API, IssueApi, NoIssueProvider } from "./src/issue/api.ts"; export type { @@ -409,6 +407,13 @@ export { } from "./src/suspension/api.ts"; export type { WorkflowSuspensionApi, WorkflowSuspensionRequest } from "./src/suspension/api.ts"; export { SUSPENSION_ANSWER } from "./src/suspension/answer.ts"; +export { + filteredRepositoryIdentity, + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, +} from "./src/composition/selection.ts"; +export type { RepositoryIdentity } from "./src/composition/selection.ts"; export { WorkflowAnswerDeliveryError, WorkflowInputDelivery, diff --git a/packages/workflow/src/composition/api.ts b/packages/workflow/src/composition/api.ts index c62d79afe..888e3d064 100644 --- a/packages/workflow/src/composition/api.ts +++ b/packages/workflow/src/composition/api.ts @@ -1,76 +1,109 @@ /** - * The provider-neutral Repository/Worktree composition Api. + * The profile-neutral Repository/Worktree composition Api. * * Repository and Worktree components ask this Api for work; the installed * provider decides how to do it. This package names no subprocess, no host - * filesystem and no runtime: a workflow host with the authority to touch a Git - * checkout — the Deno host, for now — installs a concrete provider inside its - * `withWorkflowWorkspace()` attachment. + * filesystem and no runtime: whichever host has the authority to touch a Git + * checkout installs a concrete provider, and there are two of them. A workflow + * host installs one inside its `withWorkflowWorkspace()` attachment, where a + * checkout is a retained Workspace root; the Deno and compiled `xmd run` + * entrypoints install another, where a checkout is a directory on the caller's + * own filesystem held open by an advisory lock. * - * Each component performs two steps against it, and the split is what makes - * replay work. **Creation** is the durable half: it clones, resolves, pins and - * retains, and a completed one restores from the journal without contacting - * anything. **Attachment** is the ephemeral half: it runs every time, live or - * replayed, and its job is to rebuild the live facade and check that the - * retained state the journal selected is still the state that is there. Partial - * replay therefore reattaches without recreating, and a retained checkout that - * has gone missing is discovered where it can still stop the run. + * Every operation answers with a {@link RepositorySelection} — plain structural + * data naming a target, carrying no authority. What a provider does with a + * selection it is handed afterwards is authenticate it against private state, + * so a selection that was copied, replaced or rebuilt can misname a target and + * be refused; it cannot reach one. * * The default handler throws. There is no in-memory fallback: a Repository that - * "starts" without a provider would retain nothing while claiming it had. + * "starts" without a provider would retain nothing while claiming it had, and a + * host that installs none must be distinguishable from one whose repository is + * merely not there. */ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; import { RepositoryCompositionProviderError } from "./errors.ts"; -import type { - RepositoryCreationRequest, - RepositoryRecord, - WorktreeCreationRequest, - WorktreeRecord, -} from "./records.ts"; +import type { RepositorySelection } from "./selection.ts"; + +/** + * What a `` invocation asks the provider to select. + * + * Parsed at the component boundary from the caller's props and expressions. The + * provider receives only the bytes it acts on; the locator is still raw here, + * because admitting it is the provider's job and refusing an unusable one is + * one of the answers it gives. + */ +export interface RepositoryRequest { + readonly name: string; + readonly locator: string; + readonly base: string | undefined; +} + +/** + * What a `` invocation asks the provider to select. + * + * The Repository is the selection the enclosing lexical `` — or the + * ambient one — already produced, rather than a name from props: a Worktree + * exists inside a Repository, and letting a document write the name would let it + * name a Repository that is not in scope. + */ +export interface WorktreeRequest { + readonly name: string; + readonly branch: string; + readonly base: string | undefined; +} export interface RepositoryCompositionApi { /** - * Create or restore the named Repository's creation identity. + * Select the Repository this lexical invocation names, creating it when the + * provider has none. * - * One durable effect. A live first reach authorizes the locator, resolves the - * base once, pins the commit and retains the checkout; a replayed one returns - * what was retained without reaching a remote. + * One operation rather than a creation and an attachment, because a component + * has one question: which repository am I acting on. A workflow provider + * still performs both halves inside it — one durable effect that clones, + * resolves, pins and retains, then an ephemeral reattachment that proves the + * retained state is still there — and a live provider acquires a lease, + * revalidates a compatible reuse and hands back the same directory. */ - createRepository(request: RepositoryCreationRequest): Operation; + selectRepository(request: RepositoryRequest): Operation; + + /** Select a named linked checkout of an already-selected Repository. */ + selectWorktree( + repository: RepositorySelection, + request: WorktreeRequest, + ): Operation; /** - * Rebuild the live facade for a Repository whose creation identity is settled, - * and verify that the Workspace still holds the state that identity names. + * The Repository the host is already standing in, for an element written + * outside a lexical ``. * - * Ephemeral: it appends nothing and is performed on every execution. + * Three answers, and they are three different situations. A selection means + * this profile has an ambient Repository and this invocation is in one. A + * throw means it has ambient Repositories and this invocation is not in one, + * and the sentence says how to run inside one. `undefined` means the profile + * has no such thing at all — a workflow document names its repositories, and + * the component's own refusal is what says so. */ - attachRepository(record: RepositoryRecord): Operation; - - /** Create or restore the named Worktree's creation identity, as one durable effect. */ - createWorktree(request: WorktreeCreationRequest): Operation; - - /** Rebuild and verify a settled Worktree's live facade. */ - attachWorktree(record: WorktreeRecord): Operation; + ambientRepository(): Operation; } export const RepositoryComposition: Api = createApi("executablemd.workflow.composition.repository", { // deno-lint-ignore require-yield - *createRepository(_request: RepositoryCreationRequest): Operation { + *selectRepository(_request: RepositoryRequest): Operation { throw new RepositoryCompositionProviderError(""); }, // deno-lint-ignore require-yield - *attachRepository(_record: RepositoryRecord): Operation { - throw new RepositoryCompositionProviderError(""); - }, - // deno-lint-ignore require-yield - *createWorktree(_request: WorktreeCreationRequest): Operation { + *selectWorktree( + _repository: RepositorySelection, + _request: WorktreeRequest, + ): Operation { throw new RepositoryCompositionProviderError(""); }, // deno-lint-ignore require-yield - *attachWorktree(_record: WorktreeRecord): Operation { - throw new RepositoryCompositionProviderError(""); + *ambientRepository(): Operation { + throw new RepositoryCompositionProviderError("an element written outside a "); }, }); diff --git a/packages/workflow/src/composition/components/GitAdd.ts b/packages/workflow/src/composition/components/GitAdd.ts index 3f38b1321..f32cd12be 100644 --- a/packages/workflow/src/composition/components/GitAdd.ts +++ b/packages/workflow/src/composition/components/GitAdd.ts @@ -39,7 +39,7 @@ import type { PropsSchema } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { GitComposition } from "../git-api.ts"; -import { currentRepository } from "../context.ts"; +import { selectedRepository } from "../context.ts"; import { GitOperationAuthorityError, GitOperationError } from "../errors.ts"; import { wellFormedText } from "../parse.ts"; @@ -132,7 +132,7 @@ export default function* GitAdd(props: Record): Operation invalid("renders nothing, so it takes no content. Write it as ."); } - const repository = yield* currentRepository(); + const repository = yield* selectedRepository(); if (repository === undefined) { throw new GitOperationAuthorityError( ADD, diff --git a/packages/workflow/src/composition/components/GitCommit.ts b/packages/workflow/src/composition/components/GitCommit.ts index 643223492..f3bdd65cd 100644 --- a/packages/workflow/src/composition/components/GitCommit.ts +++ b/packages/workflow/src/composition/components/GitCommit.ts @@ -41,7 +41,7 @@ import type { PropsSchema, ReturnsSchema } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { GitComposition } from "../git-api.ts"; -import { currentRepository } from "../context.ts"; +import { selectedRepository } from "../context.ts"; import { GitOperationAuthorityError, GitOperationError } from "../errors.ts"; import { wellFormedText } from "../parse.ts"; import { parseGitCommitMessageSource } from "../git-records.ts"; @@ -188,7 +188,7 @@ export default function* GitCommit(props: Record): Operation): Operation): Operation): Operation { const provider = named(props.provider); // No tracker is consulted. The URL is the identity, so a tracker written // around a read has nothing to add to it and changes nothing about it. - const details = yield* readIssue({ url, provider }); + const details = yield* IssueOperations.operations.read({ url, provider }); return { url: details.url, title: details.title, @@ -266,7 +266,7 @@ function* upsert(props: Record): Operation { assignee: typeof props.assignee === "string" && props.assignee !== "" ? props.assignee : null, }); - const reference = yield* upsertIssue({ + const reference = yield* IssueOperations.operations.upsert({ target: destination.target, provider: destination.provider, issue, diff --git a/packages/workflow/src/composition/components/PullRequest.ts b/packages/workflow/src/composition/components/PullRequest.ts index ebf4d6bed..0d42c5b78 100644 --- a/packages/workflow/src/composition/components/PullRequest.ts +++ b/packages/workflow/src/composition/components/PullRequest.ts @@ -74,8 +74,8 @@ import { content, hasContent } from "@executablemd/core"; import type { PropsSchema, ReturnsSchema } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { PullRequestAPI } from "../pull-request-api.ts"; -import { currentRepository } from "../context.ts"; +import { PullRequestOperations } from "../pull-request-operations.ts"; +import { selectedRepository } from "../context.ts"; import { PullRequestAuthorityError } from "../errors.ts"; import { pullRequestResultJson } from "../pull-request-records.ts"; @@ -145,7 +145,7 @@ export default function* PullRequest(props: Record): Operation): Operation): Operation` — name a Git repository inside the run's Workspace + * `` — select a managed Git repository by name and url * (specs/workflow-workspace-spec.md §6.1). * * Two forms, one meaning. Lexical - * `` creates or restores the named + * `` creates or reuses the named * checkout, then expands its content with that Repository installed as the * contextual one and its checkout as the contextual working directory. Both are * restored when the invocation ends, on success, failure and cancellation * alike, because they live on the invocation's own scope. * - * Self-closing `` creates or restores the same - * checkout, renders nothing, and returns the stable Workspace-relative checkout - * path. Nothing here reaches into how `as` works: the engine's ordinary capture - * binds the returned string, which is what keeps `as` one rule rather than a - * Repository-shaped exception to one. + * Self-closing `` creates or reuses the same + * checkout, renders nothing, and returns its checkout path. Nothing here reaches + * into how `as` works: the engine's ordinary capture binds the returned string, + * which is what keeps `as` one rule rather than a Repository-shaped exception to + * one. + * + * Where that checkout lives is the installed provider's. A workflow run holds it + * inside the run's own retained Workspace; an ordinary `xmd run` holds it under + * the managed root on the caller's filesystem, protected for the execution by an + * advisory lock. * * ## Who decides what a failure means * @@ -42,9 +47,9 @@ import { content, hasContent } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { RepositoryComposition } from "../api.ts"; +import type { RepositoryRequest } from "../api.ts"; import { RepositoryContext } from "../context.ts"; import { RepositoryCompositionError } from "../errors.ts"; -import type { RepositoryCreationRequest } from "../records.ts"; export const props = { type: "object", @@ -74,7 +79,7 @@ function optional(props: Record, prop: string): string | undefined return typeof value === "string" && value !== "" ? value : undefined; } -export function parseRepositoryProps(props: Record): RepositoryCreationRequest { +export function parseRepositoryProps(props: Record): RepositoryRequest { return { name: required(props, "name"), locator: required(props, "url"), @@ -83,21 +88,20 @@ export function parseRepositoryProps(props: Record): RepositoryCre } export default function* Repository(props: Record): Operation { - const record = yield* RepositoryComposition.operations.createRepository( + const selection = yield* RepositoryComposition.operations.selectRepository( parseRepositoryProps(props), ); - yield* RepositoryComposition.operations.attachRepository(record); if (!(yield* hasContent())) { - return record.checkoutPath; + return selection.checkoutPath; } - yield* RepositoryContext.around({ current: () => record }, { at: "min" }); + yield* RepositoryContext.around({ current: () => selection }, { at: "min" }); yield* API.Env.around( { // deno-lint-ignore require-yield *cwd(): Operation { - return record.checkoutPath; + return selection.checkoutPath; }, }, { at: "min" }, diff --git a/packages/workflow/src/composition/components/Worktree.ts b/packages/workflow/src/composition/components/Worktree.ts index 41ec96abc..e1f28e83d 100644 --- a/packages/workflow/src/composition/components/Worktree.ts +++ b/packages/workflow/src/composition/components/Worktree.ts @@ -1,11 +1,23 @@ /** - * `` — a named linked checkout inside the contextual Repository + * `` — a named linked checkout of the Repository in scope * (specs/workflow-workspace-spec.md §6.2). * - * Invalid without an enclosing lexical ``. A Worktree exists inside - * a Repository, and the Repository's name is half of what makes its identity - * durable across replay; threading that name through props would let a document - * name a Repository that is not in scope. + * The Repository is the enclosing lexical ``, or — under a host + * that has one — the ambient Repository the invocation started in. Either way it + * is never a prop: a Worktree exists inside a Repository, the Repository's + * identity is half of what makes the Worktree's own, and threading that name + * through props would let a document name a Repository that is not in scope. + * + * An ordinary `xmd run` from a Git checkout therefore takes a root-level + * Worktree to mean a linked checkout of the repository the person running it is + * standing in: + * + * ```md + * + * ``` + * + * A workflow run has no ambient Repository, so the same element written outside + * a `` there is invalid. * * Its two forms match Repository's. The self-closing form is the one the * adversarial workflow uses, because it is the spelling that both binds a path @@ -35,7 +47,7 @@ import { content, hasContent } from "@executablemd/core"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { RepositoryComposition } from "../api.ts"; -import { RepositoryContext } from "../context.ts"; +import { selectedRepository } from "../context.ts"; import { WorktreeCompositionError } from "../errors.ts"; export const props = { @@ -66,7 +78,7 @@ export default function* Worktree(props: Record): Operation): Operation { - return record.checkoutPath; + return selection.checkoutPath; }, }, { at: "min" }, diff --git a/packages/workflow/src/composition/context.ts b/packages/workflow/src/composition/context.ts index 2ff87d7d0..be5e85777 100644 --- a/packages/workflow/src/composition/context.ts +++ b/packages/workflow/src/composition/context.ts @@ -1,23 +1,32 @@ /** - * The contextual Repository a lexical `` installs for its content. + * The contextual Repository a lexical `` installs for its content, + * and how an element written outside one finds a Repository anyway. * - * A stable, namespaced contextual value holding a parsed record and no - * authority. `` reads it to learn which Repository it belongs to; the - * provider decides separately whether anything may be done to that Repository, - * so a replaced context can misname a Repository but cannot grant access to - * one. + * A stable, namespaced contextual value holding a {@link RepositorySelection} + * and no authority. `` and every Git element read it to learn which + * Repository they belong to; the provider decides separately whether anything + * may be done to that Repository, so a replaced context can misname a + * Repository but cannot grant access to one. * * Installed with `{ at: "min" }` wherever it is installed, so the nearest * enclosing Repository answers and the outer one is restored when that scope * ends. Nesting one Repository inside another therefore means what it reads as. + * + * With no lexical Repository in scope the installed provider is asked for its + * ambient one. An ordinary `xmd run` from a Git checkout has one — the + * repository the invocation started in — which is what lets a document write a + * root-level `` or `` and mean the checkout the person + * running it is standing in. A workflow run has none, because a workflow names + * every repository it touches. */ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; -import type { RepositoryRecord } from "./records.ts"; +import { RepositoryComposition } from "./api.ts"; +import type { RepositorySelection } from "./selection.ts"; export interface RepositoryContextApi { - readonly current: RepositoryRecord | undefined; + readonly current: RepositorySelection | undefined; } export const RepositoryContext: Api = createApi( @@ -25,7 +34,23 @@ export const RepositoryContext: Api = createApi { +/** The currently enclosing lexical Repository, or `undefined` when there is none. */ +export function currentRepository(): Operation { return RepositoryContext.operations.current; } + +/** + * The Repository this element acts on: the lexical one, or the host's own. + * + * `undefined` means neither exists, and the calling component's own refusal is + * what says so — each of them has a different sentence for what it needed a + * repository *for*. A host that has ambient Repositories and is not in one + * refuses from the provider instead, naming how to run inside one. + */ +export function* selectedRepository(): Operation { + const lexical = yield* RepositoryContext.operations.current; + if (lexical !== undefined) { + return lexical; + } + return yield* RepositoryComposition.operations.ambientRepository(); +} diff --git a/packages/workflow/src/composition/errors.ts b/packages/workflow/src/composition/errors.ts index 62eb58134..802272d1b 100644 --- a/packages/workflow/src/composition/errors.ts +++ b/packages/workflow/src/composition/errors.ts @@ -114,6 +114,31 @@ export class RepositoryCompositionProviderError extends WorkflowStorageError { } } +/** + * A Repository selection no installed provider minted, or one whose facts were + * edited after it did. + * + * A selection names a target and grants nothing: the provider keeps the + * authority in its own closure and asks what a selection names before it + * touches anything. So a value that was copied out of one execution, rebuilt + * from what a document could see, or handed over with a member changed reaches + * this rather than a checkout. + * + * A `StaleInputError`, on the same terms as {@link GitOperationAuthorityError}: + * a document cannot avoid it by asking for something else, and later siblings + * must not run as though the operation had happened. + */ +export class RepositorySelectionError extends StaleInputError { + override name = "RepositorySelectionError"; + + constructor(operation: string) { + super( + `${operation} was handed a Repository selection this provider did not make. Nothing was ` + + "read and nothing was changed.", + ); + } +} + /** The provider answered with something that is not a record. */ export class RepositoryCompositionProtocolError extends WorkflowStorageError { override name = "RepositoryCompositionProtocolError"; diff --git a/packages/workflow/src/composition/git-api.ts b/packages/workflow/src/composition/git-api.ts index 513767d72..111776f75 100644 --- a/packages/workflow/src/composition/git-api.ts +++ b/packages/workflow/src/composition/git-api.ts @@ -1,97 +1,118 @@ /** - * The provider-neutral Api a transactional Git component asks for work through. + * The profile-neutral Api a Git component asks for work through. * * `` names no subprocess and reaches no filesystem. It observes two - * things a document can see — the enclosing `` and the contextual - * working directory — and asks whoever is installed to do the rest. Neither - * observation carries authority: a replaced context can misname a Repository, - * and the provider's answer is what decides which retained checkout, if any, - * those two select. + * things a document can see — the Repository in scope and the contextual working + * directory — and asks whoever is installed to do the rest. Neither observation + * carries authority: a replaced selection can misname a Repository, and the + * provider's answer is what decides which checkout, if any, those two select. + * + * What lifecycle the work has is the installed provider's, not this Api's. A + * workflow provider runs each of these as one durable Workspace effect, so a + * completed one restores from the journal and moves no branch. The ordinary + * `xmd run` provider performs them directly against the selected checkout: + * there is no transaction to enclose a person's own repository in, and none is + * claimed. * * The default handler throws. There is no host-less fallback, because a Git * operation that "ran" without a provider would report a branch this run never - * moved — and ordinary `xmd run` installs none, so a document written for a - * workflow fails there rather than quietly touching a checkout in the caller's - * own filesystem. + * moved. */ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; import { GitCompositionProviderError } from "./errors.ts"; -import type { - GitAddRequest, - GitAddResult, - GitCommitRequest, - GitCommitResult, - GitSwitchRequest, - GitSwitchResult, -} from "./git-records.ts"; -import type { GitPushOutcome, GitPushRequest } from "./git-push-records.ts"; -import type { PullRequestReadRequest, PullRequestReadResult } from "./pull-request-read-records.ts"; +import type { GitAddResult, GitCommitResult, GitSwitchResult } from "./git-records.ts"; +import type { GitCommitMessageSource } from "./git-records.ts"; +import type { GitPushOutcome } from "./git-push-records.ts"; +import type { RepositorySelection } from "./selection.ts"; + +/** + * Where a Git operation happens, as the component observed it. + * + * The selection is what the operation belongs to; the working directory is + * where inside it the element was written, and the two are equal only when a + * document wrote the element at the checkout root. + */ +export interface GitInvocationPlace { + readonly repository: RepositorySelection; + /** The contextual working directory the component observed. */ + readonly workingDirectory: string; +} + +export interface GitSwitchInvocation extends GitInvocationPlace { + readonly branch: string; + readonly base: string | undefined; +} + +export interface GitAddInvocation extends GitInvocationPlace { + readonly paths: readonly string[]; +} + +export interface GitCommitInvocation extends GitInvocationPlace { + /** The exact bytes to commit, already canonical. */ + readonly message: string; + readonly messageSource: GitCommitMessageSource; +} + +export type GitPushInvocation = GitInvocationPlace; export interface GitCompositionApi { - /** - * Put the selected checkout on a named branch, as one durable effect. - * - * A completed one restores its retained result: replay changes no branch and - * spawns no Git. - */ - switchBranch(request: GitSwitchRequest): Operation; + /** Put the selected checkout on a named branch. */ + switchBranch(invocation: GitSwitchInvocation): Operation; /** - * Stage exactly the pathspecs this request names, as one durable effect. + * Stage exactly the pathspecs this invocation names. * * One command for the whole array rather than one per entry: Git decides what * a pathspec matches, and a per-entry loop would be several transitions where * the document wrote one. */ - addPaths(request: GitAddRequest): Operation; + addPaths(invocation: GitAddInvocation): Operation; /** - * Record exactly what the index holds, as one durable effect. + * Record exactly what the index holds. * * Nothing is staged for it and nothing is amended: the index is the whole of * what a commit is made from, and an index that already matches HEAD is - * refused rather than committed empty. A completed one restores its retained - * result: replay writes no object, reads no clock and spawns no Git. + * refused rather than committed empty. */ - commitIndex(request: GitCommitRequest): Operation; + commitIndex(invocation: GitCommitInvocation): Operation; /** * Publish the selected checkout's current branch to its origin. * - * The one operation here whose outcome a local transaction cannot enclose. - * It observes the destination ref before it mutates and performs at most - * once, so an interrupted attempt that already reached the remote is adopted - * on the next execution rather than repeated; a completed one restores its - * retained record without contacting the remote at all. + * The one operation here whose outcome no local transaction can enclose. It + * observes the destination before it mutates and performs at most once, so an + * interrupted attempt that already reached the remote is adopted rather than + * repeated. * * Routed through this Api like the other three, and for the same reason: a * document names a checkout by writing an element inside one, and what that * observation selects is the installed provider's to decide. Observation is - * all this carries — the provider still authenticates the record, the - * directory and the objects it publishes against what this run retained. + * all this carries — the provider still authenticates the selection, the + * directory and the objects it publishes. */ - pushCurrentBranch(request: GitPushRequest): Operation; + pushCurrentBranch(invocation: GitPushInvocation): Operation; } export const GitComposition: Api = createApi( "executablemd.workflow.composition.git", { // deno-lint-ignore require-yield - *switchBranch(_request: GitSwitchRequest): Operation { + *switchBranch(_invocation: GitSwitchInvocation): Operation { throw new GitCompositionProviderError(""); }, // deno-lint-ignore require-yield - *addPaths(_request: GitAddRequest): Operation { + *addPaths(_invocation: GitAddInvocation): Operation { throw new GitCompositionProviderError(""); }, // deno-lint-ignore require-yield - *commitIndex(_request: GitCommitRequest): Operation { + *commitIndex(_invocation: GitCommitInvocation): Operation { throw new GitCompositionProviderError(""); }, // deno-lint-ignore require-yield - *pushCurrentBranch(_request: GitPushRequest): Operation { + *pushCurrentBranch(_invocation: GitPushInvocation): Operation { throw new GitCompositionProviderError(""); }, }, diff --git a/packages/workflow/src/composition/git-push-records.ts b/packages/workflow/src/composition/git-push-records.ts index 944770ed0..920b5703c 100644 --- a/packages/workflow/src/composition/git-push-records.ts +++ b/packages/workflow/src/composition/git-push-records.ts @@ -10,13 +10,13 @@ * * ## The Repository travels filtered * - * The whole retained `RepositoryRecord` is what the provider authenticates a - * live invocation against, and it carries `checkoutPath` — a place inside the - * run's own Workspace. A Git host has no business holding one, and the journal - * has no reason to repeat it under a second name, so the identity that reaches - * durable JSON is the record without it. Omitting it weakens nothing: the live - * check still compares the complete record member for member, and the six - * members that remain already discriminate every Repository this run can hold. + * The whole retained `RepositoryRecord` carries `checkoutPath` — a place inside + * the run's own Workspace. A Git host has no business holding one, and the + * journal has no reason to repeat it under a second name, so what reaches + * durable JSON is the `RepositoryIdentity` a selection already carries. + * Omitting the path weakens nothing: the live check still compares the complete + * record member for member, and the six members that remain already + * discriminate every Repository this run can hold. * * ## The source commit is not part of the natural key * @@ -30,8 +30,14 @@ import type { Json } from "@executablemd/durable-streams"; import type { GitHostDecision, GitHostReconciliationRecord } from "../git-host/records.ts"; -import { members, optionalText, text } from "./parse.ts"; -import { parseObjectFormat, type GitObjectFormat, type RepositoryRecord } from "./records.ts"; +import { members, text } from "./parse.ts"; +import type { GitObjectFormat, RepositoryRecord } from "./records.ts"; +import { + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, + type RepositoryIdentity, +} from "./selection.ts"; /** The Git-host effect kind one branch publication is reconciled under. */ export const GIT_PUSH = "git-push"; @@ -49,20 +55,6 @@ export function refspecFor(sourceCommit: string, destinationRef: string): string return `${sourceCommit}:${destinationRef}`; } -/** - * The Repository identity durable Push JSON carries. - * - * The retained creation record without its Workspace checkout path. - */ -export interface GitPushRepositoryIdentity { - readonly name: string; - readonly locatorFingerprint: string; - readonly requestedBase: string | null; - readonly creationCommit: string; - readonly primaryBranch: string; - readonly objectFormat: GitObjectFormat; -} - /** What a `` invocation asks the provider to do. */ export interface GitPushRequest { /** The whole Repository record the component observed, to be compared. */ @@ -73,7 +65,7 @@ export interface GitPushRequest { /** The filtered inputs one Push reconciliation acts on. */ export interface GitPushInputs { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly remote: string; readonly branch: string; readonly destinationRef: string; @@ -82,7 +74,7 @@ export interface GitPushInputs { /** What the provider looks a Push up by: the branch on the remote, and nothing else. */ export interface GitPushNaturalKey { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly remote: string; readonly destinationRef: string; } @@ -114,7 +106,7 @@ export interface GitPushObservations { /** What a reconciled Push retains. */ export interface GitPushResult { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly remote: string; readonly branch: string; readonly destinationRef: string; @@ -129,15 +121,6 @@ export interface GitPushOutcome { readonly result: GitPushResult; } -const IDENTITY_MEMBERS = [ - "name", - "locatorFingerprint", - "requestedBase", - "creationCommit", - "primaryBranch", - "objectFormat", -] as const; - const INPUT_MEMBERS = ["repository", "remote", "branch", "destinationRef", "sourceCommit"] as const; const NATURAL_KEY_MEMBERS = ["repository", "remote", "destinationRef"] as const; @@ -173,32 +156,9 @@ export function gitObjectId(value: unknown, format: GitObjectFormat): string | u : undefined; } -/** The retained record, filtered to what durable Push JSON may carry. */ -export function filteredRepositoryIdentity(record: RepositoryRecord): GitPushRepositoryIdentity { - return Object.freeze({ - name: record.name, - locatorFingerprint: record.locatorFingerprint, - requestedBase: record.requestedBase, - creationCommit: record.creationCommit, - primaryBranch: record.primaryBranch, - objectFormat: record.objectFormat, - }); -} - -export function gitPushRepositoryIdentityJson(identity: GitPushRepositoryIdentity): Json { - return { - name: identity.name, - locatorFingerprint: identity.locatorFingerprint, - requestedBase: identity.requestedBase, - creationCommit: identity.creationCommit, - primaryBranch: identity.primaryBranch, - objectFormat: identity.objectFormat, - }; -} - export function gitPushInputsJson(inputs: GitPushInputs): Json { return { - repository: gitPushRepositoryIdentityJson(inputs.repository), + repository: repositoryIdentityJson(inputs.repository), remote: inputs.remote, branch: inputs.branch, destinationRef: inputs.destinationRef, @@ -208,7 +168,7 @@ export function gitPushInputsJson(inputs: GitPushInputs): Json { export function gitPushNaturalKeyJson(key: GitPushNaturalKey): Json { return { - repository: gitPushRepositoryIdentityJson(key.repository), + repository: repositoryIdentityJson(key.repository), remote: key.remote, destinationRef: key.destinationRef, }; @@ -229,7 +189,7 @@ export function gitPushObservationsJson(observations: GitPushObservations): Json export function gitPushResultJson(result: GitPushResult): Json { return { - repository: gitPushRepositoryIdentityJson(result.repository), + repository: repositoryIdentityJson(result.repository), remote: result.remote, branch: result.branch, destinationRef: result.destinationRef, @@ -239,49 +199,6 @@ export function gitPushResultJson(result: GitPushResult): Json { }; } -/** The filtered Repository identity this value describes, or `undefined`. */ -export function parseGitPushRepositoryIdentity( - value: unknown, -): GitPushRepositoryIdentity | undefined { - const record = members(value, IDENTITY_MEMBERS); - if (record === undefined) { - return undefined; - } - const name = text(record.name); - const locatorFingerprint = text(record.locatorFingerprint); - const requestedBase = optionalText(record.requestedBase); - const creationCommit = text(record.creationCommit); - const primaryBranch = text(record.primaryBranch); - const objectFormat = parseObjectFormat(record.objectFormat); - if ( - name === undefined || - locatorFingerprint === undefined || - !/^[0-9a-f]{64}$/.test(locatorFingerprint) || - requestedBase === undefined || - creationCommit === undefined || - primaryBranch === undefined || - objectFormat === undefined - ) { - return undefined; - } - return Object.freeze({ - name, - locatorFingerprint, - requestedBase, - creationCommit, - primaryBranch, - objectFormat, - }); -} - -/** Whether two filtered identities name the same Repository. */ -export function sameRepositoryIdentity( - left: GitPushRepositoryIdentity, - right: GitPushRepositoryIdentity, -): boolean { - return IDENTITY_MEMBERS.every((member) => left[member] === right[member]); -} - /** * What a Push result is read back for. * @@ -290,7 +207,7 @@ export function sameRepositoryIdentity( * could be called without one would be checking a value against itself. */ export interface GitPushExpectation { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly branch: string; readonly destinationRef: string; readonly sourceCommit: string; @@ -312,7 +229,7 @@ export function parseGitPushInputs(value: unknown): GitPushInputs | undefined { if (record === undefined) { return undefined; } - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const remote = text(record.remote); const branch = text(record.branch); const destinationRef = text(record.destinationRef); @@ -336,7 +253,7 @@ export function parseGitPushNaturalKey(value: unknown): GitPushNaturalKey | unde if (record === undefined) { return undefined; } - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const remote = text(record.remote); const destinationRef = text(record.destinationRef); if ( @@ -414,7 +331,7 @@ export function parseGitPushResult( return undefined; } const format = expected.repository.objectFormat; - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const remote = text(record.remote); const branch = text(record.branch); const destinationRef = text(record.destinationRef); diff --git a/packages/workflow/src/composition/installation.ts b/packages/workflow/src/composition/installation.ts index b978f8e8f..7701055dd 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/workflow/src/composition/installation.ts @@ -1,17 +1,28 @@ /** - * Registers the composition components as ordinary defaults. + * The thirteen repository-composition components, as one array of ordinary + * declarations. * - * Repository, Worktree, Dir, the Git operations, PullRequest, IssueTracker and - * Issue are ordinary - * registered defaults — not reserved and not structural — so a repository-local component - * may shadow one for its own scope, and the workflow host installs them only - * for a live or partial attachment. A completed root replay does not attach any - * provider, so a document that already ran through completion does not - * re-register them either. + * Repository, Worktree, Dir, the four Git operations, PullRequest and its three + * evidence reads, IssueTracker and Issue are ordinary registered defaults — not + * reserved and not structural — so a repository-local component may shadow one + * for its own scope. + * + * One array, three consumers, because three descriptions of one vocabulary + * would drift. `useCompositionComponents()` registers it inside a workflow + * attachment; `useRunProfileRegistry()` registers it for `xmd syntax` and for + * `xmd plan`'s validation and generation; `installDocumentComponents()` + * registers it for an ordinary run. Registering it installs no provider, + * performs no repository discovery, acquires no lock and reaches no network: + * what a name *does* is the installed provider's, and describing the + * environment mints none. + * + * A completed root replay attaches no provider and registers nothing, so a + * document that already ran through completion re-registers none of these. */ import type { Operation } from "effection"; -import { formDispatcher, registerComponents } from "@executablemd/core"; +import { documented, formDispatcher, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; import { COMPOSITION_ORIGIN, dirDefinition } from "./definitions.ts"; import Repository, { props as repositoryProps } from "./components/Repository.ts"; import Worktree, { props as worktreeProps } from "./components/Worktree.ts"; @@ -38,94 +49,230 @@ import { } from "./components/PullRequestReads.ts"; import IssueTracker, { props as issueTrackerProps } from "./components/IssueTracker.ts"; +// The same definition the generated-XMD write table pins, so the ordinary +// component and the pinned identity cannot drift apart. +const dir = dirDefinition(); + +/** The one vocabulary every consumer of these components describes. */ +export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ + { + name: "Repository", + origin: COMPOSITION_ORIGIN, + props: repositoryProps, + fn: Repository, + ...documented({ + description: + "Work in a Git repository by name and url. " + + '`` clones it once, ' + + "then expands its content with that checkout as the working directory. Written " + + '`` it renders nothing and binds ' + + "the checkout path instead. A second invocation naming the same repository and url " + + "reuses the same checkout, keeping the commits, branches and uncommitted work the " + + "first one left there.", + as: "Optional. The path of the selected checkout.", + context: "The Markdown expanded in that checkout.", + }), + }, + { + name: "Worktree", + origin: COMPOSITION_ORIGIN, + props: worktreeProps, + fn: Worktree, + ...documented({ + description: + "Work on a branch in a linked checkout of its own. " + + '`` creates the branch when ' + + "it is missing and checks it out beside the repository, so several branches are open " + + "at once without one switch disturbing another. `branch` is required and `name` never " + + "selects one. Written outside a `` it belongs to the repository the " + + "command was run in, where the host has one.", + as: "Optional. The path of the linked checkout.", + context: "The Markdown expanded in that checkout.", + }), + }, + { + name: dir.name, + origin: COMPOSITION_ORIGIN, + props: dir.props, + fn: dir.fn, + ...documented({ + description: + "Run its content in another directory. " + + "`` expands the Markdown inside with `path` as the " + + "working directory, and restores the enclosing one afterwards. A relative path is " + + "read against the directory already in effect. It selects no repository: Git " + + "elements inside still belong to the enclosing ``.", + as: null, + context: "The Markdown expanded in that directory.", + }), + }, + { + name: "Git.Switch", + origin: COMPOSITION_ORIGIN, + props: gitSwitchProps, + fn: GitSwitch, + ...documented({ + description: + "Put the checkout on a named branch. " + + '`` switches to the branch, creating ' + + "it at `base` when it does not exist yet. A branch another checkout already holds, " + + "and local changes the switch would overwrite, are both refused rather than forced.", + as: null, + context: null, + }), + }, + { + name: "Git.Add", + origin: COMPOSITION_ORIGIN, + props: gitAddProps, + fn: GitAdd, + ...documented({ + description: + "Stage exactly the paths you name. " + + '`` stages them as written, from ' + + "the directory the element appears in. `paths` is a Git pathspec and is required; " + + '`"."` is how a document says everything here.', + as: null, + context: null, + }), + }, + { + name: "Git.Commit", + origin: COMPOSITION_ORIGIN, + props: gitCommitProps, + returns: gitCommitReturns, + fn: GitCommit, + ...documented({ + description: + "Commit what is staged, and hand back the commit. " + + '`` commits the index ' + + "alone — nothing is staged for it and nothing is amended. Content expands first and " + + "becomes the message body, so a `` written inside stages before the commit " + + "exists. An index that already matches HEAD is refused rather than committed empty.", + as: "Optional. The full object id of the commit.", + context: "The message body, expanded before the commit is made.", + }), + }, + { + name: "Git.Push", + origin: COMPOSITION_ORIGIN, + props: gitPushProps, + fn: GitPush, + ...documented({ + description: + "Publish the checkout's current branch to its origin. " + + "`` takes no props: the remote is the repository's `origin`, the branch " + + "is the one the checkout is on, and the commit is the one that branch points at. It " + + "never force-pushes and changes no upstream tracking; a destination naming a commit " + + "this run did not publish from is refused.", + as: null, + context: null, + }), + }, + { + name: "PullRequest", + origin: COMPOSITION_ORIGIN, + props: pullRequestProps, + returns: pullRequestReturns, + fn: PullRequest, + ...documented({ + description: + "Open a pull request for the branch this run published, or bring one up to date. " + + '`` asks for one ' + + "pull request from the checkout's branch to `base` to exist; with `number` it updates " + + "that pull request instead. The content is the body. It publishes nothing itself: " + + "write `` first, and this run must hold that push's own successful " + + "result for the same branch and commit.", + as: "Optional. The pull request's repository, number, url, state and head and base commits.", + context: "The pull request's body.", + }), + }, + { + name: "PullRequest.Reviews", + origin: COMPOSITION_ORIGIN, + props: pullRequestReadProps, + returns: reviewsReturns, + fn: formDispatcher(reviewsForm), + ...documented({ + description: + "Read the reviews a pull request holds. " + + '`` binds one array to ' + + "iterate with ``, so an objection reaches an agent's prompt. The url is the " + + "identity — there is no repository or number prop, and no `` to be " + + "inside of. `as` is required.", + as: "Required. Each review's author, state, body, submission time, commit and url.", + context: null, + }), + }, + { + name: "PullRequest.Comments", + origin: COMPOSITION_ORIGIN, + props: pullRequestReadProps, + returns: commentsReturns, + fn: formDispatcher(commentsForm), + ...documented({ + description: + "Read the comments a pull request holds. " + + '`` binds one array of ' + + "both conversation comments and review comments, each saying which kind it is. The " + + "url is the identity. `as` is required.", + as: "Required. Each comment's kind, author, body, timestamps and url, and a review comment's file, hunk and line.", + context: null, + }), + }, + { + name: "PullRequest.Checks", + origin: COMPOSITION_ORIGIN, + props: pullRequestReadProps, + returns: checksReturns, + fn: formDispatcher(checksForm), + ...documented({ + description: + "Read the checks reported against a pull request's head. " + + '`` binds one array of both ' + + "check runs and commit statuses, each saying which kind it is. The url is the " + + "identity. `as` is required.", + as: "Required. Each check's kind, name, head commit and outcome.", + context: null, + }), + }, + { + name: "IssueTracker", + origin: COMPOSITION_ORIGIN, + props: issueTrackerProps, + fn: IssueTracker, + ...documented({ + description: + "Say which tracker the issues in its content are filed in. " + + "`` names the container new issues " + + "are created in — a GitHub repository's issues, an Atlassian project. `provider` " + + "names the only adapter allowed to act on it, for a url nobody recognizes. A nested " + + "tracker replaces the whole target for its own content rather than merging with it.", + as: null, + context: "The Markdown whose issues are filed there.", + }), + }, + { + name: "Issue", + origin: COMPOSITION_ORIGIN, + props: issueProps, + returns: issueReturns, + fn: Issue, + ...documented({ + description: + "Read an issue by url, or file one in the tracker in scope. " + + '`` reads the one that url names and needs ' + + 'no tracker. `` inside an ' + + "`` files an issue whose content is its description, creating it once " + + "and bringing it up to date afterwards. Which of the two it is, is decided by the " + + "spelling: a url reads, a title files.", + as: "Required for a read, which binds url, title, description, tags and assignee. A file binds the url alone.", + context: "The issue's description, for the form that files one.", + }), + }, +]; + +/** Register the composition vocabulary as ordinary defaults for this scope. */ export function useCompositionComponents(): Operation { - // The same definition the generated-XMD write table pins, so the ordinary - // component and the pinned identity cannot drift apart. - const dir = dirDefinition(); - return registerComponents([ - { - name: "Repository", - origin: COMPOSITION_ORIGIN, - props: repositoryProps, - fn: Repository, - }, - { - name: "Worktree", - origin: COMPOSITION_ORIGIN, - props: worktreeProps, - fn: Worktree, - }, - { - name: dir.name, - origin: COMPOSITION_ORIGIN, - props: dir.props, - fn: dir.fn, - }, - { - name: "Git.Switch", - origin: COMPOSITION_ORIGIN, - props: gitSwitchProps, - fn: GitSwitch, - }, - { - name: "Git.Add", - origin: COMPOSITION_ORIGIN, - props: gitAddProps, - fn: GitAdd, - }, - { - name: "Git.Commit", - origin: COMPOSITION_ORIGIN, - props: gitCommitProps, - returns: gitCommitReturns, - fn: GitCommit, - }, - { - name: "Git.Push", - origin: COMPOSITION_ORIGIN, - props: gitPushProps, - fn: GitPush, - }, - { - name: "PullRequest", - origin: COMPOSITION_ORIGIN, - props: pullRequestProps, - returns: pullRequestReturns, - fn: PullRequest, - }, - { - name: "PullRequest.Reviews", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: reviewsReturns, - fn: formDispatcher(reviewsForm), - }, - { - name: "PullRequest.Comments", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: commentsReturns, - fn: formDispatcher(commentsForm), - }, - { - name: "PullRequest.Checks", - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: checksReturns, - fn: formDispatcher(checksForm), - }, - { - name: "IssueTracker", - origin: COMPOSITION_ORIGIN, - props: issueTrackerProps, - fn: IssueTracker, - }, - { - name: "Issue", - origin: COMPOSITION_ORIGIN, - props: issueProps, - returns: issueReturns, - fn: Issue, - }, - ]); + return registerComponents(COMPOSITION_REGISTRATIONS); } diff --git a/packages/workflow/src/composition/pull-request-api.ts b/packages/workflow/src/composition/pull-request-api.ts index aa81c327d..1a0c318f7 100644 --- a/packages/workflow/src/composition/pull-request-api.ts +++ b/packages/workflow/src/composition/pull-request-api.ts @@ -49,7 +49,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; import type { PullRequestReadKind, PullRequestReadResult } from "./pull-request-read-records.ts"; import type { PullRequestResult } from "./pull-request-records.ts"; -import type { RepositoryRecord } from "./records.ts"; +import type { RepositorySelection } from "./selection.ts"; /** The stable name every loaded copy composes through. */ export const PULL_REQUEST_API = "executablemd.workflow.pull-request"; @@ -81,12 +81,12 @@ export interface PullRequestInput { * Where the pull request goes, and what the provider needs to get it there. * * Unlike a read, an upsert is about a branch in a checkout this run holds, so - * the Repository record and the working directory the component observed travel - * with it. They are what the selected provider authenticates against the run's - * own retained state before it publishes anything. + * the Repository selection and the working directory the component observed + * travel with it. The selected provider authenticates the selection against its + * own private state before it publishes anything. */ export interface PullRequestUpsertOptions { - readonly repository: RepositoryRecord; + readonly repository: RepositorySelection; readonly workingDirectory: string; /** The explicit discriminator, when the document named one. */ readonly provider?: string; diff --git a/packages/workflow/src/composition/pull-request-operations.ts b/packages/workflow/src/composition/pull-request-operations.ts new file mode 100644 index 000000000..978cff39e --- /dev/null +++ b/packages/workflow/src/composition/pull-request-operations.ts @@ -0,0 +1,108 @@ +/** + * The profile-level pull-request Api: what the four components ask, before any + * transport hears about it. + * + * `PullRequestAPI` is the transport surface — GitHub's middleware matches the + * URLs it recognizes, holds a read to the host's ceiling, reconciles a create + * or an update, and normalizes what comes back. This is the layer above it, and + * what it owns is *lifecycle and authority*: whether an answer is retained, + * what proves this run published the branch a pull request would name, and what + * a second execution inherits. + * + * The two profiles answer differently, which is why the seam exists. + * + * A workflow run retains a read as a durable effect and reconciles an upsert as + * a Git-host effect, both keyed by its WorkflowRun and expansion, and it proves + * publication by scanning its own journal for the matching successful + * `` record. A replayed run reaches nothing. + * + * An ordinary `xmd run` retains nothing. It reads afresh every execution, and + * it proves publication from evidence its own provider instance stored when it + * verified a Push — held in the provider's closure, for this invocation only. + * Copying a Context value, a component result or a previous `--journal` file + * grants nothing, because none of them is where the evidence lives. + * + * The default handler throws, so a host that installed neither profile is + * distinguishable from one whose pull request is merely unreachable. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { PullRequestInput } from "./pull-request-api.ts"; +import type { PullRequestReadKind, PullRequestReadResult } from "./pull-request-read-records.ts"; +import type { PullRequestResult } from "./pull-request-records.ts"; +import type { RepositorySelection } from "./selection.ts"; + +/** The stable name every loaded copy composes through. */ +export const PULL_REQUEST_OPERATIONS = "executablemd.workflow.composition.pull-request-operations"; + +/** Which collection a read wants, and where it may be sent. */ +export interface PullRequestReadInvocation { + /** The canonical pull-request URL. */ + readonly url: string; + /** Which of the three collections this read is for. */ + readonly kind: PullRequestReadKind; + /** The explicit discriminator, for a self-hosted or non-standard URL. */ + readonly provider: string | undefined; +} + +/** + * Where the pull request goes, and what the provider needs to get it there. + * + * Unlike a read, an upsert is about a branch in a checkout this run holds, so + * the Repository selection and the working directory the component observed + * travel with it. They are what the selected provider authenticates before it + * publishes anything. + */ +export interface PullRequestUpsertInvocation { + readonly pullRequest: PullRequestInput; + readonly repository: RepositorySelection; + readonly workingDirectory: string; +} + +/** No profile installed a pull-request lifecycle in this scope. */ +export class PullRequestOperationsProviderError extends Error { + override name = "PullRequestOperationsProviderError"; + + constructor(operation: string) { + super( + `no pull-request provider is installed, so ${operation} cannot answer. The Deno and ` + + "compiled `xmd run` entrypoints install the ordinary one; a workflow host installs the " + + "retained one for a live or partial execution.", + ); + } +} + +export interface PullRequestOperationsApi { + /** Read one collection the pull request this invocation names already holds. */ + read(invocation: PullRequestReadInvocation): Operation; + + /** + * Create or bring up to date one pull request for the selected checkout. + * + * Answers with the identity #295 settled, unchanged by this surface: the + * filtered Repository identity, the provider's own stable identity, the + * number, the URL, the open state, and the head and base commits the + * reconciliation finished at. + */ + upsert(invocation: PullRequestUpsertInvocation): Operation; +} + +export const PullRequestOperations: Api = + createApi(PULL_REQUEST_OPERATIONS, { + // deno-lint-ignore require-yield + *read(invocation: PullRequestReadInvocation): Operation { + throw new PullRequestOperationsProviderError( + `a `, + ); + }, + // deno-lint-ignore require-yield + *upsert(_invocation: PullRequestUpsertInvocation): Operation { + throw new PullRequestOperationsProviderError(""); + }, + }); + +/** The element name a read of this collection is written as. */ +function collection(kind: PullRequestReadKind): string { + return `${kind.charAt(0).toUpperCase()}${kind.slice(1)}`; +} diff --git a/packages/workflow/src/composition/pull-request-records.ts b/packages/workflow/src/composition/pull-request-records.ts index bf0dbc7bf..adb58c173 100644 --- a/packages/workflow/src/composition/pull-request-records.ts +++ b/packages/workflow/src/composition/pull-request-records.ts @@ -49,15 +49,15 @@ import type { Json } from "@executablemd/durable-streams"; import type { GitHostDecision, GitHostReconciliationRecord } from "../git-host/records.ts"; import { members, text } from "./parse.ts"; -import { - gitObjectId, - gitPushRepositoryIdentityJson, - parseGitPushRepositoryIdentity, - sameRepositoryIdentity, - type GitPushRepositoryIdentity, -} from "./git-push-records.ts"; +import { gitObjectId } from "./git-push-records.ts"; import type { GitObjectFormat, RepositoryRecord } from "./records.ts"; +import { + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, +} from "./selection.ts"; +import type { RepositoryIdentity } from "./selection.ts"; /** The Git-host effect kind one pull-request upsert is reconciled under. */ export const PULL_REQUEST = "pull-request"; @@ -85,7 +85,7 @@ export interface PullRequestRequest { /** The filtered inputs one pull-request reconciliation acts on. */ export interface PullRequestInputs { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; /** * The pull request this asks for by number, or `null` when it asks for one to * exist. @@ -106,7 +106,7 @@ export interface PullRequestInputs { /** What the provider looks an unnumbered request up by: the branch pair. */ export interface PullRequestCreateKey { readonly mode: "create"; - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly headBranch: string; readonly baseBranch: string; } @@ -114,7 +114,7 @@ export interface PullRequestCreateKey { /** What the provider looks a numbered request up by: that exact number. */ export interface PullRequestUpdateKey { readonly mode: "update"; - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly number: number; } @@ -155,7 +155,7 @@ export interface PullRequestObservations { /** What a reconciled pull request retains, and what a document binds. */ export interface PullRequestResult { - readonly repository: GitPushRepositoryIdentity; + readonly repository: RepositoryIdentity; readonly providerId: string; readonly number: number; readonly url: string; @@ -254,7 +254,7 @@ export function pullRequestMode(inputs: PullRequestInputs): PullRequestMode { export function pullRequestInputsJson(inputs: PullRequestInputs): Json { return { - repository: gitPushRepositoryIdentityJson(inputs.repository), + repository: repositoryIdentityJson(inputs.repository), number: inputs.number, title: inputs.title, body: inputs.body, @@ -269,13 +269,13 @@ export function pullRequestNaturalKeyJson(key: PullRequestNaturalKey): Json { return key.mode === "create" ? { mode: key.mode, - repository: gitPushRepositoryIdentityJson(key.repository), + repository: repositoryIdentityJson(key.repository), headBranch: key.headBranch, baseBranch: key.baseBranch, } : { mode: key.mode, - repository: gitPushRepositoryIdentityJson(key.repository), + repository: repositoryIdentityJson(key.repository), number: key.number, }; } @@ -309,7 +309,7 @@ export function pullRequestObservationsJson(observations: PullRequestObservation export function pullRequestResultJson(result: PullRequestResult): Json { return { - repository: gitPushRepositoryIdentityJson(result.repository), + repository: repositoryIdentityJson(result.repository), providerId: result.providerId, number: result.number, url: result.url, @@ -403,7 +403,7 @@ export function parsePullRequestInputs(value: unknown): PullRequestInputs | unde if (record === undefined) { return undefined; } - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const number = optionalNumber(record.number); const title = text(record.title); const body = bodyText(record.body); @@ -451,7 +451,7 @@ export function parsePullRequestNaturalKey(value: unknown): PullRequestNaturalKe if (record === undefined) { return undefined; } - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const headBranch = text(record.headBranch); const baseBranch = text(record.baseBranch); if (repository === undefined || headBranch === undefined || baseBranch === undefined) { @@ -466,7 +466,7 @@ export function parsePullRequestNaturalKey(value: unknown): PullRequestNaturalKe if (record === undefined) { return undefined; } - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const number = pullRequestNumber(record.number); if (repository === undefined || number === undefined) { return undefined; @@ -593,7 +593,7 @@ export function parsePullRequestResult( return undefined; } const format = expected.repository.objectFormat; - const repository = parseGitPushRepositoryIdentity(record.repository); + const repository = parseRepositoryIdentity(record.repository); const providerId = text(record.providerId); const number = pullRequestNumber(record.number); const url = text(record.url); diff --git a/packages/workflow/src/composition/push-evidence.ts b/packages/workflow/src/composition/push-evidence.ts index fc536788f..e519f6a1d 100644 --- a/packages/workflow/src/composition/push-evidence.ts +++ b/packages/workflow/src/composition/push-evidence.ts @@ -38,11 +38,11 @@ import { parseGitPushNaturalKey, parseGitPushRecord, pushExpectation, - sameRepositoryIdentity, } from "./git-push-records.ts"; import { PullRequestAuthorityError } from "./errors.ts"; import type { PullRequestInputs } from "./pull-request-records.ts"; +import { sameRepositoryIdentity } from "./selection.ts"; function refuse(reason: "missing" | "conflicting" | "unreadable"): never { if (reason === "missing") { throw new PullRequestAuthorityError( diff --git a/packages/workflow/src/composition/selection.ts b/packages/workflow/src/composition/selection.ts new file mode 100644 index 000000000..9975a3a40 --- /dev/null +++ b/packages/workflow/src/composition/selection.ts @@ -0,0 +1,192 @@ +/** + * What a component may observe about the repository it is acting on. + * + * A `` has to know *something* about the checkout it commits in — + * which repository it belongs to, which directory holds it, what its initial + * branch was called. It must not know how that checkout came to exist. A + * workflow run's checkout is a row in a database, restored from a retained + * Workspace under a WorkflowRun the document must never be able to name; an + * ordinary run's checkout is a directory on the caller's own filesystem, held + * open by an advisory lock the provider owns. The components are the same + * components either way, so what they observe is this: plain structural data, + * carrying the portable facts and nothing else. + * + * ## A selection names a target; it grants nothing + * + * `selection` is an opaque string the installed provider minted and only that + * provider can read. Every operation authenticates the selection it was handed + * against private state before it touches Git or a service, so a selection that + * was copied, replaced or reconstructed can misname a target and cause a + * refusal — it cannot grant access to one. That is the same rule the retained + * Repository context has always followed, restated for a value two profiles + * share. + * + * The rest is what a document can already see. The name is the one it wrote or + * the ambient repository's own; the identity is credential-free by + * construction; the checkout path is a place a `` may already be standing. + * None of it is authority, so retaining it, rendering it or handing it to a + * child costs nothing. + */ + +import type { Json } from "@executablemd/durable-streams"; +import { members, optionalText, text } from "./parse.ts"; +import { parseObjectFormat, type GitObjectFormat, type RepositoryRecord } from "./records.ts"; + +/** + * A repository named without publishing where it came from. + * + * The locator is present as a fingerprint alone, so a credential that slipped + * into a URL is not repeated here, in retained Push JSON, or in the evidence a + * `` binds. Everything else is a fact about the repository itself: + * the commit it was selected at, the branch it started on, and the algorithm it + * names its objects with. + */ +export interface RepositoryIdentity { + /** Workspace-local or ambient display name. */ + readonly name: string; + /** Stable fingerprint of the admitted credential-free locator. */ + readonly locatorFingerprint: string; + /** The base a caller supplied, or `null` when none was. */ + readonly requestedBase: string | null; + /** The commit this repository's identity was pinned at. */ + readonly creationCommit: string; + /** The initial branch — a Repository's primary, an ambient one's default. */ + readonly primaryBranch: string; + readonly objectFormat: GitObjectFormat; +} + +export const REPOSITORY_IDENTITY_MEMBERS = [ + "name", + "locatorFingerprint", + "requestedBase", + "creationCommit", + "primaryBranch", + "objectFormat", +] as const; + +/** + * One repository selected for one component invocation. + * + * Immutable and comparable. Two selections of the same target in one execution + * carry the same `selection`, which is what lets a provider recognize the lease + * it is already holding rather than acquiring a second one. + */ +export interface RepositorySelection { + /** + * The installed provider's own opaque name for this selection. + * + * Meaningful only to the provider that minted it, and never derived from + * anything a document wrote. A provider that does not recognize one refuses. + */ + readonly selection: string; + /** What a document named this repository, or the ambient one's display name. */ + readonly name: string; + /** The credential-free identity every operation and every record carries. */ + readonly identity: RepositoryIdentity; + /** The checkout this selection points at, as the host resolves paths. */ + readonly checkoutPath: string; +} + +const SELECTION_MEMBERS = ["selection", "name", "identity", "checkoutPath"] as const; + +/** The retained record, filtered to the identity a selection carries. */ +export function filteredRepositoryIdentity(record: RepositoryRecord): RepositoryIdentity { + return Object.freeze({ + name: record.name, + locatorFingerprint: record.locatorFingerprint, + requestedBase: record.requestedBase, + creationCommit: record.creationCommit, + primaryBranch: record.primaryBranch, + objectFormat: record.objectFormat, + }); +} + +export function repositoryIdentityJson(identity: RepositoryIdentity): Json { + return { + name: identity.name, + locatorFingerprint: identity.locatorFingerprint, + requestedBase: identity.requestedBase, + creationCommit: identity.creationCommit, + primaryBranch: identity.primaryBranch, + objectFormat: identity.objectFormat, + }; +} + +/** The identity this value describes, or `undefined` when it describes none. */ +export function parseRepositoryIdentity(value: unknown): RepositoryIdentity | undefined { + const record = members(value, REPOSITORY_IDENTITY_MEMBERS); + if (record === undefined) { + return undefined; + } + const name = text(record.name); + const locatorFingerprint = text(record.locatorFingerprint); + const requestedBase = optionalText(record.requestedBase); + const creationCommit = text(record.creationCommit); + const primaryBranch = text(record.primaryBranch); + const objectFormat = parseObjectFormat(record.objectFormat); + if ( + name === undefined || + locatorFingerprint === undefined || + !/^[0-9a-f]{64}$/.test(locatorFingerprint) || + requestedBase === undefined || + creationCommit === undefined || + primaryBranch === undefined || + objectFormat === undefined + ) { + return undefined; + } + return Object.freeze({ + name, + locatorFingerprint, + requestedBase, + creationCommit, + primaryBranch, + objectFormat, + }); +} + +/** Whether two identities name the same repository. */ +export function sameRepositoryIdentity( + left: RepositoryIdentity, + right: RepositoryIdentity, +): boolean { + return REPOSITORY_IDENTITY_MEMBERS.every((member) => left[member] === right[member]); +} + +/** + * The selection this value describes, or `undefined` when it describes none. + * + * Total, and exact about membership, for the reason every parser in this + * package is: a selection may arrive from a caller reaching the Api directly, + * and a value carrying more or fewer members than the contract declares + * describes something other than a selection. + */ +export function parseRepositorySelection(value: unknown): RepositorySelection | undefined { + const record = members(value, SELECTION_MEMBERS); + if (record === undefined) { + return undefined; + } + const selection = text(record.selection); + const name = text(record.name); + const identity = parseRepositoryIdentity(record.identity); + const checkoutPath = text(record.checkoutPath); + if ( + selection === undefined || + name === undefined || + identity === undefined || + checkoutPath === undefined + ) { + return undefined; + } + return Object.freeze({ selection, name, identity, checkoutPath }); +} + +/** The selection a provider hands back, frozen so a holder cannot edit one. */ +export function repositorySelection( + selection: string, + name: string, + identity: RepositoryIdentity, + checkoutPath: string, +): RepositorySelection { + return Object.freeze({ selection, name, identity: Object.freeze(identity), checkoutPath }); +} diff --git a/packages/workflow/src/deno/composition/commit.ts b/packages/workflow/src/deno/composition/commit.ts index c3e74b7f9..c4163f535 100644 --- a/packages/workflow/src/deno/composition/commit.ts +++ b/packages/workflow/src/deno/composition/commit.ts @@ -116,7 +116,7 @@ function* describeCommit(admitted: GitCommitRequest): Operation` and a `` respectively create and attach; - * `effects.ts` owns the durable envelope both perform inside; `refusals.ts` - * owns the words a refusal travels in; `identity.ts` owns holding a retained - * record to the identity that names it. + * This is the wiring: it installs the two profile Apis whose operations belong + * to the two components' own modules. `repository.ts` and `worktree.ts` own what + * a `` and a `` respectively create and attach; + * `effects.ts` owns the durable envelope both perform inside; `refusals.ts` owns + * the words a refusal travels in; `identity.ts` owns holding a retained record + * to the identity that names it. * - * What is left here is the pairing of a creation with an attachment, and the - * one thing neither component can do alone: hold the transaction open only for - * the export, so a Git subprocess never keeps the run's database locked. + * What is left here is the pairing of a creation with an attachment, the + * translation between the profile-neutral selection a component observes and the + * `RepositoryRecord` this run retains, and the one thing neither component can + * do alone: hold the transaction open only for the export, so a Git subprocess + * never keeps the run's database locked. * - * ## Two halves per component, and why + * ## Two halves per selection, and why * * **Creation** is one durable Workspace effect. A completed one restores from * the journal: replay reaches no remote, spawns no Git and imports nothing. @@ -22,21 +24,41 @@ * record names is still there — which is what makes a partial replay safe to * continue from, and what discovers a checkout that has gone missing before any * child or later sibling begins. + * + * ## What a selection is worth here + * + * Nothing on its own. The record stays in this provider's closure, keyed by an + * opaque identifier, and every Git operation asks the registry what the + * selection it was handed names before it reads a row. A replaced contextual + * Repository can therefore misname a checkout and be refused; it cannot reach + * one. Answering `undefined` for the ambient Repository is the other half of + * that: a workflow document names every repository it touches, so an element + * written outside a `` has none and its own refusal says so. */ import { type Operation } from "effection"; import { RepositoryComposition } from "../../composition/api.ts"; +import type { RepositoryRequest, WorktreeRequest } from "../../composition/api.ts"; import { GitComposition } from "../../composition/git-api.ts"; import type { - GitAddRequest, + GitAddInvocation, + GitCommitInvocation, + GitPushInvocation, + GitSwitchInvocation, +} from "../../composition/git-api.ts"; +import type { GitAddResult, - GitCommitRequest, GitCommitResult, - GitSwitchRequest, GitSwitchResult, } from "../../composition/git-records.ts"; -import type { GitPushOutcome, GitPushRequest } from "../../composition/git-push-records.ts"; +import type { GitPushOutcome } from "../../composition/git-push-records.ts"; import type { RepositoryRecord, WorktreeRecord } from "../../composition/records.ts"; +import { + filteredRepositoryIdentity, + type RepositorySelection, +} from "../../composition/selection.ts"; +import { GitOperationAuthorityError, RepositorySelectionError } from "../../composition/errors.ts"; +import { selectionRegistry, type SelectionRegistry } from "../selections.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; import { transactWorkspaceRoots } from "../workspace/private.ts"; import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; @@ -95,6 +117,19 @@ export interface CompositionProviderOptions { * are facts about the program that is running. */ readonly helper?: HelperAssembly; + /** + * The registry the Repository and Git installations share. + * + * They are installed as two calls and must resolve one selection: `` + * is handed what `` minted. A caller that installs both passes the + * same registry to both, which is what `withWorkflowWorkspace()` does. + */ + readonly selections?: SelectionRegistry; +} + +/** The registry both installations share, when a caller supplied none. */ +export function workflowSelections(): SelectionRegistry { + return selectionRegistry(); } /** @@ -125,6 +160,56 @@ function* attach( } } +function hostOf(options: CompositionProviderOptions): RepositoryHost { + return ( + options.host ?? + denoRepositoryHost({ + ...(options.authentication === undefined ? {} : { authentication: options.authentication }), + ...(options.helper === undefined ? {} : { helper: options.helper }), + }) + ); +} + +/** + * A Git operation handed a selection this provider did not make. + * + * The same word `selectGitCheckout` uses for a Repository this run does not + * retain, because it is the same condition reached one step earlier: what the + * element observed does not name a checkout this run has. + */ +function unselected(operation: string): GitOperationAuthorityError { + return new GitOperationAuthorityError( + operation, + "the Repository in scope is not one this run selected, so it names no retained checkout", + ); +} + +/** The key a record is minted under: the whole creation identity, in order. */ +function repositoryKey(record: RepositoryRecord): string { + return [ + "repository", + record.name, + record.locatorFingerprint, + record.requestedBase ?? "", + record.creationCommit, + record.primaryBranch, + record.objectFormat, + record.checkoutPath, + ].join(""); +} + +function worktreeKey(record: WorktreeRecord): string { + return [ + "worktree", + record.repositoryName, + record.name, + record.requestedBranch, + record.requestedBase ?? "", + record.creationCommit, + record.checkoutPath, + ].join(""); +} + /** * Install this run's Repository composition for the current scope and below. * @@ -135,22 +220,19 @@ export function useRepositoryComposition( database: WorkflowRunDatabase, options: CompositionProviderOptions = {}, ): Operation { - const host = - options.host ?? - denoRepositoryHost({ - ...(options.authentication === undefined ? {} : { authentication: options.authentication }), - ...(options.helper === undefined ? {} : { helper: options.helper }), - }); + const host = hostOf(options); const observe = options.observe ?? {}; + const selections = options.selections ?? workflowSelections(); return RepositoryComposition.around( { - *createRepository([request]): Operation { + *selectRepository([request]: [RepositoryRequest]): Operation { observe.effect?.("repository", request.name); - return yield* createRepository(database, host, request); - }, - - *attachRepository([record]): Operation { + const record = yield* createRepository(database, host, { + name: request.name, + locator: request.locator, + base: request.base, + }); observe.attachment?.("repository", record.name); yield* attach( database, @@ -166,14 +248,33 @@ export function useRepositoryComposition( (git, attached) => repositoryDisagreement(git, attached, record.objectFormat, record.creationCommit), ); + return selections.mint( + repositoryKey(record), + record.name, + filteredRepositoryIdentity(record), + record.checkoutPath, + record, + ); }, - *createWorktree([request]): Operation { + *selectWorktree([repository, request]: [ + RepositorySelection, + WorktreeRequest, + ]): Operation { + // The owner is this provider's own record for the selection it was + // handed, never the selection's own words: a Worktree of a Repository + // nobody selected is exactly what a replaced context would ask for. + const owner = selections.authenticate( + repository, + () => new RepositorySelectionError(""), + ); observe.effect?.("worktree", request.name); - return yield* createWorktree(database, host, request); - }, - - *attachWorktree([record]): Operation { + const record = yield* createWorktree(database, host, { + repositoryName: owner.name, + name: request.name, + branch: request.branch, + base: request.base, + }); observe.attachment?.("worktree", record.name); yield* attach( database, @@ -188,6 +289,25 @@ export function useRepositoryComposition( ), (git, attached) => worktreeDisagreement(git, attached, record), ); + // The owner's identity, because that is the repository this checkout + // belongs to, and the worktree's own name and path, because that is + // which checkout of it this selection points at. + return selections.mint( + worktreeKey(record), + record.name, + filteredRepositoryIdentity(owner), + record.checkoutPath, + owner, + ); + }, + + // A workflow document names every repository it touches, so there is no + // ambient one for an element written outside a `` to mean. + // `undefined` rather than a refusal: which component was written, and + // what it needed a repository for, is the component's own sentence. + // deno-lint-ignore require-yield + *ambientRepository(): Operation { + return undefined; }, }, { at: "min" }, @@ -201,40 +321,59 @@ export function useRepositoryComposition( * Separate from the composition provider above and installed beside it, because * they answer different questions: that one owns what a checkout *is*, and this * one owns what may be done to one. Both are installed only where a Workspace is - * attached, so ordinary `xmd run` has neither. + * attached, so ordinary `xmd run` reaches neither — it installs its own. */ export function useGitComposition( database: WorkflowRunDatabase, options: CompositionProviderOptions = {}, ): Operation { - const host = - options.host ?? - denoRepositoryHost({ - ...(options.authentication === undefined ? {} : { authentication: options.authentication }), - ...(options.helper === undefined ? {} : { helper: options.helper }), - }); + const host = hostOf(options); const observe = options.observe ?? {}; + const selections = options.selections ?? workflowSelections(); return GitComposition.around( { - *switchBranch([request]: [GitSwitchRequest]): Operation { + *switchBranch([invocation]: [GitSwitchInvocation]): Operation { observe.effect?.("git", "switch"); - return yield* createGitSwitch(database, host, request); + return yield* createGitSwitch(database, host, { + repository: selections.authenticate(invocation.repository, () => + unselected(""), + ), + workingDirectory: invocation.workingDirectory, + branch: invocation.branch, + base: invocation.base, + }); }, - *addPaths([request]: [GitAddRequest]): Operation { + *addPaths([invocation]: [GitAddInvocation]): Operation { observe.effect?.("git", "add"); - return yield* createGitAdd(database, host, request); + return yield* createGitAdd(database, host, { + repository: selections.authenticate(invocation.repository, () => unselected("")), + workingDirectory: invocation.workingDirectory, + paths: invocation.paths, + }); }, - *commitIndex([request]: [GitCommitRequest]): Operation { + *commitIndex([invocation]: [GitCommitInvocation]): Operation { observe.effect?.("git", "commit"); - return yield* createGitCommit(database, host, request); + return yield* createGitCommit(database, host, { + repository: selections.authenticate(invocation.repository, () => + unselected(""), + ), + workingDirectory: invocation.workingDirectory, + message: invocation.message, + messageSource: invocation.messageSource, + }); }, - *pushCurrentBranch([request]: [GitPushRequest]): Operation { + *pushCurrentBranch([invocation]: [GitPushInvocation]): Operation { observe.effect?.("git", "push"); - return yield* createGitPush(database, host, request); + return yield* createGitPush(database, host, { + repository: selections.authenticate(invocation.repository, () => + unselected(""), + ), + workingDirectory: invocation.workingDirectory, + }); }, }, { at: "min" }, diff --git a/packages/workflow/src/deno/composition/pull-request-operations.ts b/packages/workflow/src/deno/composition/pull-request-operations.ts new file mode 100644 index 000000000..58c374d22 --- /dev/null +++ b/packages/workflow/src/deno/composition/pull-request-operations.ts @@ -0,0 +1,165 @@ +/** + * A workflow run's pull-request lifecycle. + * + * The four components ask `PullRequestOperations`; this is what a workflow host + * installs behind it, and what it adds to the transport underneath is + * durability. A read becomes one ordinary durable effect, so a completed one + * restores its snapshot without opening a session. An upsert is passed straight + * through to the middleware that reconciles it as a Git-host effect, because + * that reconciliation is already durable and already holds this run's own Push + * evidence. + * + * ## What one read retains + * + * Its input is the whole normalized request — operation, canonical URL, + * provider discriminator, collection, run and expansion — so a reader of the + * history knows what was asked, and a document edited to read a different URL + * or collection at that position is a different effect rather than one + * replaying the first answer. + * + * It is not a reconciled Git-host effect. There is no natural key, no pre-state + * and nothing to adopt: repeating a read is safe in the way repeating a write + * is not. + */ + +import { getExpansion, sourceDescription } from "@executablemd/core"; +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { EffectDescription, Json as DurableJson } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; +import { scoped } from "effection"; +import { PullRequestReadError } from "../../composition/errors.ts"; +import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import { + PullRequestOperations, + type PullRequestReadInvocation, + type PullRequestUpsertInvocation, +} from "../../composition/pull-request-operations.ts"; +import { + parsePullRequestReadResult, + pullRequestReadEnvelopeJson, + pullRequestReadRequestJson, + readRequest, +} from "../../composition/pull-request-read-records.ts"; +import type { + PullRequestReadKind, + PullRequestReadRequest, + PullRequestReadResult, +} from "../../composition/pull-request-read-records.ts"; +import type { PullRequestResult } from "../../composition/pull-request-records.ts"; +import { parseJsonValue } from "../../storage/members.ts"; +import { getWorkflowRun } from "../../run.ts"; +import { gitOperationFingerprint } from "./operations.ts"; + +/** The durable effect type one evidence read is retained under. */ +export const PULL_REQUEST_READ = "pull_request_read"; + +/** Which element a refusal names, by the collection it was reading. */ +const ELEMENT: Readonly> = Object.freeze({ + reviews: "", + comments: "", + checks: "", +}); + +function* describeRead(request: PullRequestReadRequest): Operation { + const expansion = yield* getExpansion(); + // The run is in the retained request, and deliberately not in this + // fingerprint. A fork is a different run reaching the same position with the + // same question, and a name that carried the run would make every inherited + // read a different effect — which is to say, unforkable. What the name has to + // separate is different *questions*, and the four members below are what a + // question is made of. + const configuration = gitOperationFingerprint([ + request.operation, + request.url, + request.provider, + request.kind, + ]); + return { + type: PULL_REQUEST_READ, + name: `${request.expansionId}:${configuration}`, + input: pullRequestReadRequestJson(request), + configuration, + ...sourceDescription(expansion.position), + }; +} + +/** Perform one read and retain it, or restore what is retained. */ +function retainedRead(request: PullRequestReadRequest): Operation { + const element = ELEMENT[request.kind]; + + return scoped(function* () { + const description = yield* describeRead(request); + + const stored = yield createDurableOperation( + description, + function* (): Operation { + const answered = yield* PullRequestAPI.operations.read(request.url, { + kind: request.kind, + ...(request.provider === null ? {} : { provider: request.provider }), + }); + if (answered.kind !== request.kind) { + throw new PullRequestReadError( + "protocol", + element, + "the selected provider answered with a different collection than the one this " + + "element asked for.", + ); + } + return pullRequestReadEnvelopeJson(answered); + }, + ); + + const result = parsePullRequestReadResult( + parseJsonValue( + stored, + "$", + (reason, path) => + new PullRequestReadError( + "protocol", + element, + `what this run retained for it is not a value it can carry: ${reason} at ${path}.`, + ), + ), + ); + if (result === undefined || result.kind !== request.kind) { + throw new PullRequestReadError( + "protocol", + element, + "what this run retained for it is not the evidence that read produces.", + ); + } + return result; + }); +} + +/** Install the retained pull-request lifecycle for the current scope and below. */ +export function useRetainedPullRequestOperations(): Operation { + return PullRequestOperations.around( + { + *read([invocation]: [PullRequestReadInvocation]): Operation { + const run = yield* getWorkflowRun(); + const expansion = yield* getExpansion(); + return yield* retainedRead( + readRequest( + invocation.url, + invocation.kind, + invocation.provider, + run.runId, + expansion.id, + ), + ); + }, + + // Straight through. The Git-host reconciliation underneath is already a + // durable effect keyed by this run, and wrapping it in a second envelope + // would retain one answer under two identities. + *upsert([invocation]: [PullRequestUpsertInvocation]): Operation { + return yield* PullRequestAPI.operations.upsert(invocation.pullRequest, { + repository: invocation.repository, + workingDirectory: invocation.workingDirectory, + }); + }, + }, + { at: "min" }, + ); +} diff --git a/packages/workflow/src/deno/composition/pull-request-reads.ts b/packages/workflow/src/deno/composition/pull-request-reads.ts index 4143ef0bd..1e60ebd07 100644 --- a/packages/workflow/src/deno/composition/pull-request-reads.ts +++ b/packages/workflow/src/deno/composition/pull-request-reads.ts @@ -14,53 +14,34 @@ * what is allowed is asked before a credential is read, and every response is held to * the URL that was requested rather than to whatever it says about itself. * - * ## What one read retains + * ## Transport, and only transport * - * One ordinary durable effect. Its input is the whole normalized request — - * operation, canonical URL, provider discriminator, collection, run and - * expansion — so a reader of the history knows what was asked, and a document - * edited to read a different URL or collection at that position is a different - * effect rather than one replaying the first answer. - * - * It is not a reconciled Git-host effect. There is no natural key, no - * pre-state and nothing to adopt: repeating a read is safe in the way - * repeating a write is not, and a completed one restores from the journal - * without opening a session. + * What a read *costs* — whether it is performed once and retained, or performed + * afresh every execution — belongs to the profile above this, which is why both + * profiles install this same middleware and answer that question differently. + * Here there is one job: recognize the URL, hold it to the ceiling, open a + * session, and hand back the normalized evidence. */ -import { getExpansion, sourceDescription } from "@executablemd/core"; -import { createDurableOperation } from "@executablemd/durable-streams"; -import type { EffectDescription, Json as DurableJson } from "@executablemd/durable-streams"; import type { Operation } from "effection"; -import { scoped } from "effection"; -import { gitOperationFingerprint } from "./operations.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { parseJsonValue } from "../../storage/members.ts"; -import { PullRequestReadError } from "../../composition/errors.ts"; -import { - parsePullRequestReadResult, - pullRequestReadEnvelopeJson, - pullRequestReadRequestJson, - readRequest, -} from "../../composition/pull-request-read-records.ts"; +import { GitOperationAuthorityError, PullRequestReadError } from "../../composition/errors.ts"; import type { PullRequestReadKind, - PullRequestReadRequest, PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; import { PullRequestAPI } from "../../composition/pull-request-api.ts"; import type { PullRequestReadOptions } from "../../composition/pull-request-api.ts"; -import { getWorkflowRun } from "../../run.ts"; +import type { RepositoryRecord } from "../../composition/records.ts"; +import type { SelectionRegistry } from "../selections.ts"; import { denoGitHubSource } from "./github.ts"; import type { GitHubRepositoryName, GitHubSource } from "./github.ts"; import { readPullRequestEvidence as readEvidence } from "./pull-request-evidence.ts"; import { upsertPullRequest } from "./pull-request.ts"; import type { RepositoryHost } from "./host.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; import type { PullRequestResult } from "../../composition/pull-request-records.ts"; -/** The durable effect type one evidence read is retained under. */ -export const PULL_REQUEST_READ = "pull_request_read"; - /** How this middleware names itself when a document names it explicitly. */ export const GITHUB = "github"; @@ -166,148 +147,40 @@ export interface GitHubPullRequestsOptions { readonly access?: GitHubSource; } -function* describeRead(request: PullRequestReadRequest): Operation { - const expansion = yield* getExpansion(); - // The run is in the retained request, and deliberately not in this - // fingerprint. A fork is a different run reaching the same position with the - // same question, and a name that carried the run would make every inherited - // read a different effect — which is to say, unforkable. What the name has to - // separate is different *questions*, and the four members below are what a - // question is made of. - const configuration = gitOperationFingerprint([ - request.operation, - request.url, - request.provider, - request.kind, - ]); - return { - type: PULL_REQUEST_READ, - name: `${request.expansionId}:${configuration}`, - input: pullRequestReadRequestJson(request), - configuration, - ...sourceDescription(expansion.position), - }; -} - -/** Perform one read and retain it, or restore what is retained. */ -function retainedRead( - database: WorkflowRunDatabase, - source: GitHubSource, - request: PullRequestReadRequest, - name: GitHubPullRequestName, -): Operation { - const element = ELEMENT[request.kind]; - - return scoped(function* () { - const description = yield* describeRead(request); - - const stored = yield createDurableOperation( - description, - function* (): Operation { - // After the ceiling, never before: a session opened first would be an - // identity established for a target this host had not authorized. - const access = yield* source.open(); - const reading = yield* readEvidence(access, name, name.number, request.kind); - if (reading.state === "unavailable") { - throw new PullRequestReadError( - "unavailable", - element, - "the Git host did not answer with the complete collection. None of what it did " + - "answer is evidence that there is nothing there.", - ); - } - if (reading.state === "protocol-invalid") { - throw new PullRequestReadError( - "protocol", - element, - "the Git host answered about a different subject, or with an item outside the " + - "evidence contract. A well-formed answer to another question is still the wrong " + - "answer.", - ); - } - return pullRequestReadEnvelopeJson(reading.result); - }, - ); - - const result = parsePullRequestReadResult( - parseJsonValue( - stored, - "$", - (reason, path) => - new PullRequestReadError( - "protocol", - element, - `what this run retained for it is not a value it can carry: ${reason} at ${path}.`, - ), - ), - ); - if (result === undefined || result.kind !== request.kind) { - throw new PullRequestReadError( - "protocol", - element, - "what this run retained for it is not the evidence that read produces.", - ); - } - return result; - }); +/** + * The source this adapter reaches GitHub through. + * + * Credential-free, so holding one for a middleware's whole lifetime retains + * nothing. A session — which does have an identity — is opened per request, + * after that request is allowed. + * + * Precedence: an injected transport, then a configured endpoint, then the + * platform's own GitHub. A suite that supplies its own access is not asking for + * a different endpoint as well. + */ +function sourceOf(options: GitHubPullRequestsOptions): GitHubSource { + return ( + options.access ?? + (options.endpoint === undefined ? denoGitHubSource() : denoGitHubSource(options.endpoint)) + ); } /** * Install GitHub pull-request reading for the current scope and below. * + * Both profiles install exactly this. What a read *costs* — retained once, or + * performed afresh every execution — is decided above it, at + * `PullRequestOperations`; what is decided here is which URLs this host will + * read at all and what a credential may see. + * * Installing a second adapter beside it needs no coordination between them, and * installing none leaves `PullRequestAPI`'s own base error to report that * nothing handled the request. */ -export function* useGitHubPullRequests( - database: WorkflowRunDatabase, - host: RepositoryHost, - options: GitHubPullRequestsOptions, -): Operation { - // A source rather than an access: it is credential-free, so holding one for - // the middleware's whole lifetime retains nothing. A session — which does - // have an identity — is opened per request, after that request is allowed. - // - // Precedence: an injected transport, then a configured endpoint, then the - // platform's own GitHub. A suite that supplies its own access is not asking - // for a different endpoint as well. - const source = - options.access ?? - (options.endpoint === undefined ? denoGitHubSource() : denoGitHubSource(options.endpoint)); +export function* useGitHubPullRequestReads(options: GitHubPullRequestsOptions): Operation { + const source = sourceOf(options); yield* PullRequestAPI.around({ - /** - * The upsert this host performs, unchanged in everything but where it is - * reached from. - * - * It still proves this run published the branch, still reconciles through - * the Git-host engine, and still refuses a pull request belonging to - * another Repository. What moved is only the surface: `` asks - * this Api rather than the Git composition one, so both questions about a - * pull request are asked in the same place. - */ - *upsert([pullRequest, options], next): Operation { - const mine = options.provider === undefined || options.provider === GITHUB; - if (!mine) { - return yield* next(pullRequest, options); - } - const outcome = yield* upsertPullRequest( - database, - host, - { - repository: options.repository, - workingDirectory: options.workingDirectory, - number: pullRequest.number, - title: pullRequest.title, - body: pullRequest.body, - draft: pullRequest.draft, - base: pullRequest.base, - }, - source, - ); - return outcome.result; - }, - *read([url, read], next): Operation { // Matched by discriminator, or — with no discriminator — by URL. // With nothing allowed there is no URL read this host performs, so the @@ -344,16 +217,84 @@ export function* useGitHubPullRequests( ); } - const run = yield* getWorkflowRun(); - const expansion = yield* getExpansion(); - return yield* retainedRead( + // After the ceiling, never before: a session opened first would be an + // identity established for a target this host had not authorized. + const access = yield* source.open(); + const reading = yield* readEvidence(access, name, name.number, read.kind); + if (reading.state === "unavailable") { + throw new PullRequestReadError( + "unavailable", + element, + "the Git host did not answer with the complete collection. None of what it did " + + "answer is evidence that there is nothing there.", + ); + } + if (reading.state === "protocol-invalid") { + throw new PullRequestReadError( + "protocol", + element, + "the Git host answered about a different subject, or with an item outside the " + + "evidence contract. A well-formed answer to another question is still the wrong " + + "answer.", + ); + } + return reading.result; + }, + }); +} + +/** + * Install the workflow host's reconciled pull-request upsert, and its reads. + * + * The upsert is unchanged in everything but where it is reached from: it still + * proves this run published the branch, still reconciles through the Git-host + * engine, and still refuses a pull request belonging to another Repository. The + * selection it is handed is resolved through the provider's own registry, never + * believed, which is the same rule every Git operation follows. + */ +export function* useGitHubPullRequests( + database: WorkflowRunDatabase, + host: RepositoryHost, + options: GitHubPullRequestsOptions, + selections: SelectionRegistry, +): Operation { + const source = sourceOf(options); + + yield* PullRequestAPI.around({ + *upsert([pullRequest, upsert], next): Operation { + const mine = upsert.provider === undefined || upsert.provider === GITHUB; + if (!mine) { + return yield* next(pullRequest, upsert); + } + const outcome = yield* upsertPullRequest( database, + host, + { + // The record this provider itself holds for the selection, never the + // selection's own words: a Repository nobody selected is exactly what + // a replaced context would name. + repository: selections.authenticate( + upsert.repository, + () => + new GitOperationAuthorityError( + PULL_REQUEST_ELEMENT, + "the Repository in scope is not one this run selected, so it names no retained " + + "checkout", + ), + ), + workingDirectory: upsert.workingDirectory, + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + draft: pullRequest.draft, + base: pullRequest.base, + }, source, - readRequest(url, read.kind, read.provider, run.runId, expansion.id), - name, ); + return outcome.result; }, }); + yield* useGitHubPullRequestReads(options); } /** The options a read carries, re-exported for a host installing this. */ diff --git a/packages/workflow/src/deno/composition/pull-request.ts b/packages/workflow/src/deno/composition/pull-request.ts index 20fda032e..3c8a87435 100644 --- a/packages/workflow/src/deno/composition/pull-request.ts +++ b/packages/workflow/src/deno/composition/pull-request.ts @@ -45,10 +45,7 @@ import { PullRequestAuthorityError, } from "../../composition/errors.ts"; import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; -import { - filteredRepositoryIdentity, - sameRepositoryIdentity, -} from "../../composition/git-push-records.ts"; + import { parsePullRequestInputs, parsePullRequestPreState, @@ -88,6 +85,7 @@ import { type GitHubSource, } from "./github.ts"; import type { RepositoryHost } from "./host.ts"; +import { filteredRepositoryIdentity, sameRepositoryIdentity } from "../../composition/selection.ts"; import { exportCheckoutFamily, prepareCheckout, diff --git a/packages/workflow/src/deno/composition/push.ts b/packages/workflow/src/deno/composition/push.ts index 0e32adea5..147d4b1fd 100644 --- a/packages/workflow/src/deno/composition/push.ts +++ b/packages/workflow/src/deno/composition/push.ts @@ -56,7 +56,6 @@ import { PUSH } from "../../composition/components/GitPush.ts"; import { ANCESTOR, destinationRefFor, - filteredRepositoryIdentity, GIT_PUSH, gitPushInputsJson, gitPushNaturalKeyJson, @@ -68,10 +67,8 @@ import { pushExpectation, PUSH_REMOTE, refspecFor, - sameRepositoryIdentity, type GitPushInputs, type GitPushOutcome, - type GitPushRepositoryIdentity, type GitPushRequest, type GitPushResult, } from "../../composition/git-push-records.ts"; @@ -110,6 +107,8 @@ import { } from "./operations.ts"; import { gitRefusal } from "./refusals.ts"; +import { filteredRepositoryIdentity, sameRepositoryIdentity } from "../../composition/selection.ts"; +import type { RepositoryIdentity } from "../../composition/selection.ts"; function unusable(reason: string): never { throw new GitOperationInfrastructureError(PUSH, reason); } @@ -184,7 +183,7 @@ function* provenAncestor( function* retainedPushRoot( database: WorkflowRunDatabase, expansionId: string, - repository: GitPushRepositoryIdentity, + repository: RepositoryIdentity, ): Operation { const entries = yield* database.readJournalEntries(); if (!entries.ok) { diff --git a/packages/workflow/src/deno/composition/switch.ts b/packages/workflow/src/deno/composition/switch.ts index 09425812a..ffdf95159 100644 --- a/packages/workflow/src/deno/composition/switch.ts +++ b/packages/workflow/src/deno/composition/switch.ts @@ -76,11 +76,11 @@ function* describeSwitch(admitted: GitSwitchRequest): Operation + * ``` + * + * and mean the repository the command was run in. + * + * ## Two identities, and they are not the same + * + * The **common Git directory** identifies the repository; the **checkout root** + * identifies which of its checkouts this invocation is in. They differ exactly + * when the caller is standing in a linked worktree — where `.git` is a file + * naming the primary repository's administration — and keeping them apart is + * what makes starting XMD in a worktree produce the same Repository identity as + * starting it in the primary checkout, while Git operations still act on the + * worktree the command was actually run in. + * + * ## Discovery is not an operation the document asked for + * + * It happens once, before root expansion, from the invocation's starting + * directory. Being outside a repository is not a startup failure: a document + * that never asks for a Repository-dependent operation runs exactly as it would + * anywhere else, and only an element that needs one refuses. + * + * The `origin` is read the same way, and its absence is likewise not a failure. + * A repository with no origin is a perfectly good Repository for a Worktree, a + * Switch, an Add and a Commit; it is only Push and PullRequest that need a + * destination, and each of those checks for one before it opens a credential. + */ + +import { realpath } from "node:fs/promises"; +import { basename } from "node:path"; +import { until, type Operation } from "effection"; +import type { GitObjectFormat } from "../../composition/records.ts"; +import { admitLocator, locatorFingerprint } from "../composition/locator.ts"; +import { currentBranch, readObjectFormat, resolveCommit } from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; + +/** What one Git checkout on this host turned out to be. */ +export interface AmbientRepository { + /** The display name a document sees: the checkout directory's own name. */ + readonly name: string; + /** The canonical root of the checkout the invocation started in. */ + readonly checkoutRoot: string; + /** The canonical common Git directory, which identifies the repository. */ + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + /** The commit HEAD named when this invocation started. */ + readonly head: string; + /** The locally recorded, admitted `origin`, or `undefined` when there is none. */ + readonly origin: string | undefined; + readonly originFingerprint: string | undefined; + /** + * The branch a `` defaults its base to. + * + * `refs/remotes/origin/HEAD` when the checkout records one, and the branch + * this invocation started on otherwise. Nothing is asked of a remote for it: + * a default branch this run had to fetch would make an ordinary document + * reach the network before it did anything. + */ + readonly defaultBranch: string; +} + +/** + * The canonical directory this path resolves to, or `undefined`. + * + * Canonicalization matters more than usual here. `/var` on macOS is + * `/private/var`, and Git writes the resolved path into a linked worktree's + * administration — so a comparison against an unresolved path would report a + * worktree as belonging to no repository. + */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return undefined; + } +} + +/** + * Discover the ambient repository from this directory, or answer `undefined`. + * + * Every step is a local Git question. Nothing here contacts a remote, opens a + * credential or writes anything. + */ +export function* discoverAmbientRepository( + git: GitSession, + from: string, +): Operation { + const reportedRoot = yield* git.read(["rev-parse", "--show-toplevel"], from); + if (reportedRoot === undefined) { + return undefined; + } + const checkoutRoot = yield* canonical(reportedRoot); + if (checkoutRoot === undefined) { + return undefined; + } + + const reportedCommon = yield* git.read(["rev-parse", "--git-common-dir"], checkoutRoot); + if (reportedCommon === undefined) { + return undefined; + } + // Without `--path-format=absolute`, which not every supported Git has: a + // linked worktree already answers absolutely, and a primary checkout answers + // `.git` relative to itself. + const commonDirectory = yield* canonical( + reportedCommon.startsWith("/") ? reportedCommon : `${checkoutRoot}/${reportedCommon}`, + ); + if (commonDirectory === undefined) { + return undefined; + } + + const objectFormat = yield* readObjectFormat(git, checkoutRoot); + const head = yield* resolveCommit(git, checkoutRoot, "HEAD"); + if (objectFormat === undefined || head === undefined) { + // A directory Git recognizes but cannot say the shape of is not a + // repository this provider will act on. Refusing here is the same answer as + // being outside one, and for the same reason: nothing has been read that + // could name a checkout. + return undefined; + } + + const branch = yield* currentBranch(git, checkoutRoot); + const recorded = yield* git.read(["config", "--get", "remote.origin.url"], checkoutRoot); + // Admitted on the way in, not on the way out: what is not a locator this + // provider would hand to Git is a repository with no usable origin, which is + // a state Push and PullRequest already know how to refuse. + const origin = recorded === undefined ? undefined : admitLocator(recorded); + + const recordedDefault = yield* git.read( + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + checkoutRoot, + ); + const defaultBranch = defaultFrom(recordedDefault, branch); + + return Object.freeze({ + name: basename(checkoutRoot), + checkoutRoot, + commonDirectory, + objectFormat, + head, + origin, + originFingerprint: origin === undefined ? undefined : locatorFingerprint(origin), + defaultBranch, + }); +} + +/** + * The default branch, from what the checkout records. + * + * `refs/remotes/origin/HEAD` reads as `origin/main`, and what a base names is + * `main`. A detached HEAD with no recorded remote default leaves nothing to + * name, and the empty string is what a `` then has to be given a + * `base` for. + */ +function defaultFrom(recorded: string | undefined, branch: string | undefined): string { + if (recorded !== undefined && recorded.startsWith("origin/")) { + return recorded.slice("origin/".length); + } + return recorded ?? branch ?? ""; +} diff --git a/packages/workflow/src/deno/run-composition/checkouts.ts b/packages/workflow/src/deno/run-composition/checkouts.ts new file mode 100644 index 000000000..b9e8052d0 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/checkouts.ts @@ -0,0 +1,631 @@ +/** + * Managed checkouts: creating one, reusing one, and refusing everything else. + * + * A workflow Repository is created once and restored from a journal. A managed + * one is created once and then *found again* by later executions that have no + * journal at all, so everything a run needs to trust about it has to be + * re-established from what is on disk, every time, under the slot's lock. + * + * ## Reuse checks creation identity, and only creation identity + * + * What is compared is what the checkout was made from: the immutable request, + * the recorded creation facts, and the Git identity of the directory. HEAD, the + * current branch, the index, the working tree and dirtiness are all deliberately + * unchecked — they are the mutable work the checkout exists to preserve, and a + * reuse that required them to match creation would refuse every checkout + * anybody had actually used. + * + * A conflict is a refusal, never a repair. Nothing here resets, switches, + * cleans, fetches, moves, replaces or deletes; a slot that does not match is + * left byte for byte as it was found and the sentence says what it is. + * + * ## An interrupted creation is adopted only when it can be proved + * + * A process killed between cloning and writing the sidecar leaves a checkout + * nothing describes. Deleting it would be destroying work; using it blindly + * would be trusting a directory this provider cannot account for. So it is + * adopted only after the *stricter* pre-exposure state is proved — the exact + * owner and locator, the branch and base this request resolves to, the creation + * commit still being HEAD, the object format, and nothing in the slot but the + * checkout — and the sidecar is then written atomically. Anything less refuses. + */ + +import { open, rename } from "node:fs/promises"; +import { ensureDir, exists, readTextFile, readdir } from "@effectionx/fs"; +import { realpath } from "node:fs/promises"; +import { ensure, scoped, until, type Operation } from "effection"; +import { randomUUID } from "node:crypto"; +import type { GitObjectFormat } from "../../composition/records.ts"; +import { admitLocator, locatorFingerprint } from "../composition/locator.ts"; +import { + addWorktree, + branchExists, + checkoutPrimary, + clone, + commonDirectory, + currentBranch, + objectFormat as readFormat, + originLocator, + readObjectFormat, + resolveBaseCommit, + resolveCommit, + resolveRepositoryStart, +} from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; +import { useGitAuthentication, type RepositoryHost } from "../composition/host.ts"; +import { repositoryRefused, worktreeRefused } from "../composition/refusals.ts"; +import { ManagedCheckoutError } from "./errors.ts"; +import { + METADATA_VERSION, + metadataBytes, + parseRepositoryMetadata, + parseWorktreeMetadata, + type ManagedMetadata, + type ManagedRepositoryMetadata, + type ManagedWorktreeMetadata, +} from "./metadata.ts"; +import { CHECKOUT, checkoutOf, METADATA, metadataOf } from "./placement.ts"; + +/** One managed checkout, once this execution is entitled to work in it. */ +export interface ManagedCheckout { + readonly checkout: string; + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + readonly creationCommit: string; +} + +export interface ManagedRepository extends ManagedCheckout { + readonly metadata: ManagedRepositoryMetadata; +} + +export interface ManagedWorktree extends ManagedCheckout { + readonly metadata: ManagedWorktreeMetadata; +} + +function conflict(reason: "incompatible-reuse" | "partial-creation", sentence: string): never { + throw new ManagedCheckoutError(reason, sentence); +} + +function unusable(sentence: string): never { + throw new ManagedCheckoutError("unusable-checkout", sentence); +} + +/** + * Write a sidecar by exclusive temporary sibling plus atomic rename. + * + * Exclusive because the temporary name must never be one another process is + * already writing; atomic because a reader under the same lock must see either + * the whole sidecar or none of it, and a partially written one would describe a + * checkout nobody made. + */ +function* writeMetadata(slot: string, metadata: ManagedMetadata): Operation { + const temporary = `${metadataOf(slot)}.${randomUUID()}`; + yield* scoped(function* () { + const handle = yield* until(open(temporary, "wx")); + // Registered before the write, so a halt closes the descriptor: closing is + // asynchronous, and a `finally` that suspended would not be guaranteed to + // finish. + yield* ensure(() => until(handle.close())); + yield* until(handle.writeFile(metadataBytes(metadata), "utf8")); + }); + yield* until(rename(temporary, metadataOf(slot))); +} + +/** The sidecar this slot holds, or `undefined` when it holds none. */ +function* readMetadata(slot: string): Operation { + const path = metadataOf(slot); + if (!(yield* exists(path))) { + return undefined; + } + const bytes = yield* readTextFile(path); + try { + return JSON.parse(bytes); + } catch { + return null; + } +} + +/** The canonical directory this path resolves to, or `undefined`. */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return undefined; + } +} + +/** + * The Git facts a slot's checkout reports, once it reports a whole set. + * + * Read as one group because a comparison needs all of it: a checkout that can + * answer where its objects are but not which repository it belongs to is not + * one this provider can decide anything about. + */ +interface CheckoutFacts { + readonly root: string; + readonly commonDirectory: string; + readonly objectFormat: GitObjectFormat; + readonly head: string; +} + +function* readCheckoutFacts( + git: GitSession, + directory: string, +): Operation { + const reportedRoot = yield* git.read(["rev-parse", "--show-toplevel"], directory); + if (reportedRoot === undefined) { + return undefined; + } + const root = yield* canonical(reportedRoot); + const common = yield* commonDirectory(git, directory); + const objectFormat = yield* readObjectFormat(git, directory); + const head = yield* resolveCommit(git, directory, "HEAD"); + if (root === undefined || common === undefined || objectFormat === undefined) { + return undefined; + } + const commonCanonical = yield* canonical(common); + if (commonCanonical === undefined || head === undefined) { + return undefined; + } + return { root, commonDirectory: commonCanonical, objectFormat, head }; +} + +/** Whether the slot holds nothing but its own checkout directory. */ +function* slotHoldsOnlyCheckout(slot: string): Operation { + const entries = yield* readdir(slot); + return entries.length === 1 && entries[0] === CHECKOUT; +} + +/** + * Select the managed Repository this request names. + * + * The lease is already held by the caller, so everything below is this + * execution's alone until the execution ends. + */ +export function* selectManagedRepository( + git: GitSession, + host: RepositoryHost, + slot: string, + request: { readonly name: string; readonly locator: string; readonly base: string | undefined }, +): Operation { + const locator = admitLocator(request.locator); + if (locator === undefined) { + repositoryRefused(request.name, "invalid-locator"); + } + const fingerprint = locatorFingerprint(locator); + const checkout = checkoutOf(slot); + const requestedBase = request.base ?? null; + + const stored = yield* readMetadata(slot); + if (stored !== undefined) { + const metadata = parseRepositoryMetadata(stored); + if (metadata === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(request.name)} carries a ` + + `${METADATA} this version cannot read, so what it holds cannot be decided. Nothing ` + + "was changed.", + ); + } + if ( + metadata.name !== request.name || + metadata.locator !== locator || + metadata.locatorFingerprint !== fingerprint || + metadata.requestedBase !== requestedBase + ) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(request.name)} was created from a ` + + "different url or base than this invocation asks for. Nothing was reset, fetched or " + + "replaced; ask for a different name to get a checkout of your own.", + ); + } + const facts = yield* verifyRepository(git, checkout, metadata, request.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + if (yield* exists(checkout)) { + const metadata = yield* adoptRepository(git, slot, checkout, { + name: request.name, + locator, + fingerprint, + requestedBase, + base: request.base, + }); + const facts = yield* verifyRepository(git, checkout, metadata, request.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + return yield* createManagedRepository(git, host, slot, checkout, { + name: request.name, + locator, + fingerprint, + requestedBase, + base: request.base, + }); +} + +interface RepositoryCreation { + readonly name: string; + readonly locator: string; + readonly fingerprint: string; + readonly requestedBase: string | null; + readonly base: string | undefined; +} + +/** + * What a compatible reuse re-establishes about a repository's checkout. + * + * Five facts, and each of them is something an edited or replaced directory + * would fail: it is a canonical checkout at exactly this path, it belongs to + * the common directory recorded for it, it names its objects the same way, its + * `origin` still names the recorded locator, and the commit it was created at + * is still present. None of it is about where HEAD is now. + */ +function* verifyRepository( + git: GitSession, + checkout: string, + metadata: ManagedRepositoryMetadata, + name: string, +): Operation<{ commonDirectory: string; objectFormat: GitObjectFormat }> { + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined) { + unusable( + `the managed checkout for repository ${JSON.stringify(name)} is no longer a readable Git ` + + "checkout. Nothing was changed; move it aside or ask for a different name.", + ); + } + if (facts.root !== expected || facts.commonDirectory !== metadata.commonDirectory) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} belongs to a different Git ` + + "repository than the one recorded for it. Nothing was changed.", + ); + } + if (facts.objectFormat !== metadata.objectFormat) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} names its objects with a ` + + "different algorithm than the one recorded for it. Nothing was changed.", + ); + } + if ((yield* originLocator(git, checkout)) !== metadata.locator) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} no longer has the origin it ` + + "was cloned from. Nothing was changed.", + ); + } + if ((yield* resolveCommit(git, checkout, metadata.creationCommit)) === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for repository ${JSON.stringify(name)} no longer holds the commit it ` + + "was created at. Nothing was fetched or repaired.", + ); + } + return { commonDirectory: facts.commonDirectory, objectFormat: facts.objectFormat }; +} + +/** + * Adopt an interrupted repository creation, or refuse and change nothing. + * + * The proof is stricter than a reuse's, because there is no record to compare + * against: the checkout has to still be in exactly the state creation would + * have left it in — nothing but the checkout in the slot, the recorded origin, + * the branch and commit this request resolves to, and HEAD still on them. + */ +function* adoptRepository( + git: GitSession, + slot: string, + checkout: string, + creation: RepositoryCreation, +): Operation { + function refuse(): never { + conflict( + "partial-creation", + `the managed checkout for repository ${JSON.stringify(creation.name)} holds an interrupted ` + + "creation this version cannot account for. Every byte was left where it was: look at it, " + + "or ask for a different name.", + ); + } + + if (!(yield* slotHoldsOnlyCheckout(slot))) { + refuse(); + } + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined || facts.root !== expected) { + refuse(); + } + if ((yield* originLocator(git, checkout)) !== creation.locator) { + refuse(); + } + const start = yield* resolveRepositoryStart(git, checkout, creation.base); + if (start.commit !== facts.head) { + refuse(); + } + if ((yield* currentBranch(git, checkout)) !== start.primaryBranch) { + refuse(); + } + + const metadata: ManagedRepositoryMetadata = Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name: creation.name, + locator: creation.locator, + locatorFingerprint: creation.fingerprint, + requestedBase: creation.requestedBase, + creationCommit: start.commit, + primaryBranch: start.primaryBranch, + objectFormat: facts.objectFormat, + commonDirectory: facts.commonDirectory, + }); + yield* writeMetadata(slot, metadata); + return metadata; +} + +function* createManagedRepository( + git: GitSession, + host: RepositoryHost, + slot: string, + checkout: string, + creation: RepositoryCreation, +): Operation { + yield* ensureDir(slot); + // One session for this clone, opened after the locator was admitted and + // released with the scope this operation runs in. + const session = yield* useGitAuthentication(host, creation.locator); + yield* clone(git, creation.locator, checkout, slot, session); + const start = yield* resolveRepositoryStart(git, checkout, creation.base); + yield* checkoutPrimary(git, checkout, start); + const format = yield* readFormat(git, checkout); + const common = yield* commonDirectory(git, checkout); + const commonCanonical = common === undefined ? undefined : yield* canonical(common); + if (commonCanonical === undefined) { + unusable( + `the checkout just cloned for repository ${JSON.stringify(creation.name)} does not report ` + + "the Git directory it belongs to.", + ); + } + + const metadata: ManagedRepositoryMetadata = Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name: creation.name, + locator: creation.locator, + locatorFingerprint: creation.fingerprint, + requestedBase: creation.requestedBase, + creationCommit: start.commit, + primaryBranch: start.primaryBranch, + objectFormat: format, + commonDirectory: commonCanonical, + }); + // After the checkout is complete and verified, never before: a sidecar that + // existed beside a half-made checkout would make the next execution reuse it. + yield* writeMetadata(slot, metadata); + return { + checkout, + commonDirectory: commonCanonical, + objectFormat: format, + creationCommit: start.commit, + metadata, + }; +} + +/** What a Worktree selection asks for, once its owner is known. */ +export interface WorktreeCreation { + readonly name: string; + readonly branch: string; + readonly base: string | undefined; + /** The canonical common Git directory of the repository it belongs to. */ + readonly owner: string; + /** A checkout of that repository, which is where `worktree add` runs. */ + readonly ownerCheckout: string; +} + +export function* selectManagedWorktree( + git: GitSession, + slot: string, + creation: WorktreeCreation, +): Operation { + const checkout = checkoutOf(slot); + const requestedBase = creation.base ?? null; + + const stored = yield* readMetadata(slot); + if (stored !== undefined) { + const metadata = parseWorktreeMetadata(stored); + if (metadata === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(creation.name)} carries a ` + + `${METADATA} this version cannot read. Nothing was changed.`, + ); + } + if ( + metadata.name !== creation.name || + metadata.owner !== creation.owner || + metadata.requestedBranch !== creation.branch || + metadata.requestedBase !== requestedBase + ) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(creation.name)} was created for a ` + + "different repository, branch or base than this invocation asks for. Nothing was " + + "reset or replaced; ask for a different name to get a worktree of your own.", + ); + } + const facts = yield* verifyWorktree(git, checkout, metadata, creation.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + if (yield* exists(checkout)) { + const metadata = yield* adoptWorktree(git, slot, checkout, creation); + const facts = yield* verifyWorktree(git, checkout, metadata, creation.name); + return { checkout, ...facts, creationCommit: metadata.creationCommit, metadata }; + } + + return yield* createManagedWorktree(git, slot, checkout, creation); +} + +/** + * What a compatible reuse re-establishes about a worktree's checkout. + * + * The owner relationship is the one that matters and the one a plain "is this a + * checkout" question cannot see: a linked worktree's common directory is the + * repository it belongs to, so comparing it is what proves this checkout is + * still a worktree *of that repository* rather than an unrelated clone left at + * the same path. + */ +function* verifyWorktree( + git: GitSession, + checkout: string, + metadata: ManagedWorktreeMetadata, + name: string, +): Operation<{ commonDirectory: string; objectFormat: GitObjectFormat }> { + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined) { + unusable( + `the managed checkout for worktree ${JSON.stringify(name)} is no longer a readable Git ` + + "checkout. Nothing was changed.", + ); + } + if (facts.root !== expected || facts.commonDirectory !== metadata.owner) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} is no longer a linked checkout ` + + "of the repository it belongs to. Nothing was changed.", + ); + } + if (facts.objectFormat !== metadata.objectFormat) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} names its objects with a ` + + "different algorithm than the one recorded for it. Nothing was changed.", + ); + } + if ((yield* resolveCommit(git, checkout, metadata.creationCommit)) === undefined) { + conflict( + "incompatible-reuse", + `the managed checkout for worktree ${JSON.stringify(name)} no longer holds the commit it ` + + "was created at. Nothing was fetched or repaired.", + ); + } + return { commonDirectory: facts.commonDirectory, objectFormat: facts.objectFormat }; +} + +function* adoptWorktree( + git: GitSession, + slot: string, + checkout: string, + creation: WorktreeCreation, +): Operation { + function refuse(): never { + conflict( + "partial-creation", + `the managed checkout for worktree ${JSON.stringify(creation.name)} holds an interrupted ` + + "creation this version cannot account for. Every byte was left where it was.", + ); + } + + if (!(yield* slotHoldsOnlyCheckout(slot))) { + refuse(); + } + const expected = yield* canonical(checkout); + const facts = expected === undefined ? undefined : yield* readCheckoutFacts(git, checkout); + if (expected === undefined || facts === undefined || facts.root !== expected) { + refuse(); + } + // Registration as a linked worktree of exactly this repository, which is what + // makes an unrelated clone at the same path fail rather than be adopted. + if (facts.commonDirectory !== creation.owner) { + refuse(); + } + if ((yield* currentBranch(git, checkout)) !== creation.branch) { + refuse(); + } + const start = yield* worktreeStart(git, creation); + if (start === undefined || start !== facts.head) { + refuse(); + } + + const metadata: ManagedWorktreeMetadata = Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner: creation.owner, + name: creation.name, + requestedBranch: creation.branch, + requestedBase: creation.base ?? null, + creationCommit: start, + objectFormat: facts.objectFormat, + }); + yield* writeMetadata(slot, metadata); + return metadata; +} + +/** + * The commit an adoption expects this worktree to have started at. + * + * The branch is the answer when it already exists — `worktree add + * ` checks it out where it is — and the base, or the owner checkout's + * own commit, when it had to be created. + */ +function* worktreeStart( + git: GitSession, + creation: WorktreeCreation, +): Operation { + if (yield* branchExists(git, creation.ownerCheckout, creation.branch)) { + return yield* resolveCommit(git, creation.ownerCheckout, `refs/heads/${creation.branch}`); + } + return creation.base === undefined + ? yield* resolveCommit(git, creation.ownerCheckout, "HEAD") + : yield* resolveBaseCommit(git, creation.ownerCheckout, creation.base); +} + +function* createManagedWorktree( + git: GitSession, + slot: string, + checkout: string, + creation: WorktreeCreation, +): Operation { + yield* ensureDir(slot); + const added = yield* addWorktree( + git, + creation.ownerCheckout, + checkout, + creation.branch, + creation.base, + ); + const facts = yield* readCheckoutFacts(git, checkout); + if (facts === undefined || facts.commonDirectory !== creation.owner) { + unusable( + `the worktree just created for ${JSON.stringify(creation.name)} does not report the ` + + "repository it belongs to.", + ); + } + + const metadata: ManagedWorktreeMetadata = Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner: creation.owner, + name: creation.name, + requestedBranch: creation.branch, + requestedBase: creation.base ?? null, + creationCommit: added.commit, + objectFormat: facts.objectFormat, + }); + yield* writeMetadata(slot, metadata); + return { + checkout, + commonDirectory: facts.commonDirectory, + objectFormat: facts.objectFormat, + creationCommit: added.commit, + metadata, + }; +} + +/** The refusal a Worktree request reports when native Git refuses it. */ +export function worktreeRefusal(name: string, reason: string): never { + worktreeRefused(name, reason); +} diff --git a/packages/workflow/src/deno/run-composition/errors.ts b/packages/workflow/src/deno/run-composition/errors.ts new file mode 100644 index 000000000..459747f17 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/errors.ts @@ -0,0 +1,90 @@ +/** + * What an ordinary run refuses, and in whose words. + * + * Two of the three reuse the vocabulary the components already speak, because + * they are the same conditions: a locator this provider will not use, a base + * that names no commit, a branch another checkout holds. What is new here is + * only what a shared host root adds — a slot another process is working in, and + * a slot whose contents do not match what it would be reused as — so those get + * words of their own. + * + * None of these deletes, resets or repairs anything. A managed checkout is + * somebody's work; a refusal leaves every byte where it was and says what to + * look at. + */ + +import { StaleInputError } from "@executablemd/durable-streams"; + +/** A word from the fixed vocabulary a managed-checkout refusal is reported under. */ +export type ManagedCheckoutReason = + /** Another process holds this slot's lock right now. */ + | "in-use" + /** The slot holds a checkout that is not what this request would reuse. */ + | "incompatible-reuse" + /** The slot holds an interrupted creation this provider cannot prove. */ + | "partial-creation" + /** The slot holds something that is not a readable Git checkout. */ + | "unusable-checkout"; + +/** + * A managed checkout this run may not use. + * + * An ordinary Error rather than a stale-state one: every condition here is + * something the person running the document can act on — wait for the other + * process, look at what is in the slot, ask for a different name — so an + * authored `` region may decide what to do about it, exactly as it + * may for a Repository refusal. + */ +export class ManagedCheckoutError extends Error { + override name = "ManagedCheckoutError"; + + readonly reason: ManagedCheckoutReason; + + constructor(reason: ManagedCheckoutReason, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * An element that needs a repository, written where the host is not in one. + * + * A `StaleInputError`, because it is not a refusal a document asked for: a + * document that goes on running past this would run later siblings as though a + * branch had moved. The sentence names the two ways to fix it, because both are + * ordinary — write a ``, or run from inside a checkout. + */ +export class NoAmbientRepositoryError extends StaleInputError { + override name = "NoAmbientRepositoryError"; + + constructor(operation: string) { + super( + `${operation} needs a repository, and it is written outside a in a directory ` + + "that is not inside a Git checkout. Run xmd from inside one, or write " + + ' around it.', + ); + } +} + +/** + * A branch this run has not published, or has published somewhere else. + * + * The ordinary run's counterpart to the journal scan a workflow run performs. + * The evidence it reads is this provider instance's own record of a verified + * ``; nothing a document, a Context value or a previous `--journal` + * file holds is admissible, which is why an execution that did not push refuses + * here rather than observing the Git host. + */ +export class LivePushEvidenceError extends StaleInputError { + override name = "LivePushEvidenceError"; + + readonly reason: "missing-push-evidence" | "conflicting-push-evidence"; + + constructor(reason: "missing-push-evidence" | "conflicting-push-evidence", sentence: string) { + super( + ` is not authorized by what this execution published: ${sentence} Nothing was ` + + "observed at the Git host, and no pull request was created.", + ); + this.reason = reason; + } +} diff --git a/packages/workflow/src/deno/run-composition/leases.ts b/packages/workflow/src/deno/run-composition/leases.ts new file mode 100644 index 000000000..5d5f707a6 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/leases.ts @@ -0,0 +1,77 @@ +/** + * The locks an ordinary run holds on the slots it is working in. + * + * A workflow run owns its Workspace outright, so nothing coordinates with + * anything. A managed host root is shared: two `xmd run` processes on one + * machine may name the same Repository at the same moment, and one of them + * cloning into the directory the other is committing in would corrupt both. + * + * So a slot is entered under an exclusive kernel-backed advisory lock, taken + * without waiting. Refusing rather than waiting is what makes the answer + * useful: "somebody else is working in this checkout" is something the person + * running the document can act on, and a hang is not. The kernel is also what + * makes it survive a lost host — a killed process runs no cleanup and the + * operating system releases its locks anyway, which is the only evidence this + * accepts that a previous holder is gone. + * + * ## The hold is execution-wide, deliberately + * + * The lease is acquired into the scope the provider was installed in rather + * than into the component invocation that asked for it, so it outlives the + * element. A self-closing `` binds a path that a + * later sibling `` and an interactive `` inside it + * go on using; a lease that ended with the Worktree element would leave both + * working in a slot another process could take. + * + * Selecting the same slot twice in one execution reuses the lease already held. + * It is the same process and the same provider: taking a second exclusive lock + * on a file this process already holds is not a coordination question. + * + * Nothing here unlinks a lock file. Unlinking a locked path lets the next + * caller create and lock a different file at the same name while this lock is + * still held, so the sidecar is created if absent and then left, empty. + */ + +import { until, useScope, type Operation, type Scope } from "effection"; +import { useAdvisoryLock } from "../advisory-lock.ts"; +import { ManagedCheckoutError } from "./errors.ts"; +import { lockOf } from "./placement.ts"; + +export type SlotKind = "repository" | "worktree"; + +export interface Leases { + /** + * Hold this slot for the rest of the execution, or refuse. + * + * Idempotent per slot: a second call for a slot this execution already holds + * returns without asking the operating system anything. + */ + hold(kind: SlotKind, slot: string, subject: string): Operation; +} + +export function* useLeases(root: string): Operation { + // The scope the provider is installed in, captured once. Every lease is + // acquired into it, so all of them are released — by the kernel and by the + // resource's own teardown — when the document execution ends, on success, + // failure and cancellation alike. + const owner: Scope = yield* useScope(); + const held = new Set(); + + return { + *hold(kind: SlotKind, slot: string, subject: string): Operation { + const path = lockOf(root, kind, slot); + if (held.has(path)) { + return; + } + const file = yield* until(owner.run(() => useAdvisoryLock(path))); + if (file === undefined) { + throw new ManagedCheckoutError( + "in-use", + `another process is working in the managed checkout for ${subject}. Nothing was read ` + + "and nothing was changed; the other process releases it when it ends.", + ); + } + held.add(path); + }, + }; +} diff --git a/packages/workflow/src/deno/run-composition/metadata.ts b/packages/workflow/src/deno/run-composition/metadata.ts new file mode 100644 index 000000000..bd683c493 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/metadata.ts @@ -0,0 +1,165 @@ +/** + * The sidecar that says what a managed slot is, and what it was created from. + * + * A workflow run answers that question from its database. An ordinary run has + * none, so the answer lives beside the checkout — and because it lives on a + * filesystem several processes share, everything about it is defensive: the + * shape is closed and versioned, every member is parsed, and the file is only + * ever written by an exclusive temporary sibling plus an atomic rename, after + * the checkout it describes is complete and verified. + * + * ## Paths are derived, never read back + * + * A slot's checkout path is a function of the root and the identity. It is + * deliberately absent from the metadata: a path read out of a file somebody + * could edit would be a path this provider then joined and followed, and the + * whole point of digesting authored strings into slots is that no authored + * string decides where anything is. + * + * ## What compatibility means + * + * Every member here is *creation* state: what was asked for, and what was true + * the moment the checkout came into being. None of it is current state. HEAD, + * the current branch, the index and the working tree are the mutable work the + * checkout exists to preserve, and a reuse that required them to match creation + * would refuse every checkout anybody had used. + */ + +import { members, optionalText, text } from "../../composition/parse.ts"; +import { parseObjectFormat, type GitObjectFormat } from "../../composition/records.ts"; + +/** The one shape this version writes and the only one it reads. */ +export const METADATA_VERSION = 1; + +export interface ManagedRepositoryMetadata { + readonly kind: "repository"; + readonly version: typeof METADATA_VERSION; + readonly name: string; + /** The admitted, credential-free locator this checkout was cloned from. */ + readonly locator: string; + readonly locatorFingerprint: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly primaryBranch: string; + readonly objectFormat: GitObjectFormat; + /** The canonical common Git directory this checkout's own worktrees key on. */ + readonly commonDirectory: string; +} + +export interface ManagedWorktreeMetadata { + readonly kind: "worktree"; + readonly version: typeof METADATA_VERSION; + /** The canonical common Git directory of the repository this belongs to. */ + readonly owner: string; + readonly name: string; + readonly requestedBranch: string; + readonly requestedBase: string | null; + readonly creationCommit: string; + readonly objectFormat: GitObjectFormat; +} + +export type ManagedMetadata = ManagedRepositoryMetadata | ManagedWorktreeMetadata; + +const REPOSITORY_MEMBERS = [ + "kind", + "version", + "name", + "locator", + "locatorFingerprint", + "requestedBase", + "creationCommit", + "primaryBranch", + "objectFormat", + "commonDirectory", +] as const; + +const WORKTREE_MEMBERS = [ + "kind", + "version", + "owner", + "name", + "requestedBranch", + "requestedBase", + "creationCommit", + "objectFormat", +] as const; + +/** The repository sidecar this value describes, or `undefined` when it is none. */ +export function parseRepositoryMetadata(value: unknown): ManagedRepositoryMetadata | undefined { + const record = members(value, REPOSITORY_MEMBERS); + if (record === undefined || record.kind !== "repository" || record.version !== METADATA_VERSION) { + return undefined; + } + const name = text(record.name); + const locator = text(record.locator); + const locatorFingerprint = text(record.locatorFingerprint); + const requestedBase = optionalText(record.requestedBase); + const creationCommit = text(record.creationCommit); + const primaryBranch = text(record.primaryBranch); + const objectFormat = parseObjectFormat(record.objectFormat); + const commonDirectory = text(record.commonDirectory); + if ( + name === undefined || + locator === undefined || + locatorFingerprint === undefined || + !/^[0-9a-f]{64}$/.test(locatorFingerprint) || + requestedBase === undefined || + creationCommit === undefined || + primaryBranch === undefined || + objectFormat === undefined || + commonDirectory === undefined + ) { + return undefined; + } + return Object.freeze({ + kind: "repository" as const, + version: METADATA_VERSION, + name, + locator, + locatorFingerprint, + requestedBase, + creationCommit, + primaryBranch, + objectFormat, + commonDirectory, + }); +} + +/** The worktree sidecar this value describes, or `undefined` when it is none. */ +export function parseWorktreeMetadata(value: unknown): ManagedWorktreeMetadata | undefined { + const record = members(value, WORKTREE_MEMBERS); + if (record === undefined || record.kind !== "worktree" || record.version !== METADATA_VERSION) { + return undefined; + } + const owner = text(record.owner); + const name = text(record.name); + const requestedBranch = text(record.requestedBranch); + const requestedBase = optionalText(record.requestedBase); + const creationCommit = text(record.creationCommit); + const objectFormat = parseObjectFormat(record.objectFormat); + if ( + owner === undefined || + name === undefined || + requestedBranch === undefined || + requestedBase === undefined || + creationCommit === undefined || + objectFormat === undefined + ) { + return undefined; + } + return Object.freeze({ + kind: "worktree" as const, + version: METADATA_VERSION, + owner, + name, + requestedBranch, + requestedBase, + creationCommit, + objectFormat, + }); +} + +/** The bytes one sidecar is written as. Member order is an implementation detail. */ +export function metadataBytes(metadata: ManagedMetadata): string { + return `${JSON.stringify(metadata, null, 2)}\n`; +} diff --git a/packages/workflow/src/deno/run-composition/operations.ts b/packages/workflow/src/deno/run-composition/operations.ts new file mode 100644 index 000000000..f7fc0919c --- /dev/null +++ b/packages/workflow/src/deno/run-composition/operations.ts @@ -0,0 +1,422 @@ +/** + * What an ordinary run does to a checkout, and what it remembers about it. + * + * The three local operations are the workflow provider's own performers, run + * against a real directory instead of an exported materialization. That is + * deliberate reuse rather than a parallel implementation: `` refuses + * a branch another checkout holds, `` stages exactly the pathspecs a + * document wrote, and `` records the index and nothing else — and + * those are the authored semantics, not a workflow detail. + * + * What is different is everything around them. There is no Workspace + * transaction to enclose a person's own repository in, so a failure rolls back + * nothing and this makes no such claim; there is no journal, so nothing is + * retained and nothing replays; and the commit lands in the checkout the person + * is standing in rather than in a root the run owns. + * + * ## Push, and the evidence it leaves + * + * Push keeps the shared observe/adopt/fast-forward/refuse rules exactly. What + * it does *not* keep is the Git-host reconciliation record, because there is no + * history to reconcile against. Instead a verified publication leaves one entry + * in this provider instance's own closure, and that entry is the only thing + * that authorizes a later ``. + * + * The entry is not a Context value, a component result, a middleware answer or + * a journal event. It cannot be copied into another execution, because another + * execution constructs a new provider with an empty list — which is what makes + * "this run published that branch" mean this run. + */ + +import type { Operation } from "effection"; +import { + GitOperationAuthorityError, + GitOperationInfrastructureError, +} from "../../composition/errors.ts"; +import type { + GitAddResult, + GitCheckoutIdentity, + GitCheckoutState, + GitCommitMessageSource, + GitCommitResult, + GitSwitchResult, +} from "../../composition/git-records.ts"; +import { + ANCESTOR, + destinationRefFor, + PUSH_REMOTE, + refspecFor, + type GitPushInputs, + type GitPushOutcome, + type GitPushPreState, +} from "../../composition/git-push-records.ts"; +import { beneath } from "../../composition/parse.ts"; +import type { RepositoryIdentity } from "../../composition/selection.ts"; +import { + GitHostAmbiguousError, + GitHostConflictError, + GitHostUnavailableError, +} from "../../git-host/errors.ts"; +import { ADD } from "../../composition/components/GitAdd.ts"; +import { COMMIT } from "../../composition/components/GitCommit.ts"; +import { PUSH } from "../../composition/components/GitPush.ts"; +import { SWITCH } from "../../composition/components/GitSwitch.ts"; +import { + addPaths, + commitPresent, + currentBranch, + observeRemoteRef, + pushRefspec, + resolveCommit, +} from "../composition/git.ts"; +import type { GitSession } from "../composition/git.ts"; +import { checkoutState, type GitCheckout } from "../composition/operations.ts"; +import { performSwitch } from "../composition/switch.ts"; +import { gitCommitMessageEvidence, performCommit } from "../composition/commit.ts"; +import { useGitAuthentication, type RepositoryHost } from "../composition/host.ts"; +import { gitRefused } from "../composition/refusals.ts"; +import { LivePushEvidenceError } from "./errors.ts"; + +/** + * One checkout this execution may act in. + * + * Registered when a Repository or Worktree is selected, and the ambient one + * when there is one. `identity` is the *repository's*, so every checkout of one + * repository carries the same identity and a working directory selects among + * them by path. + */ +export interface RegisteredCheckout { + /** The canonical host path of the checkout root. */ + readonly root: string; + readonly identity: RepositoryIdentity; + /** The Repository's display name, as a document wrote it or the host found it. */ + readonly repositoryName: string; + /** The Worktree's name, or `null` for a repository's own checkout. */ + readonly worktreeName: string | null; + /** The admitted origin this checkout publishes to, when it has one. */ + readonly origin: string | undefined; +} + +/** What one verified publication proved, held in the provider's closure. */ +export interface PushEvidence { + readonly identity: RepositoryIdentity; + readonly checkoutRoot: string; + readonly origin: string; + readonly branch: string; + readonly destinationRef: string; + readonly commit: string; +} + +/** + * Which registered checkout this repository and working directory select. + * + * The same two observations a workflow operation makes, decided the same way: + * the repository says which checkouts are candidates, and the working directory + * says which of them the element was written in. The longest match wins, so a + * `` inside a linked worktree selects the worktree rather than the + * repository it belongs to. + */ +export function selectCheckout( + registered: readonly RegisteredCheckout[], + identity: RepositoryIdentity, + workingDirectory: string, + operation: string, +): RegisteredCheckout { + let selected: RegisteredCheckout | undefined; + for (const candidate of registered) { + if (candidate.identity.locatorFingerprint !== identity.locatorFingerprint) { + continue; + } + if (!beneath(candidate.root, workingDirectory)) { + continue; + } + if (selected === undefined || candidate.root.length > selected.root.length) { + selected = candidate; + } + } + if (selected === undefined) { + throw new GitOperationAuthorityError( + operation, + "the directory it was written in is inside none of the checkouts this execution selected " + + "for the repository in scope", + ); + } + return selected; +} + +/** The `GitCheckout` the shared performers act on, for a live directory. */ +export function liveCheckout( + git: GitSession, + checkout: RegisteredCheckout, + workingDirectory: string, +): GitCheckout { + const identity: GitCheckoutIdentity = Object.freeze({ + repositoryName: checkout.repositoryName, + worktreeName: checkout.worktreeName, + checkoutPath: checkout.root, + }); + return { + git, + directory: checkout.root, + repositoryDirectory: checkout.root, + workingDirectory, + identity, + }; +} + +export function* liveSwitch( + checkout: GitCheckout, + branch: string, + base: string | undefined, +): Operation { + const before = yield* checkoutState(checkout.git, checkout.directory, SWITCH); + const performed = yield* performSwitch(checkout, branch, base); + const after = yield* checkoutState(checkout.git, checkout.directory, SWITCH); + return Object.freeze({ + checkout: checkout.identity, + requestedBranch: branch, + resolvedBranch: after.branch, + requestedBase: base ?? null, + resolvedBase: performed.resolvedBase, + before, + after, + }); +} + +export function* liveAdd(checkout: GitCheckout, paths: readonly string[]): Operation { + const before = yield* checkoutState(checkout.git, checkout.directory, ADD); + yield* addPaths(checkout.git, { + operation: ADD, + workingDirectory: checkout.workingDirectory, + paths, + }); + const after = yield* checkoutState(checkout.git, checkout.directory, ADD); + return Object.freeze({ checkout: checkout.identity, paths, before, after }); +} + +export function* liveCommit( + checkout: GitCheckout, + message: string, + messageSource: GitCommitMessageSource, +): Operation { + const evidence = gitCommitMessageEvidence(message); + const before: GitCheckoutState = yield* checkoutState(checkout.git, checkout.directory, COMMIT); + const performed = yield* performCommit(checkout, before, message, evidence); + const after = yield* checkoutState(checkout.git, checkout.directory, COMMIT); + return Object.freeze({ + checkout: checkout.identity, + messageSource, + messageDigest: evidence.digest, + messageLength: evidence.length, + parent: performed.parent, + tree: performed.tree, + commit: performed.commit, + committedAt: performed.committedAt, + before, + after, + }); +} + +/** Whether the observed commit is somewhere in the source commit's ancestry. */ +function* provenAncestor( + git: GitSession, + directory: string, + observed: string, + desired: string, +): Operation { + if (!(yield* commitPresent(git, directory, observed))) { + return false; + } + const outcome = yield* git.run(["merge-base", "--is-ancestor", observed, desired], directory); + if (outcome.code === 0) { + return true; + } + if (outcome.code === 1) { + return false; + } + throw new GitOperationInfrastructureError( + PUSH, + "native Git could not decide whether the branch already holds an earlier commit", + ); +} + +/** + * Publish this checkout's current branch, and say what happened. + * + * The same rules the reconciled effect follows, minus the reconciliation. A + * destination that already names this exact commit is adopted rather than + * pushed again; a proven-absent one, and one holding an ancestor of this + * commit, are published once with an exact non-force refspec; anything else is + * a conflict. A host that could not answer proves nothing and is never read as + * absence. + */ +export function* livePush( + host: RepositoryHost, + git: GitSession, + checkout: RegisteredCheckout, +): Operation<{ outcome: GitPushOutcome; evidence: PushEvidence }> { + if (checkout.origin === undefined) { + throw new GitOperationAuthorityError( + 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", + ); + } + const branch = yield* currentBranch(git, checkout.root); + if (branch === undefined) { + gitRefused(PUSH, "unnamed-branch"); + } + const sourceCommit = yield* resolveCommit(git, checkout.root, "HEAD"); + if (sourceCommit === undefined) { + throw new GitOperationInfrastructureError( + PUSH, + "the checkout it ran in did not report the commit its branch holds", + ); + } + const destinationRef = destinationRefFor(branch); + const inputs: GitPushInputs = Object.freeze({ + repository: checkout.identity, + remote: PUSH_REMOTE, + branch, + destinationRef, + sourceCommit, + }); + + // One session for this publication, opened after the local checks above and + // released with the scope this operation runs in. + const session = yield* useGitAuthentication(host, checkout.origin); + const observed = yield* observeRemoteRef( + git, + checkout.root, + checkout.origin, + destinationRef, + checkout.identity.objectFormat, + session, + ); + if (observed.state === "unreachable") { + // Not absence. A host that could not answer has proven nothing, and + // offering silence as absence is what would authorize a duplicate push. + throw new GitHostUnavailableError(); + } + if (observed.state === "ambiguous") { + throw new GitHostAmbiguousError(); + } + + const evidence: PushEvidence = Object.freeze({ + identity: checkout.identity, + checkoutRoot: checkout.root, + origin: checkout.origin, + branch, + destinationRef, + commit: sourceCommit, + }); + + if (observed.state === "present" && observed.commit === sourceCommit) { + return { + outcome: { + decision: "adopted", + result: resultOf(inputs, observed.commit), + }, + evidence, + }; + } + + const preState: GitPushPreState = + observed.state === "absent" + ? { remoteCommit: null } + : (yield* provenAncestor(git, checkout.root, observed.commit, sourceCommit)) + ? { remoteCommit: observed.commit, relation: ANCESTOR } + : { remoteCommit: observed.commit }; + if (preState.remoteCommit !== null && !("relation" in preState)) { + // The destination names a commit this branch does not contain. Publishing + // over it would replace somebody's work rather than advance the branch. + throw new GitHostConflictError(); + } + + const accepted = yield* pushRefspec( + git, + checkout.root, + checkout.origin, + refspecFor(sourceCommit, destinationRef), + session, + ); + if (!accepted) { + throw new GitHostUnavailableError(); + } + // One exact observation afterwards decides the outcome, never the status of + // the command: what a push left at the destination is a question about the + // destination. + const settled = yield* observeRemoteRef( + git, + checkout.root, + checkout.origin, + destinationRef, + checkout.identity.objectFormat, + session, + ); + if (settled.state !== "present" || settled.commit !== sourceCommit) { + throw new GitHostUnavailableError(); + } + return { + outcome: { decision: "performed", result: resultOf(inputs, settled.commit) }, + evidence, + }; +} + +function resultOf(inputs: GitPushInputs, observedRemoteCommit: string) { + return Object.freeze({ + repository: inputs.repository, + remote: inputs.remote, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + refspec: refspecFor(inputs.sourceCommit, inputs.destinationRef), + sourceCommit: inputs.sourceCommit, + observedRemoteCommit, + }); +} + +/** + * That this execution published the branch a pull request would name. + * + * Every member has to match, and the *last* entry for a destination is the one + * that decides: a loop that commits, pushes, commits and pushes again leaves a + * sequence, and what a pull request is opened against is where that sequence + * ended. A push of another checkout, repository, origin, destination, branch or + * commit is irrelevant rather than disagreement. + */ +export function admitLivePushEvidence( + held: readonly PushEvidence[], + expected: Omit & { readonly commit: string }, +): void { + let published: "this head" | "another commit" | undefined; + for (const entry of held) { + if ( + entry.identity.locatorFingerprint !== expected.identity.locatorFingerprint || + entry.checkoutRoot !== expected.checkoutRoot || + entry.origin !== expected.origin || + entry.branch !== expected.branch || + entry.destinationRef !== expected.destinationRef + ) { + continue; + } + published = entry.commit === expected.commit ? "this head" : "another commit"; + } + if (published === "this head") { + return; + } + if (published === undefined) { + throw new LivePushEvidenceError( + "missing-push-evidence", + "this execution holds no successful result for the branch and commit it would " + + "open a pull request from. Write before : a pull request names " + + "work this execution published, and publishing it is an explicit act.", + ); + } + throw new LivePushEvidenceError( + "conflicting-push-evidence", + "this execution published that branch at a different commit than the one the checkout is on " + + "now, so a pull request opened from it would name a head this execution never published.", + ); +} + +export type { GitCheckout }; diff --git a/packages/workflow/src/deno/run-composition/placement.ts b/packages/workflow/src/deno/run-composition/placement.ts new file mode 100644 index 000000000..a9aecdfcc --- /dev/null +++ b/packages/workflow/src/deno/run-composition/placement.ts @@ -0,0 +1,83 @@ +/** + * Where an ordinary run keeps the repositories and worktrees it manages. + * + * A workflow run's checkouts live inside the run's own Workspace, so their + * placement is Workspace-relative and their lifetime is the run's. An ordinary + * run has no Workspace and no run. Its checkouts are the person's work — a + * branch they will look at tomorrow, a worktree an agent is still editing — so + * they live under one host root, they survive every execution, and nothing here + * ever deletes one. + * + * ## Every authored string is a digest + * + * A document may name a Repository `../etc`, and a locator may be anything Git + * accepts. Neither reaches a path: a slot is a SHA-256 digest of the whole + * identity and nothing else, so no arrangement of authored characters can name + * another slot or escape the root. That costs legibility — the layout is not + * browsable by name — and buys the one property a shared host root has to have. + * + * The encoding under each digest is length-prefixed for the same reason the + * durable Git-operation fingerprint is: any character may appear in a name or a + * locator, so a separator scheme would let one pair of values produce the slot + * that belongs to another pair. + */ + +import { createHash } from "node:crypto"; + +/** The layout, relative to whichever root the entrypoint chose. */ +export const REPOSITORIES = "repositories"; +export const WORKTREES = "worktrees"; +export const LOCKS = "locks"; + +/** What every slot holds: the checkout itself, and the sidecar describing it. */ +export const CHECKOUT = "checkout"; +export const METADATA = "metadata.json"; + +function digest(...values: readonly string[]): string { + const canonical = values.map((value) => `${value.length}:${value}`).join(""); + return createHash("sha256").update(canonical, "utf8").digest("hex"); +} + +/** + * A managed Repository's slot, named by the locator and the name together. + * + * Both, because both are identity: two documents that name the same url + * `project` and `review` are asking for two checkouts, and two that name + * different urls `project` are asking for two more. + */ +export function repositorySlot(root: string, locator: string, name: string): string { + return `${root}/${REPOSITORIES}/${digest(locator, name)}`; +} + +/** + * A Worktree's slot, named by the repository it belongs to and its own name. + * + * The owner is identified by its canonical common directory rather than by its + * locator, so a Worktree of the repository the caller is standing in and a + * Worktree of a managed clone of the same url are different slots — as they + * must be, since they are linked checkouts of different `.git` directories. + */ +export function worktreeSlot(root: string, commonDirectory: string, name: string): string { + return `${root}/${WORKTREES}/${digest(commonDirectory)}/${digest(name)}`; +} + +/** The checkout inside a slot. */ +export function checkoutOf(slot: string): string { + return `${slot}/${CHECKOUT}`; +} + +/** The metadata sidecar inside a slot. */ +export function metadataOf(slot: string): string { + return `${slot}/${METADATA}`; +} + +/** + * The lock sidecar for one slot. + * + * Outside the slot, because a lock file inside a directory this provider may be + * about to create would be part of the thing it is protecting. Different slots + * never share one: the digest is of the whole slot identity, kind included. + */ +export function lockOf(root: string, kind: "repository" | "worktree", slot: string): string { + return `${root}/${LOCKS}/${kind}/${digest(slot)}.lock`; +} diff --git a/packages/workflow/src/deno/run-composition/provider.ts b/packages/workflow/src/deno/run-composition/provider.ts new file mode 100644 index 000000000..931e60565 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/provider.ts @@ -0,0 +1,490 @@ +/** + * The ordinary run's repository provider: what `xmd run` installs under Deno + * and inside the compiled binary. + * + * It answers the same four Apis the workflow provider answers, so a document + * writes the same thirteen components either way. What differs is everything + * about lifetime and authority. + * + * A workflow run's checkouts are rows in its own database, restored from + * retained history under a WorkflowRun the document must never be able to name. + * An ordinary run's checkouts are directories: the one the person is standing + * in, and the ones under the managed root, each held for the execution by an + * advisory lock and each surviving it. Nothing is journaled, nothing replays, + * and a second `xmd run` is a second question rather than a resumption. + * + * ## What this instance holds, and what a document can reach + * + * Four private things, all in this closure: a fresh opaque invocation identity, + * the selection registry, the checkouts this execution registered, and the + * evidence of every publication it verified. None of them is a prop, a Context + * value, a middleware answer, a component result or a journal event, and none + * survives the execution. That is what makes "this run pushed that branch" mean + * this run — a `--journal` file, a copied binding and a previous execution's + * output all grant exactly nothing. + * + * The engine's own `Expansion.id` names the authored site inside that one + * invocation, which is what the live Issue and pull-request idempotency keys + * are built from. + * + * ## Discovery costs nothing until something asks + * + * The ambient repository is discovered once, before root expansion, from the + * directory the command was run in. Being outside a repository is not a startup + * failure: it is remembered as "there is none", and only an element that needs + * one refuses. + */ + +import type { Operation } from "effection"; +import { randomUUID } from "node:crypto"; +import { getExpansion } from "@executablemd/core"; +import { RepositoryComposition } from "../../composition/api.ts"; +import type { RepositoryRequest, WorktreeRequest } from "../../composition/api.ts"; +import { GitComposition } from "../../composition/git-api.ts"; +import type { + GitAddInvocation, + GitCommitInvocation, + GitPushInvocation, + GitSwitchInvocation, +} from "../../composition/git-api.ts"; +import { GitOperationAuthorityError, RepositorySelectionError } from "../../composition/errors.ts"; +import type { + GitAddResult, + GitCommitResult, + GitSwitchResult, +} from "../../composition/git-records.ts"; +import { destinationRefFor, type GitPushOutcome } from "../../composition/git-push-records.ts"; +import { admitPathspecs } from "../../composition/components/GitAdd.ts"; +import { admitCommitMessage } from "../../composition/components/GitCommit.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import { + PullRequestOperations, + type PullRequestReadInvocation, + type PullRequestUpsertInvocation, +} from "../../composition/pull-request-operations.ts"; +import type { PullRequestReadResult } from "../../composition/pull-request-read-records.ts"; +import type { + PullRequestInputs, + PullRequestResult, +} from "../../composition/pull-request-records.ts"; +import { PullRequestAuthorityError } from "../../composition/errors.ts"; +import type { RepositoryIdentity, RepositorySelection } from "../../composition/selection.ts"; +import { IssueApi } from "../../issue/api.ts"; +import type { IssueDetails, IssueReference } from "../../issue/api.ts"; +import { + IssueOperations, + type IssueReadInvocation, + type IssueUpsertInvocation, +} from "../../issue/operations.ts"; +import { issueIdempotencyKey, parseIssueDetails, parseIssueRecord } from "../../issue/records.ts"; +import { IssueProtocolError } from "../../issue/errors.ts"; +import { locatorFingerprint } from "../composition/locator.ts"; +import { currentBranch, gitSession, resolveCommit, type GitSession } from "../composition/git.ts"; +import { denoRepositoryHost, type RepositoryHost } from "../composition/host.ts"; +import type { GitAuthentication } from "../composition/authentication.ts"; +import type { HelperAssembly } from "../composition/credential-helper.ts"; +import { denoGitHubSource, type GitHubSource } from "../composition/github.ts"; +import { + useGitHubPullRequestReads, + type GitHubPullRequestsOptions, +} from "../composition/pull-request-reads.ts"; +import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; +import { selectionRegistry } from "../selections.ts"; +import { discoverAmbientRepository, type AmbientRepository } from "./ambient.ts"; +import { selectManagedRepository, selectManagedWorktree } from "./checkouts.ts"; +import { NoAmbientRepositoryError } from "./errors.ts"; +import { useLeases } from "./leases.ts"; +import { liveUpsertPullRequest } from "./pull-request.ts"; +import { + admitLivePushEvidence, + liveAdd, + liveCheckout, + liveCommit, + livePush, + liveSwitch, + selectCheckout, + type PushEvidence, + type RegisteredCheckout, +} from "./operations.ts"; +import { repositorySlot, worktreeSlot } from "./placement.ts"; + +export interface RunCompositionOptions { + /** Where managed checkouts live. Production passes `~/.xmd/repositories`. */ + readonly root: string; + /** The directory the command was run in, which ambient discovery starts from. */ + readonly cwd: string; + readonly host?: RepositoryHost; + readonly authentication?: GitAuthentication; + readonly helper?: HelperAssembly; + /** What GitHub issue handling this host installs, and what it may reach. */ + readonly gitHubIssues?: GitHubIssuesOptions; + /** The pull-request destinations this host allows a document to read. */ + readonly gitHubPullRequests?: GitHubPullRequestsOptions; +} + +/** What a Repository selection names: its checkout, and how to publish from it. */ +interface SelectedRepository { + readonly checkout: RegisteredCheckout; + /** The repository checkout `worktree add` runs in. */ + readonly ownerCheckout: string; + /** The canonical common Git directory this repository's worktrees key on. */ + readonly commonDirectory: string; +} + +/** + * Install the ordinary repository vocabulary for the current scope and below. + * + * One call rather than four, because the four Apis share this instance's + * private state and installing some without the rest would leave a document + * committing in a checkout no `` could be authorized against. + */ +export function* useRunComposition(options: RunCompositionOptions): Operation { + const host = + options.host ?? + denoRepositoryHost({ + ...(options.authentication === undefined ? {} : { authentication: options.authentication }), + ...(options.helper === undefined ? {} : { helper: options.helper }), + }); + // The Git session's root is also `HOME`, so Git reads no configuration + // belonging to whoever is running the command — the same isolation a workflow + // run gets, applied to a repository the caller owns. + const home = yield* host.useDirectory(); + const git: GitSession = gitSession(host, home); + + const leases = yield* useLeases(options.root); + const selections = selectionRegistry(); + const registered: RegisteredCheckout[] = []; + const evidence: PushEvidence[] = []; + // Fresh, opaque and never derived from anything a document wrote. It names + // this execution to a service; it is not addressable, reusable or observable. + const invocation = randomUUID(); + + // Once, before root expansion. A repository this command was not run inside + // is remembered as absent rather than refused, so a document that never asks + // for one runs exactly as it would anywhere else. + const ambient = yield* discoverAmbientRepository(git, options.cwd); + const ambientSelection = + ambient === undefined ? undefined : registerAmbient(ambient, selections, registered); + + yield* RepositoryComposition.around( + { + *selectRepository([request]: [RepositoryRequest]): Operation { + const slot = repositorySlot(options.root, request.locator, request.name); + yield* leases.hold("repository", slot, `repository ${JSON.stringify(request.name)}`); + const managed = yield* selectManagedRepository(git, host, slot, request); + const identity: RepositoryIdentity = Object.freeze({ + name: request.name, + locatorFingerprint: managed.metadata.locatorFingerprint, + requestedBase: managed.metadata.requestedBase, + creationCommit: managed.creationCommit, + primaryBranch: managed.metadata.primaryBranch, + objectFormat: managed.objectFormat, + }); + const checkout: RegisteredCheckout = Object.freeze({ + root: managed.checkout, + identity, + repositoryName: request.name, + worktreeName: null, + origin: managed.metadata.locator, + }); + register(registered, checkout); + return selections.mint(slot, request.name, identity, managed.checkout, { + checkout, + ownerCheckout: managed.checkout, + commonDirectory: managed.commonDirectory, + }); + }, + + *selectWorktree([repository, request]: [ + RepositorySelection, + WorktreeRequest, + ]): Operation { + const owner = selections.authenticate( + repository, + () => new RepositorySelectionError(""), + ); + const slot = worktreeSlot(options.root, owner.commonDirectory, request.name); + yield* leases.hold("worktree", slot, `worktree ${JSON.stringify(request.name)}`); + const managed = yield* selectManagedWorktree(git, slot, { + name: request.name, + branch: request.branch, + base: request.base, + owner: owner.commonDirectory, + ownerCheckout: owner.ownerCheckout, + }); + // The owner's identity, because that is the repository this checkout + // belongs to; the worktree's own name and path, because that is which + // checkout of it this selection points at. + const checkout: RegisteredCheckout = Object.freeze({ + root: managed.checkout, + identity: owner.checkout.identity, + repositoryName: owner.checkout.repositoryName, + worktreeName: request.name, + origin: owner.checkout.origin, + }); + register(registered, checkout); + return selections.mint(slot, request.name, owner.checkout.identity, managed.checkout, { + checkout, + ownerCheckout: owner.ownerCheckout, + commonDirectory: owner.commonDirectory, + }); + }, + + // deno-lint-ignore require-yield + *ambientRepository(): Operation { + if (ambientSelection === undefined) { + // This profile *has* ambient repositories; this invocation is not in + // one. The refusal says how to run inside one rather than reporting + // an absent provider, which is what Node and Bun report instead. + throw new NoAmbientRepositoryError("this element"); + } + return ambientSelection; + }, + }, + { at: "min" }, + ); + + function place( + invocationRepository: RepositorySelection, + workingDirectory: string, + operation: string, + ): RegisteredCheckout { + const selected = selections.authenticate( + invocationRepository, + () => + new GitOperationAuthorityError( + operation, + "the Repository in scope is not one this execution selected, so it names no checkout", + ), + ); + return selectCheckout(registered, selected.checkout.identity, workingDirectory, operation); + } + + yield* GitComposition.around( + { + *switchBranch([invocation_]: [GitSwitchInvocation]): Operation { + const checkout = place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + return yield* liveSwitch( + liveCheckout(git, checkout, invocation_.workingDirectory), + invocation_.branch, + invocation_.base, + ); + }, + + *addPaths([invocation_]: [GitAddInvocation]): Operation { + const checkout = place(invocation_.repository, invocation_.workingDirectory, ""); + // Admitted where a request enters, exactly as the retained provider + // admits it: the Api is public, and a caller reaching it directly is + // subject to the same boundary. + return yield* liveAdd( + liveCheckout(git, checkout, invocation_.workingDirectory), + admitPathspecs(invocation_.paths), + ); + }, + + *commitIndex([invocation_]: [GitCommitInvocation]): Operation { + const checkout = place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); + return yield* liveCommit( + liveCheckout(git, checkout, invocation_.workingDirectory), + admitCommitMessage(invocation_.message), + invocation_.messageSource, + ); + }, + + *pushCurrentBranch([invocation_]: [GitPushInvocation]): Operation { + const checkout = place(invocation_.repository, invocation_.workingDirectory, ""); + const published = yield* livePush(host, git, checkout); + // Only after the provider has verified a performed or adopted + // publication. A refused or unreadable one leaves no entry, so nothing + // it did authorizes a pull request. + evidence.push(published.evidence); + return published.outcome; + }, + }, + { at: "min" }, + ); + + // The transport middlewares both profiles share, installed beneath the + // ordinary lifecycle below. Absent configuration installs no matching + // provider, and a document naming one then reaches the surface's own error. + if (options.gitHubIssues !== undefined) { + yield* useGitHubIssues(options.gitHubIssues); + } + yield* useGitHubPullRequestReads(options.gitHubPullRequests ?? {}); + + yield* IssueOperations.around( + { + *read([request]: [IssueReadInvocation]): Operation { + const answered = yield* IssueApi.operations.read(request.url, { + ...(request.provider === undefined ? {} : { provider: request.provider }), + }); + const details = parseIssueDetails(answered); + if (details === undefined) { + throw new IssueProtocolError( + "the issue provider answered a read with something that is not an issue's shared " + + "fields", + ); + } + return details; + }, + + *upsert([request]: [IssueUpsertInvocation]): Operation { + const expansion = yield* getExpansion(); + const answered = yield* IssueApi.operations.upsert(request.issue, { + url: request.target, + ...(request.provider === undefined ? {} : { provider: request.provider }), + // This execution and this authored site. A provider carries it + // wherever its service can hold a mark, which is how "already + // created" is answered inside one run without a local record. + idempotencyKey: issueIdempotencyKey( + { runId: invocation, expansionId: expansion.id }, + "upsert", + request.target, + ), + }); + const record = parseIssueRecord(answered); + if (record === undefined) { + throw new IssueProtocolError( + "the issue provider answered an upsert with something that is not a URL", + ); + } + return record; + }, + }, + { at: "min" }, + ); + + const source: GitHubSource = + options.gitHubPullRequests?.access ?? + (options.gitHubPullRequests?.endpoint === undefined + ? denoGitHubSource() + : denoGitHubSource(options.gitHubPullRequests.endpoint)); + + yield* PullRequestOperations.around( + { + // Afresh, every execution. There is nothing to retain a read in and + // nothing that would replay one, so what a document binds is what the + // pull request holds now. + *read([request]: [PullRequestReadInvocation]): Operation { + return yield* PullRequestAPI.operations.read(request.url, { + kind: request.kind, + ...(request.provider === undefined ? {} : { provider: request.provider }), + }); + }, + + *upsert([request]: [PullRequestUpsertInvocation]): Operation { + const checkout = place(request.repository, request.workingDirectory, PULL_REQUEST_ELEMENT); + if (checkout.origin === undefined) { + throw new PullRequestAuthorityError( + "no-repository-context", + "the checkout it selected records no usable origin, so there is no repository at a " + + "Git host for a pull request to be opened in.", + ); + } + const headBranch = yield* currentBranch(git, checkout.root); + if (headBranch === undefined) { + throw new PullRequestAuthorityError( + "unnamed-branch", + "the checkout it selected has no branch checked out, so there is no head branch to " + + "open a pull request from — and a detached HEAD is not something this run could " + + "have published.", + ); + } + const headSha = yield* resolveCommit(git, checkout.root, "HEAD"); + if (headSha === undefined) { + throw new PullRequestAuthorityError( + "unnamed-branch", + "the checkout it selected did not report the commit its branch holds.", + ); + } + // Before a credential is read and before anything is sent. What + // authorizes a pull request is this execution's own record of + // publishing the branch. + admitLivePushEvidence(evidence, { + identity: checkout.identity, + checkoutRoot: checkout.root, + origin: checkout.origin, + branch: headBranch, + destinationRef: destinationRefFor(headBranch), + commit: headSha, + }); + + const inputs: PullRequestInputs = Object.freeze({ + repository: checkout.identity, + number: request.pullRequest.number, + title: request.pullRequest.title, + body: request.pullRequest.body, + draft: request.pullRequest.draft, + headBranch, + headSha, + baseBranch: request.pullRequest.base, + }); + const access = yield* source.open(); + return yield* liveUpsertPullRequest(access, checkout.origin, inputs); + }, + }, + { at: "min" }, + ); +} + +/** Register a checkout, replacing an earlier registration of the same root. */ +function register(registered: RegisteredCheckout[], checkout: RegisteredCheckout): void { + const existing = registered.findIndex((entry) => entry.root === checkout.root); + if (existing < 0) { + registered.push(checkout); + return; + } + registered[existing] = checkout; +} + +/** + * The ambient repository, as a selection every element outside a `` + * receives. + * + * Its identity is the origin when it records one and its own Git directory + * otherwise, because that is what identifies a repository with no remote. The + * creation commit is the commit HEAD named when this execution started: it is + * the instant this identity was pinned at, and — like every other member — it + * says nothing about where HEAD is now. + */ +function registerAmbient( + ambient: AmbientRepository, + selections: ReturnType>, + registered: RegisteredCheckout[], +): RepositorySelection { + const identity: RepositoryIdentity = Object.freeze({ + name: ambient.name, + locatorFingerprint: ambient.originFingerprint ?? locatorFingerprint(ambient.commonDirectory), + requestedBase: null, + creationCommit: ambient.head, + primaryBranch: ambient.defaultBranch, + objectFormat: ambient.objectFormat, + }); + const checkout: RegisteredCheckout = Object.freeze({ + root: ambient.checkoutRoot, + identity, + repositoryName: ambient.name, + // The checkout the command was run in, whether that is the repository's + // primary one or a linked worktree somebody made by hand. Either way it is + // the repository's own checkout as far as this execution is concerned. + worktreeName: null, + origin: ambient.origin, + }); + register(registered, checkout); + return selections.mint( + `ambient ${ambient.commonDirectory} ${ambient.checkoutRoot}`, + ambient.name, + identity, + ambient.checkoutRoot, + { checkout, ownerCheckout: ambient.checkoutRoot, commonDirectory: ambient.commonDirectory }, + ); +} diff --git a/packages/workflow/src/deno/run-composition/pull-request.ts b/packages/workflow/src/deno/run-composition/pull-request.ts new file mode 100644 index 000000000..204c967a3 --- /dev/null +++ b/packages/workflow/src/deno/run-composition/pull-request.ts @@ -0,0 +1,153 @@ +/** + * `` under an ordinary run: the same reconciliation, no history. + * + * A workflow run reconciles a pull request through the shared Git-host state + * machine, which exists so that an interrupted attempt is adopted on the next + * *execution of the same run*. An ordinary run has no next execution: a second + * `xmd run` is a second question, not a resumption. So the state machine's + * durability has nothing to be durable about, and what is left is the part that + * was always about GitHub — observe once, adopt what already says this, create + * or update once, and decide by one exact observation afterwards. + * + * That part is not reimplemented here. `gitHubPullRequests()` is the same + * adapter the workflow provider drives, with the same filtered listing, the same + * refusal of an unreadable candidate, and the same normalization; what this + * module owns is the ordering above it. + * + * ## Interruption, said plainly + * + * Inside one execution an attempt is made at most once. Across a process + * interruption there is no exactly-once claim at all: GitHub may have accepted a + * change for which this run recorded no result, and the next run observes what + * is there and decides from that. Nothing pretends otherwise, and nothing is + * retained that would let it. + */ + +import type { Operation } from "effection"; +import { GitOperationInfrastructureError } from "../../composition/errors.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import { + pullRequestAgrees, + pullRequestResultOf, + type PullRequestInputs, + type PullRequestResult, + type PullRequestSnapshot, +} from "../../composition/pull-request-records.ts"; +import { + GitHostAmbiguousError, + GitHostConflictError, + GitHostUnavailableError, +} from "../../git-host/errors.ts"; +import { GitHostProviderError } from "../../git-host/errors.ts"; +import { gitHubPullRequests, parseGitHubRepository } from "../composition/github.ts"; +import type { GitHubAccess } from "../composition/github.ts"; + +function unusable(reason: string): never { + throw new GitOperationInfrastructureError(PULL_REQUEST_ELEMENT, reason); +} + +/** + * Bring exactly one pull request to what this invocation says, once. + * + * The caller has already proved that this execution published the head branch; + * everything here is about the pull request itself. + */ +export function* liveUpsertPullRequest( + access: GitHubAccess, + locator: string, + inputs: PullRequestInputs, +): Operation { + const name = parseGitHubRepository(locator); + if (name === undefined) { + throw new GitHostProviderError( + "this Git host adapter opens pull requests only for repositories on github.com", + ); + } + const pulls = gitHubPullRequests(access, name, inputs.repository.objectFormat); + + const observed = yield* pulls.observe(inputs); + if (observed.state === "unavailable") { + // Not absence. A host that could not answer has proven nothing, and + // offering silence as absence is what would open a second pull request or + // rewrite one this invocation never saw. + throw new GitHostUnavailableError(); + } + if (observed.state === "ambiguous") { + throw new GitHostAmbiguousError(); + } + if (observed.state === "conflict") { + throw new GitHostConflictError(); + } + if (observed.state === "absent") { + // Only an unnumbered request can reach this: a number that named nothing + // provable is unavailable rather than absent, above. + if (inputs.number !== null) { + unusable("a numbered pull request cannot be created"); + } + return yield* created(pulls, inputs); + } + + const found = observed.pullRequest; + if (pullRequestAgrees(found, inputs)) { + // Everything this invocation asks for is already true — the no-op an + // unchanged document means, and the adoption an interrupted earlier attempt + // leaves behind. + return pullRequestResultOf(inputs, found); + } + if (inputs.number === null) { + // One open pull request for this branch pair, saying something else. An + // unnumbered request asks for one to exist, not for whatever is there to + // become this. + throw new GitHostConflictError(); + } + if (found.number !== inputs.number) { + unusable("the pull request this attempt would update is not the one it observed"); + } + return yield* updated(pulls, inputs, found); +} + +type Adapter = ReturnType; + +/** One creation, and one observation if its outcome is uncertain. */ +function* created(pulls: Adapter, inputs: PullRequestInputs): Operation { + const attempt = yield* pulls.create(inputs); + if (attempt.state === "settled") { + if (!pullRequestAgrees(attempt.pullRequest, inputs)) { + unusable("the Git host created a pull request other than the one it was asked for"); + } + return pullRequestResultOf(inputs, attempt.pullRequest); + } + if (attempt.state === "unreadable") { + unusable("the Git host answered the creation with something this boundary cannot read"); + } + + // A race, a rejection or a failure with no word for it: what happened is + // decided by observing once, never by a second attempt to create. + const observed = yield* pulls.observe(inputs); + if (observed.state === "found" && pullRequestAgrees(observed.pullRequest, inputs)) { + return pullRequestResultOf(inputs, observed.pullRequest); + } + throw new GitHostUnavailableError(); +} + +/** The required mutations, once each, and the one observation that decides. */ +function* updated( + pulls: Adapter, + inputs: PullRequestInputs, + before: PullRequestSnapshot, +): Operation { + const attempt = yield* pulls.update(inputs, before); + if (attempt.state === "unreadable") { + unusable("the Git host answered the update with something this boundary cannot read"); + } + if (attempt.state === "uncertain" || !pullRequestAgrees(attempt.pullRequest, inputs)) { + // A rejected mutation, a partial multi-call update and a host that could + // not be read afterwards are one answer: this attempt did not reach the + // requested state. Nothing is repeated here. + throw new GitHostUnavailableError(); + } + if (attempt.pullRequest.number !== before.number) { + unusable("the Git host answered with a pull request other than the one being updated"); + } + return pullRequestResultOf(inputs, attempt.pullRequest); +} diff --git a/packages/workflow/src/deno/selections.ts b/packages/workflow/src/deno/selections.ts new file mode 100644 index 000000000..e2b59f11b --- /dev/null +++ b/packages/workflow/src/deno/selections.ts @@ -0,0 +1,105 @@ +/** + * A provider's private map from the selections it minted to what it holds + * behind them. + * + * `RepositorySelection` is composition data: a document may bind one, render + * one, hand one to a child, and — since it is an ordinary frozen object — build + * one that looks exactly like it. So nothing a provider does may be authorized + * by the value it was handed. This is the other half of that contract: the + * provider keeps the authority here, in its own closure, and every operation + * asks this registry what a selection names before it touches Git or a service. + * + * The identifier is random and opaque. It is not derived from the name, the + * locator, the path or anything else a document can see, so a selection cannot + * be constructed — only reproduced from one the provider already handed out. + * Reproducing one is enough to name a target, which is exactly as much as a + * selection is meant to carry: the registry answers with what the provider + * itself recorded, never with what the caller's copy claims. + * + * The comparison is total, not a spot check. An identifier that matches while + * a name, a checkout path or one member of the identity does not is a value + * somebody edited, and acting on the provider's own record while the caller + * believes it named something else is exactly the confusion a selection must + * not be able to cause. + */ + +import { randomUUID } from "node:crypto"; +import { + REPOSITORY_IDENTITY_MEMBERS, + repositorySelection, + type RepositoryIdentity, + type RepositorySelection, +} from "../composition/selection.ts"; + +export interface SelectionRegistry { + /** + * The selection naming this target, minted once per key. + * + * Selecting the same target twice in one execution answers with the same + * selection, so a provider recognizes a lease it is already holding rather + * than acquiring a second one. What is held behind it is replaced, because + * the second selection revalidated and its facts are the newer ones. + */ + mint( + key: string, + name: string, + identity: RepositoryIdentity, + checkoutPath: string, + held: T, + ): RepositorySelection; + + /** + * What this selection names, or the caller's own refusal. + * + * The refusal is the caller's because the vocabulary is: `` and + * `` describe an unusable Repository in different words, and a + * registry that invented one would be a second way for the same condition to + * be reported. + */ + authenticate(selection: RepositorySelection, refuse: () => Error): T; +} + +interface Entry { + readonly selection: RepositorySelection; + held: T; +} + +export function selectionRegistry(): SelectionRegistry { + const byKey = new Map>(); + const byIdentifier = new Map>(); + + return { + mint(key, name, identity, checkoutPath, held) { + const existing = byKey.get(key); + if (existing !== undefined) { + existing.held = held; + return existing.selection; + } + const entry: Entry = { + selection: repositorySelection(randomUUID(), name, identity, checkoutPath), + held, + }; + byKey.set(key, entry); + byIdentifier.set(entry.selection.selection, entry); + return entry.selection; + }, + + authenticate(selection, refuse) { + const entry = byIdentifier.get(selection.selection); + if (entry === undefined) { + throw refuse(); + } + const minted = entry.selection; + if ( + selection.name !== minted.name || + selection.checkoutPath !== minted.checkoutPath || + !REPOSITORY_IDENTITY_MEMBERS.every( + (member) => selection.identity[member] === minted.identity[member], + ) + ) { + throw refuse(); + } + return entry.held; + }, + }; +} diff --git a/packages/workflow/src/deno/workspace/host.ts b/packages/workflow/src/deno/workspace/host.ts index cba1bf0fd..5d46e666c 100644 --- a/packages/workflow/src/deno/workspace/host.ts +++ b/packages/workflow/src/deno/workspace/host.ts @@ -50,8 +50,11 @@ import { useWorkflowElicitation } from "../../suspension/elicitation.ts"; import { useGitComposition, useRepositoryComposition, + workflowSelections, type CompositionProviderOptions, } from "../composition/provider.ts"; +import { useRetainedPullRequestOperations } from "../composition/pull-request-operations.ts"; +import { useRetainedIssueOperations } from "../../issue/effect.ts"; import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; import type { HelperAssembly } from "../composition/credential-helper.ts"; import { withWorkspaceEffects } from "./effect.ts"; @@ -154,15 +157,24 @@ export function withWorkflowWorkspace( scoped(function* () { yield* useLogicalWorkspaceCwd(); yield* useWorkflowFiles(database); + // One registry for the whole attachment: `` is handed what + // `` minted, and two registries would be two providers that + // could not recognize each other's selections. + const selections = options.composition?.selections ?? workflowSelections(); const composition = { ...options.composition, ...(options.helper === undefined ? {} : { helper: options.helper }), + selections, }; yield* useRepositoryComposition(database, composition); yield* useGitComposition(database, composition); if (options.gitHubIssues !== undefined) { yield* useGitHubIssues(options.gitHubIssues); } + // The retained lifecycle for both service-reaching vocabularies, above + // whichever transport middleware this host installed for them. + yield* useRetainedIssueOperations(); + yield* useRetainedPullRequestOperations(); yield* useCompositionComponents(); // Ordinary middleware, installed the way the Issue adapter is: it owns // the URLs it recognizes and delegates the rest. @@ -173,6 +185,7 @@ export function withWorkflowWorkspace( database, composition.host ?? denoRepositoryHost(), options.gitHubPullRequests ?? {}, + selections, ); // After the composition components and inside this attachment: a // completed replay never reaches here, so it registers no second `Elicit` diff --git a/packages/workflow/src/issue/effect.ts b/packages/workflow/src/issue/effect.ts index 962134bdf..0ee0091ed 100644 --- a/packages/workflow/src/issue/effect.ts +++ b/packages/workflow/src/issue/effect.ts @@ -48,6 +48,8 @@ import { getWorkflowRun, retainedIssueIdentitiesHere } from "../run.ts"; import { claimRetainedIssueIdentity, exhaustRetainedIssueIdentities } from "./identities.ts"; import { ISSUE_EFFECT } from "./effect-type.ts"; import { IssueApi } from "./api.ts"; +import { IssueOperations } from "./operations.ts"; +import type { IssueReadInvocation, IssueUpsertInvocation } from "./operations.ts"; import type { IssueDetails, IssueInput, IssueReference } from "./api.ts"; import { IssueProtocolError } from "./errors.ts"; import { @@ -226,3 +228,22 @@ function* attempt( } export { issueRequestJson }; + +/** + * Install the retained Issue lifecycle for the current scope and below. + * + * What a workflow run adds to the transport underneath is exactly the envelope + * above: one durable effect per operation, named by this run and this + * expansion. `` asks `IssueOperations`, this answers, and the installed + * `IssueApi` middleware still owns which service is reached and what a + * credential may see. + */ +export function useRetainedIssueOperations(): Operation { + return IssueOperations.around( + { + read: ([invocation]: [IssueReadInvocation]) => readIssue(invocation), + upsert: ([invocation]: [IssueUpsertInvocation]) => upsertIssue(invocation), + }, + { at: "min" }, + ); +} diff --git a/packages/workflow/src/issue/operations.ts b/packages/workflow/src/issue/operations.ts new file mode 100644 index 000000000..0781be6b1 --- /dev/null +++ b/packages/workflow/src/issue/operations.ts @@ -0,0 +1,81 @@ +/** + * The profile-level Issue Api: what `` asks, before any transport hears + * about it. + * + * `IssueApi` is the transport surface — GitHub's middleware recognizes its own + * URLs, holds a target to the host's ceiling, and normalizes what comes back. + * This is the layer above it, and what it owns is *lifecycle*: whether one + * question is asked once and retained, or asked once per run. + * + * The two profiles answer that differently, which is why the seam exists. A + * workflow run wraps each operation in a durable effect keyed by its WorkflowRun + * and expansion, so a replayed read hands back the snapshot it saw and a + * replayed upsert reaches no service. An ordinary `xmd run` has no WorkflowRun + * to key anything by and retains nothing: each execution derives a fresh opaque + * invocation identity, presents it as the upsert's idempotency key, and asks the + * same configured transport a new question. A second run is a second question, + * never a resumption of the first. + * + * The default handler throws. `` under a host that installed neither + * profile must be told there is no provider, rather than reaching a transport + * whose lifecycle nobody decided. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { IssueDetails, IssueInput, IssueReference } from "./api.ts"; + +/** The stable name every loaded copy composes through. */ +export const ISSUE_OPERATIONS = "executablemd.workflow.composition.issue-operations"; + +/** What one read asks for. */ +export interface IssueReadInvocation { + /** The canonical issue URL. */ + readonly url: string; + /** The explicit discriminator, or `undefined` when the document named none. */ + readonly provider: string | undefined; +} + +/** What one upsert asks for. */ +export interface IssueUpsertInvocation { + /** The canonical container URL. */ + readonly target: string; + /** The explicit discriminator, or `undefined` when the tracker named none. */ + readonly provider: string | undefined; + readonly issue: IssueInput; +} + +/** No profile installed an Issue lifecycle in this scope. */ +export class IssueOperationsProviderError extends Error { + override name = "IssueOperationsProviderError"; + + constructor(operation: string) { + super( + `no Issue provider is installed, so ${operation} cannot answer. The Deno and compiled ` + + "`xmd run` entrypoints install the ordinary one; a workflow host installs the retained " + + "one for a live or partial execution.", + ); + } +} + +export interface IssueOperationsApi { + /** Read the issue this URL names, as the fields every provider has. */ + read(invocation: IssueReadInvocation): Operation; + + /** Create or bring up to date one issue in the tracker this invocation names. */ + upsert(invocation: IssueUpsertInvocation): Operation; +} + +export const IssueOperations: Api = createApi( + ISSUE_OPERATIONS, + { + // deno-lint-ignore require-yield + *read(_invocation: IssueReadInvocation): Operation { + throw new IssueOperationsProviderError(""); + }, + // deno-lint-ignore require-yield + *upsert(_invocation: IssueUpsertInvocation): Operation { + throw new IssueOperationsProviderError(""); + }, + }, +); diff --git a/packages/workflow/tests/git-add-durability.test.ts b/packages/workflow/tests/git-add-durability.test.ts index 99ab2057f..8061619a8 100644 --- a/packages/workflow/tests/git-add-durability.test.ts +++ b/packages/workflow/tests/git-add-durability.test.ts @@ -54,6 +54,7 @@ import { import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; const REMOTE = { commits: [ { @@ -565,7 +566,7 @@ describe("workflow Git.Add composition routing", () => { /** A component that stages through a loaded copy's Api, on a chosen record. */ function probe( copy: LoadedGitApi, - observe: (repository: RepositoryRecord) => RepositoryRecord, + observe: (repository: RepositorySelection) => RepositorySelection, ): ComponentRegistration { return { name: "Probe", diff --git a/packages/workflow/tests/git-add.test.ts b/packages/workflow/tests/git-add.test.ts index 18464256f..190c086a7 100644 --- a/packages/workflow/tests/git-add.test.ts +++ b/packages/workflow/tests/git-add.test.ts @@ -43,6 +43,7 @@ import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; import type { WorkflowRunDatabase } from "../src/storage/api.ts"; import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; import { causedBy, countingHost, @@ -79,13 +80,17 @@ const REMOTE = { ], } as const; -const FORGED = Object.freeze({ +const FORGED: RepositorySelection = Object.freeze({ + selection: "forged", name: "ghost", - locatorFingerprint: "0".repeat(64), - requestedBase: null, - creationCommit: "0".repeat(40), - primaryBranch: "main", - objectFormat: "sha1" as const, + identity: Object.freeze({ + name: "ghost", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), checkoutPath: "/repositories/ghost", }); @@ -131,7 +136,7 @@ function* expectation( */ function runForged( database: WorkflowRunDatabase, - record: RepositoryRecord, + record: RepositorySelection, source: string, options: WorkflowWorkspaceOptions, ): Operation { diff --git a/packages/workflow/tests/git-commit-durability.test.ts b/packages/workflow/tests/git-commit-durability.test.ts index 2e631dc6d..43e48f87b 100644 --- a/packages/workflow/tests/git-commit-durability.test.ts +++ b/packages/workflow/tests/git-commit-durability.test.ts @@ -55,6 +55,7 @@ import { import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; const REMOTE = { commits: [{ message: "first", entries: [{ path: "which.txt", content: "main\n" }] }], } as const; @@ -557,7 +558,7 @@ describe("workflow Git.Commit composition routing", () => { /** A component that commits through a loaded copy's Api, on a chosen record. */ function probe( copy: LoadedGitApi, - observe: (repository: RepositoryRecord) => RepositoryRecord, + observe: (repository: RepositorySelection) => RepositorySelection, ): ComponentRegistration { return { name: "Probe", diff --git a/packages/workflow/tests/git-commit.test.ts b/packages/workflow/tests/git-commit.test.ts index 709f07d1b..b2bb34908 100644 --- a/packages/workflow/tests/git-commit.test.ts +++ b/packages/workflow/tests/git-commit.test.ts @@ -68,6 +68,7 @@ import { } from "./support/composition.ts"; import { dropRootClose } from "./support/replay.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; /** One tracked file at the root and one in a subdirectory. */ const REMOTE = { commits: [ @@ -81,13 +82,17 @@ const REMOTE = { ], } as const; -const FORGED = Object.freeze({ +const FORGED: RepositorySelection = Object.freeze({ + selection: "forged", name: "ghost", - locatorFingerprint: "0".repeat(64), - requestedBase: null, - creationCommit: "0".repeat(40), - primaryBranch: "main", - objectFormat: "sha1" as const, + identity: Object.freeze({ + name: "ghost", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), checkoutPath: "/repositories/ghost", }); @@ -160,7 +165,7 @@ function* checkout(database: WorkflowRunDatabase): Operation { /** One `` under a Repository context the run did not install. */ function runForged( database: WorkflowRunDatabase, - record: RepositoryRecord, + record: RepositorySelection, source: string, options: WorkflowWorkspaceOptions, ): Operation { diff --git a/packages/workflow/tests/git-push-durability.test.ts b/packages/workflow/tests/git-push-durability.test.ts index 0c5feb151..9c6896c41 100644 --- a/packages/workflow/tests/git-push-durability.test.ts +++ b/packages/workflow/tests/git-push-durability.test.ts @@ -34,7 +34,6 @@ import { PUSH_REMOTE, refspecFor, type GitPushInputs, - type GitPushRepositoryIdentity, } from "../src/composition/git-push-records.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; import { GitComposition } from "../src/composition/git-api.ts"; @@ -44,6 +43,9 @@ import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts" import type { WorkflowRunDatabase } from "../src/storage/api.ts"; import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; import { remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; +import { currentRepository } from "../src/composition/context.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; +import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { causedBy, compositionEvents, @@ -583,7 +585,7 @@ describe("workflow Git.Push durability", () => { */ // deno-lint-ignore require-yield it("refuses a record that published over the commit it says was already there", function* () { - const repository: GitPushRepositoryIdentity = Object.freeze({ + const repository: RepositoryIdentity = Object.freeze({ name: "project", locatorFingerprint: "a".repeat(64), requestedBase: null, @@ -779,13 +781,15 @@ describe("workflow Git.Push durability", () => { origin: "test", props: { type: "object", additionalProperties: true }, *fn(): Operation { - const [repository] = yield* retainedRepositories(database); - const record = repository?.record as RepositoryRecord; - const observed: Record = { ...record }; - const request = { repository: observed, workingDirectory: record.checkoutPath }; + const selected = yield* currentRepository(); + if (selected === undefined) { + throw new Error("the probe was written outside a Repository"); + } + const observed: Record = { ...selected }; + const request = { repository: observed, workingDirectory: selected.checkoutPath }; const task = yield* spawn(() => GitComposition.operations.pushCurrentBranch( - request as unknown as { repository: RepositoryRecord; workingDirectory: string }, + request as unknown as { repository: RepositorySelection; workingDirectory: string }, ), ); yield* observing.operation; @@ -844,14 +848,16 @@ describe("workflow Git.Push durability", () => { origin: "test", props: { type: "object", additionalProperties: true }, *fn(): Operation { - const [repository] = yield* retainedRepositories(database); - const record = repository?.record as RepositoryRecord; + const selected = yield* currentRepository(); + if (selected === undefined) { + throw new Error("the probe was written outside a Repository"); + } // A second physical module holding the same Api name. Sharing the // name is how composition works; it is deliberately not how authority // works, so this still reaches the one installed provider. yield* loaded.GitComposition.operations.pushCurrentBranch({ - repository: record, - workingDirectory: record.checkoutPath, + repository: selected, + workingDirectory: selected.checkoutPath, }); return ""; }, diff --git a/packages/workflow/tests/git-push.test.ts b/packages/workflow/tests/git-push.test.ts index 88a685e40..662d5db32 100644 --- a/packages/workflow/tests/git-push.test.ts +++ b/packages/workflow/tests/git-push.test.ts @@ -66,6 +66,7 @@ import { import type { CountingHost } from "./support/composition.ts"; import { committedRoot, latestRoot, publishedRoots } from "./support/replay.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; const REMOTE = { commits: [ { @@ -179,13 +180,17 @@ function publishOnlyOnRemote( } /** A well-formed Repository record naming a Repository nothing retains. */ -const FORGED: RepositoryRecord = Object.freeze({ +const FORGED: RepositorySelection = Object.freeze({ + selection: "forged", name: "ghost", - locatorFingerprint: "0".repeat(64), - requestedBase: null, - creationCommit: "0".repeat(40), - primaryBranch: "main", - objectFormat: "sha1", + identity: Object.freeze({ + name: "ghost", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), checkoutPath: "/repositories/ghost", }); diff --git a/packages/workflow/tests/git-switch-durability.test.ts b/packages/workflow/tests/git-switch-durability.test.ts index 0ab511992..d9b8dd99e 100644 --- a/packages/workflow/tests/git-switch-durability.test.ts +++ b/packages/workflow/tests/git-switch-durability.test.ts @@ -21,15 +21,22 @@ import { open } from "node:fs/promises"; import { tmpdir } from "node:os"; import { scoped, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; -import { GitOperationError, GitOperationProtocolError } from "../src/composition/errors.ts"; +import { + GitOperationAuthorityError, + GitOperationError, + GitOperationProtocolError, +} from "../src/composition/errors.ts"; import { DivergenceError } from "@executablemd/durable-streams"; -import { RepositoryContext } from "../src/composition/context.ts"; -import type { RepositoryRecord } from "../src/composition/records.ts"; +import { RepositoryComposition } from "../src/composition/api.ts"; +import { GitComposition } from "../src/composition/git-api.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { collect, execute, inlineSource } from "@executablemd/core"; +import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; +import { cwd } from "@executablemd/runtime"; +import type { ComponentRegistration } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; import { WORKSPACE_GIT_SWITCH } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; @@ -105,6 +112,10 @@ function isProtocolFailure(value: unknown): value is GitOperationProtocolError { return value instanceof GitOperationProtocolError; } +function isAuthorityFailure(value: unknown): value is GitOperationAuthorityError { + return value instanceof GitOperationAuthorityError; +} + function isDivergence(value: unknown): value is DivergenceError { return value instanceof DivergenceError; } @@ -156,25 +167,50 @@ function damageSwitchResult(path: string, damage: (record: Record` inside the checkout, under a supplied Repository context. + * One `` inside the checkout, on a selection a caller may edit. * - * A self-closing `` retains a checkout and installs no context, so - * the record the component observes is exactly the one a caller supplies here — - * which is what a replaced context is, and what makes the two runs below differ - * by nothing but that record. + * The probe selects the Repository through the Api the way `` does, + * hands what it got to `observe`, and switches on the answer. Two runs of this + * document therefore reach the same durable positions and differ by nothing but + * what `observe` did to the selection — which is what a replaced context is. */ function observedSource(locator: string): string { return [ ``, "", - ``, + ``, "", ].join("\n"); } +function observedComponent( + locator: string, + observe: (selection: RepositorySelection) => RepositorySelection, +): ComponentRegistration { + return { + name: "Observed", + origin: "test", + props: { type: "object", additionalProperties: false }, + *fn(): Operation { + const selection = yield* RepositoryComposition.operations.selectRepository({ + name: "project", + locator, + base: undefined, + }); + yield* GitComposition.operations.switchBranch({ + repository: observe(selection), + workingDirectory: yield* cwd(), + branch: "release", + base: undefined, + }); + return ""; + }, + }; +} + function runObserved( database: WorkflowRunDatabase, - record: RepositoryRecord, + observe: (selection: RepositorySelection) => RepositorySelection, locator: string, options: WorkflowWorkspaceOptions, ): Operation { @@ -182,7 +218,7 @@ function runObserved( return yield* withWorkflowWorkspace( database, scoped(function* () { - yield* RepositoryContext.around({ current: () => record }, { at: "min" }); + yield* registerComponents([observedComponent(locator, observe)]); return yield* collect( yield* execute({ ...inlineSource(observedSource(locator)), stream: database.journal }), ); @@ -406,35 +442,40 @@ describe("workflow Git.Switch durability", () => { /** * A recorded transition belongs to the observation it was authorized for. * - * Durable identity is type and name, and the name is where the observation - * lives, so the encoding behind it has to be injective: two records that - * digested alike would let a replay hand back a transition authorized for one - * of them to the other, on the path where nothing is authenticated because - * nothing is executed. + * The Repository a Git operation acts on is the one this provider selected, + * looked up privately from the opaque identifier a selection carries. So a + * context differing in one member of the identity is not a second observation + * of the same Repository — it is a value this provider never made, and it is + * refused before a durable name is computed and before Git exists in the + * story. * - * `requestedBase` is the demonstration. A record that never supplied a base - * retains `null`; a replaced context can supply the string a sentinel-based - * encoding used for absence, and the two must still be different effects. + * `requestedBase` is the demonstration, for the same reason it always was. A + * Repository that never supplied a base carries `null`; a replaced context can + * supply the string a sentinel-based encoding would use for absence. The + * encoding's own injectivity is proved directly below; what this proves is + * that a replaced context cannot reach the encoding at all. */ - it("refuses to replay a transition recorded for a different Repository record", function* () { + it("refuses a Repository context differing from the selection it was handed", function* () { const root = yield* useStorageRoot(); const remote = yield* useBareRemote(REMOTE); const path = runPath(root, "release-1.4"); yield* withStorage(root, function* () { - // What this fixture retains, learned from a run of its own: creation - // identity is a function of the name, the url and the base. - const learning = yield* createRun({ runId: "learning" }); - yield* runDocument(learning, ``); - const [learned] = yield* retainedRepositories(learning); - const record = learned?.record; - if (record === undefined || record.requestedBase !== null) { - throw new Error("the fixture did not retain a Repository with no requested base"); - } - const database = yield* createRun(); const first = countingHost(); - yield* runObserved(database, record, remote.locator, countingOptions(first)); + let observed: RepositorySelection | undefined; + yield* runObserved( + database, + (selection) => { + observed = selection; + return selection; + }, + remote.locator, + countingOptions(first), + ); + if (observed === undefined || observed.identity.requestedBase !== null) { + throw new Error("the fixture did not select a Repository with no requested base"); + } expect(subcommands(first.counters)).toContain("switch"); const recorded = yield* gitEvents(database); expect(recorded).toHaveLength(1); @@ -442,23 +483,21 @@ describe("workflow Git.Switch durability", () => { dropRootClose(path); - // The same expansion, under a context differing in one member only. + // The same expansion, on a selection differing in one member only. const second = countingHost(); const failure = yield* raised( runObserved( database, - { ...record, requestedBase: "\u0000" }, + (selection) => ({ + ...selection, + identity: { ...selection.identity, requestedBase: "\u0000" }, + }), remote.locator, countingOptions(second), ), ); - // Two different effects, so the recorded one is not this one's to take: - // the journal says so at the position it reaches, before anything runs. - // A collision would instead have handed this observation a transition - // recorded for another, on the path where nothing is authenticated - // because nothing is executed. - expect(causedBy(failure, isDivergence)).toBeInstanceOf(DivergenceError); + expect(causedBy(failure, isAuthorityFailure)).toBeInstanceOf(GitOperationAuthorityError); expect(causedBy(failure, isGitFailure)).toBe(undefined); expect(subcommands(second.counters)).not.toContain("switch"); expect(yield* gitEvents(database)).toHaveLength(recorded.length); diff --git a/packages/workflow/tests/git-switch.test.ts b/packages/workflow/tests/git-switch.test.ts index 3616bab1e..e6bee9912 100644 --- a/packages/workflow/tests/git-switch.test.ts +++ b/packages/workflow/tests/git-switch.test.ts @@ -64,6 +64,10 @@ import { import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; +import { + filteredRepositoryIdentity, + type RepositorySelection, +} from "../src/composition/selection.ts"; /** * Two branches whose content differs, plus one file that does not. * @@ -109,13 +113,17 @@ function isInfrastructureFailure(value: unknown): value is GitOperationInfrastru } /** A well-formed record naming a Repository nothing retains. */ -const FORGED = Object.freeze({ +const FORGED: RepositorySelection = Object.freeze({ + selection: "forged", name: "ghost", - locatorFingerprint: "0".repeat(64), - requestedBase: null, - creationCommit: "0".repeat(40), - primaryBranch: "main", - objectFormat: "sha1" as const, + identity: Object.freeze({ + name: "ghost", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), checkoutPath: "/repositories/ghost", }); @@ -128,7 +136,7 @@ const FORGED = Object.freeze({ */ function runForged( database: WorkflowRunDatabase, - record: RepositoryRecord, + record: RepositorySelection, source: string, options: WorkflowWorkspaceOptions, ): Operation { @@ -599,15 +607,27 @@ describe("workflow Git.Switch selection", () => { expect(yield* retainedRepositories(substitutedRun)).toHaveLength(1); expect(yield* gitEvents(substitutedRun)).toHaveLength(0); - // And a context carrying the exact retained record still supplies no - // place: the working directory a self-closing Repository leaves behind is - // the Workspace root, which is inside no checkout. The record is the one - // the first run retained, which the same fixture retains again here — + // And a context carrying the retained Repository's own facts, exactly, + // still supplies no authority: a selection is what this provider minted, + // not what a value says about itself. The identity here is the one the + // first run retained, which the same fixture retains again here — // creation identity is a function of the name, the url and the base. const [retained] = yield* retainedRepositories(unretainedRun); const exactRun = yield* createRun({ runId: "exact" }); const exact = yield* raised( - runForged(exactRun, retained?.record ?? FORGED, source, countingOptions(counting)), + runForged( + exactRun, + retained === undefined + ? FORGED + : { + ...FORGED, + name: retained.record.name, + identity: filteredRepositoryIdentity(retained.record), + checkoutPath: retained.record.checkoutPath, + }, + source, + countingOptions(counting), + ), ); expect(causedBy(exact, isAuthorityFailure)).toBeInstanceOf(GitOperationAuthorityError); expect(subcommands(counting.counters)).not.toContain("switch"); @@ -926,7 +946,7 @@ describe("workflow Git composition routing", () => { /** A component that switches through a loaded copy's Api, on a chosen record. */ function probe( copy: LoadedGitApi, - observe: (repository: RepositoryRecord) => RepositoryRecord, + observe: (repository: RepositorySelection) => RepositorySelection, ): ComponentRegistration { return { name: "Probe", @@ -952,7 +972,7 @@ function probe( type Mutable = { -readonly [K in keyof T]: T[K] }; interface MutableSwitchRequest { - repository: Mutable; + repository: Mutable; workingDirectory: string; branch: string; base: string | undefined; diff --git a/packages/workflow/tests/pull-request-github.test.ts b/packages/workflow/tests/pull-request-github.test.ts index 3f50610a0..12fc8254c 100644 --- a/packages/workflow/tests/pull-request-github.test.ts +++ b/packages/workflow/tests/pull-request-github.test.ts @@ -12,6 +12,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import process from "node:process"; import type { Operation } from "effection"; +import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { denoGitHubAccess, gitHubPullRequests, @@ -28,7 +29,7 @@ import type { PullRequestInputs, PullRequestSnapshot, } from "../src/composition/pull-request-records.ts"; -import type { GitPushRepositoryIdentity } from "../src/composition/git-push-records.ts"; + import { creations, fakeGitHubAccess, @@ -43,7 +44,7 @@ const HEAD = "a".repeat(40); const BASE = "b".repeat(40); const ENDPOINT = "https://api.github.test"; -const IDENTITY: GitPushRepositoryIdentity = Object.freeze({ +const IDENTITY: RepositoryIdentity = Object.freeze({ name: "project", locatorFingerprint: "0".repeat(64), requestedBase: null, diff --git a/packages/workflow/tests/pull-request-read.test.ts b/packages/workflow/tests/pull-request-read.test.ts index 98631ec1a..0bd85f4a1 100644 --- a/packages/workflow/tests/pull-request-read.test.ts +++ b/packages/workflow/tests/pull-request-read.test.ts @@ -30,7 +30,7 @@ import type { WorkflowRunDatabase } from "../mod.ts"; import { dropRootClose } from "./support/replay.ts"; import { raised, runWorkflowDocument } from "./support/composition.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; -import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-reads.ts"; +import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; import { collect, execute, inlineSource, isJsonObject } from "@executablemd/core"; import { InMemoryStream } from "@executablemd/durable-streams"; import { readPullRequestEvidence } from "../src/deno/composition/pull-request-evidence.ts"; diff --git a/packages/workflow/tests/pull-request-records.test.ts b/packages/workflow/tests/pull-request-records.test.ts index 693cf0bd5..ab9b50ea4 100644 --- a/packages/workflow/tests/pull-request-records.test.ts +++ b/packages/workflow/tests/pull-request-records.test.ts @@ -23,7 +23,7 @@ import { parseGitPushNaturalKey, PUSH_REMOTE, } from "../src/composition/git-push-records.ts"; -import type { GitPushRepositoryIdentity } from "../src/composition/git-push-records.ts"; + import { parsePullRequestInputs, pullRequestMode, @@ -44,11 +44,12 @@ import type { } from "../src/composition/pull-request-records.ts"; import { admitPushEvidence } from "../src/composition/push-evidence.ts"; +import type { RepositoryIdentity } from "../src/composition/selection.ts"; const HEAD = "a".repeat(40); const BASE = "b".repeat(40); const OTHER = "c".repeat(40); -const IDENTITY: GitPushRepositoryIdentity = Object.freeze({ +const IDENTITY: RepositoryIdentity = Object.freeze({ name: "project", locatorFingerprint: "0".repeat(64), requestedBase: null, @@ -57,7 +58,7 @@ const IDENTITY: GitPushRepositoryIdentity = Object.freeze({ objectFormat: "sha1", }); -const OTHER_IDENTITY: GitPushRepositoryIdentity = Object.freeze({ ...IDENTITY, name: "other" }); +const OTHER_IDENTITY: RepositoryIdentity = Object.freeze({ ...IDENTITY, name: "other" }); const INPUTS: PullRequestInputs = Object.freeze({ repository: IDENTITY, @@ -115,7 +116,7 @@ function record(overrides: Partial = {}): GitHostRe /** A complete, well-formed Push reconciliation record, as the journal holds it. */ function pushRecord(options: { - identity?: GitPushRepositoryIdentity; + identity?: RepositoryIdentity; branch?: string; commit?: string; }): Json { diff --git a/packages/workflow/tests/pull-request.test.ts b/packages/workflow/tests/pull-request.test.ts index eef11a148..3bf3054ca 100644 --- a/packages/workflow/tests/pull-request.test.ts +++ b/packages/workflow/tests/pull-request.test.ts @@ -1067,9 +1067,9 @@ describe("workflow PullRequest containment", () => { // There is no host-less fallback. A pull request that "ran" without a // provider would say this run published something it never did. Since - // #576 the surface reporting that is `PullRequestApi`, which carries both - // questions about a pull request; the property is the one it always was. - expect(String(failure)).toContain("no pull-request provider handles"); + // #643 the surface reporting that is `PullRequestOperations`, which is + // where the two profiles differ; the property is the one it always was. + expect(String(failure)).toContain("no pull-request provider is installed"); }); }); }); diff --git a/packages/workflow/tests/run-composition.test.ts b/packages/workflow/tests/run-composition.test.ts new file mode 100644 index 000000000..cb8ae5a67 --- /dev/null +++ b/packages/workflow/tests/run-composition.test.ts @@ -0,0 +1,537 @@ +/** + * Tier ORC — the repository vocabulary under an ordinary `xmd run`. + * + * The claims here are about a filesystem rather than a database. There is no + * WorkflowRun, no Workspace, no journal and nothing to replay: what makes a + * checkout this execution's is an advisory lock, and what makes it the same + * checkout tomorrow is the sidecar beside it. + * + * Every repository in this file is real, every Git command is real, and the + * managed root is a temporary directory of the suite's own — no test ever + * touches the user's `~/.xmd/repositories`. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, type Operation } from "effection"; +import { exists, readdir, rm, writeTextFile } from "@effectionx/fs"; +import { GitOperationAuthorityError } from "../src/composition/errors.ts"; +import { + ManagedCheckoutError, + NoAmbientRepositoryError, +} from "../src/deno/run-composition/errors.ts"; +import { git, remoteBranch, useBareRemote } from "./support/git-remotes.ts"; +import { + causedBy, + commonDirectoryOf, + raised, + readSidecar, + repositorySlotOf, + runOrdinaryDocument, + useHostCheckout, + useManagedRoot, + useOriginlessCheckout, + worktreeSlotOf, + type HostCheckout, +} from "./support/run-composition.ts"; + +const REMOTE = { + commits: [ + { message: "first", entries: [{ path: "which.txt", content: "main\n" }] }, + { + message: "release", + branch: "release", + entries: [{ path: "which.txt", content: "release\n" }], + }, + ], +} as const; + +function isManagedRefusal(value: unknown): value is ManagedCheckoutError { + return value instanceof ManagedCheckoutError; +} + +function isAuthorityFailure(value: unknown): value is GitOperationAuthorityError { + return value instanceof GitOperationAuthorityError; +} + +function isMissingAmbient(value: unknown): value is NoAmbientRepositoryError { + return value instanceof NoAmbientRepositoryError; +} + +/** Every entry a slot holds, sorted, so a byte-level comparison is stable. */ +function* entriesOf(path: string): Operation { + if (!(yield* exists(path))) { + return []; + } + return [...(yield* readdir(path))].sort(); +} + +describe("ORC3 — the ambient primary checkout", () => { + it("switches, stages and commits in the repository the command was run in", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + ``, + `ordinary`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // The person's own checkout moved, and it is what a later `git` sees. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("feature"); + expect(checkout.run("log", "-1", "--pretty=%s")).toBe("Write notes"); + expect(checkout.run("show", "--pretty=", "--name-only", "HEAD")).toContain("notes.md"); + }); + + it("refuses a root Worktree outside a repository and names how to run inside one", function* () { + const root = yield* useManagedRoot(); + // A directory that is not inside any Git checkout. + const elsewhere = yield* useManagedRoot(); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: elsewhere, + }), + ); + const refusal = causedBy(failure, isMissingAmbient); + expect(refusal).toBeInstanceOf(NoAmbientRepositoryError); + expect(String(refusal)).toContain("Run xmd from inside one"); + }); +}); + +describe("ORC4 — the ambient linked worktree", () => { + it("follows the common directory for identity and the worktree root for work", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const primary = yield* useHostCheckout(remote.locator); + // A linked worktree made by hand, exactly as a person would. + const linked = `${primary.root}-linked`; + primary.run("worktree", "add", "-b", "sidecar", linked); + + const before = primary.run("rev-parse", "HEAD"); + + yield* scoped(function* () { + yield* runOrdinaryDocument( + [ + `here`, + ``, + ``, + ].join("\n"), + // The command is run *in the linked worktree*. + { root, cwd: linked }, + ); + }); + + // The worktree advanced; the primary checkout did not. + expect(primary.run("rev-parse", "HEAD")).toBe(before); + expect(primary.run("log", "-1", "--pretty=%s", "sidecar")).toBe("In the worktree"); + }); +}); + +describe("ORC5 — origin is not local authority", () => { + it("creates a Worktree and commits with no origin, and refuses to publish", function* () { + const root = yield* useManagedRoot(); + const solo = yield* useOriginlessCheckout(); + + const bound = yield* runOrdinaryDocument( + [ + ``, + "", + `no remote`, + ``, + ``, + "", + ].join("\n"), + { root, cwd: solo.root }, + ); + expect(typeof bound).toBe("string"); + expect(solo.run("log", "-1", "--pretty=%s", "feature")).toBe("Local only"); + + // Push refuses before a credential, a session or a transport exists. + const failure = yield* raised(runOrdinaryDocument(``, { root, cwd: solo.root })); + const refusal = causedBy(failure, isAuthorityFailure); + expect(refusal).toBeInstanceOf(GitOperationAuthorityError); + expect(String(refusal)).toContain("no usable origin"); + }); +}); + +describe("ORC6 — lexical working directories", () => { + it("restores the enclosing directory after a Worktree body and a Dir body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // A relative `` path resolves against the contextual working + // directory, so where each one lands is where the document was standing. + const common = commonDirectoryOf(checkout); + yield* runOrdinaryDocument( + [ + `outer`, + ``, + ``, + `within`, + "", + "", + `bound`, + "", + `after`, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + const lexical = worktreeSlotOf(root, common, "lexical"); + const bound = worktreeSlotOf(root, common, "inner"); + expect(yield* exists(`${checkout.root}/outer.md`)).toBe(true); + // Each body observed its own checkout. + expect(yield* exists(`${lexical.checkout}/within.md`)).toBe(true); + expect(yield* exists(`${bound.checkout}/bound.md`)).toBe(true); + // Restored: the sibling after both is back in the enclosing directory. + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + expect(yield* exists(`${lexical.checkout}/after.md`)).toBe(false); + expect(yield* exists(`${bound.checkout}/after.md`)).toBe(false); + }); + + it("restores the enclosing directory when the body fails", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // The refusal is printed rather than fatal, so the document goes on — and + // what it goes on in is the directory the Worktree body was installed over. + yield* runOrdinaryDocument( + [ + "", + ``, + ``, + "", + "", + `after`, + ].join("\n"), + { root, cwd: checkout.root }, + ); + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + const failing = worktreeSlotOf(root, commonDirectoryOf(checkout), "failing"); + expect(yield* exists(`${failing.checkout}/after.md`)).toBe(false); + }); +}); + +describe("ORC8 — managed checkouts are persistent", () => { + it("leaves the checkout, its metadata and its working files after the run", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const common = commonDirectoryOf(checkout); + const slot = worktreeSlotOf(root, common, "kept"); + + yield* runOrdinaryDocument( + [ + ``, + `unfinished`, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* exists(`${slot.checkout}/draft.md`)).toBe(true); + const sidecar = yield* readSidecar(slot); + expect(sidecar).toMatchObject({ + kind: "worktree", + version: 1, + name: "kept", + requestedBranch: "kept", + requestedBase: null, + owner: common, + }); + }); + + it("keeps the checkout after an authored failure inside the Worktree body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "survivor"); + + yield* raised( + runOrdinaryDocument( + [ + ``, + `written before the failure`, + ``, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + expect(yield* exists(`${slot.checkout}/kept.md`)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ kind: "worktree" }); + }); +}); + +describe("ORC9 — compatible reuse", () => { + it("reuses the same checkout and preserves the work the first run left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + + const first = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const created = yield* readSidecar(slot); + + // Work a person would do between two runs: a new branch and a commit. + git(["switch", "-c", "later"], slot.checkout, checkout.home); + git(["commit", "--allow-empty", "-m", "moved on"], slot.checkout, checkout.home); + const moved = git(["rev-parse", "HEAD"], slot.checkout, checkout.home); + + const second = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + + expect(second).toBe(first); + expect(yield* readSidecar(slot)).toEqual(created); + // Neither the branch it is on nor the commit it holds was reset. + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], slot.checkout, checkout.home)).toBe("later"); + expect(git(["rev-parse", "HEAD"], slot.checkout, checkout.home)).toBe(moved); + }); +}); + +describe("ORC10 — a conflict changes nothing", () => { + it("refuses a changed base and leaves the slot byte-identical", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + const before = yield* readSidecar(slot); + const beforeEntries = yield* entriesOf(slot.slot); + + const failure = yield* raised( + runOrdinaryDocument( + ``, + { root, cwd: checkout.root }, + ), + ); + const refusal = causedBy(failure, isManagedRefusal); + expect(refusal?.reason).toBe("incompatible-reuse"); + expect(yield* readSidecar(slot)).toEqual(before); + expect(yield* entriesOf(slot.slot)).toEqual(beforeEntries); + }); + + it("refuses a Worktree asked for on a different branch", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "review"); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + const before = yield* readSidecar(slot); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("incompatible-reuse"); + expect(yield* readSidecar(slot)).toEqual(before); + }); +}); + +describe("ORC11 — an interrupted creation", () => { + it("adopts a metadata-free checkout that is exactly what creation would have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const written = yield* readSidecar(slot); + // Exactly the state an interruption between the clone and the sidecar + // leaves: the checkout, and nothing describing it. + yield* rm(slot.metadata); + expect(yield* readSidecar(slot)).toBe(undefined); + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(yield* readSidecar(slot)).toEqual(written); + }); + + it("refuses a slot holding something creation would never have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + const document = ``; + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + yield* rm(slot.metadata); + // An unexplained entry beside the checkout. + yield* writeTextFile(`${slot.slot}/stray.txt`, "who put this here\n"); + const beforeEntries = yield* entriesOf(slot.slot); + + const failure = yield* raised(runOrdinaryDocument(document, { root, cwd: checkout.root })); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("partial-creation"); + expect(yield* entriesOf(slot.slot)).toEqual(beforeEntries); + expect(yield* readSidecar(slot)).toBe(undefined); + }); +}); + +describe("ORC13 — live local Git", () => { + it("makes real, non-transactional changes and claims no rollback on failure", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `staged before the failure`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(failure).toBeInstanceOf(Error); + + // The switch and the first Add really happened, and nothing took them back. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("partway"); + expect(checkout.run("diff", "--cached", "--name-only")).toContain("staged.md"); + }); + + it("refuses a branch another checkout of the same repository holds", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + + const failure = yield* raised( + runOrdinaryDocument(``, { root, cwd: checkout.root }), + ); + expect(String(failure)).toContain("branch-checked-out-elsewhere"); + }); +}); + +describe("ORC14 — live Push evidence", () => { + it("publishes the branch and lets exactly that head reach the Git host adapter", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `published`, + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + // The branch really is at the remote, at the commit this execution made. + expect(remoteBranch(remote, "published")).toBe(checkout.run("rev-parse", "HEAD")); + // And the pull request got past the evidence gate: what stopped it is the + // adapter declining a locator that is not a github.com repository, which is + // the step *after* the local authorization this criterion is about. + expect(String(failure)).toContain("only for repositories on github.com"); + expect(String(failure)).not.toContain("holds no successful result"); + }); + + it("does not let a Push of another branch authorize this one", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(String(failure)).toContain("holds no successful result"); + expect(remoteBranch(remote, "unpublished")).toBe(undefined); + }); + + it("lets the latest publication of a destination decide", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `one`, + ``, + ``, + ``, + `two`, + ``, + ``, + // The head has moved past what was published, and no second Push + // followed it. + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(String(failure)).toContain("published that branch at a different commit"); + }); +}); + +describe("ORC15 — evidence cannot cross runs", () => { + it("refuses a PullRequest in an execution that published nothing", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(String(failure)).toContain("holds no successful result"); + }); +}); + +/** Two executions in one process must not share a checkout registry. */ +describe("ORC12 — exclusive ownership within one host", () => { + it("releases a slot's lease when the execution ends, so a later one takes it", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout: HostCheckout = yield* useHostCheckout(remote.locator); + + const document = ``; + const first = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const second = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(second).toBe(first); + }); +}); diff --git a/packages/workflow/tests/support/issue-scenario.ts b/packages/workflow/tests/support/issue-scenario.ts index 484a867cc..dfd4cf898 100644 --- a/packages/workflow/tests/support/issue-scenario.ts +++ b/packages/workflow/tests/support/issue-scenario.ts @@ -40,6 +40,7 @@ import { } from "./issue-providers.ts"; import type { ProviderLog } from "./issue-providers.ts"; import { useGitHubIssues } from "../../src/deno/issue/github.ts"; +import { useRetainedIssueOperations } from "../../src/issue/effect.ts"; import { credential, useIssueTrackerServer } from "./issue-tracker-server.ts"; import type { IssueTrackerServer, ServedIssue } from "./issue-tracker-server.ts"; @@ -160,6 +161,10 @@ export function* useScenarioFixture(): Operation { const attempting: Attempting = { current: undefined }; yield* useCompositionComponents(); + // The retained lifecycle `` asks for, above whichever transport a + // scenario installs beneath it. The scenarios are about a workflow run's + // durability, so it is the workflow one they run under. + yield* useRetainedIssueOperations(); yield* useProviderComponents(log); yield* useKeyRecorder(log); yield* useScenarioComponents(server, held, log, staged, attempting); diff --git a/packages/workflow/tests/support/pull-requests.ts b/packages/workflow/tests/support/pull-requests.ts index be4d0a1b5..d80e08d6c 100644 --- a/packages/workflow/tests/support/pull-requests.ts +++ b/packages/workflow/tests/support/pull-requests.ts @@ -24,6 +24,7 @@ import { } from "./github.ts"; import { gitHubSource } from "../../src/deno/composition/github.ts"; +import type { RepositorySelection } from "../../src/composition/selection.ts"; /** The repository the document names, and the one the fake GitHub holds. */ export const LOCATOR = "https://github.com/octo/project"; @@ -43,14 +44,18 @@ export const REMOTE = { commits: [{ message: "first", entries: [{ path: "which.txt", content: "main\n" }] }], } as const; -/** A well-formed Repository record naming a Repository nothing retains. */ -export const FORGED: RepositoryRecord = Object.freeze({ +/** A well-formed Repository selection no provider ever minted. */ +export const FORGED: RepositorySelection = Object.freeze({ + selection: "forged", name: "ghost", - locatorFingerprint: "0".repeat(64), - requestedBase: null, - creationCommit: "0".repeat(40), - primaryBranch: "main", - objectFormat: "sha1", + identity: Object.freeze({ + name: "ghost", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), checkoutPath: "/repositories/ghost", }); diff --git a/packages/workflow/tests/support/run-composition.ts b/packages/workflow/tests/support/run-composition.ts new file mode 100644 index 000000000..4929b7cf5 --- /dev/null +++ b/packages/workflow/tests/support/run-composition.ts @@ -0,0 +1,198 @@ +/** + * The harness the ordinary-run repository suites drive. + * + * Everything here is real: a real bare remote, a real working checkout the + * command is "run in", real `git`, real advisory locks and a real managed root + * in a temporary directory. What is substituted is only what a claim needs to + * be deterministic about — the managed root, so no suite ever touches the + * user's own `~/.xmd/repositories`, and the Git subprocess where a suite counts + * invocations. + * + * There is no database, no journal and no WorkflowRun anywhere in this file. + * That is the point of the profile: an ordinary run has none of them. + */ + +import { scoped, until, type Operation } from "effection"; +import { ensureDir, exists, readTextFile } from "@effectionx/fs"; +import { realpathSync } from "node:fs"; +import { realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { useCompositionComponents } from "../../src/composition/installation.ts"; +import { useRunComposition } from "../../src/deno/run-composition/provider.ts"; +import type { RunCompositionOptions } from "../../src/deno/run-composition/provider.ts"; +import { + checkoutOf, + metadataOf, + repositorySlot, + worktreeSlot, +} from "../../src/deno/run-composition/placement.ts"; +import { git } from "./git-remotes.ts"; + +/** A working checkout on this host, as if somebody had cloned it by hand. */ +export interface HostCheckout { + /** The canonical root of the checkout. */ + readonly root: string; + /** The home Git runs with when this fixture drives it directly. */ + readonly home: string; + /** Run a Git command in this checkout and answer what it printed. */ + run(...args: string[]): string; +} + +/** + * Clone `locator` into a directory the acquiring scope owns. + * + * Acquired in the caller's scope rather than a bounded one: the checkout is + * what the whole test runs against, and a `scoped()` around this would remove + * it before the first assertion. + */ +export function* useHostCheckout(locator: string, branch?: string): Operation { + const home = yield* useTempDirectory("xmd-run-composition-"); + const parent = yield* useTempDirectory("xmd-host-"); + const resolved = yield* until(realpath(parent)); + const root = join(resolved, "checkout"); + git(["clone", "--", locator, root], resolved, home); + if (branch !== undefined) { + git(["checkout", "-B", branch], root, home); + } + return { + root, + home, + run(...args: string[]): string { + return git(args, root, home); + }, + }; +} + +/** A Git checkout with no remote at all, made here rather than cloned. */ +export function* useOriginlessCheckout(): Operation { + const home = yield* useTempDirectory("xmd-run-composition-"); + const parent = yield* useTempDirectory("xmd-solo-"); + const resolved = yield* until(realpath(parent)); + const root = join(resolved, "checkout"); + git(["init", "--initial-branch=main", root], resolved, home); + git(["commit", "--allow-empty", "-m", "first"], root, home); + return { + root, + home, + run(...args: string[]): string { + return git(args, root, home); + }, + }; +} + +/** A managed root of this suite's own, removed when the scope ends. */ +export function* useManagedRoot(): Operation { + const created = yield* useTempDirectory("xmd-run-composition-"); + const root = join(created, "repositories"); + yield* ensureDir(root); + return root; +} + +export interface RunOptions extends Omit { + /** The managed root this execution uses. */ + readonly root: string; + /** The directory the command is run in, which ambient discovery starts from. */ + readonly cwd: string; + /** Props the document is executed with. */ + readonly props?: Record; +} + +/** + * Execute one document under the ordinary repository provider. + * + * The contextual working directory is installed to `cwd` first, exactly as a + * runtime entrypoint's host filesystem provider would leave it, so a document's + * root-level element is written "in" that directory. + */ +export function runOrdinaryDocument(source: string, options: RunOptions): Operation { + return scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return options.cwd; + }, + }, + { at: "min" }, + ); + // What a runtime entrypoint installs beside the provider: `API.Files` has + // no host default, and a document that writes `` must reach the + // caller's own filesystem exactly as `xmd run` leaves it. + yield* useHostFiles(); + yield* useCompositionComponents(); + const { root, cwd, props: _props, ...rest } = options; + yield* useRunComposition({ root, cwd, ...rest }); + return yield* collect( + yield* execute({ + ...inlineSource(source), + stream: new InMemoryStream(), + ...(options.props === undefined ? {} : { props: options.props }), + }), + ); + }); +} + +/** What a suite reads back about one managed slot. */ +export interface ManagedSlot { + readonly slot: string; + readonly checkout: string; + readonly metadata: string; +} + +export function repositorySlotOf(root: string, locator: string, name: string): ManagedSlot { + const slot = repositorySlot(root, locator, name); + return { slot, checkout: checkoutOf(slot), metadata: metadataOf(slot) }; +} + +export function worktreeSlotOf(root: string, commonDirectory: string, name: string): ManagedSlot { + const slot = worktreeSlot(root, commonDirectory, name); + return { slot, checkout: checkoutOf(slot), metadata: metadataOf(slot) }; +} + +/** The parsed sidecar at this slot, or `undefined` when it holds none. */ +export function* readSidecar(slot: ManagedSlot): Operation { + if (!(yield* exists(slot.metadata))) { + return undefined; + } + return JSON.parse(yield* readTextFile(slot.metadata)); +} + +/** The canonical common Git directory of this checkout. */ +export function commonDirectoryOf(checkout: HostCheckout): string { + const reported = checkout.run("rev-parse", "--git-common-dir"); + const absolute = reported.startsWith("/") ? reported : join(checkout.root, reported); + // Synchronous so a test body can name a slot in an ordinary expression, the + // way it already names one from `git()`. Nothing is in flight to lose: this + // is a fixture reading its own directory before an execution exists. + // oxlint-disable-next-line local/no-sync-filesystem + return realpathSync(absolute); +} + +/** Whatever this operation raised, as a value. */ +export function* raised(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error; + } + throw new Error("the operation did not fail"); +} + +/** The first cause in this error's chain that `is` accepts. */ +export function causedBy(error: unknown, is: (value: unknown) => value is T): T | undefined { + let current: unknown = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (is(current)) { + return current; + } + current = current instanceof Error ? current.cause : undefined; + } + return undefined; +} diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index c0b88c42f..800e61b06 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -116,6 +116,28 @@ paths relative to cwd, and the engine's own file access is written that way: component search directories (`["./components", "./"]`) are relative, and resolved paths in the journal (`"components/Greeting.md"`) are relative. +#### Lexical working directories + +Three components produce the contextual working directory the operations above +resolve against, and each restores the enclosing one when its invocation ends — +on success, failure and cancellation alike. + +- `` expands its content at the selected checkout. +- `` expands its content at the linked checkout it + selected of the Repository in scope. +- `` expands its content at `path`, reading a relative one against + the directory already in effect. It selects no repository. + +Written self-closing with `as`, Repository and Worktree render nothing and bind +the checkout path instead, which is what lets a later sibling `` +render descendants there. + +Every consumer of the contextual working directory observes the selected +checkout: a process a document runs, a `` an agent is launched into, +and every relative document path. Which repository a Git element acts on is +therefore decided by where it is written, and the same element inside a `` +at a linked worktree acts on that worktree. + #### Document data and engine control plane Two kinds of filesystem access are separate boundaries, and the separation is @@ -2633,6 +2655,23 @@ absence falls through to a default: a candidate that exists but cannot be read, imported, parsed, or compiled fails where it is loaded, so a broken local component is never quietly replaced. +#### The run profile's repository declarations + +Thirteen names — `Repository`, `Worktree`, `Dir`, `Git.Switch`, `Git.Add`, +`Git.Commit`, `Git.Push`, `PullRequest`, `PullRequest.Reviews`, +`PullRequest.Comments`, `PullRequest.Checks`, `IssueTracker` and `Issue` — are +ordinary registered defaults in tier 5. A repository-local Markdown or +TypeScript component of any of those names is chosen ahead of them, exactly as +it is ahead of any other package's default, and for its own scope alone. + +They are one array with several consumers: an ordinary document execution, a +workflow attachment, `xmd syntax`, and `xmd plan`'s validation and generation. +Registering them installs no provider, discovers no repository, acquires no +lock, spawns no Git and reads no credential — describing an environment mints +nothing. What each name *does* is decided by whichever repository provider the +command installed (§8.1), and a host that installed none still resolves every +one of them. + The bundle tier exists only inside a workflow run, and such a run searches no repository directories at all — so a declared name resolves to the exact source its pinned commit holds and to nothing beside it in a mutable checkout. Core's @@ -8279,6 +8318,45 @@ workflow and returns a `DocumentExecution` handle. Options: - `secretDetection?` — detect credentials before durable events persist (default: enabled) +#### The host's repository provider + +A document execution reaches repository operations only through the provider +the command installed inside the execution scope, before the root document is +imported. There are two, and they differ in lifetime and authority rather than +in what an author writes. + +The **ordinary provider** is what the Deno source entrypoint and the compiled +binary install for `xmd run` and for an approved `xmd plan --run`. Constructing +it mints a fresh opaque invocation identity and empty state, both private to +that one execution: + +- **Ambient discovery** happens once, before root expansion, from the + invocation's starting directory: the canonical checkout root, the canonical + common Git directory, the object format, the current HEAD and branch, the + locally recorded admitted `origin` when there is one, and the recorded default + branch. Being outside a Git checkout is not a startup failure — only an + element that needs a repository refuses, and it names how to run inside one. +- **The invocation identity** names this execution to a service. It is not a + prop, a Context value, a component result, a middleware answer or a journal + event; it is neither addressable nor reusable, and the engine's own + `Expansion.id` names the authored site inside it. +- **The journal is not authority.** `--journal` writes a diagnostic trace that + starts from a path that did not exist; nothing reads one back. A run with an + in-memory stream and a run with `--journal` perform the same live operations, + the same number of times, and a later run starts with a new identity, empty + evidence and a new request. + +The **retained provider** is what a workflow host installs inside its Workspace +attachment. Its operations are durable effects keyed by the WorkflowRun, so a +completed one restores from the journal without contacting anything. + +Node and Bun install neither. They register the same declarations (§5.3), and +every repository operation there reports an absent provider before a lock, a +credential, a subprocess or a request exists. + +A nested `` child constructs a provider of its own, so its +identity, its locks and its evidence do not reach its parent or a sibling. + #### Secret detection Every execution refuses to persist a durable event that carries a credential. @@ -11440,6 +11518,39 @@ timed. | NEX23–NEX31 | Authority transport | A canonical `` whose host attached no installer refuses; installers planted under the delivery context's name are handed nothing and displace no real delivery; providers planted under the former public context are ignored; public `Component` middleware cannot change bound/unbound classification, rescue an unbound child failure, or suppress early publication; host middleware sees no replacement operation and cannot mutate the frozen profile or props; the `` behavior hook is called with the test's props alone, so middleware and a second loaded copy composing there acquire nothing | | NEXH1–NEXH4 | Production assembly | Under `xmd test`, a child resolves `./dir/kebab-name.md` and `file.md#Target`, runs a foreground command through the entrypoint's own adapter, collects a diagnostic journal, leaves no file behind for inline source, and refuses `` on a host with no workflow profile | +### Tier ORC — Repository composition under an ordinary run (§5.3, §8.1) + +Every case distinguishes what a *document* observed from what the *host* +holds, because that is the boundary the two profiles differ across. Real Git +repositories, a real managed root and a real second process carry the +canonical-identity, linked-worktree and kernel-lock claims; injected roots, +transports, credential readers and gates carry the rest. No case points at the +user's own `~/.xmd/repositories`. + +| # | Test | Verify | +|---|------|--------| +| ORC1 | One declaration surface | All thirteen names appear in the catalog with complete contracts; a repository file of one of those names shadows the default; catalog construction performs no ambient discovery, lock, Git, credential or network operation | +| ORC2 | Runtime declaration parity | The same catalog assertion holds under Deno, Node and Bun; on a runtime that installs no operational provider, representative Repository, Worktree, Git, Issue and PullRequest forms each report an absent provider with zero mutation, and `` still works | +| ORC3 | Ambient primary checkout | From a normal repository, root Switch/Add/Commit select the ambient Repository and the contextual checkout; outside Git, a root Worktree, Git operation or PullRequest refusal names how to run inside one | +| ORC4 | Ambient linked worktree | Invoked from a linked worktree, Repository identity follows the canonical common directory, Git acts on that worktree's root, and the primary checkout is untouched | +| ORC5 | Origin is not local authority | A repository with no `origin` creates a Worktree and performs local Git; Push and PullRequest refuse before a credential, session or transport exists | +| ORC6 | Lexical working directories | Repository, Worktree and Dir bodies each observe their own checkout, and the enclosing directory is restored on success and on a printed failure | +| ORC7 | Session placement | A Session launched in a managed Worktree receives that Git root and a distinct worktree session key; `.git`-file discovery remains the boundary | +| ORC8 | Persistent lifecycle | Managed paths, metadata and working files survive normal completion, authored failure and cancellation; no teardown Git or delete command occurs | +| ORC9 | Compatible reuse | A second invocation with the same immutable request reuses the same path and preserves a moved branch and a later commit while revalidating owner, origin, object format and creation commit | +| ORC10 | Conflict is non-mutating | A changed base, Worktree branch or base, metadata, origin, common directory, object format or owner relationship refuses, and the slot's entries and sidecar are identical before and after | +| ORC11 | Partial creation | A metadata-free slot in exactly the pre-exposure state is adopted and receives its sidecar; an incompatible or non-empty one refuses and remains byte-identical | +| ORC12 | Exclusive ownership | A second process selecting the same slot is refused while the first holds it; another slot succeeds concurrently; normal release permits a later owner and the checkout remains | +| ORC13 | Live local Git | Switch, Add and Commit keep their authored semantics and make real, non-transactional changes; a failure claims neither rollback nor replay | +| ORC14 | Live Push evidence | A performed or already-equal Push stores exact private evidence; a Push of another branch, checkout, origin, destination or commit does not authorize a PullRequest; the latest publication of a destination decides | +| ORC15 | Evidence cannot cross runs | A PullRequest succeeds only after an exact Push in the same execution; a new run must publish again, and copying a Context value, a result or a previous trace grants nothing | +| ORC16 | Live Issues | Configured reads and upserts use the existing normalized contracts and this execution's own identity; absent or out-of-ceiling configuration sends no credential and no request | +| ORC17 | Live PullRequests | Configured reviews, comments and checks reads, and a Push-authorized upsert, run under ordinary Deno; the read ceiling and the local evidence check both precede credential and network access | +| ORC18 | The journal is diagnostic | The same fixture without and with `--journal` performs the same live operations once per invocation; the trace is newly created and never consumed as continuation or evidence | +| ORC19 | Nested run profile | An isolated `host="run"` child receives the declarations and a fresh provider; its evidence and locks reach neither its parent nor a sibling | +| ORC20 | Retained workflow regression | Repository and Worktree replay, transactional Git, Push and pull-request history evidence, Issue effects, forks and completed replay keep their records, identities, provider call counts and native-launch refusal unchanged | +| ORC21 | Compiled binary | A compiled smoke creates a root-level ambient Worktree, runs a command there, proves `.git` is a file and the checkout persists after exit; a second gated process proves lock refusal and release | + --- diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index c56d5e25f..42f84cafe 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -60,6 +60,34 @@ exits. - `--journal` remains a diagnostic trace and is not continuation input. - Agent permissions remain caller-selected. +Repository operations are part of that environment under Deno and inside the +compiled binary. The ordinary provider gives a document the same thirteen +components a workflow run has, over the caller's own filesystem: + +- The **ambient Repository** is the Git checkout the command was run in, + discovered once before root expansion. Its identity is the canonical common + Git directory and its selected checkout is the canonical checkout root, so a + command started in a linked worktree names the same repository as one started + in the primary checkout while Git operations act on the worktree. A document + that never asks for a repository runs unchanged outside one. +- `` selects a **managed checkout** beneath + `~/.xmd/repositories`, and `` selects a linked one of whichever + Repository is in scope. Both survive every execution and are held for one + document execution by an exclusive non-blocking advisory lock. +- Local Git operations happen directly against the selected checkout. There is + no transaction, no rollback and no replay, and none is claimed. +- `` keeps the observe/adopt/fast-forward/refuse rules and stores + private evidence of what it published. `` is authorized by that + evidence and by nothing else, so a new run must publish again. +- `` and the pull-request evidence reads reach the same configured + transports under the same host ceilings, retaining nothing. + +Node and Bun register the same thirteen declarations and install no operational +provider, so `xmd syntax` describes one language everywhere and every +repository operation there reports an absent provider before a lock, a +credential, a subprocess or a request exists. `` needs no provider and +works everywhere. + ### 2.2 `xmd workflow` `xmd workflow` executes supported operations against a retained constrained @@ -813,6 +841,32 @@ run whose history it has just replaced. ## 6. Repository and Worktree +``, `` and `` are one component language with two +providers behind it (§2.1). What an author writes — the forms, the props, what +`as` binds, and the refusal vocabulary — is the same either way; what differs +is what a checkout *is*. + +Under a workflow run a checkout is retained Workspace state: creation identity +is a durable effect, placement is Workspace-relative, and a completed effect +restores from the journal. That is what the rest of this section describes. + +Under an ordinary `xmd run` a checkout is a directory. `` and +`` select a managed checkout beneath `~/.xmd/repositories`, described +by a closed version 1 sidecar and held for one document execution by an +exclusive non-blocking advisory lock; reuse compares creation identity alone +and never resets, cleans, fetches, repairs or deletes; and a Worktree written +outside a lexical `` belongs to the ambient Repository the command +was run in. Nothing there is retained, replayed or forkable, and none of the +durability this section states applies to it. + +What both share is the composition value a component observes: a Repository +selection carries a provider-minted identifier, a display name, the +credential-free repository identity and the selected checkout path, and grants +nothing. Every operation authenticates the selection it was handed against the +installed provider's private state before it touches Git, so a replaced +contextual Repository can misname a checkout and be refused but can never reach +one. + ``, `` and `` are ordinary registered defaults that the workflow host installs for a live or partial execution, alongside the document filesystem. A repository-local component with one of those names is @@ -980,6 +1034,19 @@ contextual way `` writes one. ## 7. Git operations +The four Git components are likewise one language with two providers. Which +checkout one acts on is decided the same way in both: by the Repository in +scope and by the contextual working directory, neither of which carries +authority. + +Under a workflow run each is a durable Workspace effect, or — for Push — a +reconciled Git-host effect, and that is what the rest of this section +describes. Under an ordinary `xmd run` the same authored transitions happen +directly against the selected checkout: no transaction encloses them, nothing +rolls back, nothing replays, and no such claim is made. Push keeps the +observe/adopt/fast-forward/refuse rules and, instead of a reconciliation +record, leaves private evidence in the provider instance that verified it. + Git operations require a contextual Repository or Worktree checkout. They use the transactional Workspace Git implementation, not an implicit host command. @@ -2178,6 +2245,11 @@ retained no checkpoint remains unassociated. ## 9. Replay and continuation +Everything in this section is workflow-only. An ordinary `xmd run` has no +WorkflowRun, no retained history and nothing to replay: its repository +operations are performed once per execution, and a second run is a second +question rather than a continuation of the first. + Replay rehydrates the Effection tree. Ephemeral structure executes again; durable observations and mutations restore. @@ -2335,6 +2407,14 @@ delete` (§12), which no document can reach. ### 10.2 Git-host effects +The reconciliation described here is a workflow run's. Under an ordinary run +there is no history to reconcile against: a pull request is observed once, +created or updated at most once inside that execution, and decided by one exact +observation afterwards, and what authorizes it is the provider instance's own +record of publishing the branch rather than a journal scan. Within one +invocation the attempt happens at most once; across a process interruption +there is no exactly-once claim. + A **Git host** is an external service that owns remote Git repositories and associated collaboration objects such as branches and pull requests. GitHub is one Git-host adapter. A Git host is distinct from the local Git capability of @@ -2487,6 +2567,12 @@ missing result causes reconciliation under the same deterministic identity. ### 10.3 Issue effects +The durable envelope described here is a workflow run's. Under an ordinary run +`` reaches the same configured transport under the same host ceiling +with no envelope at all: identity is that execution's own opaque invocation +identity together with the engine's expansion identity, an upsert presents an +idempotency key derived from them, and nothing is retained. + An **Issue provider** is an external service that owns a collection of issues. GitHub and Atlassian Cloud are Issue-provider adapters. @@ -3007,6 +3093,15 @@ is outside the initial local capability set; Worker Shell follows §10.4. A late Cloudflare-hosted or workerd-backed provider may install the same Workspace and lifecycle contracts; documents do not choose that topology. +An ordinary `xmd run` has a topology of its own beside this one. Managed +checkouts live under `~/.xmd/repositories`, in `repositories//` and +`worktrees///` slots holding a `checkout/` directory and a +`metadata.json` sidecar, with lock sidecars under `locks//.lock` +outside the slot they protect. Every authored string — a name, a locator — is +present only as a digest, so no name a document writes decides a path. It uses +the same host authentication and the same `XMD_WORKFLOW_GITHUB_ISSUES` and +`XMD_WORKFLOW_GITHUB_PULL_REQUESTS` configurations this host already reads. + The local lifecycle adapter owns a non-blocking exclusive advisory lock on one deterministic sidecar per run. The open file belongs to the workflow executor's scope and the operating system releases it when the process exits. The exact @@ -3256,7 +3351,11 @@ fetch operation requires its own language and durability contract. | caller-owned storage transaction | built by #291; Workspace mutations join it in #365 | | provider-backed retained Workspace | document filesystem built by #366 and repository composition by #293; document deletion (§10.1) built by #567 for both providers; process capabilities unbuilt (#218) | | `xmd workflow start` / `resume` | built by #366, Deno entrypoints only; both acquire #367's executor lock | -| ``, `` and `` composition | built by #293, Deno provider only | +| ``, `` and `` composition under a workflow run | built by #293, Deno provider only | +| the same thirteen declarations under every runtime | built by #643: one shadowable array consumed by the workflow attachment, `xmd syntax`, `xmd plan` and an ordinary document execution | +| ``, `` 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 | +| 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, 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 | | `` read and upsert, and the `issue_effect` boundary (§10.3) | built by #296; GitHub middleware, Deno host | | ``, ``, `` (§7.7) | built by #576; GitHub middleware, Deno host. Named by canonical URL and asked of `PullRequestApi`, which carries the upsert too; ordinary durable reads rather than reconciled effects, inheritable by a fork; complete or unavailable, never truncated. Which URLs may be read is operator configuration | From 3708337040307aece7982e93aa981792f8fc5e31 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:58:43 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A4=96=20Complete=20the=20ordinary=20?= =?UTF-8?q?run's=20repository=20evidence=20and=20Git=20identity=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the rest of the frozen ORC1–ORC21 matrix and corrects who an ordinary commit is by. An ordinary `` now records the invoking user's own effective Git author and committer identity, captured once from the trusted host's environment and configuration before a document expands and read back off the object it wrote. A workflow run keeps its one fixed identity — its retained state must not depend on whose machine made it — and an ordinary run inverts that, because the commit lands in that person's checkout. Nothing else is borrowed: hooks, file-system monitors, signing programs and repository-supplied credential helpers stay disabled. A host that can name no identity refuses that one component and names the commands that fix it rather than substituting the workflow name; every other component stays usable. Three defects the new evidence found, all fixed: - a managed-checkout lease was released when the task that took it completed rather than when the provider's scope ended, so a second process could take a slot an interactive Session was still working in; - the managed root canonicalized to one path before it existed and another afterwards, so the execution that created it and every execution after it computed different slots — and therefore never contended; and - the shared CLI module statically imported the Deno adapter, whose graph reaches `node:sqlite`, which stopped `xmd` loading under Bun at all. A working directory reached through a symbolic link now matches the checkout it is inside, and `scripts/smoke-run-composition.ts` runs two compiled binaries at once to prove placement, persistence, cross-process refusal and release. --- .github/workflows/ci.yml | 7 + architecture.md | 3 +- packages/cli/src/compiled.ts | 2 +- packages/cli/src/deno-repositories.ts | 52 + packages/cli/src/deno.ts | 2 +- packages/cli/src/run-repositories.ts | 39 +- .../cli/tests/run-composition-deno.test.ts | 233 ++++ packages/cli/tests/run-composition.test.ts | 152 ++- .../workflow/src/deno/composition/commit.ts | 24 +- packages/workflow/src/deno/composition/git.ts | 45 +- .../workflow/src/deno/composition/host.ts | 53 +- .../src/deno/run-composition/errors.ts | 28 + .../src/deno/run-composition/identity.ts | 140 ++ .../src/deno/run-composition/leases.ts | 30 +- .../src/deno/run-composition/operations.ts | 18 +- .../src/deno/run-composition/provider.ts | 80 +- .../workflow/tests/run-composition.test.ts | 1193 ++++++++++++++++- .../tests/support/run-composition-child.ts | 82 ++ .../workflow/tests/support/run-composition.ts | 284 +++- scripts/runtime-test-exclusions.ts | 33 +- scripts/smoke-run-composition.ts | 223 +++ scripts/tests/ci-workflow.test.ts | 1 + specs/executable-mdx-spec.md | 9 +- specs/workflow-workspace-spec.md | 18 +- 24 files changed, 2616 insertions(+), 135 deletions(-) create mode 100644 packages/cli/src/deno-repositories.ts create mode 100644 packages/cli/tests/run-composition-deno.test.ts create mode 100644 packages/workflow/src/deno/run-composition/identity.ts create mode 100644 packages/workflow/tests/support/run-composition-child.ts create mode 100644 scripts/smoke-run-composition.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dcb2943b..f427ddb0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,6 +222,13 @@ jobs: - name: The Component the compiled binary carries run: deno test --allow-all --frozen scripts/tests/plan-component-compiled.test.ts + # The ordinary repository provider is assembled at a runtime-named + # entrypoint and holds managed checkouts with a kernel-released advisory + # lock, so only the binary shows both surviving `deno compile`. The script + # runs two of them at once against a managed root of its own. + - name: Smoke test repository composition with the compiled binary + run: deno run --allow-all --frozen scripts/smoke-run-composition.ts + # `` resolves from core's registry, requests through the contextual # Fetch adapter, and detaches the response before binding it. All three # live in the module graph, so only the binary shows they survived diff --git a/architecture.md b/architecture.md index 64300e716..4260cd5a9 100644 --- a/architecture.md +++ b/architecture.md @@ -36,6 +36,7 @@ Existing documents and code get aligned to this section retroactively. | Repository selection | plain structural composition data naming the repository one component invocation acts on: an opaque provider-minted selection identifier, the display name, the credential-free repository identity, and the selected checkout path. It carries no credential, provider handle, lock, database, run ID or authority — the installed provider authenticates every selection against private state before it touches Git or a service, so a copied, replaced or rebuilt one can misname a target and be refused but can never reach one | | ambient Repository | the repository an ordinary `xmd run` was started inside, discovered once before root expansion from the invocation's starting directory. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so starting in a linked worktree names the same repository as starting in the primary checkout while Git operations still act on the worktree. A workflow run has none | | managed checkout | a Repository or Worktree an ordinary `xmd run` created under the host root `~/.xmd/repositories`, addressed by a digest of its whole identity, described by a closed version 1 sidecar written beside it, and held for one document execution by an exclusive non-blocking advisory lock. It survives every execution: nothing deletes, resets, cleans, fetches or repairs one | +| ordinary Git identity | the invoking user's effective Git author and committer name and email, captured once from the trusted host's own environment and configuration before a document expands and kept in the provider's closure. It is used for an ordinary `` and nothing else, because that commit lands in the caller's own checkout; a workflow run keeps its one fixed identity, whose whole purpose is that retained Git state does not depend on whose machine made it. It is not a prop, a Context value, a component result or a middleware answer, and nothing else about the environment is borrowed with it — hooks, file-system monitors, signing programs and repository-supplied credential helpers stay disabled by the same fixed command-line configuration. A host where Git can name no identity refuses `` with an actionable sentence rather than substituting the workflow one; every other component is unaffected | | ordinary invocation identity | a fresh opaque random value an ordinary document execution's repository provider mints for itself and keeps in its own closure. It is not a prop, a Context value, a component result, a middleware answer, a lifecycle ID or a retained record, and it is neither addressable nor reusable; live Issue and pull-request idempotency and reconciliation keys are derived from it together with the engine's own expansion identity | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | | document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | @@ -3717,7 +3718,7 @@ Status is measured against main. | `` under a workflow run | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` — the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit — proven by a scan of the whole successful history that requires each relevant record's natural key, inputs and result to describe one publication; a branch is published repeatedly, so the whole history is read in order and the run's last publication of that branch decides — an earlier one behind it is history rather than disagreement, while a last one naming another commit is the branch having moved on; that is conflicting, no relevant record at all is missing, and a relevant record that cannot be read whole is unreadable, each failing locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN`, then `GITHUB_TOKEN`, then the machine's own `gh` login, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only | | repository composition vocabulary | one array of thirteen ordinary, shadowable registrations — `Repository`, `Worktree`, `Dir`, the four `Git.*` operations, `PullRequest` and its three evidence reads, `IssueTracker` and `Issue` — consumed by the workflow attachment, by `xmd syntax` and `xmd plan`'s validation and generation, and by an ordinary document execution, so one vocabulary is described and resolved everywhere. Registering it installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential; what a name does is the installed provider's. A repository-local Markdown or TypeScript component of the same name is chosen ahead of any of them | built on the #643 stack | | `` / `` composition under an ordinary run | selects a managed checkout under `~/.xmd/repositories`, addressed by a digest of its whole identity and described by a closed version 1 sidecar written by exclusive temporary sibling plus atomic rename only after the checkout is complete and verified. The slot is entered under an exclusive non-blocking advisory lock held for the whole document execution, so a second process is refused rather than made to wait and a self-closing Worktree captured with `as` stays protected while a later sibling `` and an interactive Session use it. Reuse compares creation identity alone — the immutable request, the recorded creation facts, the canonical checkout and common directory, the object format, the admitted `origin` and the creation commit still being present — and never HEAD, the current branch, the index or the working tree, which are the mutable work the checkout exists to preserve; a conflict refuses and leaves every byte where it was, and nothing resets, switches, cleans, fetches, moves, replaces, repairs or deletes. A metadata-free slot is adopted only after the stricter pre-exposure state is proved — exact owner and locator, the branch and base this request resolves to, the creation commit still being HEAD, the object format, linked-worktree registration where applicable, and nothing in the slot but the checkout — and refuses otherwise. Written outside a lexical ``, a Worktree belongs to the ambient Repository; outside a Git checkout it refuses locally and names how to run inside one. `` is unchanged and needs no provider at all | built on the #643 stack, Deno and compiled only | -| local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | perform the same authored transitions the workflow performers perform — named branches only, explicit Add pathspecs, index-only Commit, no implicit stage or push, and the same fixed provider Git configuration that disables hooks, signing, file-system monitors and repository-supplied helper programs — directly against the authenticated selected checkout. They enlist in no transaction, roll back nothing and replay nothing, and a failure claims neither. Which checkout one runs in is decided by the Repository selection in scope and the contextual working directory, resolved through the provider's own invocation-owned checkout registry rather than through anything the selection says about itself | built on the #643 stack, Deno and compiled only | +| local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | perform the same authored transitions the workflow performers perform — named branches only, explicit Add pathspecs, index-only Commit, no implicit stage or push, and the same fixed provider Git configuration that disables hooks, signing, file-system monitors and repository-supplied helper programs — directly against the authenticated selected checkout. A commit records the invoking user's own effective Git identity, captured once from the trusted host before the document expands and read back off the written object; a host that can name no identity refuses `` and names the commands that fix it rather than writing the workflow identity into somebody's repository, and every other component stays usable. They enlist in no transaction, roll back nothing and replay nothing, and a failure claims neither. Which checkout one runs in is decided by the Repository selection in scope and the contextual working directory, resolved through the provider's own invocation-owned checkout registry rather than through anything the selection says about itself | built on the #643 stack, Deno and compiled only | | `Git.Push` under an ordinary run | keeps the same observe/adopt/fast-forward/refuse rules and the same isolated transport aimed at the checkout's admitted `origin`: a destination proven absent is published once, one already naming this exact commit is adopted, one holding a proven ancestor is published over by the same exact non-force refspec, and a divergent or unreadable one is a conflict, with an unreachable host never read as absence. It reconciles no Git-host effect and retains nothing. After a verified performed or adopted publication it stores one private evidence entry — the authenticated Repository identity, canonical checkout root, origin, named branch, destination ref and exact commit — in the provider instance's own closure. A checkout with no admitted `origin` refuses before a credential, a session or a transport exists | built on the #643 stack, Deno and compiled only | | `` and its evidence reads under an ordinary run | share the URL matching, host ceiling, response normalization and low-level GitHub reconciliation, and differ in lifecycle and authority. A read is performed afresh every execution and retained nowhere. An upsert authenticates the Repository selection and the contextual checkout, reads the current named branch and commit, and requires the exact matching entry this provider instance already holds — a Push for another checkout, Repository, origin, destination, branch or commit is irrelevant, a later Push of the same destination supersedes the earlier entry, and missing or conflicting evidence is a local refusal before a credential is opened. Nothing crosses executions: a new run and a new `--journal` run each start with a new invocation identity and empty evidence, and copying a Context value, a component result or a previous trace file grants nothing. Within one invocation the attempt happens at most once; across a process interruption there is no exactly-once claim | built on the #643 stack, Deno and compiled only | | `` under an ordinary run | reaches the same configured transport under the same host ceiling, with no durable envelope: identity is this execution's own opaque invocation identity together with the engine's expansion identity, so an upsert presents an idempotency key a provider can carry and a second run is a new request rather than a resumption. Absent or out-of-ceiling configuration installs no matching provider and sends no credential and no request | built on the #643 stack, Deno and compiled only | diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 68837112a..7f7828e50 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -12,7 +12,7 @@ import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoRunRepositories } from "./run-repositories.ts"; +import { denoRunRepositories } from "./deno-repositories.ts"; import { isCredentialHelperMode, runCredentialHelper, diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts new file mode 100644 index 000000000..1b8346769 --- /dev/null +++ b/packages/cli/src/deno-repositories.ts @@ -0,0 +1,52 @@ +/** + * The live repository provider, assembled where it can be. + * + * Kept apart from `run-repositories.ts` because that module is on the shared + * command path and this one names the Deno adapter, whose module graph reaches + * `node:sqlite`. Bun has no such built-in, so a static import of this from + * shared code would stop `xmd` loading there — not refuse a repository + * operation, but fail to start at all. Only `deno.ts` and `compiled.ts` import + * this file, and both of them are Deno. + * + * Managed checkouts live beneath `~/.xmd/repositories` and survive every + * execution: what is in one is somebody's work, and nothing deletes one. There + * is no environment variable naming a different root, because the only caller + * that needs one is a test, and a test is handed the root directly. + */ + +import type { Operation } from "effection"; +import { cwd } from "@executablemd/runtime"; +import { useRunComposition } from "@executablemd/workflow/deno"; +import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; +import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; +import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; +import type { RepositoryInstaller } from "./run-repositories.ts"; + +/** + * The live provider Deno and the compiled binary install. + * + * The two GitHub configurations are read once, when the installer runs, so an + * operator who wrote something this host cannot use learns it before a document + * expands rather than in the middle of one. + */ +export function denoRunRepositories( + helper: HelperAssembly, + root: string = DEFAULT_REPOSITORY_ROOT, +): RepositoryInstaller { + return function* (): Operation { + const gitHubIssues = yield* gitHubIssuesConfiguration(); + const gitHubPullRequests = yield* gitHubPullRequestsConfiguration(); + yield* useRunComposition({ + root, + // The directory this execution starts in, which is where the ambient + // repository is discovered from. Read through the contextual Api rather + // than from the process, so a nested execution that composed its own + // working directory is discovered from that one. + cwd: yield* cwd(), + helper, + ...(gitHubIssues === undefined ? {} : { gitHubIssues }), + ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), + }); + }; +} diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index ef7444aef..f24c20ea1 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -15,7 +15,7 @@ import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; -import { denoRunRepositories } from "./run-repositories.ts"; +import { denoRunRepositories } from "./deno-repositories.ts"; import { isCredentialHelperMode, runCredentialHelper, diff --git a/packages/cli/src/run-repositories.ts b/packages/cli/src/run-repositories.ts index 663969276..859a7b3a6 100644 --- a/packages/cli/src/run-repositories.ts +++ b/packages/cli/src/run-repositories.ts @@ -18,16 +18,17 @@ * declarations, so `xmd syntax` describes one language and a document resolves * the same names everywhere, and every operation then reaches a clear * provider-absence error before anything local or remote is touched. + * + * Nothing here imports the Deno adapter. This module is on the shared command + * path, and the adapter's module graph reaches `node:sqlite` — a built-in Bun + * does not have — so a static import of it here would stop `xmd` loading there + * at all. The live installer lives beside the entrypoints that can use it, in + * `deno-repositories.ts`. */ import { homedir } from "node:os"; import { join } from "node:path"; import type { Operation } from "effection"; -import { cwd } from "@executablemd/runtime"; -import { useRunComposition } from "@executablemd/workflow/deno"; -import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; -import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; -import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; /** Where managed repositories and worktrees live. */ export const DEFAULT_REPOSITORY_ROOT: string = join(homedir(), ".xmd", "repositories"); @@ -57,31 +58,3 @@ export function unsupportedRepositories(): Operation { // deno-lint-ignore require-yield function* noRepositories(): Operation {} - -/** - * The live provider Deno and the compiled binary install. - * - * The two GitHub configurations are read once, when the installer is built, so - * an operator who wrote something this host cannot use learns it before a - * document runs rather than in the middle of one. - */ -export function denoRunRepositories( - helper: HelperAssembly, - root: string = DEFAULT_REPOSITORY_ROOT, -): RepositoryInstaller { - return function* (): Operation { - const gitHubIssues = yield* gitHubIssuesConfiguration(); - const gitHubPullRequests = yield* gitHubPullRequestsConfiguration(); - yield* useRunComposition({ - root, - // The directory this execution starts in, which is where the ambient - // repository is discovered from. Read through the contextual Api rather - // than from the process, so a nested execution that composed its own - // working directory is discovered from that one. - cwd: yield* cwd(), - helper, - ...(gitHubIssues === undefined ? {} : { gitHubIssues }), - ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), - }); - }; -} diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts new file mode 100644 index 000000000..c26fa823d --- /dev/null +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -0,0 +1,233 @@ +/** + * Tier ORC — what only the runtime that operates repositories can be asked. + * + * Everything here needs the live provider, which needs a kernel-released + * advisory lock, so it is Deno's alone. The parity half — that Node and Bun + * describe the same language and operate none of it — is + * `run-composition.test.ts`, which runs everywhere. + * + * Three claims: where a Session launched in a managed Worktree lands, that a + * diagnostic trace grants nothing, and that a nested `host="run"` child gets a + * provider of its own. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, until, type Operation } from "effection"; +import { realpath } from "node:fs/promises"; +import { exists, readTextFile } from "@effectionx/fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import process from "node:process"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { deriveSessionKey, sessionCandidates } from "../../acp/src/session-key.ts"; +import { useCompositionComponents } from "@executablemd/workflow"; +import { useRunComposition } from "@executablemd/workflow/deno"; +import { FileStream } from "../src/file-stream.ts"; + +/** Git, with an environment a caller's own configuration cannot reach into. */ +function git(args: readonly string[], cwd: string, home: string): string { + const outcome = spawnSync("git", [...args], { + cwd, + env: { + ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), + HOME: home, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + LC_ALL: "C", + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + if (outcome.status !== 0) { + throw new Error(`git ${args.join(" ")} exited ${outcome.status}: ${outcome.stderr}`); + } + return outcome.stdout.trim(); +} + +/** A repository the command is "run in", and a managed root of this suite's own. */ +function* useAmbient(): Operation<{ checkout: string; root: string; home: string }> { + const home = yield* useTempDirectory("xmd-orc-home-"); + // Canonical, so what this fixture names and what Git reports are one string. + const parent = yield* until(realpath(yield* useTempDirectory("xmd-orc-ambient-"))); + const checkout = join(parent, "checkout"); + git(["init", "--initial-branch=main", checkout], parent, home); + git(["commit", "--allow-empty", "-m", "first"], checkout, home); + const managed = yield* until(realpath(yield* useTempDirectory("xmd-orc-managed-"))); + return { checkout, root: join(managed, "repositories"), home }; +} + +/** Run one document under the ordinary provider, on a stream a caller chose. */ +function runOrdinary( + source: string, + options: { root: string; cwd: string; journal?: string }, +): Operation { + return scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return options.cwd; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useCompositionComponents(); + yield* useRunComposition({ root: options.root, cwd: options.cwd }); + // `--journal` is exactly this: the file-backed stream instead of the + // in-memory one, created by the command before the run begins. + const stream = + options.journal === undefined ? new InMemoryStream() : new FileStream(options.journal); + return yield* collect(yield* execute({ ...inlineSource(source), stream })); + }); +} + +describe("ORC7 — a Session launched in a managed Worktree", () => { + it("receives the worktree's own Git root and a session key of its own", function* () { + const ambient = yield* useAmbient(); + + // A managed Worktree, made by the ordinary provider. + const bound = String( + yield* runOrdinary(`\n\n{w}`, { + root: ambient.root, + cwd: ambient.checkout, + }), + ).trim(); + expect(yield* exists(bound)).toBe(true); + + // `.git` there is a file, not a directory — which is what bounds the walk. + expect(yield* readTextFile(`${bound}/.git`)).toContain("gitdir:"); + + // The candidate walk from inside it stops at the worktree root, so a + // Session placed there is placed in the worktree rather than in the + // repository it belongs to. + const candidates = yield* sessionCandidates("codex", bound); + expect(candidates.map((candidate) => candidate.cwd)).toEqual([bound]); + + // And its key is its own: the same agent and the same session name in the + // ambient checkout is a different session. + const inWorktree = deriveSessionKey("codex", bound, "implementer"); + const inAmbient = deriveSessionKey("codex", ambient.checkout, "implementer"); + expect(inWorktree).not.toBe(inAmbient); + + // The ambient checkout's own walk is unaffected, and reaches its own root. + const ambientCandidates = yield* sessionCandidates("codex", ambient.checkout); + expect(ambientCandidates.map((candidate) => candidate.cwd)).toEqual([ambient.checkout]); + }); +}); + +describe("ORC18 — the journal is diagnostic", () => { + it("performs the same live work with and without a trace, once each", function* () { + const first = yield* useAmbient(); + const second = yield* useAmbient(); + const trace = join(second.root, "..", "diagnostic.jsonl"); + + const document = [ + ``, + "", + `made`, + ``, + ``, + "", + ].join("\n"); + + yield* runOrdinary(document, { root: first.root, cwd: first.checkout }); + yield* runOrdinary(document, { root: second.root, cwd: second.checkout, journal: trace }); + + // One live mutation per invocation, either way: each repository has exactly + // one commit on the branch beyond the one it started with. + for (const ambient of [first, second]) { + expect( + git(["log", "--oneline", "traced"], ambient.checkout, ambient.home).split("\n"), + ).toHaveLength(2); + expect(git(["log", "-1", "--pretty=%s", "traced"], ambient.checkout, ambient.home)).toBe( + "Traced", + ); + } + + // The trace was newly created by that run and holds its events. + expect(yield* exists(trace)).toBe(true); + const written = yield* readTextFile(trace); + expect(written.length).toBeGreaterThan(0); + + // And it is not continuation. A third execution handed that exact trace + // performs its own work against its own repository — the trace neither + // restores the earlier commit nor stands in for one. + const third = yield* useAmbient(); + yield* runOrdinary(document, { root: third.root, cwd: third.checkout, journal: trace }); + expect(git(["log", "-1", "--pretty=%s", "traced"], third.checkout, third.home)).toBe("Traced"); + expect( + git(["log", "--oneline", "traced"], third.checkout, third.home).split("\n"), + ).toHaveLength(2); + }); +}); + +describe("ORC19 — a nested run profile", () => { + it("gives each execution a provider of its own, with no shared leases", function* () { + const ambient = yield* useAmbient(); + + // Two executions in sequence, each constructing its own provider against + // the same managed root and the same slot. The second is only possible if + // the first released — which is what a provider per execution means. + const document = `\n\n{w}`; + const parent = String( + yield* runOrdinary(document, { root: ambient.root, cwd: ambient.checkout }), + ).trim(); + const child = String( + yield* runOrdinary(document, { root: ambient.root, cwd: ambient.checkout }), + ).trim(); + expect(child).toBe(parent); + + // And a provider constructed inside another execution's scope holds its own + // evidence: the inner one has published nothing, so its `` is + // refused even though the outer one is standing in the same checkout. + const failure = yield* raisedValue( + scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return ambient.checkout; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useCompositionComponents(); + yield* useRunComposition({ root: ambient.root, cwd: ambient.checkout }); + // A second, nested provider — exactly what an isolated `host="run"` + // child constructs from the same installer. + return yield* scoped(function* () { + yield* useRunComposition({ root: ambient.root, cwd: ambient.checkout }); + return yield* collect( + yield* execute({ + ...inlineSource(``), + stream: new InMemoryStream(), + }), + ); + }); + }), + ); + expect(String(failure)).toMatch(/no usable origin|holds no successful result/); + }); +}); + +/** Whatever this operation raised, as a value. */ +function* raisedValue(operation: Operation): Operation { + try { + yield* operation; + } catch (error) { + return error; + } + throw new Error("the operation did not fail"); +} diff --git a/packages/cli/tests/run-composition.test.ts b/packages/cli/tests/run-composition.test.ts index a87d9cf75..8793ffb03 100644 --- a/packages/cli/tests/run-composition.test.ts +++ b/packages/cli/tests/run-composition.test.ts @@ -1,24 +1,32 @@ /** * Tier ORC — how the command line assembles repository operations. * - * Two claims, and they are about opposite things. One runtime *operates* the - * vocabulary and one only *describes* it, and both have to be true at once: a - * document written for `xmd run` resolves the same thirteen names everywhere, - * and on a runtime that operates none of them every one reports an absent - * provider before a lock, a credential, a subprocess or a request exists. + * Three claims, and they are about opposite things. Describing the vocabulary + * must reach nothing; one runtime *operates* it; and one only *describes* it. + * All three have to be true at once, so a document written for `xmd run` + * resolves the same thirteen names everywhere, and on a runtime that operates + * none of them every one reports an absent provider before a lock, a + * credential, a subprocess or a request exists. * - * The declarations are the same array in both cases, which is why there is no - * third thing to keep in agreement. + * The declarations are the same array in every case, which is why there is no + * fourth thing to keep in agreement. + * + * Everything here runs under Deno, Node and Bun. The parity claim is not one to + * defer to CI: what it asserts is that a runtime with no operational provider + * still describes and resolves the whole language. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, type Operation } from "effection"; +import { scoped, suspend, type Operation } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; -import { API, useHostFiles } from "@executablemd/runtime"; +import { API, Service, useHostFiles } from "@executablemd/runtime"; +import type { RuntimeFetchResponse } from "@executablemd/runtime"; +import { exists, readdir } from "@effectionx/fs"; +import { useTempDirectory } from "@executablemd/test-support/temp"; import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; -import { useRunProfileRegistry } from "../src/syntax.ts"; +import { syntaxCatalog, useRunProfileRegistry } from "../src/syntax.ts"; import { DEFAULT_REPOSITORY_ROOT, unsupportedRepositories } from "../src/run-repositories.ts"; /** Every element an author can write that needs a repository provider. */ @@ -37,9 +45,34 @@ const OPERATIONS: readonly { readonly name: string; readonly source: string }[] name: "PullRequest.Reviews", source: ``, }, + { + name: "PullRequest.Comments", + source: ``, + }, + { + name: "PullRequest.Checks", + source: ``, + }, { name: "Issue", source: `` }, ]; +/** The thirteen names #643 settled, exactly as a document writes them. */ +const COMPOSITION_NAMES = [ + "Repository", + "Worktree", + "Dir", + "Git.Switch", + "Git.Add", + "Git.Commit", + "Git.Push", + "PullRequest", + "PullRequest.Reviews", + "PullRequest.Comments", + "PullRequest.Checks", + "IssueTracker", + "Issue", +] as const; + /** * Run one element with the declarations registered and no provider installed — * which is exactly what Node and Bun assemble. @@ -64,15 +97,98 @@ function ordinaryWithoutProvider(source: string, cwd: string): Operation { + it("builds the catalog without a subprocess, a service, a request or a lock", function* () { + const managed = yield* useTempDirectory("xmd-orc1-managed-"); + const reached: string[] = []; + + const catalog = yield* scoped(function* () { + // Tripwires at every host boundary the provider would use, installed + // beneath everything so nothing can answer ahead of them. Each records + // rather than throwing, so a failure says which boundary was reached. + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *command(): Operation { + reached.push("command"); + return []; + }, + // deno-lint-ignore require-yield + *cwd(): Operation { + return managed; + }, + }, + { at: "min" }, + ); + yield* API.Fetch.around( + { + // deno-lint-ignore require-yield + *fetch(): Operation { + reached.push("fetch"); + throw new Error("the catalog reached the network"); + }, + }, + { at: "min" }, + ); + yield* Service.around( + { + *start(): Operation { + reached.push("service"); + throw new Error("the catalog started a service"); + // deno-lint-ignore no-unreachable + yield* suspend(); + }, + }, + { at: "min" }, + ); + return yield* syntaxCatalog([]); + }); + + // The whole vocabulary is described. + const builtIn = catalog.categories[1].entries.map((entry) => entry.name); + for (const name of COMPOSITION_NAMES) { + expect(builtIn).toContain(name); + } + + // And nothing was reached to describe it: no command was built for a + // subprocess, no request was sent, no service was started. + expect(reached).toEqual([]); + // No managed root, no slot, no lock sidecar — nothing was created at all. + expect(yield* readdir(managed)).toEqual([]); + }); + + it("leaves every repository operation unprovided after inspection", function* () { + // Registering the declarations installs no provider: the Apis still answer + // with their own defaults, which is what a catalog is allowed to leave + // behind. + yield* useRunProfileRegistry(); + const failure = yield* raisedValue( + collect(yield* execute({ ...inlineSource(``), stream: new InMemoryStream() })), + ); + // `` has no lexical Repository, so the first thing it asks for + // is the ambient one — and that is the Api reporting absence. + expect(String(failure)).toContain("no Repository composition provider is installed"); + }); +}); + describe("ORC2 — one language, described everywhere and operated somewhere", () => { it("registers the same thirteen declarations the syntax catalog describes", function* () { // The array itself, rather than a second list: `useRunProfileRegistry()`, // `installDocumentComponents()` and `useCompositionComponents()` all // consume this one, so there is nothing for a runtime to disagree about. expect(COMPOSITION_REGISTRATIONS).toHaveLength(13); - yield* scoped(function* () { - yield* useRunProfileRegistry(); - }); + expect([...COMPOSITION_REGISTRATIONS].map((registration) => registration.name).sort()).toEqual( + [...COMPOSITION_NAMES].sort(), + ); + + // And the catalog every runtime builds describes each of them completely. + const catalog = yield* scoped(() => syntaxCatalog([])); + const builtIn = catalog.categories[1].entries; + for (const name of COMPOSITION_NAMES) { + const entry = builtIn.find((candidate) => candidate.name === name); + expect(`${name}: ${entry?.description !== undefined}`).toBe(`${name}: true`); + expect(`${name}: ${(entry?.forms?.length ?? 0) > 0}`).toBe(`${name}: true`); + } }); it("reports an absent provider for every repository operation, and mutates nothing", function* () { @@ -82,10 +198,10 @@ describe("ORC2 — one language, described everywhere and operated somewhere", ( // refusal below would then be about something else. const failure = yield* raisedValue(ordinaryWithoutProvider(operation.source, ".")); // The element's name travels with the assertion, so a failure says which - // of the nine reported something else. + // of the twelve reported something else. const reported = `${operation.name}: ${String(failure)}`; expect(reported).toMatch( - /provider is not installed|no Repository composition provider|no Git composition provider|no Issue provider|no pull-request provider/, + /provider is not installed|no Repository composition provider|no Git composition provider|no issue provider|no Issue provider|no pull-request provider/, ); } }); @@ -102,7 +218,11 @@ describe("ORC2 — one language, described everywhere and operated somewhere", ( describe("ORC2 — where the managed root is", () => { it("names ~/.xmd/repositories and nothing a document can influence", function* () { expect(DEFAULT_REPOSITORY_ROOT.endsWith("/.xmd/repositories")).toBe(true); - yield* scoped(function* () {}); + // Describing the vocabulary and refusing an operation both leave it exactly + // as they found it — including not existing. + const before = yield* exists(DEFAULT_REPOSITORY_ROOT); + yield* raisedValue(ordinaryWithoutProvider(``, ".")); + expect(yield* exists(DEFAULT_REPOSITORY_ROOT)).toBe(before); }); }); diff --git a/packages/workflow/src/deno/composition/commit.ts b/packages/workflow/src/deno/composition/commit.ts index c4163f535..765502bee 100644 --- a/packages/workflow/src/deno/composition/commit.ts +++ b/packages/workflow/src/deno/composition/commit.ts @@ -43,7 +43,7 @@ import { } from "../../composition/components/GitCommit.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; import { commitIndex, readCommit, readCommitMessage, resolveCommit } from "./git.ts"; -import type { RepositoryHost } from "./host.ts"; +import type { GitCommitIdentity, RepositoryHost } from "./host.ts"; import { settled, type CompositionOutcome, type MutationContext } from "./effects.ts"; import { gitRefused } from "./refusals.ts"; import { @@ -141,6 +141,14 @@ export function* performCommit( before: GitCheckoutState, message: string, evidence: GitCommitMessageEvidence, + /** + * Who this commit is by, when it is not the fixed workflow identity. + * + * Absent for a workflow run, whose retained Git state must not depend on + * whose machine it was made on. Present for an ordinary run, where the commit + * lands in the caller's own checkout. + */ + identity?: GitCommitIdentity, ): Operation { // Nothing staged is not a failure of native Git; it is the state of the // checkout, and a document can act on it. Deciding it here means no command @@ -156,6 +164,7 @@ export function* performCommit( workingDirectory, message, committedAt, + ...(identity === undefined ? {} : { identity }), }); const commit = yield* resolveCommit(git, directory, "HEAD"); @@ -180,6 +189,19 @@ export function* performCommit( if (facts.authoredAt !== committedAt || facts.committedAt !== committedAt) { unexpected("the commit it wrote is not stamped with the instant this operation captured"); } + // Read back rather than assumed. The identity is the one thing about a commit + // this provider borrows from outside itself, so the object is held to it: a + // host that ignored the variables would otherwise write somebody else's name + // and this operation would report success. + if ( + identity !== undefined && + (facts.authorName !== identity.authorName || + facts.authorEmail !== identity.authorEmail || + facts.committerName !== identity.committerName || + facts.committerEmail !== identity.committerEmail) + ) { + unexpected("the commit it wrote does not record the identity this operation was given"); + } const written = yield* readCommitMessage(git, directory, commit); if (written === undefined) { diff --git a/packages/workflow/src/deno/composition/git.ts b/packages/workflow/src/deno/composition/git.ts index f801d856d..0a1594cb4 100644 --- a/packages/workflow/src/deno/composition/git.ts +++ b/packages/workflow/src/deno/composition/git.ts @@ -32,7 +32,7 @@ import { GitOperationInfrastructureError, type GitFailureReason, } from "../../composition/errors.ts"; -import type { GitOutcome, RepositoryHost } from "./host.ts"; +import type { GitCommitIdentity, GitOutcome, RepositoryHost } from "./host.ts"; import { unauthenticable } from "./authentication.ts"; import type { GitAttachment, GitAuthenticationSession } from "./authentication.ts"; @@ -65,6 +65,8 @@ export interface GitCommand { readonly input?: string; /** The whole Unix second an object-writing command records. */ readonly committedAt?: number; + /** Who this command records as author and committer, when not the fixed one. */ + readonly identity?: GitCommitIdentity; /** * What the provider invocation running this command borrowed from the host. * @@ -642,6 +644,8 @@ export interface IndexCommit { /** The canonical message bytes, exactly as they are to be committed. */ readonly message: string; readonly committedAt: number; + /** Who this commit is by, when it is not the fixed workflow identity. */ + readonly identity?: GitCommitIdentity; } /** @@ -656,7 +660,11 @@ export function* commitIndex(git: GitSession, request: IndexCommit): Operation { const reported = yield* git.read( - ["log", "-1", "--pretty=format:%T%n%at%n%ct%n%P", commit, "--"], + ["log", "-1", "--pretty=format:%T%n%at%n%ct%n%an%n%ae%n%cn%n%ce%n%P", commit, "--"], directory, ); if (reported === undefined) { return undefined; } - const [tree, authored, committed, parents] = reported.split("\n"); + const [ + tree, + authored, + committed, + authorName, + authorEmail, + committerName, + committerEmail, + parents, + ] = reported.split("\n"); const authoredAt = wholeSeconds(authored); const committedAt = wholeSeconds(committed); - if (tree === undefined || tree === "" || authoredAt === undefined || committedAt === undefined) { + if ( + tree === undefined || + tree === "" || + authoredAt === undefined || + committedAt === undefined || + authorName === undefined || + authorEmail === undefined || + committerName === undefined || + committerEmail === undefined + ) { return undefined; } return { @@ -704,6 +735,10 @@ export function* readCommit( tree, authoredAt, committedAt, + authorName, + authorEmail, + committerName, + committerEmail, }; } diff --git a/packages/workflow/src/deno/composition/host.ts b/packages/workflow/src/deno/composition/host.ts index 30240e3c3..a3d4fb690 100644 --- a/packages/workflow/src/deno/composition/host.ts +++ b/packages/workflow/src/deno/composition/host.ts @@ -85,6 +85,17 @@ export interface GitInvocation { * decides for itself is when. Absent for every command that writes no object. */ readonly committedAt?: number; + /** + * Who this command records as author and committer, when it is not the + * workflow identity below. + * + * Absent on every command a workflow run issues, and on every command that + * writes no object. An ordinary run supplies the invoking user's own + * effective identity for its one commit, because that commit lands in that + * person's checkout — and everything else about the environment stays exactly + * as fixed as it is for a workflow. + */ + readonly identity?: GitCommitIdentity; /** * What this command's provider invocation borrowed from the host. * @@ -145,8 +156,20 @@ const CONFIGURATION: readonly string[] = [ const IDENTITY_NAME = "Executable.md workflow"; const IDENTITY_EMAIL = "workflow@executable.md.invalid"; +/** Who a run's Git state is written by, when it is not the fixed identity. */ +export interface GitCommitIdentity { + readonly authorName: string; + readonly authorEmail: string; + readonly committerName: string; + readonly committerEmail: string; +} + /** The variables Git may see, and nothing else. */ -function environment(home: string, committedAt: number | undefined): Record { +function environment( + home: string, + committedAt: number | undefined, + identity: GitCommitIdentity | undefined, +): Record { const path = process.env.PATH; return { // A fixed offset beside the second, so the instant a commit records is the @@ -171,10 +194,15 @@ function environment(home: string, committedAt: number | undefined): Record { + git({ + args, + cwd, + home, + input, + committedAt, + identity, + attachment, + }: GitInvocation): Operation { // A command with no attachment reaches no authentication mechanism. That // is what makes a completed replay reach none: replay performs no remote // operation, so no invocation ever opens a session to attach. @@ -207,7 +243,10 @@ export function denoRepositoryHost(options: RepositoryHostOptions = {}): Reposit command: "git", args: [...CONFIGURATION, ...(attachment?.configuration ?? []), ...args], cwd, - env: { ...environment(home, committedAt), ...(attachment?.environment ?? {}) }, + env: { + ...environment(home, committedAt, identity), + ...(attachment?.environment ?? {}), + }, ...(input === undefined ? {} : { input }), }); }, diff --git a/packages/workflow/src/deno/run-composition/errors.ts b/packages/workflow/src/deno/run-composition/errors.ts index 459747f17..c474b4b4a 100644 --- a/packages/workflow/src/deno/run-composition/errors.ts +++ b/packages/workflow/src/deno/run-composition/errors.ts @@ -66,6 +66,34 @@ export class NoAmbientRepositoryError extends StaleInputError { } } +/** + * This host cannot say who a commit would be by. + * + * A refusal a document can act on, in the same vocabulary `` + * already speaks: it names the two commands that fix it. Substituting the + * workflow identity instead would write a name nobody in this repository + * recognizes, which is exactly what an ordinary run must not do — and it would + * do it silently. + * + * It reaches `` alone. Repository, Worktree, Dir, Switch, Add, + * Push, Issue and PullRequest write no commit object and are unaffected. + */ +export class UnresolvedGitIdentityError extends Error { + override name = "UnresolvedGitIdentityError"; + + readonly reason = "unresolved-identity"; + + constructor() { + super( + " cannot record who this commit is by: this host's Git reports no author or " + + 'committer identity. Set one with `git config --global user.name "Your Name"` and ' + + "`git config --global user.email you@example.com`, or export GIT_AUTHOR_NAME, " + + "GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL. Nothing was committed, " + + "and no other identity was substituted for yours.", + ); + } +} + /** * A branch this run has not published, or has published somewhere else. * diff --git a/packages/workflow/src/deno/run-composition/identity.ts b/packages/workflow/src/deno/run-composition/identity.ts new file mode 100644 index 000000000..a295a972d --- /dev/null +++ b/packages/workflow/src/deno/run-composition/identity.ts @@ -0,0 +1,140 @@ +/** + * Who an ordinary run's commits are by. + * + * A workflow run's Git state must not depend on whose machine it was created + * on, so its provider commits under one fixed identity and builds Git's + * environment from nothing. An ordinary run is the opposite case: the commit + * lands in a person's own checkout, on a branch they will look at tomorrow, and + * attributing it to `Executable.md workflow` would put a name in their history + * that nobody there recognizes. + * + * So the invoking user's effective identity is captured once, before the + * document expands, and used for `` alone. + * + * ## Captured from the trusted host, and nowhere else + * + * `git var GIT_AUTHOR_IDENT` and `GIT_COMMITTER_IDENT` are exactly what native + * Git would use: the `GIT_*_NAME`/`GIT_*_EMAIL` variables, then `user.name` and + * `user.email` from the configuration Git itself resolves, then whatever the + * host can auto-detect. Reading it takes the caller's own environment and the + * directory the command was run in, which is why it happens here — at the + * trusted entrypoint's provider construction, before any document code exists. + * + * It is not a prop, a Context value, a component result or a middleware answer, + * and no document can read it, replace it or ask for a different one. + * + * ## Only the identity is borrowed + * + * The commands that run afterwards keep every other protection: hooks, + * file-system monitors, signing programs and repository-supplied credential + * helpers stay disabled by the same fixed command-line configuration a workflow + * run uses, and `HOME` still points at a disposable directory. What crosses + * from the caller's environment is four strings. + * + * ## An unresolvable identity refuses, and refuses narrowly + * + * A host where Git cannot say who the user is is a host that cannot commit, and + * substituting the workflow identity would be writing somebody else's name into + * a person's repository to avoid saying so. `` reports it and names + * the two commands that fix it. Every other component — Repository, Worktree, + * Dir, Switch, Add, Push, Issue, PullRequest — is unaffected: none of them + * writes a commit object. + */ + +import type { Operation } from "effection"; +import process from "node:process"; +import { runProcess } from "../composition/subprocess.ts"; + +/** The four strings a commit object records about who made it. */ +export interface GitCommitIdentity { + readonly authorName: string; + readonly authorEmail: string; + readonly committerName: string; + readonly committerEmail: string; +} + +/** + * What one `git var …_IDENT` answer says, or `undefined` when it says nothing + * usable. + * + * The shape is `Name `, and the timestamp is + * deliberately discarded: when a commit is made is the operation's own decision, + * captured at the moment it runs. + */ +export function parseGitIdent(reported: string): { name: string; email: string } | undefined { + const opened = reported.lastIndexOf(" <"); + const closed = reported.indexOf(">", opened); + if (opened <= 0 || closed < 0) { + return undefined; + } + const name = reported.slice(0, opened).trim(); + const email = reported.slice(opened + 2, closed).trim(); + return name === "" || email === "" ? undefined : { name, email }; +} + +/** How this module asks Git a question. Substituted whole by a suite. */ +export type IdentityReader = (variable: string) => Operation; + +/** + * The reader the trusted entrypoint uses: native Git, the caller's own + * environment, and the directory the command was run in. + * + * The environment is inherited rather than built, which is the one place in + * this provider that is true — the whole question being asked is what the + * caller's environment and configuration say. + */ +export function denoIdentityReader(cwd: string): IdentityReader { + return function* (variable: string): Operation { + const outcome = yield* runProcess({ + command: "git", + args: ["var", variable], + cwd, + env: { ...inherited(), LC_ALL: "C" }, + }); + if (outcome.code !== 0) { + return undefined; + } + const reported = outcome.stdout.trim(); + return reported === "" ? undefined : reported; + }; +} + +function inherited(): Record { + const environment: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) { + environment[name] = value; + } + } + return environment; +} + +/** + * The identity ordinary commits are made under, or `undefined` when this host + * cannot say. + * + * Both idents are asked for, because Git resolves them separately and a host + * may know one and not the other. Either one missing leaves the whole answer + * absent: a commit whose author this run knows and whose committer it guessed + * would be exactly the substitution this exists to prevent. + */ +export function* captureCommitIdentity( + read: IdentityReader, +): Operation { + const authored = yield* read("GIT_AUTHOR_IDENT"); + const committed = yield* read("GIT_COMMITTER_IDENT"); + if (authored === undefined || committed === undefined) { + return undefined; + } + const author = parseGitIdent(authored); + const committer = parseGitIdent(committed); + if (author === undefined || committer === undefined) { + return undefined; + } + return Object.freeze({ + authorName: author.name, + authorEmail: author.email, + committerName: committer.name, + committerEmail: committer.email, + }); +} diff --git a/packages/workflow/src/deno/run-composition/leases.ts b/packages/workflow/src/deno/run-composition/leases.ts index 5d5f707a6..27d353806 100644 --- a/packages/workflow/src/deno/run-composition/leases.ts +++ b/packages/workflow/src/deno/run-composition/leases.ts @@ -32,8 +32,9 @@ * still held, so the sidecar is created if absent and then left, empty. */ -import { until, useScope, type Operation, type Scope } from "effection"; +import { race, suspend, useScope, withResolvers, type Operation, type Scope } from "effection"; import { useAdvisoryLock } from "../advisory-lock.ts"; +import type { AdvisoryLockFile } from "../advisory-lock.ts"; import { ManagedCheckoutError } from "./errors.ts"; import { lockOf } from "./placement.ts"; @@ -63,7 +64,32 @@ export function* useLeases(root: string): Operation { if (held.has(path)) { return; } - const file = yield* until(owner.run(() => useAdvisoryLock(path))); + // The acquisition runs in the provider's scope and then *suspends*, which + // is what makes the hold last as long as the provider does. A task that + // returned the handle would complete, and completing releases everything + // the task acquired — so the lock would be gone the moment the element + // that asked for it finished, and a second process could take the slot + // out from under an interactive Session still working in it. + const acquired = withResolvers(); + const failed = withResolvers(); + owner.run(function* () { + let file: AdvisoryLockFile | undefined; + try { + file = yield* useAdvisoryLock(path); + } catch (error) { + failed.reject(error instanceof Error ? error : new Error(String(error))); + return; + } + acquired.resolve(file); + if (file === undefined) { + // Refused. There is nothing to hold open, so this task ends rather + // than suspending for the rest of the execution over a lock it never + // took. + return; + } + yield* suspend(); + }); + const file = yield* race([acquired.operation, failed.operation]); if (file === undefined) { throw new ManagedCheckoutError( "in-use", diff --git a/packages/workflow/src/deno/run-composition/operations.ts b/packages/workflow/src/deno/run-composition/operations.ts index f7fc0919c..80d0cb4fb 100644 --- a/packages/workflow/src/deno/run-composition/operations.ts +++ b/packages/workflow/src/deno/run-composition/operations.ts @@ -73,9 +73,13 @@ import type { GitSession } from "../composition/git.ts"; import { checkoutState, type GitCheckout } from "../composition/operations.ts"; import { performSwitch } from "../composition/switch.ts"; import { gitCommitMessageEvidence, performCommit } from "../composition/commit.ts"; -import { useGitAuthentication, type RepositoryHost } from "../composition/host.ts"; +import { + useGitAuthentication, + type GitCommitIdentity, + type RepositoryHost, +} from "../composition/host.ts"; import { gitRefused } from "../composition/refusals.ts"; -import { LivePushEvidenceError } from "./errors.ts"; +import { LivePushEvidenceError, UnresolvedGitIdentityError } from "./errors.ts"; /** * One checkout this execution may act in. @@ -198,10 +202,18 @@ export function* liveCommit( checkout: GitCheckout, message: string, messageSource: GitCommitMessageSource, + identity: GitCommitIdentity | undefined, ): Operation { + // Before the index is read and long before an object is written: a host that + // cannot say who a commit is by cannot make one, and saying so first means + // nothing was staged, moved or written for a commit that was never going to + // exist. + if (identity === undefined) { + throw new UnresolvedGitIdentityError(); + } const evidence = gitCommitMessageEvidence(message); const before: GitCheckoutState = yield* checkoutState(checkout.git, checkout.directory, COMMIT); - const performed = yield* performCommit(checkout, before, message, evidence); + const performed = yield* performCommit(checkout, before, message, evidence, identity); const after = yield* checkoutState(checkout.git, checkout.directory, COMMIT); return Object.freeze({ checkout: checkout.identity, diff --git a/packages/workflow/src/deno/run-composition/provider.ts b/packages/workflow/src/deno/run-composition/provider.ts index 931e60565..e3e8a913c 100644 --- a/packages/workflow/src/deno/run-composition/provider.ts +++ b/packages/workflow/src/deno/run-composition/provider.ts @@ -92,6 +92,7 @@ import { import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; import { selectionRegistry } from "../selections.ts"; import { discoverAmbientRepository, type AmbientRepository } from "./ambient.ts"; +import { captureCommitIdentity, denoIdentityReader, type IdentityReader } from "./identity.ts"; import { selectManagedRepository, selectManagedWorktree } from "./checkouts.ts"; import { NoAmbientRepositoryError } from "./errors.ts"; import { useLeases } from "./leases.ts"; @@ -108,6 +109,18 @@ import { type RegisteredCheckout, } from "./operations.ts"; import { repositorySlot, worktreeSlot } from "./placement.ts"; +import { realpath } from "node:fs/promises"; +import { ensureDir } from "@effectionx/fs"; +import { until } from "effection"; + +/** The canonical directory this path resolves to, or the path as written. */ +function* canonicalPath(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} export interface RunCompositionOptions { /** Where managed checkouts live. Production passes `~/.xmd/repositories`. */ @@ -117,6 +130,14 @@ export interface RunCompositionOptions { readonly host?: RepositoryHost; readonly authentication?: GitAuthentication; readonly helper?: HelperAssembly; + /** + * How this host reads the invoking user's effective Git identity. + * + * Absent uses native Git with the caller's own environment and starting + * directory, which is the whole question. A suite substitutes it to say what + * this host knows — including that it knows nothing. + */ + readonly identity?: IdentityReader; /** What GitHub issue handling this host installs, and what it may reach. */ readonly gitHubIssues?: GitHubIssuesOptions; /** The pull-request destinations this host allows a document to read. */ @@ -152,7 +173,14 @@ export function* useRunComposition(options: RunCompositionOptions): Operation(); const registered: RegisteredCheckout[] = []; const evidence: PushEvidence[] = []; @@ -160,6 +188,13 @@ export function* useRunComposition(options: RunCompositionOptions): Operation` refuses. + const identity = yield* captureCommitIdentity( + options.identity ?? denoIdentityReader(options.cwd), + ); + // Once, before root expansion. A repository this command was not run inside // is remembered as absent rather than refused, so a document that never asks // for one runs exactly as it would anywhere else. @@ -170,7 +205,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const slot = repositorySlot(options.root, request.locator, request.name); + const slot = repositorySlot(root, request.locator, request.name); yield* leases.hold("repository", slot, `repository ${JSON.stringify(request.name)}`); const managed = yield* selectManagedRepository(git, host, slot, request); const identity: RepositoryIdentity = Object.freeze({ @@ -204,7 +239,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation new RepositorySelectionError(""), ); - const slot = worktreeSlot(options.root, owner.commonDirectory, request.name); + const slot = worktreeSlot(root, owner.commonDirectory, request.name); yield* leases.hold("worktree", slot, `worktree ${JSON.stringify(request.name)}`); const managed = yield* selectManagedWorktree(git, slot, { name: request.name, @@ -245,11 +280,11 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { const selected = selections.authenticate( invocationRepository, () => @@ -258,13 +293,23 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const checkout = place( + const checkout = yield* place( invocation_.repository, invocation_.workingDirectory, "", @@ -277,7 +322,11 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const checkout = place(invocation_.repository, invocation_.workingDirectory, ""); + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); // Admitted where a request enters, exactly as the retained provider // admits it: the Api is public, and a caller reaching it directly is // subject to the same boundary. @@ -288,7 +337,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const checkout = place( + const checkout = yield* place( invocation_.repository, invocation_.workingDirectory, "", @@ -297,11 +346,16 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const checkout = place(invocation_.repository, invocation_.workingDirectory, ""); + const checkout = yield* place( + invocation_.repository, + invocation_.workingDirectory, + "", + ); const published = yield* livePush(host, git, checkout); // Only after the provider has verified a performed or adopted // publication. A refused or unreadable one leaves no entry, so nothing @@ -382,7 +436,11 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { - const checkout = place(request.repository, request.workingDirectory, PULL_REQUEST_ELEMENT); + const checkout = yield* place( + request.repository, + request.workingDirectory, + PULL_REQUEST_ELEMENT, + ); if (checkout.origin === undefined) { throw new PullRequestAuthorityError( "no-repository-context", diff --git a/packages/workflow/tests/run-composition.test.ts b/packages/workflow/tests/run-composition.test.ts index cb8ae5a67..fffb34739 100644 --- a/packages/workflow/tests/run-composition.test.ts +++ b/packages/workflow/tests/run-composition.test.ts @@ -14,27 +14,66 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { scoped, type Operation } from "effection"; -import { exists, readdir, rm, writeTextFile } from "@effectionx/fs"; +import { ensureDir, exists, readTextFile, readdir, rm, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { until } from "effection"; +import { useTempDirectory } from "@executablemd/test-support/temp"; import { GitOperationAuthorityError } from "../src/composition/errors.ts"; +import { admitLivePushEvidence } from "../src/deno/run-composition/operations.ts"; import { ManagedCheckoutError, NoAmbientRepositoryError, + LivePushEvidenceError, + UnresolvedGitIdentityError, } from "../src/deno/run-composition/errors.ts"; -import { git, remoteBranch, useBareRemote } from "./support/git-remotes.ts"; +import { git, remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { spawn, withResolvers } from "effection"; +import { registerComponents } from "@executablemd/core"; +import type { ChildOutcome } from "./support/run-composition-child.ts"; +import { selectedRepository } from "../src/composition/context.ts"; +import type { RepositorySelection } from "../src/composition/selection.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import { + creations, + fakeGitHubAccess, + gitHubStore, + issueCreations, + patches, +} from "./support/github.ts"; import { causedBy, commonDirectoryOf, + countingOrdinaryHost, + fingerprintTree, + gitStateOf, + haltAtGate, + statedIdentity, + subcommands, raised, readSidecar, repositorySlotOf, runOrdinaryDocument, + gateComponent, + recordingAccess, + rewritingHost, useHostCheckout, useManagedRoot, + useNamedOriginCheckout, useOriginlessCheckout, worktreeSlotOf, type HostCheckout, } from "./support/run-composition.ts"; +/** The github.com repository the modeled store answers for. */ +const GITHUB_LOCATOR = "https://github.com/octo/project"; + +/** The second process every exclusive-ownership case runs. */ +const CHILD = fileURLToPath(new URL("./support/run-composition-child.ts", import.meta.url)); +const TOKEN = "test-token"; + const REMOTE = { commits: [ { message: "first", entries: [{ path: "which.txt", content: "main\n" }] }, @@ -88,20 +127,40 @@ describe("ORC3 — the ambient primary checkout", () => { expect(checkout.run("show", "--pretty=", "--name-only", "HEAD")).toContain("notes.md"); }); - it("refuses a root Worktree outside a repository and names how to run inside one", function* () { + it("refuses every root element that needs a repository outside a Git checkout", function* () { const root = yield* useManagedRoot(); // A directory that is not inside any Git checkout. const elsewhere = yield* useManagedRoot(); - const failure = yield* raised( - runOrdinaryDocument(``, { - root, - cwd: elsewhere, - }), - ); - const refusal = causedBy(failure, isMissingAmbient); - expect(refusal).toBeInstanceOf(NoAmbientRepositoryError); - expect(String(refusal)).toContain("Run xmd from inside one"); + const outside = [ + ``, + ``, + ``, + ``, + ``, + ``, + ]; + for (const source of outside) { + const counting = countingOrdinaryHost(); + const failure = yield* raised( + runOrdinaryDocument(source, { + root, + cwd: elsewhere, + host: counting.host, + authentication: counting.authentication, + }), + ); + const refusal = causedBy(failure, isMissingAmbient); + // The element travels in the message, so a failure says which of the six + // reported something else. + expect(`${source} ${refusal?.name}`).toBe(`${source} NoAmbientRepositoryError`); + expect(String(refusal)).toContain("Run xmd from inside one"); + // Discovery asked Git where it was and stopped. Nothing published, nothing + // authenticated, no transport. + expect(counting.counters.sessions).toEqual([]); + expect(subcommands(counting.counters)).not.toContain("push"); + expect(subcommands(counting.counters)).not.toContain("ls-remote"); + } }); }); @@ -219,6 +278,40 @@ describe("ORC6 — lexical working directories", () => { const failing = worktreeSlotOf(root, commonDirectoryOf(checkout), "failing"); expect(yield* exists(`${failing.checkout}/after.md`)).toBe(false); }); + + it("restores the enclosing directory when the body is cancelled", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "halted"); + + // The document is torn down from outside with `` still in flight, + // inside the Worktree body. The installation lives on the invocation's own + // scope, so unwinding it is what restores the enclosing directory. + yield* haltAtGate( + [ + ``, + `written before the halt`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // The Worktree's own file is where the body was standing, and the enclosing + // checkout never received it. + expect(yield* exists(`${slot.checkout}/written.md`)).toBe(true); + expect(yield* exists(`${checkout.root}/written.md`)).toBe(false); + + // And the enclosing directory is usable again: a later execution writes at + // the ambient checkout, not inside the Worktree. + yield* runOrdinaryDocument(`after`, { + root, + cwd: checkout.root, + }); + expect(yield* exists(`${checkout.root}/after.md`)).toBe(true); + expect(yield* exists(`${slot.checkout}/after.md`)).toBe(false); + }); }); describe("ORC8 — managed checkouts are persistent", () => { @@ -251,6 +344,82 @@ describe("ORC8 — managed checkouts are persistent", () => { }); }); + it("leaves a managed Repository, its metadata and its files after the run", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + + yield* runOrdinaryDocument( + [ + ``, + `unfinished`, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + expect(yield* exists(`${slot.checkout}/draft.md`)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ + kind: "repository", + version: 1, + name: "project", + locator: remote.locator, + requestedBase: null, + }); + }); + + it("issues no Git or delete command for a checkout while tearing down", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const counting = countingOrdinaryHost(); + + yield* runOrdinaryDocument( + [ + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root, host: counting.host }, + ); + + // Nothing that could undo a checkout ever ran — not while the document was + // expanding, and not on the way out. + const issued = subcommands(counting.counters); + for (const undoing of ["reset", "clean", "restore", "prune", "gc", "fetch"]) { + expect(`${undoing} ${issued.includes(undoing)}`).toBe(`${undoing} false`); + } + expect(counting.counters.commands.some((args) => args.includes("--force"))).toBe(false); + expect( + counting.counters.commands.some((args) => args[0] === "worktree" && args[1] === "remove"), + ).toBe(false); + }); + + it("keeps both kinds of checkout after a cancellation", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const repository = repositorySlotOf(root, remote.locator, "project"); + const worktree = worktreeSlotOf(root, commonDirectoryOf(checkout), "surviving"); + + yield* haltAtGate( + [ + ``, + ``, + `written before the halt`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + + for (const slot of [repository, worktree]) { + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* readSidecar(slot)).not.toBe(undefined); + } + expect(yield* exists(`${worktree.checkout}/in-flight.md`)).toBe(true); + }); + it("keeps the checkout after an authored failure inside the Worktree body", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); @@ -291,43 +460,156 @@ describe("ORC9 — compatible reuse", () => { git(["commit", "--allow-empty", "-m", "moved on"], slot.checkout, checkout.home); const moved = git(["rev-parse", "HEAD"], slot.checkout, checkout.home); + // And uncommitted work: one tracked file edited, one untracked file added. + yield* writeTextFile(`${slot.checkout}/which.txt`, "edited by hand\n"); + yield* writeTextFile(`${slot.checkout}/scratch.md`, "not committed\n"); + const dirty = git(["status", "--porcelain"], slot.checkout, checkout.home); + expect(dirty).toContain("which.txt"); + expect(dirty).toContain("scratch.md"); + const second = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); expect(second).toBe(first); + // Reuse revalidated the identity — owner, origin, object format and creation + // commit — and recorded nothing new. expect(yield* readSidecar(slot)).toEqual(created); // Neither the branch it is on nor the commit it holds was reset. expect(git(["rev-parse", "--abbrev-ref", "HEAD"], slot.checkout, checkout.home)).toBe("later"); expect(git(["rev-parse", "HEAD"], slot.checkout, checkout.home)).toBe(moved); + // And the working tree is exactly as dirty as it was left. + expect(git(["status", "--porcelain"], slot.checkout, checkout.home)).toBe(dirty); + expect(yield* readTextFile(`${slot.checkout}/which.txt`)).toBe("edited by hand\n"); + expect(yield* readTextFile(`${slot.checkout}/scratch.md`)).toBe("not committed\n"); }); -}); -describe("ORC10 — a conflict changes nothing", () => { - it("refuses a changed base and leaves the slot byte-identical", function* () { + it("revalidates the identity it reuses rather than trusting the sidecar", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); const slot = repositorySlotOf(root, remote.locator, "project"); + const document = ``; - yield* runOrdinaryDocument(``, { + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const counting = countingOrdinaryHost(); + yield* runOrdinaryDocument(document, { root, cwd: checkout.root, + host: counting.host, }); - const before = yield* readSidecar(slot); - const beforeEntries = yield* entriesOf(slot.slot); - const failure = yield* raised( - runOrdinaryDocument( - ``, - { root, cwd: checkout.root }, - ), + // The second selection asked the checkout itself who it is, rather than + // reading the sidecar and believing it. + const issued = counting.counters.commands.map((args) => args.join(" ")); + expect(issued.some((command) => command.includes("rev-parse --show-toplevel"))).toBe(true); + expect(issued.some((command) => command.includes("rev-parse --git-common-dir"))).toBe(true); + expect(issued.some((command) => command.includes("rev-parse --show-object-format"))).toBe(true); + expect(issued.some((command) => command.includes("config --get remote.origin.url"))).toBe(true); + // And it cloned nothing. + expect(subcommands(counting.counters)).not.toContain("clone"); + }); +}); + +describe("ORC10 — a conflict changes nothing", () => { + /** + * One refusal, fingerprinted on both sides. + * + * The claim is not "it failed" but "it failed and changed nothing", so the + * slot's complete byte fingerprint and the checkout's own Git state are taken + * before the refusal and compared after it. A reset, a fetch, a switch or a + * rewritten sidecar would all show up here. + */ + function* refusesWithoutMutating( + slot: ReturnType, + checkout: HostCheckout, + run: () => Operation, + reason: string, + ): Operation { + const bytes = yield* fingerprintTree(slot.slot); + const state = gitStateOf(checkout, slot.checkout); + + const failure = yield* raised(run()); + expect(`${reason}: ${causedBy(failure, isManagedRefusal)?.reason}`).toBe( + `${reason}: incompatible-reuse`, + ); + + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(gitStateOf(checkout, slot.checkout)).toEqual(state); + } + + it("refuses every changed Repository fact and leaves the slot byte-identical", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "project"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const created = yield* readSidecar(slot); + + // A changed base. Same name, same url, different creation identity. + yield* refusesWithoutMutating( + slot, + checkout, + () => + runOrdinaryDocument( + ``, + { root, cwd: checkout.root }, + ), + "changed base", + ); + + // A sidecar somebody edited. The object format is the member the checkout + // itself can contradict, so this is the object-format comparison too. + yield* writeTextFile( + slot.metadata, + `${JSON.stringify({ ...(created as object), objectFormat: "sha256" }, null, 2)}\n`, + ); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "object format", + ); + + // A sidecar naming another repository's creation commit. + yield* writeTextFile( + slot.metadata, + `${JSON.stringify({ ...(created as object), creationCommit: "0".repeat(40) }, null, 2)}\n`, + ); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "metadata", + ); + + // An origin that no longer names what the checkout was cloned from. + yield* writeTextFile(slot.metadata, `${JSON.stringify(created, null, 2)}\n`); + git(["remote", "set-url", "origin", other.locator], slot.checkout, checkout.home); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "origin", + ); + git(["remote", "set-url", "origin", remote.locator], slot.checkout, checkout.home); + + // A common directory belonging to a different repository: the slot now + // holds an unrelated clone at exactly the recorded path. + const shadow = `${slot.slot}/shadow`; + git(["clone", "--", other.locator, shadow], slot.slot, checkout.home); + yield* rm(slot.checkout, { recursive: true }); + git(["clone", "--", other.locator, slot.checkout], slot.slot, checkout.home); + yield* refusesWithoutMutating( + slot, + checkout, + () => runOrdinaryDocument(document, { root, cwd: checkout.root }), + "common directory", ); - const refusal = causedBy(failure, isManagedRefusal); - expect(refusal?.reason).toBe("incompatible-reuse"); - expect(yield* readSidecar(slot)).toEqual(before); - expect(yield* entriesOf(slot.slot)).toEqual(beforeEntries); }); - it("refuses a Worktree asked for on a different branch", function* () { + it("refuses a Worktree asked for on a different branch or base, and changes nothing", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); @@ -337,16 +619,52 @@ describe("ORC10 — a conflict changes nothing", () => { root, cwd: checkout.root, }); - const before = yield* readSidecar(slot); + const created = yield* readSidecar(slot); + + for (const [reason, source] of [ + ["branch", ``], + ["base", ``], + ] as const) { + const bytes = yield* fingerprintTree(slot.slot); + const state = gitStateOf(checkout, slot.checkout); + const failure = yield* raised(runOrdinaryDocument(source, { root, cwd: checkout.root })); + expect(`${reason}: ${causedBy(failure, isManagedRefusal)?.reason}`).toBe( + `${reason}: incompatible-reuse`, + ); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(gitStateOf(checkout, slot.checkout)).toEqual(state); + expect(yield* readSidecar(slot)).toEqual(created); + } + }); + + it("refuses a Worktree whose checkout stopped belonging to its owner", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const common = commonDirectoryOf(checkout); + const slot = worktreeSlotOf(root, common, "owned"); + + yield* runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }); + // An unrelated clone at exactly the recorded path. It is a perfectly good + // Git checkout; what it is not is a linked worktree of the owner. + yield* rm(slot.checkout, { recursive: true }); + git(["clone", "--", other.locator, slot.checkout], slot.slot, checkout.home); + + const bytes = yield* fingerprintTree(slot.slot); const failure = yield* raised( - runOrdinaryDocument(``, { + runOrdinaryDocument(``, { root, cwd: checkout.root, }), ); expect(causedBy(failure, isManagedRefusal)?.reason).toBe("incompatible-reuse"); - expect(yield* readSidecar(slot)).toEqual(before); + expect(String(failure)).toContain("linked checkout"); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); }); }); @@ -387,6 +705,41 @@ describe("ORC11 — an interrupted creation", () => { expect(yield* entriesOf(slot.slot)).toEqual(beforeEntries); expect(yield* readSidecar(slot)).toBe(undefined); }); + + it("adopts a metadata-free Worktree that is exactly what creation would have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "resumed"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + const written = yield* readSidecar(slot); + yield* rm(slot.metadata); + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(yield* readSidecar(slot)).toEqual(written); + }); + + it("refuses a metadata-free Worktree that creation would never have left", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "moved"); + const document = ``; + + yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + yield* rm(slot.metadata); + // The branch it is on is no longer the branch this request names, so this + // is not the state creation would have left behind. + git(["switch", "-c", "somewhere-else"], slot.checkout, checkout.home); + + const bytes = yield* fingerprintTree(slot.slot); + const failure = yield* raised(runOrdinaryDocument(document, { root, cwd: checkout.root })); + expect(causedBy(failure, isManagedRefusal)?.reason).toBe("partial-creation"); + expect(yield* fingerprintTree(slot.slot)).toEqual(bytes); + expect(yield* readSidecar(slot)).toBe(undefined); + }); }); describe("ORC13 — live local Git", () => { @@ -413,6 +766,179 @@ describe("ORC13 — live local Git", () => { expect(checkout.run("diff", "--cached", "--name-only")).toContain("staged.md"); }); + it("keeps what a cancelled document had already done, and claims no rollback", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* haltAtGate( + [ + ``, + `staged before the halt`, + ``, + "", + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + + // Both transitions really happened, and nothing took them back. + expect(checkout.run("rev-parse", "--abbrev-ref", "HEAD")).toBe("interrupted"); + expect(checkout.run("diff", "--cached", "--name-only")).toContain("staged.md"); + // And the commit the document never reached was never made. + expect(checkout.run("log", "-1", "--pretty=%s")).not.toBe("never reached"); + }); + + it("commits as the invoking user, not as the workflow identity", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + `mine`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity("Ada Lovelace 1 +0000"), + }, + ); + + expect(checkout.run("log", "-1", "--pretty=%an|%ae|%cn|%ce")).toBe( + "Ada Lovelace|ada@example.test|Ada Lovelace|ada@example.test", + ); + expect(checkout.run("log", "-1", "--pretty=%an")).not.toBe("Executable.md workflow"); + }); + + it("takes author and committer separately when the host resolves them apart", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + yield* runOrdinaryDocument( + [ + `pair`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity( + "Ada Lovelace 1 +0000", + "Grace Hopper 1 +0000", + ), + }, + ); + + expect(checkout.run("log", "-1", "--pretty=%an|%ae|%cn|%ce")).toBe( + "Ada Lovelace|ada@example.test|Grace Hopper|grace@example.test", + ); + }); + + it("refuses to commit when the host cannot say who the commit is by", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const before = checkout.run("rev-parse", "HEAD"); + + const failure = yield* raised( + runOrdinaryDocument( + [ + `orphan`, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root, identity: statedIdentity(undefined) }, + ), + ); + + expect(failure).toBeInstanceOf(UnresolvedGitIdentityError); + expect(String(failure)).toContain("git config --global user.name"); + // Nothing was committed, and no identity was substituted. + expect(checkout.run("rev-parse", "HEAD")).toBe(before); + // The staging that came before it still happened: this refuses the commit, + // not the document that led to it. + expect(checkout.run("diff", "--cached", "--name-only")).toContain("orphan.md"); + }); + + it("leaves every other component usable when no identity resolves", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // Repository, Worktree, Dir, Switch and Add all work: none of them writes a + // commit object, so none of them needs to know who anybody is. + const rendered = yield* runOrdinaryDocument( + [ + ``, + ``, + "", + ``, + `fine`, + ``, + "", + "", + "ran", + ].join("\n"), + { root, cwd: checkout.root, identity: statedIdentity(undefined) }, + ); + expect(String(rendered)).toContain("ran"); + }); + + it("keeps hooks, monitors, signing and repository helpers disabled", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const outside = yield* useTempDirectory("xmd-ordinary-hooks-"); + const marks = { pre: `${outside}/pre-commit`, post: `${outside}/post-commit` }; + + // A repository that does everything it can to run a program of its own: two + // hooks, a signing program, a file-system monitor and a credential helper. + for (const [hook, mark] of [ + ["pre-commit", marks.pre], + ["post-commit", marks.post], + ] as const) { + yield* ensureDir(`${checkout.root}/.githooks`); + yield* writeTextFile( + `${checkout.root}/.githooks/${hook}`, + `#!/bin/sh\nprintf ran > ${mark}\n`, + ); + yield* until(chmod(`${checkout.root}/.githooks/${hook}`, 0o755)); + } + checkout.run("config", "core.hooksPath", ".githooks"); + checkout.run("config", "commit.gpgSign", "true"); + checkout.run("config", "gpg.program", `${outside}/absent-signer`); + checkout.run("config", "core.fsmonitor", `${outside}/absent-monitor`); + checkout.run("config", "credential.helper", `!${outside}/absent-helper`); + + yield* runOrdinaryDocument( + [ + `safe`, + ``, + ``, + ].join("\n"), + { + root, + cwd: checkout.root, + identity: statedIdentity("Ada Lovelace 1 +0000"), + }, + ); + + // The identity is the only thing borrowed. Neither hook ran, the commit is + // unsigned, and the monitor and helper programs — which do not exist — + // never had to. + expect({ + pre: yield* exists(marks.pre), + post: yield* exists(marks.post), + }).toEqual({ pre: false, post: false }); + expect(checkout.run("log", "-1", "--pretty=%G?")).toBe("N"); + expect(checkout.run("log", "-1", "--pretty=%an")).toBe("Ada Lovelace"); + }); + it("refuses a branch another checkout of the same repository holds", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); @@ -431,7 +957,7 @@ describe("ORC13 — live local Git", () => { }); describe("ORC14 — live Push evidence", () => { - it("publishes the branch and lets exactly that head reach the Git host adapter", function* () { + it("records a performed publication and lets exactly that head be authorized", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); @@ -459,12 +985,49 @@ describe("ORC14 — live Push evidence", () => { expect(String(failure)).not.toContain("holds no successful result"); }); - it("does not let a Push of another branch authorize this one", function* () { + it("records an already-equal publication the same way it records a performed one", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); + // The first execution performs the publication. + yield* runOrdinaryDocument( + [ + ``, + `equal`, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ); + const published = checkout.run("rev-parse", "HEAD"); + expect(remoteBranch(remote, "equal")).toBe(published); + + // The second finds the destination already naming this exact commit and + // adopts it — pushing nothing — and the adopted publication is evidence. + const counting = countingOrdinaryHost(); const failure = yield* raised( + runOrdinaryDocument( + [``, ``].join("\n"), + { root, cwd: checkout.root, host: counting.host, authentication: counting.authentication }, + ), + ); + expect(subcommands(counting.counters)).toContain("ls-remote"); + expect(subcommands(counting.counters)).not.toContain("push"); + expect(String(failure)).toContain("only for repositories on github.com"); + expect(String(failure)).not.toContain("holds no successful result"); + }); + + it("refuses when the Push named another branch, checkout, origin or destination", function* () { + const remote = yield* useBareRemote(REMOTE); + const other = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // Another branch and therefore another destination ref: the Push is real + // and irrelevant. + const branch = yield* raised( runOrdinaryDocument( [ ``, @@ -475,8 +1038,91 @@ describe("ORC14 — live Push evidence", () => { { root, cwd: checkout.root }, ), ); - expect(String(failure)).toContain("holds no successful result"); + expect(String(branch)).toContain("holds no successful result"); expect(remoteBranch(remote, "unpublished")).toBe(undefined); + + // Another repository entirely: a managed Repository publishes, and the + // ambient one asks. + const repository = yield* raised( + runOrdinaryDocument( + [ + ``, + ``, + ``, + "", + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + expect(String(repository)).toContain("holds no successful result"); + }); + + it("admits one head only when every dimension of the publication matches", function* () { + const identity = { + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }; + const held = { + identity, + checkoutRoot: "/checkouts/project", + origin: "https://github.com/octo/project", + branch: "feature", + destinationRef: "refs/heads/feature", + commit: "c".repeat(40), + }; + + // The exact publication authorizes. + admitLivePushEvidence([held], held); + + // Every single dimension, changed on its own, does not. Git forbids two + // checkouts of one repository on one branch, so the checkout dimension is + // unreachable through a document — and it is exactly as load-bearing as the + // others, which is why it is asked here rather than left unasked. + const wrong: readonly [string, typeof held][] = [ + ["repository", { ...held, identity: { ...identity, locatorFingerprint: "d".repeat(64) } }], + ["checkout", { ...held, checkoutRoot: "/checkouts/elsewhere" }], + ["origin", { ...held, origin: "https://github.com/octo/other" }], + ["branch", { ...held, branch: "other" }], + ["destination", { ...held, destinationRef: "refs/heads/other" }], + ["commit", { ...held, commit: "e".repeat(40) }], + ]; + for (const [dimension, expected] of wrong) { + let refused: unknown; + try { + admitLivePushEvidence([held], expected); + } catch (error) { + refused = error; + } + expect(`${dimension}: ${refused instanceof LivePushEvidenceError}`).toBe( + `${dimension}: true`, + ); + } + + // A changed commit on the same destination is disagreement, not absence. + let conflicting: unknown; + try { + admitLivePushEvidence([held], { ...held, commit: "e".repeat(40) }); + } catch (error) { + conflicting = error; + } + expect((conflicting as LivePushEvidenceError).reason).toBe("conflicting-push-evidence"); + + // And the last publication of a destination is the one that decides. + const superseded = { ...held, commit: "f".repeat(40) }; + admitLivePushEvidence([held, superseded], superseded); + let stale: unknown; + try { + admitLivePushEvidence([held, superseded], held); + } catch (error) { + stale = error; + } + expect((stale as LivePushEvidenceError).reason).toBe("conflicting-push-evidence"); }); it("lets the latest publication of a destination decide", function* () { @@ -484,6 +1130,8 @@ describe("ORC14 — live Push evidence", () => { const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); + // Two publications of one destination, at successive commits, and then a + // third commit nothing published. const failure = yield* raised( runOrdinaryDocument( [ @@ -495,14 +1143,47 @@ describe("ORC14 — live Push evidence", () => { `two`, ``, ``, - // The head has moved past what was published, and no second Push - // followed it. + ``, + `three`, + ``, + ``, ``, ].join("\n"), { root, cwd: checkout.root }, ), ); + // The second publication superseded the first, and the head has moved past + // both — so this is a conflict rather than an absence. expect(String(failure)).toContain("published that branch at a different commit"); + expect(remoteBranch(remote, "moving")).toBe(checkout.run("rev-parse", "HEAD~1")); + }); + + it("authorizes at the second publication's commit, not the first's", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument( + [ + ``, + `one`, + ``, + ``, + ``, + `two`, + ``, + ``, + ``, + ``, + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + // Past the gate: the latest publication names the head the pull request + // would open from. + expect(String(failure)).toContain("only for repositories on github.com"); + expect(remoteBranch(remote, "latest")).toBe(checkout.run("rev-parse", "HEAD")); }); }); @@ -520,18 +1201,444 @@ describe("ORC15 — evidence cannot cross runs", () => { ); expect(String(failure)).toContain("holds no successful result"); }); + + it("does not let one execution's real publication authorize the next", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + store.resolveHead = (branch) => remoteRefs(remote).get(`refs/heads/${branch}`); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + const options = { + root, + cwd: checkout.root, + host: rewritingHost(GITHUB_LOCATOR, remote.locator), + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }; + + // One execution publishes and opens a pull request. This is the real thing: + // a branch at the remote and a pull request at the modeled GitHub. + yield* runOrdinaryDocument( + [ + ``, + `crossing`, + ``, + ``, + ``, + ``, + ].join("\n"), + options, + ); + expect(creations(store)).toBe(1); + const published = checkout.run("rev-parse", "HEAD"); + expect(remoteBranch(remote, "crossing")).toBe(published); + + // A second, ordinary execution. The branch is still at the remote, the + // checkout is still on it, and the pull request still exists — and none of + // that is this execution's evidence. + const second = yield* raised( + runOrdinaryDocument(``, options), + ); + expect(String(second)).toContain("holds no successful result"); + + // The refusal never reached GitHub at all. + expect(creations(store)).toBe(1); + expect(patches(store)).toBe(0); + }); + + it("grants nothing to a copied selection, a copied result or a previous trace", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // One execution publishes, and hands its own Repository selection and the + // rendered result of the Push out to the suite. + let carried: RepositorySelection | undefined; + yield* runOrdinaryDocument( + [ + ``, + `carried`, + ``, + ``, + ``, + "", + ].join("\n"), + { + root, + cwd: checkout.root, + components: [ + { + name: "Capture", + origin: "test", + props: { type: "object", additionalProperties: false }, + *fn(): Operation { + carried = yield* selectedRepository(); + return ""; + }, + }, + ], + }, + ); + expect(carried).toBeDefined(); + + // A new execution, handed the exact selection the first one minted and the + // path it bound, installed as its contextual Repository. + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + contextualRepository: carried, + }), + ); + // The selection is not one this provider minted, so it names no checkout — + // and the evidence it would have needed does not exist here either. + expect(String(failure)).toContain("not one this execution selected"); + }); +}); + +describe("ORC16 — live Issues", () => { + it("reads and files through the configured transport, keyed to this execution", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ + token: TOKEN, + issues: [ + { + number: 7, + nodeId: "I_7", + state: "open", + title: "an existing issue", + body: "described", + labels: [], + assignee: null, + }, + ], + }); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const options = { + root, + cwd: checkout.root, + gitHubIssues: { + ceiling: [GITHUB_LOCATOR], + access: gitHubSource(fakeGitHubAccess(store)), + }, + }; + + const rendered = yield* runOrdinaryDocument( + [ + ``, + "", + "read {found.title}", + "", + ``, + ``, + "the description", + "", + "", + ].join("\n"), + options, + ); + expect(String(rendered)).toContain("read an existing issue"); + expect(issueCreations(store)).toBe(1); + + // A second execution is a new question, not a resumption: the identity it + // presents is its own, so the provider is asked again. + yield* runOrdinaryDocument( + [ + ``, + ``, + "the description", + "", + "", + ].join("\n"), + options, + ); + expect(issueCreations(store)).toBe(2); + }); + + it("sends no credential and no request for a target outside the ceiling", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + gitHubIssues: { + ceiling: [GITHUB_LOCATOR], + access: gitHubSource(fakeGitHubAccess(store)), + }, + }), + ); + expect(failure).toBeInstanceOf(Error); + expect(store.requests).toHaveLength(0); + }); + + it("installs no matching provider when nothing is configured", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + }), + ); + expect(String(failure)).toContain("no issue provider handles"); + }); +}); + +describe("ORC17 — live PullRequests", () => { + it("opens a pull request the run published, through the configured transport", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + store.resolveHead = (branch) => remoteRefs(remote).get(`refs/heads/${branch}`); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + + const rendered = yield* runOrdinaryDocument( + [ + ``, + `opened`, + ``, + ``, + ``, + ``, + "the body", + "", + "", + "number {pullRequest.number} state {pullRequest.state}", + ].join("\n"), + { + root, + cwd: checkout.root, + host: rewritingHost(GITHUB_LOCATOR, remote.locator), + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }, + ); + expect(creations(store)).toBe(1); + expect(String(rendered)).toContain("state open"); + // The evidence it bound names the repository this run acted on. + expect(String(rendered)).toContain("number 1"); + }); + + it("reads evidence collections and holds a URL to the configured ceiling", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const endpoint = "https://api.github.test"; + const recording = recordingAccess( + { + // The pull request itself, which is what an answer is authenticated + // against, and the collection this read asks for. + "/repos/octo/project/pulls/4": JSON.stringify({ + number: 4, + head: { sha: "a".repeat(40) }, + base: { repo: { full_name: "octo/project" } }, + }), + "/repos/octo/project/pulls/4/reviews": JSON.stringify([ + { + id: 10, + user: { login: "reviewer" }, + state: "APPROVED", + body: "looks right", + submitted_at: "2026-01-01T00:00:00Z", + commit_id: "a".repeat(40), + html_url: "https://github.test/pr/4#r10", + pull_request_url: `${endpoint}/repos/octo/project/pulls/4`, + }, + ]), + }, + endpoint, + ); + const access = gitHubSource(recording.access); + + // Allowed: the read reaches the transport under an authorization header and + // binds what came back. + const rendered = yield* runOrdinaryDocument( + [ + ``, + "", + "reviews {reviews.length}", + "", + "", + ].join("\n"), + { + root, + cwd: checkout.root, + gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access }, + }, + ); + expect(String(rendered)).toContain("reviews 1"); + // The collection itself, normalized by the shared adapter and bound here. + expect(String(rendered)).toContain('"state": "approved"'); + expect(String(rendered)).toContain('"author": "reviewer"'); + const asked = recording.requests.length; + expect(asked).toBeGreaterThan(0); + expect(recording.requests.every((request) => request.authorized)).toBe(true); + + // Outside the ceiling: refused before anything is sent. + const failure = yield* raised( + runOrdinaryDocument( + ``, + { root, cwd: checkout.root, gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access } }, + ), + ); + expect(String(failure)).toContain("has not authorized"); + expect(recording.requests).toHaveLength(asked); + + // And with nothing allowed, no read this host performs exists at all. + const unconfigured = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + gitHubPullRequests: { access }, + }), + ); + expect(String(unconfigured)).toContain("no pull-request provider handles"); + expect(recording.requests).toHaveLength(asked); + }); + + it("refuses an unpublished head before a credential or a request", function* () { + const remote = yield* useBareRemote(REMOTE); + const store = gitHubStore({ token: TOKEN }); + const root = yield* useManagedRoot(); + const checkout = yield* useNamedOriginCheckout(remote, GITHUB_LOCATOR); + const counting = countingOrdinaryHost(rewritingHost(GITHUB_LOCATOR, remote.locator)); + + const failure = yield* raised( + runOrdinaryDocument(``, { + root, + cwd: checkout.root, + host: counting.host, + authentication: counting.authentication, + gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, + }), + ); + expect(String(failure)).toContain("holds no successful result"); + expect(store.requests).toHaveLength(0); + expect(counting.counters.sessions).toEqual([]); + }); }); -/** Two executions in one process must not share a checkout registry. */ -describe("ORC12 — exclusive ownership within one host", () => { - it("releases a slot's lease when the execution ends, so a later one takes it", function* () { +describe("ORC12 — exclusive ownership across processes", () => { + /** One second process, run to completion, and what it reported. */ + function* elsewhere(root: string, cwd: string, source: string): Operation { + const outcome = spawnSync( + process.execPath, + ["run", "--allow-all", "--frozen", CHILD, root, cwd, source], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + const printed = outcome.stdout.trim().split("\n").at(-1) ?? ""; + if (printed === "") { + throw new Error(`the child printed nothing: ${outcome.stderr}`); + } + return JSON.parse(printed) as ChildOutcome; + } + + it("refuses a second process the slot a first is holding, and changes nothing", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const held = worktreeSlotOf(root, commonDirectoryOf(checkout), "contended"); + const free = worktreeSlotOf(root, commonDirectoryOf(checkout), "uncontended"); + // The child renders what it bound, so the parent can read the path back. + const document = `\n\n{w}`; + + const opened = withResolvers(); + let reached = false; + const holder = yield* spawn(() => + scoped(function* () { + yield* registerComponents([ + gateComponent(() => { + if (!reached) { + reached = true; + opened.resolve(); + } + }), + ]); + yield* runOrdinaryDocument( + [ + ``, + `held by the first process`, + "", + "", + ].join("\n"), + { root, cwd: checkout.root }, + ); + }), + ); + yield* opened.operation; + + // While the first process holds it, a real second process is refused — + // without waiting, and with a word the person running it can act on. + const bytes = yield* fingerprintTree(held.slot); + const refused = yield* elsewhere(root, checkout.root, document); + expect(refused.kind).toBe("refused"); + expect(refused.reason).toBe("in-use"); + expect(refused.message).toContain("another process is working in"); + // And nothing under the slot moved. + expect(yield* fingerprintTree(held.slot)).toEqual(bytes); + + // A different slot is not contended, and succeeds while the first is still + // held: the lock is per-slot, not per-root. + const other = yield* elsewhere( + root, + checkout.root, + `\n\n{w}`, + ); + expect(other.kind).toBe("selected"); + expect(other.bound).toBe(free.checkout); + + // The first process is cancelled. The kernel releases what it held, and the + // checkout it made is still there. + yield* holder.halt(); + expect(yield* exists(`${held.checkout}/held.md`)).toBe(true); + + const afterCancellation = yield* elsewhere(root, checkout.root, document); + expect(afterCancellation.kind).toBe("selected"); + expect(afterCancellation.bound).toBe(held.checkout); + // It reused the very checkout the cancelled process left, contents and all. + expect(yield* exists(`${held.checkout}/held.md`)).toBe(true); + }); + + it("hands a slot on after a normal release, with the checkout intact", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = worktreeSlotOf(root, commonDirectoryOf(checkout), "serial"); + const document = `\n\n{w}`; + + // A first execution completes normally and releases. + const bound = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); + expect(String(bound).trim()).toBe(slot.checkout); + + // A real second process then takes it, and finds the same checkout. + const later = yield* elsewhere(root, checkout.root, document); + expect(later.kind).toBe("selected"); + expect(later.bound).toBe(slot.checkout); + expect(yield* readSidecar(slot)).toMatchObject({ kind: "worktree", name: "serial" }); + }); +}); + +/** Two executions in one process must not share a checkout registry. */ /** Two executions in one process must not share a checkout registry. */ +describe("ORC12 — one process reuses the lease it already holds", () => { + it("selects the same slot twice in one execution without asking twice", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout: HostCheckout = yield* useHostCheckout(remote.locator); - const document = ``; - const first = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); - const second = yield* runOrdinaryDocument(document, { root, cwd: checkout.root }); - expect(second).toBe(first); + const rendered = yield* runOrdinaryDocument( + [ + ``, + ``, + "", + "{first === second ? 'same' : 'different'}", + ].join("\n"), + { root, cwd: checkout.root }, + ); + expect(String(rendered)).toContain("same"); }); }); diff --git a/packages/workflow/tests/support/run-composition-child.ts b/packages/workflow/tests/support/run-composition-child.ts new file mode 100644 index 000000000..fade190d2 --- /dev/null +++ b/packages/workflow/tests/support/run-composition-child.ts @@ -0,0 +1,82 @@ +/** + * A second process asking for the same managed checkout. + * + * Exclusive ownership is a claim about two operating-system processes, so it is + * proved by two operating-system processes. This one constructs its own + * ordinary repository provider — its own leases, its own invocation identity — + * against a managed root and a starting directory the parent names, selects + * what it was told to select, and writes one JSON line saying what happened. + * + * It prints and exits. Everything it holds is released by the kernel when it + * does, which is the other half of what the parent asserts. + */ + +import { main } from "effection"; +import process from "node:process"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { useCompositionComponents } from "../../src/composition/installation.ts"; +import { useRunComposition } from "../../src/deno/run-composition/provider.ts"; +import { ManagedCheckoutError } from "../../src/deno/run-composition/errors.ts"; + +/** What the parent reads back off this process's stdout. */ +export interface ChildOutcome { + readonly kind: "selected" | "refused" | "failed"; + /** The path a selection bound, when it made one. */ + readonly bound?: string; + /** The fixed word a managed-checkout refusal is reported under. */ + readonly reason?: string; + readonly message?: string; +} + +await main(function* () { + const [root, cwd, source] = process.argv.slice(2); + if (root === undefined || cwd === undefined || source === undefined) { + throw new Error("the child needs a managed root, a starting directory and a document"); + } + + let outcome: ChildOutcome; + try { + const rendered = yield* (function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return cwd; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useCompositionComponents(); + yield* useRunComposition({ root, cwd }); + return yield* collect( + yield* execute({ ...inlineSource(source), stream: new InMemoryStream() }), + ); + })(); + outcome = { kind: "selected", bound: String(rendered).trim() }; + } catch (error) { + const refusal = managedRefusal(error); + outcome = + refusal === undefined + ? { kind: "failed", message: String(error) } + : { kind: "refused", reason: refusal.reason, message: refusal.message }; + } + + process.stdout.write(`${JSON.stringify(outcome)}\n`); +}); + +/** The managed-checkout refusal in this error's chain, if there is one. */ +function managedRefusal(error: unknown): ManagedCheckoutError | undefined { + let current: unknown = error; + const seen = new Set(); + while (current !== undefined && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof ManagedCheckoutError) { + return current; + } + current = current instanceof Error ? current.cause : undefined; + } + return undefined; +} diff --git a/packages/workflow/tests/support/run-composition.ts b/packages/workflow/tests/support/run-composition.ts index 4929b7cf5..9e1d61b1f 100644 --- a/packages/workflow/tests/support/run-composition.ts +++ b/packages/workflow/tests/support/run-composition.ts @@ -12,17 +12,31 @@ * That is the point of the profile: an ordinary run has none of them. */ -import { scoped, until, type Operation } from "effection"; -import { ensureDir, exists, readTextFile } from "@effectionx/fs"; +import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection"; +import { ensureDir, exists, lstat, readdir, readTextFile } from "@effectionx/fs"; +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { join } from "node:path"; -import { collect, execute, inlineSource } from "@executablemd/core"; +import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; import { API, useHostFiles } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/durable-streams"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { useCompositionComponents } from "../../src/composition/installation.ts"; +import { RepositoryContext } from "../../src/composition/context.ts"; +import type { RepositorySelection } from "../../src/composition/selection.ts"; +import { denoRepositoryHost } from "../../src/deno/composition/host.ts"; +import type { GitInvocation, GitOutcome, RepositoryHost } from "../../src/deno/composition/host.ts"; +import { UNAUTHENTICATED } from "../../src/deno/composition/authentication.ts"; +import type { + GitAuthentication, + GitAuthenticationSession, +} from "../../src/deno/composition/authentication.ts"; +import type { IdentityReader } from "../../src/deno/run-composition/identity.ts"; +import type { GitHubAccess, GitHubHttpResponse } from "../../src/deno/composition/github.ts"; import { useRunComposition } from "../../src/deno/run-composition/provider.ts"; import type { RunCompositionOptions } from "../../src/deno/run-composition/provider.ts"; import { @@ -32,6 +46,7 @@ import { worktreeSlot, } from "../../src/deno/run-composition/placement.ts"; import { git } from "./git-remotes.ts"; +import type { BareRemote } from "./git-remotes.ts"; /** A working checkout on this host, as if somebody had cloned it by hand. */ export interface HostCheckout { @@ -88,11 +103,78 @@ export function* useOriginlessCheckout(): Operation { /** A managed root of this suite's own, removed when the scope ends. */ export function* useManagedRoot(): Operation { const created = yield* useTempDirectory("xmd-run-composition-"); - const root = join(created, "repositories"); + const root = join(yield* until(realpath(created)), "repositories"); yield* ensureDir(root); return root; } +/** What one ordinary execution reached, at the boundaries a claim is made at. */ +export interface OrdinaryCounters { + /** Every Git command, in order, as its argument list. */ + readonly commands: string[][]; + /** Every authentication session opened, by locator. */ + readonly sessions: string[]; +} + +export interface CountingOrdinaryHost { + readonly host: RepositoryHost; + readonly authentication: GitAuthentication; + readonly counters: OrdinaryCounters; +} + +/** + * The production host, counted. + * + * Both leaves are wrapped rather than replaced: what a suite needs to know is + * *whether* a credential was opened and *whether* a transport ran, and the only + * honest way to answer is to let the real one happen and watch. + */ +export function countingOrdinaryHost( + inner: RepositoryHost = denoRepositoryHost(), +): CountingOrdinaryHost { + const counters: OrdinaryCounters = { commands: [], sessions: [] }; + return { + counters, + authentication: { + *open(locator: string): Operation { + counters.sessions.push(locator); + return UNAUTHENTICATED; + }, + }, + host: { + *git(invocation: GitInvocation): Operation { + counters.commands.push([...invocation.args]); + return yield* inner.git(invocation); + }, + useDirectory: inner.useDirectory, + ...(inner.useAuthentication === undefined + ? {} + : { useAuthentication: inner.useAuthentication }), + }, + }; +} + +/** The Git subcommands one execution issued, in order. */ +export function subcommands(counters: OrdinaryCounters): string[] { + return counters.commands.map((args) => args.find((arg) => !arg.startsWith("-")) ?? ""); +} + +/** + * An identity reader that answers whatever a suite says this host knows. + * + * `undefined` for either variable is a host that cannot say who a commit would + * be by, which is the one condition `` refuses on. + */ +export function statedIdentity( + author: string | undefined, + committer: string | undefined = author, +): IdentityReader { + // deno-lint-ignore require-yield + return function* (variable: string): Operation { + return variable === "GIT_AUTHOR_IDENT" ? author : committer; + }; +} + export interface RunOptions extends Omit { /** The managed root this execution uses. */ readonly root: string; @@ -100,6 +182,16 @@ export interface RunOptions extends Omit readonly cwd: string; /** Props the document is executed with. */ readonly props?: Record; + /** Extra components this execution registers, for a suite's own probes. */ + readonly components?: readonly ComponentRegistration[]; + /** + * A Repository selection installed as the contextual one, ahead of the + * document. + * + * The only thing a document could replace, and therefore what a suite hands + * over to prove that replacing it buys nothing. + */ + readonly contextualRepository?: RepositorySelection; } /** @@ -125,8 +217,14 @@ export function runOrdinaryDocument(source: string, options: RunOptions): Operat // caller's own filesystem exactly as `xmd run` leaves it. yield* useHostFiles(); yield* useCompositionComponents(); - const { root, cwd, props: _props, ...rest } = options; + const { root, cwd, props: _props, components, contextualRepository, ...rest } = options; yield* useRunComposition({ root, cwd, ...rest }); + if (components !== undefined) { + yield* registerComponents([...components]); + } + if (contextualRepository !== undefined) { + yield* RepositoryContext.around({ current: () => contextualRepository }, { at: "min" }); + } return yield* collect( yield* execute({ ...inlineSource(source), @@ -196,3 +294,179 @@ export function causedBy(error: unknown, is: (value: unknown) => value is T): } return undefined; } + +/** + * Everything a directory holds, as one comparable value. + * + * Paths, kinds and content digests, sorted. A refusal that claims to change + * nothing has to survive this: a comparison of "the checkout is still there" + * would pass while a file inside it had been rewritten. + */ +export function* fingerprintTree(root: string): Operation { + if (!(yield* exists(root))) { + return []; + } + const entries: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop() as string; + for (const name of yield* readdir(directory)) { + const path = `${directory}/${name}`; + const info = yield* lstat(path); + const relative = path.slice(root.length + 1); + if (info.isDirectory()) { + entries.push(`d ${relative}`); + pending.push(path); + continue; + } + if (info.isSymbolicLink()) { + entries.push(`l ${relative}`); + continue; + } + const bytes = yield* until(readFile(path)); + entries.push(`f ${relative} ${createHash("sha256").update(bytes).digest("hex")}`); + } + } + return entries.sort(); +} + +/** + * What Git says this checkout holds right now. + * + * Deliberately the mutable half — HEAD, the branch, every ref, and the working + * tree's own dirtiness — because that is what a refusal must not touch and what + * a compatible reuse must preserve. + */ +export function gitStateOf(checkout: HostCheckout, directory: string = checkout.root): string[] { + return [ + `head ${git(["rev-parse", "HEAD"], directory, checkout.home)}`, + `branch ${git(["rev-parse", "--abbrev-ref", "HEAD"], directory, checkout.home)}`, + `status ${git(["status", "--porcelain"], directory, checkout.home)}`, + `refs ${git(["for-each-ref", "--format=%(refname) %(objectname)"], directory, checkout.home)}`, + ]; +} + +/** A component that suspends forever, so a suite can halt an execution inside it. */ +export function gateComponent(reached: () => void): ComponentRegistration { + return { + name: "Gate", + origin: "test", + props: { type: "object", additionalProperties: false }, + *fn(): Operation { + reached(); + yield* suspend(); + return ""; + }, + }; +} + +/** + * Run a document that reaches ``, then halt it there. + * + * The halt is the cancellation every persistence claim is made against: the + * execution is torn down from outside, mid-document, with a component still in + * flight. + */ +export function* haltAtGate(source: string, options: RunOptions): Operation { + const opened = withResolvers(); + let reached = false; + const task = yield* spawn(() => + scoped(function* () { + yield* registerComponents([ + gateComponent(() => { + if (!reached) { + reached = true; + opened.resolve(); + } + }), + ]); + yield* runOrdinaryDocument(source, options); + }), + ); + yield* opened.operation; + yield* task.halt(); +} + +/** + * A host that answers for one locator while Git works against another. + * + * The ambient checkout records a `github.com` origin, because that is what the + * pull-request adapter parses a repository out of; native Git is handed the + * local bare repository instead, and what it prints is translated back. Exactly + * one string moves in each direction — the same substitution the workflow + * pull-request suites already run on. + */ +export function rewritingHost( + named: string, + actual: string, + inner: RepositoryHost = denoRepositoryHost(), +): RepositoryHost { + return { + *git(invocation: GitInvocation): Operation { + const outcome = yield* inner.git({ + ...invocation, + args: invocation.args.map((argument) => (argument === named ? actual : argument)), + }); + return { ...outcome, stdout: outcome.stdout.split(actual).join(named) }; + }, + useDirectory: inner.useDirectory, + }; +} + +/** A checkout of `remote` that records `named` as its origin. */ +export function* useNamedOriginCheckout( + remote: BareRemote, + named: string, +): Operation { + const checkout = yield* useHostCheckout(remote.locator); + checkout.run("remote", "set-url", "origin", named); + return checkout; +} + +/** One request a recording access received. */ +export interface RecordedRequest { + readonly method: string; + readonly url: string; + readonly authorized: boolean; +} + +export interface RecordingAccess { + readonly access: GitHubAccess; + readonly requests: RecordedRequest[]; +} + +/** + * A GitHub access that answers a fixed route table and records every request. + * + * Enough to say whether the transport was reached and with what, which is the + * whole of what an ordinary-profile read has to prove: what a body normalizes + * to belongs to the shared adapter's own suite. + */ +export function recordingAccess( + bodies: Readonly>, + endpoint = "https://api.github.test", + token: string | undefined = "test-token", +): RecordingAccess { + const requests: RecordedRequest[] = []; + return { + requests, + access: { + endpoint, + // deno-lint-ignore require-yield + *token(): Operation { + return token; + }, + // deno-lint-ignore require-yield + *send(request): Operation { + const path = new URL(request.url).pathname; + requests.push({ + method: request.method, + url: request.url, + authorized: request.headers?.Authorization !== undefined, + }); + const body = bodies[path]; + return body === undefined ? { status: 404, body: "{}" } : { status: 200, body }; + }, + }, + }; +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index dfee5a8de..924b5d3da 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -552,6 +552,30 @@ const COMPILED_BINARY: RuntimeExclusion[] = [ * unflagged and Bun has not, so folding these into the shared list would drop * coverage Node is currently giving. */ +/** + * Tests whose subject is the ordinary repository provider. + * + * It holds a managed checkout with a kernel-released exclusive advisory lock, + * which this repository reaches through the Deno runtime and which Node and Bun + * expose no equivalent of. The declarations it installs, and the fact that a + * runtime without the provider refuses every operation, are covered portably by + * `packages/cli/tests/run-composition.test.ts`, which runs everywhere. + */ +const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ + { + path: "packages/workflow/tests/run-composition.test.ts", + reason: + "the subject is the ordinary run's repository provider, which holds managed checkouts under a kernel-released exclusive advisory lock taken through the Deno runtime; Node and Bun expose no equivalent, and the declaration and provider-absence halves are covered portably by packages/cli/tests/run-composition.test.ts", + issue: DERIVED_SCOPE, + }, + { + path: "packages/cli/tests/run-composition-deno.test.ts", + reason: + "the same provider, asked the three questions only a runtime that operates repositories can answer: managed-Worktree session placement, diagnostic-trace non-authority, and a nested execution's own provider instance", + issue: DERIVED_SCOPE, + }, +]; + const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ { path: "packages/workflow/tests/xmd-artifact.test.ts", @@ -563,6 +587,11 @@ const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ export const exclusions: Record = { deno: COMPILED_BINARY, - node: [...DENO_ONLY_TOOLING, ...COMPILED_BINARY], - bun: [...DENO_ONLY_TOOLING, ...COMPILED_BINARY, ...BUN_MISSING_NODE_SQLITE], + node: [...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, ...COMPILED_BINARY], + bun: [ + ...DENO_ONLY_TOOLING, + ...DENO_ONLY_REPOSITORY_PROVIDER, + ...COMPILED_BINARY, + ...BUN_MISSING_NODE_SQLITE, + ], }; diff --git a/scripts/smoke-run-composition.ts b/scripts/smoke-run-composition.ts new file mode 100644 index 000000000..d078f16b5 --- /dev/null +++ b/scripts/smoke-run-composition.ts @@ -0,0 +1,223 @@ +/** + * Repository composition through the compiled binary (#643). + * + * The ordinary provider is assembled at a runtime-named entrypoint, holds its + * managed checkouts with a kernel-released advisory lock, and discovers the + * ambient repository from the directory the command was run in. Every one of + * those is a fact about the program that is running, so only the binary shows + * they survived `deno compile`. + * + * Four claims, each observed from outside the process: + * + * 1. a root-level `` belongs to the repository the binary was run in, + * and a command inside it runs there; + * 2. that checkout is a real linked worktree — `.git` is a file — and it is + * still on disk after the process exits; + * 3. a second binary, run while the first still holds the slot, is refused + * without waiting and changes nothing; and + * 4. once the first exits, the slot is taken by the next one, which finds the + * same checkout. + * + * The managed root is a temporary directory named through the same environment + * a person's would be reached through, so nothing here touches + * `~/.xmd/repositories`. + */ + +import { main } from "effection"; +import { sleep, until } from "effection"; +import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { useTempDirectory } from "./lib/temp-directory.ts"; +import * as path from "node:path"; + +const BINARY = path.join(Deno.cwd(), "dist", "xmd"); + +function fail(claim: string): never { + console.error(`run-composition smoke: ${claim}`); + Deno.exit(1); +} + +/** Git, run with an environment a caller's own configuration cannot reach. */ +function git(args: readonly string[], cwd: string, home: string): string { + const outcome = new Deno.Command("git", { + args: [...args], + cwd, + env: { + PATH: Deno.env.get("PATH") ?? "", + HOME: home, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + LC_ALL: "C", + GIT_AUTHOR_NAME: "Smoke", + GIT_AUTHOR_EMAIL: "smoke@example.invalid", + GIT_COMMITTER_NAME: "Smoke", + GIT_COMMITTER_EMAIL: "smoke@example.invalid", + }, + clearEnv: true, + stdout: "piped", + stderr: "piped", + }).outputSync(); + const printed = new TextDecoder().decode(outcome.stdout).trim(); + if (!outcome.success) { + fail(`git ${args.join(" ")} failed: ${new TextDecoder().decode(outcome.stderr)}`); + } + return printed; +} + +/** + * One invocation of the compiled binary on the smoke document. + * + * The long-lived one inherits its streams. A piped stream nobody is draining is + * a buffer that fills, and the run this script gates on is deliberately held + * open — so the one invocation that must not be blocked by its own output is + * the one whose output nothing is reading. + */ +function binary( + cwd: string, + env: Record, + streams: "piped" | "null" = "piped", +): Deno.Command { + return new Deno.Command(BINARY, { + args: ["run", "smoke.md", "--raw"], + cwd, + env, + clearEnv: true, + stdout: streams, + stderr: streams, + }); +} + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +/** The environment every binary invocation in this smoke runs under. */ +function environment(home: string, managed: string): Record { + return { + PATH: Deno.env.get("PATH") ?? "", + // The managed root follows `HOME`, so this smoke never reaches the real + // `~/.xmd/repositories` and never needs an option that does not exist. + HOME: managed, + GIT_CONFIG_GLOBAL: path.join(home, ".gitconfig"), + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + LC_ALL: "C", + }; +} + +await main(function* () { + if (!(yield* exists(BINARY))) { + fail(`no compiled binary at ${BINARY} — run \`deno task build\` first`); + } + + const home = yield* useTempDirectory("xmd-smoke-orc-home-"); + const managed = yield* useTempDirectory("xmd-smoke-orc-managed-"); + const workspace = yield* useTempDirectory("xmd-smoke-orc-"); + const checkout = path.join(workspace, "checkout"); + + // A configured identity, because an ordinary run commits as the invoking user + // and refuses when this host cannot say who that is. + yield* writeTextFile( + path.join(home, ".gitconfig"), + ["[user]", "\tname = Smoke Runner", "\temail = smoke@example.invalid", ""].join("\n"), + ); + + git(["init", "--initial-branch=main", checkout], workspace, home); + git(["commit", "--allow-empty", "-m", "first"], checkout, home); + const started = git(["rev-parse", "HEAD"], checkout, home); + + // 1 and 2. A root-level Worktree of the repository the binary was run in, a + // command inside it, and a gate the document holds itself open on. The + // command writes where it is standing to a file rather than to its own + // output, so what this script reads is the checkout Git resolved rather + // than a line it had to parse out of a rendered document. + const release = path.join(workspace, "release"); + const marker = path.join(workspace, "standing-in"); + yield* writeTextFile( + path.join(checkout, "smoke.md"), + [ + "# Ordinary repository composition", + "", + '', + "", + "", + "", + "```bash exec", + `git rev-parse --show-toplevel > ${marker}; while [ ! -f ${release} ]; do sleep 0.05; done`, + "```", + "", + "", + "", + ].join("\n"), + ); + + const holding = binary(checkout, environment(home, managed), "null").spawn(); + + // Observed while the child is still waiting for a file this script has not + // written yet, so the slot is genuinely held when the second binary asks for + // it. + for (let attempt = 0; !(yield* exists(marker)); attempt += 1) { + if (attempt > 600) { + fail("the document never reached its worktree command"); + } + yield* sleep(100); + } + + // 3. A second binary, while the first still holds the slot. + const contended = yield* until(binary(checkout, environment(home, managed)).output()); + const reported = decode(contended.stdout) + decode(contended.stderr); + if (contended.success) { + fail("a second process was allowed into a slot the first was holding"); + } + if (!reported.includes("another process is working in")) { + fail(`a second process refused for the wrong reason: ${reported}`); + } + + yield* writeTextFile(release, "go\n"); + const first = yield* until(holding.status); + if (!first.success) { + fail(`the holding run exited ${first.code}`); + } + + // Where the command inside the Worktree was actually standing. + const slot = (yield* readTextFile(marker)).trim(); + + // The worktree the run made is still there, and it is a real linked one. + if (!(yield* exists(slot))) { + fail(`the managed worktree did not survive the run: ${slot}`); + } + const administration = yield* readTextFile(path.join(slot, ".git")); + if (!administration.startsWith("gitdir:")) { + fail(`the managed worktree's .git is not a file naming its repository: ${administration}`); + } + if (git(["rev-parse", "--show-toplevel"], slot, home) !== slot) { + fail("the managed worktree does not report itself as its own checkout root"); + } + // It belongs to the ambient repository, which is what "ambient" means, and + // the ambient checkout was left where it was. + if (git(["rev-parse", "HEAD"], checkout, home) !== started) { + fail("the ambient checkout moved"); + } + if (git(["rev-parse", "--abbrev-ref", "HEAD"], slot, home) !== "smoke") { + fail("the managed worktree is not on the branch the document asked for"); + } + + // 4. The slot is free again, and the next run finds the same checkout. The + // marker is removed first, so what it holds afterwards is that run's own + // answer rather than the first one's. + yield* rm(marker); + const later = yield* until(binary(checkout, environment(home, managed)).output()); + if (!later.success) { + fail(`the slot was not released for a later run: ${decode(later.stderr)}`); + } + if ((yield* readTextFile(marker)).trim() !== slot) { + fail("a later run did not reuse the checkout the first one made"); + } + if (!(yield* exists(slot))) { + fail("the managed worktree did not survive the later run"); + } + + console.log("run-composition smoke: ok"); +}); diff --git a/scripts/tests/ci-workflow.test.ts b/scripts/tests/ci-workflow.test.ts index 922d5b164..19bf9dc28 100644 --- a/scripts/tests/ci-workflow.test.ts +++ b/scripts/tests/ci-workflow.test.ts @@ -270,6 +270,7 @@ describe("the CI smoke job", () => { "scripts/smoke-foreground.ts", "scripts/smoke-loaded-copy.ts", "scripts/smoke-fetch.ts", + "scripts/smoke-run-composition.ts", ]) { expect(commands).toContain(script); } diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 800e61b06..85b3af712 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8336,6 +8336,13 @@ that one execution: locally recorded admitted `origin` when there is one, and the recorded default branch. Being outside a Git checkout is not a startup failure — only an element that needs a repository refuses, and it names how to run inside one. +- **The Git identity** an ordinary commit records is the invoking user's own, + read once from the trusted host's environment and configuration. It is used + for `` alone and is not otherwise observable; a host that can name + no identity refuses that one component and leaves every other one usable. + Nothing else crosses from the caller's environment: hooks, file-system + monitors, signing programs and repository-supplied credential helpers stay + disabled. - **The invocation identity** names this execution to a service. It is not a prop, a Context value, a component result, a middleware answer or a journal event; it is neither addressable nor reusable, and the engine's own @@ -11541,7 +11548,7 @@ user's own `~/.xmd/repositories`. | ORC10 | Conflict is non-mutating | A changed base, Worktree branch or base, metadata, origin, common directory, object format or owner relationship refuses, and the slot's entries and sidecar are identical before and after | | ORC11 | Partial creation | A metadata-free slot in exactly the pre-exposure state is adopted and receives its sidecar; an incompatible or non-empty one refuses and remains byte-identical | | ORC12 | Exclusive ownership | A second process selecting the same slot is refused while the first holds it; another slot succeeds concurrently; normal release permits a later owner and the checkout remains | -| ORC13 | Live local Git | Switch, Add and Commit keep their authored semantics and make real, non-transactional changes; a failure claims neither rollback nor replay | +| ORC13 | Live local Git | Switch, Add and Commit keep their authored semantics and make real, non-transactional changes; a failure or a cancellation claims neither rollback nor replay; a commit records the invoking user's own identity, an unresolvable one refuses that component alone, and hooks, monitors, signing and repository helpers stay disabled | | ORC14 | Live Push evidence | A performed or already-equal Push stores exact private evidence; a Push of another branch, checkout, origin, destination or commit does not authorize a PullRequest; the latest publication of a destination decides | | ORC15 | Evidence cannot cross runs | A PullRequest succeeds only after an exact Push in the same execution; a new run must publish again, and copying a Context value, a result or a previous trace grants nothing | | ORC16 | Live Issues | Configured reads and upserts use the existing normalized contracts and this execution's own identity; absent or out-of-ceiling configuration sends no credential and no request | diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 42f84cafe..189596b60 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -75,7 +75,14 @@ components a workflow run has, over the caller's own filesystem: Repository is in scope. Both survive every execution and are held for one document execution by an exclusive non-blocking advisory lock. - Local Git operations happen directly against the selected checkout. There is - no transaction, no rollback and no replay, and none is claimed. + no transaction, no rollback and no replay, and none is claimed. A commit is + made under the invoking user's own effective Git identity, captured once from + the trusted host before the document expands; a host where Git can name no + identity refuses `` and names the two commands that fix it, and + every other component stays usable. Nothing else is borrowed from the caller's + environment: hooks, file-system monitors, signing programs and + repository-supplied credential helpers stay disabled exactly as they are for a + workflow run. - `` keeps the observe/adopt/fast-forward/refuse rules and stores private evidence of what it published. `` is authorized by that evidence and by nothing else, so a new run must publish again. @@ -1043,7 +1050,12 @@ Under a workflow run each is a durable Workspace effect, or — for Push — a reconciled Git-host effect, and that is what the rest of this section describes. Under an ordinary `xmd run` the same authored transitions happen directly against the selected checkout: no transaction encloses them, nothing -rolls back, nothing replays, and no such claim is made. Push keeps the +rolls back, nothing replays, and no such claim is made. Section 7.3's fixed +Git identity is a workflow run's, for a reason that inverts here — a workflow's +retained state must not depend on whose machine made it, and an ordinary run's +commit lands in that person's own checkout. So an ordinary commit records the +invoking user's own effective identity, and a host that can name none refuses +rather than substituting one. Push keeps the observe/adopt/fast-forward/refuse rules and, instead of a reconciliation record, leaves private evidence in the provider instance that verified it. @@ -3354,7 +3366,7 @@ fetch operation requires its own language and durability contract. | ``, `` and `` composition under a workflow run | built by #293, Deno provider only | | the same thirteen declarations under every runtime | built by #643: one shadowable array consumed by the workflow attachment, `xmd syntax`, `xmd plan` and an ordinary document execution | | ``, `` 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 | -| 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, and a private per-execution Push evidence entry that authorizes `` and crosses no run | +| 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 | | `` read and upsert, and the `issue_effect` boundary (§10.3) | built by #296; GitHub middleware, Deno host | From 505ecc4b06cc4cf53c117ce046ff2160753945f8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:25:33 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A7=AA=20Prove=20the=20ordinary=20run?= =?UTF-8?q?'s=20refusals,=20isolation=20and=20read=20routes=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence for ORC5, ORC8, ORC15 and ORC17, written to discriminate rather than to confirm. - ORC15: a diagnostic trace of an execution that really published grants nothing. A `` handed one is refused, so what authorizes a pull request is evidence an execution holds rather than a record describing one. - ORC5: a run with no origin does local work and refuses to publish before reaching anything; a run with an origin opens a session and transports; a run with a Git host configured reaches it. Three counters that can each fail on their own, so no single stub makes the set pass. - ORC8: a managed Repository survives an authored failure inside its body, and a Push result handed back by middleware that never performed one grants nothing. - ORC17: all three pull-request read collections are read, each from its own route. --- .../cli/tests/run-composition-deno.test.ts | 75 +++- .../workflow/tests/run-composition.test.ts | 371 +++++++++++++++--- .../workflow/tests/support/run-composition.ts | 45 ++- 3 files changed, 432 insertions(+), 59 deletions(-) diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index c26fa823d..5bdbeb9d5 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -54,14 +54,30 @@ function git(args: readonly string[], cwd: string, home: string): string { return outcome.stdout.trim(); } +/** A bare repository this suite can publish to, made from a real checkout. */ +function* useRemote(): Operation { + const home = yield* useTempDirectory("xmd-orc-remote-home-"); + const parent = yield* until(realpath(yield* useTempDirectory("xmd-orc-remote-"))); + const seed = join(parent, "seed"); + git(["init", "--initial-branch=main", seed], parent, home); + git(["commit", "--allow-empty", "-m", "first"], seed, home); + const bare = join(parent, "remote.git"); + git(["clone", "--bare", "--", seed, bare], parent, home); + return bare; +} + /** A repository the command is "run in", and a managed root of this suite's own. */ -function* useAmbient(): Operation<{ checkout: string; root: string; home: string }> { +function* useAmbient(locator?: string): Operation<{ checkout: string; root: string; home: string }> { const home = yield* useTempDirectory("xmd-orc-home-"); // Canonical, so what this fixture names and what Git reports are one string. const parent = yield* until(realpath(yield* useTempDirectory("xmd-orc-ambient-"))); const checkout = join(parent, "checkout"); - git(["init", "--initial-branch=main", checkout], parent, home); - git(["commit", "--allow-empty", "-m", "first"], checkout, home); + if (locator === undefined) { + git(["init", "--initial-branch=main", checkout], parent, home); + git(["commit", "--allow-empty", "-m", "first"], checkout, home); + } else { + git(["clone", "--", locator, checkout], parent, home); + } const managed = yield* until(realpath(yield* useTempDirectory("xmd-orc-managed-"))); return { checkout, root: join(managed, "repositories"), home }; } @@ -172,6 +188,59 @@ describe("ORC18 — the journal is diagnostic", () => { }); }); +describe("ORC15 — a trace is not evidence", () => { + it("refuses a PullRequest handed the trace of an execution that really published", function* () { + const remote = yield* useRemote(); + const first = yield* useAmbient(remote); + const trace = join(first.root, "..", "published.jsonl"); + + // A real publication, written into a real diagnostic trace. + yield* runOrdinary( + [ + ``, + `pushed`, + ``, + ``, + ``, + ].join("\n"), + { root: first.root, cwd: first.checkout, journal: trace }, + ); + const published = git(["rev-parse", "HEAD"], first.checkout, first.home); + expect(git(["rev-parse", "traced-push"], remote, first.home)).toBe(published); + expect(yield* exists(trace)).toBe(true); + const written = yield* readTextFile(trace); + // The trace holds this run's own events, and none of them is the + // publication: an ordinary run journals no repository effect at all, so + // there is not even a record for a later run to misread as evidence. + expect(written.length).toBeGreaterThan(0); + expect(written).toContain("import_component"); + expect(written).not.toContain("git-push"); + expect(written).not.toContain("git_host"); + + // A new execution, on the same checkout, on the same branch, at the same + // commit — handed that exact file as its journal, and containing only a + // pull request. + const failure = yield* raisedValue( + runOrdinary(``, { + root: first.root, + cwd: first.checkout, + journal: trace, + }), + ); + expect(String(failure)).toContain("holds no successful result"); + + // The second run wrote its own events after the first run's, which is what + // a trace is: a file appended to, never a file read back. Nothing in it + // authorized anything, and there is still no publication recorded anywhere + // in it. + const after = yield* readTextFile(trace); + expect(after.startsWith(written)).toBe(true); + expect(after.length).toBeGreaterThan(written.length); + expect(after).not.toContain("git-push"); + expect(after).not.toContain("git_host"); + }); +}); + describe("ORC19 — a nested run profile", () => { it("gives each execution a provider of its own, with no shared leases", function* () { const ambient = yield* useAmbient(); diff --git a/packages/workflow/tests/run-composition.test.ts b/packages/workflow/tests/run-composition.test.ts index fffb34739..5dd7cb7e1 100644 --- a/packages/workflow/tests/run-composition.test.ts +++ b/packages/workflow/tests/run-composition.test.ts @@ -20,6 +20,8 @@ import { until } from "effection"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { GitOperationAuthorityError } from "../src/composition/errors.ts"; import { admitLivePushEvidence } from "../src/deno/run-composition/operations.ts"; +import { GitComposition } from "../src/composition/git-api.ts"; +import type { GitPushOutcome } from "../src/composition/git-push-records.ts"; import { ManagedCheckoutError, NoAmbientRepositoryError, @@ -70,6 +72,108 @@ import { /** The github.com repository the modeled store answers for. */ const GITHUB_LOCATOR = "https://github.com/octo/project"; +/** The head every modeled pull request in this file is opened from. */ +const HEAD = "a".repeat(40); + +/** + * The routes one modeled pull request answers a reviews read on. + * + * The pull request itself is one of them: an answer is authenticated against + * the subject it claims, so a collection with no pull request behind it is + * refused rather than bound. + */ +function reviewRoutes(endpoint: string): Record { + return { + "/repos/octo/project/pulls/4": JSON.stringify({ + number: 4, + head: { sha: HEAD }, + base: { repo: { full_name: "octo/project" } }, + }), + "/repos/octo/project/pulls/4/reviews": JSON.stringify([ + { + id: 10, + user: { login: "reviewer" }, + state: "APPROVED", + body: "looks right", + submitted_at: "2026-01-01T00:00:00Z", + commit_id: HEAD, + html_url: "https://github.test/pr/4#r10", + pull_request_url: `${endpoint}/repos/octo/project/pulls/4`, + }, + ]), + }; +} + +/** Every route the three collections are read from, each answering its own. */ +function evidenceRoutes(endpoint: string): Record { + const subject = `${endpoint}/repos/octo/project/pulls/4`; + return { + ...reviewRoutes(endpoint), + "/repos/octo/project/issues/4/comments": JSON.stringify([ + { + id: 20, + user: { login: "watcher" }, + body: "a conversation comment", + created_at: "2026-01-01T01:00:00Z", + updated_at: "2026-01-01T01:00:00Z", + html_url: "https://github.test/pr/4#c20", + issue_url: `${endpoint}/repos/octo/project/issues/4`, + }, + ]), + "/repos/octo/project/pulls/4/comments": JSON.stringify([ + { + id: 21, + pull_request_review_id: 10, + user: { login: "reviewer" }, + body: "an inline comment", + created_at: "2026-01-01T02:00:00Z", + updated_at: "2026-01-01T02:00:00Z", + html_url: "https://github.test/pr/4#d21", + path: "packages/core/mod.ts", + diff_hunk: "@@ -1 +1 @@\n-old\n+new", + commit_id: HEAD, + original_commit_id: HEAD, + line: 12, + side: "RIGHT", + start_line: null, + start_side: null, + in_reply_to_id: null, + pull_request_url: subject, + }, + ]), + [`/repos/octo/project/commits/${HEAD}/check-runs`]: JSON.stringify({ + total_count: 1, + check_runs: [ + { + id: 30, + head_sha: HEAD, + name: "test-deno", + status: "completed", + conclusion: "failure", + html_url: "https://github.test/run/30", + started_at: "2026-01-01T03:00:00Z", + completed_at: "2026-01-01T03:10:00Z", + output: { title: "1 failed", summary: "a summary", text: null }, + }, + ], + }), + [`/repos/octo/project/commits/${HEAD}/status`]: JSON.stringify({ + sha: HEAD, + statuses: [ + { + id: 31, + context: "deploy", + state: "error", + description: "a description", + target_url: null, + created_at: "2026-01-01T04:00:00Z", + updated_at: "2026-01-01T04:00:00Z", + }, + ], + }), + }; +} + /** The second process every exclusive-ownership case runs. */ const CHILD = fileURLToPath(new URL("./support/run-composition-child.ts", import.meta.url)); const TOKEN = "test-token"; @@ -147,7 +251,6 @@ describe("ORC3 — the ambient primary checkout", () => { root, cwd: elsewhere, host: counting.host, - authentication: counting.authentication, }), ); const refusal = causedBy(failure, isMissingAmbient); @@ -194,14 +297,17 @@ describe("ORC4 — the ambient linked worktree", () => { }); describe("ORC5 — origin is not local authority", () => { - it("creates a Worktree and commits with no origin, and refuses to publish", function* () { + it("does local work with no origin, and refuses to publish before reaching anything", function* () { const root = yield* useManagedRoot(); const solo = yield* useOriginlessCheckout(); + // Worktree, Switch, Add and Commit all work without an origin: none of them + // has anywhere to go. const bound = yield* runOrdinaryDocument( [ ``, "", + ``, `no remote`, ``, ``, @@ -210,13 +316,71 @@ describe("ORC5 — origin is not local authority", () => { { root, cwd: solo.root }, ); expect(typeof bound).toBe("string"); - expect(solo.run("log", "-1", "--pretty=%s", "feature")).toBe("Local only"); + expect(solo.run("log", "-1", "--pretty=%s", "feature-two")).toBe("Local only"); - // Push refuses before a credential, a session or a transport exists. - const failure = yield* raised(runOrdinaryDocument(``, { root, cwd: solo.root })); - const refusal = causedBy(failure, isAuthorityFailure); - expect(refusal).toBeInstanceOf(GitOperationAuthorityError); - expect(String(refusal)).toContain("no usable origin"); + // Push and PullRequest each refuse, and each refuses before a credential is + // read, a session is opened or a byte leaves for a Git host. + for (const source of [``, ``]) { + const counting = countingOrdinaryHost(); + const github = recordingAccess({}); + const failure = yield* raised( + runOrdinaryDocument(source, { + root, + cwd: solo.root, + host: counting.host, + gitHubPullRequests: { access: gitHubSource(github.access) }, + gitHubIssues: { ceiling: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + }), + ); + expect(`${source} ${String(failure)}`).toContain("no usable origin"); + // No authentication session was opened for any locator. + expect(counting.counters.sessions).toEqual([]); + // No transport ran: neither observation nor publication. + expect(subcommands(counting.counters)).not.toContain("ls-remote"); + expect(subcommands(counting.counters)).not.toContain("push"); + // And nothing was asked of a Git host — no credential, no request. + expect(github.credentials).toBe(0); + expect(github.requests).toEqual([]); + } + }); + + it("opens a session and transports when there is an origin, so the counters can fail", function* () { + // The same counters, on a repository that *does* have an origin. Without + // this, every assertion above would pass on a counter that can never be + // incremented — which is the one way "nothing was reached" lies. + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const counting = countingOrdinaryHost(); + + yield* runOrdinaryDocument( + [``, ``].join("\n"), + { root, cwd: checkout.root, host: counting.host }, + ); + + expect(counting.counters.sessions).toEqual([remote.locator]); + expect(subcommands(counting.counters)).toContain("ls-remote"); + expect(subcommands(counting.counters)).toContain("push"); + }); + + it("reaches a Git host when one is configured, so those counters can fail too", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const endpoint = "https://api.github.test"; + const github = recordingAccess(reviewRoutes(endpoint), endpoint); + + yield* runOrdinaryDocument( + ``, + { + root, + cwd: checkout.root, + gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + }, + ); + + expect(github.credentials).toBeGreaterThan(0); + expect(github.requests.length).toBeGreaterThan(0); }); }); @@ -420,6 +584,45 @@ describe("ORC8 — managed checkouts are persistent", () => { expect(yield* exists(`${worktree.checkout}/in-flight.md`)).toBe(true); }); + it("keeps a managed Repository after an authored failure inside its body", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + const slot = repositorySlotOf(root, remote.locator, "surviving"); + + yield* raised( + runOrdinaryDocument( + [ + ``, + `written before the failure`, + ``, + ``, + ``, + "", + ].join("\n"), + { root, cwd: checkout.root }, + ), + ); + + // The path, the sidecar, the Git state and the working file all survive. + expect(yield* exists(slot.checkout)).toBe(true); + expect(yield* readSidecar(slot)).toMatchObject({ + kind: "repository", + version: 1, + name: "surviving", + locator: remote.locator, + }); + expect(yield* readTextFile(`${slot.checkout}/kept.md`)).toBe("written before the failure"); + // The branch the document switched to and the staging it did are both still + // there: nothing rolled back, and nothing was cleaned up on the way out. + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], slot.checkout, checkout.home)).toBe( + "in-progress", + ); + expect(git(["diff", "--cached", "--name-only"], slot.checkout, checkout.home)).toContain( + "kept.md", + ); + }); + it("keeps the checkout after an authored failure inside the Worktree body", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); @@ -1010,7 +1213,7 @@ describe("ORC14 — live Push evidence", () => { const failure = yield* raised( runOrdinaryDocument( [``, ``].join("\n"), - { root, cwd: checkout.root, host: counting.host, authentication: counting.authentication }, + { root, cwd: checkout.root, host: counting.host }, ), ); expect(subcommands(counting.counters)).toContain("ls-remote"); @@ -1245,6 +1448,85 @@ describe("ORC15 — evidence cannot cross runs", () => { expect(patches(store)).toBe(0); }); + it("grants nothing to a Push result middleware handed back without performing one", function* () { + const remote = yield* useBareRemote(REMOTE); + const root = yield* useManagedRoot(); + const checkout = yield* useHostCheckout(remote.locator); + + // One execution really publishes, and the suite keeps the exact outcome the + // provider answered with — the whole successful `GitPushOutcome`. + let published: GitPushOutcome | undefined; + yield* runOrdinaryDocument( + [ + ``, + `copied`, + ``, + ``, + ``, + "", + ].join("\n"), + { + root, + cwd: checkout.root, + around: function* () { + yield* GitComposition.around({ + *pushCurrentBranch([invocation], next): Operation { + published = yield* next(invocation); + return published; + }, + }); + }, + components: [ + { + name: "Capture", + origin: "test", + props: { type: "object", additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return ""; + }, + }, + ], + }, + ); + expect(published).toBeDefined(); + expect(published?.decision).toBe("performed"); + const head = checkout.run("rev-parse", "HEAD"); + + // A new execution whose `` is answered by middleware handing that + // exact successful outcome back. The provider underneath never runs, so it + // never verifies a publication and never records evidence — and a result is + // not evidence. + let delegated = 0; + const failure = yield* raised( + runOrdinaryDocument([``, ``].join("\n"), { + root, + cwd: checkout.root, + around: function* () { + yield* GitComposition.around({ + // deno-lint-ignore require-yield + *pushCurrentBranch([_invocation], _next): Operation { + delegated += 1; + if (published === undefined) { + throw new Error("the suite captured no publication to hand back"); + } + return published; + }, + }); + }, + }), + ); + + // The middleware answered, so the component saw a successful Push. + expect(delegated).toBe(1); + // The branch really is still published at that commit, so nothing about the + // world contradicts the copied result. + expect(remoteBranch(remote, "copied")).toBe(head); + // And the pull request is refused anyway: what authorizes it is what this + // provider verified, not what anything handed it. + expect(String(failure)).toContain("holds no successful result"); + }); + it("grants nothing to a copied selection, a copied result or a previous trace", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); @@ -1424,46 +1706,28 @@ describe("ORC17 — live PullRequests", () => { expect(String(rendered)).toContain("number 1"); }); - it("reads evidence collections and holds a URL to the configured ceiling", function* () { + it("reads all three collections, each from its own route", function* () { const remote = yield* useBareRemote(REMOTE); const root = yield* useManagedRoot(); const checkout = yield* useHostCheckout(remote.locator); const endpoint = "https://api.github.test"; - const recording = recordingAccess( - { - // The pull request itself, which is what an answer is authenticated - // against, and the collection this read asks for. - "/repos/octo/project/pulls/4": JSON.stringify({ - number: 4, - head: { sha: "a".repeat(40) }, - base: { repo: { full_name: "octo/project" } }, - }), - "/repos/octo/project/pulls/4/reviews": JSON.stringify([ - { - id: 10, - user: { login: "reviewer" }, - state: "APPROVED", - body: "looks right", - submitted_at: "2026-01-01T00:00:00Z", - commit_id: "a".repeat(40), - html_url: "https://github.test/pr/4#r10", - pull_request_url: `${endpoint}/repos/octo/project/pulls/4`, - }, - ]), - }, - endpoint, - ); + const recording = recordingAccess(evidenceRoutes(endpoint), endpoint); const access = gitHubSource(recording.access); - // Allowed: the read reaches the transport under an authorization header and - // binds what came back. + // All three, in one document, under the ordinary provider. const rendered = yield* runOrdinaryDocument( [ ``, + ``, + ``, "", - "reviews {reviews.length}", + "counts {reviews.length} {comments.length} {checks.length}", "", "", + "", + "", + "", + "", ].join("\n"), { root, @@ -1471,15 +1735,35 @@ describe("ORC17 — live PullRequests", () => { gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access }, }, ); - expect(String(rendered)).toContain("reviews 1"); - // The collection itself, normalized by the shared adapter and bound here. + + // One review, two comments of both kinds, and two checks of both kinds. + expect(String(rendered)).toContain("counts 1 2 2"); + // Each collection carries the existing normalized contract. expect(String(rendered)).toContain('"state": "approved"'); expect(String(rendered)).toContain('"author": "reviewer"'); - const asked = recording.requests.length; - expect(asked).toBeGreaterThan(0); + expect(String(rendered)).toContain('"kind": "conversation"'); + expect(String(rendered)).toContain('"kind": "review"'); + expect(String(rendered)).toContain('"diffHunk"'); + expect(String(rendered)).toContain('"kind": "check-run"'); + expect(String(rendered)).toContain('"conclusion": "failure"'); + expect(String(rendered)).toContain('"kind": "commit-status"'); + expect(String(rendered)).toContain('"state": "error"'); + + // Each read reached the route its own collection lives at. + const asked = recording.requests.map((request) => new URL(request.url).pathname); + for (const route of [ + "/repos/octo/project/pulls/4/reviews", + "/repos/octo/project/issues/4/comments", + "/repos/octo/project/pulls/4/comments", + `/repos/octo/project/commits/${HEAD}/check-runs`, + `/repos/octo/project/commits/${HEAD}/status`, + ]) { + expect(`${route}: ${asked.includes(route)}`).toBe(`${route}: true`); + } expect(recording.requests.every((request) => request.authorized)).toBe(true); // Outside the ceiling: refused before anything is sent. + const sent = recording.requests.length; const failure = yield* raised( runOrdinaryDocument( ``, @@ -1487,7 +1771,7 @@ describe("ORC17 — live PullRequests", () => { ), ); expect(String(failure)).toContain("has not authorized"); - expect(recording.requests).toHaveLength(asked); + expect(recording.requests).toHaveLength(sent); // And with nothing allowed, no read this host performs exists at all. const unconfigured = yield* raised( @@ -1498,7 +1782,7 @@ describe("ORC17 — live PullRequests", () => { }), ); expect(String(unconfigured)).toContain("no pull-request provider handles"); - expect(recording.requests).toHaveLength(asked); + expect(recording.requests).toHaveLength(sent); }); it("refuses an unpublished head before a credential or a request", function* () { @@ -1513,7 +1797,6 @@ describe("ORC17 — live PullRequests", () => { root, cwd: checkout.root, host: counting.host, - authentication: counting.authentication, gitHubPullRequests: { access: gitHubSource(fakeGitHubAccess(store)) }, }), ); diff --git a/packages/workflow/tests/support/run-composition.ts b/packages/workflow/tests/support/run-composition.ts index 9e1d61b1f..0e9f0f147 100644 --- a/packages/workflow/tests/support/run-composition.ts +++ b/packages/workflow/tests/support/run-composition.ts @@ -112,13 +112,12 @@ export function* useManagedRoot(): Operation { export interface OrdinaryCounters { /** Every Git command, in order, as its argument list. */ readonly commands: string[][]; - /** Every authentication session opened, by locator. */ + /** Every authentication session this host was asked to open, by locator. */ readonly sessions: string[]; } export interface CountingOrdinaryHost { readonly host: RepositoryHost; - readonly authentication: GitAuthentication; readonly counters: OrdinaryCounters; } @@ -126,8 +125,15 @@ export interface CountingOrdinaryHost { * The production host, counted. * * Both leaves are wrapped rather than replaced: what a suite needs to know is - * *whether* a credential was opened and *whether* a transport ran, and the only + * *whether* a session was opened and *whether* a transport ran, and the only * honest way to answer is to let the real one happen and watch. + * + * The session counter is on `useAuthentication` rather than on a separate + * `authentication` option, because that option only reaches the *default* host + * — a suite that supplies its own has already replaced the thing a session + * would be opened by. A counter installed there would never be incremented, + * and every "no session was opened" assertion made against it would pass + * without ever having been able to fail. */ export function countingOrdinaryHost( inner: RepositoryHost = denoRepositoryHost(), @@ -135,21 +141,18 @@ export function countingOrdinaryHost( const counters: OrdinaryCounters = { commands: [], sessions: [] }; return { counters, - authentication: { - *open(locator: string): Operation { - counters.sessions.push(locator); - return UNAUTHENTICATED; - }, - }, host: { *git(invocation: GitInvocation): Operation { counters.commands.push([...invocation.args]); return yield* inner.git(invocation); }, useDirectory: inner.useDirectory, - ...(inner.useAuthentication === undefined - ? {} - : { useAuthentication: inner.useAuthentication }), + *useAuthentication(locator: string): Operation { + counters.sessions.push(locator); + return inner.useAuthentication === undefined + ? UNAUTHENTICATED + : yield* inner.useAuthentication(locator); + }, }, }; } @@ -192,6 +195,14 @@ export interface RunOptions extends Omit * over to prove that replacing it buys nothing. */ readonly contextualRepository?: RepositorySelection; + /** + * Middleware installed after the provider and before the document. + * + * The nearest handler at the same depth, which is what a document's own + * composition would be: a suite uses it to answer an operation the provider + * would otherwise perform, and to prove that answering one grants nothing. + */ + readonly around?: () => Operation; } /** @@ -225,6 +236,9 @@ export function runOrdinaryDocument(source: string, options: RunOptions): Operat if (contextualRepository !== undefined) { yield* RepositoryContext.around({ current: () => contextualRepository }, { at: "min" }); } + if (options.around !== undefined) { + yield* options.around(); + } return yield* collect( yield* execute({ ...inlineSource(source), @@ -433,6 +447,8 @@ export interface RecordedRequest { export interface RecordingAccess { readonly access: GitHubAccess; readonly requests: RecordedRequest[]; + /** How many times this access was asked for a credential. */ + readonly credentials: number; } /** @@ -448,12 +464,17 @@ export function recordingAccess( token: string | undefined = "test-token", ): RecordingAccess { const requests: RecordedRequest[] = []; + let credentials = 0; return { requests, + get credentials(): number { + return credentials; + }, access: { endpoint, // deno-lint-ignore require-yield *token(): Operation { + credentials += 1; return token; }, // deno-lint-ignore require-yield From 58739343a518886bb2d315d424d549376f7db763 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:07:30 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20Stand=20a=20nested=20run=20c?= =?UTF-8?q?hild=20in=20the=20document's=20working=20directory=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `` child is a root execution in a scope that does not descend from the document's, so it inherited no `API.Env` handler and stood in the *process* directory. A `` or `` around the element scoped every component inside it except the child, and the child's own repository provider discovered its ambient Git from whatever checkout the process happened to be launched in. The harness now reads the contextual directory on the last line still inside the invocation and carries it on `ChildInvocation`, the private value the terminal hands the trusted provider. Deliberately not on `HostProfileRequest`: middleware that could see it could swap it, and the directory a child resolves its root and its repository in would become composed policy's choice rather than the document's. The run profile installs it first, ahead of the root, the provider and the execution, so all three agree with the document that asked. ORC19 now asks this of a real `xmd run` in a subprocess whose process directory is a temporary directory that is not a Git checkout, with every repository, managed root and document fixture-owned and `HOME` redirected. A break in propagation therefore refuses for want of a repository rather than operating on the checkout the suite runs from. The fourth case proves that refusal directly, which is what stops the first from passing for the wrong reason. Two claims are asked of siblings rather than of a parent and its child, and the tests say why: a managed checkout stays leased for the whole run of whoever touched it, so a parent and a child cannot share one; and a run discovers its ambient repository once, when its provider is installed, from the process directory — which here is deliberately not a repository. The parent's side of the isolation is the lease case, where a parent's hold survives its child's teardown. --- packages/cli/src/cli.ts | 27 +- packages/cli/src/testing-host.ts | 19 +- .../cli/tests/run-composition-deno.test.ts | 548 +++++++++++++++--- .../cli/tests/testing-execution-host.test.ts | 4 + packages/testing/package.json | 1 + packages/testing/src/execution-harness.ts | 13 +- packages/testing/src/execution-host.ts | 21 + .../workflow/tests/run-composition.test.ts | 9 +- pnpm-lock.yaml | 3 + specs/testing-spec.md | 8 + specs/workflow-workspace-spec.md | 6 +- 11 files changed, 566 insertions(+), 93 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8d389acbb..d9878eba0 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -743,6 +743,7 @@ function* runDocument( mode: DocumentMode, installService: HostServiceInstaller, installRepositories: RepositoryInstaller, + childRepositories: RepositoryInstaller, ): Operation> { const { root, include, verbose, journal, raw, secretDetection, retainProcessOutput } = config; @@ -872,11 +873,13 @@ function* runDocument( includes: include, secretDetection, installService, - // Passed rather than inherited: a child runs in an isolated scope, and what - // it needs is a *fresh* provider instance of its own. Handing it the - // installer is what gives an isolated `host="run"` child its own invocation - // identity, its own leases and its own Push evidence. - installRepositories, + // The *entrypoint's* installer, not this command's. A `host="run"` child is + // an ordinary run whatever command is hosting it, so `xmd test` — which + // installs no repository provider for its own document — still gives one to + // a child that asked to be a run. Passed rather than inherited because a + // child runs in an isolated scope and needs a fresh instance: its own + // invocation identity, its own leases and its own Push evidence. + installRepositories: childRepositories, testAgentWorker: yield* readWorkerCommand(), plan, }); @@ -979,9 +982,12 @@ function* runScopedDocument( mode: DocumentMode, installService: HostServiceInstaller, installRepositories: RepositoryInstaller, + childRepositories: RepositoryInstaller = installRepositories, ): Operation> { try { - return yield* scoped(() => runDocument(config, mode, installService, installRepositories)); + return yield* scoped(() => + runDocument(config, mode, installService, installRepositories, childRepositories), + ); } catch (error) { return Err(error instanceof Error ? error : new Error(String(error))); } @@ -1096,6 +1102,8 @@ function* test( config: TestConfig, args: string[], installService: HostServiceInstaller, + /** What a `` child installs. This command installs none. */ + installRepositories: RepositoryInstaller, ): Operation { const patterns = readPatternFlags(args); if (patterns.missingValue) { @@ -1131,9 +1139,10 @@ function* test( installService, // The outer `xmd test` command installs no operational repository // provider. A test that needs the production behavior exercises an - // explicit `` child, which constructs one of its - // own. + // explicit `` child, which is an ordinary run and + // is handed the entrypoint's own installer below. unsupportedRepositories, + installRepositories, ); if (!result.ok) { reportFailure(result.error); @@ -1175,6 +1184,7 @@ function* test( { testing: true }, installService, unsupportedRepositories, + installRepositories, ); if (!result.ok) { reportFailure(result.error, document.relativePath); @@ -2101,6 +2111,7 @@ function* dispatch( { ...command.config, retainProcessOutput: keepsProcessOutput(command.config.journal) }, evalFlags.rest, installService, + installRepositories, ); break; } diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index e8ccfe8ca..f6d3218ca 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -26,7 +26,7 @@ import type { DeclaredMarkdownComponent } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { forEach } from "@effectionx/stream-helpers"; -import { useHostFiles } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; import { installWebElicitation } from "@executablemd/web"; import type { Operation, Result } from "effection"; import { @@ -159,6 +159,23 @@ function* runProfileChild( if (request.host !== "run") { throw new Error(`the ${request.host} host profile is not available on this entrypoint`); } + // First, because everything below resolves against it. This scope inherits no + // `API.Env` handler, so without this the child would stand in the *process* + // directory: a `` around the `` would scope every component + // in it except the child, the root reference would resolve from somewhere the + // document never named, and the repository provider installed below would + // discover its ambient Git from whatever checkout the process was launched + // in. Installed ahead of the root, the provider and the execution, so all + // three agree with the document that asked. + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return invocation.cwd; + }, + }, + { at: "min" }, + ); const root = rootOf(request); // `--journal` is the only thing that asks `xmd run` for a diagnostic record, // and a declaration is the only thing that asks a child for one. Neither diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5bdbeb9d5..f0d52ba92 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -13,15 +13,30 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, until, type Operation } from "effection"; +import { type Operation, scoped, until } from "effection"; import { realpath } from "node:fs/promises"; -import { exists, readTextFile } from "@effectionx/fs"; +import { exists, readdir, readTextFile, writeTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import process from "node:process"; -import { API, useHostFiles } from "@executablemd/runtime"; +import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime"; +import { Err } from "effection"; +import { testHarnessInstallation, useTesting } from "@executablemd/testing"; +import { executeInstalled } from "@executablemd/core/host"; +import { testingExecutionHost } from "../src/testing-host.ts"; +import { denoRunRepositories } from "../src/deno-repositories.ts"; +import type { RepositoryInstaller } from "../src/run-repositories.ts"; +import { TEST_HELPER } from "../../workflow/tests/support/composition.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { collect, execute, inlineSource } from "@executablemd/core"; +import { + Agent, + collect, + execute, + inlineSource, + installAgentComponents, + registerAgentProvider, +} from "@executablemd/core"; +import { runCli } from "@executablemd/test-support/launch"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { deriveSessionKey, sessionCandidates } from "../../acp/src/session-key.ts"; import { useCompositionComponents } from "@executablemd/workflow"; @@ -67,7 +82,9 @@ function* useRemote(): Operation { } /** A repository the command is "run in", and a managed root of this suite's own. */ -function* useAmbient(locator?: string): Operation<{ checkout: string; root: string; home: string }> { +function* useAmbient( + locator?: string, +): Operation<{ checkout: string; root: string; home: string }> { const home = yield* useTempDirectory("xmd-orc-home-"); // Canonical, so what this fixture names and what Git reports are one string. const parent = yield* until(realpath(yield* useTempDirectory("xmd-orc-ambient-"))); @@ -85,7 +102,13 @@ function* useAmbient(locator?: string): Operation<{ checkout: string; root: stri /** Run one document under the ordinary provider, on a stream a caller chose. */ function runOrdinary( source: string, - options: { root: string; cwd: string; journal?: string }, + options: { + root: string; + cwd: string; + journal?: string; + /** Installed after the components, where a provider's own middleware goes. */ + agent?: () => Operation; + }, ): Operation { return scoped(function* () { yield* API.Env.around( @@ -98,8 +121,12 @@ function runOrdinary( { at: "min" }, ); yield* useHostFiles(); + yield* installAgentComponents(); yield* useCompositionComponents(); yield* useRunComposition({ root: options.root, cwd: options.cwd }); + if (options.agent !== undefined) { + yield* options.agent(); + } // `--journal` is exactly this: the file-backed stream instead of the // in-memory one, created by the command before the run begins. const stream = @@ -109,36 +136,133 @@ function runOrdinary( } describe("ORC7 — a Session launched in a managed Worktree", () => { - it("receives the worktree's own Git root and a session key of its own", function* () { + it("hands the launch that worktree's own root and a session key of its own", function* () { const ambient = yield* useAmbient(); - // A managed Worktree, made by the ordinary provider. - const bound = String( - yield* runOrdinary(`\n\n{w}`, { - root: ambient.root, - cwd: ambient.checkout, - }), - ).trim(); - expect(yield* exists(bound)).toBe(true); - - // `.git` there is a file, not a directory — which is what bounds the walk. - expect(yield* readTextFile(`${bound}/.git`)).toContain("gitdir:"); - - // The candidate walk from inside it stops at the worktree root, so a - // Session placed there is placed in the worktree rather than in the - // repository it belongs to. - const candidates = yield* sessionCandidates("codex", bound); - expect(candidates.map((candidate) => candidate.cwd)).toEqual([bound]); - - // And its key is its own: the same agent and the same session name in the - // ambient checkout is a different session. - const inWorktree = deriveSessionKey("codex", bound, "implementer"); - const inAmbient = deriveSessionKey("codex", ambient.checkout, "implementer"); + /** Every launch this document routed, as the placement it was given. */ + const routed: { cwd: string; session: string | undefined }[] = []; + + // The public launch surface a provider answers. A real `` + // reaches exactly this, through the same installation `xmd run` makes, and + // what it is handed is the placement: the directory the session belongs to. + const capture = function* (): Operation { + // A registered provider, reached the way `` reaches one. + // Only a registered provider is handed the launch authority, so only one + // can settle a launch — middleware can route a request and cannot + // perform it, which is the boundary this uses rather than works around. + yield* registerAgentProvider("probe", function* (options, authority) { + yield* Agent.around( + { + // deno-lint-ignore require-yield + *agent([name]): Operation { + return name ?? options.defaultAgent; + }, + *launch([request]): Operation { + routed.push({ + cwd: request.cwd, + session: typeof request.session === "string" ? request.session : undefined, + }); + // Settled as a refusal rather than performed: this suite is about + // where a launch is placed, and starting a native UI would need a + // terminal nothing here has. + yield* authority.refuse(request, { + phase: "prepared", + agent: "codex", + sessionKey: deriveSessionKey( + "codex", + request.cwd, + typeof request.session === "string" ? request.session : undefined, + ), + provider: "probe", + nativeSessionId: "probe-session", + sessionState: "created", + instructionChannel: "probe", + instructionReconciliation: "installed", + identityProvenance: "provider-returned", + instructionsDigest: "0".repeat(64), + instructions: request.instructions, + cwd: request.cwd, + additionalDirectories: [...request.additionalDirectories], + permissionMode: request.permissionMode, + launcher: "probe", + failure: { + class: "unsupported-capability", + message: "this suite launches nothing", + }, + }); + }, + }, + { at: "min" }, + ); + }); + // The terminal a native launch reserves before it is routed. Reserving is + // what `xmd run` installs a real launcher for; a suite installs one that + // owns nothing, so the launch reaches the surface below rather than + // failing on a host with no terminal. + yield* NativeLauncher.around( + { + // deno-lint-ignore require-yield + *reserve(): Operation {}, + // deno-lint-ignore require-yield + *flush(): Operation {}, + }, + { at: "min" }, + ); + }; + + // One launch inside a managed Worktree, and one in the ambient checkout, in + // the same document — so the two placements are decided by where each + // element was written and by nothing else. + const bound = yield* runOrdinary( + [ + '', + ``, + // The provider settles each launch as a refusal, so the region that + // prints one is what lets the second launch happen at all. What is + // under test is where each was placed, not whether a UI started. + "", + "", + '', + "INSIDE", + "", + "", + '', + "OUTSIDE", + "", + "", + "", + "", + "{w}", + ].join("\n"), + { root: ambient.root, cwd: ambient.checkout, agent: capture }, + ); + const worktree = String(bound).trim().split("\n").at(-1) ?? ""; + expect(yield* exists(worktree)).toBe(true); + + // Both launches were routed, and each received the directory it was + // written in. + expect(routed).toHaveLength(2); + expect(routed[0]?.cwd).toBe(worktree); + expect(routed[1]?.cwd).toBe(ambient.checkout); + expect(routed[0]?.session).toBe("implementer"); + expect(routed[1]?.session).toBe("implementer"); + + // The same agent and the same session name in the two places are two + // sessions, because the placement differs. + const agent = "codex"; + const inWorktree = deriveSessionKey(agent, routed[0]?.cwd ?? "", "implementer"); + const inAmbient = deriveSessionKey(agent, routed[1]?.cwd ?? "", "implementer"); expect(inWorktree).not.toBe(inAmbient); - // The ambient checkout's own walk is unaffected, and reaches its own root. - const ambientCandidates = yield* sessionCandidates("codex", ambient.checkout); - expect(ambientCandidates.map((candidate) => candidate.cwd)).toEqual([ambient.checkout]); + // Supporting evidence for *why* the placement stops at the worktree: `.git` + // there is a file, and the candidate walk is bounded by it. + expect(yield* readTextFile(`${worktree}/.git`)).toContain("gitdir:"); + const candidates = yield* sessionCandidates(agent, worktree); + expect(candidates.map((candidate: { cwd: string }) => candidate.cwd)).toEqual([worktree]); + const ambientCandidates = yield* sessionCandidates(agent, ambient.checkout); + expect(ambientCandidates.map((candidate: { cwd: string }) => candidate.cwd)).toEqual([ + ambient.checkout, + ]); }); }); @@ -158,7 +282,11 @@ describe("ORC18 — the journal is diagnostic", () => { ].join("\n"); yield* runOrdinary(document, { root: first.root, cwd: first.checkout }); - yield* runOrdinary(document, { root: second.root, cwd: second.checkout, journal: trace }); + yield* runOrdinary(document, { + root: second.root, + cwd: second.checkout, + journal: trace, + }); // One live mutation per invocation, either way: each repository has exactly // one commit on the branch beyond the one it started with. @@ -180,7 +308,11 @@ describe("ORC18 — the journal is diagnostic", () => { // performs its own work against its own repository — the trace neither // restores the earlier commit nor stands in for one. const third = yield* useAmbient(); - yield* runOrdinary(document, { root: third.root, cwd: third.checkout, journal: trace }); + yield* runOrdinary(document, { + root: third.root, + cwd: third.checkout, + journal: trace, + }); expect(git(["log", "-1", "--pretty=%s", "traced"], third.checkout, third.home)).toBe("Traced"); expect( git(["log", "--oneline", "traced"], third.checkout, third.home).split("\n"), @@ -242,52 +374,312 @@ describe("ORC15 — a trace is not evidence", () => { }); describe("ORC19 — a nested run profile", () => { - it("gives each execution a provider of its own, with no shared leases", function* () { - const ambient = yield* useAmbient(); + /** + * The claims here are about a real `` child, so they + * are asked of a real `xmd run` — a subprocess, launched the way a person + * launches one. + * + * ## Why a subprocess, and why from nowhere + * + * A child is a root execution in a scope that does not descend from the + * document's, so it inherits no `API.Env` handler and its working directory + * is whatever the host installs for it. When that propagation breaks, the + * child falls back to the *process* directory — and an in-process suite's + * process directory is this repository. A regression would then discover this + * checkout as its ambient repository and operate on it: branches, worktrees + * and commits in the tree the suite is running from. + * + * So the process directory is a temporary directory that is not a Git + * checkout at all. Every repository, every managed root and every document is + * under a fixture-owned temporary directory, and `HOME` is one too — which is + * what moves `~/.xmd/repositories` out of the way, since a run takes its + * managed root from there and no option names another. A break cannot reach a + * shared checkout because, from where these processes stand, there is no + * checkout to reach: the child refuses for want of a repository. + * + * That refusal is the last test below, and it is what keeps the first one + * honest. Without it, "the child worked" would be equally well explained by + * the child having found a repository some other way. + * + * ## Why `` rather than `xmd test` + * + * Two of these claims are about what a *parent* holds while its child runs — + * publication evidence, and a lease. `xmd test` installs no repository + * provider for its own document, by design: only its children get one. So the + * parent here is an ordinary `xmd run`, which has a provider of its own, and + * `` turns on the harness for the region containing the children. + */ + interface Nested { + readonly checkout: string; + readonly home: string; + /** Where a run of this fixture keeps its managed checkouts. */ + readonly managed: string; + /** The process directory every run below is launched from. */ + readonly outside: string; + readonly documents: string; + } - // Two executions in sequence, each constructing its own provider against - // the same managed root and the same slot. The second is only possible if - // the first released — which is what a provider per execution means. - const document = `\n\n{w}`; - const parent = String( - yield* runOrdinary(document, { root: ambient.root, cwd: ambient.checkout }), - ).trim(); - const child = String( - yield* runOrdinary(document, { root: ambient.root, cwd: ambient.checkout }), - ).trim(); - expect(child).toBe(parent); - - // And a provider constructed inside another execution's scope holds its own - // evidence: the inner one has published nothing, so its `` is - // refused even though the outer one is standing in the same checkout. - const failure = yield* raisedValue( - scoped(function* () { - yield* API.Env.around( - { - // deno-lint-ignore require-yield - *cwd(): Operation { - return ambient.checkout; - }, - }, - { at: "min" }, - ); - yield* useHostFiles(); - yield* useCompositionComponents(); - yield* useRunComposition({ root: ambient.root, cwd: ambient.checkout }); - // A second, nested provider — exactly what an isolated `host="run"` - // child constructs from the same installer. - return yield* scoped(function* () { - yield* useRunComposition({ root: ambient.root, cwd: ambient.checkout }); - return yield* collect( - yield* execute({ - ...inlineSource(``), - stream: new InMemoryStream(), - }), - ); - }); - }), + /** + * A repository to work in, a home to be nobody in, and a directory to stand + * in that is neither. + */ + function* useNested(locator?: string): Operation { + const ambient = yield* useAmbient(locator); + // An ordinary run commits as the invoking user and refuses when the host + // cannot say who that is, so the identity a child would use is configured + // here — in this fixture's `HOME`, never the developer's. + yield* writeTextFile( + join(ambient.home, ".gitconfig"), + ["[user]", "\tname = Nested Fixture", "\temail = nested@example.invalid", ""].join("\n"), + ); + return { + checkout: ambient.checkout, + home: ambient.home, + // Where `denoRunRepositories` puts them when nothing names a root, which + // is every `xmd run`. Fixture-owned because `HOME` is. + managed: join(ambient.home, ".xmd", "repositories"), + outside: yield* until(realpath(yield* useTempDirectory("xmd-orc-outside-"))), + documents: yield* until(realpath(yield* useTempDirectory("xmd-orc-documents-"))), + }; + } + + /** One `xmd run` of `source`, from a directory that is not a repository. */ + function* runNested( + source: string, + fixture: Nested, + expected: "passes" | "fails" = "passes", + ): Operation { + const document = join(fixture.documents, "nested.md"); + yield* writeTextFile(document, source); + const run = yield* runCli(["run", document], { + // The whole point: nothing about where this process stands names a + // repository, so only what the document says can put a child in one. + cwd: fixture.outside, + env: { HOME: fixture.home }, + timeout: 180_000, + }).join(); + const reported = `${run.stdout}\n${run.stderr}`; + const passed = run.code === 0; + if (passed !== (expected === "passes")) { + throw new Error(`xmd run exited ${run.code}, expected to ${expected}:\n${reported}`); + } + return reported; + } + + /** A child document, as one escaped `source` attribute value. */ + function child(source: string): string { + return JSON.stringify(source); + } + + /** The `` child, which reaches its repository ambiently. */ + const AMBIENT_WORKTREE = child('\n\n{w}\n'); + + it("stands the child where the document is, not where the process is", function* () { + const fixture = yield* useNested(); + + // The `` is the only thing that puts anything in a repository. If the + // contextual directory did not reach the child it would stand in + // `fixture.outside` and refuse — which is exactly what the last test here + // shows happens when the `` is absent. + const reported = yield* runNested( + [ + ``, + "", + "", + "", + '', + "", + ``, + '', + "", + // What it bound is a managed checkout under *this* run's root, so the + // child really resolved `` through a provider of its own + // rather than reporting a path it never made. + ``, + "", + "", + "", + "", + "", + "", + "", + "", + ].join("\n"), + fixture, + ); + expect(reported).not.toContain("not inside a Git checkout"); + + // And the worktree is on disk, belonging to the repository the document + // named — observed from outside the run that made it, so this is the state + // the child left rather than a line it printed. + const slots = join(fixture.managed, "worktrees"); + const [repository] = yield* readdir(slots); + const [slot] = yield* readdir(join(slots, repository ?? "")); + const bound = join(slots, repository ?? "", slot ?? "", "checkout"); + expect(yield* readTextFile(join(bound, ".git"))).toContain("gitdir:"); + expect(git(["rev-parse", "--abbrev-ref", "HEAD"], bound, fixture.home)).toBe("child"); + // It is a linked worktree of the ambient checkout the `` named, which + // is the whole claim: the child discovered its repository from the + // directory the *document* was standing in. + expect( + git(["rev-parse", "--path-format=absolute", "--git-common-dir"], bound, fixture.home), + ).toBe(join(fixture.checkout, ".git")); + }); + + it("does not let one child's Push authorize its sibling", function* () { + const remote = yield* useRemote(); + const fixture = yield* useNested(remote); + + // Both sides are children, and both reach the *same* repository — the + // ambient one, which they discover from the `` this `` is + // written in. + // + // Two things force that shape, and both are worth stating because they + // bound what this proves. A managed checkout stays leased for the whole run + // of whoever touched it, so a parent and a child cannot share one: the + // child would be refused for want of the slot and never reach + // ``. And a run discovers its ambient repository once, when + // its provider is installed, from the directory the *process* stands in — + // which here is deliberately not a repository at all. So the parent has no + // repository of its own to publish from, and the publishing side has to be + // a child too. + // + // What that leaves is the same boundary asked of two siblings: each + // execution gets a provider of its own, so evidence one earns does not + // authorize the next. The parent's side of the isolation is the lease test + // below, where a parent's hold survives its child's teardown. + const publishes = child( + [ + '', + 'first', + '', + '', + "", + "", + ].join("\n"), + ); + const asks = child('\n'); + + yield* runNested( + [ + ``, + "", + "", + "", + '', + "", + // The first child really publishes, through a provider of its own. + ``, + "", + "", + "", + // The second stands in the same repository, immediately after, and + // holds none of it. If Push evidence outlived one execution this would + // succeed. + ``, + "", + '', + "", + "", + "", + "", + "", + "", + "", + "", + ].join("\n"), + fixture, ); - expect(String(failure)).toMatch(/no usable origin|holds no successful result/); + + // The first child's publication really happened, so what the sibling + // lacked is evidence rather than a branch: the remote carries the commit, + // observed from outside the run that made it. + expect(git(["rev-parse", "pushed-by-first"], remote, fixture.home)).not.toBe(""); + }); + + it("gives a child its own lease owner, and keeps the parent's when it ends", function* () { + const remote = yield* useRemote(); + const fixture = yield* useNested(remote); + + const selection = ``; + // The same repository and the same worktree name, so parent and child ask + // the operating system for one slot. + const asks = child( + `${selection}\n\n\n`, + ); + + yield* runNested( + [ + selection, + "", + // The parent takes the lease on `shared` and holds it for its whole + // run. + '', + "", + "", + "", + '', + "", + // A provider sharing the parent's held set would answer out of it and + // succeed without asking the operating system anything; one with an + // owner of its own asks, and is refused because the parent is still + // holding it. + ``, + "", + '', + "", + "", + // A second child, after the first has torn down. The parent's lease + // survived that teardown, so this one is refused for the same reason + // rather than finding the slot free. + ``, + "", + '', + "", + "", + "", + "", + "", + "", + "", + "", + ].join("\n"), + fixture, + ); + }); + + it("refuses outside a repository when nothing places the child in one", function* () { + const fixture = yield* useNested(); + + // The same child as the first test, with the `` removed and nothing + // else changed. This is what a break in contextual-directory propagation + // looks like from the child's side — and it is a refusal, in a temporary + // directory, rather than work done in whatever checkout the process + // happened to be launched from. + yield* runNested( + [ + "", + "", + '', + "", + ``, + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + fixture, + ); + + // Nothing was checked out for it. The managed root itself exists — every + // run creates one before a document expands — so what says the child did no + // work is that it never reached a repository to make a slot under. + expect(yield* exists(fixture.managed)).toBe(true); + expect(yield* exists(join(fixture.managed, "worktrees"))).toBe(false); }); }); diff --git a/packages/cli/tests/testing-execution-host.test.ts b/packages/cli/tests/testing-execution-host.test.ts index 4a5d4ad1d..34571d81b 100644 --- a/packages/cli/tests/testing-execution-host.test.ts +++ b/packages/cli/tests/testing-execution-host.test.ts @@ -520,6 +520,10 @@ describe("deterministic dependencies declared for a nested run", () => { configuration: [{ kind: "test-agent", defaultAgent: "test", scenarios: [] }], }, run: undefined, + // Unobservable here: the refusal is reached before the child stands + // anywhere, and `tmpdir()` is a directory this assertion cannot + // depend on having any particular contents. + cwd: tmpdir(), // deno-lint-ignore require-yield *chunk(): Operation {}, }); diff --git a/packages/testing/package.json b/packages/testing/package.json index 1c87b1d7a..0f198fff2 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -12,6 +12,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0" } } diff --git a/packages/testing/src/execution-harness.ts b/packages/testing/src/execution-harness.ts index 2ac135ec9..7c915e7f6 100644 --- a/packages/testing/src/execution-harness.ts +++ b/packages/testing/src/execution-harness.ts @@ -85,6 +85,7 @@ import type { SourcePosition, } from "@executablemd/core"; import { DeclarationScan } from "@executablemd/core/host"; +import { cwd } from "@executablemd/runtime"; import type { AnswerConfiguration, AnswersPlacement, @@ -939,9 +940,19 @@ function* runChild( // Spent only once the chain has agreed there is a child to run, and never // twice: two nested executions are two authorizations. grant.spend(); + // Read here, on the last line that is still inside this invocation. The + // isolated scope below does not descend from the document's, so this is the + // only place a `` or `` around the `` is still + // observable — one statement later it is the process directory. + const directory = yield* cwd(); return yield* inIsolation(function* (childScope) { return yield* childScope.run(() => - provider.runChild({ request: settled, run: run?.scope, chunk: channel.chunk }), + provider.runChild({ + request: settled, + run: run?.scope, + chunk: channel.chunk, + cwd: directory, + }), ); }); } diff --git a/packages/testing/src/execution-host.ts b/packages/testing/src/execution-host.ts index 234b66d39..087f803c9 100644 --- a/packages/testing/src/execution-host.ts +++ b/packages/testing/src/execution-host.ts @@ -234,6 +234,27 @@ export interface WorkflowRunScope { export interface ChildInvocation { /** The profile the terminal recorded. */ readonly request: HostProfileRequest; + /** + * The contextual working directory the `` invocation was standing + * in, captured before the child's isolated scope existed. + * + * A child is a root execution in a scope that does not descend from the + * document's, so it inherits no `API.Env` handler and `cwd()` would fall back + * to the *process* directory. That silently disagrees with the document: a + * `` or `` around an `` would scope every + * component in it except the child, and a run provider discovering its + * ambient repository would find the one the process happens to be standing + * in — a shared checkout nobody addressed. + * + * Deliberately here and not on {@link HostProfileRequest}. The profile is + * what middleware reads, refuses on, and delegates, and a handler that could + * see this could also swap it: the directory a child resolves its root and + * its repository in would become something composed policy chose rather than + * something the document did. So it travels on the private invocation the + * terminal hands the trusted provider, where the same rule already keeps + * `run` and `chunk`. + */ + readonly cwd: string; /** The isolated workflow run this child belongs to, under the workflow profile. */ readonly run: WorkflowRunScope | undefined; /** diff --git a/packages/workflow/tests/run-composition.test.ts b/packages/workflow/tests/run-composition.test.ts index 5dd7cb7e1..9035b9b9f 100644 --- a/packages/workflow/tests/run-composition.test.ts +++ b/packages/workflow/tests/run-composition.test.ts @@ -353,10 +353,11 @@ describe("ORC5 — origin is not local authority", () => { const checkout = yield* useHostCheckout(remote.locator); const counting = countingOrdinaryHost(); - yield* runOrdinaryDocument( - [``, ``].join("\n"), - { root, cwd: checkout.root, host: counting.host }, - ); + yield* runOrdinaryDocument([``, ``].join("\n"), { + root, + cwd: checkout.root, + host: counting.host, + }); expect(counting.counters.sessions).toEqual([remote.locator]); expect(subcommands(counting.counters)).toContain("ls-remote"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140cced90..937eec5a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/runtime': + specifier: workspace:* + version: link:../runtime effection: specifier: 4.1.0 version: 4.1.0 diff --git a/specs/testing-spec.md b/specs/testing-spec.md index ae4ddff8f..016549b23 100644 --- a/specs/testing-spec.md +++ b/specs/testing-spec.md @@ -305,6 +305,14 @@ arbitrary directory is addressable without being a component. A `source` is markdown supplied directly and follows the production `run -e` path: it reports the `` identity and writes no file. `props` are the child root's props. +A child runs in the working directory the `` was written in, not the +one the process was launched from. A `` or `` around an +`` therefore scopes the child as it scopes everything else in it: a +relative `target` resolves there, and a host profile that discovers an ambient +repository discovers it from there. The child's scope does not descend from the +document's, so this is the trusted host installing what the invocation was +standing in rather than the child inheriting it. + `host="workflow"`, and the `` scope it requires, are specified in issue #454 and are not built: a host that provides no workflow profile refuses them, naming that. diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 189596b60..9fce0b5b4 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -69,7 +69,11 @@ components a workflow run has, over the caller's own filesystem: Git directory and its selected checkout is the canonical checkout root, so a command started in a linked worktree names the same repository as one started in the primary checkout while Git operations act on the worktree. A document - that never asks for a repository runs unchanged outside one. + that never asks for a repository runs unchanged outside one. Each execution + discovers its own: a nested `` builds a provider of its + own and discovers from the working directory that `` was written + in, so a child under a `` is in that directory's repository rather than + in the one the outer command was started in. - `` selects a **managed checkout** beneath `~/.xmd/repositories`, and `` selects a linked one of whichever Repository is in scope. Both survive every execution and are held for one From 9b34403f43870df8cc9245adcd406ccc33d4c527 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:28:40 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85=20Close=20ORC19's=20parent/child?= =?UTF-8?q?=20evidence=20boundary=20in=20both=20directions=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit claimed parent/child Push-evidence isolation could not be asked in one process. That was wrong, and the claim is removed. Only reselecting the same *managed* slot conflicts with the parent's lease; caller-owned ambient Git carries no lease at all, so two executions can use one checkout in sequence. That makes ambient Git the correct fixture for this criterion, not a barrier to it. A separate subprocess now stands in a fixture-owned temporary Git checkout and asks the boundary both ways, over the same checkout, origin, branch and head: - the parent performs a real Push, and a `host="run"` child immediately asks for a `` from that same head, and is refused; and - a child performs the Push, tears down, and the parent asks from the head its own child just published, and is refused. Both refusals are for missing successful Push evidence and both land before any Git host is reached. The implementation states that itself — "Nothing was observed at the Git host, and no pull request was created" — so the test asserts that sentence rather than inferring the ordering. A second, independent reading corroborates it: the origin here is a local path, so an execution that had got past the evidence gate would have failed with "no usable origin" instead, and that sentence is asserted absent. Standing in a repository keeps the fixture escape-safe on its own terms: a regression in contextual-directory propagation can reach only the disposable clone this fixture made. The non-Git-cwd case is kept unchanged and carries the other half of the argument — that the propagation is real rather than incidentally agreeing with where the process stands. The contextual-cwd, sibling-evidence and parent-lease cases are kept as accepted. --- .../cli/tests/run-composition-deno.test.ts | 151 ++++++++++++++++-- 1 file changed, 136 insertions(+), 15 deletions(-) diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index f0d52ba92..e58324d13 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -532,22 +532,14 @@ describe("ORC19 — a nested run profile", () => { // Both sides are children, and both reach the *same* repository — the // ambient one, which they discover from the `` this `` is - // written in. - // - // Two things force that shape, and both are worth stating because they - // bound what this proves. A managed checkout stays leased for the whole run - // of whoever touched it, so a parent and a child cannot share one: the - // child would be refused for want of the slot and never reach - // ``. And a run discovers its ambient repository once, when - // its provider is installed, from the directory the *process* stands in — - // which here is deliberately not a repository at all. So the parent has no - // repository of its own to publish from, and the publishing side has to be - // a child too. + // written in. Sequential sharing is what makes that work: caller-owned + // ambient Git is nobody's managed slot, so it carries no lease, and one + // execution can hand it to the next. // - // What that leaves is the same boundary asked of two siblings: each - // execution gets a provider of its own, so evidence one earns does not - // authorize the next. The parent's side of the isolation is the lease test - // below, where a parent's hold survives its child's teardown. + // The claim is that each execution gets a provider of its own, so evidence + // one earns does not authorize the next. The parent/child direction of the + // same boundary is asked separately, from a process standing in a + // repository. const publishes = child( [ '', @@ -648,6 +640,135 @@ describe("ORC19 — a nested run profile", () => { ); }); + /** + * The same fixture, with the process standing *inside* a disposable checkout. + * + * The escape argument is different here and still holds: a regression in + * contextual-directory propagation reaches the process directory, and the + * process directory is a temporary clone this fixture made and owns. There is + * nothing shared to reach. The non-Git case above keeps the other half of the + * argument — that the propagation is real rather than incidentally agreeing + * with where the process happens to stand. + */ + function* useInRepository(): Operation { + const remote = yield* useRemote(); + const fixture = yield* useNested(remote); + // The process stands in the caller's own checkout, which is what an + // ordinary `xmd run` stands in. Nothing here is a managed slot, so nothing + // here is leased, and two executions can use it one after the other. + return { ...fixture, remote, outside: fixture.checkout }; + } + + /** One branch, published once, asked about from both directions. */ + const PUBLISHES = [ + '', + 'published', + '', + '', + "", + ]; + + /** + * `` reaches its Git host through `source.open()`, and the + * evidence gate runs ahead of it. The refusal says so itself — "Nothing was + * observed at the Git host, and no pull request was created" — so that + * sentence is the claim rather than an inference from it. + * + * The second assertion is the corroborating one: a local file origin is not + * a Git host, so an execution that had got past the gate would have failed + * with "no usable origin" instead. Its absence and the sentence's presence + * are two independent readings of the same ordering. + */ + const REFUSED_BEFORE_HOST_ACCESS = "Nothing was observed at the Git host"; + + function refusedBeforeHostAccess(binding: string): string[] { + return [ + ``, + ``, + ``, + ``, + ]; + } + + it("does not let a parent's Push authorize its child", function* () { + const fixture = yield* useInRepository(); + + // The parent publishes in the ambient checkout the process is standing in; + // the child, immediately after, asks for a pull request from that same + // checkout, origin, branch and head. Everything about the repository is + // identical between them. The only thing that differs is which provider + // holds the Push evidence. + yield* runNested( + [ + ...PUBLISHES, + "", + "", + "", + '', + "", + `\n')}} as="opened">`, + ...refusedBeforeHostAccess("opened"), + "", + "", + "", + "", + "", + "", + ].join("\n"), + fixture, + ); + + // The parent's publication was real, so what the child lacked is evidence. + expect(git(["rev-parse", "shared-head"], fixture.remote, fixture.home)).toBe( + git(["rev-parse", "HEAD"], fixture.checkout, fixture.home), + ); + }); + + it("does not let a child's Push authorize its parent", function* () { + const fixture = yield* useInRepository(); + + // The other direction, in the same checkout. The child publishes and tears + // down; the parent — whose provider has been installed the whole time — + // then asks for a pull request from the head its own child just pushed. + // + // The parent's refusal ends the document, which is what a refusal at + // document level is supposed to do, so this run is expected to fail and the + // refusal is read out of what it reported. The child's test block runs + // first and is reported before it. + const reported = yield* runNested( + [ + "", + "", + '', + "", + ``, + "", + "", + "", + "", + "", + "", + "", + '', + "", + ].join("\n"), + fixture, + "fails", + ); + + // The parent is refused for want of evidence, and before its Git host is + // reached. + expect(reported).toContain("holds no successful"); + expect(reported).toContain(REFUSED_BEFORE_HOST_ACCESS); + expect(reported).not.toContain("no usable origin"); + // The child's assertions passed, so the publication it was refused credit + // for really happened. + expect(reported).not.toContain("❌"); + expect(git(["rev-parse", "shared-head"], fixture.remote, fixture.home)).toBe( + git(["rev-parse", "HEAD"], fixture.checkout, fixture.home), + ); + }); + it("refuses outside a repository when nothing places the child in one", function* () { const fixture = yield* useNested();