From debf132644228f47a1c25c784dca1bfc37db9e90 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:47:27 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Give=20repository=20co?= =?UTF-8?q?mposition=20a=20profile-neutral=20selection=20seam=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thirteen repository components reached their provider through values that carried run-scoped authority, so only a retained workflow run could implement them. This introduces the seam a second profile can implement without changing what the first one does. `RepositorySelection` is the seam: plain structural composition data naming the repository one invocation acts on — an opaque provider-minted identifier, the display name, the credential-free identity, and the selected checkout path. It carries no credential, handle, lock, database, run ID or authority. A document may bind one, render one, hand one to a child, and build one that looks exactly like it; naming a target and being allowed to reach it are now different things. The other half of that contract is `selectionRegistry`, where a provider keeps what it actually holds. Every operation asks the registry what a selection names before touching Git or a service, and the comparison is total rather than 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 the confusion a selection must not be able to cause. Above the transport middleware, Repository, Git, Issue and pull-request operations become profile-level APIs, and the thirteen registrations become one declaration array so a single vocabulary is declared once and installed wherever a profile wants it. The retained workflow provider is adapted to these seams and its behavior is unchanged — records, replay, transactions, forks, Push evidence, Issue and pull-request effects, and provider call counts all stay as they were. No ordinary live provider is exposed: `deno.ts` gains no export here, because there is nothing yet to export. Evidence: ORC20 — 59 passed (390 steps) across the retained-workflow suites — and a new selection-refusal suite covering a forged selection, an edited name, an edited checkout path, every identity member edited individually, and one registry refusing another's selection. --- architecture.md | 14 +- packages/workflow/deno.ts | 2 +- 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 | 28 +- packages/workflow/src/deno/composition/git.ts | 45 ++- .../workflow/src/deno/composition/host.ts | 53 ++- .../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 +- 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 +- .../tests/selection-authentication.test.ts | 122 +++++++ .../workflow/tests/support/issue-scenario.ts | 5 + .../workflow/tests/support/pull-requests.ts | 19 +- 51 files changed, 1999 insertions(+), 726 deletions(-) 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/selections.ts create mode 100644 packages/workflow/src/issue/operations.ts create mode 100644 packages/workflow/tests/selection-authentication.test.ts diff --git a/architecture.md b/architecture.md index 13eb4d954..eaf3086c2 100644 --- a/architecture.md +++ b/architecture.md @@ -32,7 +32,8 @@ 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 | | 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 | @@ -2379,6 +2380,16 @@ 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, +is everything a selection is *not*: the canonical Git identity each selection +resolves to, the retained rows behind it, and the credentials and locators used +to reach a service. 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` — @@ -3759,6 +3770,7 @@ 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) | +| 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` — declared once and consumed by the workflow attachment, so one vocabulary is described and resolved wherever it is installed. 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 | 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 | diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 1e356dbc8..8a6f92bd5 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -140,4 +140,4 @@ export type { SuspensionController, SuspensionControllerOptions, SuspensionNotice, -} from "./src/deno/suspension.ts"; +} from "./src/deno/suspension.ts"; \ No newline at end of file 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..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 { @@ -116,7 +116,7 @@ function* describeCommit(admitted: GitCommitRequest): 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 @@ function* performCommit( workingDirectory, message, committedAt, + ...(identity === undefined ? {} : { identity }), }); const commit = yield* resolveCommit(git, directory, "HEAD"); @@ -180,6 +189,19 @@ 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/composition/provider.ts b/packages/workflow/src/deno/composition/provider.ts index 404d1de25..c105839c9 100644 --- a/packages/workflow/src/deno/composition/provider.ts +++ b/packages/workflow/src/deno/composition/provider.ts @@ -1,18 +1,20 @@ /** - * The Deno-local Repository composition provider. + * The Deno workflow Repository composition provider. * - * This is the wiring: it installs one Api whose four 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. + * 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 { + /** + * 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/selection-authentication.test.ts b/packages/workflow/tests/selection-authentication.test.ts new file mode 100644 index 000000000..6d7a2ae5e --- /dev/null +++ b/packages/workflow/tests/selection-authentication.test.ts @@ -0,0 +1,122 @@ +/** + * What a Repository selection can and cannot do (#643). + * + * A selection 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 the seam this suite guards is that naming a target + * and being allowed to reach it are different things. The registry keeps the + * authority; the value keeps only the name. + * + * Every case here is a *refusal* that must happen. A registry that answered a + * forged selection, or one whose members were edited after it was minted, would + * act on the provider's own record while the caller believed it named something + * else — which is the confusion a selection exists not to be able to cause. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { selectionRegistry } from "../src/deno/selections.ts"; +import { + REPOSITORY_IDENTITY_MEMBERS, + repositorySelection, + type RepositoryIdentity, +} from "../src/composition/selection.ts"; + +const IDENTITY: RepositoryIdentity = Object.freeze({ + name: "project", + locatorFingerprint: "fingerprint", + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", +}); + +/** A registry holding one minted selection, and what it was minted for. */ +function held() { + const registry = selectionRegistry(); + const selection = registry.mint("key", "project", IDENTITY, "/checkout", "held value"); + return { registry, selection }; +} + +/** What the registry throws when it does not recognize a selection. */ +function refusal(): Error { + return new Error("refused"); +} + +describe("a Repository selection names a target and carries no authority", () => { + it("answers a selection the registry itself minted", function* () { + const { registry, selection } = held(); + expect(registry.authenticate(selection, refusal)).toBe("held value"); + }); + + it("refuses one it never minted, however well-formed", function* () { + const { registry } = held(); + // Structurally a selection in every respect. Built rather than handed out, + // which is the only difference and the whole of the difference. + const forged = repositorySelection( + "00000000-0000-4000-8000-000000000000", + "project", + IDENTITY, + "/checkout", + ); + expect(() => registry.authenticate(forged, refusal)).toThrow("refused"); + }); + + it("refuses one whose name was edited after it was minted", function* () { + const { registry, selection } = held(); + const edited = repositorySelection( + selection.selection, + "other", + selection.identity, + selection.checkoutPath, + ); + expect(() => registry.authenticate(edited, refusal)).toThrow("refused"); + }); + + it("refuses one whose checkout path was edited after it was minted", function* () { + const { registry, selection } = held(); + const edited = repositorySelection( + selection.selection, + selection.name, + selection.identity, + "/elsewhere", + ); + expect(() => registry.authenticate(edited, refusal)).toThrow("refused"); + }); + + it("refuses when any single member of the identity was edited", function* () { + // The comparison is total rather than a spot check, so this walks every + // member: a registry that compared only the fingerprint would pass three of + // these four and still be wrong. + const { registry, selection } = held(); + const edits: RepositoryIdentity[] = [ + { ...IDENTITY, name: "another" }, + { ...IDENTITY, locatorFingerprint: "another" }, + { ...IDENTITY, requestedBase: "v1" }, + { ...IDENTITY, creationCommit: "1".repeat(40) }, + { ...IDENTITY, primaryBranch: "trunk" }, + { ...IDENTITY, objectFormat: "sha256" }, + ]; + // One per member of REPOSITORY_IDENTITY_MEMBERS, so a registry that + // compared any proper subset would pass some of these and still be wrong. + expect(edits).toHaveLength(REPOSITORY_IDENTITY_MEMBERS.length); + for (const identity of edits) { + const edited = repositorySelection( + selection.selection, + selection.name, + identity, + selection.checkoutPath, + ); + expect(() => registry.authenticate(edited, refusal)).toThrow("refused"); + } + }); + + it("does not let one registry's selection authorize another's", function* () { + // Two providers in one process, or a parent and an isolated child: a + // selection is only ever good at the registry that minted it. + const first = held(); + const second = selectionRegistry(); + second.mint("key", "project", IDENTITY, "/checkout", "the other value"); + expect(() => second.authenticate(first.selection, refusal)).toThrow("refused"); + }); +}); 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", }); From 5181ecd3a47a7c19c416e7c6dfe807e4cba3483e Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:49:52 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20Describe=20only=20the=20cons?= =?UTF-8?q?umer=20this=20stack=20level=20has=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two intermediate-state inaccuracies, both introduced by how PR 1 was cut rather than by the seam itself. `installation.ts` described three consumers of the declaration array. Two of them — `useRunProfileRegistry()` and `installDocumentComponents()` — do not exist at this level; they arrive when the ordinary run profile is exposed. The comment now names the one consumer there is, the workflow attachment, and keeps the part that is true at every level: registering the array installs no provider, discovers no repository, acquires no lock and reaches no network. `deno.ts` lost its terminating newline when the ordinary provider export was held back for the branch that introduces the provider. This branch adds no export there, so the file is byte-identical to base and no longer appears in the diff at all. Comment and newline only; no behavior changes. --- packages/workflow/deno.ts | 2 +- packages/workflow/src/composition/installation.ts | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 8a6f92bd5..1e356dbc8 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -140,4 +140,4 @@ export type { SuspensionController, SuspensionControllerOptions, SuspensionNotice, -} from "./src/deno/suspension.ts"; \ No newline at end of file +} from "./src/deno/suspension.ts"; diff --git a/packages/workflow/src/composition/installation.ts b/packages/workflow/src/composition/installation.ts index 7701055dd..1d29d9e95 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/workflow/src/composition/installation.ts @@ -7,14 +7,12 @@ * 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. + * One array rather than a list per caller, because two descriptions of one + * vocabulary would drift. `useCompositionComponents()` registers it inside a + * workflow attachment. 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. From 2985a320ae25414f29eb436ef6c4ab79eaaca441 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:30:26 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Keep=20a=20host=20name=20out?= =?UTF-8?q?=20of=20the=20shared=20composition=20surface=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DLC13` scans the shared coordination modules for host-specific vocabulary, because those modules state in their own comments that they name no host — and a comment saying so is not the same as it being true. ``'s description illustrated what a tracker container is by naming two of them, and one of those names is on the scanned list. The illustration was the only thing that needed a name, so it loses them: a tracker names one repository's issues or one project, and which product provides either is the installed adapter's business rather than this array's. Caught by CI rather than by the focused evidence: `workspace-effect.test.ts` is not an ORC row, so nothing in the frozen matrix ran it. --- packages/workflow/src/composition/installation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/composition/installation.ts b/packages/workflow/src/composition/installation.ts index 1d29d9e95..c0e55854b 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/workflow/src/composition/installation.ts @@ -243,7 +243,7 @@ export const COMPOSITION_REGISTRATIONS: readonly ComponentRegistration[] = [ 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` " + + "are created in — one repository's issues, one 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, From 32d7f7554c0519636b94b9428c548b7dc776d9a1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:05:34 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Restore=20the=20profil?= =?UTF-8?q?e-neutral=20pull-request=20read=20boundary=20(#643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rebase onto 0.11.0 kept the host-name correction and dropped the accepted pull-request work that followed it. This ports that work back as one change rather than as the three commits it was reached through: the middle one put workflow durability inside the shared adapter and the next corrected it, and replaying a design that was already superseded would put a state in this branch that nothing should merge. What returns is the accepted end state. `PullRequestReadExecution` is the profile-neutral boundary an adapter reaches once it has admitted a read; its base performs the transport afresh, which is the ordinary profile's whole lifecycle, and the workflow host installs the policy that makes an admitted read durable. Matching, host-ceiling admission and target validation stay the adapter's own and happen before that boundary, so none of them can leave a `pull_request_read` behind. New main touched none of the five paths, so each is byte-identical to its version at the accepted head — verified by object id rather than by reading. --- .../pull-request-read-execution.ts | 77 ++++++++ .../composition/pull-request-operations.ts | 96 +++++++--- .../deno/composition/pull-request-reads.ts | 85 ++++++--- packages/workflow/src/deno/workspace/host.ts | 8 +- .../workflow/tests/pull-request-read.test.ts | 173 ++++++++++++++++++ 5 files changed, 384 insertions(+), 55 deletions(-) create mode 100644 packages/workflow/src/composition/pull-request-read-execution.ts diff --git a/packages/workflow/src/composition/pull-request-read-execution.ts b/packages/workflow/src/composition/pull-request-read-execution.ts new file mode 100644 index 000000000..38b7f4928 --- /dev/null +++ b/packages/workflow/src/composition/pull-request-read-execution.ts @@ -0,0 +1,77 @@ +/** + * What happens to a pull-request read *after* the transport has admitted it. + * + * The GitHub adapter decides three things that belong to it alone: whether the + * request is one it recognizes, whether the host ceiling authorizes the target, + * and whether the URL names a pull request it can read. None of those is a + * question about lifecycle, and none of them may leave a trace — a target this + * host never authorized is a question the run was not permitted to ask, not a + * read that failed. + * + * Once those three are answered, what to *do* with the admitted read is the + * profile's business and no longer the adapter's. That is this boundary. The + * adapter hands over the admitted read and the operation that would perform it; + * the installed policy decides whether performing it is retained. + * + * The base performs it afresh, which is both the ordinary profile's behaviour + * and the honest default: a host that installed no lifecycle policy still reads + * the collection, it simply keeps nothing. A workflow run composes middleware + * here that wraps the same operation in one durable effect, so a completed one + * replays without a session and a failure after admission is retained as this + * read's own. + * + * The admitted read carries only what identifies the question — no run, no + * expansion, no database. Those are the workflow profile's to add, from context + * it has and the adapter does not, which is what lets one adapter serve both + * profiles without importing either one's authority. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { PullRequestReadKind, PullRequestReadResult } from "./pull-request-read-records.ts"; + +/** The stable name every loaded copy composes through. */ +export const PULL_REQUEST_READ_EXECUTION = + "executablemd.workflow.composition.pull-request-read-execution"; + +/** One read a transport has decided to answer, as its identity alone. */ +export interface AdmittedPullRequestRead { + /** The canonical pull-request URL the transport admitted. */ + readonly url: string; + /** Which of the three collections this read is for. */ + readonly kind: PullRequestReadKind; + /** The explicit discriminator, when the element carried one. */ + readonly provider: string | undefined; +} + +export interface PullRequestReadExecutionApi { + /** + * Perform one admitted read under this profile's lifecycle. + * + * `transport` is the adapter's own work — opening its session and reading the + * collection — and is called at most once. A policy that retains may skip it + * entirely when it already holds the answer. + */ + perform( + admitted: AdmittedPullRequestRead, + transport: () => Operation, + ): Operation; +} + +/** + * Fresh execution, which is the ordinary profile's whole lifecycle. + * + * Unlike the surfaces whose default refuses, this one performs: an adapter that + * has admitted a read has already established that this host authorizes the + * target, and refusing here would make reading depend on a lifecycle policy + * that only one profile installs. + */ +export const PullRequestReadExecution: Api = + createApi(PULL_REQUEST_READ_EXECUTION, { + *perform( + _admitted: AdmittedPullRequestRead, + transport: () => Operation, + ): Operation { + return yield* transport(); + }, + }); diff --git a/packages/workflow/src/deno/composition/pull-request-operations.ts b/packages/workflow/src/deno/composition/pull-request-operations.ts index 58c374d22..346838c93 100644 --- a/packages/workflow/src/deno/composition/pull-request-operations.ts +++ b/packages/workflow/src/deno/composition/pull-request-operations.ts @@ -2,12 +2,27 @@ * 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. + * installs behind it. Both members pass straight through to the transport, and + * what makes each durable lives underneath. + * + * A read becomes one durable effect, but not from here: retaining around the + * whole transport would wrap the adapter's own decisions — whether it matches + * the request, whether the host ceiling authorizes the target, whether the URL + * names something it can read — and a target the host never authorized would + * leave a failed read in the history, a record of a question this run was never + * permitted to ask. + * + * So the durability is installed one layer down, as policy on + * `PullRequestReadExecution`. That surface is profile-neutral and performs + * afresh by default; an adapter reaches it only after it has admitted a read, + * and this middleware is what makes an admitted one durable under a workflow + * run. The adapter itself stays free of WorkflowRun and expansion context, + * which is what lets an ordinary run install the same adapter and keep + * nothing. + * + * An upsert passes through for a different reason: the Git-host reconciliation + * beneath it is already durable and already holds this run's own Push evidence, + * so a second envelope would retain one answer under two identities. * * ## What one read retains * @@ -29,6 +44,7 @@ import type { Operation } from "effection"; import { scoped } from "effection"; import { PullRequestReadError } from "../../composition/errors.ts"; import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; import { PullRequestOperations, type PullRequestReadInvocation, @@ -83,8 +99,20 @@ function* describeRead(request: PullRequestReadRequest): Operation { +/** + * Retain one admitted read, or restore what is already retained. + * + * Reached only through `PullRequestReadExecution`, after a transport has + * matched the request, admitted it against the host ceiling and validated the + * target — so the effect this creates always describes a question this run was + * permitted to ask. Everything from here is durable: a transport or evidence + * failure retains the failed read, and a completed one restores its snapshot + * without opening a session or reaching the network. + */ +function retainDurableRead( + request: PullRequestReadRequest, + perform: () => Operation, +): Operation { const element = ELEMENT[request.kind]; return scoped(function* () { @@ -93,10 +121,7 @@ function retainedRead(request: PullRequestReadRequest): Operation( description, function* (): Operation { - const answered = yield* PullRequestAPI.operations.read(request.url, { - kind: request.kind, - ...(request.provider === null ? {} : { provider: request.provider }), - }); + const answered = yield* perform(); if (answered.kind !== request.kind) { throw new PullRequestReadError( "protocol", @@ -132,27 +157,46 @@ function retainedRead(request: PullRequestReadRequest): Operation { + return PullRequestReadExecution.around( + { + *perform([admitted, transport]): Operation { + const run = yield* getWorkflowRun(); + const expansion = yield* getExpansion(); + return yield* retainDurableRead( + readRequest(admitted.url, admitted.kind, admitted.provider, run.runId, expansion.id), + transport, + ); + }, + }, + { at: "min" }, + ); +} + /** Install the retained pull-request lifecycle for the current scope and below. */ export function useRetainedPullRequestOperations(): Operation { return PullRequestOperations.around( { + // Straight through, so the adapter beneath decides when a read is + // admitted and retains it from there. Wrapping it here would put the + // ceiling and URL refusals inside the effect. *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, - ), - ); + return yield* PullRequestAPI.operations.read(invocation.url, { + kind: invocation.kind, + ...(invocation.provider === null ? {} : { provider: invocation.provider }), + }); }, - // 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. + // Straight through for its own reason: 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, diff --git a/packages/workflow/src/deno/composition/pull-request-reads.ts b/packages/workflow/src/deno/composition/pull-request-reads.ts index 1e60ebd07..04434127f 100644 --- a/packages/workflow/src/deno/composition/pull-request-reads.ts +++ b/packages/workflow/src/deno/composition/pull-request-reads.ts @@ -1,5 +1,5 @@ /** - * GitHub's pull-request middleware: reads by URL, retained. + * GitHub's pull-request middleware: reads by URL. * * Ordinary middleware around `PullRequestAPI`, the way `useGitHubIssues` is * ordinary middleware around `IssueApi`. It looks at the URL, handles the ones @@ -16,11 +16,24 @@ * * ## Transport, and only transport * - * 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. + * Nothing here is retained. What a read *costs* — whether it is performed once + * and kept, or performed afresh every execution — is selected through + * `PullRequestReadExecution`, whose base performs the transport and around + * which a workflow run installs the policy that makes an admitted read durable. + * That is a different surface from `PullRequestOperations`, deliberately: this + * middleware reaches the execution boundary from *inside*, once it has admitted + * a request, while the operations seam sits above the transport entirely and + * cannot see what was admitted. + * + * The order is what that buys. Matching, the host ceiling and target validation + * are this middleware's own decisions and are made before any profile hears + * about the read, so a target this host never authorized leaves no record + * anywhere — not even a failed one. Everything after admission is the profile's. + * + * So there is one job here: recognize the URL, hold it to the ceiling, validate + * the target, then open a session and hand back the normalized evidence when + * the installed policy asks for it. Both profiles install this same middleware + * and neither changes it. */ import type { Operation } from "effection"; @@ -31,6 +44,7 @@ import type { PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; import type { PullRequestReadOptions } from "../../composition/pull-request-api.ts"; import type { RepositoryRecord } from "../../composition/records.ts"; import type { SelectionRegistry } from "../selections.ts"; @@ -217,28 +231,43 @@ export function* useGitHubPullRequestReads(options: GitHubPullRequestsOptions): ); } - // 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; + // Admitted. Everything above decided whether this host may answer at all, + // and none of it is retained: a target outside the ceiling, or one this + // adapter cannot name, leaves no record of a question that was never + // permitted. + // + // What happens from here is the profile's, not this adapter's. Both + // profiles install this same middleware, so it asks rather than decides: + // an ordinary run performs the transport afresh, and a workflow run wraps + // it in one durable effect. Neither a WorkflowRun nor an expansion is + // reachable from here, which is what keeps that true. + return yield* PullRequestReadExecution.operations.perform( + { url, kind: read.kind, provider: read.provider }, + 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, 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; + }, + ); }, }); } diff --git a/packages/workflow/src/deno/workspace/host.ts b/packages/workflow/src/deno/workspace/host.ts index 5d46e666c..09dd50bf4 100644 --- a/packages/workflow/src/deno/workspace/host.ts +++ b/packages/workflow/src/deno/workspace/host.ts @@ -53,7 +53,10 @@ import { workflowSelections, type CompositionProviderOptions, } from "../composition/provider.ts"; -import { useRetainedPullRequestOperations } from "../composition/pull-request-operations.ts"; +import { + useRetainedPullRequestOperations, + useRetainedPullRequestReads, +} 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"; @@ -175,6 +178,9 @@ export function withWorkflowWorkspace( // whichever transport middleware this host installed for them. yield* useRetainedIssueOperations(); yield* useRetainedPullRequestOperations(); + // Durability for an admitted read, installed beside the transport rather + // than above it: the adapter admits, this retains. + yield* useRetainedPullRequestReads(); yield* useCompositionComponents(); // Ordinary middleware, installed the way the Issue adapter is: it owns // the URLs it recognizes and delegates the rest. diff --git a/packages/workflow/tests/pull-request-read.test.ts b/packages/workflow/tests/pull-request-read.test.ts index 0bd85f4a1..d3f39a072 100644 --- a/packages/workflow/tests/pull-request-read.test.ts +++ b/packages/workflow/tests/pull-request-read.test.ts @@ -30,6 +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 { useGitHubPullRequestReads } 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"; @@ -984,6 +985,15 @@ describe("Tier PRR — pull-request evidence", () => { ), ); expect(String(failure)).toContain("has not authorized"); + + // And the run retains nothing for it. A target outside the ceiling is a + // question this host never permitted, so there is no read in the history + // — not a failed one either. Retaining the refusal would put a record of + // an unauthorized target into the journal and make the ceiling look like + // something a read can fail, rather than something asked before one + // exists. PRR22 keeps the other half: once a read is admitted, a failure + // after that point is durable. + expect(yield* reads(database)).toEqual([]); }); // No access session was opened, so no credential was read and nothing was @@ -992,6 +1002,169 @@ describe("Tier PRR — pull-request evidence", () => { expect(host.requests).toEqual([]); }); + // PRR29: the shared adapter is both profiles', so it must work with neither a + // WorkflowRun nor an expansion in scope. Installed outside a run entirely and + // asked through an injected transport, an admitted read performs afresh — + // which is the ordinary profile's whole lifecycle, and the default this + // boundary carries when no policy is installed. + // + // This is the case an adapter coupled to workflow context cannot pass: it + // would reach for a run that is not there. + it("PRR29: an admitted read executes afresh with no WorkflowRun in scope", function* () { + const host = server({ + [REVIEWS]: { body: JSON.stringify([review(1, "APPROVED", "ship it")]) }, + }); + + // No storage, no run, no attachment — just the adapter and the components. + const answers = yield* scoped(function* () { + yield* useGitHubPullRequestReads({ + allowed: [SUBJECT_REPO], + access: gitHubSource(host.access), + }); + const first = yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); + const second = yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); + return [first, second]; + }); + + // Both reads answered, and each really went to the host: performing afresh + // is the point, so two reads are two requests rather than one and a + // restored snapshot. + expect(answers[0]?.kind).toBe("reviews"); + expect(answers[1]?.kind).toBe("reviews"); + expect(host.requests.length).toBeGreaterThanOrEqual(2); + }); + + // PRR29a: and the refusals still precede everything, outside a run as well. + // The ceiling is the adapter's own decision, so it cannot depend on a profile + // being installed. + it("PRR29a: a ceiling refusal outside a run opens no session and sends nothing", function* () { + const sessions: string[] = []; + const host = server({ [REVIEWS]: { body: "[]" } }); + const counted: GitHubSource = { + endpoint: host.access.endpoint, + open(): Operation { + return resource(function* (provide) { + sessions.push("open"); + try { + yield* provide(host.access); + } finally { + sessions.push("close"); + } + }); + }, + }; + + const failure = yield* raised( + scoped(function* () { + yield* useGitHubPullRequestReads({ + allowed: ["https://github.com/octo/other"], + access: counted, + }); + return yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); + }), + ); + + expect(String(failure)).toContain("has not authorized"); + expect(sessions).toEqual([]); + expect(host.requests).toEqual([]); + }); + + // PRR30: a provider the adapter is not, inside a real workflow run, with + // nothing else installed to answer. The adapter delegates rather than + // refusing, the request reaches the surface's own base error, and the run + // retains nothing — matching is a decision about whether this adapter answers + // at all, and a question nobody answered is not a read that failed. + // + // Inside a run, because that is the only place a journal exists: PRR29a makes + // the same point about sessions and requests outside one, and cannot speak + // about Yields. + it("PRR30: an explicit non-match retains nothing and reaches no transport", function* () { + const sessions: string[] = []; + const host = server({ [REVIEWS]: { body: "[]" } }); + const counted: GitHubSource = { + endpoint: host.access.endpoint, + open(): Operation { + return resource(function* (provide) { + sessions.push("open"); + try { + yield* provide(host.access); + } finally { + sessions.push("close"); + } + }); + }, + }; + + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const failure = yield* raised( + runWorkflowDocument( + database, + `\n`, + { + composition: {}, + // The ceiling would admit the target; the discriminator is what + // this adapter does not answer to. + gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted }, + }, + ), + ); + + expect(failure).toBeDefined(); + expect(yield* reads(database)).toEqual([]); + }); + + expect(sessions).toEqual([]); + expect(host.requests).toEqual([]); + }); + + // PRR31: the adapter's own target validation, inside a real workflow run. The + // URL passes the configured ceiling — it is beneath the admitted repository — + // and still names no pull request this adapter can read. GitHub is named + // explicitly, so matching is not what refuses; the shape is. + it("PRR31: an invalid target retains nothing and reaches no transport", function* () { + const sessions: string[] = []; + const host = server({ [REVIEWS]: { body: "[]" } }); + const counted: GitHubSource = { + endpoint: host.access.endpoint, + open(): Operation { + return resource(function* (provide) { + sessions.push("open"); + try { + yield* provide(host.access); + } finally { + sessions.push("close"); + } + }); + }, + }; + + // Inside the ceiling by prefix, and not a pull request. + const notAPullRequest = `${SUBJECT_REPO}/issues/7`; + + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const failure = yield* raised( + runWorkflowDocument( + database, + `\n`, + { + composition: {}, + gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted }, + }, + ), + ); + + expect(String(failure)).toContain("does not name a pull request"); + expect(yield* reads(database)).toEqual([]); + }); + + expect(sessions).toEqual([]); + expect(host.requests).toEqual([]); + }); + it("PRR18a: middleware may refuse, and nothing is read", function* () { const host = server({ [REVIEWS]: { body: JSON.stringify([review(1, "APPROVED", "ship it")]) },