From fca4f8074763eb5d66886623b0c3c1517d85f2ba Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:12:45 -0400 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=9A=A7=20Classify=20one=20entry's?= =?UTF-8?q?=20Push=20and=20pull-request=20history=20together=20(#181,=205/?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers 1 and 2 of delivery 5. Not the feedback commit: F1–F6 have not run. The saved pull-request request gets its closed version-1 codec, named by the SHA-256 of canonical JSON like every other retained entry record. It holds its resolved inputs to the question that was received, so a saved request whose resolved title, body, draft, number or base drifted from what the document authored describes work this entry never asked for. Push's own place codec is shared rather than copied: one encoding of where external work was asked for has one answer. The admission reader classified every failed `git_host_effect` as a publication, which was true while publication was the only external protocol an entry retained through that boundary. A failed envelope names no kind, so with pull requests joining it nothing in the record itself can say which protocol refused. The pairing says it instead: the preceding unconsumed request in the same coroutine, and that request's own expected worker name. A successful outcome must describe its paired request's kind, and a record of a kind neither protocol owns is still passed over without consuming a pairing. Two readers that each rejected the other's failures would each see an orphan where the other saw a pair. A saved pull request is held to one thing publication is not: the entry must already have published the head it names. The last relevant publication decides, so an earlier push of that branch at another commit does not keep authorizing work the entry has published past. The ordered facts come from the admitted snapshot, and each becomes usable only once execution reaches it — a publication standing after the current position is not evidence because a previous session got further than this one has. Evidence: retained-repository-push-records 13/13, retained-repository-push 23/23, module-partition 7/7, `deno task check` clean. --- .../git/src/deno/retained-repository/push.ts | 40 +- .../publication-evidence.ts | 161 ++++++++ .../pull-request-records.ts | 347 +++++++++++++++++ .../src/retained-repository/push-admission.ts | 364 +++++++++++++++--- .../src/retained-repository/push-records.ts | 25 +- packages/git/tests/module-partition.test.ts | 2 + 6 files changed, 883 insertions(+), 56 deletions(-) create mode 100644 packages/git/src/retained-repository/publication-evidence.ts create mode 100644 packages/git/src/retained-repository/pull-request-records.ts diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts index abbdd71fa..6ab1c03ac 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/git/src/deno/retained-repository/push.ts @@ -97,6 +97,7 @@ import { replPushPlacesHeld, type ReplPushPlacement, } from "../../retained-repository/push-admission.ts"; +import type { ReplPublicationMemory } from "../../retained-repository/publication-evidence.ts"; import { parseReplPushRequestRecord, replPushRequestJson, @@ -151,6 +152,16 @@ export interface ReplPushEntry { readonly selections: SelectionRegistry; /** The checkouts this entry has registered, as they stand now. */ checkouts(): readonly ReplGitCheckout[]; + /** + * What this entry has published and reached, for the pull requests that rest + * on it. + * + * Written here and read by the pull-request feature beside this one. It is + * private entry state for the same reason the registry is: a publication + * grant that a document, a prop, middleware or the CLI could name would be a + * grant anything could mint. + */ + readonly publications: ReplPublicationMemory; } /** @@ -163,9 +174,20 @@ export interface ReplPushEntry { */ export type ReplPushAccess = () => ReplPushEntry | undefined; -/** What this entry requires of the publication records its history holds. */ -export function replPushAdmission(owner: string): JournalAdmission { - return (retained) => admitReplPushHistory(owner, retained); +/** + * What this entry requires of the publication records its history holds. + * + * The admission also establishes the order its publications were acknowledged + * in, which the entry's publication memory keeps. None of them authorizes a + * pull request yet: that takes execution actually reaching one. + */ +export function replPushAdmission( + owner: string, + publications: ReplPublicationMemory, +): JournalAdmission { + return function* (retained) { + publications.admitted(yield* admitReplPushHistory(owner, retained)); + }; } /** @@ -580,6 +602,18 @@ function* reconcile( if (outcome === undefined) { throw new GitOperationProtocolError(PUSH); } + // Reached, so usable. A publication becomes evidence for a pull request only + // once execution has actually arrived at its acknowledged receipt — live here, + // or by the shared worker replaying it above. The rank it keeps is the one its + // own acknowledgement had, not the order this replay happened to reach it in. + entry.publications.reach({ + site: invocation.site, + repository: inputs.repository, + origin: record.resolved.origin, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + sourceCommit: inputs.sourceCommit, + }); return outcome; } diff --git a/packages/git/src/retained-repository/publication-evidence.ts b/packages/git/src/retained-repository/publication-evidence.ts new file mode 100644 index 000000000..d67b0e4ff --- /dev/null +++ b/packages/git/src/retained-repository/publication-evidence.ts @@ -0,0 +1,161 @@ +/** + * Proving that *this entry* published the branch its pull request names. + * + * `../composition/push-evidence.ts` answers the same question for an ordinary + * run by scanning that run's Git-host results. An entry cannot use it. A run's + * events are one run's; an entry's segment is reopened hours later beside other + * entries, and what has to be proven is narrower: not "a push of this branch is + * recorded somewhere", but "this entry asked for that publication, this entry's + * own request was answered by its own acknowledged receipt, and this execution + * has actually reached it". + * + * Each of those words excludes something. A bare receipt with no saved request + * before it is not this entry's publication. A request with no receipt published + * nothing yet. A receipt belonging to another entry's segment, a synthetic + * `Git.push` answer a handler returned, a local `` result and a + * pull-request receipt are all records that are not publications of this + * branch — so none of them appears here at all. The admission beside this + * module is what proves the pair; this module is what remembers the proven ones + * and decides which of them a pull request may rest on. + * + * ## Reached, not merely present + * + * The admitted snapshot holds every publication this entry ever acknowledged, + * including ones standing *after* the position execution is currently at. Those + * are not usable. A document whose pull request is written before its second + * publication must not be authorized by that second publication merely because + * a previous session got further than this one has. So the snapshot establishes + * the ordered facts, and each becomes usable only when execution reaches it — + * live, or by restoring its receipt. + * + * ## Durable order, not arrival order + * + * Which publication decides is the *last* one to a destination, because a branch + * holds whatever was published to it last. That order is the order the journal + * acknowledged them in, and it is fixed. Replay does not have to revisit two + * concurrent children in the order they first ran, so arrival order here can be + * reversed from the durable one — and ordering by arrival would let a replayed + * history authorize a head the entry had already published past. Every fact + * therefore carries the rank the snapshot gave it, and a new live publication + * ranks after all of them. + */ + +import { destinationRefFor } from "../composition/git-push-records.ts"; +import { PullRequestAdmissionError } from "../composition/errors.ts"; +import type { PullRequestInputs } from "../composition/pull-request-records.ts"; +import { sameRepositoryIdentity, type RepositoryIdentity } from "../composition/selection.ts"; + +/** + * One publication this entry acknowledged, as a later pull request reads it. + * + * `site` is the invocation that asked for it, which is what identifies the same + * publication across a reopen: the snapshot's facts and the receipt execution + * restores are the same publication when they were asked for at the same + * invocation. + */ +export interface ReplPublication { + /** The invocation this publication was asked for at. */ + readonly site: string; + readonly repository: RepositoryIdentity; + /** The admitted credential-free locator the branch went to. */ + readonly origin: string; + readonly branch: string; + readonly destinationRef: string; + /** The commit that was published, which is what a pull request's head must be. */ + readonly sourceCommit: string; +} + +/** One acknowledged publication, with the durable order it was acknowledged in. */ +export interface RankedReplPublication extends ReplPublication { + /** Its position in this entry's durable acknowledgement order. */ + readonly rank: number; +} + +/** + * This entry's private memory of what it has published and actually reached. + * + * Created once per initialized entry and reachable only from this feature's own + * closure. It is deliberately not an accessor, a registry or a handle: nothing + * returns it to the CLI, puts it in Context, hands it to middleware or lets a + * document, a prop or a public input name it. + */ +export interface ReplPublicationMemory { + /** The ordered facts the admitted snapshot holds, none of them yet usable. */ + admitted(publications: readonly RankedReplPublication[]): void; + /** + * Record that execution has reached this publication, and remember it. + * + * One call covers both paths. A restored receipt names a publication the + * snapshot already ranked, and that rank is kept. A live one is new, and + * ranks after every publication the snapshot held. + */ + reach(publication: ReplPublication): void; + /** + * Admit this pull request against the publications reached so far. + * + * Refuses rather than answers: the two closed words are the ones the ordinary + * evidence scan uses, so one vocabulary describes both paths. + */ + admit(inputs: PullRequestInputs, origin: string): void; +} + +/** This entry's publication memory, empty until a snapshot is admitted. */ +export function createReplPublicationMemory(): ReplPublicationMemory { + /** The ordered facts the snapshot held, by the invocation that asked. */ + let snapshot: ReadonlyMap = new Map(); + /** The highest rank the snapshot used, which live work ranks after. */ + let ceiling = 0; + /** How many live publications have been acknowledged since. */ + let live = 0; + /** What execution has actually reached, usable and in durable order. */ + const reached = new Map(); + + return { + admitted(publications) { + snapshot = new Map(publications.map((publication) => [publication.site, publication])); + ceiling = publications.reduce((highest, one) => Math.max(highest, one.rank), 0); + }, + + reach(publication) { + const ranked = snapshot.get(publication.site); + reached.set(publication.site, { + ...publication, + // A publication the snapshot ranked keeps that rank however late + // execution reaches it. One it never held is new work, and new work is + // acknowledged after everything already in the journal. + rank: ranked === undefined ? ceiling + ++live : ranked.rank, + }); + }, + + admit(inputs, origin) { + const destinationRef = destinationRefFor(inputs.headBranch); + const relevant = Array.from(reached.values()) + .filter( + (publication) => + publication.destinationRef === destinationRef && + publication.branch === inputs.headBranch && + publication.origin === origin && + sameRepositoryIdentity(publication.repository, inputs.repository), + ) + .sort((left, right) => left.rank - right.rank); + const last = relevant.at(-1); + if (last === undefined) { + throw new PullRequestAdmissionError( + "missing-push-evidence", + "this entry holds no acknowledged of the branch and commit it would open " + + "a pull request from. Write before : a pull request names " + + "work this entry published, and publishing it is an explicit act. Nothing was " + + "contacted.", + ); + } + if (last.sourceCommit !== inputs.headSha) { + throw new PullRequestAdmissionError( + "conflicting-push-evidence", + "this entry published that branch at a different commit than the one its checkout is " + + "on now, so a pull request opened from it would name a head this entry never " + + "published. Nothing was contacted.", + ); + } + }, + }; +} diff --git a/packages/git/src/retained-repository/pull-request-records.ts b/packages/git/src/retained-repository/pull-request-records.ts new file mode 100644 index 000000000..9fe5051f9 --- /dev/null +++ b/packages/git/src/retained-repository/pull-request-records.ts @@ -0,0 +1,347 @@ +/** + * What one REPL entry's `` asked the Git host for, on the wire. + * + * An entry that opens or updates a pull request writes this down *before* the + * service is contacted, and the external outcome is reconciled afterwards under + * the shared Git-host contract. Those are two records, exactly as they are for + * publication: this one says what was asked, and the Git-host record beside it + * says what the host did about it. Reopen the entry and the saved request is + * what the next attempt reconciles — the branch, the head and the body it + * originally named, however far the checkout has moved since. + * + * A record here is not evidence that any pull request exists. An entry + * interrupted between this append and the service leaves exactly this record + * and no outcome, which is the window the reconciliation exists to close. + * + * ## Three readings of one question + * + * The shape follows the saved Push request beside it, for the same reasons. + * `requested` is what the invocation observed: the pull request the document + * authored, and the place that authored it. `received` is the same pair as they + * stood when the live dispatch reached this provider, so an intentional + * middleware transformation is visible rather than hidden. `resolved` is the + * answer: the covering checkout, its common directory, the admitted + * credential-free origin and the complete pull-request inputs, which pin the + * head branch, the head commit and the base branch this attempt settled on. + * + * Only `requested` names the record. A replay matches the question, not the + * answer, so `received` and `resolved` are read back and held to each other + * rather than re-resolved: resolving them again would ask today's checkout what + * a saved request meant, and the head it names has usually moved. + * + * ## Two naming algorithms, deliberately + * + * This request is named by the SHA-256 of canonical JSON, which is what every + * retained entry record is named by. The evidence reads beside it keep the + * *existing* `pull_request_read` name — a length-prefixed UTF-16 fingerprint + * truncated to sixteen characters — because that name is already in journals + * Workflow reads. They are not interchangeable and neither replaces the other. + * + * ## What is deliberately absent + * + * No opaque selection identifier, no credential, no endpoint, no provider + * handle, no checkout inventory and no pull-request number this attempt has not + * been given. Host paths are kept as the bytes they arrived as, for the same + * reason the Push request keeps them: an entry's paths are places on somebody's + * disk, and rewriting one would make a saved request name a different directory + * than the one that was asked for. + */ + +import type { Operation } from "effection"; +import type { Json } from "@executablemd/durable-streams"; +import { canonicalJson } from "@executablemd/workflow"; +import { members, text } from "../composition/parse.ts"; +import { admitLocator } from "../composition/locator.ts"; +import type { PullRequestInput } from "../composition/pull-request-api.ts"; +import { + parsePullRequestInputs, + pullRequestInputsJson, + pullRequestNumber, + type PullRequestInputs, +} from "../composition/pull-request-records.ts"; +import { sameRepositoryIdentity } from "../composition/selection.ts"; +import { parseReplPushPlace, replPushPlaceJson, type ReplPushPlace } from "./push-records.ts"; +import { digest, REPL_REPOSITORY_VERSION } from "./records.ts"; + +/** The durable type one entry pull-request request is published under. */ +export const REPL_REPOSITORY_PULL_REQUEST_REQUEST = "repl_repository_pull_request_request"; + +/** The prefix the request's own name is built from. */ +const PULL_REQUEST_REQUEST_PREFIX = "executablemd.repl.pull-request-request"; + +/** One reading of what was asked, and of where it was asked. */ +export interface ReplPullRequestAsk { + /** The pull request the document authored, before any resolution. */ + readonly pullRequest: PullRequestInput; + /** The place that authored it, as the Push request records a place. */ + readonly place: ReplPushPlace; +} + +/** What routing answered, and the complete request it produced. */ +export interface ReplPullRequestResolution { + /** The checkout actually covering the received working directory. */ + readonly checkoutRoot: string; + /** That checkout's canonical common Git directory. */ + readonly commonDirectory: string; + /** The admitted credential-free locator this pull request belongs to. */ + readonly origin: string; + /** The complete inputs the Git-host effect acts on. */ + readonly inputs: PullRequestInputs; +} + +/** One entry pull-request request, as a continuation reads it back. */ +export interface ReplPullRequestRequestRecord { + readonly version: typeof REPL_REPOSITORY_VERSION; + /** The entry whose retained work this is. */ + readonly owner: string; + /** The invocation inside that entry, as canonical core minted it. */ + readonly site: string; + readonly requested: ReplPullRequestAsk; + readonly received: ReplPullRequestAsk; + readonly resolved: ReplPullRequestResolution; +} + +/** What names one request: the owner, the site and the question it asked. */ +export interface ReplPullRequestRequestName { + readonly owner: string; + readonly site: string; + readonly requested: ReplPullRequestAsk; +} + +const RECORD_MEMBERS = ["version", "owner", "site", "requested", "received", "resolved"] as const; + +const ASK_MEMBERS = ["pullRequest", "place"] as const; + +const INPUT_MEMBERS = ["number", "title", "body", "draft", "base"] as const; + +const RESOLUTION_MEMBERS = ["checkoutRoot", "commonDirectory", "origin", "inputs"] as const; + +/** + * The stable name one pull-request request is published under. + * + * A digest of the version, the owner, the site and every member of the + * question. Two equal questions at two positions produce one name, which is + * allowed: telling them apart is the durable engine's own positional matching, + * and nothing here searches history by name. + * + * The answer takes no part in it. A name derived from the resolved head would + * have to be recomputed to find the record, which means resolving a saved + * request again against a checkout whose head has since moved. + */ +export function replPullRequestRequestName(request: ReplPullRequestRequestName): Operation { + return digest([ + PULL_REQUEST_REQUEST_PREFIX, + REPL_REPOSITORY_VERSION, + request.owner, + request.site, + askJson(request.requested), + ]); +} + +/** The payload one published pull-request request carries. */ +export function replPullRequestRequestJson(record: { + readonly owner: string; + readonly site: string; + readonly requested: ReplPullRequestAsk; + readonly received: ReplPullRequestAsk; + readonly resolved: ReplPullRequestResolution; +}): Json { + return { + version: REPL_REPOSITORY_VERSION, + owner: record.owner, + site: record.site, + requested: askJson(record.requested), + received: askJson(record.received), + resolved: { + checkoutRoot: record.resolved.checkoutRoot, + commonDirectory: record.resolved.commonDirectory, + origin: record.resolved.origin, + inputs: pullRequestInputsJson(record.resolved.inputs), + }, + }; +} + +/** + * The pull-request request this saved value describes, or `undefined`. + * + * Total, and exact about every member. Beyond the shape it refuses a request + * whose origin is not a locator this host would hand to Git, and one whose + * resolved inputs belong to a Repository other than the one the live dispatch + * received. It also holds the received question to the answer: a saved request + * whose resolved title, body, draft, number or base is not the one that was + * received describes work this entry did not ask for. + * + * Whether the saved roots are places this entry holds, and whether a + * publication authorized this pull request, are not asked here. Those are + * questions about parsed placements and about this entry's own publication + * memory, and they are answered by the admission beside this codec. + */ +export function parseReplPullRequestRequestRecord( + value: unknown, +): ReplPullRequestRequestRecord | undefined { + // Totally, for the same reason the Push request codec is total: enumeration + // and every property read are the value's own to refuse, and a refusal of any + // of them is the same answer. Answering `undefined` is what keeps an + // untrusted value from carrying its own text out through an exception. + try { + return readRequestRecord(value); + } catch { + return undefined; + } +} + +function readRequestRecord(value: unknown): ReplPullRequestRequestRecord | undefined { + const read = members(value, RECORD_MEMBERS); + if (read === undefined || read["version"] !== REPL_REPOSITORY_VERSION) { + return undefined; + } + const owner = text(read["owner"]); + const site = text(read["site"]); + const requested = parseAsk(read["requested"]); + const received = parseAsk(read["received"]); + if ( + owner === undefined || + site === undefined || + requested === undefined || + received === undefined + ) { + return undefined; + } + const resolved = parseResolution(read["resolved"], received); + if (resolved === undefined) { + return undefined; + } + return Object.freeze({ + version: REPL_REPOSITORY_VERSION, + owner, + site, + requested, + received, + resolved, + }); +} + +/** One reading of what was asked and where, or `undefined`. */ +function parseAsk(value: unknown): ReplPullRequestAsk | undefined { + const read = members(value, ASK_MEMBERS); + if (read === undefined) { + return undefined; + } + const pullRequest = parseAuthoredInput(read["pullRequest"]); + const place = parseReplPushPlace(read["place"]); + if (pullRequest === undefined || place === undefined) { + return undefined; + } + return Object.freeze({ pullRequest, place }); +} + +/** + * The pull request a document authored, or `undefined`. + * + * The five members the public input carries and no others. The number is + * `null` when the document asked for a pull request to exist, and a positive + * integer when it named one; the body may be empty and may not be absent, + * because an empty `` renders `""` and a missing member is a + * second shape rather than a value. + */ +function parseAuthoredInput(value: unknown): PullRequestInput | undefined { + const read = members(value, INPUT_MEMBERS); + if (read === undefined) { + return undefined; + } + const number = read["number"] === null ? null : pullRequestNumber(read["number"]); + const title = text(read["title"]); + const body = read["body"]; + const draft = read["draft"]; + const base = text(read["base"]); + if ( + number === undefined || + title === undefined || + typeof body !== "string" || + typeof draft !== "boolean" || + base === undefined + ) { + return undefined; + } + return Object.freeze({ number, title, body, draft, base }); +} + +/** + * What routing resolved, held to the reading it resolved from. + * + * The origin is admitted through the same closed grammar a document's locator + * passes, so a saved origin this host would not hand to Git is refused here. + * Whether an admitted one is the origin the Repository identity was + * fingerprinted from is the other half of that question, and the digest it + * needs belongs to the runtime-named half of this package: the provider asks it + * of every record it reads back, before the record may be reconciled. + * + * The resolved inputs are also held to the question they answer. A resolved + * title, body, draft, number or base that is not the received one would let a + * saved request reconcile a pull request the document never authored, so the + * mutable fields have to agree. The head branch, head commit and base branch + * are the answer and have no counterpart to agree with. + */ +function parseResolution( + value: unknown, + received: ReplPullRequestAsk, +): ReplPullRequestResolution | undefined { + const read = members(value, RESOLUTION_MEMBERS); + if (read === undefined) { + return undefined; + } + const checkoutRoot = text(read["checkoutRoot"]); + const commonDirectory = text(read["commonDirectory"]); + const origin = text(read["origin"]); + const inputs = parsePullRequestInputs(read["inputs"]); + if ( + checkoutRoot === undefined || + commonDirectory === undefined || + origin === undefined || + inputs === undefined + ) { + return undefined; + } + if (admitLocator(origin) !== origin) { + return undefined; + } + if (!sameRepositoryIdentity(inputs.repository, received.place.repository)) { + return undefined; + } + if (!resolvedAnswersAsk(inputs, received.pullRequest)) { + return undefined; + } + return Object.freeze({ checkoutRoot, commonDirectory, origin, inputs }); +} + +/** Whether the resolved inputs carry the question that was received. */ +function resolvedAnswersAsk(inputs: PullRequestInputs, asked: PullRequestInput): boolean { + return ( + inputs.number === asked.number && + inputs.title === asked.title && + inputs.body === asked.body && + inputs.draft === asked.draft && + inputs.baseBranch === asked.base + ); +} + +function askJson(ask: ReplPullRequestAsk): Json { + return { + pullRequest: { + number: ask.pullRequest.number, + title: ask.pullRequest.title, + body: ask.pullRequest.body, + draft: ask.pullRequest.draft, + base: ask.pullRequest.base, + }, + place: replPushPlaceJson(ask.place), + }; +} + +/** Whether two readings of one pull request's question describe the same question. */ +export function sameReplPullRequestAsk( + left: ReplPullRequestAsk, + right: ReplPullRequestAsk, +): boolean { + return canonicalJson(askJson(left)) === canonicalJson(askJson(right)); +} diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts index a28017213..7353cdb3d 100644 --- a/packages/git/src/retained-repository/push-admission.ts +++ b/packages/git/src/retained-repository/push-admission.ts @@ -33,6 +33,28 @@ * outcome answers the other's request. Nothing is matched by searching the * stream for a name: equal requests at two positions are two occurrences, and a * reader that deduplicated them would refuse a document that is allowed. + * + * ## One reader for two external protocols + * + * An entry retains two kinds of Git-host work — publication and pull-request + * upsert — and both settle through the same `git_host_effect` type. A failed + * envelope names no kind, because a refusal retains no request of its own, so + * nothing in the record itself says which protocol it belongs to. What says so + * is the request standing before it in the same coroutine: that request's own + * kind decides which worker name the outcome must carry, which inputs and + * natural key it must describe, and which result codec must be able to read it. + * + * This is why there is one reader rather than two. Two readers that each passed + * over the other's failures would each see an orphan where the other saw a + * pair, and a Push-only reader handed a refused pull request would check it + * against a publication name it never had. Classification belongs to the + * pairing, and the pairing is in one place. + * + * A pull-request request is held to one thing publication is not: the entry + * must already have published the head it names. That is what makes the pull + * request this entry's own work rather than a claim about somebody else's + * branch, and the publication it rests on has to be one this history actually + * acknowledged before it. */ import { StaleInputError } from "@executablemd/durable-streams"; @@ -47,6 +69,14 @@ import { pushExpectation, type GitPushInputs, } from "../composition/git-push-records.ts"; +import { + parsePullRequestRecord, + PULL_REQUEST, + pullRequestInputsJson, + pullRequestNaturalKey, + pullRequestNaturalKeyJson, + type PullRequestInputs, +} from "../composition/pull-request-records.ts"; import { sameRepositoryIdentity, type RepositoryIdentity } from "../composition/selection.ts"; import { GIT_HOST_EFFECT } from "../git-host/effect-type.ts"; import { @@ -61,6 +91,13 @@ import { REPL_REPOSITORY_PUSH_REQUEST, type ReplPushRequestRecord, } from "./push-records.ts"; +import type { RankedReplPublication } from "./publication-evidence.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + type ReplPullRequestRequestRecord, +} from "./pull-request-records.ts"; import { REPL_REPOSITORY_VERSION, statedReplRecordVersion } from "./records.ts"; /** Why a publication history cannot be continued. */ @@ -79,8 +116,10 @@ export type ReplPushRefusal = | "orphan-outcome" /** A second outcome for a request one already answered. */ | "duplicate-outcome" - /** A settled outcome that is not the result a Push produces. */ - | "malformed-receipt"; + /** A settled outcome that is not the result its own request produces. */ + | "malformed-receipt" + /** A saved pull request whose head no publication before it published. */ + | "unauthorized-pull-request"; /** * A retained publication history this entry cannot continue from. @@ -174,13 +213,92 @@ export function replPushOutcomeName(record: ReplPushRequestRecord): Operation = new Set([ "GitHostConflictError", "GitHostAmbiguousError", "GitHostUnavailableError", ]); +/** + * One saved request of either protocol, with the kind that classifies it. + * + * The pairing carries the kind so an outcome's own envelope does not have to. + * A failed envelope says nothing about which protocol refused, and this is what + * answers it. + */ +type ReplExternalRequest = + | { readonly kind: typeof GIT_PUSH; readonly record: ReplPushRequestRecord } + | { readonly kind: typeof PULL_REQUEST; readonly record: ReplPullRequestRequestRecord }; + +/** + * Whether some publication before this point put this pull request's head + * where the pull request says it is. + * + * The last relevant publication decides. A pull request naming head `B` is + * authorized by a publication of `B` to its own branch, and an earlier + * publication of `A` to that same branch does not authorize it — the branch + * holds whatever was published to it last. Finding any exact match anywhere in + * history would let a superseded publication keep authorizing work. + */ +function publicationAuthorizes( + record: ReplPullRequestRequestRecord, + publications: readonly RankedReplPublication[], +): boolean { + const inputs = record.resolved.inputs; + const relevant = publications + .filter( + (publication) => + publication.branch === inputs.headBranch && + publication.origin === record.resolved.origin && + sameRepositoryIdentity(publication.repository, inputs.repository), + ) + .sort((left, right) => left.rank - right.rank); + const last = relevant.at(-1); + return last !== undefined && last.sourceCommit === inputs.headSha; +} + +/** The Git-host request one saved pull-request request asks the worker to reconcile. */ +export function replPullRequestEffectRequest(inputs: PullRequestInputs): { + readonly kind: string; + readonly inputs: Json; + readonly naturalKey: Json; +} { + return { + kind: PULL_REQUEST, + inputs: pullRequestInputsJson(inputs), + naturalKey: pullRequestNaturalKeyJson(pullRequestNaturalKey(inputs)), + }; +} + +/** The name the worker publishes one saved pull-request request's outcome under. */ +export function replPullRequestOutcomeName( + record: ReplPullRequestRequestRecord, +): Operation { + return gitHostRequestFingerprint({ + identity: { runId: record.owner, expansionId: record.site }, + ...replPullRequestEffectRequest(record.resolved.inputs), + }); +} + +/** The Git-host request one saved request of either protocol asks for. */ +function effectRequestOf(request: ReplExternalRequest): { + readonly kind: string; + readonly inputs: Json; + readonly naturalKey: Json; +} { + return request.kind === GIT_PUSH + ? replPushEffectRequest(request.record.resolved.inputs) + : replPullRequestEffectRequest(request.record.resolved.inputs); +} + +/** The name the worker publishes one saved request's outcome under. */ +function outcomeNameOf(request: ReplExternalRequest): Operation { + return request.kind === GIT_PUSH + ? replPushOutcomeName(request.record) + : replPullRequestOutcomeName(request.record); +} + /** * Hold this entry's retained publication history to what this host can read. * @@ -191,13 +309,15 @@ const CLOSED_FAILURES: ReadonlySet = new Set([ export function* admitReplPushHistory( owner: string, retained: readonly DurableEvent[], -): Operation { +): Operation { /** Every place a successful placement record before this point described. */ const placements: ReplPushPlacement[] = []; + /** Every publication acknowledged before this point, in that order. */ + const publications: RankedReplPublication[] = []; /** Per coroutine, the requests no outcome has answered yet. */ - const pending = new Map(); + const pending = new Map(); /** Per coroutine, the requests an outcome already answered. */ - const answered = new Map(); + const answered = new Map(); for (const event of retained) { if (event.type !== "yield") { @@ -210,14 +330,23 @@ export function* admitReplPushHistory( } if (type === REPL_REPOSITORY_PUSH_REQUEST) { const record = yield* admitRequest(owner, event, placements); - queue(pending, event.coroutineId).push(record); + queue(pending, event.coroutineId).push({ kind: GIT_PUSH, record }); + continue; + } + if (type === REPL_REPOSITORY_PULL_REQUEST_REQUEST) { + const record = yield* admitPullRequest(owner, event, placements, publications); + queue(pending, event.coroutineId).push({ kind: PULL_REQUEST, record }); continue; } if (type !== GIT_HOST_EFFECT) { continue; } - yield* admitOutcome(owner, event, pending, answered); + yield* admitOutcome(owner, event, pending, answered, publications); } + // The ordered publication facts this snapshot holds. None of them is usable + // until execution reaches it; that is the publication memory's question, and + // what this reader supplies is the order. + return publications; } /** Remember one successful placement's parsed facts, or pass over the record. */ @@ -242,14 +371,14 @@ function remember(placements: ReplPushPlacement[], event: DurableEvent): void { } function queue( - queues: Map, + queues: Map, coroutineId: string, -): ReplPushRequestRecord[] { +): ReplExternalRequest[] { const existing = queues.get(coroutineId); if (existing !== undefined) { return existing; } - const created: ReplPushRequestRecord[] = []; + const created: ReplExternalRequest[] = []; queues.set(coroutineId, created); return created; } @@ -305,26 +434,130 @@ function* admitRequest( return record; } +/** + * One saved pull-request request, held to everything a publication request is + * held to, and to the publication that authorizes it. + * + * The first four questions are the same as publication's, for the same + * reasons. The fifth is this protocol's own: a pull request names a head, and + * an entry may only name a head it published itself. A saved request whose head + * no publication in this history put on that branch describes work this entry + * cannot account for — which is exactly what a copied, forged or cross-entry + * receipt would look like from here. + */ +function* admitPullRequest( + owner: string, + event: Yield, + placements: readonly ReplPushPlacement[], + publications: readonly RankedReplPublication[], +): Operation { + if (event.result.status !== "ok") { + throw malformedPullRequest(); + } + const payload = event.result.value; + const stated = statedReplRecordVersion(payload); + if (stated !== undefined && stated !== REPL_REPOSITORY_VERSION) { + throw refusal( + "unknown-version", + `a ${REPL_REPOSITORY_PULL_REQUEST_REQUEST} record states version ${stated}, and this host ` + + `implements version ${REPL_REPOSITORY_VERSION} only. Nothing is assumed about what the ` + + "rest of it means.", + ); + } + const record = parseReplPullRequestRequestRecord(payload); + if (record === undefined) { + throw malformedPullRequest(); + } + if (record.owner !== owner) { + throw refusal( + "foreign-owner", + "a saved pull-request request in this entry's history was written under another owner, so " + + "the pull request it asked for was not asked for by this entry and its request is not " + + "one this entry can present again.", + ); + } + if (event.description.name !== (yield* replPullRequestRequestName(record))) { + throw refusal( + "request-fingerprint", + "a saved pull-request request is journaled under a name that is not the name its own " + + "request produces, so the record and the pull request it claims to be do not match.", + ); + } + if (!replPullRequestPlacesHeld(record, placements)) { + throw refusal( + "unknown-placement", + "a saved pull-request request names a checkout no selection before it placed, so this " + + "history does not say where the pull request it asked for came from.", + ); + } + if (!publicationAuthorizes(record, publications)) { + throw refusal( + "unauthorized-pull-request", + "a saved pull-request request names a head this entry's history never published to that " + + "branch, so the pull request it asked for was not this entry's own work to ask for.", + ); + } + return record; +} + +/** + * Whether every place a saved pull-request request names is one these + * placements hold. + * + * The same three readings publication is checked on, against the same + * placements: the selection the invocation authenticated, the selection the + * live dispatch received, and the checkout routing resolved. + */ +function replPullRequestPlacesHeld( + record: ReplPullRequestRequestRecord, + placements: readonly ReplPushPlacement[], +): boolean { + const places: readonly (readonly [RepositoryIdentity, string, string])[] = [ + [ + record.requested.place.repository, + record.requested.place.selectionCheckout, + record.requested.place.commonDirectory, + ], + [ + record.received.place.repository, + record.received.place.selectionCheckout, + record.received.place.commonDirectory, + ], + [ + record.received.place.repository, + record.resolved.checkoutRoot, + record.resolved.commonDirectory, + ], + ]; + return places.every(([identity, checkoutRoot, commonDirectory]) => + placements.some( + (placement) => + placement.checkoutRoot === checkoutRoot && + placement.commonDirectory === commonDirectory && + sameRepositoryIdentity(placement.identity, identity), + ), + ); +} + /** * One Git-host outcome, paired with the request it answers. * - * Only this entry's own Push effects are this reader's business. A successful - * Git-host record of another kind belongs to a protocol this feature does not - * retain and is passed over; one naming another owner is refused, because an - * entry's segment holds its own work and borrowing somebody else's receipt is - * exactly what must not be possible. + * The request standing before it in the same coroutine is what says which + * protocol it belongs to. A failed envelope names no kind, so nothing in the + * record itself could; and a successful one naming a kind the pairing does not + * expect describes different work than was asked for. An outcome naming another + * owner is refused, because an entry's segment holds its own work and borrowing + * somebody else's receipt is exactly what must not be possible. * - * A failed envelope names no kind — a refusal retains no request of its own — - * so every one of them in an entry's segment is read as a publication. That is - * exactly what they are: publication is the one external protocol an entry - * retains through this boundary, and the name the failure is journaled under - * still has to be the one the request before it produces. + * A successful record of a kind neither protocol here owns belongs to something + * this reader does not retain, and is passed over without consuming a pairing. */ function* admitOutcome( owner: string, event: Yield, - pending: Map, - answered: Map, + pending: Map, + answered: Map, + publications: RankedReplPublication[], ): Operation { const settled = event.result.status === "ok" ? successOf(event.result.value) : undefined; if (event.result.status === "ok" && settled === undefined) { @@ -333,18 +566,23 @@ function* admitOutcome( throw refusal( "malformed-receipt", "a Git-host record in this entry's history is not one this host can read, so what it says " + - "about work already published is unknown.", + "about work already done is unknown.", ); } - if (settled !== undefined && settled.request.kind !== GIT_PUSH) { - // Another external protocol's record. This reader owns publication only. + if ( + settled !== undefined && + settled.request.kind !== GIT_PUSH && + settled.request.kind !== PULL_REQUEST + ) { + // Another external protocol's record. This reader owns publication and + // pull requests, and consumes no pairing for anything else. return; } if (settled !== undefined && settled.request.identity.runId !== owner) { throw refusal( "foreign-owner", - "a publication outcome in this entry's history was reconciled under another owner, so the " + - "work it describes was not published by this entry.", + "a Git-host outcome in this entry's history was reconciled under another owner, so the " + + "work it describes was not done by this entry.", ); } @@ -355,21 +593,21 @@ function* admitOutcome( throw spent.length > 0 ? refusal( "duplicate-outcome", - "a publication request in this entry's history is answered by more than one Git-host " + - "outcome, so this history does not say which of them describes what was published.", + "a saved request in this entry's history is answered by more than one Git-host " + + "outcome, so this history does not say which of them describes what was done.", ) : refusal( "orphan-outcome", - "a publication outcome in this entry's history stands where no saved request asked for " + + "a Git-host outcome in this entry's history stands where no saved request asked for " + "one, so this history does not say what was asked of the Git host.", ); } queue(answered, event.coroutineId).push(request); - if (event.description.name !== (yield* replPushOutcomeName(request))) { + if (event.description.name !== (yield* outcomeNameOf(request))) { throw refusal( "request-fingerprint", - "a publication outcome is journaled under a name that is not the name the request before " + + "a Git-host outcome is journaled under a name that is not the name the request before " + "it produces, so the outcome and the request it claims to answer do not match.", ); } @@ -378,35 +616,65 @@ function* admitOutcome( if (failed === undefined || !CLOSED_FAILURES.has(failed)) { throw refusal( "malformed-receipt", - "a refused publication is recorded under a condition this host has no word for, so what " + - "it says about the branch is unknown.", + "a refused Git-host effect is recorded under a condition this host has no word for, so " + + "what it says about the work is unknown.", ); } return; } - if (settled.request.identity.expansionId !== request.site) { + if (settled.request.kind !== request.kind) { + throw refusal( + "malformed-receipt", + "a Git-host outcome describes a different kind of work than the request before it asked " + + "for, so this history does not say what was done.", + ); + } + if (settled.request.identity.expansionId !== request.record.site) { throw refusal( "foreign-owner", - "a publication outcome names an invocation other than the one the request before it was " + - "made at, so the two describe different publications.", + "a Git-host outcome names an invocation other than the one the request before it was " + + "made at, so the two describe different work.", ); } - const asked = replPushEffectRequest(request.resolved.inputs); + const asked = effectRequestOf(request); if ( canonicalJson(settled.request.inputs) !== canonicalJson(asked.inputs) || canonicalJson(settled.request.naturalKey) !== canonicalJson(asked.naturalKey) ) { throw refusal( "malformed-receipt", - "a publication outcome describes a push other than the one the request before it asked " + - "for, so this history does not say what was published.", + "a Git-host outcome describes work other than the one the request before it asked for, " + + "so this history does not say what was done.", ); } - if (parseGitPushRecord(settled, pushExpectation(request.resolved.inputs)) === undefined) { + if (request.kind === GIT_PUSH) { + const inputs = request.record.resolved.inputs; + if (parseGitPushRecord(settled, pushExpectation(inputs)) === undefined) { + throw refusal( + "malformed-receipt", + "a publication outcome holds a result that is not the one a Push of that branch " + + "produces, so what it says was published is unknown.", + ); + } + // What this publication proved, for whichever pull request rests on it. + // Remembered in acknowledgement order, because the last publication to a + // destination is the one that decides what is on that branch. + publications.push({ + site: request.record.site, + rank: publications.length, + repository: inputs.repository, + origin: request.record.resolved.origin, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + sourceCommit: inputs.sourceCommit, + }); + return; + } + if (parsePullRequestRecord(settled, request.record.resolved.inputs) === undefined) { throw refusal( "malformed-receipt", - "a publication outcome holds a result that is not the one a Push of that branch produces, " + - "so what it says was published is unknown.", + "a pull-request outcome holds a result that is not the one an upsert of that pull request " + + "produces, so what it says exists is unknown.", ); } } @@ -429,6 +697,14 @@ function malformed(): ReplPushHistoryError { ); } +function malformedPullRequest(): ReplPushHistoryError { + return refusal( + "malformed-record", + `a ${REPL_REPOSITORY_PULL_REQUEST_REQUEST} record in this entry's history is not one this ` + + "host can read, so what it says about work already asked for is unknown.", + ); +} + function refusal(reason: ReplPushRefusal, sentence: string): ReplPushHistoryError { return new ReplPushHistoryError(reason, sentence); } diff --git a/packages/git/src/retained-repository/push-records.ts b/packages/git/src/retained-repository/push-records.ts index 7a9ae85f4..05dcdf716 100644 --- a/packages/git/src/retained-repository/push-records.ts +++ b/packages/git/src/retained-repository/push-records.ts @@ -145,7 +145,7 @@ export function replPushRequestName(request: ReplPushRequestName): Operation Date: Sat, 17 Oct 2026 12:23:22 -0400 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=9A=A7=20Open=20one=20entry's=20pul?= =?UTF-8?q?l=20request=20from=20the=20head=20it=20published=20(#181,=205/6?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 3 of delivery 5. Not the feedback commit: F1–F6 have not run. `` becomes one of the entry's own identity components, with the same origin, props, return contract, forms and prose the bundled registration carries — inspection describes one component, so both registrations now read the documentation and the authored normalization from the component module rather than keeping their own copies of either. The body is rendered after the invocation is authenticated and before the Repository is observed, so a child durable operation inside that content finishes at its own earlier position and a body that failed to render stops everything this component owns before a request exists. A fresh invocation dispatches through two invocation-local defaults under the two stable Api names, so middleware composed at either level still answers, transforms, refuses and delegates. What a handler may decide is what to ask for; what it may not decide is what was answered. An answer that never delegated binds only the closed result contract and retains nothing — which is why the result reading splits in two, the narrower half validating a shape with no resolved head to hold it to. Once the owned terminal has run, its outcome is kept as a `Result` and that outcome binds or fails: a rewritten return cannot bind another pull request, and a swallowed delegated failure cannot become a success. An outer throw after a success stays ordinary failure. Resolution follows publication's order — route, origin against the Repository fingerprint, managed slot, then branch and head inside the entry's own short native turn — and then asks this protocol's own question: whether this entry published that head. That is the private publication memory, and a refusal happens with the Git host never having been asked anything. The request is written before any service contact, and a pending one is reconciled from its saved head, base, number, title, body and draft without rereading a checkout whose HEAD has usually moved. The access session belongs to the attempt's scope, so it closes and joins before the reconciliation may acknowledge a result. The entry factory now declares four components, so the two assembly inventories that froze the declared list gain `PullRequest`. Evidence: repl-push 15/15, repl-repositories, repl-issues 38/38 together, pull-request + pull-request-records 61/61, retained-repository-push 23/23, module-partition 7/7, `deno task check` clean. --- packages/cli/tests/repl-issues.test.ts | 1 + packages/cli/tests/repl-push.test.ts | 1 + .../src/composition/pull-request-records.ts | 41 +- .../deno/retained-repository/pull-request.ts | 883 ++++++++++++++++++ packages/git/tests/module-partition.test.ts | 1 + 5 files changed, 919 insertions(+), 8 deletions(-) create mode 100644 packages/git/src/deno/retained-repository/pull-request.ts diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 6ea275e97..5d84fa3af 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -1502,6 +1502,7 @@ describe("how a REPL entry gets its issues", () => { expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", ]); }); diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index 4e6eb1cf5..ce44cf223 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -439,6 +439,7 @@ describe("how a REPL entry gets its publication capability", () => { expect([...(built.components ?? [])].map((component) => component.name).sort()).toEqual([ "Git.Push", "Issue", + "PullRequest", ]); expect((built.admissions ?? []).length).toBe(2); expect(yield* nothingUnder(fixture.managed)).toBe(true); diff --git a/packages/git/src/composition/pull-request-records.ts b/packages/git/src/composition/pull-request-records.ts index 42cb45539..82f493108 100644 --- a/packages/git/src/composition/pull-request-records.ts +++ b/packages/git/src/composition/pull-request-records.ts @@ -587,12 +587,44 @@ export function parsePullRequestObservations( export function parsePullRequestResult( value: unknown, expected: PullRequestExpectation, +): PullRequestResult | undefined { + const result = parseClosedPullRequestResult(value, expected.repository.objectFormat); + if (result === undefined) { + return undefined; + } + if ( + !sameRepositoryIdentity(result.repository, expected.repository) || + result.headSha !== expected.headSha || + (expected.number !== null && result.number !== expected.number) + ) { + return undefined; + } + return result; +} + +/** + * The closed result shape this value carries, without holding it to a request. + * + * The members alone: every one the contract declares, no others, each the kind + * of value it is declared as. What this deliberately does not ask is whether + * the pull request it describes is the one some request asked for — that needs + * a resolved head, and there are callers with no head to compare against. + * + * A retained upsert is not one of them. It always has its resolved inputs and + * goes through `parsePullRequestResult` above, which adds exactly those + * questions. This narrower reading is for an answer that was never delegated: + * a handler that answered the document itself has produced something that must + * still be this component's closed contract, and discovering a head to check it + * against would be performing the work the handler declined to do. + */ +export function parseClosedPullRequestResult( + value: unknown, + format: GitObjectFormat, ): PullRequestResult | undefined { const record = members(value, RESULT_MEMBERS); if (record === undefined) { return undefined; } - const format = expected.repository.objectFormat; const repository = parseRepositoryIdentity(record.repository); const providerId = text(record.providerId); const number = pullRequestNumber(record.number); @@ -610,13 +642,6 @@ export function parsePullRequestResult( ) { return undefined; } - if ( - !sameRepositoryIdentity(repository, expected.repository) || - headSha !== expected.headSha || - (expected.number !== null && number !== expected.number) - ) { - return undefined; - } return Object.freeze({ repository, providerId, diff --git a/packages/git/src/deno/retained-repository/pull-request.ts b/packages/git/src/deno/retained-repository/pull-request.ts new file mode 100644 index 000000000..ed1bcd36a --- /dev/null +++ b/packages/git/src/deno/retained-repository/pull-request.ts @@ -0,0 +1,883 @@ +/** + * One REPL entry's ``: asked once, reconciled once, answered from + * what the Git host said. + * + * `push.ts` beside this is the same shape for the same reasons, and this + * operation is built on top of what that one proves. A publication changes a + * branch at a remote; a pull request is a *statement about* a branch that is + * already there, so before anything is written down this entry has to be able + * to show that it published the head the pull request names. That evidence is + * the entry's own private publication memory, and nothing else is accepted — + * not a receipt-shaped value, not a local `` result, not a + * handler's `Git.push` answer, and not another entry's history. + * + * Everything after that follows publication's order exactly: resolve one + * complete request out of the checkout, write that request down, and only then + * reconcile the external effect through the shared Git-host state machine. An + * entry that opens a pull request, leaves at a question and is reopened an hour + * later finds the request it already made — the same head, the same base, the + * same body — and reconciles *that* rather than whatever the checkout has + * become since. A completed upsert answers from its saved receipt and contacts + * nobody. + * + * ## Two chains, and what a handler may decide + * + * A fresh invocation dispatches through two invocation-local defaults under the + * two stable Api names, so every public handler a document or a plugin composed + * at either level still answers, transforms, refuses and delegates exactly as + * it does in an ordinary run. The profile-level default reaches the transport + * surface; the transport-level default is this entry's own terminal, and it is + * the only thing here that writes anything down. + * + * What a handler may decide is what to ask for. What it may not decide is what + * was answered. A handler that answers without delegating has answered the + * document and nothing more: no request is written, no Git host is asked, no + * receipt exists, and the value it bound has none of the stability a retained + * one has. But once the owned terminal has actually run, its outcome is kept + * privately as a `Result` and that outcome is what binds or fails — an outer + * handler that rewrites the return cannot bind a different pull request, and + * one that swallows a delegated failure and returns a success cannot turn a + * refusal into one. A throw of the outer chain's own after a success is + * ordinary surrounding failure and is left alone. + * + * ## A saved request bypasses the middleware it already passed + * + * On a continuation the saved request is consumed before anything is + * dispatched. The transformation a handler applied the first time is already + * inside that record; re-dispatching would let a changed handler retarget + * external work this entry has already admitted. + */ + +import { createApi } from "@effectionx/context-api"; +import { Err, Ok, until, useScope } from "effection"; +import type { Operation, Result, Scope } from "effection"; +import { realpath } from "node:fs/promises"; +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { + ActivateDurabilityFailure, + Json, + LiveDurableOperationCoordinator, + Result as ProtocolResult, +} from "@executablemd/durable-streams"; +import { content, documented, hasContent } from "@executablemd/core"; +import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; +import { cwd } from "@executablemd/runtime"; +import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; +import { + authoredPullRequest, + documentation as pullRequestDocumentation, + props as pullRequestProps, + PULL_REQUEST_ELEMENT, + returns as pullRequestReturns, +} from "../../composition/components/PullRequest.ts"; +import { selectedRepository } from "../../composition/context.ts"; +import { + GitOperationAdmissionError, + GitOperationInfrastructureError, + GitOperationProtocolError, + PullRequestAdmissionError, +} from "../../composition/errors.ts"; +import { NoPullRequestProvider, PULL_REQUEST_API } from "../../composition/pull-request-api.ts"; +import type { + PullRequestApi, + PullRequestInput, + PullRequestReadOptions, + PullRequestUpsertOptions, +} from "../../composition/pull-request-api.ts"; +import { + PULL_REQUEST_OPERATIONS, + PullRequestOperationsProviderError, +} from "../../composition/pull-request-operations.ts"; +import type { + PullRequestOperationsApi, + PullRequestReadInvocation, + PullRequestUpsertInvocation, +} from "../../composition/pull-request-operations.ts"; +import { + parseClosedPullRequestResult, + parsePullRequestRecord, + pullRequestResultJson, + type PullRequestInputs, + type PullRequestResult, +} from "../../composition/pull-request-records.ts"; +import { sameRepositoryIdentity } from "../../composition/selection.ts"; +import type { RepositorySelection } from "../../composition/selection.ts"; +import { reconcileOwnedGitHostEffect } from "../../git-host/effect.ts"; +import type { GitHostProvider } from "../../git-host/api.ts"; +import type { GitHostReconciliationRecord } from "../../git-host/records.ts"; +import { replPullRequestEffectRequest } from "../../retained-repository/push-admission.ts"; +import type { ReplPushPlacement } from "../../retained-repository/push-admission.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestJson, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + sameReplPullRequestAsk, + type ReplPullRequestAsk, + type ReplPullRequestRequestRecord, + type ReplPullRequestResolution, +} from "../../retained-repository/pull-request-records.ts"; +import type { ReplPushPlace } from "../../retained-repository/push-records.ts"; +import { locatorFingerprint } from "../composition/locator.ts"; +import { currentBranch, resolveCommit } from "../composition/git.ts"; +import type { GitHubSource } from "../composition/github.ts"; +import { gitHubPullRequestAccess } from "../composition/pull-request-reads.ts"; +import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; +import { pullRequestProvider } from "../composition/github-pull-request.ts"; +import { gitRefusal } from "../composition/refusals.ts"; +import { liveCheckout, selectCheckout } from "../run-composition/operations.ts"; +import { holdManagedPlacement } from "./leases.ts"; +import type { ReplGitCheckout } from "./git.ts"; +import { retainedRecordAhead } from "./local.ts"; +import type { ReplPushAccess, ReplPushEntry } from "./push.ts"; +import { placementSubject } from "./selections.ts"; +import type { ReplPlacementRegistration } from "./selections.ts"; + +/** + * The entry's own `PullRequest`, declared as an ordinary shadowable component. + * + * It shadows the inherited bundled registration for this execution's scope + * without touching it, so a repository-local `` keeps its ordinary + * precedence over both. The name is neither reserved nor protected: what a + * replacement does not get is an identity authenticator, so it names no durable + * work of this entry's. + * + * Inspection describes one `PullRequest` — the same origin, props, return + * contract, forms and prose the bundled registration carries — because it is + * the same component with this entry's identity behind it. + */ +export function replPullRequestComponent( + owner: string, + access: ReplPushAccess, + options: GitHubPullRequestsOptions = {}, +): IdentityComponent { + const upstream = gitHubPullRequestAccess(options); + return { + name: "PullRequest", + origin: COMPOSITION_ORIGIN, + props: pullRequestProps, + returns: pullRequestReturns, + // Both authored forms, as the bundled registration has them: a pull request + // with no body is written `` and its body is the + // empty string. Unlike ``, the form selects no second effect, so + // reading the content contextually decides nothing but what the body says. + forms: ["self-closing", "paired"], + ...documented(pullRequestDocumentation), + factory: (claim: IdentityClaimant) => upsertingBody(owner, access, upstream, claim), + }; +} + +function upsertingBody( + owner: string, + access: ReplPushAccess, + upstream: () => Operation, + claim: IdentityClaimant, +): FunctionComponent { + return function* PullRequest( + props: Record, + invocation: ComponentInvocation, + ): Operation { + // Before this element projects its own content, because an invocation names + // nothing while it is expanding what is inside it — and after it would be + // naming the identity from a frame the children have already left. + const site = yield* claim(invocation); + // Then the body, and completely, before a Repository is observed and before + // this upsert has a position of its own. A child durable operation in that + // content finishes at its own earlier position, and a body that failed to + // render stops everything this component owns before a request exists. + const body = (yield* hasContent()) ? yield* content() : ""; + + const repository = yield* selectedRepository(); + if (repository === undefined) { + throw new PullRequestAdmissionError( + "no-repository-context", + "it is written outside a lexical , so there is no repository in scope for a " + + "pull request to be opened in.", + ); + } + const result = yield* upsert({ + owner, + site, + access, + upstream, + pullRequest: authoredPullRequest(props, body, repository.identity.primaryBranch), + repository, + workingDirectory: yield* cwd(), + }); + return pullRequestResultJson(result); + }; +} + +interface Invocation { + readonly owner: string; + readonly site: string; + readonly access: ReplPushAccess; + readonly upstream: () => Operation; + readonly pullRequest: PullRequestInput; + readonly repository: RepositorySelection; + readonly workingDirectory: string; +} + +/** + * Open or update this invocation's pull request, or reconcile the request it + * already made. + * + * The saved request is looked for first, and found without consuming anything: + * a position holding this invocation's own request is answered from that + * request, and only an invocation with nothing saved dispatches, resolves and + * writes one down. + */ +function* upsert(invocation: Invocation): Operation { + const entry = held(invocation.access); + const registration = authenticated(entry, invocation.repository); + const requested: ReplPullRequestAsk = Object.freeze({ + pullRequest: invocation.pullRequest, + place: Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + // Exactly as received. Resolving it is live routing, which a continuation + // does not do, so the name cannot depend on it. + workingDirectory: invocation.workingDirectory, + }), + }); + const name = yield* replPullRequestRequestName({ + owner: invocation.owner, + site: invocation.site, + requested, + }); + + if (yield* retainedRecordAhead(REPL_REPOSITORY_PULL_REQUEST_REQUEST, name)) { + const saved = yield* restored(entry, invocation, requested, name); + return yield* reconcile(entry, invocation, saved); + } + + // Two invocation-local defaults under the two stable Api names, so the + // middleware a document or a plugin composed at either level applies and each + // chain ends in this invocation's own terminal rather than in the public + // refusing default. Nothing but this dispatch holds them, and they are + // revoked below. + const live: Dispatch = { open: true, delegated: false, owned: undefined }; + const transport = createApi(PULL_REQUEST_API, { + upsert: ( + pullRequest: PullRequestInput, + options: PullRequestUpsertOptions, + ): Operation => + ownedUpsert(entry, invocation, requested, name, live, pullRequest, options), + // The public refusal, for a member this descriptor never routes. It is + // unreachable through it — the only call made on it is the upsert below — + // and declaring it any other way would make this invocation's private + // terminal answer for a collection it owns nothing about. + read: refusingRead(), + }); + const profile = createApi(PULL_REQUEST_OPERATIONS, { + // Straight through to the transport surface, for the same reason the + // workflow profile passes through: what makes this upsert durable lives + // underneath, and retaining here would hold one answer under two + // identities. + upsert: (invoked: PullRequestUpsertInvocation): Operation => + transport.operations.upsert(invoked.pullRequest, { + repository: invoked.repository, + workingDirectory: invoked.workingDirectory, + }), + read: refusingOperationsRead(), + }); + + let answered: PullRequestResult; + try { + answered = yield* profile.operations.upsert({ + pullRequest: invocation.pullRequest, + repository: invocation.repository, + workingDirectory: invocation.workingDirectory, + }); + } finally { + live.open = false; + } + + const owned = live.owned; + if (owned === undefined) { + // Nothing delegated to this entry's terminal, so a handler answered the + // document itself. That answer may bind, but it is not a pull request this + // entry asked for: no request, no receipt, and none of the stability a + // retained result has. What it must still be is the closed contract this + // component promises, because a document reads its binding through it. + const synthetic = parseClosedPullRequestResult( + pullRequestResultJson(answered), + invocation.repository.identity.objectFormat, + ); + if (synthetic === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + return synthetic; + } + // The owned outcome decides, not what came back up the chain. A rewritten + // return cannot bind another pull request, and a delegated failure a handler + // caught and answered past stays a failure. + if (!owned.ok) { + throw owned.error; + } + return owned.value; +} + +/** What one invocation's own defaults have done, where only it can reach. */ +interface Dispatch { + /** Whether the invocation that captured these defaults is still running. */ + open: boolean; + /** Whether a delegation has already reached the owned terminal. */ + delegated: boolean; + /** The outcome that one delegation produced, as the authority on binding. */ + owned: Result | undefined; +} + +/** + * The one delegation this invocation's terminal accepts. + * + * It snapshots the delegated input once, authenticates it, resolves the + * complete request, proves this entry published the head, writes the request + * down and reconciles it. A second use refuses before another request or + * another worker call: the first delegation may have already opened a pull + * request, and nothing here cancels or repeats acknowledged external work. + */ +function* ownedUpsert( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPullRequestAsk, + name: string, + live: Dispatch, + pullRequest: PullRequestInput, + options: PullRequestUpsertOptions, +): Operation { + if (!live.open) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the pull request it belongs to has finished, so this dispatch names no invocation. " + + "Nothing was asked of the Git host", + ); + } + if (live.delegated) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "this pull request was already dispatched once. A second delegation of one " + + "would ask the Git host again about work this invocation has already acknowledged, so " + + "nothing was asked of it", + ); + } + live.delegated = true; + + // Read once, here. The arguments are the caller's own objects and everything + // below suspends, so reading them again later would let one pull request be + // authenticated and another opened. + const received: ReplPullRequestAsk = Object.freeze({ + pullRequest: Object.freeze({ + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + draft: pullRequest.draft, + base: pullRequest.base, + }), + place: placeOf(entry, options.repository, options.workingDirectory), + }); + + // Held privately whichever way it goes, before anything up the chain can see + // it. A failure recorded here is what stops a handler from catching it and + // answering the document with a success instead. + const outcome = yield* attempt(entry, invocation, requested, received, name); + live.owned = outcome; + if (!outcome.ok) { + throw outcome.error; + } + return outcome.value; +} + +/** The transport surface's own refusal, for a member this descriptor never routes. */ +function refusingRead(): PullRequestApi["read"] { + // deno-lint-ignore require-yield + return function* (url: string, options: PullRequestReadOptions): Operation { + throw new NoPullRequestProvider("read", url, options.provider); + }; +} + +/** The profile surface's own refusal, on the same terms. */ +function refusingOperationsRead(): PullRequestOperationsApi["read"] { + // deno-lint-ignore require-yield + return function* (invoked: PullRequestReadInvocation): Operation { + throw new PullRequestOperationsProviderError(`a `); + }; +} + +/** The owned attempt, as the `Result` that decides this invocation's binding. */ +function* attempt( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPullRequestAsk, + received: ReplPullRequestAsk, + name: string, +): Operation> { + try { + const resolved = yield* resolve(entry, invocation, received); + const record = yield* publishRequest(entry, invocation, requested, received, resolved, name); + return Ok(yield* reconcile(entry, invocation, record)); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +/** + * The complete request this invocation opens, resolved from the checkout. + * + * Publication's order, for publication's reasons: the checkout covering the + * received directory, the destination this repository belongs to, the managed + * slot that says this entry owns that checkout, and only then — inside the + * entry's own native turn — the branch it is on and the commit that branch + * holds. + * + * Then the question this protocol adds: whether this entry actually published + * that head. It is asked here, before a request is written, before an adapter + * is built, before a credential is read and before anything is contacted. + */ +function* resolve( + entry: ReplPushEntry, + invocation: Invocation, + received: ReplPullRequestAsk, +): Operation { + const routed = yield* route(entry, received.place); + const placement = routed.registration.placement; + const origin = yield* destination(routed.registration); + + yield* own(entry, routed); + + const git = yield* entry.git(); + // Serialized with this entry's own native Git, because reading HEAD beside a + // `` of the same repository would describe a moment neither of + // them is at. The turn is held for these two short reads and released before + // anything is contacted: a network round trip never holds it. + const read = yield* entry.serialize(`git ${placement.commonDirectory}`, function* () { + const checkout = liveCheckout(git, routed.checkout, received.place.workingDirectory); + const branch = yield* currentBranch(checkout.git, checkout.directory); + if (branch === undefined) { + throw gitRefusal(PULL_REQUEST_ELEMENT, "unnamed-branch"); + } + const headSha = yield* resolveCommit(checkout.git, checkout.directory, "HEAD"); + if (headSha === undefined) { + throw new GitOperationInfrastructureError( + PULL_REQUEST_ELEMENT, + "the checkout it ran in did not report the commit its branch holds", + ); + } + return { branch, headSha }; + }); + + const inputs: PullRequestInputs = Object.freeze({ + repository: received.place.repository, + number: received.pullRequest.number, + title: received.pullRequest.title, + body: received.pullRequest.body, + draft: received.pullRequest.draft, + headBranch: read.branch, + headSha: read.headSha, + baseBranch: received.pullRequest.base, + }); + + // This entry's own account of publishing that head, and nothing else. A + // refusal here happens with the Git host never having been asked anything. + entry.publications.admit(inputs, origin); + + return Object.freeze({ + checkoutRoot: routed.checkout.root, + commonDirectory: placement.commonDirectory, + origin, + inputs, + }); +} + +/** + * Where this checkout's pull requests belong, as this entry can see it now. + * + * The locator the entry admitted, held to the fingerprint the Repository + * identity carries — the same question publication asks, because a pull request + * opened against an origin nothing authenticated would be a statement about + * somebody else's repository. + */ +function* destination(registration: ReplPlacementRegistration): Operation { + const origin = yield* registration.origin(); + if (origin === undefined) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the checkout it selected records no usable origin, so there is no repository for this " + + "pull request to be opened in. No credential was read and nothing was contacted", + ); + } + if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the origin this checkout records is not the one the Repository in scope was selected " + + "from, so a pull request opened against it would name a repository this entry never " + + "authenticated", + ); + } + return origin; +} + +/** + * Write this request down, and read it back before it is acknowledged. + * + * The request is one durable position and the reconciliation below is another: + * they are two inline operations rather than one wrapped in the other, because + * a request that was acknowledged and an outcome that was not is exactly the + * history a continuation has to be able to read. + */ +function* publishRequest( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPullRequestAsk, + received: ReplPullRequestAsk, + resolved: ReplPullRequestResolution, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + function* () { + const payload = replPullRequestRequestJson({ + owner: invocation.owner, + site: invocation.site, + requested, + received, + resolved, + }); + readback(entry, invocation, requested, payload); + return payload; + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, invocation, requested, stored); +} + +/** + * The saved request standing at this position, read back for this invocation. + * + * Reached only when the cursor already holds this invocation's own request. The + * callback below is the engine's to run and this position's history says it will + * not: a live attempt here would write a request nothing resolved, so it refuses + * rather than inventing one. + */ +function* restored( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPullRequestAsk, + name: string, +): Operation { + const stored: unknown = yield createDurableOperation( + { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + // deno-lint-ignore require-yield + function* (): Operation { + throw new GitOperationInfrastructureError( + PULL_REQUEST_ELEMENT, + "its retained history named a saved pull-request request at this position and then had " + + "none, so what this entry asked the Git host for is unknown", + ); + }, + { coordinator: requestCoordinator }, + ); + return readback(entry, invocation, requested, stored); +} + +/** + * The record this value is for this invocation, or the fatal failure it is not. + * + * The same five questions on both paths, which is the point: a resolved request + * is held to them before it is written, and a restored one before it is + * reconciled. The payload parses, it is this entry's and this invocation's, it + * asks the question this position is asking, every place it names is one this + * entry's own registry holds, and the origin it says it belongs to is the one + * the Repository identity in it was fingerprinted from. + * + * That last question is asked here rather than in the codec because the digest + * is this half of the package's: a surface naming no runtime has no synchronous + * one, and a restored request is reconciled against the origin it recorded + * rather than against today's. + */ +function readback( + entry: ReplPushEntry, + invocation: Invocation, + requested: ReplPullRequestAsk, + stored: unknown, +): ReplPullRequestRequestRecord { + const record = parseReplPullRequestRequestRecord(stored); + if (record === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if ( + record.owner !== invocation.owner || + record.site !== invocation.site || + !sameReplPullRequestAsk(record.requested, requested) + ) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if (!placesHeld(record, placements(entry))) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + if ( + locatorFingerprint(record.resolved.origin) !== + record.resolved.inputs.repository.locatorFingerprint + ) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + return record; +} + +/** Whether every place this saved request names is one this entry holds. */ +function placesHeld( + record: ReplPullRequestRequestRecord, + held: readonly ReplPushPlacement[], +): boolean { + const places: readonly (readonly [string, string])[] = [ + [record.requested.place.selectionCheckout, record.requested.place.commonDirectory], + [record.received.place.selectionCheckout, record.received.place.commonDirectory], + [record.resolved.checkoutRoot, record.resolved.commonDirectory], + ]; + return places.every(([checkoutRoot, commonDirectory]) => + held.some( + (placement) => + placement.checkoutRoot === checkoutRoot && + placement.commonDirectory === commonDirectory && + sameRepositoryIdentity(placement.identity, record.received.place.repository), + ), + ); +} + +/** The places this entry's reconstructed registry holds, as parsed facts. */ +function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { + return entry.checkouts().map((checkout) => ({ + identity: checkout.registration.placement.identity, + checkoutRoot: checkout.registration.placement.checkoutRoot, + commonDirectory: checkout.registration.placement.commonDirectory, + })); +} + +/** + * Reconcile this request's external effect, and hold the result to the request. + * + * A completed outcome comes back before any native work, any adapter, any + * configuration and any credential: the shared worker replays it without + * constructing a provider at all. A pending one reaches the live provider below, + * using the saved request's own head, base, number, title, body and draft rather + * than rereading a checkout whose HEAD has usually moved. + */ +function* reconcile( + entry: ReplPushEntry, + invocation: Invocation, + record: ReplPullRequestRequestRecord, +): Operation { + const inputs = record.resolved.inputs; + const reconciliation = yield* reconcileOwnedGitHostEffect( + { + owner: invocation.owner, + site: invocation.site, + request: replPullRequestEffectRequest(inputs), + }, + { + provider: () => upstreamProvider(entry, invocation, record), + validate: (produced: GitHostReconciliationRecord) => { + if (parsePullRequestRecord(produced, inputs) === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + }, + }, + ); + const outcome = parsePullRequestRecord(reconciliation, inputs); + if (outcome === undefined) { + throw new GitOperationProtocolError(PULL_REQUEST_ELEMENT); + } + return outcome.result; +} + +/** + * The provider that answers this exact upsert, built inside the attempt. + * + * Reached only by a live attempt. The access session belongs to that attempt's + * own scope, so what the observation opened is still open when the upsert + * performs, and it is closed and joined before the reconciliation may + * acknowledge a result. + * + * It needs no checkout, no object source and no native hold. A pull request is + * a question for the service about a branch the host already has, and the saved + * request already says which branch and which commit — so a pending one is + * reconciled without asking today's filesystem anything. + */ +function* upstreamProvider( + entry: ReplPushEntry, + invocation: Invocation, + record: ReplPullRequestRequestRecord, +): Operation { + const resolved = record.resolved; + // The saved checkout, authenticated against this entry's own registry rather + // than against the filesystem. A root this entry holds no placement for is + // not somewhere it may open a pull request from, however the record came to + // name it. + const routed = entry + .checkouts() + .find( + (checkout) => + checkout.registration.placement.checkoutRoot === resolved.checkoutRoot && + checkout.registration.placement.commonDirectory === resolved.commonDirectory && + sameRepositoryIdentity( + checkout.registration.placement.identity, + resolved.inputs.repository, + ), + ); + if (routed === undefined) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the checkout its saved request names is not one this entry holds a placement for, so " + + "this pull request cannot be reconciled. Nothing was contacted", + ); + } + + const attempt: Scope = yield* useScope(); + // Where the API lives is configuration, read here rather than at + // installation, so an entry that writes no `` reads none of it. + const source = yield* invocation.upstream(); + // One session for this whole reconciliation, shared by its observations and + // its mutation, so a pull request is not created under one identity and + // observed under another. It is a resource of the attempt's own scope. + const access = yield* attempt.run(() => source.open()); + return pullRequestProvider(access, resolved.origin, resolved.inputs); +} + +/** + * Which registered checkout this repository and working directory select. + * + * The ordinary selector over this entry's own registrations, restricted by the + * whole authenticated Repository identity and resolving the received directory + * first. The longest covering checkout wins, so a subdirectory, a linked + * worktree and a captured path entered through `` each select the checkout + * their own paths belong to. + */ +function* route(entry: ReplPushEntry, received: ReplPushPlace): Operation { + const registered = entry.checkouts(); + const selected = selectCheckout( + registered.map((checkout) => checkout.checkout), + received.repository, + yield* canonical(received.workingDirectory), + PULL_REQUEST_ELEMENT, + ); + const found = registered.find((checkout) => checkout.checkout.root === selected.root); + if (found === undefined) { + throw new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the checkout it selected is not one this entry holds a placement for", + ); + } + return found; +} + +/** + * Take ownership of the checkout this pull request is opened from. + * + * The managed slot alone, as publication takes it: the slot says which entry + * owns a managed checkout, which is what reading its branch and head needs. No + * mutation lease, because a pull request changes no checkout, index, branch or + * tracking configuration. + */ +function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { + const registration = target.registration; + const slot = registration.slot; + const root = registration.placement.managedRoot; + if (slot === undefined || root === undefined) { + return; + } + yield* holdManagedPlacement(() => + entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), + ); +} + +/** One reading of where this pull request was asked for. */ +function placeOf( + entry: ReplPushEntry, + selection: RepositorySelection, + workingDirectory: string, +): ReplPushPlace { + const registration = authenticated(entry, selection); + return Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + workingDirectory, + }); +} + +/** + * The canonical directory this path resolves to, or the path as written. + * + * Routing resolves the received directory exactly as recorded local Git and + * publication do, because a working directory reached through a link is the + * same place under another name. Only live routing resolves anything: a saved + * request's roots are authenticated against parsed placements instead. + */ +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +/** The private entry state this execution holds, or the ordinary refusal. */ +function held(access: ReplPushAccess): ReplPushEntry { + const entry = access(); + if (entry === undefined) { + // The same failure the public surface's own default raises, because that is + // what this condition is: no retained pull-request capability was + // established for this execution, so nothing performed anything. + throw new PullRequestOperationsProviderError(PULL_REQUEST_ELEMENT); + } + return entry; +} + +/** What this selection names in this entry's registry, or an admission refusal. */ +function authenticated( + entry: ReplPushEntry, + selection: RepositorySelection, +): ReplPlacementRegistration { + return entry.selections.authenticate( + selection, + () => + new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the Repository in scope is not one this entry selected, so it names no checkout", + ), + ); +} + +/** + * The live boundary one written request passes through. + * + * The default coordinator would serialize whatever was thrown and publish it as + * this operation's outcome, which is the one thing that must not happen here: a + * request written for work nobody resolved would send a continuation to + * reconcile a pull request this entry never asked for. + * + * A throw therefore activates the run's existing fail-stop state and is + * re-raised with its identity intact, writing nothing. A valid request is + * written once and returned only after the append is acknowledged, which is what + * makes "no service contact before the request exists" a property of the + * ordering rather than a promise. + */ +const requestCoordinator: LiveDurableOperationCoordinator = { + *run( + execute: () => Operation, + publish: (result: ProtocolResult) => Operation, + activateFailure: ActivateDurabilityFailure, + ): Operation { + let value: T; + try { + value = yield* execute(); + } catch (error) { + throw activateFailure(error); + } + const result: ProtocolResult = { status: "ok", value }; + yield* publish(result); + return result; + }, +}; diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 715d13902..192648c36 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -179,6 +179,7 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/retained-repository/local.ts", "src/deno/retained-repository/observations.ts", "src/deno/retained-repository/provider.ts", + "src/deno/retained-repository/pull-request.ts", "src/deno/retained-repository/push.ts", "src/deno/retained-repository/selections.ts", "src/deno/run-composition/ambient.ts", From dfb32b6ce6cec0eb18653bd21a5e55122dcaf769 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:27:24 -0400 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=9A=A7=20Retain=20one=20entry's=20e?= =?UTF-8?q?vidence=20reads=20behind=20the=20current=20ceiling=20(#181,=205?= =?UTF-8?q?/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers 4 and 5 of delivery 5. Not the feedback commit: F1–F6 have not run. The three evidence reads become the entry's own identity components, declared and installed whether or not the entry holds a checkout: a read names a URL, so it needs no Repository, working directory, managed root, Git session or native hold, and an entry whose document only reads evidence acquires none of them. Retention begins at the admitted-read seam and nowhere earlier. The adapter still decides on every execution whether it recognizes the request, whether this deployment's allowed list authorizes the target and whether the URL names a pull request it can read — including the execution that reopens an entry whose read is already saved. Removing the repository from the allowed list therefore refuses to continue, which is the accepted contract rather than an accident; the saved record is not erased by that refusal, it is simply not reachable while the ceiling says so. Wrapping the public dispatch in a durable envelope instead would have serialized those refusals as read outcomes. What is retained is the normalized answer produced inside the seam. A URL or provider transformation before admission changed which read was admitted, so the record says so. One after delegation is ordinary composition: it runs again on continuation and may produce a different array from the same saved provider answer, and nothing here wraps it in a second envelope and claims it was saved. A handler that never reaches the seam retains nothing. Identity travels in a bridge installed in each read's own scope, holding the owner and the invocation that read authenticated, and permitting exactly one admitted handoff. Installing it there is what makes nesting and concurrency work with no coordination between reads: a nested read's bridge is deeper and wins for the invocation inside it, two concurrent reads finishing in either order each keep their own question, and a finished read has no bridge to borrow. An entry-wide pointer, a value on `next` or a Context identity would each have to be right about an ordering nothing guarantees. The existing `pull_request_read` description, name algorithm and envelope are reused rather than reimplemented — the name is already in journals a fork reads — so the description, the retained-value reading and the element vocabulary are now shared helpers and the Workflow profile calls the same ones. Evidence: pull-request-read + pull-request-read-fork 30/30 (fork matching and the owner-omitting name unchanged), repl-push + repl-issues + repl-repositories 38/38 with all six components declared, module-partition 7/7, `deno task check` clean. --- packages/cli/tests/repl-issues.test.ts | 3 + packages/cli/tests/repl-push.test.ts | 3 + .../composition/pull-request-operations.ts | 76 +++-- .../retained-repository/pull-request-reads.ts | 318 ++++++++++++++++++ packages/git/tests/module-partition.test.ts | 1 + 5 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 packages/git/src/deno/retained-repository/pull-request-reads.ts diff --git a/packages/cli/tests/repl-issues.test.ts b/packages/cli/tests/repl-issues.test.ts index 5d84fa3af..633cecdeb 100644 --- a/packages/cli/tests/repl-issues.test.ts +++ b/packages/cli/tests/repl-issues.test.ts @@ -1503,6 +1503,9 @@ describe("how a REPL entry gets its issues", () => { "Git.Push", "Issue", "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); }); diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index ce44cf223..768407ff2 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -440,6 +440,9 @@ describe("how a REPL entry gets its publication capability", () => { "Git.Push", "Issue", "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", ]); expect((built.admissions ?? []).length).toBe(2); expect(yield* nothingUnder(fixture.managed)).toBe(true); diff --git a/packages/git/src/deno/composition/pull-request-operations.ts b/packages/git/src/deno/composition/pull-request-operations.ts index bb63af11c..f7dd6239b 100644 --- a/packages/git/src/deno/composition/pull-request-operations.ts +++ b/packages/git/src/deno/composition/pull-request-operations.ts @@ -76,7 +76,17 @@ const ELEMENT: Readonly> = Object.freeze({ checks: "", }); -function* describeRead(request: PullRequestReadRequest): Operation { +/** + * The durable description one read is retained under. + * + * Shared with the entry's own retained reads: the name is already in journals a + * fork reads, so there is one algorithm for it and this is where it lives. The + * entry supplies its owner as the run and its authenticated invocation as the + * expansion; nothing else about it differs. + */ +export function* describePullRequestRead( + 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 @@ -116,7 +126,7 @@ function retainDurableRead( const element = ELEMENT[request.kind]; return scoped(function* () { - const description = yield* describeRead(request); + const description = yield* describePullRequestRead(request); const stored = yield createDurableOperation( description, @@ -134,29 +144,51 @@ function retainDurableRead( }, ); - 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; + return retainedPullRequestRead(stored, request.kind, element); }); } +/** + * The read this retained value describes, or the refusal that it describes none. + * + * Shared by both retained profiles, and applied on both of their paths: a live + * answer is held to it before it is appended, and a restored one before it is + * handed back. A retained value that is not the evidence that read produces, + * or that is a different collection than the one asked for, is refused rather + * than bound. + */ +export function retainedPullRequestRead( + stored: unknown, + kind: PullRequestReadKind, + element: string, +): PullRequestReadResult { + 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 !== kind) { + throw new PullRequestReadError( + "protocol", + element, + "what this run retained for it is not the evidence that read produces.", + ); + } + return result; +} + +/** Which element a pull-request read refusal names, by its collection. */ +export function pullRequestReadElement(kind: PullRequestReadKind): string { + return ELEMENT[kind]; +} + /** * Make an admitted read durable, for this scope and below. * diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts new file mode 100644 index 000000000..df00d4b61 --- /dev/null +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -0,0 +1,318 @@ +/** + * One REPL entry's ``, `.Comments` and `.Checks`: read + * once, and answered from what the service said that time. + * + * `pull-request.ts` beside this opens the pull request; these three read what + * it has collected since. They are the cheapest of the entry's retained + * operations and the least like the others: a read needs no Repository, no + * working directory, no managed root, no Git session and no native hold. It + * needs a URL. So these identities are declared and installed even for an entry + * that holds no checkout at all, and an entry whose document only reads + * evidence acquires nothing. + * + * ## Where retention begins, and what is deliberately outside it + * + * The GitHub adapter decides three things before anything is retained: whether + * it recognizes the request, whether this deployment's allowed list authorizes + * the target, and whether the URL names a pull request it can read. None of + * those is a lifecycle question and none of them may leave a record — a target + * this host never authorized is a question the entry was not permitted to ask, + * not a read that failed. + * + * Those three run on *every* execution, including the one that reopens an entry + * whose read is already saved. That is the accepted contract rather than an + * accident: remove the repository from the allowed list and reopening refuses + * to continue, even though the reviews were saved an hour ago. The saved record + * is not erased by that refusal; it simply is not reachable while the ceiling + * says so. An allowed completed read opens no session and sends nothing. + * + * ## What is retained is what the provider answered + * + * Retention begins at the admitted-read seam and holds exactly the normalized + * answer produced inside it. A URL or provider transformation *before* + * admission changes the question that was recorded, because it changed which + * read was admitted. A transformation *after* delegation is outside the + * snapshot: it is ordinary composition, it runs again on continuation, and it + * may produce a different array from the same saved provider answer. Nothing + * here wraps that composition in a second envelope and claims it was saved. A + * handler that answers without ever reaching the seam has retained nothing at + * all. + * + * ## One bridge per read + * + * The adapter's seam carries only the question — a URL, a collection and a + * discriminator. Who is asking is this entry's business and travels nowhere + * public: each authored read installs a bridge in a scope of its own, holding + * the owner and the invocation it authenticated, and that bridge permits + * exactly one admitted handoff before it is spent. + * + * Installing it in the read's own scope is what makes nesting and concurrency + * work without any coordination between reads. A nested authored read installs + * a deeper bridge, so the deeper one wins for the invocation inside it and + * cannot consume its parent's handoff; two concurrent reads finishing in either + * order each keep their own URL, collection, owner and invocation; and a read + * that has ended has no bridge left to borrow. An entry-wide pointer at the + * provider, a value on `next` or a Context identity would each have to be + * correct about an ordering nothing guarantees. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { documented, formDispatcher } from "@executablemd/core"; +import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; +import type { Around } from "@effectionx/context-api"; +import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; +import { + checksDocumentation, + checksReturns, + CHECKS_ELEMENT, + commentsDocumentation, + commentsReturns, + COMMENTS_ELEMENT, + props as pullRequestReadProps, + pullRequestReadBinding, + pullRequestReadTarget, + reviewsDocumentation, + reviewsReturns, + REVIEWS_ELEMENT, +} from "../../composition/components/PullRequestReads.ts"; +import { PullRequestReadError } from "../../composition/errors.ts"; +import { PullRequestOperations } from "../../composition/pull-request-operations.ts"; +import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; +import type { + AdmittedPullRequestRead, + PullRequestReadExecutionApi, +} from "../../composition/pull-request-read-execution.ts"; +import { + pullRequestReadEnvelopeJson, + readRequest, +} from "../../composition/pull-request-read-records.ts"; +import type { + PullRequestReadKind, + PullRequestReadRequest, + PullRequestReadResult, +} from "../../composition/pull-request-read-records.ts"; +import type { FirstPartyDocumentation, ReturnsSchema } from "@executablemd/core"; +import { + describePullRequestRead, + pullRequestReadElement, + retainedPullRequestRead, +} from "../composition/pull-request-operations.ts"; + +/** What one authored read carries that nothing public may name. */ +interface ReadBridge { + readonly owner: string; + readonly site: string; + /** Whether the one admitted handoff this bridge permits has been taken. */ + spent: boolean; + /** Whether the read that installed it is still running. */ + open: boolean; +} + +/** The three collections, with everything their declarations differ by. */ +const COLLECTIONS: readonly { + readonly kind: PullRequestReadKind; + readonly name: string; + readonly element: string; + readonly returns: ReturnsSchema; + readonly documentation: FirstPartyDocumentation; +}[] = [ + { + kind: "reviews", + name: "PullRequest.Reviews", + element: REVIEWS_ELEMENT, + returns: reviewsReturns, + documentation: reviewsDocumentation, + }, + { + kind: "comments", + name: "PullRequest.Comments", + element: COMMENTS_ELEMENT, + returns: commentsReturns, + documentation: commentsDocumentation, + }, + { + kind: "checks", + name: "PullRequest.Checks", + element: CHECKS_ELEMENT, + returns: checksReturns, + documentation: checksDocumentation, + }, +]; + +/** + * The entry's own three evidence reads, as ordinary shadowable components. + * + * Each shadows the inherited bundled registration for this execution's scope + * without touching it, so a repository-local replacement keeps its ordinary + * precedence over both. What a replacement does not get is an identity + * authenticator, so it names no retained read of this entry's. + * + * Inspection describes one component per collection — the same origin, props, + * return contract, self-closing form and prose the bundled registrations carry. + */ +export function replPullRequestReadComponents(owner: string): IdentityComponent[] { + return COLLECTIONS.map((collection) => ({ + name: collection.name, + origin: COMPOSITION_ORIGIN, + props: pullRequestReadProps, + returns: collection.returns, + forms: ["self-closing"] as const, + ...documented(collection.documentation), + factory: (claim: IdentityClaimant) => + // Canonical form dispatch, so the form is settled by the scan before the + // body runs, and a paired invocation is refused with the component's own + // sentence rather than reaching a read. + formDispatcher({ + forms: "self-closing", + fn: readingBody(owner, claim, collection.kind, collection.element), + refuse: () => + new PullRequestReadError( + "unexpected-content", + collection.element, + "it names a pull request to read and renders nothing of its own, so it is written " + + "self-closing.", + ), + }), + })); +} + +function readingBody( + owner: string, + claim: IdentityClaimant, + kind: PullRequestReadKind, + element: string, +): FunctionComponent { + return function* PullRequestRead( + props: Record, + invocation: ComponentInvocation, + ): Operation { + // The same reading of the same props the bundled component makes, and its + // refusals are the component's own settled sentences. A URL this element + // cannot name is refused here, before a provider is asked anything. + const target = pullRequestReadTarget(props, element); + const site = yield* claim(invocation); + + return yield* scoped(function* (): Operation { + // This read's own bridge, in this read's own scope. It is gone when this + // scope ends, which is what leaves a finished read nothing to borrow. + const bridge: ReadBridge = { owner, site, spent: false, open: true }; + yield* PullRequestReadExecution.around(retaining(bridge, kind), { at: "min" }); + try { + // The ordinary dispatch, so every public handler a document or a plugin + // composed still answers, transforms, refuses and delegates. What comes + // back is whatever composed above the provider, and it is held to the + // collection this element asked for before any of it binds. + return pullRequestReadBinding( + yield* PullRequestOperations.operations.read({ + url: target.url, + kind, + provider: target.provider, + }), + kind, + element, + ); + } finally { + bridge.open = false; + } + }); + }; +} + +/** + * Retain one admitted read under this entry's identity. + * + * Installed at the implementation slot, so it is the innermost policy and the + * adapter reaches it only after it has admitted the read. It consumes the one + * handoff its bridge permits: a second admitted read under the same bridge, and + * one reaching a bridge whose read has finished, each refuse before a record or + * a session exists. + */ +function retaining( + bridge: ReadBridge, + kind: PullRequestReadKind, +): Partial> { + return { + *perform([admitted, transport]: [ + AdmittedPullRequestRead, + () => Operation, + ]): Operation { + const element = pullRequestReadElement(admitted.kind); + if (!bridge.open) { + throw new PullRequestReadError( + "protocol", + element, + "the read it belongs to has finished, so this dispatch names no invocation. Nothing " + + "was asked of the Git host.", + ); + } + if (bridge.spent) { + throw new PullRequestReadError( + "protocol", + element, + "this read was already admitted once. A second admitted read under one element would " + + "retain two answers under one identity, so nothing was asked of the Git host.", + ); + } + if (admitted.kind !== kind) { + throw new PullRequestReadError( + "protocol", + element, + "the admitted read is for a different collection than the element that owns this " + + "dispatch, so it is not this read's answer to retain.", + ); + } + bridge.spent = true; + + return yield* retain( + // Owner in the run, and the authenticated invocation in the expansion, + // which is the existing request shape. The question is the one that was + // *admitted*: a handler that rewrote the URL or the discriminator before + // the adapter matched it changed which read this is, and the record says + // so. + readRequest(admitted.url, admitted.kind, admitted.provider, bridge.owner, bridge.site), + element, + transport, + ); + }, + }; +} + +/** + * Retain this admitted read, or restore what is already retained. + * + * One durable position, described and named by the existing algorithm so a + * journal a fork reads keeps saying the same thing. The whole envelope is + * validated before it is appended and again when it is read back: a value that + * is not the evidence this read produces is refused rather than bound, and only + * `items` ever becomes the component's binding. + * + * The transport's own session and pagination belong to the callback, so they are + * finished and joined before this position publishes anything. An expected + * failure after admission is retained as this read's own and binds no partial + * array; an interruption before acknowledgement leaves the read repeatable, + * which is safe in the way repeating a mutation is not. + */ +function* retain( + request: PullRequestReadRequest, + element: string, + transport: () => Operation, +): Operation { + const description = yield* describePullRequestRead(request); + const stored: unknown = yield createDurableOperation(description, function* () { + const answered = yield* transport(); + 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); + }); + return retainedPullRequestRead(stored, request.kind, element); +} diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 192648c36..b6fe237f9 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -179,6 +179,7 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/retained-repository/local.ts", "src/deno/retained-repository/observations.ts", "src/deno/retained-repository/provider.ts", + "src/deno/retained-repository/pull-request-reads.ts", "src/deno/retained-repository/pull-request.ts", "src/deno/retained-repository/push.ts", "src/deno/retained-repository/selections.ts", From e84490198d34c021f7eb24db29a0b80a5d9d0fd3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:35:47 -0400 Subject: [PATCH 04/17] =?UTF-8?q?=F0=9F=9A=A7=20Hold=20a=20saved=20read=20?= =?UTF-8?q?to=20its=20owner,=20and=20an=20orphan=20to=20its=20own=20name?= =?UTF-8?q?=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of layer 6 of delivery 5: V6's portable evidence. Not the feedback commit. The entry's evidence reads gain the admission they were missing. Their name deliberately omits the owner — a fork is a different run reaching the same position with the same question, and a name carrying the run would make every inherited read unforkable — so the name alone cannot say whose retained collection a record describes. The owner is checked explicitly instead, and it is checked on a failed read's description too: a refusal retains no result, and its description is the only thing that says who asked. That admission splits where the saved publication request splits. The record, the owner and the retained collection are provider-neutral questions; the name is a synchronous native digest and belongs to the runtime-named half, so the caller computes it and the neutral half answers everything a name cannot. Writing V6 found a real defect in the coordinated classifier. Orphan and duplicate were told apart by whether anything earlier in the coroutine was already paired, which was sound while publication was the only protocol there. With two protocols settling through one durable type, a completed publication pair earlier in the same coroutine is ordinary — so a genuinely orphaned pull-request outcome was being reported as a duplicate, describing a history that was not the one in front of it. Which of the two it is, is now a question about this outcome: a duplicate is one carrying the name of a request already answered here. V6 also pins both naming algorithms against each other: the canonical-JSON request digest and the length-prefixed UTF-16 read fingerprint are not interchangeable, and one vector carries a null number, text outside ASCII and an unpaired surrogate so a change to either moves it. Evidence: F1 complete and green — retained-repository-pull-request-records 23/23, retained-repository-push-records 13/13, git-host-owned, pull-request-records, module-partition 7/7, 81 steps over 9 suites. The new suite is portable: 23/23 under Deno, 23/23 under Node (tsx), 23/23 under Bun. --- packages/cli/tests/repl-push.test.ts | 4 +- .../composition/pull-request-read-records.ts | 47 + .../retained-repository/pull-request-reads.ts | 44 +- .../pull-request-read-admission.ts | 151 +++ .../src/retained-repository/push-admission.ts | 15 +- packages/git/tests/module-partition.test.ts | 1 + ...ed-repository-pull-request-records.test.ts | 976 ++++++++++++++++++ 7 files changed, 1233 insertions(+), 5 deletions(-) create mode 100644 packages/git/src/retained-repository/pull-request-read-admission.ts create mode 100644 packages/git/tests/retained-repository-pull-request-records.test.ts diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index 768407ff2..a9c8575c3 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -444,7 +444,9 @@ describe("how a REPL entry gets its publication capability", () => { "PullRequest.Comments", "PullRequest.Reviews", ]); - expect((built.admissions ?? []).length).toBe(2); + // Three: the issues history, the entry's publication and pull-request + // history, and its evidence reads. + expect((built.admissions ?? []).length).toBe(3); expect(yield* nothingUnder(fixture.managed)).toBe(true); }); }); diff --git a/packages/git/src/composition/pull-request-read-records.ts b/packages/git/src/composition/pull-request-read-records.ts index dddbc86d6..c5fa5c409 100644 --- a/packages/git/src/composition/pull-request-read-records.ts +++ b/packages/git/src/composition/pull-request-read-records.ts @@ -230,6 +230,53 @@ export function pullRequestReadRequestJson(request: PullRequestReadRequest): Jso }; } +const READ_REQUEST_MEMBERS = ["operation", "url", "provider", "kind", "runId", "expansionId"]; + +/** + * The normalized read request this retained description carries, or + * `undefined`. + * + * Closed and exact, because this is what a continuation decides ownership from: + * the owner is in `runId`, and a description carrying one more member than the + * contract declares is not a description this host wrote. The question itself — + * operation, URL, discriminator and collection — is what the name is derived + * from, so reading it back is how a saved read is held to the name it is + * journaled under. + */ +export function parsePullRequestReadRequest( + value: Json | undefined, +): PullRequestReadRequest | undefined { + if (!isJsonObject(value) || !exactMembers(value, READ_REQUEST_MEMBERS)) { + return undefined; + } + const kind = PULL_REQUEST_READ_KINDS.find((known) => known === value.kind); + const url = value.url; + const provider = value.provider; + const runId = value.runId; + const expansionId = value.expansionId; + if ( + value.operation !== "read" || + kind === undefined || + typeof url !== "string" || + url === "" || + !(provider === null || (typeof provider === "string" && provider !== "")) || + typeof runId !== "string" || + runId === "" || + typeof expansionId !== "string" || + expansionId === "" + ) { + return undefined; + } + return Object.freeze({ + operation: "read" as const, + url, + provider, + kind, + runId, + expansionId, + }); +} + /** * Exactly these members, and no others. * diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index df00d4b61..e0aa3790c 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -59,10 +59,14 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { createDurableOperation } from "@executablemd/durable-streams"; -import type { Json } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; import { documented, formDispatcher } from "@executablemd/core"; import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; -import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; +import type { + IdentityClaimant, + IdentityComponent, + JournalAdmission, +} from "@executablemd/core/host"; import type { Around } from "@effectionx/context-api"; import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; import { @@ -90,6 +94,11 @@ import { pullRequestReadEnvelopeJson, readRequest, } from "../../composition/pull-request-read-records.ts"; +import { + admitReplPullRequestReadRequest, + admitReplPullRequestReadResult, + replPullRequestReadNameRefusal, +} from "../../retained-repository/pull-request-read-admission.ts"; import type { PullRequestReadKind, PullRequestReadRequest, @@ -98,6 +107,7 @@ import type { import type { FirstPartyDocumentation, ReturnsSchema } from "@executablemd/core"; import { describePullRequestRead, + PULL_REQUEST_READ, pullRequestReadElement, retainedPullRequestRead, } from "../composition/pull-request-operations.ts"; @@ -316,3 +326,33 @@ function* retain( }); return retainedPullRequestRead(stored, request.kind, element); } + +/** + * What this entry requires of the evidence reads its history holds. + * + * The record, the owner and the retained collection are the neutral + * admission's questions; what is asked here is the one it cannot ask, because + * the read's name is a synchronous native digest that belongs to this half of + * the package. + */ +export function replPullRequestReadAdmission(owner: string): JournalAdmission { + return function* (retained: readonly DurableEvent[]): Operation { + for (const event of retained) { + if (event.type !== "yield" || event.description.type !== PULL_REQUEST_READ) { + continue; + } + const request = admitReplPullRequestReadRequest(owner, event.description["input"]); + const described = yield* describePullRequestRead(request); + if (event.description.name !== described.name) { + throw replPullRequestReadNameRefusal(); + } + if (event.result.status !== "ok") { + // A refused read retains no result of its own: its outcome is the + // durable operation's failed result, and there is nothing in it to hold + // to a collection. Its description was still held to this entry above. + continue; + } + admitReplPullRequestReadResult(request, event.result.value); + } + }; +} diff --git a/packages/git/src/retained-repository/pull-request-read-admission.ts b/packages/git/src/retained-repository/pull-request-read-admission.ts new file mode 100644 index 000000000..6c6125fc5 --- /dev/null +++ b/packages/git/src/retained-repository/pull-request-read-admission.ts @@ -0,0 +1,151 @@ +/** + * Whether one entry's retained evidence reads may be continued at all. + * + * Three questions. **Integrity**: every retained read's description parses as + * the closed normalized request. **Ownership**: every one of them names this + * entry's owner. **Result**: a settled one holds the envelope its own + * collection produces, with every check it carries about the head it recorded. + * + * Ownership is asked explicitly, and that is the whole reason this boundary + * exists. The read name deliberately omits the owner — a fork is a different + * run reaching the same position with the same question, and a name carrying + * the run would make every inherited read unforkable — so the name alone cannot + * tell this entry's read from another's. A record whose `runId` is somebody + * else's is refused however correct its name is, and that is asked of a failed + * read's description too: a failure retains no result to check, but its + * description still says who asked. + * + * Nothing here contacts a URL, opens a session or hands a result out. + * Replaying a completed read is the durable engine's own positional matching, + * and this runs before any of it. Whether the ceiling still authorizes a saved + * read is a different question, asked by the adapter on every execution. + * + * ## Why the name check is not here + * + * The read's name is a synchronous native digest, and that digest belongs to + * the runtime-named half of this package rather than to this graph — the same + * split the saved publication request keeps when it leaves its origin's + * fingerprint to the provider that reads it back. So the caller computes the + * name and this module answers everything a name cannot: what the record is, + * whose it is, and whether its value is the evidence it claims. + */ + +import { StaleInputError } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { + parsePullRequestReadRequest, + parsePullRequestReadResult, +} from "../composition/pull-request-read-records.ts"; +import type { + PullRequestReadRequest, + PullRequestReadResult, +} from "../composition/pull-request-read-records.ts"; + +/** Why one entry's retained evidence-read history cannot be continued. */ +export type ReplPullRequestReadRefusal = + /** A retained read whose description this host cannot read. */ + | "malformed-record" + /** A retained read naming a history that is not this entry's. */ + | "foreign-owner" + /** A retained read journaled under a name its own question does not produce. */ + | "request-fingerprint" + /** A settled read holding a value that is not the evidence a read produces. */ + | "malformed-result"; + +/** + * A retained evidence-read history this entry cannot continue from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. + */ +export class ReplPullRequestReadHistoryError extends StaleInputError { + override name = "ReplPullRequestReadHistoryError"; + + readonly reason: ReplPullRequestReadRefusal; + + constructor(reason: ReplPullRequestReadRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * The question one retained read asked, held to this entry, or the refusal. + * + * Asked of every retained read, settled or failed, because a description says + * who asked whether or not there is a result beside it. + */ +export function admitReplPullRequestReadRequest( + owner: string, + input: Json | undefined, +): PullRequestReadRequest { + const request = parsePullRequestReadRequest(input); + if (request === undefined) { + throw readRefusal( + "malformed-record", + "a retained evidence read in this entry's history is not one this host can read, so what " + + "it says was read is unknown.", + ); + } + if (request.runId !== owner) { + throw readRefusal( + "foreign-owner", + "a retained evidence read in this entry's history was written under another owner, so the " + + "collection it describes was not read by this entry and its record is not one this " + + "entry can present again.", + ); + } + return request; +} + +/** The refusal a read journaled under the wrong name gets. */ +export function replPullRequestReadNameRefusal(): ReplPullRequestReadHistoryError { + return readRefusal( + "request-fingerprint", + "a retained evidence read is journaled under a name that is not the name its own question " + + "produces, so the record and the read it claims to be do not match.", + ); +} + +/** + * The collection one settled read retained, or the refusal that it retained + * something else. + * + * The envelope has to be the one this question asked for, and a checks envelope + * has to be internally consistent: every item about the head it recorded. That + * is asked even of an empty collection, because an empty checks result still + * says which head was read and that head is the whole of what it claims. + */ +export function admitReplPullRequestReadResult( + request: PullRequestReadRequest, + value: Json | undefined, +): PullRequestReadResult { + const envelope = value === undefined ? undefined : parsePullRequestReadResult(value); + if (envelope === undefined || envelope.kind !== request.kind) { + throw readRefusal( + "malformed-result", + "a retained evidence read holds a value that is not the collection its own question asked " + + "for, so what it says was read is unknown.", + ); + } + if (envelope.kind === "checks" && !sameHead(envelope.headSha, envelope.items)) { + throw readRefusal( + "malformed-result", + "a retained checks read holds an item for a different head than the one it recorded, so " + + "what it says about that commit is unknown.", + ); + } + return envelope; +} + +/** Whether every check in this collection is about the head that was read. */ +function sameHead(headSha: string, items: readonly { readonly headSha: string }[]): boolean { + return items.every((item) => item.headSha === headSha); +} + +function readRefusal( + reason: ReplPullRequestReadRefusal, + sentence: string, +): ReplPullRequestReadHistoryError { + return new ReplPullRequestReadHistoryError(reason, sentence); +} diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts index 7353cdb3d..6720b469e 100644 --- a/packages/git/src/retained-repository/push-admission.ts +++ b/packages/git/src/retained-repository/push-admission.ts @@ -589,8 +589,19 @@ function* admitOutcome( const queued = pending.get(event.coroutineId) ?? []; const request = queued.pop(); if (request === undefined) { - const spent = answered.get(event.coroutineId) ?? []; - throw spent.length > 0 + // Which of the two this is, is a question about *this* outcome rather than + // about how much of the coroutine is already paired. With two protocols + // settling through one durable type, a completed pair earlier in the same + // coroutine is ordinary, and calling an orphan a duplicate because one + // exists would describe a history that is not this one. + let answeredTwice = false; + for (const candidate of answered.get(event.coroutineId) ?? []) { + if (event.description.name === (yield* outcomeNameOf(candidate))) { + answeredTwice = true; + break; + } + } + throw answeredTwice ? refusal( "duplicate-outcome", "a saved request in this entry's history is answered by more than one Git-host " + diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index b6fe237f9..bebb5f207 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -102,6 +102,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/retained-repository/observation-records.ts", "src/retained-repository/placement-records.ts", "src/retained-repository/publication-evidence.ts", + "src/retained-repository/pull-request-read-admission.ts", "src/retained-repository/pull-request-records.ts", "src/retained-repository/push-admission.ts", "src/retained-repository/push-records.ts", diff --git a/packages/git/tests/retained-repository-pull-request-records.test.ts b/packages/git/tests/retained-repository-pull-request-records.test.ts new file mode 100644 index 000000000..878d871eb --- /dev/null +++ b/packages/git/tests/retained-repository-pull-request-records.test.ts @@ -0,0 +1,976 @@ +/** + * Tier RPR — what a REPL entry's saved pull-request request says, what a + * history holding one beside its publications is allowed to look like, and + * whose retained evidence reads are whose. + * + * Three boundaries, all provider-neutral and all pure. The **codec** decides + * what a saved request is: the shape, the closed origin grammar, and the facts + * the three readings of one question have to agree about. The **coordinated + * admission** decides whether a whole history may be continued now that two + * external protocols settle through one durable type — which is the part that + * could not be answered by either protocol alone. The **read admission** + * decides whose retained collection a record describes, which a name that + * deliberately omits the owner cannot say. + * + * Nothing here opens a checkout, resolves a path or contacts anything, because + * none of these boundaries may: a continuation whose managed worktree was + * deleted while the person was away answers from what it already wrote down. + * + * These cases carry V6 of the delivery-5 matrix, the ordering consequence V2 + * states, and the request half of V3's trailing-request history. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Ok } from "effection"; +import type { Operation } from "effection"; +import { serializeError } from "@executablemd/durable-streams"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { + destinationRefFor, + gitPushObservationsJson, + gitPushPreStateJson, + gitPushResultJson, + PUSH_REMOTE, + refspecFor, + type GitPushInputs, +} from "../src/composition/git-push-records.ts"; +import { locatorFingerprint } from "../src/deno/composition/locator.ts"; +import type { RepositoryIdentity } from "../src/composition/selection.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; +import { + gitHostReconciliationRecordJson, + gitHostRequestFingerprint, +} from "../src/git-host/records.ts"; +import type { PullRequestInput } from "@executablemd/git/api"; +import { + pullRequestInputsJson, + pullRequestNaturalKey, + pullRequestNaturalKeyJson, + pullRequestResultJson, + pullRequestSnapshotJson, + type PullRequestInputs, + type PullRequestSnapshot, +} from "../src/composition/pull-request-records.ts"; +import { + admitReplPushHistory, + replPullRequestOutcomeName, + replPushEffectRequest, + replPushOutcomeName, + ReplPushHistoryError, + type ReplPushPlacement, +} from "../src/retained-repository/push-admission.ts"; +import { + parseReplPullRequestRequestRecord, + replPullRequestRequestJson, + replPullRequestRequestName, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, + sameReplPullRequestAsk, + type ReplPullRequestAsk, + type ReplPullRequestRequestRecord, +} from "../src/retained-repository/pull-request-records.ts"; +import { + replPushRequestJson, + replPushRequestName, + REPL_REPOSITORY_PUSH_REQUEST, + type ReplPushPlace, + type ReplPushRequestRecord, +} from "../src/retained-repository/push-records.ts"; +import { parseReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; +import { + admitReplPullRequestReadRequest, + admitReplPullRequestReadResult, + ReplPullRequestReadHistoryError, +} from "../src/retained-repository/pull-request-read-admission.ts"; +import { + pullRequestReadRequestJson, + readRequest, +} from "../src/composition/pull-request-read-records.ts"; +import { + replPlacementFingerprint, + replPlacementRecordJson, + replPlacementRecordName, + REPL_REPOSITORY_PLACEMENT, + type ReplPlacement, +} from "../src/retained-repository/placement-records.ts"; + +const OWNER = '["executablemd.repl.issue-owner",1,"execution-5","entry-1"]'; +const OTHER_OWNER = '["executablemd.repl.issue-owner",1,"execution-5","entry-2"]'; +const SITE = "expansion-9c1"; +const PUSH_SITE = "expansion-4b2"; + +const ORIGIN = "/srv/remotes/project.git"; +const CHECKOUT = "/home/dev/.xmd/repositories/project/checkout"; +const COMMON = "/home/dev/.xmd/repositories/project/checkout/.git"; +const BRANCH = "review/notes"; +const HEAD = "9fceb02d8c5a1b4e7f03a6d9c2b8e5174a0f3b6d"; +const MOVED_HEAD = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"; +const BASE_SHA = "beefcafe00000000000000000000000000000001"; +const URL = "https://github.com/octo/project/pull/7"; + +const IDENTITY: RepositoryIdentity = Object.freeze({ + name: "project", + locatorFingerprint: locatorFingerprint(ORIGIN), + requestedBase: null, + creationCommit: "c0ffee00000000000000000000000000deadbeef", + primaryBranch: "main", + objectFormat: "sha1", +}); + +const PLACE: ReplPushPlace = Object.freeze({ + repository: IDENTITY, + selectionCheckout: CHECKOUT, + commonDirectory: COMMON, + workingDirectory: CHECKOUT, +}); + +const AUTHORED: PullRequestInput = Object.freeze({ + number: null, + title: "Prepare notes", + body: "Ready for review.", + draft: false, + base: "main", +}); + +const ASK: ReplPullRequestAsk = Object.freeze({ pullRequest: AUTHORED, place: PLACE }); + +const INPUTS: PullRequestInputs = Object.freeze({ + repository: IDENTITY, + number: null, + title: AUTHORED.title, + body: AUTHORED.body, + draft: AUTHORED.draft, + headBranch: BRANCH, + headSha: HEAD, + baseBranch: AUTHORED.base, +}); + +const PUSH_INPUTS: GitPushInputs = Object.freeze({ + repository: IDENTITY, + remote: PUSH_REMOTE, + branch: BRANCH, + destinationRef: destinationRefFor(BRANCH), + sourceCommit: HEAD, +}); + +const HELD: readonly ReplPushPlacement[] = [ + { identity: IDENTITY, checkoutRoot: CHECKOUT, commonDirectory: COMMON }, +]; + +const PLACEMENT: ReplPlacement = Object.freeze({ + kind: "repository", + managedRoot: "/home/dev/.xmd/repositories", + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + identity: IDENTITY, + displayName: "project", + ownerCheckout: CHECKOUT, +}); + +const SNAPSHOT: PullRequestSnapshot = Object.freeze({ + providerId: "PR_kwDO", + number: 7, + url: URL, + state: "open", + title: AUTHORED.title, + body: AUTHORED.body, + draft: AUTHORED.draft, + headBranch: BRANCH, + headSha: HEAD, + baseBranch: AUTHORED.base, + baseSha: BASE_SHA, +}); + +/** One saved pull-request request, with whatever a case wants different. */ +function request( + overrides: { + readonly owner?: string; + readonly site?: string; + readonly requested?: ReplPullRequestAsk; + readonly received?: ReplPullRequestAsk; + readonly resolved?: { + readonly checkoutRoot?: string; + readonly commonDirectory?: string; + readonly origin?: string; + readonly inputs?: PullRequestInputs; + }; + } = {}, +): Json { + return replPullRequestRequestJson({ + owner: overrides.owner ?? OWNER, + site: overrides.site ?? SITE, + requested: overrides.requested ?? ASK, + received: overrides.received ?? ASK, + resolved: { + checkoutRoot: overrides.resolved?.checkoutRoot ?? CHECKOUT, + commonDirectory: overrides.resolved?.commonDirectory ?? COMMON, + origin: overrides.resolved?.origin ?? ORIGIN, + inputs: overrides.resolved?.inputs ?? INPUTS, + }, + }); +} + +function parsed(payload: Json): ReplPullRequestRequestRecord { + const record = parseReplPullRequestRequestRecord(payload); + if (record === undefined) { + throw new Error("the saved pull-request request this case builds does not parse"); + } + return record; +} + +/** The journal event one written pull-request request is held by. */ +function* requestEvent( + payload: Json, + options: { readonly coroutineId?: string; readonly name?: string } = {}, +): Operation { + const record = parseReplPullRequestRequestRecord(payload); + const name = + options.name ?? + (record === undefined ? "unreadable" : yield* replPullRequestRequestName(record)); + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, + result: { status: "ok", value: payload }, + }; +} + +/** One saved publication request, as the pull request's authority rests on it. */ +function* pushRequestEvent( + options: { + readonly coroutineId?: string; + readonly site?: string; + readonly inputs?: GitPushInputs; + } = {}, +): Operation<{ readonly event: DurableEvent; readonly record: ReplPushRequestRecord }> { + const payload = replPushRequestJson({ + owner: OWNER, + site: options.site ?? PUSH_SITE, + requested: PLACE, + received: PLACE, + resolved: { + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + origin: ORIGIN, + inputs: options.inputs ?? PUSH_INPUTS, + }, + }); + const record = parseReplPushRequestRecord(payload); + if (record === undefined) { + throw new Error("the saved publication request this case builds does not parse"); + } + return { + record, + event: { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: REPL_REPOSITORY_PUSH_REQUEST, + name: yield* replPushRequestName(record), + }, + result: { status: "ok", value: payload }, + }, + }; +} + +/** The successful publication outcome one saved publication request produces. */ +function* pushOutcomeEvent( + record: ReplPushRequestRecord, + options: { readonly coroutineId?: string } = {}, +): Operation { + const inputs = record.resolved.inputs; + const complete = { + identity: { runId: record.owner, expansionId: record.site }, + ...replPushEffectRequest(inputs), + }; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: yield* gitHostRequestFingerprint(complete), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: { + identity: complete.identity, + kind: complete.kind, + inputs: complete.inputs, + naturalKey: complete.naturalKey, + }, + preState: gitPushPreStateJson({ remoteCommit: null }), + observations: gitPushObservationsJson({ remoteCommit: inputs.sourceCommit }), + decision: "performed", + result: gitPushResultJson({ + repository: inputs.repository, + remote: inputs.remote, + branch: inputs.branch, + destinationRef: inputs.destinationRef, + refspec: refspecFor(inputs.sourceCommit, inputs.destinationRef), + sourceCommit: inputs.sourceCommit, + observedRemoteCommit: inputs.sourceCommit, + }), + }), + }, + }; +} + +/** The successful upsert outcome one saved pull-request request produces. */ +function* pullRequestOutcomeEvent( + record: ReplPullRequestRequestRecord, + options: { + readonly coroutineId?: string; + readonly name?: string; + readonly inputs?: PullRequestInputs; + readonly kind?: string; + readonly naturalKey?: Json; + readonly snapshot?: PullRequestSnapshot; + readonly runId?: string; + readonly expansionId?: string; + } = {}, +): Operation { + const inputs = options.inputs ?? record.resolved.inputs; + const snapshot = options.snapshot ?? SNAPSHOT; + const identity = { + runId: options.runId ?? record.owner, + expansionId: options.expansionId ?? record.site, + }; + const complete = { + identity, + kind: options.kind ?? "pull-request", + inputs: pullRequestInputsJson(inputs), + naturalKey: options.naturalKey ?? pullRequestNaturalKeyJson(pullRequestNaturalKey(inputs)), + }; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { + type: GIT_HOST_EFFECT, + name: options.name ?? (yield* gitHostRequestFingerprint(complete)), + }, + result: { + status: "ok", + value: gitHostReconciliationRecordJson({ + request: complete, + preState: { pullRequest: null }, + observations: { pullRequest: pullRequestSnapshotJson(snapshot) }, + decision: "performed", + result: pullRequestResultJson({ + repository: inputs.repository, + providerId: snapshot.providerId, + number: snapshot.number, + url: snapshot.url, + state: "open", + headSha: snapshot.headSha, + baseSha: snapshot.baseSha, + }), + }), + }, + }; +} + +/** The failed Git-host outcome a refused upsert leaves. */ +function* refusedEvent( + name: string, + journaled: string, + options: { readonly coroutineId?: string } = {}, +): Operation { + const failure = new Error("refused"); + failure.name = name; + return { + type: "yield", + coroutineId: options.coroutineId ?? "root", + description: { type: GIT_HOST_EFFECT, name: journaled }, + result: { status: "err", error: serializeError(failure) }, + }; +} + +/** The placement record a selection of this checkout leaves behind. */ +function* placementEvent(): Operation { + const selection = { + kind: "repository" as const, + name: PLACEMENT.displayName, + locatorFingerprint: PLACEMENT.identity.locatorFingerprint, + base: null, + }; + const fingerprint = yield* replPlacementFingerprint(selection); + return { + type: "yield", + coroutineId: "root", + description: { + type: REPL_REPOSITORY_PLACEMENT, + name: replPlacementRecordName({ kind: "repository", fingerprint }), + }, + result: { + status: "ok", + value: replPlacementRecordJson({ fingerprint, request: selection, outcome: Ok(PLACEMENT) }), + }, + }; +} + +/** One history that published this head and then opened its pull request. */ +function* published( + options: { readonly inputs?: GitPushInputs } = {}, +): Operation { + const push = yield* pushRequestEvent(options); + return [yield* placementEvent(), push.event, yield* pushOutcomeEvent(push.record)]; +} + +/** What admitting this history refused, or `undefined` when it admitted it. */ +function* refusalOf(events: readonly DurableEvent[]): Operation { + try { + yield* admitReplPushHistory(OWNER, events); + return undefined; + } catch (error) { + if (error instanceof ReplPushHistoryError) { + return error; + } + throw error; + } +} + +describe("Tier RPR — a REPL entry's saved pull-request request", () => { + it("RPR1: holds the question, the received reading and what routing resolved", function* () { + const record = parsed(request()); + expect(record.version).toBe(1); + expect(record.owner).toBe(OWNER); + expect(record.site).toBe(SITE); + expect(record.requested.pullRequest).toEqual(AUTHORED); + expect(record.requested.place.workingDirectory).toBe(CHECKOUT); + expect(record.resolved.origin).toBe(ORIGIN); + // The answer pins what the question could not say: the branch this head is + // on, the commit it was at, and the base it is going to. + expect(record.resolved.inputs.headBranch).toBe(BRANCH); + expect(record.resolved.inputs.headSha).toBe(HEAD); + expect(record.resolved.inputs.baseBranch).toBe("main"); + }); + + it("RPR2: the name covers the question alone, and every member of it", function* () { + const base = yield* replPullRequestRequestName({ owner: OWNER, site: SITE, requested: ASK }); + + // The answer takes no part in it: a request resolved against another head + // is the same question and keeps the same name, which is what lets a + // continuation find it after HEAD has moved. + expect( + yield* replPullRequestRequestName({ + owner: OWNER, + site: SITE, + requested: ASK, + }), + ).toBe(base); + + for (const differing of [ + { owner: OTHER_OWNER, site: SITE, requested: ASK }, + { owner: OWNER, site: "expansion-other", requested: ASK }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, pullRequest: { ...AUTHORED, title: "Other" } }, + }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, pullRequest: { ...AUTHORED, body: "Other body" } }, + }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, pullRequest: { ...AUTHORED, draft: true } }, + }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, pullRequest: { ...AUTHORED, base: "release" } }, + }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, pullRequest: { ...AUTHORED, number: 7 } }, + }, + { + owner: OWNER, + site: SITE, + requested: { ...ASK, place: { ...PLACE, workingDirectory: `${CHECKOUT}/docs` } }, + }, + ]) { + expect(yield* replPullRequestRequestName(differing)).not.toBe(base); + } + }); + + it("RPR3: one name vector keeps the canonical-JSON digest, including odd text", function* () { + // A null number, text outside ASCII and an unpaired surrogate are all + // values a document can write and this record can carry. The vector pins + // the algorithm rather than describing it: a change to the prefix, the + // version, the member order or the digest moves it. + const odd: ReplPullRequestAsk = { + pullRequest: { + number: null, + title: "Préparer les notes \u{1F600}", + body: "lone \ud800 surrogate", + draft: true, + base: "main", + }, + place: PLACE, + }; + const name = yield* replPullRequestRequestName({ owner: OWNER, site: SITE, requested: odd }); + expect(name).toMatch(/^[0-9a-f]{64}$/); + // And it is not the publication request's name for the same place, because + // the two prefixes differ: one record type cannot be mistaken for the other. + const push = yield* replPushRequestName({ owner: OWNER, site: SITE, requested: PLACE }); + expect(name).not.toBe(push); + }); + + it("RPR4: refuses an unknown version, a hostile value and a missing member", function* () { + expect( + parseReplPullRequestRequestRecord({ ...(request() as object), version: 2 }), + ).toBeUndefined(); + expect(parseReplPullRequestRequestRecord(undefined)).toBeUndefined(); + expect(parseReplPullRequestRequestRecord("a request")).toBeUndefined(); + + const extra = { ...(request() as Record), surplus: true }; + expect(parseReplPullRequestRequestRecord(extra)).toBeUndefined(); + + for (const member of ["version", "owner", "site", "requested", "received", "resolved"]) { + const missing = { ...(request() as Record) }; + delete missing[member]; + expect(parseReplPullRequestRequestRecord(missing)).toBeUndefined(); + } + + // A getter that throws is the value's own refusal, and answering it with a + // refusal is what keeps an untrusted value from carrying its text out. + const hostile = { + ...(request() as Record), + get requested(): Json { + throw new Error("hostile"); + }, + }; + expect(parseReplPullRequestRequestRecord(hostile)).toBeUndefined(); + }); + + it("RPR5: refuses an origin this host would not hand to Git", function* () { + expect( + parseReplPullRequestRequestRecord( + request({ resolved: { origin: "https://user:token@example.com/p.git" } }), + ), + ).toBeUndefined(); + }); + + it("RPR6: refuses resolved inputs that do not answer the received question", function* () { + // Each of the five authored fields has to survive resolution. A saved + // request whose resolved title, body, draft, number or base is not the one + // that was received describes a pull request the document never authored. + for (const drifted of [ + { ...INPUTS, title: "Other" }, + { ...INPUTS, body: "Other body" }, + { ...INPUTS, draft: true }, + { ...INPUTS, number: 7 }, + { ...INPUTS, baseBranch: "release" }, + ]) { + expect( + parseReplPullRequestRequestRecord(request({ resolved: { inputs: drifted } })), + ).toBeUndefined(); + } + }); + + it("RPR7: refuses inputs that do not belong to the Repository the dispatch received", function* () { + const foreign: RepositoryIdentity = { ...IDENTITY, name: "other" }; + expect( + parseReplPullRequestRequestRecord( + request({ resolved: { inputs: { ...INPUTS, repository: foreign } } }), + ), + ).toBeUndefined(); + }); + + it("RPR8: both object-id formats round-trip", function* () { + const sha256 = "a".repeat(64); + const identity: RepositoryIdentity = { + ...IDENTITY, + objectFormat: "sha256", + creationCommit: "b".repeat(64), + }; + const place: ReplPushPlace = { ...PLACE, repository: identity }; + const ask: ReplPullRequestAsk = { pullRequest: AUTHORED, place }; + const record = parsed( + request({ + requested: ask, + received: ask, + resolved: { inputs: { ...INPUTS, repository: identity, headSha: sha256 } }, + }), + ); + expect(record.resolved.inputs.headSha).toBe(sha256); + expect(sameReplPullRequestAsk(record.requested, ask)).toBe(true); + }); +}); + +describe("Tier RPR — admitting a history that published and then opened", () => { + it("RPR9: admits a pull request its own publication authorized, and one still pending", function* () { + const prefix = yield* published(); + const payload = request(); + const record = parsed(payload); + const complete = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record), + ]; + expect(yield* refusalOf(complete)).toBeUndefined(); + + // A request with no outcome after it is the window this whole feature + // exists for, so it is admitted rather than refused. + expect(yield* refusalOf([...prefix, yield* requestEvent(payload)])).toBeUndefined(); + }); + + it("RPR10: refuses a pull request no publication of this entry's authorized", function* () { + const payload = request(); + // The placement is there, so the places are held; what is missing is the + // publication itself. + const unpublished = [yield* placementEvent(), yield* requestEvent(payload)]; + expect((yield* refusalOf(unpublished))?.reason).toBe("unauthorized-pull-request"); + + // A publication of the same branch at another commit does not authorize + // this head either, which is the conflicting category. + const moved = yield* published({ inputs: { ...PUSH_INPUTS, sourceCommit: MOVED_HEAD } }); + expect((yield* refusalOf([...moved, yield* requestEvent(payload)]))?.reason).toBe( + "unauthorized-pull-request", + ); + }); + + it("RPR11: the last relevant publication decides, not an earlier exact one", function* () { + // A publishes this head, then B publishes the same branch at another + // commit. The branch holds B, so a pull request naming this head is no + // longer authorized even though an exact publication of it is in history. + const first = yield* pushRequestEvent({ site: "expansion-a" }); + const second = yield* pushRequestEvent({ + site: "expansion-b", + inputs: { ...PUSH_INPUTS, sourceCommit: MOVED_HEAD }, + }); + const superseded = [ + yield* placementEvent(), + first.event, + yield* pushOutcomeEvent(first.record), + second.event, + yield* pushOutcomeEvent(second.record), + yield* requestEvent(request()), + ]; + expect((yield* refusalOf(superseded))?.reason).toBe("unauthorized-pull-request"); + + // The other way round, the later publication is the one that authorizes. + const movedFirst = yield* pushRequestEvent({ + site: "expansion-a", + inputs: { ...PUSH_INPUTS, sourceCommit: MOVED_HEAD }, + }); + const headSecond = yield* pushRequestEvent({ site: "expansion-b" }); + const current = [ + yield* placementEvent(), + movedFirst.event, + yield* pushOutcomeEvent(movedFirst.record), + headSecond.event, + yield* pushOutcomeEvent(headSecond.record), + yield* requestEvent(request()), + ]; + expect(yield* refusalOf(current)).toBeUndefined(); + }); + + it("RPR12: refuses a request that is misnamed, foreign or unplaced", function* () { + const prefix = yield* published(); + + expect( + (yield* refusalOf([...prefix, yield* requestEvent(request(), { name: "not-its-name" })])) + ?.reason, + ).toBe("request-fingerprint"); + + expect( + (yield* refusalOf([...prefix, yield* requestEvent(request({ owner: OTHER_OWNER }))]))?.reason, + ).toBe("foreign-owner"); + + // No placement before it, so the history does not say where the pull + // request came from. The publication prefix is dropped with it, so the + // placement question is the one that answers first. + const push = yield* pushRequestEvent(); + expect((yield* refusalOf([push.event, yield* pushOutcomeEvent(push.record)]))?.reason).toBe( + "unknown-placement", + ); + }); + + it("RPR13: refuses an unknown version before anything else about the record", function* () { + const prefix = yield* published(); + const stated = { ...(request() as object), version: 2 }; + const event: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name: "whatever" }, + result: { status: "ok", value: stated as Json }, + }; + expect((yield* refusalOf([...prefix, event]))?.reason).toBe("unknown-version"); + }); + + it("RPR14: pairs one failed upsert with the request before it, by that request's own name", function* () { + const prefix = yield* published(); + const payload = request(); + const record = parsed(payload); + const expected = yield* replPullRequestOutcomeName(record); + + // The three words the shared protocol may refuse under. A failed envelope + // names no kind, so what says this refusal is a pull request's and not a + // publication's is the request standing before it and that request's own + // expected worker name. + for (const name of [ + "GitHostConflictError", + "GitHostAmbiguousError", + "GitHostUnavailableError", + ]) { + const history = [ + ...prefix, + yield* requestEvent(payload), + yield* refusedEvent(name, expected), + ]; + expect(yield* refusalOf(history)).toBeUndefined(); + } + + // A word this host has no vocabulary for says nothing it can act on. + const unknown = [ + ...prefix, + yield* requestEvent(payload), + yield* refusedEvent("SomethingElseError", expected), + ]; + expect((yield* refusalOf(unknown))?.reason).toBe("malformed-receipt"); + + // And a refusal journaled under the publication's name rather than the + // pull request's does not answer the request before it. This is the case a + // Push-only classifier got wrong: it would have checked this failure + // against a publication name it never had. + const push = yield* pushRequestEvent(); + const mispaired = [ + ...prefix, + yield* requestEvent(payload), + yield* refusedEvent("GitHostConflictError", yield* replPushOutcomeName(push.record)), + ]; + expect((yield* refusalOf(mispaired))?.reason).toBe("request-fingerprint"); + }); + + it("RPR15: refuses an orphan outcome, a duplicate pairing and a wrong-kind result", function* () { + const prefix = yield* published(); + const payload = request(); + const record = parsed(payload); + + expect((yield* refusalOf([...prefix, yield* pullRequestOutcomeEvent(record)]))?.reason).toBe( + "orphan-outcome", + ); + + const duplicated = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record), + yield* pullRequestOutcomeEvent(record), + ]; + expect((yield* refusalOf(duplicated))?.reason).toBe("duplicate-outcome"); + + // A successful outcome describing a publication where a pull request was + // asked for. Its own name is right for what it says it is, so what refuses + // it is the pairing: the request before it asked for something else. + const wrongKind = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { + kind: "git-push", + name: yield* replPullRequestOutcomeName(record), + }), + ]; + expect((yield* refusalOf(wrongKind))?.reason).toBe("malformed-receipt"); + }); + + it("RPR16: refuses a foreign identity and a result that is not this request's", function* () { + const prefix = yield* published(); + const payload = request(); + const record = parsed(payload); + + const foreignOwner = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { runId: OTHER_OWNER }), + ]; + expect((yield* refusalOf(foreignOwner))?.reason).toBe("foreign-owner"); + + const foreignSite = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { + expansionId: "expansion-other", + name: yield* replPullRequestOutcomeName(record), + }), + ]; + expect((yield* refusalOf(foreignSite))?.reason).toBe("foreign-owner"); + + // A natural key that names another resource, with the name still the one + // this request produces. + const wrongKey = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { + naturalKey: pullRequestNaturalKeyJson({ + mode: "update", + repository: IDENTITY, + number: 99, + }), + name: yield* replPullRequestOutcomeName(record), + }), + ]; + expect((yield* refusalOf(wrongKey))?.reason).toBe("malformed-receipt"); + + // A snapshot that finished at a different head than the request asked for. + const wrongHead = [ + ...prefix, + yield* requestEvent(payload), + yield* pullRequestOutcomeEvent(record, { + snapshot: { ...SNAPSHOT, headSha: MOVED_HEAD }, + }), + ]; + expect((yield* refusalOf(wrongHead))?.reason).toBe("malformed-receipt"); + }); + + it("RPR17: accepts mixed publication and pull-request work across two coroutines", function* () { + // Two `` branches, each publishing and then opening, interleaved in + // the stream. Pairing is per coroutine, so neither branch's outcome answers + // the other's request. + const placement = yield* placementEvent(); + const leftPush = yield* pushRequestEvent({ coroutineId: "left" }); + const rightPush = yield* pushRequestEvent({ + coroutineId: "right", + site: "expansion-right", + }); + const leftPayload = request({ site: "expansion-left-pr" }); + const rightPayload = request({ site: "expansion-right-pr" }); + + const interleaved = [ + placement, + leftPush.event, + rightPush.event, + yield* pushOutcomeEvent(leftPush.record, { coroutineId: "left" }), + yield* pushOutcomeEvent(rightPush.record, { coroutineId: "right" }), + yield* requestEvent(leftPayload, { coroutineId: "left" }), + yield* requestEvent(rightPayload, { coroutineId: "right" }), + yield* pullRequestOutcomeEvent(parsed(rightPayload), { coroutineId: "right" }), + // And the left branch's pull request is still pending, which is a valid + // trailing request rather than damage. + ]; + expect(yield* refusalOf(interleaved)).toBeUndefined(); + }); + + it("RPR18: passes over records of other features and other external protocols", function* () { + const prefix = yield* published(); + const unrelated: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type: "some_other_feature", name: "whatever" }, + result: { status: "ok", value: { anything: true } }, + }; + expect(yield* refusalOf([...prefix, unrelated])).toBeUndefined(); + }); +}); + +describe("Tier RPR — whose retained evidence read is whose", () => { + const request5 = readRequest(URL, "reviews", undefined, OWNER, SITE); + + it("RPR19: admits this entry's own read, settled or failed", function* () { + const admitted = admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(request5)); + expect(admitted.runId).toBe(OWNER); + expect(admitted.url).toBe(URL); + expect(admitted.kind).toBe("reviews"); + expect(admitted.provider).toBeNull(); + }); + + it("RPR20: refuses a foreign owner, which the name alone cannot say", function* () { + // The name omits the owner, so two entries reading the same URL at the same + // position produce the same name. What tells them apart is `runId`, and + // this is the case that proves the check is independent of the name. + const foreign = readRequest(URL, "reviews", undefined, OTHER_OWNER, SITE); + let refusal: ReplPullRequestReadHistoryError | undefined; + try { + admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(foreign)); + } catch (error) { + refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; + } + expect(refusal?.reason).toBe("foreign-owner"); + + // Asked of a failed read's description too: a refusal retains no result, + // and its description is the only thing that says who asked. + let failedRefusal: ReplPullRequestReadHistoryError | undefined; + try { + admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(foreign)); + } catch (error) { + failedRefusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; + } + expect(failedRefusal?.reason).toBe("foreign-owner"); + }); + + it("RPR21: refuses a description this host cannot read", function* () { + for (const value of [ + undefined, + "a read", + { ...pullRequestReadRequestJson(request5), surplus: true }, + { ...pullRequestReadRequestJson(request5), operation: "write" }, + { ...pullRequestReadRequestJson(request5), kind: "labels" }, + ]) { + let refusal: ReplPullRequestReadHistoryError | undefined; + try { + admitReplPullRequestReadRequest(OWNER, value as Json); + } catch (error) { + refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; + } + expect(refusal?.reason).toBe("malformed-record"); + } + }); + + it("RPR22: holds a settled read to the collection its own question asked for", function* () { + const reviews = admitReplPullRequestReadResult(request5, { kind: "reviews", items: [] }); + expect(reviews.kind).toBe("reviews"); + + // Another collection entirely, however well-formed. + let refusal: ReplPullRequestReadHistoryError | undefined; + try { + admitReplPullRequestReadResult(request5, { kind: "comments", items: [] }); + } catch (error) { + refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; + } + expect(refusal?.reason).toBe("malformed-result"); + }); + + it("RPR23: holds every check to the head the read recorded, empty or not", function* () { + const checks = readRequest(URL, "checks", undefined, OWNER, SITE); + const run = { + kind: "check-run", + id: "1", + headSha: HEAD, + name: "build", + status: "completed", + conclusion: "success", + url: null, + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:01:00Z", + title: null, + summary: null, + text: null, + }; + + // An empty collection still says which head was read, and that is admitted. + expect( + admitReplPullRequestReadResult(checks, { kind: "checks", headSha: HEAD, items: [] }).kind, + ).toBe("checks"); + + expect( + admitReplPullRequestReadResult(checks, { kind: "checks", headSha: HEAD, items: [run] }).kind, + ).toBe("checks"); + + let refusal: ReplPullRequestReadHistoryError | undefined; + try { + admitReplPullRequestReadResult(checks, { + kind: "checks", + headSha: HEAD, + items: [{ ...run, headSha: MOVED_HEAD }], + }); + } catch (error) { + refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; + } + expect(refusal?.reason).toBe("malformed-result"); + }); +}); From 3e6e1fcdf30c8681ecb399da7598ddc36808d4bc Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:40:22 -0400 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=9A=A7=20Prove=20the=20entry's=20pu?= =?UTF-8?q?ll=20request=20opens=20once,=20from=20what=20it=20published=20(?= =?UTF-8?q?#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of layer 6 of delivery 5: the native journey works end to end. Not the feedback commit. Three rows against a real checkout, a real bare remote and a modeled Git host, driven through canonical execution with the production entry capability installed — the same shape the publication suite established, with the one github.com locator standing in for a disposable bare remote in both directions so the Repository identity the entry authenticates stays the locator the document wrote. What they establish is that the whole path actually runs: the authored component asks the composition Api, this entry's publication memory authorizes the head, the request is written down, the shared engine observes, the pull request is created once, and its number binds. Without `` the same document refuses with nothing written down and nothing contacted. Replayed over its own history, the completed upsert answers from its receipt: no second create and no patch of any kind. The modeled host resolves a branch by asking the real bare remote, which is how the real one knows the commit a pull request is opened from — the branch is there because the publication put it there, rather than because the suite made a commit up. The native suite is registered Deno-only. Its reason is the merged publication suite's reason, and it carries the same derived-scope issue those entries carry: the saved request, the coordinated admission and the owned worker are provider-neutral and proven on every runtime by the records suite, and the unsupported-runtime refusal belongs to the PTY row that runs everywhere. Evidence: retained-repository-pull-request 3/3, runtime-exclusions and test-file-discovery 21/21. --- .../retained-repository-pull-request.test.ts | 353 ++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + 2 files changed, 359 insertions(+) create mode 100644 packages/git/tests/retained-repository-pull-request.test.ts diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts new file mode 100644 index 000000000..7678e5e90 --- /dev/null +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -0,0 +1,353 @@ +/** + * What one REPL entry's `` asks for, opens once, and answers from + * afterwards. + * + * Every row runs a document through canonical execution with the production + * entry capability installed, against a real checkout, a real bare remote and a + * modeled Git host. What is proven is the whole path: the authored component + * asks the composition Api, this entry's own publication memory authorizes the + * head, the request is written down, the shared Git-host engine observes, the + * pull request is opened at most once, and a continuation answers from the + * receipt rather than from what the run believes. + * + * The contract these rows hold the host to is the REPL's execution model. The + * request is retained before anything is contacted, and a continuation + * reconciles *that* request — the head, the base and the body it named — + * however far the checkout has moved since. So the rows that matter most are + * the ones where the two disagree, and the ones where something that looks like + * evidence is not. + * + * These rows carry V2, V3, V4, V5 and V8 of the delivery-5 matrix. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdir, mkdtemp, realpath, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, hostFilesHandler } from "@executablemd/runtime"; +import type { FilesHandler } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; +import { executeInstalled } from "@executablemd/core/host"; +import { collect, Elicitation } from "@executablemd/core"; +import { useCompositionComponents } from "../src/composition/installation.ts"; +import { denoRepositoryHost } from "../src/deno/composition/host.ts"; +import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; +import { gitSession } from "../src/deno/composition/git.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; +import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; +import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; +import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; +import { REPL_REPOSITORY_PULL_REQUEST_REQUEST } from "../src/retained-repository/pull-request-records.ts"; +import { gitOutput } from "./support/git-remotes.ts"; +import { + creations, + fakeGitHubAccess, + gitHubStore, + mutations, + patches, + type GitHubStore, +} from "./support/github.ts"; + +/** The owner the CLI derives for an entry, as this suite states one. */ +const OWNER = '["executablemd.repl.issue-owner",1,"execution-pull-request","1"]'; + +/** The repository the document names, and the one the modeled host holds. */ +const LOCATOR = "https://github.com/octo/project"; + +const TOKEN = "github-credential-for-this-test"; + +const BRANCH = "review/notes"; + +interface Fixture { + /** The scenario's own directory: checkouts, managed root and HOME. */ + readonly root: string; + /** The bare repository every managed Repository is cloned from. */ + readonly origin: string; + /** Where this scenario's managed checkouts live. */ + readonly managed: string; + /** A symbolic link an entry is opened through. */ + readonly link: string; + /** An ordinary checkout an entry is opened in. */ + readonly checkout: string; +} + +/** + * One bare remote with a commit in it, plus this scenario's own root. + * + * The same shape the publication suite builds, because a pull request is about + * a branch a publication put somewhere: the remote is real, `git` really + * pushes to it, and the modeled host resolves a branch by asking it. + */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-pr-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const git = gitSession(denoRepositoryHost(), root); + const origin = join(root, "origin.git"); + const seed = join(root, "seed"); + yield* until(mkdir(seed, { recursive: true })); + yield* git.run(["init", "--bare", "-b", "main", origin], root); + yield* git.run(["init", "-b", "main", seed], root); + yield* writeTextFile(join(seed, "seed.md"), "seed\n"); + yield* git.run(["add", "seed.md"], seed); + yield* git.run(["commit", "-m", "seed"], seed); + yield* git.run(["push", origin, "main:refs/heads/main"], seed); + + const checkout = join(root, "checkout"); + yield* git.run(["clone", origin, checkout], root); + const link = join(root, "entered"); + yield* until(symlink(checkout, link)); + yield* provide({ root, origin, managed: join(root, "repositories"), link, checkout }); + }); +} + +/** + * The production host, with the one github.com locator standing in for this + * scenario's bare remote. + * + * The substitution goes both ways, because Git remembers where a checkout came + * from and this entry's own origin check reads it back — and the Repository + * identity is fingerprinted from the locator the document wrote, so what the + * entry observes has to stay that locator. + */ +function rewriting(to: string, inner: RepositoryHost = denoRepositoryHost()): RepositoryHost { + return { + *git(invocation: GitInvocation): Operation { + const outcome = yield* inner.git({ + ...invocation, + args: invocation.args.map((argument) => (argument === LOCATOR ? to : argument)), + }); + return { ...outcome, stdout: outcome.stdout.split(to).join(LOCATOR) }; + }, + useDirectory: inner.useDirectory, + }; +} + +function hostFiles(): FilesHandler { + return hostFilesHandler(); +} + +interface Run { + readonly output: string; + readonly events: readonly DurableEvent[]; + readonly refusal: string | undefined; +} + +interface RunOptions { + readonly answer?: unknown; + readonly owner?: string; + readonly cwd?: string; + /** What this deployment authorizes reading, when a row narrows it. */ + readonly allowed?: readonly string[]; + readonly between?: () => Operation; +} + +/** + * Run one document as the session runs it: one per-entry factory, called in the + * entry's own task, for the owner the CLI derived. + */ +function run( + fixture: Fixture, + source: string, + stream: DurableStream, + store: GitHubStore, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const path = join(fixture.root, "entry.md"); + yield* writeTextFile(path, source); + const working = options.cwd ?? fixture.link; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return working; + }, + }, + { at: "min" }, + ); + const files = hostFiles(); + yield* API.Files.around( + { + checkFilePath: ([input]) => files.checkFilePath(input), + readTextFile: ([input]) => files.readTextFile(input), + globFiles: ([input]) => files.globFiles(input), + temporaryDirectory: () => files.temporaryDirectory(), + writeTextFile: ([input]) => files.writeTextFile(input), + deleteFile: ([input]) => files.deleteFile(input), + ensureDirectory: ([input]) => files.ensureDirectory(input), + }, + { at: "min" }, + ); + yield* useCompositionComponents(); + if (options.answer !== undefined) { + const answer = options.answer; + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit(): Operation { + return answer; + }, + }, + { at: "min" }, + ); + } + if (options.between !== undefined) { + yield* options.between(); + } + const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { + host: rewriting(fixture.origin), + root: fixture.managed, + pullRequests: { + // The modeled host, injected: an access that outranks any configured + // endpoint, so no row reaches a network. + access: gitHubSource(fakeGitHubAccess(store)), + configuration: { + allowed: options.allowed === undefined ? [LOCATOR] : [...options.allowed], + }, + }, + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ + { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + installation, + ]), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, events: yield* stream.readAll(), refusal }; + }); +} + +/** One document inside the Repository this suite names. */ +function document(...lines: string[]): string { + return [``, ...lines, ""].join("\n"); +} + +/** The whole journey: publish a branch, then open its pull request. */ +function opening(...extra: string[]): string { + return document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' Ready for review.', + " ", + ...extra, + " ", + ); +} + +/** Every saved pull-request request this history holds. */ +function requests(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => + event.type === "yield" && event.description.type === REPL_REPOSITORY_PULL_REQUEST_REQUEST, + ); +} + +/** Every pull-request reconciliation this history acknowledged. */ +function upserts(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter((event) => { + if (event.type !== "yield" || event.description.type !== GIT_HOST_EFFECT) { + return false; + } + if (event.result.status !== "ok") { + return false; + } + const record = parseGitHostReconciliationRecord(event.result.value); + return record?.request.kind === "pull-request"; + }); +} + +/** + * The modeled Git host, resolving a branch by asking the real bare remote. + * + * Which is how the real one knows the commit a pull request is opened from: + * `` put it there, so the host is told to look where it was put + * rather than being handed a commit this suite made up. + */ +function store(fixture: Fixture): GitHubStore { + const forge = gitHubStore({ token: TOKEN }); + forge.resolveHead = (branch) => { + try { + return gitOutput(["rev-parse", `refs/heads/${branch}`], fixture.origin, fixture.root).trim(); + } catch { + return undefined; + } + }; + return forge; +} + +describe("one entry's pull request", () => { + it("V2a: opens a pull request for the head it published, once", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + const first = yield* run(fixture, opening(), stream, forge); + + expect(first.refusal).toBeUndefined(); + // One request written down, one reconciliation acknowledged, one pull + // request created at the host. + expect(requests(first.events).length).toBe(1); + expect(upserts(first.events).length).toBe(1); + expect(creations(forge)).toBe(1); + expect(first.output).toContain("1"); + }); + + it("V2b: refuses when this entry published no such head", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + // The same document without its ``: there is a branch and a + // commit, and nothing published them. + const unpublished = document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + ' Ready for review.', + " ", + ); + const attempt = yield* run(fixture, unpublished, stream, forge); + + expect(attempt.refusal).toBeDefined(); + // Nothing was written down and nothing was contacted. + expect(requests(attempt.events).length).toBe(0); + expect(mutations(forge)).toEqual([]); + }); + + it("V4a: a completed upsert replays from its receipt, contacting nothing", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + const first = yield* run(fixture, opening(), stream, forge); + expect(first.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + + // The same document again over the same history. The upsert is complete, so + // it answers from what it acknowledged. + const again = yield* run(fixture, opening(), stream, forge); + expect(again.refusal).toBeUndefined(); + expect(requests(again.events).length).toBe(1); + expect(upserts(again.events).length).toBe(1); + // No second create, and no mutation of any kind. + expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 869ac10c5..2e21b1b5b 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -754,6 +754,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "continues seven histories the merged implementation wrote through the production per-entry capability, so its module graph reaches @executablemd/git/deno — the native repository host, the GitHub issue adapter and Workflow's storage adapter with node:sqlite. The construction those histories are continued through is provider-neutral and is proven on every runtime by packages/git/tests/composition-component-binding.test.ts; the records themselves are proven everywhere by packages/git/tests/retained-repository-push-records.test.ts and packages/git/tests/issue-owned.test.ts", issue: "https://github.com/taras/executable.md/issues/181", }, + { + path: "packages/git/tests/retained-repository-pull-request.test.ts", + reason: + "the subject is a REPL entry's native pull-request upsert: it clones a managed checkout, publishes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and reconciles against a modeled Git host. The saved request, the coordinated Push/pull-request admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-pull-request-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses before any native or service operation is proven by packages/cli/tests/repl-pull-request-pty.test.ts, which runs everywhere", + issue: DERIVED_SCOPE, + }, { path: "packages/cli/tests/repl-push.test.ts", reason: From de06f7d54acb3aa068833df1a330535f09c7c103 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:54:35 -0400 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=9A=A7=20Cut=20the=20entry=20mid-up?= =?UTF-8?q?sert=20and=20hold=20the=20outcome=20to=20the=20request=20(#181,?= =?UTF-8?q?=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More of layer 6: V3, V5 and V8 against the native journey. Not the feedback commit. Cut at the first service call and the request stands with nothing open at the host; remove the checkout it published from and the pending request still reconciles, because the head, the base and the body are in the record. Cut while the reconciliation is appending its own result and the pull request exists where this history does not say so — resuming observes it and adopts it rather than creating a second one. Refuse the request append and nothing is contacted and the sentinel after it never runs; refuse the outcome append and the request stands, no success binds, and a later attempt adopts the completion. Both barriers had to learn what the classifier already knows: publication and upsert settle through one durable type, so a barrier on the type alone caught the publication this journey needs. They discriminate on the record kind. The composition rows hold the result contract. A synthetic answer at either chain binds and writes nothing. After delegation the owned outcome decides: a rewritten return cannot bind another pull request, and a delegated failure a handler caught and answered past stays a failure. One transform records both readings, and replaying consumes the saved request before anything is dispatched, so a handler no longer installed changes nothing. The body>s own child effect lands before the parent has a position at all, and a body that refuses leaves no request and contacts nothing. Evidence: retained-repository-pull-request 12/12, retained-repository-push 23/23. --- .../retained-repository-pull-request.test.ts | 445 +++++++++++++++++- 1 file changed, 440 insertions(+), 5 deletions(-) diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index 7678e5e90..e86e79b66 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -22,8 +22,18 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, until } from "effection"; -import type { Operation } from "effection"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, WithResolvers } from "effection"; import { rm, writeTextFile } from "@effectionx/fs"; import { mkdir, mkdtemp, realpath, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -39,11 +49,23 @@ import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; import { gitSession } from "../src/deno/composition/git.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; +import type { + GitHubAccess, + GitHubHttpRequest, + GitHubHttpResponse, +} from "../src/deno/composition/github.ts"; +import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; +import type { PullRequestResult } from "../src/composition/pull-request-records.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; -import { REPL_REPOSITORY_PULL_REQUEST_REQUEST } from "../src/retained-repository/pull-request-records.ts"; +import { + parseReplPullRequestRequestRecord, + REPL_REPOSITORY_PULL_REQUEST_REQUEST, +} from "../src/retained-repository/pull-request-records.ts"; +import { REPL_REPOSITORY_LOCAL } from "../src/retained-repository/records.ts"; import { gitOutput } from "./support/git-remotes.ts"; import { creations, @@ -145,6 +167,8 @@ interface RunOptions { readonly cwd?: string; /** What this deployment authorizes reading, when a row narrows it. */ readonly allowed?: readonly string[]; + /** A gated transport, when a row cuts around the Git host. */ + readonly access?: GitHubAccess; readonly between?: () => Operation; } @@ -207,7 +231,7 @@ function run( pullRequests: { // The modeled host, injected: an access that outranks any configured // endpoint, so no row reaches a network. - access: gitHubSource(fakeGitHubAccess(store)), + access: gitHubSource(options.access ?? fakeGitHubAccess(store)), configuration: { allowed: options.allowed === undefined ? [LOCATOR] : [...options.allowed], }, @@ -238,8 +262,13 @@ function document(...lines: string[]): string { /** The whole journey: publish a branch, then open its pull request. */ function opening(...extra: string[]): string { + return openingOn(BRANCH, ...extra); +} + +/** The same journey on a named branch, for a row that runs more than once. */ +function openingOn(branch: string, ...extra: string[]): string { return document( - ` `, + ` `, " ", ' Ready for review.', ' ', @@ -351,3 +380,409 @@ describe("one entry's pull request", () => { expect(patches(forge)).toBe(0); }); }); + +/** + * An access that holds the first service call matching a predicate. + * + * What makes a cut around the Git host deterministic: the request is in flight + * when the row halts the entry, so what the journal holds is everything up to + * that call and nothing after it. + */ +function gatedAccess( + store: GitHubStore, + at: (request: GitHubHttpRequest) => boolean, + reached: WithResolvers, +): GitHubAccess { + const inner = fakeGitHubAccess(store); + return { + endpoint: inner.endpoint, + token: () => inner.token(), + *send(request: GitHubHttpRequest): Operation { + if (at(request)) { + reached.resolve(); + yield* suspend(); + } + return yield* inner.send(request); + }, + }; +} + +/** + * Whether this event is the pull-request half of the shared Git-host type. + * + * Both external protocols settle through `git_host_effect`, so a barrier on + * the durable type alone would catch the publication this journey needs — which + * is the same reason the history classifier cannot read a kind off the + * envelope. What discriminates is the record's own request kind. + */ +function isUpsert(event: DurableEvent): boolean { + if (event.type !== "yield" || event.description.type !== GIT_HOST_EFFECT) { + return false; + } + if (event.result.status !== "ok") { + return false; + } + const record = parseGitHostReconciliationRecord(event.result.value); + return record?.request.kind === "pull-request"; +} + +/** A journal that holds one append, and says when it did. */ +function holdingAppend( + inner: InMemoryStream, + at: (event: DurableEvent) => boolean, + reached: WithResolvers, +): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (at(event)) { + reached.resolve(); + yield* suspend(); + } + yield* inner.append(event); + }, + }; +} + +/** A journal that refuses every append this predicate names. */ +function refusing(inner: InMemoryStream, at: (event: DurableEvent) => boolean): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (at(event)) { + throw new Error("the journal backend refused this append"); + } + yield* inner.append(event); + }, + }; +} + +/** Whether this event is a saved pull-request request. */ +function isRequest(event: DurableEvent): boolean { + return event.type === "yield" && event.description.type === REPL_REPOSITORY_PULL_REQUEST_REQUEST; +} + +/** Run the entry and halt it the moment `reached` resolves, keeping the prefix. */ +function cutAt( + fixture: Fixture, + source: string, + stream: DurableStream, + forge: GitHubStore, + reached: WithResolvers, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const task = yield* spawn(() => run(fixture, source, stream, forge, options)); + yield* race([ + reached.operation, + (function* (): Operation { + yield* sleep(30_000); + throw new Error("the entry never reached the barrier it was cut at"); + })(), + ]); + const prefix = [...(yield* stream.readAll())]; + yield* task.halt(); + return prefix; + }); +} + +/** A journal holding exactly one unfinished history's events. */ +function replaying(prefix: readonly DurableEvent[]): InMemoryStream { + // Every event but the root's own close, which is what an unfinished run has: + // a journal whose run finished replays every position it holds and runs no + // component, so a continuation would never reach the suffix. + return new InMemoryStream( + [...prefix].filter((event) => !(event.type === "close" && event.coroutineId === "root")), + ); +} + +/** A closed result a handler can build and offer as a completion. */ +const SYNTHETIC = Object.freeze({ + repository: Object.freeze({ + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), + providerId: "PR_synthetic", + number: 4242, + url: "https://github.com/octo/project/pull/4242", + state: "open" as const, + headSha: "c".repeat(40), + baseSha: "d".repeat(40), +}); + +describe("one entry's pull request, interrupted", () => { + it("V3a: a pending request reconciles its saved question, reading no checkout", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + + // Cut at the first service call: the request is acknowledged, nothing is + // open at the host yet. + const prefix = yield* cutAt(fixture, opening(), inner, forge, reached, { + access: gatedAccess(forge, () => true, reached), + }); + expect(requests(prefix).length).toBe(1); + expect(upserts(prefix).length).toBe(0); + expect(mutations(forge)).toEqual([]); + + // Now the checkout it published from is gone. A pending request needs none + // of it: the head, the base and the body are in the record. + yield* rm(join(fixture.managed), { recursive: true, force: true }); + + const resumed = yield* run(fixture, opening(), replaying(prefix), forge); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + }); + + it("V3b: a remote completion whose outcome was never acknowledged is adopted", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + + // Cut while the reconciliation's own result is being appended: the pull + // request exists at the host and this history does not say so. + const prefix = yield* cutAt( + fixture, + opening(), + holdingAppend(inner, isUpsert, reached), + forge, + reached, + ); + expect(requests(prefix).length).toBe(1); + expect(upserts(prefix).length).toBe(0); + expect(creations(forge)).toBe(1); + + // Resuming observes what is there and adopts it rather than creating a + // second pull request. + const resumed = yield* run(fixture, opening(), replaying(prefix), forge); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + expect(upserts(yield* replaying(prefix).readAll()).length).toBe(0); + }); + + it("V8a: a refused request append contacts nothing and fences what follows", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const attempt = yield* run( + fixture, + opening(" "), + refusing(inner, isRequest), + forge, + ); + + expect(attempt.refusal).toBeDefined(); + const events = yield* inner.readAll(); + expect(requests(events).length).toBe(0); + expect(upserts(events).length).toBe(0); + // Nothing was asked of the Git host, and the sentinel after it never ran. + expect(mutations(forge)).toEqual([]); + }); + + it("V8b: a refused outcome append binds nothing and leaves the completion to adopt", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const attempt = yield* run(fixture, opening(), refusing(inner, isUpsert), forge); + + expect(attempt.refusal).toBeDefined(); + const events = yield* inner.readAll(); + // The request stands, the outcome does not, and the remote work may have + // happened: that is the window reconciliation exists to close. + expect(requests(events).length).toBe(1); + expect(upserts(events).length).toBe(0); + + // A later attempt over that history adopts the completion. + const resumed = yield* run(fixture, opening(), replaying(events), forge); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + }); +}); + +describe("one entry's pull request, composed", () => { + it("V5a: a synthetic answer at either chain binds and retains nothing", function* () { + const fixture = yield* useFixture(); + + for (const chain of ["operations", "api"] as const) { + const forge = store(fixture); + const stream = new InMemoryStream(); + const answered = yield* run(fixture, openingOn(`synthetic/${chain}`), stream, forge, { + *between(): Operation { + if (chain === "operations") { + yield* PullRequestOperations.around({ + // deno-lint-ignore require-yield + *upsert(): Operation { + return SYNTHETIC; + }, + }); + } else { + yield* PullRequestAPI.around({ + // deno-lint-ignore require-yield + *upsert(): Operation { + return SYNTHETIC; + }, + }); + } + }, + }); + + expect(answered.refusal).toBeUndefined(); + expect(answered.output).toContain("4242"); + const events = yield* stream.readAll(); + // Nothing was written down and nothing was asked of the Git host. + expect(requests(events).length).toBe(0); + expect(upserts(events).length).toBe(0); + expect(mutations(forge)).toEqual([]); + } + }); + + it("V5b: after delegation the owned outcome binds, whatever an outer handler returns", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + + // Delegates, then rewrites the answer. The rewrite cannot bind another + // pull request: what binds is what this entry actually acknowledged. + const rewritten = yield* run(fixture, opening(), stream, forge, { + *between(): Operation { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + yield* next(invocation); + return SYNTHETIC; + }, + }); + }, + }); + expect(rewritten.refusal).toBeUndefined(); + expect(rewritten.output).not.toContain("4242"); + expect(creations(forge)).toBe(1); + expect(requests(yield* stream.readAll()).length).toBe(1); + }); + + it("V5c: a swallowed delegated failure stays a failure", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + forge.fault = { on: "create", status: 503 }; + const stream = new InMemoryStream(); + + const swallowed = yield* run(fixture, opening(), stream, forge, { + *between(): Operation { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + try { + return yield* next(invocation); + } catch { + // Catching the delegated failure and answering with a success + // cannot rescue it. + return SYNTHETIC; + } + }, + }); + }, + }); + expect(swallowed.refusal).toBeDefined(); + expect(swallowed.output).not.toContain("4242"); + }); + + it("V5d: one transform records both readings, and a saved request bypasses it", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + + const transformed = yield* run(fixture, opening(), stream, forge, { + *between(): Operation { + yield* PullRequestAPI.around({ + *upsert([pullRequest, options], next): Operation { + return yield* next({ ...pullRequest, title: "Transformed" }, options); + }, + }); + }, + }); + expect(transformed.refusal).toBeUndefined(); + + const events = yield* stream.readAll(); + const saved = requests(events)[0]; + expect(saved).toBeDefined(); + if (saved?.type === "yield" && saved.result.status === "ok") { + const record = parseReplPullRequestRequestRecord(saved.result.value); + // The question the invocation asked, and what the dispatch received. + expect(record?.requested.pullRequest.title).toBe("Prepare notes"); + expect(record?.received.pullRequest.title).toBe("Transformed"); + expect(record?.resolved.inputs.title).toBe("Transformed"); + } + + // Replaying consumes the saved request before anything is dispatched, so a + // handler that is no longer installed changes nothing. + const resumed = yield* run(fixture, opening(), replaying(events), forge); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + }); + + it("V5e: a body child effect precedes the request, and a failed body contacts nothing", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + + // The body writes a file. That child finishes at its own earlier position, + // before this upsert has a position of its own. + const withChild = document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' ', + ' from the body', + " Ready for review.", + " ", + " ", + ); + const ordered = yield* run(fixture, withChild, stream, forge); + expect(ordered.refusal).toBeUndefined(); + + // The body's own `` is a retained local record, and what matters is + // that it stands before this upsert's request: a child effect in the + // content finishes at its own earlier position, which is why the body is + // projected before the parent has a position at all. + const types = (yield* stream.readAll()) + .filter((event) => event.type === "yield") + .map((event) => event.description.type); + const requestAt = types.lastIndexOf(REPL_REPOSITORY_PULL_REQUEST_REQUEST); + const lastWriteAt = types.lastIndexOf(REPL_REPOSITORY_LOCAL); + expect(requestAt).toBeGreaterThan(-1); + expect(lastWriteAt).toBeGreaterThan(-1); + expect(lastWriteAt).toBeLessThan(requestAt); + + // A body that cannot render stops everything this component owns before a + // request exists and before anything is contacted. + const failingForge = store(fixture); + const failing = new InMemoryStream(); + const refused = yield* run( + fixture, + document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' ', + ' ', + " ", + " ", + ), + failing, + failingForge, + ); + expect(refused.refusal).toBeDefined(); + expect(requests(yield* failing.readAll()).length).toBe(0); + expect(mutations(failingForge)).toEqual([]); + }); +}); From 4ee587d02baff76004d34a7fea399a60a857405d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 12:59:03 -0400 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=9A=A7=20Give=20each=20evidence=20r?= =?UTF-8?q?ead=20its=20own=20bridge,=20behind=20the=20live=20ceiling=20(#1?= =?UTF-8?q?81,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More of layer 6: V7, the read half of V4 and the read half of V8. Not the feedback commit. Every row installs the entry capability over a canned transport and nothing else — no managed root, no bare remote, no `git` — which is itself part of the contract: a read names a URL, so these identities are declared and installed for an entry that holds no checkout. The rows are about the boundary between what the adapter decides and what this entry retains. A denied target leaves no record and opens no session. A completed read restores without asking the host again. Narrowing the allowed list refuses a saved read on reopening while leaving its record in history — the accepted contract, and the reason the public dispatch is not wrapped in a durable envelope. A transformation before admission changed which read was admitted, so the record says so; one after delegation is live, and the same saved snapshot binds a different array when a different handler composes over it. A handler that never reaches the seam retains nothing and runs again. An incomplete walk retains a failed read and binds no array, and a refused append binds none either while leaving the read safe to repeat. Two concurrent reads finishing in the opposite order each keep their own question, and a history offered to another entry is refused by the owner in the record rather than by a name that omits it. Two defects surfaced. The entry installed the read adapter but nothing bridged the profile surface to it, so `PullRequestOperations`'s own refusing default answered every read; it now forwards straight through, while upsert keeps that refusal because its retention is identity-bearing. And the read admission computed its expected name through the effect description, which reads the expanding element's source position — unavailable while admitting a retained history. The name is now computable on its own, and the description adds the diagnostics to it. Evidence: retained-repository-pull-request-reads 11/11, and the Workflow read path unchanged — pull-request-read, pull-request-read-fork and runtime-exclusions 49/49 together. --- .../composition/pull-request-operations.ts | 39 +- .../retained-repository/pull-request-reads.ts | 39 +- ...ined-repository-pull-request-reads.test.ts | 613 ++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + 4 files changed, 684 insertions(+), 13 deletions(-) create mode 100644 packages/git/tests/retained-repository-pull-request-reads.test.ts diff --git a/packages/git/src/deno/composition/pull-request-operations.ts b/packages/git/src/deno/composition/pull-request-operations.ts index f7dd6239b..c8595cbc5 100644 --- a/packages/git/src/deno/composition/pull-request-operations.ts +++ b/packages/git/src/deno/composition/pull-request-operations.ts @@ -84,25 +84,42 @@ const ELEMENT: Readonly> = Object.freeze({ * entry supplies its owner as the run and its authenticated invocation as the * expansion; nothing else about it differs. */ -export function* describePullRequestRead( - 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. +/** + * The name one read is published under, and the configuration it covers. + * + * Separate from the description below because a name has to be computable + * where a description is not. An entry's admission recomputes it over a + * retained history, before any element is expanding, and the source position a + * description also carries is diagnostic and only available there. + * + * 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. + */ +export function pullRequestReadName(request: PullRequestReadRequest): { + readonly name: string; + readonly configuration: string; +} { const configuration = gitOperationFingerprint([ request.operation, request.url, request.provider, request.kind, ]); + return { name: `${request.expansionId}:${configuration}`, configuration }; +} + +export function* describePullRequestRead( + request: PullRequestReadRequest, +): Operation { + const expansion = yield* getExpansion(); + const { name, configuration } = pullRequestReadName(request); return { type: PULL_REQUEST_READ, - name: `${request.expansionId}:${configuration}`, + name, input: pullRequestReadRequestJson(request), configuration, ...sourceDescription(expansion.position), diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index e0aa3790c..feee4adde 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -84,7 +84,9 @@ import { REVIEWS_ELEMENT, } from "../../composition/components/PullRequestReads.ts"; import { PullRequestReadError } from "../../composition/errors.ts"; +import { PullRequestAPI } from "../../composition/pull-request-api.ts"; import { PullRequestOperations } from "../../composition/pull-request-operations.ts"; +import type { PullRequestReadInvocation } from "../../composition/pull-request-operations.ts"; import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; import type { AdmittedPullRequestRead, @@ -109,6 +111,7 @@ import { describePullRequestRead, PULL_REQUEST_READ, pullRequestReadElement, + pullRequestReadName, retainedPullRequestRead, } from "../composition/pull-request-operations.ts"; @@ -342,8 +345,10 @@ export function replPullRequestReadAdmission(owner: string): JournalAdmission { continue; } const request = admitReplPullRequestReadRequest(owner, event.description["input"]); - const described = yield* describePullRequestRead(request); - if (event.description.name !== described.name) { + // The name alone, not the whole description: this runs over a retained + // history before any element is expanding, and the source position a + // description also carries is only available there. + if (event.description.name !== pullRequestReadName(request).name) { throw replPullRequestReadNameRefusal(); } if (event.result.status !== "ok") { @@ -356,3 +361,33 @@ export function replPullRequestReadAdmission(owner: string): JournalAdmission { } }; } + +/** + * Forward this entry's profile-level reads to the transport surface. + * + * The profile surface's own default refuses, which is what distinguishes a host + * that installed no pull-request lifecycle from one whose pull request is + * merely unreachable. An entry has installed one, so its reads have to reach + * the adapter beneath — and they reach it straight through, because the + * adapter is what decides when a read is admitted and retention begins after + * that. Wrapping the dispatch here would put the ceiling and the URL refusals + * inside a durable effect. + * + * Upsert is deliberately not forwarded. Its retention is identity-bearing and + * belongs to the authored component's own invocation-local defaults, so a + * standalone `PullRequestOperations.upsert(...)` keeps the absent-provider + * refusal rather than acquiring this entry's lifecycle. + */ +export function useReplPullRequestReadOperations(): Operation { + return PullRequestOperations.around( + { + *read([invocation]: [PullRequestReadInvocation]): Operation { + return yield* PullRequestAPI.operations.read(invocation.url, { + kind: invocation.kind, + ...(invocation.provider === undefined ? {} : { provider: invocation.provider }), + }); + }, + }, + { at: "min" }, + ); +} diff --git a/packages/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts new file mode 100644 index 000000000..086be3aac --- /dev/null +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -0,0 +1,613 @@ +/** + * What one REPL entry's ``, `.Comments` and `.Checks` + * retain, and whose read is whose. + * + * A read needs no Repository, no checkout and no working directory, so every + * row here installs the entry capability over a canned transport and nothing + * else — no managed root, no bare remote, no `git`. That is itself part of the + * contract: these identities are declared and installed for an entry that + * holds no checkout at all. + * + * What the rows are about is the boundary between what the adapter decides and + * what this entry retains. The adapter matches the request, holds it to the + * configured ceiling and validates the target on *every* execution, including + * the one that reopens an entry whose read is already saved; retention begins + * after all three and holds exactly the normalized answer produced inside the + * seam. So the rows that matter most are the ones where those two disagree: a + * target the ceiling has since stopped authorizing, a transformation applied + * after delegation, and two reads running at once. + * + * These rows carry V7, the read half of V4, and the read half of V8. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, hostFilesHandler } from "@executablemd/runtime"; +import type { FilesHandler } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; +import { executeInstalled } from "@executablemd/core/host"; +import { collect } from "@executablemd/core"; +import { useCompositionComponents } from "../src/composition/installation.ts"; +import { gitHubSource } from "../src/deno/composition/github.ts"; +import type { + GitHubAccess, + GitHubHttpRequest, + GitHubHttpResponse, +} from "../src/deno/composition/github.ts"; +import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; +import type { PullRequestReadResult } from "../src/composition/pull-request-read-records.ts"; +import { parsePullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; +import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; +import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; +import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; + +const OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","1"]'; +const OTHER_OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","2"]'; + +const ENDPOINT = "https://api.github.test"; +const SUBJECT_REPO = "https://github.com/octo/project"; +const SUBJECT_URL = `${SUBJECT_REPO}/pull/7`; +const OTHER_URL = `${SUBJECT_REPO}/pull/9`; +const HEAD = "a".repeat(40); + +const PULL = "/repos/octo/project/pulls/7"; +const REVIEWS = "/repos/octo/project/pulls/7/reviews"; +const OTHER_PULL = "/repos/octo/project/pulls/9"; +const OTHER_REVIEWS = "/repos/octo/project/pulls/9/reviews"; + +interface Answer { + readonly status?: number; + readonly body: string; + readonly link?: string; +} + +interface Server { + readonly access: GitHubAccess; + readonly requests: GitHubHttpRequest[]; +} + +/** + * A transport that answers by path, and records what it was asked. + * + * Keyed by pathname plus search so a paged case can answer page 2 differently + * from page 1, and a row can assert that a replay asked for nothing at all. + */ +function server(answers: Record, token: string | null = "t"): Server { + const requests: GitHubHttpRequest[] = []; + return { + requests, + access: { + endpoint: ENDPOINT, + // deno-lint-ignore require-yield + *token(): Operation { + return token ?? undefined; + }, + // deno-lint-ignore require-yield + *send(request: GitHubHttpRequest): Operation { + requests.push(request); + const url = new URL(request.url); + const answer = answers[`${url.pathname}${url.search}`] ?? answers[url.pathname]; + if (answer === undefined) { + throw new Error(`no canned answer for ${url.pathname}${url.search}`); + } + return { + status: answer.status ?? 200, + body: answer.body, + ...(answer.link === undefined ? {} : { link: answer.link }), + }; + }, + }, + }; +} + +/** The pull request itself, carrying what an answer is authenticated against. */ +function pullRequestPayload(number = 7): string { + return JSON.stringify({ + number, + head: { sha: HEAD }, + base: { repo: { full_name: "octo/project" } }, + }); +} + +function review(id: number, pull = 7): unknown { + return { + id, + user: { login: "reviewer" }, + state: "APPROVED", + body: `review ${id}`, + submitted_at: "2026-08-24T00:00:00Z", + commit_id: HEAD, + html_url: `https://github.test/pr/${pull}#r${id}`, + pull_request_url: `${ENDPOINT}/repos/octo/project/pulls/${pull}`, + }; +} + +/** One pull request with two reviews, plus a second one for the paired rows. */ +function reviewing(): Server { + return server({ + [PULL]: { body: pullRequestPayload() }, + [REVIEWS]: { body: JSON.stringify([review(1), review(2)]) }, + [OTHER_PULL]: { body: pullRequestPayload(9) }, + [OTHER_REVIEWS]: { body: JSON.stringify([review(3, 9)]) }, + }); +} + +interface Fixture { + readonly root: string; +} + +/** This scenario's own directory. No checkout, and no managed root. */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-pr-reads-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + yield* provide({ root }); + }); +} + +function hostFiles(): FilesHandler { + return hostFilesHandler(); +} + +interface Run { + readonly output: string; + readonly events: readonly DurableEvent[]; + readonly refusal: string | undefined; +} + +interface RunOptions { + readonly owner?: string; + /** What this deployment authorizes reading, when a row narrows it. */ + readonly allowed?: readonly string[]; + readonly between?: () => Operation; +} + +/** + * Run one document with the entry capability installed over this transport. + * + * Deliberately without `root`: no managed checkout is configured, which is what + * proves these identities need none. + */ +function run( + fixture: Fixture, + source: string, + stream: DurableStream, + host: Server, + options: RunOptions = {}, +): Operation { + return scoped(function* () { + const path = join(fixture.root, "entry.md"); + yield* writeTextFile(path, source); + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return fixture.root; + }, + }, + { at: "min" }, + ); + const files = hostFiles(); + yield* API.Files.around( + { + checkFilePath: ([input]) => files.checkFilePath(input), + readTextFile: ([input]) => files.readTextFile(input), + globFiles: ([input]) => files.globFiles(input), + temporaryDirectory: () => files.temporaryDirectory(), + writeTextFile: ([input]) => files.writeTextFile(input), + deleteFile: ([input]) => files.deleteFile(input), + ensureDirectory: ([input]) => files.ensureDirectory(input), + }, + { at: "min" }, + ); + yield* useCompositionComponents(); + if (options.between !== undefined) { + yield* options.between(); + } + const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { + pullRequests: { + access: gitHubSource(host.access), + configuration: { + allowed: options.allowed === undefined ? [SUBJECT_REPO] : [...options.allowed], + }, + }, + }); + let refusal: string | undefined; + let output = ""; + try { + output = String( + yield* collect( + yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ + { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + installation, + ]), + ), + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + return { output, events: yield* stream.readAll(), refusal }; + }); +} + +/** Every retained evidence read this history holds. */ +function reads(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ); +} + +/** The normalized question one retained read recorded. */ +function question(event: DurableEvent) { + return event.type === "yield" + ? parsePullRequestReadRequest(event.description["input"]) + : undefined; +} + +/** How many times this transport was asked for a reviews collection. */ +function reviewCalls(host: Server): number { + return host.requests.filter((request) => new URL(request.url).pathname.endsWith("/reviews")) + .length; +} + +/** A journal that refuses every append of one durable type. */ +function refusing(inner: InMemoryStream, type: string): DurableStream { + return { + readAll: () => inner.readAll(), + *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === type) { + throw new Error("the journal backend refused this append"); + } + yield* inner.append(event); + }, + }; +} + +/** A journal holding one unfinished history's events. */ +function replaying(prefix: readonly DurableEvent[]): InMemoryStream { + return new InMemoryStream( + [...prefix].filter((event) => !(event.type === "close" && event.coroutineId === "root")), + ); +} + +describe("one entry's evidence reads", () => { + it("V7a: reads a collection with no checkout, and retains the question it asked", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const first = yield* run( + fixture, + `\n`, + stream, + host, + ); + + expect(first.refusal).toBeUndefined(); + expect(first.output).toContain("reviewer"); + const retained = reads(first.events); + expect(retained.length).toBe(1); + const asked = question(retained[0]!); + expect(asked?.url).toBe(SUBJECT_URL); + expect(asked?.kind).toBe("reviews"); + // The owner is in the run, and the authenticated invocation in the + // expansion. The name omits the owner, so this is where it is said. + expect(asked?.runId).toBe(OWNER); + expect(asked?.expansionId).not.toBe(""); + }); + + it("V4a: a completed read restores without a session or a request", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const first = yield* run( + fixture, + `\n`, + stream, + host, + ); + expect(first.refusal).toBeUndefined(); + expect(reviewCalls(host)).toBe(1); + + const again = yield* run( + fixture, + `\n`, + replaying(first.events), + host, + ); + expect(again.refusal).toBeUndefined(); + expect(again.output).toContain("reviewer"); + // Nothing was asked of the host a second time, and no second record exists. + expect(reviewCalls(host)).toBe(1); + expect(reads(again.events).length).toBe(1); + }); + + it("V4b: removing the repository from the allowed list refuses a saved read", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const first = yield* run( + fixture, + ``, + stream, + host, + ); + expect(first.refusal).toBeUndefined(); + expect(reads(first.events).length).toBe(1); + + // The ceiling is asked on every execution, including this one. The saved + // record is history and is not erased; it is simply not reachable. + const narrowed = yield* run( + fixture, + ``, + replaying(first.events), + host, + { allowed: [] }, + ); + expect(narrowed.refusal).toBeDefined(); + expect(reads(narrowed.events).length).toBe(1); + expect(reviewCalls(host)).toBe(1); + }); + + it("V7b: a denied target leaves no record and asks nothing", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const denied = yield* run( + fixture, + ``, + stream, + host, + { allowed: ["https://github.com/octo/other"] }, + ); + + expect(denied.refusal).toBeDefined(); + // A question this host was never permitted to ask is not a read that + // failed: there is no record of it and no session was opened. + expect(reads(denied.events).length).toBe(0); + expect(host.requests).toEqual([]); + }); + + it("V7c: two concurrent reads keep their own question under reversed completion", function* () { + const fixture = yield* useFixture(); + // The second pull request answers first, so the two reads complete in the + // opposite order from the one they started in. + const order: string[] = []; + const inner = reviewing(); + const host: Server = { + requests: inner.requests, + access: { + endpoint: inner.access.endpoint, + token: () => inner.access.token(), + *send(request: GitHubHttpRequest): Operation { + const path = new URL(request.url).pathname; + if (path === REVIEWS) { + // Let the other read's walk finish first. + yield* until(new Promise((resolve) => setTimeout(resolve, 40))); + } + order.push(path); + return yield* inner.access.send(request); + }, + }, + }; + const stream = new InMemoryStream(); + const both = yield* run( + fixture, + [ + "", + ` `, + ` `, + "", + "", + ].join("\n"), + stream, + host, + { allowed: [SUBJECT_REPO] }, + ); + + expect(both.refusal).toBeUndefined(); + const retained = reads(both.events); + expect(retained.length).toBe(2); + const asked = retained.map((event) => question(event)); + // Each read kept its own URL and its own invocation, whichever finished + // first: one bridge per read, so neither could answer the other. + expect(new Set(asked.map((one) => one?.url))).toEqual(new Set([SUBJECT_URL, OTHER_URL])); + expect(new Set(asked.map((one) => one?.expansionId)).size).toBe(2); + for (const one of asked) { + expect(one?.runId).toBe(OWNER); + expect(one?.kind).toBe("reviews"); + } + expect(order.indexOf(OTHER_REVIEWS)).toBeLessThan(order.indexOf(REVIEWS)); + }); + + it("V7d: a URL transformation before admission is the question that is recorded", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const redirected = yield* run( + fixture, + ``, + stream, + host, + { + *between(): Operation { + yield* PullRequestOperations.around({ + *read([invocation], next): Operation { + return yield* next({ ...invocation, url: OTHER_URL }); + }, + }); + }, + }, + ); + + expect(redirected.refusal).toBeUndefined(); + // The transformation changed which read was admitted, so the record says + // the other pull request. + expect(question(reads(redirected.events)[0]!)?.url).toBe(OTHER_URL); + }); + + it("V4c: a transformation after delegation is live, over the saved snapshot", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const document = + `\n` + ""; + + const first = yield* run(fixture, document, stream, host, { + *between(): Operation { + yield* PullRequestOperations.around({ + *read([invocation], next): Operation { + const answered = yield* next(invocation); + return answered.kind === "reviews" + ? { kind: "reviews", items: answered.items.slice(0, 1) } + : answered; + }, + }); + }, + }); + expect(first.refusal).toBeUndefined(); + // One of the two, because the handler kept one. + expect(first.output).toContain("review 1"); + expect(first.output).not.toContain("review 2"); + + // Reopening with a handler that keeps the other one: the saved provider + // snapshot is fixed, and the composition over it is not. + const again = yield* run(fixture, document, replaying(first.events), host, { + *between(): Operation { + yield* PullRequestOperations.around({ + *read([invocation], next): Operation { + const answered = yield* next(invocation); + return answered.kind === "reviews" + ? { kind: "reviews", items: answered.items.slice(1) } + : answered; + }, + }); + }, + }); + expect(again.refusal).toBeUndefined(); + expect(again.output).toContain("review 2"); + expect(again.output).not.toContain("review 1"); + // And nothing was asked of the host again. + expect(reviewCalls(host)).toBe(1); + }); + + it("V4d: a synthetic read reruns and retains nothing", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const synthetic = { + *between(): Operation { + yield* PullRequestAPI.around({ + // deno-lint-ignore require-yield + *read(): Operation { + return { kind: "reviews", items: [] }; + }, + }); + }, + }; + const first = yield* run( + fixture, + ``, + stream, + host, + synthetic, + ); + + expect(first.refusal).toBeUndefined(); + // A handler that never reached the seam retained nothing, and asked + // nothing. + expect(reads(first.events).length).toBe(0); + expect(host.requests).toEqual([]); + + // So reopening runs it again rather than restoring it. + const again = yield* run( + fixture, + ``, + replaying(first.events), + host, + synthetic, + ); + expect(again.refusal).toBeUndefined(); + expect(reads(again.events).length).toBe(0); + }); + + it("V7e: a read whose walk cannot finish binds no array", function* () { + const fixture = yield* useFixture(); + // The collection is paged and page two is unavailable, so the walk never + // completes. None of what it did answer is evidence that there is nothing + // there. + const host = server({ + [PULL]: { body: pullRequestPayload() }, + [REVIEWS]: { + body: JSON.stringify([review(1)]), + link: `<${ENDPOINT}${REVIEWS}?page=2>; rel="next"`, + }, + [`${REVIEWS}?page=2`]: { status: 500, body: "{}" }, + }); + const stream = new InMemoryStream(); + const incomplete = yield* run( + fixture, + `\n`, + stream, + host, + ); + + expect(incomplete.refusal).toBeDefined(); + expect(incomplete.output).not.toContain("reviewer"); + // The failed read is this read's own outcome, and it holds no partial + // array. + const retained = reads(incomplete.events); + expect(retained.length).toBe(1); + expect(retained[0]?.type === "yield" && retained[0].result.status).toBe("err"); + }); + + it("V8a: a refused read append binds no array", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const inner = new InMemoryStream(); + const attempt = yield* run( + fixture, + `\n`, + refusing(inner, PULL_REQUEST_READ), + host, + ); + + expect(attempt.refusal).toBeDefined(); + expect(attempt.output).not.toContain("reviewer"); + expect(reads(yield* inner.readAll()).length).toBe(0); + // The read may have happened; what did not happen is a binding, and an + // unacknowledged read can safely run again. + expect(reviewCalls(host)).toBe(1); + }); + + it("V6a: a read saved under another owner is refused before replay", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const first = yield* run( + fixture, + ``, + stream, + host, + ); + expect(first.refusal).toBeUndefined(); + + // The same history offered to another entry. The read's name omits the + // owner, so what refuses this is the owner in the record itself. + const borrowed = yield* run( + fixture, + ``, + replaying(first.events), + host, + { owner: OTHER_OWNER }, + ); + expect(borrowed.refusal).toBeDefined(); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 2e21b1b5b..981845dfc 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -760,6 +760,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "the subject is a REPL entry's native pull-request upsert: it clones a managed checkout, publishes to a real bare remote with real `git` children, holds that checkout's managed slot under a kernel-released exclusive advisory lock taken through the Deno runtime, and reconciles against a modeled Git host. The saved request, the coordinated Push/pull-request admission and the owner-specified worker are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-pull-request-records.test.ts and packages/git/tests/git-host-owned.test.ts; that a runtime without the provider refuses before any native or service operation is proven by packages/cli/tests/repl-pull-request-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, + { + path: "packages/git/tests/retained-repository-pull-request-reads.test.ts", + reason: + "drives the production per-entry capability, whose module graph reaches @executablemd/git/deno and so Workflow's storage adapter and node:sqlite. The retained read's record, its owner admission and its result contract are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-pull-request-records.test.ts; that a runtime selecting no entry capability reports no provider for all three reads is proven by packages/cli/tests/repl-pull-request-pty.test.ts, which runs everywhere", + issue: DERIVED_SCOPE, + }, { path: "packages/cli/tests/repl-push.test.ts", reason: From 07d69b0338a31389c92841f83efc5e096d867536 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 13:01:17 -0400 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=9A=A7=20Declare=20four=20identitie?= =?UTF-8?q?s=20per=20entry,=20and=20reads=20without=20a=20checkout=20(#181?= =?UTF-8?q?,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 6's V9 rows. Not the feedback commit. The entry declares the upsert and its three evidence reads beside the capabilities it already had, and building one still reaches nothing: no managed root, no `git`, no credential, no service. A host that selects no managed root declares the reads anyway, because a read names a URL rather than a checkout, and selecting none creates none. With no entry capability selected at all — which is what Node and Bun leave — the upsert and all three reads reach the ordinary missing-provider refusal with no native work and no service call on the way to it. A passive view of a settled history initializes nothing. Evidence: F4 complete and green — repl-pull-request 5/5, repl-push, repl-issues, repl-repositories, runtime-exclusions, test-file-discovery and ci-workflow, 18 suites over 107 steps. --- packages/cli/tests/repl-pull-request.test.ts | 410 +++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + 2 files changed, 416 insertions(+) create mode 100644 packages/cli/tests/repl-pull-request.test.ts diff --git a/packages/cli/tests/repl-pull-request.test.ts b/packages/cli/tests/repl-pull-request.test.ts new file mode 100644 index 000000000..92c0ea2c1 --- /dev/null +++ b/packages/cli/tests/repl-pull-request.test.ts @@ -0,0 +1,410 @@ +/** + * How a REPL entry gets its pull-request capability, and who never gets one. + * + * The entry host prepares two kinds of thing per entry, and pull requests sit + * on both sides of that line. The upsert needs the private initialization its + * checkouts and publications are created by; the three evidence reads need none + * of it, because a read names a URL. These rows hold the assembly to that + * shape — four identities declared together, one initialization per executing + * entry, reads that work with no managed root at all, and the existing + * capabilities intact beside the new ones. + * + * Every row drives the production path: the real selector, the real per-entry + * factory, the real session. Nothing here reaches a Git host, because none of + * what these rows are about happens after a request exists. + * + * They carry V9 of the delivery-5 matrix. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation, Result } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { appendFile, mkdtemp, open, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { Execution } from "@executablemd/core/api"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { denoRepositoryHost } from "../../git/src/deno/composition/host.ts"; +import { gitSession } from "../../git/src/deno/composition/git.ts"; +import { gitPlugin } from "../../git/src/plugin.ts"; +import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; +import { REPL_REPOSITORY_PULL_REQUEST_REQUEST } from "../../git/src/retained-repository/pull-request-records.ts"; +import { PULL_REQUEST_READ } from "../../git/src/deno/composition/pull-request-operations.ts"; +import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; +import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; +import { selectReplEntryHost } from "../src/deno-repositories.ts"; +import { assembleReplProfile } from "../src/repl-profile.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; +import { resolveAgentStack } from "../src/agent-stack.ts"; +import { NO_PLUGINS } from "../src/plugin-host.ts"; +import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; +import { ReplHost } from "../src/repl/host.ts"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; + +/** The credential-helper assembly these rows' host selects. Never invoked. */ +const SUITE_HELPER: HelperAssembly = Object.freeze({ + runtime: "source", + platform: "unix", + execPath: "/nonexistent/xmd", +}); + +interface Fixture { + readonly root: string; + readonly checkout: string; + readonly origin: string; + readonly managed: string; +} + +/** One checkout and one bare remote, which is all these rows need. */ +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "repl-pr-cli-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const git = gitSession(denoRepositoryHost(), root); + const checkout = join(root, "checkout"); + const origin = join(root, "origin.git"); + yield* git.run(["init", "--bare", "-b", "main", origin], root); + yield* git.run(["init", "-b", "main", checkout], root); + yield* until(writeFile(join(checkout, "seed.md"), "seed\n")); + yield* git.run(["add", "seed.md"], checkout); + yield* git.run(["commit", "-m", "seed"], checkout); + yield* git.run(["push", origin, "main:refs/heads/main"], checkout); + yield* provide({ root, checkout, origin, managed: join(root, "repositories") }); + }); +} + +/** + * The author and committer this suite's host states. + * + * `` borrows the *invoking person's* identity, which the production + * reader asks native Git for in the environment the command was started in. On + * a developer's machine Git auto-detects one from the operating-system user; a + * continuous-integration runner whose hostname carries no domain can detect + * nothing, `git var` fails, and the commit in these entries would report its + * ordinary missing-identity refusal — failing the row for a reason that is + * about the runner rather than about the assembly these rows are measuring. + */ +const STATED_IDENTITY: Record = { + GIT_AUTHOR_IDENT: "Entry Author 1700000000 +0000", + GIT_COMMITTER_IDENT: "Entry Committer 1700000000 +0000", +}; + +/** That identity, as the host reads one. */ +// deno-lint-ignore require-yield +function* statedIdentity(variable: string): Operation { + return STATED_IDENTITY[variable]; +} + +/** The host half a runtime entrypoint owns, installed here by the suite. */ +function useNodeReplHost(name: string): Operation { + return ReplHost.around( + { + // deno-lint-ignore require-yield + *identify(): Operation { + return name; + }, + *createExclusive([path]: [string]): Operation { + const handle = yield* until(open(path, "wx")); + yield* until(handle.close()); + }, + *appendRecord([path, record]: [string, string]): Operation { + yield* until(appendFile(path, record)); + }, + }, + { at: "min" }, + ); +} + +/** Where every authored path in these entries resolves. */ +function useWorkingDirectory(directory: string): Operation { + return API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return directory; + }, + }, + { at: "min" }, + ); +} + +/** What the Git Plugin contributes for the `repl` command. */ +function* replInstallations(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installs nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "repl", args: [] }); + if (installed === undefined) { + throw new Error("the Git Plugin declared nothing for the repl command"); + } + return [{ admissions: [...(installed.admissions ?? [])] }]; +} + +/** What one run's assembly did, as these rows read it. */ +interface Counted { + /** Every owner the session derived and handed to the factory. */ + readonly owners: string[]; + /** How many times core invoked `init` for an execution. */ + initialized: number; +} + +function counted(): Counted { + return { owners: [], initialized: 0 }; +} + +function opened(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function execution(id: string, events: readonly DurableEvent[] = []): ReplExecution { + return { id, stream: new InMemoryStream([...events]) }; +} + +interface Ran { + readonly events: readonly DurableEvent[]; + readonly outcome: Result | undefined; +} + +interface RunOptions { + /** Whether this host selects a managed root at all. */ + readonly root?: string | undefined; + /** Whether this host selects an entry capability at all. */ + readonly host?: boolean; +} + +/** + * One submitted entry, run through the production session. + * + * The factory is the one the production selector selects, and the owner is the + * one the session derives: what these rows are about is that assembly, so none + * of it is substituted. + */ +function submit( + fixture: Fixture, + source: string, + run: ReplExecution, + seen: Counted, + options: RunOptions = {}, +): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + // Counted on the same seam core invokes, so "one initialization per + // executing entry" is measured rather than inferred. + yield* Execution.around({ + *init(_args, next): Operation { + seen.initialized += 1; + yield* next(); + }, + }); + const managed = "root" in options ? options.root : fixture.managed; + const session = opened( + yield* submitReplEntry({ + execution: run, + source, + installations: [...(yield* replInstallations())], + generated: ordinaryGeneratedSelection(), + ...(options.host === false + ? {} + : { + entryInstallation: (owner: string) => { + seen.owners.push(owner); + return replRepositoryInstallation(owner, { + host: denoRepositoryHost(), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + }, + }), + }), + ); + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** One reopened session over a history nobody is continuing. */ +function reopen( + fixture: Fixture, + run: ReplExecution, + seen: Counted, + options: RunOptions = {}, +): Operation { + return scoped(function* (): Operation { + yield* useNodeReplHost(run.id); + yield* useHostFiles(); + yield* useWorkingDirectory(fixture.checkout); + yield* Execution.around({ + *init(_args, next): Operation { + seen.initialized += 1; + yield* next(); + }, + }); + const managed = "root" in options ? options.root : fixture.managed; + const session = opened( + yield* openReplSession({ + execution: run, + installations: [...(yield* replInstallations())], + generated: ordinaryGeneratedSelection(), + entryInstallation: (owner: string) => { + seen.owners.push(owner); + return replRepositoryInstallation(owner, { + host: denoRepositoryHost(), + identity: statedIdentity, + ...(managed === undefined ? {} : { root: managed }), + }); + }, + }), + ); + const outcome = yield* session.join(); + return { events: yield* run.stream.readAll(), outcome }; + }); +} + +/** The durable types one history holds, in order. */ +function types(events: readonly DurableEvent[]): string[] { + return events.flatMap((event) => (event.type === "yield" ? [event.description.type] : [])); +} + +const PUBLISHING = (fixture: Fixture): string => + [ + ``, + ` `, + ` ready`, + ` `, + ` `, + ` `, + ``, + "", + ].join("\n"); + +describe("how a REPL entry gets its pull-request capability", () => { + it("D1: one executing entry declares all four identities under its own owner", function* () { + const fixture = yield* useFixture(); + const seen = counted(); + const run = execution("execution-pr-cli-1"); + + // Inspection describes the four components this capability adds, beside + // the ones it already had. + const built = yield* scoped(function* (): Operation { + return yield* replRepositoryInstallation( + '["executablemd.repl.issue-owner",1,"execution-pr-cli-1","1"]', + { host: denoRepositoryHost(), root: fixture.managed }, + ); + }); + const declared = [...(built.components ?? [])].map((component) => component.name).sort(); + expect(declared).toEqual([ + "Git.Push", + "Issue", + "PullRequest", + "PullRequest.Checks", + "PullRequest.Comments", + "PullRequest.Reviews", + ]); + + // And building one reaches nothing: no managed root, no `git`, no + // credential and no service. + expect(yield* nothingUnder(fixture.managed)).toBe(true); + expect(seen.initialized).toBe(0); + expect(run.id).toBe("execution-pr-cli-1"); + }); + + it("D2: with no entry capability selected, all four reach the ordinary refusal", function* () { + const fixture = yield* useFixture(); + + // What Node and Bun leave: the components are declared by the Plugin for + // this command, and reaching one is the missing-provider refusal. No + // native work and no service call happens on the way to it. + for (const element of [ + 'body', + '', + '', + '', + ]) { + const seen = counted(); + const run = execution(`execution-pr-cli-2-${element.length}`); + const source = element.startsWith("`, + ` ${element}`, + ``, + "", + ].join("\n"); + const ran = yield* submit(fixture, source, run, seen, { host: false }); + expect(ran.outcome?.ok).toBe(false); + expect(seen.owners).toEqual([]); + expect(types(ran.events)).not.toContain(REPL_REPOSITORY_PULL_REQUEST_REQUEST); + expect(types(ran.events)).not.toContain(PULL_REQUEST_READ); + expect(types(ran.events)).not.toContain(GIT_HOST_EFFECT); + } + }); + + it("D3: an entry with no managed root still declares the reads", function* () { + const fixture = yield* useFixture(); + + // A read needs no checkout, so the identities are declared whether or not + // this host selects a managed root — and selecting none creates none. + const built = yield* scoped(function* (): Operation { + return yield* replRepositoryInstallation( + '["executablemd.repl.issue-owner",1,"execution-pr-cli-3","1"]', + { host: denoRepositoryHost() }, + ); + }); + const declared = [...(built.components ?? [])].map((component) => component.name); + expect(declared).toContain("PullRequest.Reviews"); + expect(declared).toContain("PullRequest.Comments"); + expect(declared).toContain("PullRequest.Checks"); + expect(yield* nothingUnder(fixture.managed)).toBe(true); + }); + + it("D4: a passive view of a settled history initializes nothing", function* () { + const fixture = yield* useFixture(); + const first = counted(); + const run = execution("execution-pr-cli-4"); + const ran = yield* submit(fixture, PUBLISHING(fixture), run, first); + expect(ran.outcome?.ok).toBe(true); + expect(first.initialized).toBe(1); + + const passive = counted(); + const again = yield* reopen(fixture, execution(run.id, ran.events), passive); + expect(again.outcome?.ok).toBe(true); + expect(passive.owners).toEqual([]); + expect(passive.initialized).toBe(0); + }); + + it("D5: the entry declares one admission for its reads beside the others", function* () { + const fixture = yield* useFixture(); + const built = yield* scoped(function* (): Operation { + return yield* replRepositoryInstallation( + '["executablemd.repl.issue-owner",1,"execution-pr-cli-5","1"]', + { host: denoRepositoryHost(), root: fixture.managed }, + ); + }); + // Three: the issues history, this entry's publication and pull-request + // history, and its evidence reads. + expect((built.admissions ?? []).length).toBe(3); + }); +}); + +/** Whether this path still holds nothing, which is what inertness looks like. */ +function* nothingUnder(path: string): Operation { + try { + yield* writeTextFile(join(path, "probe"), "probe"); + return false; + } catch { + return true; + } +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 981845dfc..d1629aea8 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -766,6 +766,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "drives the production per-entry capability, whose module graph reaches @executablemd/git/deno and so Workflow's storage adapter and node:sqlite. The retained read's record, its owner admission and its result contract are provider-neutral and are proven on every runtime by packages/git/tests/retained-repository-pull-request-records.test.ts; that a runtime selecting no entry capability reports no provider for all three reads is proven by packages/cli/tests/repl-pull-request-pty.test.ts, which runs everywhere", issue: DERIVED_SCOPE, }, + { + path: "packages/cli/tests/repl-pull-request.test.ts", + reason: + "drives the production per-entry capability, which only Deno and the compiled binary select: its module graph reaches @executablemd/git/deno and so Workflow's storage adapter and node:sqlite, and its rows build real checkouts with real `git` children. That a runtime selecting no entry capability reports no provider for and all three reads is proven by packages/cli/tests/repl-pull-request-pty.test.ts, which runs everywhere", + issue: DERIVED_SCOPE, + }, { path: "packages/cli/tests/repl-push.test.ts", reason: From ad91d20442f7819edfdb3b0130112eada5ff306f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 14:37:19 -0400 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=9A=A7=20Drive=20the=20whole=20jour?= =?UTF-8?q?ney=20on=20a=20real=20terminal,=20from=20source=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 6's V1 source row and the shared journey both rows call. Not the feedback commit. One entry publishes a branch and stops at a question. Reopening the printed location continues it: the pull request opens once, each of the three collections is read once, and it stops at the second question. The service then gains a second reviewer, a comment and a check while the person is away. Reopening again reaches that second question having asked the host *nothing* — no mutation, no read — which is what says the identity and the three arrays came back from the journal rather than from the service. Answering performs the one later explicit read, which is the only request that session makes, and it sees the change. The primary branch is untouched throughout and the published branch still holds the commit that was published. Two leaf boundaries are substituted and nothing else. `git` reaches a bare repository on disk through a shim that rewrites the one locator in its arguments — it has to be the shim rather than Git's own `insteadOf`, because every remote-touching invocation runs with the invoking user's configuration off, which is the property that stops a document retargeting a publication. The entry still authenticates and fingerprints the locator it admitted, which it holds itself and never reads back from Git, so nothing is substituted on the way out. The pull request itself is served by the shared fixture over a loopback port, so these payloads are the ones every other pull-request suite is written against; what is added is the evidence half it does not serve. Finding the journey also fixed a real defect: the access session was opened through the attempt's scope handle rather than inside the attempt, so it was acquired as a task that a resource never completes. It is opened in the attempt now, which is also where it must be released and joined. The second question renders its saved evidence as output rather than inside its prompt, because a prompt carrying three collections scrolls its own option below the fold — a property of the terminal, not of the behaviour. Evidence: repl-pull-request-pty V1 passes from source under Deno (30s); the same suite's unsupported-runtime branch passes under Node (tsx) and under Bun with the runner's own `--timeout`. retained-repository-pull-request 13/13, including a new row that reaches a configured endpoint rather than an injected transport. --- .../cli/tests/repl-pull-request-pty.test.ts | 49 + .../support/repl-pull-request-journey.ts | 871 ++++++++++++++++++ .../deno/retained-repository/pull-request.ts | 10 +- .../retained-repository-pull-request.test.ts | 18 + .../tests/repl-pull-request-compiled.test.ts | 50 + 5 files changed, 993 insertions(+), 5 deletions(-) create mode 100644 packages/cli/tests/repl-pull-request-pty.test.ts create mode 100644 packages/cli/tests/support/repl-pull-request-journey.ts create mode 100644 scripts/tests/repl-pull-request-compiled.test.ts diff --git a/packages/cli/tests/repl-pull-request-pty.test.ts b/packages/cli/tests/repl-pull-request-pty.test.ts new file mode 100644 index 000000000..7366d6f91 --- /dev/null +++ b/packages/cli/tests/repl-pull-request-pty.test.ts @@ -0,0 +1,49 @@ +/** + * The whole public behavior of a pull request and its evidence, on a real + * terminal, from source (#181 V1, V9). + * + * Every other row of this feature's evidence installs its own host, which is + * what makes those rows portable and deterministic. None of them can show the + * thing a person actually does: type an entry that publishes a branch and opens + * its pull request, walk away at a question, let the reviews change, and reopen + * the printed location. + * + * ## One suite, two behaviors, by runtime + * + * Deno is where the entry capability exists, so the Deno branch runs the + * journey. Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not — so their branch runs the other behavior + * that matters, which is that `` and its reads there refuse before + * contacting anything. Neither branch silently skips its own behavior. + * + * The journey itself is `support/repl-pull-request-journey.ts`, shared with the + * compiled row so the two cannot drift. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { cliRuntime, cliShellCommand } from "@executablemd/test-support/launch"; +import type { Operation } from "effection"; +import { + replPullRequestJourney, + replUnsupportedPullRequest, +} from "./support/repl-pull-request-journey.ts"; + +const JOURNEY = cliRuntime() === "deno"; + +/** How this suite invokes `xmd`: the production entrypoint for this runtime. */ +function sourceCommand(args: readonly string[]): string { + return cliShellCommand([...args]); +} + +describe("a REPL entry's pull request, across leaving and coming back", () => { + it( + JOURNEY + ? "V1: opens once, restores its identity and saved evidence, and reads again on answering" + : "V9: this runtime refuses and its reads before contacting anything", + function* (): Operation { + yield* JOURNEY + ? replPullRequestJourney(sourceCommand) + : replUnsupportedPullRequest(sourceCommand); + }, + ); +}); diff --git a/packages/cli/tests/support/repl-pull-request-journey.ts b/packages/cli/tests/support/repl-pull-request-journey.ts new file mode 100644 index 000000000..50f8f2925 --- /dev/null +++ b/packages/cli/tests/support/repl-pull-request-journey.ts @@ -0,0 +1,871 @@ +/** + * The one real-terminal journey an entry's pull requests and evidence rest on. + * + * Shared by two rows that differ in exactly one thing: which `xmd` runs. The + * Deno source row proves the entry capability this repository builds; the + * compiled row proves the binary carries it. Writing the journey twice would + * let the two drift, and a compiled row asserting something slightly weaker + * than the source row is how a packaging defect survives. + * + * Every other row of this feature's evidence installs its own host, which is + * what makes those rows portable and deterministic. None of them can show the + * thing a person actually does: type an entry that publishes a branch and opens + * its pull request, walk away at a question, let the reviews and checks change + * while away, and come back to the printed location. So this launches the + * production entrypoint on a pty, against a bare remote on disk and a Git host + * on a loopback port, and reads the answer off the screen and off both. + * + * ## What is substituted, and what is not + * + * Two leaf boundaries, and nothing else. `git` reaches a bare repository on + * disk rather than github.com, through Git's own `insteadOf` — so the locator + * the entry authenticates and fingerprints stays the github.com one a document + * wrote, which is the whole point of substituting at that layer rather than + * rewriting the document. And the configured API endpoint points at a forge in + * this process, which counts what it was asked. + * + * The CLI, the per-entry factory, the selectors, the publication, the upsert, + * the three read adapters, the storage and the terminal are all the real ones. + */ + +import { expect } from "@executablemd/test-support/expect"; +import { cliRuntime, shellQuote } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { each, ensure, resource, scoped, sleep, until, useScope } from "effection"; +import { fromReadable } from "@effectionx/node"; +import { Buffer } from "node:buffer"; +import type { Operation } from "effection"; +import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; +import { execFile, spawnSync } from "node:child_process"; +import { chmod, mkdir, readdir } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import process from "node:process"; +import { creations, gitHubStore, mutations, respond } from "../../../git/tests/support/github.ts"; +import type { GitHubHttpResponse } from "../../../git/src/deno/composition/github.ts"; +import { allocate, showing, showingAcross } from "./pty.ts"; +import type { Pty } from "./pty.ts"; + +const REACH_MS = 180_000; +const ACT_MS = 60_000; + +/** How this row's `xmd` is invoked, as one shell command. */ +export type ReplCommand = (args: readonly string[]) => string; + +const JOURNEY_IDENTITY = { name: "Journey Author", email: "journey-author@example.test" }; + +/** The branch this journey publishes, which is not the repository's primary one. */ +const BRANCH = "review/notes"; + +/** + * The repository a document names, and the one Git reaches on disk instead. + * + * `owner/repository` rather than anything else, because that is the repository + * the shared pull-request fixture reports a created pull request at — and the + * evidence reads follow that URL. A locator naming one repository while the + * host answered about another is exactly what those reads refuse. + */ +const LOCATOR = "https://github.com/owner/repository"; + +/** The credential this journey's forge requires, which no record may hold. */ +const TOKEN = "journey-github-credential"; + +const HEAD_SHA_PATTERN = /^[0-9a-f]{40}$/; + +const FIRST_QUESTION = + 'The branch containing {commit} is published. Continue to its pull ' + + "request?"; + +// The saved evidence is rendered as output before this question rather than +// inside its prompt. A prompt carrying three collections scrolls its own option +// below the fold, which is a property of the terminal rather than of the +// behaviour under test. +const SECOND_QUESTION = + 'Continue to a new review read?'; + +/** + * The entry this journey types, as the specification shows it. + * + * A managed Repository, a Worktree whose captured path a `` renders at, a + * commit inside that linked checkout, the publication, a question, the pull + * request and its three evidence reads, a second question, and one later + * explicit review read after it. + */ +function entry(): string { + return [ + ``, + ``, + "", + 'Ready for review.', + '', + '', + "", + FIRST_QUESTION, + 'Ready for review.', + "", + '', + '', + '', + "", + SECOND_QUESTION, + '', + "", + "", + "", + ].join("\n"); +} + +/** + * Run the whole journey, and answer both questions. + * + * Takes the command rather than building one, so the only difference between + * the two rows that call this is which `xmd` they name. + */ +export function replPullRequestJourney(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-pr-pty-"); + const origin = yield* seedBareOrigin(home); + const shim = yield* gitShim(home, origin); + yield* seedIdentity(home); + const forge = yield* useForge(origin); + + const away = join(home, "away"); + yield* until(mkdir(away, { recursive: true })); + const exports = [ + // A developer laptop configures a credential helper system-wide, so a + // fixture home that only replaced HOME would still stand on the real + // machine's authentication. + "export GIT_CONFIG_SYSTEM=/dev/null", + "export GIT_CONFIG_NOSYSTEM=1", + `export GH_TOKEN=${shellQuote(TOKEN)}`, + `export XMD_WORKFLOW_GITHUB_PULL_REQUESTS=${shellQuote( + JSON.stringify({ + endpoint: forge.endpoint, + allowed: [LOCATOR], + }), + )}`, + ].join("; "); + const open = (args: readonly string[]): string => + `cd ${shellQuote(away)} && ${exports}; ${shim.prefix} ${repl(args)}`; + + // ---- First session: publish, then leave at the first question. ---- + const first = yield* allocate(open(["repl", "--deny-all"]), home); + yield* showing(first, "[exit]", REACH_MS); + yield* first.type(entry()); + yield* sleep(500); + yield* first.type("\r"); + + const working = yield* managedCheckout(home, "worktrees"); + yield* holding(join(working, "notes.md"), "Ready for review."); + yield* showing(first, "waiting for an answer", ACT_MS); + + const published = yield* gitSaid(working, ["rev-parse", "HEAD"], home); + expect(published).toMatch(HEAD_SHA_PATTERN); + yield* remoteHolding(origin, BRANCH, published, home); + const primary = yield* remoteRef(origin, "main", home); + expect(primary).toMatch(HEAD_SHA_PATTERN); + expect(primary).not.toBe(published); + // Nothing of the pull request happened: the entry is standing at the + // question before it. + expect(forge.creations()).toBe(0); + expect(forge.evidenceReads()).toBe(0); + + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + + // ---- Second session: answer, open the pull request, read all three. ---- + const second = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(second, "[exit]", REACH_MS); + // Reopening a location shows the entry at its checkpoint; driving the view + // is what continues the unfinished work. + yield* resume(second); + yield* reach(second, "[answer]"); + yield* second.type("\r"); + yield* showingAcross(second, `The branch containing ${published} is published`, ACT_MS); + yield* click(second, "( ) continue"); + + // It stops at the second question. What says it got there is the work + // between the two: one pull request opened, and each collection read once. + // Every question's transcript header says "waiting", so the screen cannot + // tell the two apart. + yield* reached(() => forge.evidenceReads() >= 3, "the three evidence reads", second); + expect(forge.creations()).toBe(1); + expect(forge.reviewReads()).toBe(1); + expect(forge.commentReads()).toBe(1); + expect(forge.checkReads()).toBe(1); + const number = forge.number(); + expect(number).toBeGreaterThan(0); + + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + + // ---- The service changes while the person is away. ---- + forge.change(); + const mutationsSoFar = forge.mutations(); + const evidenceSoFar = forge.evidenceReads(); + + // ---- Third session: the saved identity and arrays come back unchanged. ---- + const third = yield* allocate(open(["repl", "--deny-all", location]), home); + yield* showing(third, "[exit]", REACH_MS); + yield* resume(third); + + // Nothing was mutated and nothing was read again: the completed upsert came + // from its receipt and the three reads from their saved snapshots. + expect(forge.mutations()).toBe(mutationsSoFar); + expect(forge.evidenceReads()).toBe(evidenceSoFar); + expect(forge.creations()).toBe(1); + + // What proves the three arrays came back from the journal is that nothing + // was asked for them. The second reviewer, the comment and the check all + // arrived while the person was away, and this session reaches the question + // it was left at without asking the host anything at all. + yield* reach(third, "[answer]"); + yield* third.type("\r"); + yield* showingAcross(third, "Continue to a new review read", ACT_MS); + expect(forge.evidenceReads()).toBe(evidenceSoFar); + expect(forge.mutations()).toBe(mutationsSoFar); + + // ---- Answering performs the one later read, which sees the change. ---- + yield* click(third, "( ) continue"); + yield* showing(third, "[ok]", ACT_MS); + + // Exactly one more read — the later explicit one — and it is the only + // thing in this session that reached the host. + yield* reached(() => forge.reviewReads() === 2, "the later explicit review read", third); + expect(forge.evidenceReads()).toBe(evidenceSoFar + 1); + expect(forge.mutations()).toBe(mutationsSoFar); + expect(forge.creations()).toBe(1); + // The primary branch was never touched by any of it. + expect(yield* remoteRef(origin, "main", home)).toBe(primary); + expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); + + yield* reach(third, "[exit]"); + yield* third.type("\r"); + expect(yield* ended(third)).toBe(0); + }); +} + +/** + * What a pull request and its reads do on a runtime with no entry capability. + * + * Node and Bun assemble none — the Deno adapter's module graph reaches + * `node:sqlite`, which Bun has not got — so `` and all three reads + * there say no provider is installed rather than reaching a Git host, and say + * it before anything is contacted or any managed root is made. + */ +export function replUnsupportedPullRequest(repl: ReplCommand): Operation { + return scoped(function* (): Operation { + const home = yield* useTempDirectory("xmd-repl-pr-pty-"); + const origin = yield* seedBareOrigin(home); + const shim = yield* gitShim(home, origin); + yield* seedIdentity(home); + const forge = yield* useForge(origin); + const head = yield* remoteRef(origin, "main", home); + yield* writeTextFile(shim.log, ""); + + const exports = [ + // A developer laptop configures a credential helper system-wide, so a + // fixture home that only replaced HOME would still stand on the real + // machine's authentication. + "export GIT_CONFIG_SYSTEM=/dev/null", + "export GIT_CONFIG_NOSYSTEM=1", + `export GH_TOKEN=${shellQuote(TOKEN)}`, + `export XMD_WORKFLOW_GITHUB_PULL_REQUESTS=${shellQuote( + JSON.stringify({ endpoint: forge.endpoint, allowed: [LOCATOR] }), + )}`, + ].join("; "); + + const pty = yield* allocate( + `cd ${shellQuote(home)} && ${exports}; ${shim.prefix} ${repl(["repl", "--deny-all"])}`, + home, + ); + yield* showing(pty, "[exit]", REACH_MS); + yield* pty.type( + [ + "", + ``, + 'body', + "", + "", + "", + ``, + "", + 'continued', + ].join("\n"), + ); + yield* sleep(500); + yield* pty.type("\r"); + + // Refused before a managed checkout exists, which is before a Git host + // exists in the story at all. The refusal is a sentence, wrapped where the + // Transcript's own width cuts it, so it is read across those rows. + yield* showingAcross(pty, "pull-request provider is installed", ACT_MS); + yield* holding(join(home, "after.md"), "continued"); + expect(yield* logged(shim.log)).toEqual([]); + expect(forge.requests()).toBe(0); + expect(yield* remoteRef(origin, "main", home)).toBe(head); + expect(yield* exists(join(home, ".xmd", "repositories"))).toBe(false); + + yield* reach(pty, "[exit]"); + yield* pty.type("\r"); + expect(yield* ended(pty)).toBe(0); + }); +} + +/** One review, as this forge reports one. */ +/** + * One review, as this forge reports one. + * + * The subject is built from this forge's own endpoint, because that is what the + * read holds an item to: a well-formed answer about another pull request is + * still the wrong answer. + */ +function review(id: number, author: string, subject: string, head: string): unknown { + return { + id, + user: { login: author }, + state: "APPROVED", + body: `${author} says so`, + submitted_at: "2026-08-24T00:00:00Z", + commit_id: head, + html_url: `https://github.com/owner/repository/pull/1#r${id}`, + pull_request_url: subject, + }; +} + +interface Forge { + readonly endpoint: string; + /** Add a review, a comment and a check, as a service would between sessions. */ + change(): void; + /** How many pull requests this forge was asked to create. */ + creations(): number; + /** How many state-changing calls it was asked for at all. */ + mutations(): number; + /** How many evidence collections it was asked for, by kind and in total. */ + reviewReads(): number; + commentReads(): number; + checkReads(): number; + evidenceReads(): number; + /** How many requests of any kind reached it. */ + requests(): number; + /** The number the created pull request was given. */ + number(): number; + /** Every path this forge was asked for, in order. */ + paths(): string[]; +} + +/** + * A Git host on a loopback port, holding one pull request and its evidence. + * + * The pull request itself is the shared fixture's, so the payloads this journey + * answers with are the ones every other pull-request suite is written against + * rather than a second account of what GitHub returns. What is added here is + * the evidence half, which that fixture does not serve: the three collections + * this entry reads, and a way to change them between sessions. + * + * A compiled binary has no seam a suite can substitute, so the only way to ask + * it what it did is to count what arrived here. + */ +function useForge(origin: string): Operation { + return resource(function* (provide) { + const store = gitHubStore({ owner: "owner", repository: "repository", token: TOKEN }); + // The commit a branch holds, read from the bare remote — which is how the + // real host knows the commit a pull request is opened from, because the + // publication put it there. + store.resolveHead = (branch) => resolveRef(origin, branch); + + const asked: string[] = []; + // What the host holds, as authors and identifiers rather than as payloads: + // an item's subject is built from this forge's own endpoint, which is not + // known until it is listening. + const evidence = { + reviews: 0, + comments: 0, + checks: 0, + reviewers: ["first reviewer"], + commented: false, + checked: false, + }; + + const scope = yield* useScope(); + let endpoint = ""; + const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => { + scope.run(function* () { + // The body is read by a task of this server's own scope, so tearing the + // forge down ends the reads still in progress rather than leaving their + // listeners on sockets it is about to destroy. + const chunks: Uint8Array[] = []; + for (const chunk of yield* each(fromReadable(incoming))) { + chunks.push(chunk); + yield* each.next(); + } + const body = Buffer.concat(chunks).toString("utf8"); + const url = new URL(`http://localhost${incoming.url ?? "/"}`); + const path = url.pathname; + asked.push(`${incoming.method ?? "GET"} ${path}`); + + const answer = (status: number, payload: unknown): void => { + outgoing.writeHead(status, { "content-type": "application/json" }); + outgoing.end(JSON.stringify(payload)); + }; + + // The three evidence collections, matched for any repository so the URL + // this forge reports a pull request at is the one that is read. They + // come first: the shared fixture reads a number out of a pull-request + // path and would take these for one. + const head = resolveRef(origin, BRANCH); + const reviewSubject = `${endpoint}/repos/owner/repository/pulls/1`; + const issueSubject = `${endpoint}/repos/owner/repository/issues/1`; + if (/\/pulls\/\d+\/reviews$/.test(path)) { + evidence.reviews += 1; + answer( + 200, + evidence.reviewers.map((author, index) => + review(index + 1, author, reviewSubject, head), + ), + ); + return; + } + if (/\/issues\/\d+\/comments$/.test(path)) { + evidence.comments += 1; + answer( + 200, + evidence.commented + ? [ + { + id: 9, + user: { login: "commenter" }, + body: "a later comment", + created_at: "2026-08-25T00:00:00Z", + updated_at: "2026-08-25T00:00:00Z", + html_url: "https://github.com/owner/repository/pull/1#c9", + issue_url: issueSubject, + }, + ] + : [], + ); + return; + } + if (/\/pulls\/\d+\/comments$/.test(path)) { + // The other half of one comments read, already counted above. + answer(200, []); + return; + } + if (/\/commits\/[0-9a-f]+\/check-runs$/.test(path)) { + evidence.checks += 1; + answer(200, { + check_runs: evidence.checked + ? [ + { + id: 4, + name: "build", + head_sha: head, + status: "completed", + conclusion: "success", + html_url: null, + started_at: "2026-08-25T00:00:00Z", + completed_at: "2026-08-25T00:01:00Z", + output: { title: null, summary: null, text: null }, + }, + ] + : [], + }); + return; + } + if (/\/commits\/[0-9a-f]+\/status$/.test(path)) { + // The other half of one checks read, already counted above. + answer(200, { sha: head, statuses: [] }); + return; + } + + const headers: Record = {}; + for (const [name, value] of Object.entries(incoming.headers)) { + headers[name === "authorization" ? "Authorization" : name] = String(value); + } + let served: GitHubHttpResponse; + try { + served = respond(store, { + method: + incoming.method === "POST" ? "POST" : incoming.method === "PATCH" ? "PATCH" : "GET", + url: `http://localhost${incoming.url ?? "/"}`, + headers, + body, + }); + } catch { + outgoing.destroy(); + return; + } + outgoing.writeHead(served.status, { + "content-type": "application/json", + ...(served.link === undefined ? {} : { link: served.link }), + }); + outgoing.end(served.body); + }); + }); + + yield* until(new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()))); + yield* ensure(function* () { + server.closeAllConnections(); + yield* until(new Promise((resolve) => server.close(() => resolve()))); + }); + const address = server.address() as AddressInfo; + endpoint = `http://127.0.0.1:${address.port}`; + + yield* provide({ + endpoint, + change(): void { + evidence.reviewers = ["first reviewer", "second reviewer"]; + evidence.commented = true; + evidence.checked = true; + }, + creations: () => creations(store), + mutations: () => mutations(store).length, + reviewReads: () => evidence.reviews, + commentReads: () => evidence.comments, + checkReads: () => evidence.checks, + evidenceReads: () => evidence.reviews + evidence.comments + evidence.checks, + requests: () => + store.requests.length + evidence.reviews + evidence.comments + evidence.checks, + number: () => store.pullRequests[0]?.number ?? 0, + paths: () => [...asked], + }); + }); +} + +/** The commit one ref on the bare remote holds. */ +function resolveRef(origin: string, ref: string): string { + const shown = spawnSync("git", ["rev-parse", `refs/heads/${ref}`], { + cwd: origin, + encoding: "utf8", + }); + return shown.status === 0 ? shown.stdout.trim() : ""; +} + +function* seedBareOrigin(home: string): Operation { + const origin = join(home, "origin.git"); + const seed = join(home, "seed"); + yield* until(mkdir(seed, { recursive: true })); + yield* git(home, ["init", "--bare", "-b", "main", origin]); + yield* git(home, ["init", "-b", "main", seed]); + yield* writeTextFile(join(seed, "README.md"), "origin\n"); + yield* git(seed, ["add", "README.md"], home); + yield* git(seed, ["commit", "-m", "first"], home); + yield* git(seed, ["push", origin, "main:refs/heads/main"], home); + return origin; +} + +/** The commit a ref on the bare remote holds, or the empty string for absence. */ +function* remoteRef(origin: string, branch: string, home: string): Operation { + const shown = yield* gitSaid(origin, ["for-each-ref", "--format=%(refname)|%(objectname)"], home); + for (const line of shown.split("\n")) { + const [name, commit] = line.split("|"); + if (name === `refs/heads/${branch}` && commit !== undefined) { + return commit; + } + } + return ""; +} + +/** Wait until the remote's branch holds this exact commit. */ +function* remoteHolding( + origin: string, + branch: string, + commit: string, + home: string, +): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const held = yield* remoteRef(origin, branch, home); + if (held === commit) { + return; + } + if (Date.now() > deadline) { + throw new Error( + `${origin} never held ${commit} at refs/heads/${branch}; it holds ` + + JSON.stringify(held === "" ? "" : held), + ); + } + yield* sleep(250); + } +} + +/** + * A `git` on `PATH` that logs every invocation and forwards to the real one. + * + * Counting from outside the process is the only way to ask a *packaged* binary + * whether a reopened entry ran anything, because a binary has no seam a suite + * can substitute. + */ +function* gitShim( + home: string, + origin: string, +): Operation<{ readonly prefix: string; readonly log: string }> { + const real = yield* until( + new Promise((resolve, reject) => { + execFile("sh", ["-c", "command -v git"], (error, stdout) => + error === null ? resolve(stdout.trim()) : reject(error), + ); + }), + ); + if (real === "") { + throw new Error("this host has no git on PATH, so there is nothing to forward to"); + } + const directory = join(home, "git-shim"); + yield* until(mkdir(directory, { recursive: true })); + const log = join(home, "git-commands.log"); + const shim = join(directory, "git"); + // The one locator a document writes, swapped for the bare repository on disk + // in `git`'s own arguments. It has to happen here rather than through Git's + // `insteadOf`, because every remote-touching invocation this provider makes + // runs with the invoking user's configuration off — which is exactly the + // property that keeps a document from retargeting a publication. + // + // Only the arguments. What the entry authenticates and fingerprints is the + // locator it admitted, which it holds itself and never reads back from Git, + // so nothing has to be substituted on the way out. + yield* writeTextFile( + shim, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >> ${shellQuote(log)}`, + "count=$#", + "i=0", + 'while [ "$i" -lt "$count" ]; do', + " arg=$1", + " shift", + ' case "$arg" in', + ` ${shellQuote(LOCATOR)}) arg=${shellQuote(origin)} ;;`, + " esac", + ' set -- "$@" "$arg"', + " i=$((i + 1))", + "done", + `exec ${shellQuote(real)} "$@"`, + "", + ].join("\n"), + ); + yield* until(chmod(shim, 0o755)); + yield* writeTextFile(log, ""); + return { prefix: `PATH=${shellQuote(directory)}:$PATH`, log }; +} + +/** Every `git` the shim has seen since it was last forgotten. */ +function* logged(log: string): Operation { + const held = (yield* exists(log)) ? yield* readTextFile(log) : ""; + return held.split("\n").filter((line) => line !== ""); +} + +/** + * Give this journey's isolated home a Git identity, and one substitution. + * + * `insteadOf` is Git's own: the entry clones and pushes the locator a document + * wrote, and Git connects to the bare repository on disk instead. What the + * checkout records as its origin — and therefore what the entry authenticates + * and fingerprints — stays that locator, which is why the substitution belongs + * here rather than in the document. + */ +function* seedIdentity(home: string): Operation { + yield* writeTextFile( + join(home, ".gitconfig"), + `[user]\n\tname = ${JOURNEY_IDENTITY.name}\n\temail = ${JOURNEY_IDENTITY.email}\n`, + ); +} + +/** One `git` command, in this journey's own isolated home. */ +function git(cwd: string, args: readonly string[], home = cwd): Operation { + return until( + new Promise((resolve, reject) => { + execFile("git", [...args], { cwd, env: environment(home) }, (error) => + error === null ? resolve() : reject(error), + ); + }), + ); +} + +/** What one `git` command printed, for a fixture that reads a repository. */ +function gitSaid(cwd: string, args: readonly string[], home: string): Operation { + return until( + new Promise((resolve, reject) => { + execFile("git", [...args], { cwd, env: environment(home) }, (error, stdout) => + error === null ? resolve(stdout.trim()) : reject(error), + ); + }), + ); +} + +function environment(home: string): Record { + return { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_AUTHOR_NAME: "Journey", + GIT_AUTHOR_EMAIL: "journey@example.test", + GIT_COMMITTER_NAME: "Journey", + GIT_COMMITTER_EMAIL: "journey@example.test", + }; +} + +/** The one managed checkout this journey's entry created, once it is there. */ +function* managedCheckout(home: string, kind: "repositories" | "worktrees"): Operation { + const base = join(home, ".xmd", "repositories", kind); + const depth = kind === "repositories" ? 1 : 2; + const deadline = Date.now() + ACT_MS; + while (true) { + const found = yield* slotsBeneath(base, depth); + if (found.length === 1) { + return found[0]!; + } + if (Date.now() > deadline) { + throw new Error( + `expected exactly one managed ${kind} checkout beneath ${base}; found ${found.length}`, + ); + } + yield* sleep(250); + } +} + +function* slotsBeneath(base: string, depth: number): Operation { + if (!(yield* exists(base))) { + return []; + } + let level = [base]; + for (let step = 0; step < depth; step += 1) { + const next: string[] = []; + for (const directory of level) { + for (const name of yield* until(readdir(directory))) { + next.push(join(directory, name)); + } + } + level = next; + } + const found: string[] = []; + for (const slot of level) { + const checkout = join(slot, "checkout"); + if (yield* exists(checkout)) { + found.push(checkout); + } + } + return found; +} + +/** + * Continue the unfinished entry this reopened view is showing. + * + * A location opens at the entry's own checkpoint rather than running it, so + * what resumes it is activating it — and once it is running it reaches the + * question it was left at. + */ +function* resume(pty: Pty): Operation { + // A reopened location shows the entry at its own checkpoint. It goes live + // when the view is driven, so this drives it until the question it was left + // at is on the screen rather than waiting for it to resume on its own. + const deadline = Date.now() + REACH_MS; + while (!(yield* pty.shows()).some((line) => line.includes("waiting for an answer"))) { + if (Date.now() > deadline) { + throw new Error( + `the reopened entry never reached its question. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* pty.type("\t"); + yield* sleep(250); + } +} + +/** Wait until this becomes true at the Git host, which is where work shows. */ +function* reached(done: () => boolean, what: string, pty?: Pty): Operation { + const deadline = Date.now() + REACH_MS; + while (!done()) { + if (Date.now() > deadline) { + const screen = + pty === undefined + ? "" + : ` screen=${JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd()))}`; + throw new Error(`the entry never reached ${what}.${screen}`); + } + yield* sleep(250); + } +} + +/** Tab until focus reaches this label. */ +function* reach(pty: Pty, label: string): Operation { + const deadline = Date.now() + ACT_MS; + while (!(yield* pty.shows()).some((line) => line.includes(`> ${label}`))) { + if (Date.now() > deadline) { + throw new Error( + `focus never reached ${label} under ${cliRuntime()}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* pty.type("\t"); + yield* sleep(250); + } +} + +/** Activate the control holding this text with a pointer. */ +function* click(pty: Pty, text: string): Operation { + const screen = yield* pty.shows(); + const row = screen.findIndex((line) => line.includes(text)); + if (row < 0) { + throw new Error( + `nothing on this screen holds ${JSON.stringify(text)} to click. screen=` + + JSON.stringify(screen.map((one) => one.trimEnd())), + ); + } + const column = (screen[row] ?? "").indexOf(text) + 1; + yield* pty.type(`\u001b[<0;${column};${row + 1}M`); + yield* pty.type(`\u001b[<0;${column};${row + 1}m`); + yield* sleep(300); +} + +/** Wait until this file holds this exact text. */ +function* holding(path: string, content: string): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + if ((yield* exists(path)) && (yield* readTextFile(path)) === content) { + return; + } + if (Date.now() > deadline) { + const found = (yield* exists(path)) ? yield* readTextFile(path) : ""; + throw new Error( + `${path} never held ${JSON.stringify(content)}; it holds ${JSON.stringify(found)}`, + ); + } + yield* sleep(200); + } +} + +/** Wait until this command finished, and answer with its exit status. */ +/** The exit status, once the command has ended. */ +function* ended(pty: Pty): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const status = yield* pty.status(); + if (status !== undefined) { + return status; + } + if (Date.now() > deadline) { + throw new Error(`the command never ended\n${(yield* pty.shows()).join("\n")}`); + } + yield* sleep(100); + } +} + +/** The location this session printed, which the next one reopens. */ +function printedLocation(screen: string): string { + const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); + if (found === null || found[1] === undefined) { + throw new Error( + `this command printed no reopenable location. screen=${JSON.stringify(screen)}`, + ); + } + return found[1]; +} diff --git a/packages/git/src/deno/retained-repository/pull-request.ts b/packages/git/src/deno/retained-repository/pull-request.ts index ed1bcd36a..3f872aa3b 100644 --- a/packages/git/src/deno/retained-repository/pull-request.ts +++ b/packages/git/src/deno/retained-repository/pull-request.ts @@ -49,8 +49,8 @@ */ import { createApi } from "@effectionx/context-api"; -import { Err, Ok, until, useScope } from "effection"; -import type { Operation, Result, Scope } from "effection"; +import { Err, Ok, until } from "effection"; +import type { Operation, Result } from "effection"; import { realpath } from "node:fs/promises"; import { createDurableOperation } from "@executablemd/durable-streams"; import type { @@ -734,14 +734,14 @@ function* upstreamProvider( ); } - const attempt: Scope = yield* useScope(); // Where the API lives is configuration, read here rather than at // installation, so an entry that writes no `` reads none of it. const source = yield* invocation.upstream(); // One session for this whole reconciliation, shared by its observations and // its mutation, so a pull request is not created under one identity and - // observed under another. It is a resource of the attempt's own scope. - const access = yield* attempt.run(() => source.open()); + // observed under another. Acquired here, which is inside the attempt, so it + // is released and joined with the attempt rather than outliving it. + const access = yield* source.open(); return pullRequestProvider(access, resolved.origin, resolved.inputs); } diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index e86e79b66..56c3a09e4 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -67,6 +67,7 @@ import { } from "../src/retained-repository/pull-request-records.ts"; import { REPL_REPOSITORY_LOCAL } from "../src/retained-repository/records.ts"; import { gitOutput } from "./support/git-remotes.ts"; +import { useGitHubServer } from "./support/github.ts"; import { creations, fakeGitHubAccess, @@ -169,6 +170,8 @@ interface RunOptions { readonly allowed?: readonly string[]; /** A gated transport, when a row cuts around the Git host. */ readonly access?: GitHubAccess; + /** A configured endpoint instead of an injected transport. */ + readonly endpoint?: string; readonly between?: () => Operation; } @@ -786,3 +789,18 @@ describe("one entry's pull request, composed", () => { expect(mutations(failingForge)).toEqual([]); }); }); + +describe("one entry's pull request, over a configured transport", () => { + it("V9a: reaches a configured endpoint rather than an injected transport", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const endpoint = yield* useGitHubServer(forge); + const stream = new InMemoryStream(); + const ran = yield* run(fixture, opening(), stream, forge, { endpoint }); + expect(ran.refusal).toBeUndefined(); + // The adapter read where the API lives from configuration, built the + // platform's own transport for it, and opened one pull request through it. + expect(creations(forge)).toBe(1); + expect(requests(ran.events).length).toBe(1); + }); +}); diff --git a/scripts/tests/repl-pull-request-compiled.test.ts b/scripts/tests/repl-pull-request-compiled.test.ts new file mode 100644 index 000000000..12c8f637f --- /dev/null +++ b/scripts/tests/repl-pull-request-compiled.test.ts @@ -0,0 +1,50 @@ +/** + * The same pull-request journey, against the compiled binary (#181 V1). + * + * A compiled `xmd` has no checkout to read from and no module graph to lose a + * module out of — it has whatever `deno compile` embedded. The entry capability + * is assembled by `compiled.ts`, which is a different entrypoint from the one + * the source row runs, so a binary built without it would start a REPL, refuse + * the pull request, and the defect would appear at a person's first attempt to + * open one rather than here. + * + * It runs the *same* journey as the source row, from one shared module, so the + * two cannot drift into asserting different things. + * + * It runs against `dist/xmd`, so `deno task build` has to have happened. A + * missing binary is reported as the setup it is rather than as a failure of the + * behavior. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { shellQuote } from "@executablemd/test-support/launch"; +import { exists } from "@effectionx/fs"; +import type { Operation } from "effection"; +import { fileURLToPath as fromFileUrl } from "node:url"; +import { join } from "node:path"; +import { replPullRequestJourney } from "../../packages/cli/tests/support/repl-pull-request-journey.ts"; + +const ROOT = fromFileUrl(new URL("../..", import.meta.url)); +const BINARY = join(ROOT, "dist", "xmd"); + +/** How this suite invokes the compiled `xmd`. */ +function compiled(args: readonly string[]): string { + return [shellQuote(BINARY), ...args].join(" "); +} + +/** Refuse the row rather than skip it: a missing binary is missing evidence. */ +function* requireBinary(): Operation { + if (!(yield* exists(BINARY))) { + throw new Error( + `this row drives the compiled binary and ${BINARY} is not there. Run \`deno task build\` ` + + "first; a skipped row is missing evidence rather than a passing behavior.", + ); + } +} + +describe("the compiled binary's REPL pull requests", () => { + it("V1: carries the pull-request capability, so one is opened and restored", function* (): Operation { + yield* requireBinary(); + yield* replPullRequestJourney(compiled); + }); +}); From 3057429f65bbce74f721d3185f5178c166f8fcb7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 14:46:26 -0400 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=93=9D=20Describe=20retained=20pull?= =?UTF-8?q?=20requests=20and=20the=20ceiling=20their=20reads=20stay=20unde?= =?UTF-8?q?r=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 6's documentation, the compiled smoke step and the exclusion registrations. Not the feedback commit. `architecture.md` gains the two subsections this delivery adds and loses the sentence saying a pull request is not here. The coordinated admission is described as one reader rather than two, because both protocols settle through one `git_host_effect` type and a failed envelope names no kind — two readers that each passed over the other's failures would each see an orphan where the other saw a pair. The publication a pull request rests on is described in the order the journal acknowledged it rather than the order replay revisits it, and as usable only once execution reaches it. The evidence reads get their own subsection, because where retention begins for them is the whole of what is interesting: after the adapter's matching, ceiling and target decisions, which run again on every execution including one that reopens a saved read. `specs/repl-spec.md` replaces its pull-request exclusion with the accepted two-question example and states the distinction the two halves have on replay: a completed upsert reaches nothing, while a completed read still passes today's ceiling. `specs/workflow-workspace-spec.md` §2.3 and its profile inventory gain the same contract on the terms publication already had there. `specs/executable-mdx-spec.md` §8.1 is corrected and gains Tier EPR, nine rows bounded to what this delivery decides rather than what the shared reconciliation and read adapter already prove in their own tiers. The compiled journey is registered beside the other compiled REPL rows in the existing smoke job, and excluded from every ordinary runtime corpus — only `deno compile` produces its subject and no test shard builds one. Evidence: `deno task lint` exits 0 and `deno task check` reports no errors; runtime-exclusions, test-file-discovery and ci-workflow 14 suites over 64 steps. --- .github/workflows/ci.yml | 3 + architecture.md | 118 ++++++++++++++++-- .../retained-repository/pull-request-reads.ts | 2 +- .../pull-request-read-admission.ts | 6 +- .../src/retained-repository/push-admission.ts | 2 +- scripts/runtime-test-exclusions.ts | 6 + scripts/tests/runtime-exclusions.test.ts | 1 + specs/executable-mdx-spec.md | 45 ++++++- specs/repl-spec.md | 87 ++++++++++++- specs/workflow-workspace-spec.md | 17 ++- 10 files changed, 253 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a52d96a0..6eca9a4ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,6 +243,9 @@ jobs: - name: A REPL entry's publications across the compiled binary run: deno test --allow-all --frozen scripts/tests/repl-push-compiled.test.ts + - name: A REPL entry's pull requests across the compiled binary + run: deno test --allow-all --frozen scripts/tests/repl-pull-request-compiled.test.ts + # Each package's `components.md` is a packaged asset on the same footing, # and the long-form documentation is the only thing that reports whether # one shipped — a binary missing them still lists every component and diff --git a/architecture.md b/architecture.md index 4e7d9f370..4a949e29b 100644 --- a/architecture.md +++ b/architecture.md @@ -7126,13 +7126,13 @@ persistence failure with its first cause: no success binding is offered, no later sibling runs as though the commit succeeded, and no compensating Close is appended. -A pull request is not here: `` stays declared where it already is -and refuses through its own unchanged missing-provider default, and a local -result confers no publication evidence. Publication itself is the next -subsection's subject, and `` the section's after it — the first because -it belongs to a checkout and to a remote at once, the second because it belongs -to no checkout at all. Nothing inventories the managed root, snapshots a -checkout's contents or restores one. +A pull request is not here, and a local result confers no publication evidence. +Publication itself is the next subsection's subject, the pull request that rests +on it the one after that, and `` the last — the first because it belongs +to a checkout and to a remote at once, the second because it is a statement +about a branch a remote already holds, and the third because it belongs to no +checkout at all. Nothing inventories the managed root, snapshots a checkout's +contents or restores one. #### What one entry asked to publish, and what the Git host did about it @@ -7220,13 +7220,103 @@ before publication may acknowledge anything, so a cleanup that failed publishes neither a success nor a refusal the worker had already decided. An entry's retained publication history is admitted before any of it is -replayed: every saved request parses, states this version, is named by its own +replayed, together with the pull requests that rest on it. That admission is one +reader rather than two, because both protocols settle through one +`git_host_effect` type and a failed envelope names no kind: what says which +protocol a refusal belongs to is the request standing before it in the same +coroutine, and that request's own expected worker name. Two readers that each +passed over the other's failures would each see an orphan where the other saw a +pair. Every saved request parses, states this version, is named by its own question and names a checkout a placement before it described; every outcome -stands after the request it answers, carries that request's own fingerprint and -reads back as a Push of exactly what was asked. A trailing request with no -outcome is valid partial history — it is the state this protocol exists for — and -an orphan outcome, a request answered twice and a receipt this build cannot read -are fatal unreadable history. +stands after the request it answers, describes that request's kind, carries its +fingerprint and reads back as the result that request's protocol produces. A +trailing request with no outcome is valid partial history — it is the state this +protocol exists for — and an orphan outcome, a request answered twice and a +receipt this build cannot read are fatal unreadable history. + +#### The pull request that rests on what this entry published + +`` is a statement about a branch a Git host already holds, and this +entry never makes one exist. So before anything is written down it has to show +that *this entry* published the head the pull request names. An ordinary run +proves that by scanning its own journal; an entry cannot, because its segment is +reopened hours later beside other entries and what has to be proven is narrower: +this entry asked for that publication, its own request was answered by its own +acknowledged receipt, and this execution has actually reached it. A bare +receipt, a local `` result, a handler's synthetic `Git.push` answer +and another entry's history are each not that, and none of them appears in the +memory the admission builds. + +The last relevant publication decides, because a branch holds whatever was +published to it last — so an earlier publication of that branch at another +commit does not keep authorizing work the entry has since published past. The +admitted snapshot establishes the order those publications were acknowledged in, +and each becomes usable only when execution reaches it: a publication standing +after the position execution is at is not evidence, because a previous session +getting further than this one does not authorize this one. Replay is free to +revisit two concurrent children in either order, so arrival order is not that +order and ordering by it would let a replayed history authorize a superseded +head. + +Everything after that follows publication's shape. The invocation is +authenticated, then the body is rendered completely — before the Repository is +observed and before this upsert has a position of its own, so a child durable +operation in that content finishes at its own earlier position and a body that +failed to render stops everything this component owns. Then the checkout is +routed, the origin is held to the Repository's fingerprint, the managed slot is +taken, the branch and head are read inside the entry's own short native turn, +and the complete request is written down before the service is contacted. A +pending request is reconciled from its saved head, base, number, title, body and +draft: it rereads no HEAD and needs no checkout export merely to ask the +service, because current checkout state does not re-authorize external work this +entry already admitted. + +A fresh call composes both ordinary middleware chains through invocation-local +defaults, so what a document or a plugin installed still answers, transforms, +refuses and delegates. What a handler may decide is what to ask for; what it may +not decide is what was answered. An answer that never delegated has answered the +document and nothing more — no request, no receipt, and none of the stability a +retained result has — and it may bind only the closed result contract. Once the +owned terminal has run, its outcome is held privately and that outcome binds or +fails: a rewritten return cannot bind a different pull request, and a delegated +failure a handler caught and answered past stays a failure. An outer throw after +a success is ordinary surrounding failure; a persistence failure stays fatal. + +#### The evidence reads an entry retains, and the ceiling they stay under + +The three evidence reads are the cheapest retained operations here and the least +like the others: a read names a URL, so it needs no Repository, no working +directory, no managed root, no Git session and no native hold. Their identities +are therefore declared and installed for an entry that holds no checkout at all. + +Retention begins at the admitted-read seam and nowhere earlier. The adapter +decides on *every* execution whether it recognizes the request, whether this +deployment's allowed list authorizes the target and whether the URL names a pull +request it can read — including the execution that reopens an entry whose read is +already saved. Removing the repository from that list therefore refuses to +continue, which is the accepted contract: the saved record stays in history and +is simply not reachable while the ceiling says so. Wrapping the public dispatch +in a durable envelope instead would serialize those refusals as read outcomes, +recording questions the run was never permitted to ask. + +What is retained is the normalized answer produced inside that seam. A URL or +provider transformation *before* admission changed which read was admitted, so +the record says so. One *after* delegation is ordinary composition: it runs again +on continuation and may bind a different array from the same saved snapshot, and +nothing presents those transformations as saved. A handler that never reaches +the seam retains nothing and runs again. + +Who is asking travels nowhere public. Each authored read installs a bridge in a +scope of its own, holding the owner and the invocation it authenticated and +permitting exactly one admitted handoff. Installing it there is what makes +nesting and concurrency work without coordination: a nested read's bridge is +deeper and wins for the invocation inside it, two concurrent reads finishing in +either order each keep their own question, and a finished read has no bridge to +borrow. An entry-wide pointer, a value on `next` or a Context identity would +each have to be correct about an ordering nothing guarantees. The read's own +durable name deliberately omits the owner, so a fork can inherit it; ownership +is therefore checked explicitly from the record, including on a failed read's +description, which is the only thing a refusal retains. ### What one entry retains about the issue it filed @@ -8600,6 +8690,8 @@ Status is measured against main. | `` / `` composition under a workflow run | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes [Interface and evidence](specs/workflow-workspace-spec.md#6-repository-and-worktree). | built on the #293 stack, Deno provider only | | `` | Creates or adopts its target before content, scopes cwd without changing Repository selection, and restores cwd without deleting created directories. [Invariant](#the-document-filesystem-boundary); [Interface and evidence](specs/executable-mdx-spec.md#6132-entering-a-directory-dir). | Directory-creation contract added by #643; host and workflow Files providers. | | transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) under a workflow run | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation admits anything — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of admission, of retained state or of an unrecognized native condition fails the run instead of publishing a result [Invariant](#workspace-local-transactions); [Interface and evidence](specs/workflow-workspace-spec.md#7-git-operations). | built on the #294 stack, Deno provider only | +| REPL entry retained pull request | one executing REPL entry's own ``, retained as a complete `repl_repository_pull_request_request` record written before the service is contacted and the shared `git_host_effect` outcome that answers it. The only publication grant is a completely validated saved Push request paired with its own acknowledged receipt and reached by this execution; the last relevant publication to that destination decides, in the order the journal acknowledged them rather than the order replay revisits them, and a publication standing after the current position grants nothing. A bare receipt, a local Commit or File result, a synthetic `Git.push` answer, a failed Push and another entry's history each grant nothing. The request keeps the question asked, the reading the live dispatch received and what routing resolved — pinning head branch, head commit and base branch — with no selection identifier, credential, endpoint, provider handle or checkout snapshot. A pending request is reconciled from its saved inputs without rereading HEAD or needing a checkout; a completed one restores its acknowledged worker result before any native, provider, configuration or credential path. Both ordinary middleware chains compose on a fresh call through invocation-local defaults: a wholly synthetic answer binds only a valid closed result and retains nothing, one occurrence permits one owned delegation, and after delegation the actual outcome controls binding or failure [Reference](#the-pull-request-that-rests-on-what-this-entry-published). | built on the #181 stack | +| REPL entry retained evidence reads | one executing REPL entry's own ``, `.Comments` and `.Checks`, retained under the existing `pull_request_read` description and name. They need no Repository, working directory, managed root, Git session or native hold, so they are declared and installed for an entry holding no checkout. Provider matching, the configured allowed list and target validation run on every execution, including one that reopens a saved read — so removing the repository from that list refuses continuation while leaving the record in history — and none of those refusals is retained. What is retained is the normalized answer produced inside the admitted-read seam: a transformation before admission changes the recorded question, one after delegation reruns live over the saved snapshot, and an answer that never reaches the seam is unretained. Each read installs its own scoped bridge permitting one admitted handoff, so nested and concurrent reads cannot exchange identities; the name omits the owner for fork compatibility, and ownership is checked explicitly from the record including a failed read's description [Reference](#the-evidence-reads-an-entry-retains-and-the-ceiling-they-stay-under). | built on the #181 stack | | `Git.Push` under a workflow run | Publishes or adopts the selected named branch through authenticated non-force reconciliation. [Authored contract](specs/workflow-workspace-spec.md#74-push) and [effect mechanics](#replay-and-effect-transactions). | built on the #370 stack, Deno provider only | | `` under a workflow run | Upserts the exact published branch and returns stable evidence only after this run proves matching Push history. [Props, results and refusals](specs/workflow-workspace-spec.md#75-pull-request); [positive-integer number and reconciliation](#issue-effects). | built on the #295 stack, Deno provider only | | repository composition vocabulary | Declares sixteen ordinary shadowable defaults for every command that carries it, without installing an operational provider; an execution that retains one of the six external operations declares that name again with its own policy over the default. [Names and profile ownership](#ordinary-repository-vocabulary). | built on the #643 stack | diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index feee4adde..d7fe287a6 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -34,7 +34,7 @@ * read was admitted. A transformation *after* delegation is outside the * snapshot: it is ordinary composition, it runs again on continuation, and it * may produce a different array from the same saved provider answer. Nothing - * here wraps that composition in a second envelope and claims it was saved. A + * here wraps that composition in a second envelope and presents it as saved. A * handler that answers without ever reaching the seam has retained nothing at * all. * diff --git a/packages/git/src/retained-repository/pull-request-read-admission.ts b/packages/git/src/retained-repository/pull-request-read-admission.ts index 6c6125fc5..b2a68a8cb 100644 --- a/packages/git/src/retained-repository/pull-request-read-admission.ts +++ b/packages/git/src/retained-repository/pull-request-read-admission.ts @@ -27,7 +27,7 @@ * split the saved publication request keeps when it leaves its origin's * fingerprint to the provider that reads it back. So the caller computes the * name and this module answers everything a name cannot: what the record is, - * whose it is, and whether its value is the evidence it claims. + * whose it is, and whether its value is the evidence that read produces. */ import { StaleInputError } from "@executablemd/durable-streams"; @@ -103,7 +103,7 @@ export function replPullRequestReadNameRefusal(): ReplPullRequestReadHistoryErro return readRefusal( "request-fingerprint", "a retained evidence read is journaled under a name that is not the name its own question " + - "produces, so the record and the read it claims to be do not match.", + "produces, so the record and the read it names do not match.", ); } @@ -114,7 +114,7 @@ export function replPullRequestReadNameRefusal(): ReplPullRequestReadHistoryErro * The envelope has to be the one this question asked for, and a checks envelope * has to be internally consistent: every item about the head it recorded. That * is asked even of an empty collection, because an empty checks result still - * says which head was read and that head is the whole of what it claims. + * says which head was read and that head is the whole of what it reports. */ export function admitReplPullRequestReadResult( request: PullRequestReadRequest, diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts index 6720b469e..ef7f16268 100644 --- a/packages/git/src/retained-repository/push-admission.ts +++ b/packages/git/src/retained-repository/push-admission.ts @@ -52,7 +52,7 @@ * * A pull-request request is held to one thing publication is not: the entry * must already have published the head it names. That is what makes the pull - * request this entry's own work rather than a claim about somebody else's + * request this entry's own work rather than a statement about somebody else's * branch, and the publication it rests on has to be one this history actually * acknowledged before it. */ diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index d1629aea8..d716f8414 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -614,6 +614,12 @@ const COMPILED_BINARY: RuntimeExclusion[] = [ "drives the compiled `dist/xmd` through a real terminal: it files an issue at a loopback tracker, leaves at a question and reopens the printed location to prove the binary carries the same entry issue host the source does. Only `deno compile` produces it and no test shard builds it, so it belongs beside the other compiled-binary evidence the `smoke` job builds for through README.md#Build", issue: "https://github.com/taras/executable.md/issues/567", }, + { + path: "scripts/tests/repl-pull-request-compiled.test.ts", + reason: + "drives the compiled `dist/xmd` through a real terminal: it publishes a branch, opens its pull request, reads reviews, comments and checks, leaves at a question, lets the Git host's evidence change and reopens the printed location to prove the binary restores the pull request it opened and the snapshots it saved. Only `deno compile` produces it and no test shard builds it, so it belongs beside the other compiled-binary evidence the `smoke` job builds for through README.md#Build", + issue: "https://github.com/taras/executable.md/issues/567", + }, { path: "scripts/tests/repl-push-compiled.test.ts", reason: diff --git a/scripts/tests/runtime-exclusions.test.ts b/scripts/tests/runtime-exclusions.test.ts index 29a9bd47b..c012fc2ef 100644 --- a/scripts/tests/runtime-exclusions.test.ts +++ b/scripts/tests/runtime-exclusions.test.ts @@ -84,6 +84,7 @@ describe("runtime exclusions", () => { "scripts/tests/plan-component-compiled.test.ts", "scripts/tests/plugin-compiled.test.ts", "scripts/tests/repl-issues-compiled.test.ts", + "scripts/tests/repl-pull-request-compiled.test.ts", "scripts/tests/repl-push-compiled.test.ts", "scripts/tests/repl-repositories-compiled.test.ts", ]); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 438ef7c1e..82ae21435 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10442,12 +10442,22 @@ configuration or a credential. An interrupted upsert presents the key it used the first time, which is what lets the provider answer with the issue it already accepted rather than filing a second one; a read has no key. -What the entry provider installs nothing of is pull requests, so -`` refuses there exactly as it does under a host that installs no -provider for it, and neither a retained local result nor a retained publication -authorizes one. Constructing it acquires nothing, including no managed root, no -commit identity, no credential and no configuration read, and a passive reading -of a settled history initializes nothing at all. +`` is retained on those terms too, and rests on the publication +beside it: what authorizes one is a completely validated saved Push request +paired with its own acknowledged receipt and reached by this execution, with the +last publication to that branch deciding. A completed upsert answers with the +result it acknowledged, reaching no native, provider, configuration or +credential path; an interrupted one reconciles the complete request it wrote +before any service contact rather than rereading a checkout that has moved. Its +three evidence reads are retained under the existing read records and need no +Repository, working directory or checkout at all, so they are installed for an +entry holding none — but they stay under today's configured allowed list on +every execution, including the one that reopens a read already saved, and what +a reopened read answers with is the snapshot the provider gave inside that +boundary rather than whatever composition above it now returns. Constructing the +provider acquires nothing, including no managed root, no commit identity, no +credential and no configuration read, and a passive reading of a settled history +initializes nothing at all. The identity an entry's publication and issue work are named by is the entry's own: the execution and the entry's admission-order position, derived by the host @@ -14497,6 +14507,29 @@ acknowledgement. | EPB8 | Resources and ownership | A fresh and a pending publication each take the selected placement's managed slot before any native read, and contention appends neither a request nor an outcome while preserving a previously saved pending one; a publication proceeds while another entry holds only the shared Git directory's mutation lease; the checkout's own `pushurl`, hooks and credential helpers reach nothing because the transport runs in a control repository with a read-only alternate; one attempt's scratch transport, object source and credential session survive both phases and are closed and joined before publication, and a cleanup that failed acknowledges neither a success nor a worker-authored refusal; a publication cancelled while its scratch space or its credential session is still pending delivers nothing it then leaves behind, releases what it did receive before the halt settles, publishes no outcome and preserves any acknowledged request, and a cancelled blocked native child closes before the entry settles | | EPB9 | Assembly and unsupported runtimes | The per-entry factory is inert — no directory, no `git`, no credential and no managed root — and declares exactly this entry's publication and issue components; exactly one core initialization happens per executing entry; a passive reading, a frozen position and a refused location initialize nothing; the file work, selections, local Git and issues of the same entry survive the combined assembly; an entry with no managed root and a runtime that installs no entry capability each report an absent provider for `` before any remote contact or managed root | +### Tier EPR — Pull requests and evidence inside a REPL entry (§5.3, §8.1) + +Bounded to what one executing entry asks a Git host for about a pull request and +what it reads back about one. Everything the shared reconciliation and the +existing read adapter already prove — the create, adopt, numbered update and +no-op decisions, the conflict, ambiguity and unavailability refusals, the +pagination walks and the item and subject contracts — stays in their own tiers. +The rows below are about what authorizes a pull request, the saved request, the +value the dispatch binds, where retention begins for a read, and who each read +belongs to. + +| # | Test | Verify | +|---|------|--------| +| EPR1 | The public journey | A real-terminal source and compiled `xmd repl` each publish a branch, leave at a question, reopen the printed location and answer it to open one pull request and read reviews, comments and checks once each, then leave at a second question; after the host's three collections change, reopening restores the same pull-request identity and the three saved arrays while asking the host nothing at all — no mutation and no read — and answering performs the one later explicit read, which is that session's only request and sees the change; the repository's primary branch is untouched throughout and the published branch still holds the commit that was published | +| EPR2 | What authorizes a pull request | A fresh pull request at a moved head refuses with only the earlier publication of that branch, and the same entry publishing the new head permits it; a prior entry's history, a bare or forged receipt, a local `` or `` result, a synthetic `Git.push` answer and an unrelated authenticated checkout each authorize none; missing, conflicting and unreadable evidence are distinct refusals raised before any request, credential or service contact; the last relevant acknowledged publication wins over an earlier exact one, in the journal's acknowledgement order rather than the order replay revisits two concurrent children, and a publication standing after the current position authorizes nothing until execution reaches it | +| EPR3 | A pending request keeps what it asked for | A request acknowledged before any service contact is reconciled after the checkout it published from is gone, using its saved head, base, number, title, body and draft with zero native checkout reads; a remote creation and a numbered update each accepted before the outcome was recorded are adopted with zero repeated mutation; a cut taken before the request was acknowledged resolves live state instead and refuses conflicting publication evidence rather than recording the superseded one | +| EPR4 | What a reopened entry reaches, and what it does not | A real unfinished prefix left at a second question restores the completed upsert and the completed reads; the upsert reaches no native, provider, configuration or credential path; the reads still pass today's provider matching, configured ceiling and target validation yet open no session and send nothing; removing the repository from the allowed list refuses a saved read without writing a new read record and without erasing the existing one; a URL or provider transformation before admission is what the record says was asked; a changed transformation after delegation reruns over the saved snapshot and may bind a different array; an answer a handler produced without reaching the admitted-read boundary is unretained and runs again | +| EPR5 | The value a dispatch binds | The existing create, adopt, numbered update, no-op, conflict, ambiguity, unavailability and multi-call update behaviour is unchanged; at each of the two middleware chains one valid answer that never delegated binds the closed result contract and writes no request and no receipt; after delegation a rewritten return cannot bind another pull request and a swallowed delegated failure cannot become a success; an ordinary outer throw after a success keeps ordinary surrounding failure handling while a persistence failure stays fatal; one transforming delegation records both the original and the received question and a saved continuation bypasses it; a duplicate or stale delegation performs no second owned call; a child effect in the body settles before the parent's request exists and a body that refuses contacts nothing | +| EPR6 | Coordinated admission | One reader admits mixed publication and pull-request successes and the three existing definitive failure names across two interleaved durable children plus a trailing pending request, because a failed envelope names no kind and the preceding unconsumed request in the same coroutine with that request's own expected worker name is what classifies it; it refuses an unknown version, a missing or extra member, a misnamed request, a foreign owner or site, an unplaced or unauthorized saved request, an orphan or duplicate outcome and a wrong kind, fingerprint, natural key or receipt; read admission refuses a foreign `input.runId` independently of the owner-omitting name, including on a failed read's description; both object-id formats round-trip and one vector per algorithm — the canonical-JSON request digest and the length-prefixed UTF-16 read fingerprint — carries a null and text outside ASCII | +| EPR7 | One read, one identity | A denied target writes no read record and opens no session; a multipage success is complete; a walk that cannot finish retains a failed read and binds no array; a wrong subject or check head is refused before any success is appended; two concurrent authored reads completing in reverse order each keep their own URL, collection, owner and invocation; a nested authored read cannot consume its parent's handoff and a duplicate or stale handoff runs no second read; the final result after any outer composition is validated before its items bind | +| EPR8 | Acknowledgement, not completion | A refused request append contacts nothing and fences the work after it; a refused outcome append binds no success, appends no compensating Close and leaves the remote completion for the next attempt to adopt; a refused read append binds no array and leaves the read safe to repeat; each first persistence failure is preserved; a cancelled blocked admitted read, a session still being acquired and a cleanup that throws after either a success or an expected failure each publish no invented outcome and join their cleanup | +| EPR9 | Assembly and unsupported runtimes | The per-entry factory is inert and declares exactly this entry's publication, pull-request, evidence-read and issue components with one admission for each history it reads; exactly one core initialization happens per executing entry and a passive reading initializes nothing; the file work, selections, local Git, publication and issues of the same entry survive the combined assembly; the three reads are declared and installed for an entry holding no managed root and acquire no checkout, discovery, hold, Git session or authentication; a direct upsert acquires no retained identity and a direct read keeps ordinary provider behaviour; shadowing stays ordinary and a kept first-party registration gains no successor identity; a runtime that installs no entry capability reports an absent provider for the upsert and all three reads before any native or service work; the workflow profile's own pull request, reads and forks are unchanged | + --- ## 14. Walked example: diagnostic journal diff --git a/specs/repl-spec.md b/specs/repl-spec.md index f3208a6a2..540ca4d4a 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1537,6 +1537,62 @@ when you continue it — the commit it named, not today's HEAD — and an entry push the remote accepted and whose result was never written down adopts that completion rather than repeating it. One publication happens, not two. +### The pull request an entry opened, and the evidence it read + +A pull request is a statement about a branch the Git host already holds, so it +is written after the publication it rests on. Type this, with the host +credentials and allowed-read list an ordinary run uses: + +```xmd + + + + Ready for review. + + + + + The branch containing {commit} is published. Continue to its pull request? + + Ready for review. + + + + + + Continue to a new review read? + + + + + +``` + +Leave at the first question and come back: answering opens the pull request and +reads each collection once. Leave at the second, change the reviews, comments +and checks at the host, and come back again. The pull request has the same +identity it had, the three arrays are the ones that were saved, and nothing was +mutated or read to produce them. Answering the second question performs the one +read written after it, and *that* read sees the change. + +The two halves replay differently, and the difference is deliberate. A completed +pull request answers from the receipt it acknowledged, reaching no provider, no +configuration, no credential and no service. A completed read still passes +today's provider matching, allowed list and target validation before it answers +from its saved snapshot — so removing the repository from that list refuses to +continue, even though the reviews were saved an hour ago, and the saved record +stays in history rather than being erased by that refusal. An allowed completed +read opens no authentication session and sends nothing. + +What `` binds is evidence of what the upsert settled on, not +freshness: the repository, the provider's own identity for the pull request, its +number, its URL, its open state and the head and base commits. Reviews, +comments, checks and later edits are the separate reads above, which is why a +replayed entry hands back what it recorded rather than what the pull request +looks like now. + +### What publication decides + The decisions are the ones publication has everywhere else. A destination that does not exist yet, and one holding a commit this branch already contains, are published once with an exact non-force refspec. A destination already holding @@ -2321,16 +2377,15 @@ resolved against the frame that published its coordinates. do not open the same location twice for writing. The hold on a checkout is a different thing: it says one entry at a time may mutate one repository, and it says nothing about who may write this execution's history. -- Pull requests are not part of this. `` refuses in a REPL entry - exactly as it does under any host that installs no provider for it, and - neither a retained local result nor a retained publication authorizes one. - What an entry retains is `` and `` selection, the file +- What an entry retains is `` and `` selection, the file and directory work inside those checkouts and the one it was started in, ``, `` and `` in them, the page each of ``, ``, `` and `` answered with, - the branch `` published, and the issues it filed and read. A + the branch `` published, the pull request it opened or updated, + its reviews, comments and checks, and the issues it filed and read. A runtime that installs no repository provider — Node and Bun — refuses the Git - components, the four reads and publication included, before any native + components, the four checkout reads, publication and pull-request operations, + before any native operation or remote contact, while ordinary `` and `` work keeps working there. - A read confers nothing. Nothing a page holds authorizes a mutation, a @@ -2343,6 +2398,26 @@ resolved against the frame that published its coordinates. as it promises: nothing locks the repository, watches it or compares every write that happened while it ran. What continuation checks is whether the *answer* changed, not whether the filesystem did. +- A pull request rests on this entry's own publication and on nothing else. A + retained local result, a receipt-shaped value, a handler's synthetic + `Git.push` answer and another entry's history each authorize none, and the + last publication to that branch is the one that decides — so a head this entry + has since published past no longer authorizes one. Reopening an entry whose + pull request is complete restores the identity it acknowledged without + contacting the service; a pull request whose request was written but never + answered is reconciled from that saved request, not from wherever the checkout + has moved to. +- A retained evidence read stays under the live ceiling. The three reads need no + Repository, working directory or checkout — a read names a URL — but provider + matching, the configured allowed list and target validation run on every + execution, including the one that reopens a read already saved. Removing the + repository from that list refuses to continue; the saved record stays in + history and is simply not reachable while it says so, and no new record is + written for a question the host did not permit. What a reopened read restores + is the answer the provider gave inside that boundary: middleware composed + above it runs again and may bind a different array from the same saved + snapshot, and an answer a handler produced without ever reaching the provider + is retained not at all and runs again. - `` is the same component here as under every other host: one implementation, the same props, forms and return contract, asking a semantic issue operation for its work. The `repl` command registers it as an ordinary diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index e0c928fcc..e23c1047c 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -214,10 +214,17 @@ ordinary run and a REPL entry is the policy beneath that operation — which journal it writes to, what it reconciles and what it replays — and nothing a document can see. -`` is not available to a REPL entry and refuses there as it does -under any host that installs no provider for it; neither a retained local result -nor a retained publication authorizes one. `` is available on the same -terms as publication, and lives at a service rather than in a Workspace. +`` is available on the same terms, and rests on the publication +beside it: the only thing that authorizes one is a completely validated saved +Push request paired with its own acknowledged receipt, reached by this +execution, and the last publication to that branch is what decides. The request +it writes before contacting the service keeps the head, base, number, title, +body and draft it asked for, so an interrupted upsert reconciles that exact +request rather than rereading a checkout that has moved. Its three evidence +reads need no Workspace, no Repository and no checkout at all — a read names a +URL — and they stay under today's configured allowed list on every execution, +including one that reopens a read already saved. `` is available on the +same terms as publication, and lives at a service rather than in a Workspace. ## 3. Workflow lifecycle @@ -3742,6 +3749,8 @@ fetch operation requires its own language and durability contract. | `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary managed selector above, with what it answered retained as a small routing record so a completed selection restores without a selector, a clone, a `worktree add`, a sidecar read, a credential or a lock — and only new work meets the current filesystem. One entry at a time owns a managed slot, held until the entry is torn down and reacquired lazily by the first new operation inside a restored placement; that hold is distinct from the shared Git directory's mutation lease. Nothing is inventoried, snapshotted, restored or deleted, and Node and Bun install no operational provider | | ``, `` and `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the ordinary performers above against the checkout the entry is working in, with what each produced retained as fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products. A completed operation is answered from that record with no `git` invocation of any kind, and a completed Commit's object id names the commit that was made then rather than today's HEAD. A live Commit reads the invoking person's own author and committer once per entry and only when a commit runs, captures the clock after deciding the index is nonempty, and commits exactly that index. A live operation takes the selected placement's slot and then the shared Git directory's mutation lease and keeps both until teardown; a completed one takes neither. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add keeps what it staged and claims no rollback; native completion and acknowledgement are separate, so an unacknowledged operation may run again and answer differently. No transaction, no rollback, no exactly-once local effect, and no publication evidence | | `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the same observe/adopt/fast-forward/refuse reconciliation §7.4 describes, reached under the entry's own owner and core's minted invocation identity rather than a run and an expansion. The request the entry asked for — branch, commit, destination ref and admitted credential-free origin — is published before anything is contacted, so an interrupted publication reconciles that exact request and an accepted push whose result was never recorded is adopted rather than repeated. Objects are read from the caller's own checkout through a control repository with a read-only alternate rather than from an exported Workspace family; no Workspace root, savepoint or frontier takes part, a completed publication reaches no provider, credential or remote, and the publication takes the managed slot and no mutation lease | +| `` inside a REPL entry | built on the #181 stack, Deno and compiled only: the same create/adopt, numbered update/no-op and conflict/ambiguity/unavailability reconciliation §7.5 describes, reached under the entry's own owner and core's minted invocation identity. It is authorized only by a completely validated saved Push request paired with its own acknowledged receipt and reached by this execution, with the last publication to that branch deciding and the journal's acknowledgement order deciding which that is; a bare receipt, a local Commit or File result, a synthetic `Git.push` answer, a failed Push and another entry's history authorize none. The complete request — head branch, head commit, base branch, number, title, verbatim body and draft — is written before the service is contacted, so an interrupted upsert reconciles that exact request without rereading HEAD or needing a checkout, and a completed one restores its acknowledged result before any native, provider, configuration or credential path. Both ordinary middleware chains compose on a fresh call: an answer that never delegated binds only the closed result contract and retains nothing, one occurrence permits one owned delegation, and after delegation the actual outcome controls the binding or the failure. It never pushes implicitly | +| the three pull-request evidence reads inside a REPL entry | built on the #181 stack, Deno and compiled only: the existing `pull_request_read` records and names, reached under the entry's owner and authenticated invocation. They need no Repository, working directory, managed root, Git session or native hold, so they are declared and installed for an entry holding no checkout. Provider matching, the configured allowed list and target validation run on every execution, including one reopening a saved read — removing the repository from that list refuses continuation while leaving the record in history, and no record is written for a question the host did not permit. What is retained is the answer produced inside the admitted-read boundary: a transformation before admission changes the recorded question, one after delegation reruns live over the saved snapshot, and an answer that never reaches the boundary is unretained. Each read holds one scoped handoff, so nested and concurrent reads keep their own question, and ownership is checked from the record because the name omits the owner for fork compatibility | | local Git operations and `Git.Push` evidence under an ordinary run | built by #643, Deno and compiled only: the same authored transitions with no transaction and no replay, commits recorded under the invoking user's own captured Git identity with an actionable refusal when the host can name none, and a private per-execution Push evidence entry that authorizes `` and crosses no run | | `` and pull-request reads under an ordinary run | built by #643, Deno and compiled only: the same transports and ceilings with no durable envelope, keyed by this execution's own invocation identity | | transactional Git components (`Git.Switch`, `Git.Add`, `Git.Commit`) | built by #294, Deno provider only | From 268bbb46a54e75b16a74a9ff38812e3e7102d6ed Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 17 Oct 2026 14:54:37 -0400 Subject: [PATCH 11/17] =?UTF-8?q?=E2=9C=A8=20Retain=20one=20REPL=20entry's?= =?UTF-8?q?=20pull=20requests=20and=20the=20evidence=20it=20read=20(#181,?= =?UTF-8?q?=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authored REPL entry can now publish a branch, open or bring up to date its pull request, read the reviews, comments and checks the Git host holds for it, leave at a question and continue from acknowledged work. Delivery 5 of #181. A pull request is a statement about a branch a Git host already holds, and this entry never makes one exist. So the only thing that authorizes one is a completely validated saved Push request paired with its own acknowledged receipt and reached by this execution. An ordinary run proves that by scanning its own journal; an entry cannot, because its segment is reopened hours later beside other entries. A bare receipt, a local Commit or File result, a handler's synthetic `Git.push` answer, a failed Push and another entry's history authorize none. The last publication to a branch decides what is on it, in the order the journal acknowledged them — replay may revisit two concurrent children either way round, so arrival order would let a superseded head keep authorizing work — and a publication standing after the current position is not evidence, because a previous session getting further than this one does not authorize this one. Both external protocols settle through one `git_host_effect` type, and a failed envelope names no kind. The admission reader therefore classifies from the preceding unconsumed request in the same coroutine and that request's own expected worker name, rather than reading every refusal as a publication the way it could while publication was the only protocol there. Two readers that each passed over the other's failures would each see an orphan where the other saw a pair. The request is written down before the service is contacted, keeping the head, base, number, title, verbatim body and draft it asked for, so an interrupted upsert reconciles that exact question without rereading a checkout that has moved. Both ordinary middleware chains compose on a fresh call through invocation-local defaults: what a handler may decide is what to ask for, not what was answered. An answer that never delegated binds only the closed result contract and retains nothing; once the owned terminal has run its outcome binds or fails, so a rewritten return cannot bind another pull request and a swallowed delegated failure stays a failure. The three evidence reads need no Repository, working directory, managed root, Git session or native hold, so they are declared and installed for an entry that holds no checkout. Retention begins at the admitted-read seam and nowhere earlier: provider matching, the configured allowed list and target validation run on every execution, including the one that reopens a saved read. Removing the repository from that list refuses to continue while leaving the record in history — wrapping the public dispatch in a durable envelope instead would record questions the run was never permitted to ask. What is retained is the answer produced inside that seam, so a transformation before admission changes the recorded question while one after delegation reruns live over the saved snapshot. Each read installs its own scoped bridge permitting one admitted handoff, which is what makes nesting and concurrency work without coordination; the durable name omits the owner so a fork can inherit it, and ownership is checked explicitly from the record, including a failed read's description. Intentionally unchanged: Push's request format, dispatch and native fingerprint boundary; the Git-host worker, the GitHub pull-request provider and the admitted read adapter; Workflow's pull request, reads, forks and lifecycle; ordinary-run behavior; File, Dir, selection, local Git and Issue contracts; the CLI factory signature and owner bytes. No implicit Push, no second reconciliation state machine, no general publication API and no new operator setting. Known limitation: an unacknowledged pull request may already have changed the service, and the saved request is preserved for existing reconciliation to settle rather than retried; an unacknowledged read may simply run again. Evidence (F1–F6, all green on this tree): F1 9 suites/81 steps · F2 6/47 · F3 24/156 · F4 18/107 · F5 source 1/1, `deno task build` exit 0, compiled 1/1 · F6 Node 48/48 and Bun 48/48, `git diff --check` clean. `deno task lint` exits 0 and `deno task check` reports no errors. F6's Bun invocation needs the `--timeout=300000` the repository's own Bun runner always passes; the landed `repl-push-pty` row times out under a bare `bun test` for the same reason. --- .../tests/retained-repository-pull-request-reads.test.ts | 7 +++++-- .../git/tests/retained-repository-pull-request.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts index 086be3aac..20b1a254a 100644 --- a/packages/git/tests/retained-repository-pull-request-reads.test.ts +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -41,9 +41,12 @@ import type { GitHubHttpRequest, GitHubHttpResponse, } from "../src/deno/composition/github.ts"; -import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +// From the subpath that publishes them rather than from the source modules: +// these are public, and reaching one by relative path would bypass the export +// map the partition test holds every consumer to. +import { PullRequestAPI } from "@executablemd/git/api"; +import type { PullRequestReadResult } from "@executablemd/git/api"; import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; -import type { PullRequestReadResult } from "../src/composition/pull-request-read-records.ts"; import { parsePullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index 56c3a09e4..18a5ceb53 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -54,9 +54,12 @@ import type { GitHubHttpRequest, GitHubHttpResponse, } from "../src/deno/composition/github.ts"; -import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +// From the subpath that publishes them rather than from the source modules: +// these are public, and reaching one by relative path would bypass the export +// map the partition test holds every consumer to. +import { PullRequestAPI } from "@executablemd/git/api"; +import type { PullRequestResult } from "@executablemd/git/api"; import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; -import type { PullRequestResult } from "../src/composition/pull-request-records.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; From a741be94daf1f509a7782b03001d7d24792e8213 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 00:42:24 -0400 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=9A=A7=20Join=20a=20read's=20attemp?= =?UTF-8?q?t=20before=20acknowledging=20it,=20and=20name=20the=20checkout?= =?UTF-8?q?=20a=20publication=20authorizes=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects R1-R4 of the Planner review on fa3b2ace and completes the enumerated V1-V9 evidence. - R1: an entry read runs in an attempt scope of its own, closed and joined before either outcome is published. Which outcome may be published is read from that attempt's own record of what it raised, by object identity: an expected read failure is retained as this read's own, while a cleanup that threw and any unauthored failure publish nothing and activate fail-stop. An interruption publishes nothing. - R2: the whole envelope is validated before a success is appended, again on readback, and again after the outer dispatch composes over it — including every check's agreement with the head it was read at, which the element's return contract cannot ask. - R3: read admission holds the saved configuration to the one its own question produces, beside the name. - R4: a publication carries the checkout it resolved in, and authorizes a pull request only from that checkout — on the live path and in saved history alike. Evidence: reads suite 23 rows, native PR suite 24, portable records 28, and the shared journey now asserts the restored identity, the three saved arrays and the later read's new data from the entry's own journal records. F1 10/86, F2 7/70, F3 24/156, F4 18/107 pass on this tree; lint and check are clean. F5 and F6 are not yet run. --- architecture.md | 33 + .../support/repl-pull-request-journey.ts | 228 ++++- .../composition/pull-request-read-records.ts | 15 + .../retained-repository/pull-request-reads.ts | 211 ++++- .../deno/retained-repository/pull-request.ts | 13 +- .../git/src/deno/retained-repository/push.ts | 3 + .../publication-evidence.ts | 42 +- .../pull-request-read-admission.ts | 27 +- .../src/retained-repository/push-admission.ts | 12 + ...ined-repository-pull-request-reads.test.ts | 564 +++++++++++- ...ed-repository-pull-request-records.test.ts | 183 ++++ .../retained-repository-pull-request.test.ts | 825 +++++++++++++++++- specs/executable-mdx-spec.md | 6 +- specs/repl-spec.md | 13 + 14 files changed, 2062 insertions(+), 113 deletions(-) diff --git a/architecture.md b/architecture.md index 4a949e29b..586950aad 100644 --- a/architecture.md +++ b/architecture.md @@ -7247,6 +7247,16 @@ receipt, a local `` result, a handler's synthetic `Git.push` answer and another entry's history are each not that, and none of them appears in the memory the admission builds. +The checkout the pull request resolved in is part of the question too. Both +requests naming a placement this entry authenticated says only that each was +asked somewhere legitimate; a publication this entry made from one of its +*other* checkouts is a statement about that checkout's branch, which holds its +own commit under the same name. So a publication authorizes a pull request only +when the checkout root and common directory it resolved in are the ones the +publication resolved in, on both the live path and the saved history. The +publication is still remembered — it simply authorizes nothing here. + + The last relevant publication decides, because a branch holds whatever was published to it last — so an earlier publication of that branch at another commit does not keep authorizing work the entry has since published past. The @@ -7306,7 +7316,30 @@ on continuation and may bind a different array from the same saved snapshot, and nothing presents those transformations as saved. A handler that never reaches the seam retains nothing and runs again. +A read is acknowledged only once the attempt that produced it has finished. The +transport's own session and page walk belong to a scope of the read's own, and +that scope is closed and joined before either outcome is published — otherwise a +reopen could restore a read whose first run was still unwinding when the journal +said it was done. Which outcome may be published is read from that attempt's own +record of what it raised, by object identity: an expected read failure is this +read's answer and is retained as one, while a cleanup that threw and any failure +nobody there authored publish nothing at all and activate the run's existing +fail-stop state. An interruption publishes nothing, which leaves the read +repeatable — safe in the way repeating a mutation is not. + +What may be acknowledged is also held to the whole evidence contract before it +is published, again when it is restored, and again after the outer dispatch has +composed over it. A collection whose items no reader can parse, and a checks +envelope holding an item about a commit other than the head it recorded, are +answers whose meaning is unknown rather than answers to acknowledge; the head +question in particular is one the element's own return contract cannot ask, +because the envelope's head never reaches the binding. Continuing a saved read +asks the same of its description: the input, the name and the configuration the +engine's own divergence policy reads all have to be the ones its own question +produces. + Who is asking travels nowhere public. Each authored read installs a bridge in a + scope of its own, holding the owner and the invocation it authenticated and permitting exactly one admitted handoff. Installing it there is what makes nesting and concurrency work without coordination: a nested read's bridge is diff --git a/packages/cli/tests/support/repl-pull-request-journey.ts b/packages/cli/tests/support/repl-pull-request-journey.ts index 50f8f2925..37f6a0b33 100644 --- a/packages/cli/tests/support/repl-pull-request-journey.ts +++ b/packages/cli/tests/support/repl-pull-request-journey.ts @@ -18,11 +18,15 @@ * ## What is substituted, and what is not * * Two leaf boundaries, and nothing else. `git` reaches a bare repository on - * disk rather than github.com, through Git's own `insteadOf` — so the locator - * the entry authenticates and fingerprints stays the github.com one a document - * wrote, which is the whole point of substituting at that layer rather than - * rewriting the document. And the configured API endpoint points at a forge in - * this process, which counts what it was asked. + * disk rather than github.com, through a `git` on PATH that rewrites that one + * locator in its own arguments — so the locator the entry authenticates and + * fingerprints stays the github.com one a document wrote, which is the whole + * point of substituting at that layer rather than rewriting the document. + * Git's own `insteadOf` cannot do it: every remote-touching invocation here + * runs with the invoking user's configuration off, which is exactly the + * property that stops a document retargeting a publication. And the configured + * API endpoint points at a forge in this process, which counts what it was + * asked. * * The CLI, the per-entry factory, the selectors, the publication, the upsert, * the three read adapters, the storage and the terminal are all the real ones. @@ -44,6 +48,13 @@ import type { AddressInfo } from "node:net"; import { join } from "node:path"; import process from "node:process"; import { creations, gitHubStore, mutations, respond } from "../../../git/tests/support/github.ts"; +import type { Json } from "@executablemd/durable-streams"; +import { GIT_HOST_EFFECT } from "../../../git/src/git-host/effect-type.ts"; +import { parseGitHostReconciliationRecord } from "../../../git/src/git-host/records.ts"; +import { parseClosedPullRequestResult } from "../../../git/src/composition/pull-request-records.ts"; +import { parsePullRequestReadResult } from "../../../git/src/composition/pull-request-read-records.ts"; +import type { PullRequestReadResult, PullRequestResult } from "@executablemd/git/api"; +import { PULL_REQUEST_READ } from "../../../git/src/deno/composition/pull-request-operations.ts"; import type { GitHubHttpResponse } from "../../../git/src/deno/composition/github.ts"; import { allocate, showing, showingAcross } from "./pty.ts"; import type { Pty } from "./pty.ts"; @@ -204,12 +215,31 @@ export function replPullRequestJourney(repl: ReplCommand): Operation { const number = forge.number(); expect(number).toBeGreaterThan(0); + // And what it wrote down is what a reopen answers from: one pull request + // at this head, and each collection exactly as the host answered it then. + const saved = yield* retainedReads(home, 3, second); + const opened = openedPullRequest(yield* retained(home)); + expect(opened?.number).toBe(number); + expect(opened?.url).toBe(`${LOCATOR}/pull/${number}`); + expect(opened?.state).toBe("open"); + expect(opened?.headSha).toBe(published); + expect(saved.map((collection) => collection.kind)).toEqual(["reviews", "comments", "checks"]); + + expect(saved[0]?.items.map((item) => ("author" in item ? item.author : ""))).toEqual([ + "first reviewer", + ]); + expect(saved[1]?.items).toEqual([]); + expect(saved[2]?.items).toEqual([]); + yield* reach(second, "[exit]"); yield* second.type("\r"); expect(yield* ended(second)).toBe(0); // ---- The service changes while the person is away. ---- forge.change(); + // Which the saved records do not: a second reviewer, a comment and a check + // now exist at the host and nothing in this history mentions them. + const mutationsSoFar = forge.mutations(); const evidenceSoFar = forge.evidenceReads(); @@ -234,6 +264,28 @@ export function replPullRequestJourney(repl: ReplCommand): Operation { expect(forge.evidenceReads()).toBe(evidenceSoFar); expect(forge.mutations()).toBe(mutationsSoFar); + // The identity and the three arrays this session restored are the saved + // ones, item for item — not the ones the host would answer with now. + const restored = yield* retained(home); + const again = openedPullRequest(restored); + expect(again?.number).toBe(number); + expect(again?.url).toBe(`${LOCATOR}/pull/${number}`); + expect(again?.headSha).toBe(published); + const unchanged = readCollections(restored); + expect(unchanged.map((collection) => collection.kind)).toEqual([ + "reviews", + "comments", + "checks", + ]); + expect(unchanged[0]?.items.map((item) => ("author" in item ? item.author : ""))).toEqual([ + "first reviewer", + ]); + expect(unchanged[1]?.items).toEqual([]); + expect(unchanged[2]?.items).toEqual([]); + // The screen is not where this is read: a question's own modal covers the + // transcript it was asked over, which is the surface's settled behaviour. + // What the document bound is what these records hold. + // ---- Answering performs the one later read, which sees the change. ---- yield* click(third, "( ) continue"); yield* showing(third, "[ok]", ACT_MS); @@ -244,6 +296,28 @@ export function replPullRequestJourney(repl: ReplCommand): Operation { expect(forge.evidenceReads()).toBe(evidenceSoFar + 1); expect(forge.mutations()).toBe(mutationsSoFar); expect(forge.creations()).toBe(1); + + // The restored reviews are what this session printed: the array it bound + // came from the record, and the record is what it was read from. + yield* showingAcross(third, '"author": "first reviewer"', ACT_MS); + + // The later read saw the change, and it is a fourth retained read rather + // than a rewriting of any of the three. Its own output stands below the + // window's fold, so what it holds is read from the record. + const afterwards = yield* retainedReads(home, 4, third); + + expect(afterwards.map((collection) => collection.kind)).toEqual([ + "reviews", + "comments", + "checks", + "reviews", + ]); + expect(afterwards[3]?.items.map((item) => ("author" in item ? item.author : ""))).toEqual([ + "first reviewer", + "second reviewer", + ]); + expect(afterwards[0]?.items.length).toBe(1); + // The primary branch was never touched by any of it. expect(yield* remoteRef(origin, "main", home)).toBe(primary); expect(yield* remoteRef(origin, BRANCH, home)).toBe(published); @@ -541,6 +615,150 @@ function useForge(origin: string): Operation { }); } +/** One retained record of this entry's history, as the file holds it. */ +interface Retained { + readonly type: string; + readonly status: string; + readonly value: unknown; +} + +/** + * Where this host keeps the REPL's histories, under the fixture's own HOME. + * + * Two candidates because the runtime answers it per platform and the pty's + * shell exports an XDG root of its own; whichever exists is the one this + * journey's sessions wrote to. + */ +function* journalRoot(home: string): Operation { + const candidates = [ + join(home, "share", "xmd", "repl"), + join(home, "Library", "Application Support", "xmd", "repl"), + join(home, ".local", "share", "xmd", "repl"), + ]; + for (const candidate of candidates) { + if (yield* exists(candidate)) { + return candidate; + } + } + throw new Error(`no REPL history beneath ${home}`); +} + +/** + * Every durable record this entry's history holds, read off the file. + * + * The counters say what reached the Git host; these say what was written down, + * which is the half a reopen actually answers from. + */ +function* retained(home: string): Operation { + const root = yield* journalRoot(home); + // A paired history keeps its entries beside its chat, one directory per + // execution; an older single-file one sits at the root. Only the entries are + // read: a chat record is not a durable event. + const files = yield* entryFiles(root); + + if (files.length === 0) { + throw new Error(`no retained history files beneath ${root}`); + } + const records: Retained[] = []; + for (const file of files) { + for (const line of (yield* readTextFile(file)).split("\n")) { + if (line.trim() === "") { + continue; + } + const parsed: unknown = JSON.parse(line); + const event = parsed as { + type?: unknown; + description?: { type?: unknown }; + result?: { status?: unknown; value?: unknown }; + }; + if (event.type !== "yield" || typeof event.description?.type !== "string") { + continue; + } + records.push({ + type: event.description.type, + status: typeof event.result?.status === "string" ? event.result.status : "", + value: event.result?.value, + }); + } + } + return records; +} + +/** + * Wait until this entry's history holds this many retained reads. + * + * The counters say what the host was asked, which happens before the record + * for it is appended. What a reopen answers from is the record, so a row + * asserting what was retained waits for the retention rather than for the + * request that preceded it. + */ +function* retainedReads(home: string, count: number, pty: Pty): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const collections = readCollections(yield* retained(home)); + if (collections.length >= count) { + return collections; + } + if (Date.now() > deadline) { + throw new Error( + `this entry's history never held ${count} retained reads; it holds ` + + `${collections.length}. screen=` + + JSON.stringify((yield* pty.shows()).map((one) => one.trimEnd())), + ); + } + yield* sleep(250); + } +} + +/** Every entries file beneath this root, however the history is laid out. */ + +function* entryFiles(directory: string): Operation { + const found: string[] = []; + for (const entry of yield* until(readdir(directory, { withFileTypes: true }))) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + found.push(...(yield* entryFiles(path))); + continue; + } + if (entry.name === "entries.jsonl" || /^[^.]+\.jsonl$/.test(entry.name)) { + found.push(path); + } + } + return found; +} + +/** The pull request this history says was opened, as the record holds it. */ + +function openedPullRequest(records: readonly Retained[]): PullRequestResult | undefined { + for (const record of records) { + if (record.type !== GIT_HOST_EFFECT || record.status !== "ok") { + continue; + } + const reconciliation = parseGitHostReconciliationRecord(record.value); + if (reconciliation?.request.kind !== "pull-request") { + continue; + } + return parseClosedPullRequestResult(reconciliation.result, "sha1"); + } + return undefined; +} + +/** Every evidence collection this history retained, in the order it read them. */ +function readCollections(records: readonly Retained[]): PullRequestReadResult[] { + const collections: PullRequestReadResult[] = []; + for (const record of records) { + if (record.type !== PULL_REQUEST_READ || record.status !== "ok") { + continue; + } + const value = record.value; + const parsed = value === undefined ? undefined : parsePullRequestReadResult(value as Json); + if (parsed !== undefined) { + collections.push(parsed); + } + } + return collections; +} + /** The commit one ref on the bare remote holds. */ function resolveRef(origin: string, ref: string): string { const shown = spawnSync("git", ["rev-parse", `refs/heads/${ref}`], { diff --git a/packages/git/src/composition/pull-request-read-records.ts b/packages/git/src/composition/pull-request-read-records.ts index c5fa5c409..fef6425e5 100644 --- a/packages/git/src/composition/pull-request-read-records.ts +++ b/packages/git/src/composition/pull-request-read-records.ts @@ -331,7 +331,22 @@ function evidenceJson(item: PullRequestEvidence): JsonObject { return record; } +/** + * Whether every check in this envelope is about the head it recorded. + * + * A checks envelope says which commit it was read at, and that head is the + * whole of what it reports: an item about a different commit makes the envelope + * two answers at once, and whichever a reader picks is wrong about the other. + * Asked of an empty collection too, where it is vacuously true and the recorded + * head is still the whole of what the envelope reports. + + */ +export function pullRequestReadHeadsAgree(result: PullRequestReadResult): boolean { + return result.kind !== "checks" || result.items.every((item) => item.headSha === result.headSha); +} + /** The retained envelope, read back rather than asserted. */ + export function parsePullRequestReadResult(value: Json): PullRequestReadResult | undefined { if (!isJsonObject(value)) { return undefined; diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index d7fe287a6..4e79a3145 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -58,8 +58,17 @@ import { scoped } from "effection"; import type { Operation } from "effection"; -import { createDurableOperation } from "@executablemd/durable-streams"; -import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { createDurableOperation, serializeError } from "@executablemd/durable-streams"; +import type { + AbandonedRetainedHistory, + ActivateDurabilityFailure, + DurableEvent, + Json, + JournalProvenance, + LiveDurableOperationCoordinator, + Result as DurableResult, +} from "@executablemd/durable-streams"; + import { documented, formDispatcher } from "@executablemd/core"; import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; import type { @@ -93,14 +102,19 @@ import type { PullRequestReadExecutionApi, } from "../../composition/pull-request-read-execution.ts"; import { + parsePullRequestReadResult, pullRequestReadEnvelopeJson, + pullRequestReadHeadsAgree, readRequest, } from "../../composition/pull-request-read-records.ts"; + import { admitReplPullRequestReadRequest, admitReplPullRequestReadResult, + replPullRequestReadConfigurationRefusal, replPullRequestReadNameRefusal, } from "../../retained-repository/pull-request-read-admission.ts"; + import type { PullRequestReadKind, PullRequestReadRequest, @@ -217,17 +231,18 @@ function readingBody( try { // The ordinary dispatch, so every public handler a document or a plugin // composed still answers, transforms, refuses and delegates. What comes - // back is whatever composed above the provider, and it is held to the - // collection this element asked for before any of it binds. - return pullRequestReadBinding( - yield* PullRequestOperations.operations.read({ - url: target.url, - kind, - provider: target.provider, - }), + // back is whatever composed above the provider — which is why it is + // held to the whole evidence contract here and not only to the + // collection: the outer result is the value a document binds, and a + // handler that rewrote it into something no reader can parse must not + // reach the binding merely because the saved answer underneath it was + // sound. + const answered = yield* PullRequestOperations.operations.read({ + url: target.url, kind, - element, - ); + provider: target.provider, + }); + return pullRequestReadBinding(validRead(answered, kind, element).result, kind, element); } finally { bridge.open = false; } @@ -303,11 +318,9 @@ function retaining( * is not the evidence this read produces is refused rather than bound, and only * `items` ever becomes the component's binding. * - * The transport's own session and pagination belong to the callback, so they are - * finished and joined before this position publishes anything. An expected - * failure after admission is retained as this read's own and binds no partial - * array; an interruption before acknowledgement leaves the read repeatable, - * which is safe in the way repeating a mutation is not. + * An expected failure after admission is retained as this read's own and binds + * no partial array; an interruption before acknowledgement leaves the read + * repeatable, which is safe in the way repeating a mutation is not. */ function* retain( request: PullRequestReadRequest, @@ -315,19 +328,144 @@ function* retain( transport: () => Operation, ): Operation { const description = yield* describePullRequestRead(request); - const stored: unknown = yield createDurableOperation(description, function* () { - const answered = yield* transport(); - 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); - }); - return retainedPullRequestRead(stored, request.kind, element); + // One settlement per attempt, reachable only from the two halves of this + // operation. Nothing a provider, a handler or a document holds can put an + // answer in it. + const settlement: ReadSettlement = {}; + const stored: unknown = yield createDurableOperation( + description, + function* (): Operation { + try { + const answered = yield* transport(); + return validRead(answered, request.kind, element).envelope; + } catch (error) { + // What this attempt itself produced, held by object identity so the + // coordinator can tell it from whatever the unwinding raises. + settlement.failure = error; + throw error; + } + }, + { coordinator: readCoordinator(settlement) }, + ); + return restoredRead(stored, request.kind, element); +} + +/** What one read's own attempt produced, as only this operation can see it. */ +interface ReadSettlement { + /** The failure the admitted read itself raised, by object identity. */ + failure?: unknown; +} + +/** + * This read's attempt boundary: finish, tear down, join, then acknowledge. + * + * A read opens things — an authenticated session, a page walk, whatever a + * provider acquires to answer. Publishing while those are still open would + * acknowledge an answer the attempt that produced it has not finished + * producing, and a reopen would then restore a read whose first run was still + * unwinding when the journal said it was done. So the attempt runs in a scope + * of its own and the scope is closed before either outcome is published. + * + * Which outcome may be published is read from this attempt's own record of what + * it raised, by object identity — never from the error's name or class, which + * anything in the dispatch above could imitate. An expected read failure is + * this read's answer and is retained as one. A cleanup that threw while the + * attempt unwound, or any failure nobody here authored, is the boundary failing + * rather than a finding: it publishes nothing and activates the run's existing + * fail-stop state, because a live operation that appended nothing would + * otherwise leave the next one to append at this one's position. An + * interruption publishes nothing at all. + */ +function readCoordinator(settlement: ReadSettlement): LiveDurableOperationCoordinator { + return { + *run( + execute: () => Operation, + publish: (result: DurableResult) => Operation, + activateFailure: ActivateDurabilityFailure, + _journalProvenance: JournalProvenance | undefined, + _abandoned: AbandonedRetainedHistory, + ): Operation { + let result: DurableResult; + try { + result = { status: "ok", value: yield* scoped(execute) }; + } catch (error) { + if (error !== settlement.failure) { + throw activateFailure( + error instanceof Error ? error : new Error("the evidence read boundary failed"), + ); + } + result = { status: "err", error: serializeError(errorOf(error)) }; + } + yield* publish(result); + return result; + }, + }; +} + +function errorOf(value: unknown): Error { + return value instanceof Error ? value : new Error("the evidence read failed"); +} + +/** + * The envelope this answer retains, or this read's own refusal that it is none. + * + * Held to the whole contract before a success may be published, and held to it + * through the codec that writes the record rather than over the object in hand: + * what has to be the evidence that read produces is the value this position + * would append. An item shape no reader can parse and a checks collection + * describing a commit other than the head it was read at are both answers whose + * meaning is unknown, and an unknown answer is not one to acknowledge. + */ +function validRead( + answered: PullRequestReadResult, + kind: PullRequestReadKind, + element: string, +): { readonly result: PullRequestReadResult; readonly envelope: Json } { + if (answered.kind !== kind) { + throw new PullRequestReadError( + "protocol", + element, + "the selected provider answered with a different collection than the one this element " + + "asked for.", + ); + } + const envelope = pullRequestReadEnvelopeJson(answered); + const parsed = parsePullRequestReadResult(envelope); + if (parsed === undefined || parsed.kind !== kind) { + throw new PullRequestReadError( + "protocol", + element, + "the answer it received is not the evidence that read produces, so nothing was retained " + + "for it.", + ); + } + if (!pullRequestReadHeadsAgree(parsed)) { + throw new PullRequestReadError( + "protocol", + element, + "the checks it received include one about a commit other than the head they were read " + + "at, so what they say about that commit is unknown and nothing was retained for it.", + ); + } + return { result: parsed, envelope }; +} + +/** The retained value this read restores, held to the same whole contract. */ +function restoredRead( + stored: unknown, + kind: PullRequestReadKind, + element: string, +): PullRequestReadResult { + const result = retainedPullRequestRead(stored, kind, element); + if (!pullRequestReadHeadsAgree(result)) { + throw new PullRequestReadError( + "protocol", + element, + "what this entry retained for it includes a check about a commit other than the head it " + + "recorded, so what it says about that commit is unknown.", + ); + } + return result; } /** @@ -345,12 +483,17 @@ export function replPullRequestReadAdmission(owner: string): JournalAdmission { continue; } const request = admitReplPullRequestReadRequest(owner, event.description["input"]); - // The name alone, not the whole description: this runs over a retained - // history before any element is expanding, and the source position a - // description also carries is only available there. - if (event.description.name !== pullRequestReadName(request).name) { + // The name and the configuration, but not the whole description: this + // runs over a retained history before any element is expanding, and the + // source position a description also carries is only available there. + const named = pullRequestReadName(request); + if (event.description.name !== named.name) { throw replPullRequestReadNameRefusal(); } + if (event.description.configuration !== named.configuration) { + throw replPullRequestReadConfigurationRefusal(); + } + if (event.result.status !== "ok") { // A refused read retains no result of its own: its outcome is the // durable operation's failed result, and there is nothing in it to hold diff --git a/packages/git/src/deno/retained-repository/pull-request.ts b/packages/git/src/deno/retained-repository/pull-request.ts index 3f872aa3b..3d3140cd4 100644 --- a/packages/git/src/deno/retained-repository/pull-request.ts +++ b/packages/git/src/deno/retained-repository/pull-request.ts @@ -479,16 +479,19 @@ function* resolve( baseBranch: received.pullRequest.base, }); - // This entry's own account of publishing that head, and nothing else. A - // refusal here happens with the Git host never having been asked anything. - entry.publications.admit(inputs, origin); - - return Object.freeze({ + const resolved = Object.freeze({ checkoutRoot: routed.checkout.root, commonDirectory: placement.commonDirectory, origin, inputs, }); + + // This entry's own account of publishing that head, from this checkout, and + // nothing else. A refusal here happens with the Git host never having been + // asked anything. + entry.publications.admit(inputs, origin, resolved); + + return resolved; } /** diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts index 6ab1c03ac..e0a6affe8 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/git/src/deno/retained-repository/push.ts @@ -610,10 +610,13 @@ function* reconcile( site: invocation.site, repository: inputs.repository, origin: record.resolved.origin, + checkoutRoot: record.resolved.checkoutRoot, + commonDirectory: record.resolved.commonDirectory, branch: inputs.branch, destinationRef: inputs.destinationRef, sourceCommit: inputs.sourceCommit, }); + return outcome; } diff --git a/packages/git/src/retained-repository/publication-evidence.ts b/packages/git/src/retained-repository/publication-evidence.ts index d67b0e4ff..fe49100a5 100644 --- a/packages/git/src/retained-repository/publication-evidence.ts +++ b/packages/git/src/retained-repository/publication-evidence.ts @@ -14,9 +14,12 @@ * nothing yet. A receipt belonging to another entry's segment, a synthetic * `Git.push` answer a handler returned, a local `` result and a * pull-request receipt are all records that are not publications of this - * branch — so none of them appears here at all. The admission beside this - * module is what proves the pair; this module is what remembers the proven ones - * and decides which of them a pull request may rest on. + * branch — so none of them appears here at all. And a publication this entry + * made from one of its *other* checkouts is a publication of that checkout's + * branch: it is remembered, and it authorizes nothing here. The admission + * beside this module is what proves the pair; this module is what remembers the + * proven ones and decides which of them a pull request may rest on. + * * ## Reached, not merely present * @@ -53,7 +56,7 @@ import { sameRepositoryIdentity, type RepositoryIdentity } from "../composition/ * restores are the same publication when they were asked for at the same * invocation. */ -export interface ReplPublication { +export interface ReplPublication extends ReplPublicationCheckout { /** The invocation this publication was asked for at. */ readonly site: string; readonly repository: RepositoryIdentity; @@ -65,6 +68,19 @@ export interface ReplPublication { readonly sourceCommit: string; } +/** + * Which of this entry's checkouts a publication was resolved in. + * + * Two members rather than one, because a worktree and the repository it belongs + * to share a common directory and are still different checkouts, while two + * clones of the same remote share neither. Both have to agree for one + * publication to be the other's. + */ +export interface ReplPublicationCheckout { + readonly checkoutRoot: string; + readonly commonDirectory: string; +} + /** One acknowledged publication, with the durable order it was acknowledged in. */ export interface RankedReplPublication extends ReplPublication { /** Its position in this entry's durable acknowledgement order. */ @@ -93,10 +109,15 @@ export interface ReplPublicationMemory { /** * Admit this pull request against the publications reached so far. * + * The checkout the pull request resolved in is part of the question. A + * publication this entry made from another of its checkouts is a statement + * about that checkout's branch, and authorizing a pull request with it would + * let one authenticated worktree speak for work another one published. + * * Refuses rather than answers: the two closed words are the ones the ordinary * evidence scan uses, so one vocabulary describes both paths. */ - admit(inputs: PullRequestInputs, origin: string): void; + admit(inputs: PullRequestInputs, origin: string, checkout: ReplPublicationCheckout): void; } /** This entry's publication memory, empty until a snapshot is admitted. */ @@ -127,7 +148,7 @@ export function createReplPublicationMemory(): ReplPublicationMemory { }); }, - admit(inputs, origin) { + admit(inputs, origin, checkout) { const destinationRef = destinationRefFor(inputs.headBranch); const relevant = Array.from(reached.values()) .filter( @@ -135,17 +156,20 @@ export function createReplPublicationMemory(): ReplPublicationMemory { publication.destinationRef === destinationRef && publication.branch === inputs.headBranch && publication.origin === origin && + publication.checkoutRoot === checkout.checkoutRoot && + publication.commonDirectory === checkout.commonDirectory && sameRepositoryIdentity(publication.repository, inputs.repository), ) + .sort((left, right) => left.rank - right.rank); const last = relevant.at(-1); if (last === undefined) { throw new PullRequestAdmissionError( "missing-push-evidence", "this entry holds no acknowledged of the branch and commit it would open " + - "a pull request from. Write before : a pull request names " + - "work this entry published, and publishing it is an explicit act. Nothing was " + - "contacted.", + "a pull request from, in the checkout it is opening it in. Write " + + "before : a pull request names work this entry published, and " + + "publishing it is an explicit act. Nothing was contacted.", ); } if (last.sourceCommit !== inputs.headSha) { diff --git a/packages/git/src/retained-repository/pull-request-read-admission.ts b/packages/git/src/retained-repository/pull-request-read-admission.ts index b2a68a8cb..af2c36567 100644 --- a/packages/git/src/retained-repository/pull-request-read-admission.ts +++ b/packages/git/src/retained-repository/pull-request-read-admission.ts @@ -35,7 +35,9 @@ import type { Json } from "@executablemd/durable-streams"; import { parsePullRequestReadRequest, parsePullRequestReadResult, + pullRequestReadHeadsAgree, } from "../composition/pull-request-read-records.ts"; + import type { PullRequestReadRequest, PullRequestReadResult, @@ -107,6 +109,24 @@ export function replPullRequestReadNameRefusal(): ReplPullRequestReadHistoryErro ); } +/** + * The refusal a read whose saved configuration is not its question's gets. + * + * A description carries the configuration beside the name, and the two are not + * redundant: the name a position publishes under is the invocation plus the + * fingerprint, so a record moved to another position keeps a fingerprint the + * name no longer exposes. Both are asked, because the engine's own divergence + * policy reads the configuration and a record whose configuration disagrees + * with the question beside it describes two different reads. + */ +export function replPullRequestReadConfigurationRefusal(): ReplPullRequestReadHistoryError { + return readRefusal( + "request-fingerprint", + "a retained evidence read is journaled with a configuration that is not the one its own " + + "question produces, so the record and the read it describes do not match.", + ); +} + /** * The collection one settled read retained, or the refusal that it retained * something else. @@ -128,7 +148,7 @@ export function admitReplPullRequestReadResult( "for, so what it says was read is unknown.", ); } - if (envelope.kind === "checks" && !sameHead(envelope.headSha, envelope.items)) { + if (!pullRequestReadHeadsAgree(envelope)) { throw readRefusal( "malformed-result", "a retained checks read holds an item for a different head than the one it recorded, so " + @@ -138,11 +158,6 @@ export function admitReplPullRequestReadResult( return envelope; } -/** Whether every check in this collection is about the head that was read. */ -function sameHead(headSha: string, items: readonly { readonly headSha: string }[]): boolean { - return items.every((item) => item.headSha === headSha); -} - function readRefusal( reason: ReplPullRequestReadRefusal, sentence: string, diff --git a/packages/git/src/retained-repository/push-admission.ts b/packages/git/src/retained-repository/push-admission.ts index ef7f16268..0e014b49f 100644 --- a/packages/git/src/retained-repository/push-admission.ts +++ b/packages/git/src/retained-repository/push-admission.ts @@ -240,6 +240,12 @@ type ReplExternalRequest = * publication of `A` to that same branch does not authorize it — the branch * holds whatever was published to it last. Finding any exact match anywhere in * history would let a superseded publication keep authorizing work. + * + * Relevant includes the checkout. Both requests naming a placement this entry + * authenticated says only that each was asked somewhere legitimate; what + * authorizes this pull request is a publication from the checkout it actually + * resolved in, because another of this entry's checkouts has its own branch of + * that name holding its own commit. */ function publicationAuthorizes( record: ReplPullRequestRequestRecord, @@ -251,8 +257,11 @@ function publicationAuthorizes( (publication) => publication.branch === inputs.headBranch && publication.origin === record.resolved.origin && + publication.checkoutRoot === record.resolved.checkoutRoot && + publication.commonDirectory === record.resolved.commonDirectory && sameRepositoryIdentity(publication.repository, inputs.repository), ) + .sort((left, right) => left.rank - right.rank); const last = relevant.at(-1); return last !== undefined && last.sourceCommit === inputs.headSha; @@ -675,10 +684,13 @@ function* admitOutcome( rank: publications.length, repository: inputs.repository, origin: request.record.resolved.origin, + checkoutRoot: request.record.resolved.checkoutRoot, + commonDirectory: request.record.resolved.commonDirectory, branch: inputs.branch, destinationRef: inputs.destinationRef, sourceCommit: inputs.sourceCommit, }); + return; } if (parsePullRequestRecord(settled, request.record.resolved.inputs) === undefined) { diff --git a/packages/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts index 20b1a254a..32ba61691 100644 --- a/packages/git/tests/retained-repository-pull-request-reads.test.ts +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -22,7 +22,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, until } from "effection"; +import { ensure, race, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp, realpath } from "node:fs/promises"; @@ -33,7 +33,8 @@ import type { FilesHandler } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { executeInstalled } from "@executablemd/core/host"; -import { collect } from "@executablemd/core"; +import { collect, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; import { useCompositionComponents } from "../src/composition/installation.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; import type { @@ -47,7 +48,13 @@ import type { import { PullRequestAPI } from "@executablemd/git/api"; import type { PullRequestReadResult } from "@executablemd/git/api"; import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; -import { parsePullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; +import { + parsePullRequestReadRequest, + parsePullRequestReadResult, +} from "../src/composition/pull-request-read-records.ts"; +import type { CheckEvidence } from "../src/composition/pull-request-read-records.ts"; + +import { PullRequestReadExecution } from "../src/composition/pull-request-read-execution.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; @@ -168,6 +175,10 @@ interface Run { interface RunOptions { readonly owner?: string; + /** Registrations this row installs beside the inherited ones. */ + readonly shadowing?: readonly ComponentRegistration[]; + /** Whether this row installs the entry capability at all. */ + readonly entry?: boolean; /** What this deployment authorizes reading, when a row narrows it. */ readonly allowed?: readonly string[]; readonly between?: () => Operation; @@ -230,7 +241,16 @@ function run( yield* collect( yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, - installation, + ...(options.entry === false ? [] : [installation]), + ...(options.shadowing === undefined + ? [] + : [ + { + *install(): Operation { + yield* registerComponents([...(options.shadowing ?? [])]); + }, + }, + ]), ]), ), ); @@ -281,6 +301,76 @@ function replaying(prefix: readonly DurableEvent[]): InMemoryStream { ); } +/** The retained reads that hold a collection, as opposed to a failure. */ +function settled(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter((event) => event.type === "yield" && event.result.status === "ok"); +} + +/** The collection one settled read retained, read back rather than asserted. */ +function collectionOf(event: DurableEvent): PullRequestReadResult | undefined { + if (event.type !== "yield" || event.result.status !== "ok") { + return undefined; + } + const value = event.result.value; + return value === undefined ? undefined : parsePullRequestReadResult(value); +} + +/** One check run, about whichever commit the row says it is about. */ +function check(headSha: string): CheckEvidence { + return { + kind: "check-run", + id: "1", + headSha, + name: "build", + status: "completed", + conclusion: "success", + url: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + }; +} + +/** + * An adapter whose read opens something its own cleanup cannot close. + * + * It admits the read, answers through the seam — or fails there — and then its + * teardown throws. That is the one way an attempt ends in a state its outcome + * cannot be read from, and it is deliberately the same shape for both. + */ +function answering(answer: () => void): Operation { + return PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + function* (): Operation { + yield* resource(function* (provide) { + yield* ensure(function* (): Operation { + throw new Error("the read's own cleanup failed"); + }); + yield* provide(null); + }); + answer(); + return { kind: "reviews", items: [] }; + }, + ); + }, + }); +} + +/** Wait for this signal, and fail the row rather than hang if it never comes. */ +function* within(signal: Operation, sentence: string): Operation { + yield* race([ + signal, + (function* (): Operation { + yield* sleep(30_000); + throw new Error(sentence); + })(), + ]); +} + describe("one entry's evidence reads", () => { it("V7a: reads a collection with no checkout, and retains the question it asked", function* () { const fixture = yield* useFixture(); @@ -613,4 +703,470 @@ describe("one entry's evidence reads", () => { ); expect(borrowed.refusal).toBeDefined(); }); + + it("V7f: a paged collection is retained whole, and restores whole", function* () { + const fixture = yield* useFixture(); + // Two pages, so the answer is only complete once the walk has finished + // both. Nothing partial may be retained as the collection. + const host = server({ + [PULL]: { body: pullRequestPayload() }, + [REVIEWS]: { + body: JSON.stringify([review(1)]), + link: `<${ENDPOINT}${REVIEWS}?page=2>; rel="next"`, + }, + [`${REVIEWS}?page=2`]: { body: JSON.stringify([review(2)]) }, + }); + const document = `\n`; + const stream = new InMemoryStream(); + const walked = yield* run(fixture, document, stream, host); + + expect(walked.refusal).toBeUndefined(); + expect(walked.output).toContain("review 1"); + expect(walked.output).toContain("review 2"); + const retained = reads(walked.events); + expect(retained.length).toBe(1); + expect(collectionOf(retained[0]!)?.items.length).toBe(2); + expect(reviewCalls(host)).toBe(2); + + // And the whole collection comes back without walking a page. + const again = yield* run(fixture, document, replaying(walked.events), host); + expect(again.refusal).toBeUndefined(); + expect(again.output).toContain("review 1"); + expect(again.output).toContain("review 2"); + expect(reviewCalls(host)).toBe(2); + }); + + it("V7g: an answer about another subject is refused before any success", function* () { + const fixture = yield* useFixture(); + // The collection answers with a review belonging to pull request 9 while + // the read asked about 7. The adapter checks the subject it was handed + // rather than accepting it, so this never reaches retention as an answer. + const host = server({ + [PULL]: { body: pullRequestPayload() }, + [REVIEWS]: { body: JSON.stringify([review(1, 9)]) }, + }); + const stream = new InMemoryStream(); + const wrong = yield* run( + fixture, + `\n`, + stream, + host, + ); + + expect(wrong.refusal).toBeDefined(); + expect(wrong.output).not.toContain("review 1"); + const retained = reads(wrong.events); + expect(settled(retained)).toEqual([]); + expect(retained.length).toBe(1); + expect(retained[0]?.type === "yield" && retained[0].result.status).toBe("err"); + }); + + it("V7h: a checks answer about another commit is refused before any success", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + // An adapter that admitted the read and then answered with an envelope for + // one head holding a check about another. The entry owns what happens + // after the seam, so this is its refusal to make: an envelope that is two + // answers at once says nothing certain about either commit. + const inconsistent = yield* run( + fixture, + `\n`, + stream, + host, + { + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + // deno-lint-ignore require-yield + function* (): Operation { + return { kind: "checks", headSha: HEAD, items: [check("b".repeat(40))] }; + }, + ); + }, + }); + }, + }, + ); + + expect(inconsistent.refusal).toBeDefined(); + expect(inconsistent.output).not.toContain("build"); + const retained = reads(inconsistent.events); + // Refused before success: the position holds this read's own failure and + // no collection at all. + expect(settled(retained)).toEqual([]); + expect(retained.length).toBe(1); + }); + + it("V7i: a duplicate admitted handoff and a stale one each run no second read", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + // A middleware above the entry's own policy that offers the same admitted + // read twice. The first handoff is this read's; the second would retain a + // second answer under one identity. + const duplicated = yield* run( + fixture, + ``, + stream, + host, + { + *between(): Operation { + yield* PullRequestReadExecution.around({ + *perform([admitted, transport], next): Operation { + yield* next(admitted, transport); + return yield* next(admitted, transport); + }, + }); + }, + }, + ); + + expect(duplicated.refusal).toBeDefined(); + expect(reads(duplicated.events).length).toBe(1); + // The second handoff asked the host nothing: it was refused before a + // session or a record existed. + expect(reviewCalls(host)).toBe(1); + + // And one offered while the read that owns it is already unwinding reaches + // the same refusal, from the other guard. + const late = reviewing(); + const stale = yield* run( + fixture, + ``, + new InMemoryStream(), + late, + { + *between(): Operation { + yield* PullRequestReadExecution.around({ + *perform([admitted, transport], next): Operation { + const answered = yield* next(admitted, transport); + // Registered in the read's own scope, so it runs while the + // bridge is still installed and the read that installed it has + // finished. + yield* ensure(function* (): Operation { + yield* next(admitted, transport); + }); + return answered; + }, + }); + }, + }, + ); + + expect(stale.refusal).toBeDefined(); + expect(reviewCalls(late)).toBe(1); + }); + + it("V7j: the final answer is validated after outer middleware, before it binds", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + // The provider answers soundly and a handler above the entry rewrites what + // it answered: same envelope head, an item about another commit. Each item + // is well formed on its own, so the element's own return contract accepts + // it — the question it cannot ask is whether the collection agrees with + // the head it was read at, and that is this boundary's to ask. + const rewritten = yield* run( + fixture, + `\n`, + stream, + host, + { + *between(): Operation { + yield* PullRequestOperations.around({ + *read([invocation], next): Operation { + const answered = yield* next(invocation); + return answered.kind === "checks" + ? { kind: "checks", headSha: answered.headSha, items: [check("b".repeat(40))] } + : answered; + }, + }); + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + // deno-lint-ignore require-yield + function* (): Operation { + return { kind: "checks", headSha: HEAD, items: [check(HEAD)] }; + }, + ); + }, + }); + }, + }, + ); + + expect(rewritten.refusal).toBeDefined(); + expect(rewritten.output).not.toContain("build"); + // What the provider answered was retained, and it is sound; what the + // handler made of it never became a binding. + const retained = reads(rewritten.events); + expect(retained.length).toBe(1); + const collection = collectionOf(retained[0]!); + expect(collection?.kind === "checks" && collection.headSha).toBe(HEAD); + expect(collection?.items.length).toBe(1); + }); + + it("V8b: cancelling a blocked read publishes nothing and joins its cleanup", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const reached = withResolvers(); + const held = withResolvers(); + const session = { opened: false, released: false }; + const stream = new InMemoryStream(); + + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, ``, stream, host, { + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + function* (): Operation { + yield* resource(function* (provide) { + session.opened = true; + yield* ensure(() => { + session.released = true; + }); + yield* provide(null); + }); + reached.resolve(); + yield* held.operation; + return { kind: "reviews", items: [] }; + }, + ); + }, + }); + }, + }), + ); + yield* within(reached.operation, "the read never opened its session"); + const halting = yield* spawn(() => task.halt()); + held.resolve(); + yield* halting; + }); + + // The session this read opened was released, and the halt waited for it. + expect(session.opened).toBe(true); + expect(session.released).toBe(true); + const events = yield* stream.readAll(); + // An interrupted read invented no outcome, so it is repeatable. + expect(reads(events).length).toBe(0); + expect(events.filter((event) => event.type === "close")).toEqual([]); + }); + + it("V8c: cancelling a read still acquiring its session releases it", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const reached = withResolvers(); + const held = withResolvers(); + const session = { opened: false, released: false }; + const stream = new InMemoryStream(); + + yield* scoped(function* (): Operation { + const task = yield* spawn(() => + run(fixture, ``, stream, host, { + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + function* (): Operation { + // Still being acquired when the halt arrives: the delivery + // this scope is owed has not happened yet. Its release is + // registered before it blocks, because a registration made + // after a halt has begun is not one the scope will honour. + yield* resource(function* (provide) { + yield* ensure(() => { + session.released = true; + }); + reached.resolve(); + yield* held.operation; + session.opened = true; + yield* provide(null); + }); + + return { kind: "reviews", items: [] }; + }, + ); + }, + }); + }, + }), + ); + yield* within(reached.operation, "the read never began acquiring its session"); + const halting = yield* spawn(() => task.halt()); + held.resolve(); + yield* halting; + }); + + // The acquisition finished after the halt began, and the halt did not + // settle until what it delivered was released. + expect(session.opened).toBe(true); + expect(session.released).toBe(true); + const events = yield* stream.readAll(); + expect(reads(events).length).toBe(0); + expect(events.filter((event) => event.type === "close")).toEqual([]); + }); + + it("V8d: a cleanup that throws after a successful read publishes nothing", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const failing = yield* run( + fixture, + `\n`, + stream, + host, + { between: () => answering(() => undefined) }, + ); + + expect(failing.refusal).toBeDefined(); + expect(failing.output).not.toContain("reviewer"); + // The read answered, but the attempt that produced the answer did not + // finish. Nothing is acknowledged for a position whose work is in an + // unknown state. + expect(reads(failing.events).length).toBe(0); + expect(failing.events.filter((event) => event.type === "close")).toEqual([]); + }); + + it("V8e: a cleanup that throws after an expected failure publishes nothing", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const failing = yield* run( + fixture, + `\n`, + stream, + host, + { + between: () => + answering(() => { + throw new Error("the collection could not be read"); + }), + }, + ); + + expect(failing.refusal).toBeDefined(); + // The read's own failure would have been retained had the attempt ended + // cleanly. It did not, so this position holds nothing rather than a + // failure whose cleanup is unaccounted for. + expect(reads(failing.events).length).toBe(0); + expect(failing.events.filter((event) => event.type === "close")).toEqual([]); + }); + + it("V6c: a failed read saved under another owner is refused before replay", function* () { + const fixture = yield* useFixture(); + // A walk that cannot finish, so what the history holds at that position is + // a failed read: no collection, and a description that still says who + // asked. + const host = server({ + [PULL]: { body: pullRequestPayload() }, + [REVIEWS]: { + body: JSON.stringify([review(1)]), + link: `<${ENDPOINT}${REVIEWS}?page=2>; rel="next"`, + }, + [`${REVIEWS}?page=2`]: { status: 500, body: "{}" }, + }); + const stream = new InMemoryStream(); + const incomplete = yield* run( + fixture, + ``, + stream, + host, + ); + expect(incomplete.refusal).toBeDefined(); + const retained = reads(incomplete.events); + expect(retained.length).toBe(1); + expect(retained[0]?.type === "yield" && retained[0].result.status).toBe("err"); + + // Offered to another entry. A failure retains no result to hold to + // anything, and its description is still held to this entry's owner. + const borrowed = yield* run( + fixture, + ``, + replaying(incomplete.events), + host, + { owner: OTHER_OWNER }, + ); + expect(borrowed.refusal).toBeDefined(); + expect(borrowed.refusal).toContain("written under another owner"); + }); + + it("V6e: a saved read whose configuration is not its question's is refused", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const document = ``; + const first = yield* run(fixture, document, stream, host); + expect(first.refusal).toBeUndefined(); + + // A description carries the configuration beside the name, and the engine's + // own divergence policy reads it. One edited to say another question was + // asked is a record that does not describe the read beside it, however + // correct its name and its value are. + const edited = first.events.map((event) => + event.type === "yield" && event.description.type === PULL_REQUEST_READ + ? { + ...event, + description: { ...event.description, configuration: "another-question" }, + } + : event, + ); + const resumed = yield* run(fixture, document, replaying(edited), host); + expect(resumed.refusal).toBeDefined(); + expect(resumed.refusal).toContain("configuration"); + // Refused before replay, so the read was never performed again either. + expect(reviewCalls(host)).toBe(1); + }); + + it("V9b: the entry's components are ordinary registrations over a kept one", function* () { + const fixture = yield* useFixture(); + + // The first-party registration is kept: this entry's own component is an + // ordinary registration in the scope the execution mints for it, shadowing + // the inherited one rather than replacing or retiring it. So a host that + // registers the same name in the *inherited* scope is refused by the + // ordinary rule, with no special precedence anywhere. + const collided = yield* run( + fixture, + ``, + new InMemoryStream(), + reviewing(), + { + shadowing: [ + { + name: "PullRequest.Reviews", + origin: "test", + props: { type: "object", additionalProperties: true }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "shadowed"; + }, + }, + ], + }, + ); + expect(collided.refusal).toContain("Register the name once per scope"); + + // And the kept registration has no identity authenticator of its own: with + // the entry capability not installed at all, the same element reaches the + // ordinary missing-provider refusal and retains nothing. + const host = reviewing(); + const stream = new InMemoryStream(); + const kept = yield* run( + fixture, + ``, + stream, + host, + { entry: false }, + ); + expect(kept.refusal).toBeDefined(); + expect(reads(yield* stream.readAll()).length).toBe(0); + expect(host.requests).toEqual([]); + }); }); diff --git a/packages/git/tests/retained-repository-pull-request-records.test.ts b/packages/git/tests/retained-repository-pull-request-records.test.ts index 878d871eb..38be801cc 100644 --- a/packages/git/tests/retained-repository-pull-request-records.test.ts +++ b/packages/git/tests/retained-repository-pull-request-records.test.ts @@ -36,6 +36,13 @@ import { type GitPushInputs, } from "../src/composition/git-push-records.ts"; import { locatorFingerprint } from "../src/deno/composition/locator.ts"; +import { pullRequestReadName } from "../src/deno/composition/pull-request-operations.ts"; +import { + createReplPublicationMemory, + type RankedReplPublication, + type ReplPublicationMemory, +} from "../src/retained-repository/publication-evidence.ts"; +import { PullRequestAdmissionError } from "../src/composition/errors.ts"; import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; import { @@ -866,6 +873,100 @@ describe("Tier RPR — admitting a history that published and then opened", () = }; expect(yield* refusalOf([...prefix, unrelated])).toBeUndefined(); }); + + it("RPR24: one read-name vector keeps the length-prefixed fingerprint, odd text and all", function* () { + // The other naming algorithm this feature touches, and deliberately not + // the canonical-JSON digest above: an evidence read is named the way every + // other Git operation in this package is named, so a journal a fork reads + // keeps answering. + // + // The vector is the first 16 hex digits of SHA-256 over the UTF-16 + // big-endian code units of `4:read38:-6:checks` — a null discriminator + // is "-" rather than a length, and the 38 counts code units, so the "ö" + // counts once. + const named = pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", undefined, OWNER, SITE), + ); + expect(named.configuration).toBe("6e1959358a175d22"); + expect(named.name).toBe(`${SITE}:6e1959358a175d22`); + + // The discriminator is part of the question, so naming one changes it. + expect( + pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", "github", OWNER, SITE), + ).configuration, + ).not.toBe(named.configuration); + + // The owner is deliberately not: a fork is a different run asking the same + // question at the same position, and a name carrying the run would make + // every inherited read unforkable. + expect( + pullRequestReadName( + readRequest( + "https://github.com/octo/pröject/pull/7", + "checks", + undefined, + OTHER_OWNER, + SITE, + ), + ), + ).toEqual(named); + }); + + it("RPR25: a pull request resolved in another authenticated checkout is refused", function* () { + // Both requests name a placement this entry holds, which is the most this + // feature's placement question can establish. What it cannot establish is + // that the publication was of *this* checkout's branch: a worktree of the + // same repository has its own `review/notes` holding its own commit. + const push = yield* pushRequestEvent(); + const otherRoot = `${CHECKOUT}-other`; + const selection = { + kind: "worktree" as const, + name: "other", + branch: BRANCH, + owner: IDENTITY, + commonDirectory: COMMON, + ownerCheckout: CHECKOUT, + base: null, + }; + const fingerprint = yield* replPlacementFingerprint(selection); + const elsewhere: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { + type: REPL_REPOSITORY_PLACEMENT, + name: replPlacementRecordName({ kind: "worktree", fingerprint }), + }, + result: { + status: "ok", + value: replPlacementRecordJson({ + fingerprint, + request: selection, + outcome: Ok({ ...PLACEMENT, kind: "worktree", checkoutRoot: otherRoot }), + }), + }, + }; + + const refusal = yield* refusalOf([ + yield* placementEvent(), + elsewhere, + push.event, + yield* pushOutcomeEvent(push.record), + yield* requestEvent(request({ resolved: { checkoutRoot: otherRoot } })), + ]); + expect(refusal?.reason).toBe("unauthorized-pull-request"); + + // Resolved in the checkout that published it, the same history admits. + expect( + yield* refusalOf([ + yield* placementEvent(), + elsewhere, + push.event, + yield* pushOutcomeEvent(push.record), + yield* requestEvent(request()), + ]), + ).toBeUndefined(); + }); }); describe("Tier RPR — whose retained evidence read is whose", () => { @@ -974,3 +1075,85 @@ describe("Tier RPR — whose retained evidence read is whose", () => { expect(refusal?.reason).toBe("malformed-result"); }); }); + +/** + * What a pull request may rest on, as the entry remembers it live. + * + * The admission above establishes the ordered facts; this is the memory that + * decides which of them authorizes a pull request *now*. The two questions it + * answers that nothing else can are reached-ness — a fact standing after the + * position execution is at is not usable — and durable order, because replay + * may revisit two concurrent children in either order. + */ +describe("Tier RPR — which publication a pull request may rest on", () => { + /** One acknowledged publication, with whatever a case wants different. */ + function publication(overrides: Partial = {}): RankedReplPublication { + return { + site: PUSH_SITE, + rank: 0, + repository: IDENTITY, + origin: ORIGIN, + checkoutRoot: CHECKOUT, + commonDirectory: COMMON, + branch: BRANCH, + destinationRef: destinationRefFor(BRANCH), + sourceCommit: HEAD, + ...overrides, + }; + } + + /** What admitting this pull request refused, or `undefined`. */ + function refusedBy( + memory: ReplPublicationMemory, + inputs: PullRequestInputs = INPUTS, + ): string | undefined { + try { + memory.admit(inputs, ORIGIN, { checkoutRoot: CHECKOUT, commonDirectory: COMMON }); + return undefined; + } catch (error) { + return error instanceof PullRequestAdmissionError ? error.reason : "unexpected"; + } + } + + it("RPR26: a fact the snapshot holds authorizes nothing until execution reaches it", function* () { + const memory = createReplPublicationMemory(); + memory.admitted([publication()]); + // The publication is in this entry's history and this execution has not + // arrived at it. A previous session getting further than this one is not + // authority for work this one is doing now. + expect(refusedBy(memory)).toBe("missing-push-evidence"); + + memory.reach(publication()); + expect(refusedBy(memory)).toBeUndefined(); + }); + + it("RPR27: restoring two publications in reverse keeps the order they were acknowledged in", function* () { + const first = publication({ site: "expansion-a", rank: 0 }); + const second = publication({ site: "expansion-b", rank: 1, sourceCommit: MOVED_HEAD }); + const memory = createReplPublicationMemory(); + memory.admitted([first, second]); + + // Replay reaches the later publication first. The branch still holds what + // was published to it last, so this head is superseded however this + // execution happened to arrive. + memory.reach(second); + memory.reach(first); + expect(refusedBy(memory)).toBe("conflicting-push-evidence"); + expect(refusedBy(memory, { ...INPUTS, headSha: MOVED_HEAD })).toBeUndefined(); + }); + + it("RPR28: a publication from another of this entry's checkouts authorizes nothing", function* () { + const memory = createReplPublicationMemory(); + const elsewhere = publication({ checkoutRoot: `${CHECKOUT}-other` }); + memory.admitted([elsewhere]); + memory.reach(elsewhere); + // Same repository, same origin, same branch, same commit — and a different + // checkout, which is a different branch of that name. + expect(refusedBy(memory)).toBe("missing-push-evidence"); + + // A live publication from this checkout ranks after everything the + // snapshot held, and is the one that authorizes. + memory.reach(publication({ site: "expansion-live" })); + expect(refusedBy(memory)).toBeUndefined(); + }); +}); diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index 18a5ceb53..3e132c6fc 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -34,7 +34,8 @@ import { withResolvers, } from "effection"; import type { Operation, WithResolvers } from "effection"; -import { rm, writeTextFile } from "@effectionx/fs"; +import { exists, rm, writeTextFile } from "@effectionx/fs"; + import { mkdir, mkdtemp, realpath, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -57,9 +58,14 @@ import type { // From the subpath that publishes them rather than from the source modules: // these are public, and reaching one by relative path would bypass the export // map the partition test holds every consumer to. -import { PullRequestAPI } from "@executablemd/git/api"; -import type { PullRequestResult } from "@executablemd/git/api"; +import { Git, PullRequestAPI } from "@executablemd/git/api"; +import type { + GitPushOutcome, + PullRequestReadResult, + PullRequestResult, +} from "@executablemd/git/api"; import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; +import type { PullRequestUpsertInvocation } from "../src/composition/pull-request-operations.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect-type.ts"; @@ -67,8 +73,15 @@ import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; import { parseReplPullRequestRequestRecord, REPL_REPOSITORY_PULL_REQUEST_REQUEST, + replPullRequestRequestJson, } from "../src/retained-repository/pull-request-records.ts"; import { REPL_REPOSITORY_LOCAL } from "../src/retained-repository/records.ts"; +import { + parseReplPlacementRecord, + REPL_REPOSITORY_PLACEMENT, +} from "../src/retained-repository/placement-records.ts"; +import { parsePullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; +import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; import { gitOutput } from "./support/git-remotes.ts"; import { useGitHubServer } from "./support/github.ts"; import { @@ -167,6 +180,10 @@ interface Run { interface RunOptions { readonly answer?: unknown; + /** Resolved when the entry reaches a question, which then never answers. */ + readonly atQuestion?: WithResolvers; + /** The host this row installs, when it watches or refuses native work. */ + readonly host?: RepositoryHost; readonly owner?: string; readonly cwd?: string; /** What this deployment authorizes reading, when a row narrows it. */ @@ -228,11 +245,23 @@ function run( { at: "min" }, ); } + if (options.atQuestion !== undefined) { + const reached = options.atQuestion; + yield* Elicitation.around( + { + *elicit(): Operation { + reached.resolve(); + return yield* suspend(); + }, + }, + { at: "min" }, + ); + } if (options.between !== undefined) { yield* options.between(); } const installation = yield* replRepositoryInstallation(options.owner ?? OWNER, { - host: rewriting(fixture.origin), + host: options.host ?? rewriting(fixture.origin), root: fixture.managed, pullRequests: { // The modeled host, injected: an access that outranks any configured @@ -328,6 +357,196 @@ function store(fixture: Fixture): GitHubStore { return forge; } +/** A publication answer a handler can build, and that proves nothing. */ +const SYNTHETIC_PUSH: GitPushOutcome = Object.freeze({ + decision: "performed" as const, + result: Object.freeze({ + repository: Object.freeze({ + name: "project", + locatorFingerprint: "0".repeat(64), + requestedBase: null, + creationCommit: "0".repeat(40), + primaryBranch: "main", + objectFormat: "sha1" as const, + }), + remote: "origin", + branch: "review/synthetic", + destinationRef: "refs/heads/review/synthetic", + refspec: `${"0".repeat(40)}:refs/heads/review/synthetic`, + sourceCommit: "0".repeat(40), + observedRemoteCommit: "0".repeat(40), + }), +}); + +/** The question half every left-at-a-question row writes. */ + +const QUESTION = [ + ` `, + `The pull request is open. Continue?`, + ` `, +].join("\n"); + +/** Every retained evidence read this history holds. */ +function reads(events: readonly DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ); +} + +/** The normalized question one retained read recorded. */ +function question(event: DurableEvent) { + return event.type === "yield" + ? parsePullRequestReadRequest(event.description["input"]) + : undefined; +} + +/** + * The modeled host, plus the one evidence route a read in a body needs. + * + * The fake GitHub answers pull-request routes; a reviews collection is not one + * of them, so this answers that path from the pull request the store holds and + * leaves every other request exactly as it was. + */ +function readable(store: GitHubStore): { readonly access: GitHubAccess } { + const inner = fakeGitHubAccess(store); + return { + access: { + endpoint: inner.endpoint, + token: () => inner.token(), + *send(request: GitHubHttpRequest): Operation { + const url = new URL(request.url); + const reviews = /^\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)\/reviews$/.exec(url.pathname); + if (reviews === null) { + return yield* inner.send(request); + } + const number = Number(reviews[1]); + const found = store.pullRequests.find((pullRequest) => pullRequest.number === number); + if (found === undefined) { + return { status: 404, body: JSON.stringify({ message: "Not Found" }) }; + } + return { + status: 200, + body: JSON.stringify([ + { + id: 11, + user: { login: "reviewer" }, + state: "APPROVED", + body: "looks right", + submitted_at: "2026-08-24T00:00:00Z", + commit_id: found.headSha, + html_url: `${LOCATOR}/pull/${number}#r11`, + pull_request_url: `${inner.endpoint}/repos/${store.owner}/${store.repository}/pulls/${number}`, + }, + ]), + }; + }, + }, + }; +} + +/** A host that answers exactly as the production one does, and counts. */ +function watching(fixture: Fixture): { readonly host: RepositoryHost; readonly ran: string[] } { + const ran: string[] = []; + const inner = rewriting(fixture.origin); + return { + ran, + host: { + *git(invocation: GitInvocation): Operation { + ran.push(invocation.args[0] ?? ""); + return yield* inner.git(invocation); + }, + useDirectory: inner.useDirectory, + }, + }; +} + +/** A transport that fails the row if a credential or a service is reached. */ +function unreachableAccess(): GitHubAccess { + return { + endpoint: "https://api.github.test", + // deno-lint-ignore require-yield + *token(): Operation { + throw new Error("a credential was read for a completed upsert"); + }, + // deno-lint-ignore require-yield + *send(): Operation { + throw new Error("the service was contacted for a completed upsert"); + }, + }; +} + +/** The same predicate, true only on its nth match. */ +function nth( + at: (event: DurableEvent) => boolean, + which: number, +): (event: DurableEvent) => boolean { + let seen = 0; + return (event) => { + if (!at(event)) { + return false; + } + seen += 1; + return seen === which; + }; +} + +/** Every managed checkout this history's placement records name. */ +function* placements(events: readonly DurableEvent[]): Operation { + const found: string[] = []; + for (const event of events) { + if (event.type !== "yield" || event.description.type !== REPL_REPOSITORY_PLACEMENT) { + continue; + } + if (event.result.status !== "ok") { + continue; + } + const record = parseReplPlacementRecord(event.result.value); + if (record !== undefined && record.outcome.ok) { + found.push(record.outcome.value.checkoutRoot); + } + } + return found; +} + +/** The directory this branch is checked out in, as Git itself lists them. */ +function* worktreeOn(fixture: Fixture, checkout: string, branch: string): Operation { + const git = gitSession(denoRepositoryHost(), fixture.root); + const outcome = yield* git.run(["worktree", "list", "--porcelain"], checkout); + let directory: string | undefined; + for (const line of outcome.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + directory = line.slice("worktree ".length).trim(); + } + if (line.trim() === `branch refs/heads/${branch}` && directory !== undefined) { + return directory; + } + } + throw new Error(`no worktree of ${checkout} is on ${branch}`); +} + +/** Commit one more file in a checkout, as a person at a shell would. */ +function* commitThere(checkout: string, name: string): Operation { + const git = gitSession(denoRepositoryHost(), checkout); + yield* writeTextFile(join(checkout, name), `${name}\n`); + yield* git.run(["add", name], checkout); + yield* git.run( + ["-c", "user.name=Shell", "-c", "user.email=shell@example.test", "commit", "-m", name], + checkout, + ); + const outcome = yield* git.run(["rev-parse", "HEAD"], checkout); + return outcome.stdout.trim(); +} + +/** The saved request record one retained event holds. */ +function savedRequest(event: DurableEvent | undefined) { + if (event?.type !== "yield" || event.result.status !== "ok") { + return undefined; + } + const value = event.result.value; + return value === undefined ? undefined : parseReplPullRequestRequestRecord(value); +} + describe("one entry's pull request", () => { it("V2a: opens a pull request for the head it published, once", function* () { const fixture = yield* useFixture(); @@ -385,6 +604,113 @@ describe("one entry's pull request", () => { expect(creations(forge)).toBe(1); expect(patches(forge)).toBe(0); }); + + it("V2c: a head this entry has not published yet refuses, until it publishes it", function* () { + const fixture = yield* useFixture(); + // Published at A, then committed again: the checkout is on B and this + // entry's only publication is of A. + const moved = (branch: string, ...extra: string[]): string => + document( + ` `, + " ", + + ' Ready for review.', + ' ', + ' ', + " ", + ' And more.', + ' ', + ' ', + ...extra, + ' Ready for review.', + " ", + ); + + const stale = store(fixture); + const refused = yield* run(fixture, moved("stale"), new InMemoryStream(), stale); + + expect(refused.refusal).toBeDefined(); + // The branch is published; this commit is not. Refused before a request + // exists and before anything was contacted. + expect(refused.refusal).toContain("different commit"); + expect(mutations(stale)).toEqual([]); + + // The same document with the entry publishing B explicitly. Now the last + // publication to that branch is the head the pull request names. + const fresh = store(fixture); + const opened = yield* run( + fixture, + moved("fresh", " "), + new InMemoryStream(), + fresh, + ); + + expect(opened.refusal).toBeUndefined(); + expect(creations(fresh)).toBe(1); + }); + + it("V2d: a synthetic publication answer authorizes nothing", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + // A handler that answers `Git.push` itself. The branch is never published, + // nothing is retained for it, and a pull request resting on it is resting + // on a value a handler made up. + const synthetic = yield* run(fixture, openingOn("review/synthetic"), stream, forge, { + *between(): Operation { + yield* Git.around({ + // deno-lint-ignore require-yield + *push(): Operation { + return SYNTHETIC_PUSH; + }, + }); + }, + }); + + expect(synthetic.refusal).toBeDefined(); + expect(synthetic.refusal).toContain("no acknowledged"); + expect(requests(synthetic.events).length).toBe(0); + expect(mutations(forge)).toEqual([]); + }); + + it("V7k: a read authored in the pull request's body keeps its own handoff", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const reviewed = readable(forge); + const stream = new InMemoryStream(); + // The second element updates what the first opened, and its body reads + // that pull request's reviews. The read runs inside an upsert's own + // invocation and is still its own retained read, under its own identity. + const nested = document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' Ready for review.', + ' ', + ` `, + " Ready for review.", + " ", + " ", + " ", + ); + const ran = yield* run(fixture, nested, stream, forge, { access: reviewed.access }); + + expect(ran.refusal).toBeUndefined(); + expect(ran.output).toContain("reviewer"); + const retained = reads(ran.events); + expect(retained.length).toBe(1); + const asked = question(retained[0]!); + expect(asked?.url).toBe(`${LOCATOR}/pull/1`); + expect(asked?.runId).toBe(OWNER); + // Two upserts asked for, and the read is neither of their invocations: a + // nested read's handoff is its own. + const sites = new Set(requests(ran.events).map((event) => savedRequest(event)?.site)); + expect(sites.size).toBe(2); + expect(sites.has(asked?.expansionId)).toBe(false); + }); }); /** @@ -502,6 +828,29 @@ function replaying(prefix: readonly DurableEvent[]): InMemoryStream { ); } +/** The same history, with every saved request's recorded origin replaced. */ +function* retargeted(events: readonly DurableEvent[], origin: string): Operation { + const rewritten: DurableEvent[] = []; + for (const event of events) { + const record = isRequest(event) ? savedRequest(event) : undefined; + if (record === undefined) { + rewritten.push(event); + continue; + } + rewritten.push({ + ...event, + result: { + status: "ok", + value: replPullRequestRequestJson({ + ...record, + resolved: { ...record.resolved, origin }, + }), + }, + }); + } + return rewritten; +} + /** A closed result a handler can build and offer as a completion. */ const SYNTHETIC = Object.freeze({ repository: Object.freeze({ @@ -536,13 +885,30 @@ describe("one entry's pull request, interrupted", () => { expect(upserts(prefix).length).toBe(0); expect(mutations(forge)).toEqual([]); + // What that record holds is the whole question, complete enough to ask the + // service without reading anything again. + const published = savedRequest(requests(prefix)[0]); + expect(published?.resolved.inputs.title).toBe("Prepare notes"); + expect(published?.resolved.inputs.body).toBe("Ready for review."); + expect(published?.resolved.inputs.draft).toBe(false); + expect(published?.resolved.inputs.number).toBe(null); + expect(published?.resolved.inputs.headBranch).toBe(BRANCH); + expect(published?.resolved.inputs.baseBranch).toBe("main"); + expect(published?.resolved.inputs.headSha).toMatch(/^[0-9a-f]{40}$/); + // Now the checkout it published from is gone. A pending request needs none // of it: the head, the base and the body are in the record. yield* rm(join(fixture.managed), { recursive: true, force: true }); - const resumed = yield* run(fixture, opening(), replaying(prefix), forge); + const watched = watching(fixture); + const resumed = yield* run(fixture, opening(), replaying(prefix), forge, { + host: watched.host, + }); expect(resumed.refusal).toBeUndefined(); expect(creations(forge)).toBe(1); + // And it read no checkout to do it: reconciling a saved request is a + // service call over parsed facts. + expect(watched.ran).toEqual([]); }); it("V3b: a remote completion whose outcome was never acknowledged is adopted", function* () { @@ -578,7 +944,7 @@ describe("one entry's pull request, interrupted", () => { const inner = new InMemoryStream(); const attempt = yield* run( fixture, - opening(" "), + opening(' continued'), refusing(inner, isRequest), forge, ); @@ -587,15 +953,30 @@ describe("one entry's pull request, interrupted", () => { const events = yield* inner.readAll(); expect(requests(events).length).toBe(0); expect(upserts(events).length).toBe(0); - // Nothing was asked of the Git host, and the sentinel after it never ran. + // Nothing was asked of the Git host. expect(mutations(forge)).toEqual([]); + // The write after it never ran: a first persistence failure fences what + // follows rather than letting the run continue past a position the journal + // does not hold. + const checkout = (yield* placements(events))[0]; + expect(checkout).toBeDefined(); + const worktree = yield* worktreeOn(fixture, checkout ?? "", BRANCH); + expect(yield* exists(join(worktree, "after.md"))).toBe(false); + // And nothing compensated for it: no coroutine was closed, so a + // continuation replays this position rather than stepping over it. + expect(events.filter((event) => event.type === "close")).toEqual([]); }); it("V8b: a refused outcome append binds nothing and leaves the completion to adopt", function* () { const fixture = yield* useFixture(); const forge = store(fixture); const inner = new InMemoryStream(); - const attempt = yield* run(fixture, opening(), refusing(inner, isUpsert), forge); + const attempt = yield* run( + fixture, + opening(' continued'), + refusing(inner, isUpsert), + forge, + ); expect(attempt.refusal).toBeDefined(); const events = yield* inner.readAll(); @@ -603,11 +984,161 @@ describe("one entry's pull request, interrupted", () => { // happened: that is the window reconciliation exists to close. expect(requests(events).length).toBe(1); expect(upserts(events).length).toBe(0); + // The write after it never ran, and nothing was closed to compensate. + const checkout = (yield* placements(events))[0]; + expect(checkout).toBeDefined(); + expect( + yield* exists(join(yield* worktreeOn(fixture, checkout ?? "", BRANCH), "after.md")), + ).toBe(false); + expect(events.filter((event) => event.type === "close")).toEqual([]); + + // A later attempt over that history adopts the completion, and reaches the + // write that was fenced. + const resumed = yield* run( + fixture, + opening(' continued'), + replaying(events), + forge, + ); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + expect( + yield* exists(join(yield* worktreeOn(fixture, checkout ?? "", BRANCH), "after.md")), + ).toBe(true); + }); + + it("V3c: a numbered update whose outcome was never acknowledged is adopted", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + // One document that opens a pull request and then brings that number to a + // new title. The cut holds the *second* reconciliation's append, so the + // update happened at the host and this history does not say so. + const source = document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' Ready for review.', + ' Ready for review.', + " ", + " ", + ); + + const prefix = yield* cutAt( + fixture, + source, + holdingAppend(inner, nth(isUpsert, 2), reached), + forge, + reached, + ); + expect(requests(prefix).length).toBe(2); + expect(upserts(prefix).length).toBe(1); + expect(patches(forge)).toBe(1); + + // Resuming observes the pull request already says what the update asked + // for, records that, and patches nothing a second time. + const resumed = yield* run(fixture, source, replaying(prefix), forge); + expect(resumed.refusal).toBeUndefined(); + expect(patches(forge)).toBe(1); + expect(creations(forge)).toBe(1); + expect(upserts(resumed.events).length).toBe(2); + }); + + it("V3d: a cut before the request is acknowledged resolves today's commit", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + + // Cut with the request append in flight: the publication of A is history + // and the pull request asked for nothing yet. + const prefix = yield* cutAt( + fixture, + opening(), + holdingAppend(inner, isRequest, reached), + forge, + reached, + ); + expect(requests(prefix).length).toBe(0); + expect(mutations(forge)).toEqual([]); + + // Somebody advances the branch while the entry is away. Nothing was + // recorded for the pull request, so the continuation resolves it now — at + // B, which this entry never published. + const checkout = (yield* placements(prefix))[0]; + expect(checkout).toBeDefined(); + const worktree = yield* worktreeOn(fixture, checkout ?? "", BRANCH); + yield* commitThere(worktree, "later.md"); + + const resumed = yield* run(fixture, opening(), replaying(prefix), forge); + expect(resumed.refusal).toBeDefined(); + expect(resumed.refusal).toContain("different commit"); + // No request was invented for the commit it published an hour ago, and + // nothing was contacted. + expect(requests(resumed.events).length).toBe(0); + expect(mutations(forge)).toEqual([]); + }); + + it("V4b: an unfinished prefix restores the upsert with nothing native or remote", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + + // A genuine unfinished history: the pull request is open and acknowledged, + // and the entry is standing at a question it has not answered. + const source = opening(QUESTION); + const prefix = yield* cutAt(fixture, source, inner, forge, reached, { atQuestion: reached }); + expect(requests(prefix).length).toBe(1); + expect(upserts(prefix).length).toBe(1); + expect(creations(forge)).toBe(1); + expect( + prefix.filter((event) => event.type === "close" && event.coroutineId === "root"), + ).toEqual([]); + + // Now take away everything a fresh upsert would need: the managed root is + // gone, the transport fails if it is asked anything, and reading a + // credential fails too. + yield* rm(fixture.managed, { recursive: true, force: true }); + const watched = watching(fixture); + const resumed = yield* run(fixture, source, replaying(prefix), forge, { + answer: { choice: "continue" }, + access: unreachableAccess(), + host: watched.host, + }); - // A later attempt over that history adopts the completion. - const resumed = yield* run(fixture, opening(), replaying(events), forge); expect(resumed.refusal).toBeUndefined(); + expect(resumed.output).toContain("1"); + // The completed upsert came back from its own receipt: no `git`, no + // provider, no credential, no service. + expect(watched.ran).toEqual([]); + expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); + expect(upserts(resumed.events).length).toBe(1); + }); + + it("V6d: a saved request whose recorded origin was edited is refused on readback", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + const first = yield* run(fixture, opening(), stream, forge); + expect(first.refusal).toBeUndefined(); + + // The same history with one letter of its recorded origin changed. The + // name covers the question rather than what routing resolved, so the name + // still matches and nothing upstream of this notices. + const edited = yield* retargeted(yield* stream.readAll(), "https://github.com/octo/elsewhere"); + const resumed = yield* run(fixture, opening(), replaying(edited), forge); + + // Held to the fingerprint the Repository identity in the record carries, + // so a request edited to name another repository continues nothing. + expect(resumed.refusal).toBeDefined(); expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); }); }); @@ -650,50 +1181,76 @@ describe("one entry's pull request, composed", () => { it("V5b: after delegation the owned outcome binds, whatever an outer handler returns", function* () { const fixture = yield* useFixture(); - const forge = store(fixture); - const stream = new InMemoryStream(); - // Delegates, then rewrites the answer. The rewrite cannot bind another - // pull request: what binds is what this entry actually acknowledged. - const rewritten = yield* run(fixture, opening(), stream, forge, { - *between(): Operation { - yield* PullRequestOperations.around({ - *upsert([invocation], next): Operation { - yield* next(invocation); - return SYNTHETIC; - }, - }); - }, - }); - expect(rewritten.refusal).toBeUndefined(); - expect(rewritten.output).not.toContain("4242"); - expect(creations(forge)).toBe(1); - expect(requests(yield* stream.readAll()).length).toBe(1); + // Delegates, then rewrites the answer, at each of the two chains a + // document composes through. Neither rewrite can bind another pull + // request: what binds is what this entry actually acknowledged. + for (const chain of ["operations", "api"] as const) { + const forge = store(fixture); + const stream = new InMemoryStream(); + const rewritten = yield* run(fixture, openingOn(`rewritten/${chain}`), stream, forge, { + *between(): Operation { + if (chain === "operations") { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + yield* next(invocation); + return SYNTHETIC; + }, + }); + } else { + yield* PullRequestAPI.around({ + *upsert([pullRequest, options], next): Operation { + yield* next(pullRequest, options); + return SYNTHETIC; + }, + }); + } + }, + }); + expect(rewritten.refusal).toBeUndefined(); + expect(rewritten.output).not.toContain("4242"); + expect(creations(forge)).toBe(1); + expect(requests(yield* stream.readAll()).length).toBe(1); + } }); it("V5c: a swallowed delegated failure stays a failure", function* () { const fixture = yield* useFixture(); - const forge = store(fixture); - forge.fault = { on: "create", status: 503 }; - const stream = new InMemoryStream(); - const swallowed = yield* run(fixture, opening(), stream, forge, { - *between(): Operation { - yield* PullRequestOperations.around({ - *upsert([invocation], next): Operation { - try { - return yield* next(invocation); - } catch { - // Catching the delegated failure and answering with a success - // cannot rescue it. - return SYNTHETIC; - } - }, - }); - }, - }); - expect(swallowed.refusal).toBeDefined(); - expect(swallowed.output).not.toContain("4242"); + for (const chain of ["operations", "api"] as const) { + const forge = store(fixture); + forge.fault = { on: "create", status: 503 }; + const stream = new InMemoryStream(); + const swallowed = yield* run(fixture, openingOn(`swallowed/${chain}`), stream, forge, { + *between(): Operation { + if (chain === "operations") { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + try { + return yield* next(invocation); + } catch { + // Catching the delegated failure and answering with a + // success cannot rescue it. + return SYNTHETIC; + } + }, + }); + } else { + yield* PullRequestAPI.around({ + *upsert([pullRequest, options], next): Operation { + try { + return yield* next(pullRequest, options); + } catch { + return SYNTHETIC; + } + }, + }); + } + }, + }); + expect(swallowed.refusal).toBeDefined(); + expect(swallowed.output).not.toContain("4242"); + } }); it("V5d: one transform records both readings, and a saved request bypasses it", function* () { @@ -791,6 +1348,178 @@ describe("one entry's pull request, composed", () => { expect(requests(yield* failing.readAll()).length).toBe(0); expect(mutations(failingForge)).toEqual([]); }); + + it("V5f: an outer throw after success leaves the record standing", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + // Delegates, gets the owned answer, and then fails for its own reasons. + // That is an ordinary failure: the document stops, and what this entry + // acknowledged is history either way. + const thrown = yield* run(fixture, opening(), stream, forge, { + *between(): Operation { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + yield* next(invocation); + throw new Error("the handler above it failed"); + }, + }); + }, + }); + + expect(thrown.refusal).toBe("the handler above it failed"); + const events = yield* stream.readAll(); + expect(requests(events).length).toBe(1); + expect(upserts(events).length).toBe(1); + expect(creations(forge)).toBe(1); + + // And the pull request it opened is restored rather than opened again. + const resumed = yield* run(fixture, opening(), replaying(events), forge); + expect(resumed.refusal).toBeUndefined(); + expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); + }); + + it("V5g: a duplicate delegation performs no second owned call", function* () { + const fixture = yield* useFixture(); + + for (const chain of ["operations", "api"] as const) { + const forge = store(fixture); + const stream = new InMemoryStream(); + const twice = yield* run(fixture, openingOn(`duplicate/${chain}`), stream, forge, { + *between(): Operation { + if (chain === "operations") { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + yield* next(invocation); + return yield* next(invocation); + }, + }); + } else { + yield* PullRequestAPI.around({ + *upsert([pullRequest, options], next): Operation { + yield* next(pullRequest, options); + return yield* next(pullRequest, options); + }, + }); + } + }, + }); + + // Whatever the second dispatch did, it did not open or repoint a second + // pull request, and this entry holds one request and one outcome. + expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); + const events = yield* stream.readAll(); + expect(requests(events).length).toBe(1); + expect(upserts(events).length).toBe(1); + if (twice.refusal === undefined) { + expect(twice.output).toContain("1"); + } + } + }); +}); + +describe("one entry's pull request, dispatched directly", () => { + /** The journey, then an update of what it opened, then a read of it. */ + const twice = (...extra: string[]): string => + document( + ` `, + " ", + ' Ready for review.', + ' ', + ' ', + " ", + ' Ready for review.', + ' Ready for review.', + ...extra, + " ", + ); + + it("V9d: a read dispatched outside a read element is ordinary and retains nothing", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + let items: number | undefined; + + // Dispatched from inside the second upsert, where no read element is + // expanding. It reaches the same adapter and the same ceiling, answers + // from the service, and retains nothing: retention belongs to the authored + // read's own bridge. + const ran = yield* run(fixture, twice(), stream, forge, { + access: readable(forge).access, + *between(): Operation { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + if (invocation.pullRequest.number === 1) { + const answered = yield* PullRequestOperations.operations.read({ + url: `${LOCATOR}/pull/1`, + kind: "reviews", + provider: undefined, + }); + + items = answered.items.length; + } + return yield* next(invocation); + }, + }); + }, + }); + + expect(ran.refusal).toBeUndefined(); + expect(items).toBe(1); + expect(reads(ran.events).length).toBe(0); + expect(requests(ran.events).length).toBe(2); + }); + + it("V9e: an upsert dispatched outside its component's invocation is refused", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + let captured: PullRequestUpsertInvocation | undefined; + let refusedBy: string | undefined; + + // The same invocation the component made, re-dispatched from a position + // that is not a `` invocation. The entry's upsert lifecycle is + // that component's own invocation-local default, so there is nothing here + // to answer it and nothing is retained for it. + const ran = yield* run( + fixture, + twice(` `), + stream, + forge, + { + access: readable(forge).access, + *between(): Operation { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + captured = invocation; + return yield* next(invocation); + }, + *read([invocation], next): Operation { + if (captured !== undefined) { + try { + yield* PullRequestOperations.operations.upsert(captured); + } catch (error) { + refusedBy = error instanceof Error ? error.message : String(error); + } + } + return yield* next(invocation); + }, + }); + }, + }, + ); + + expect(ran.refusal).toBeUndefined(); + expect(refusedBy).toContain("no pull-request provider is installed"); + // Two upserts asked for by two elements, and no third retained for the + // dispatch that reached nothing. + expect(requests(ran.events).length).toBe(2); + expect(upserts(ran.events).length).toBe(2); + expect(creations(forge)).toBe(1); + expect(reads(ran.events).length).toBe(1); + }); }); describe("one entry's pull request, over a configured transport", () => { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 82ae21435..6752f85b5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -14525,8 +14525,10 @@ belongs to. | EPR3 | A pending request keeps what it asked for | A request acknowledged before any service contact is reconciled after the checkout it published from is gone, using its saved head, base, number, title, body and draft with zero native checkout reads; a remote creation and a numbered update each accepted before the outcome was recorded are adopted with zero repeated mutation; a cut taken before the request was acknowledged resolves live state instead and refuses conflicting publication evidence rather than recording the superseded one | | EPR4 | What a reopened entry reaches, and what it does not | A real unfinished prefix left at a second question restores the completed upsert and the completed reads; the upsert reaches no native, provider, configuration or credential path; the reads still pass today's provider matching, configured ceiling and target validation yet open no session and send nothing; removing the repository from the allowed list refuses a saved read without writing a new read record and without erasing the existing one; a URL or provider transformation before admission is what the record says was asked; a changed transformation after delegation reruns over the saved snapshot and may bind a different array; an answer a handler produced without reaching the admitted-read boundary is unretained and runs again | | EPR5 | The value a dispatch binds | The existing create, adopt, numbered update, no-op, conflict, ambiguity, unavailability and multi-call update behaviour is unchanged; at each of the two middleware chains one valid answer that never delegated binds the closed result contract and writes no request and no receipt; after delegation a rewritten return cannot bind another pull request and a swallowed delegated failure cannot become a success; an ordinary outer throw after a success keeps ordinary surrounding failure handling while a persistence failure stays fatal; one transforming delegation records both the original and the received question and a saved continuation bypasses it; a duplicate or stale delegation performs no second owned call; a child effect in the body settles before the parent's request exists and a body that refuses contacts nothing | -| EPR6 | Coordinated admission | One reader admits mixed publication and pull-request successes and the three existing definitive failure names across two interleaved durable children plus a trailing pending request, because a failed envelope names no kind and the preceding unconsumed request in the same coroutine with that request's own expected worker name is what classifies it; it refuses an unknown version, a missing or extra member, a misnamed request, a foreign owner or site, an unplaced or unauthorized saved request, an orphan or duplicate outcome and a wrong kind, fingerprint, natural key or receipt; read admission refuses a foreign `input.runId` independently of the owner-omitting name, including on a failed read's description; both object-id formats round-trip and one vector per algorithm — the canonical-JSON request digest and the length-prefixed UTF-16 read fingerprint — carries a null and text outside ASCII | -| EPR7 | One read, one identity | A denied target writes no read record and opens no session; a multipage success is complete; a walk that cannot finish retains a failed read and binds no array; a wrong subject or check head is refused before any success is appended; two concurrent authored reads completing in reverse order each keep their own URL, collection, owner and invocation; a nested authored read cannot consume its parent's handoff and a duplicate or stale handoff runs no second read; the final result after any outer composition is validated before its items bind | +| EPR6 | Coordinated admission | One reader admits mixed publication and pull-request successes and the three existing definitive failure names across two interleaved durable children plus a trailing pending request, because a failed envelope names no kind and the preceding unconsumed request in the same coroutine with that request's own expected worker name is what classifies it; it refuses an unknown version, a missing or extra member, a misnamed request, a foreign owner or site, an unplaced or unauthorized saved request, an orphan or duplicate outcome and a wrong kind, fingerprint, natural key or receipt; read admission refuses a foreign `input.runId` independently of the owner-omitting name, including on a failed read's description, and refuses a saved read whose configuration is not the one its own question produces; + both object-id formats round-trip and one vector per algorithm — the canonical-JSON request digest and the length-prefixed UTF-16 read fingerprint — carries a null and text outside ASCII | +| EPR7 | One read, one identity | A denied target writes no read record and opens no session; a multipage success is complete; a walk that cannot finish retains a failed read and binds no array; a wrong subject or check head is refused before any success is appended; two concurrent authored reads completing in reverse order each keep their own URL, collection, owner and invocation; a read authored inside another element's own invocation keeps its own handoff — a read cannot enclose one, because these three forms are self-closing — and a duplicate or stale handoff runs no second read; + the final result after any outer composition is validated before its items bind | | EPR8 | Acknowledgement, not completion | A refused request append contacts nothing and fences the work after it; a refused outcome append binds no success, appends no compensating Close and leaves the remote completion for the next attempt to adopt; a refused read append binds no array and leaves the read safe to repeat; each first persistence failure is preserved; a cancelled blocked admitted read, a session still being acquired and a cleanup that throws after either a success or an expected failure each publish no invented outcome and join their cleanup | | EPR9 | Assembly and unsupported runtimes | The per-entry factory is inert and declares exactly this entry's publication, pull-request, evidence-read and issue components with one admission for each history it reads; exactly one core initialization happens per executing entry and a passive reading initializes nothing; the file work, selections, local Git, publication and issues of the same entry survive the combined assembly; the three reads are declared and installed for an entry holding no managed root and acquire no checkout, discovery, hold, Git session or authentication; a direct upsert acquires no retained identity and a direct read keeps ordinary provider behaviour; shadowing stays ordinary and a kept first-party registration gains no successor identity; a runtime that installs no entry capability reports an absent provider for the upsert and all three reads before any native or service work; the workflow profile's own pull request, reads and forks are unchanged | diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 540ca4d4a..a1d0e6274 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1584,7 +1584,20 @@ continue, even though the reviews were saved an hour ago, and the saved record stays in history rather than being erased by that refusal. An allowed completed read opens no authentication session and sends nothing. +The publication it rests on is one this entry made from the checkout it is +opening from. A `` this entry made from another of its worktrees +published that worktree's `review/notes`, which holds its own commit, so a pull +request opened here refuses rather than resting on it — and the refusal happens +before a credential is read or anything is contacted. + +A read interrupted before it was acknowledged simply runs again: nothing is +written down for it, which is safe in a way repeating a publication is not. A +read whose answer arrived but whose own session could not be closed records +nothing either, because what it did is in a state its outcome cannot be read +from. + What `` binds is evidence of what the upsert settled on, not + freshness: the repository, the provider's own identity for the pull request, its number, its URL, its open state and the head and base commits. Reviews, comments, checks and later edits are the separate reads above, which is why a From 1c1e68bd36726786b015c38fbcfac798cb8a57f0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 06:19:38 -0400 Subject: [PATCH 13/17] =?UTF-8?q?=E2=9C=85=20Keep=20a=20read's=20type=20wh?= =?UTF-8?q?ere=20its=20records=20are,=20so=20both=20portable=20runtimes=20?= =?UTF-8?q?read=20it=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-name vector and the journey's record inspection reached the durable read type through the runtime-named half, whose module graph reaches `node:sqlite`. Node tolerates that import and Bun has no such module, so two portable suites failed to load there rather than running — which is also why Bun reported 26 tests where Node reported 53. The type name is part of what a journal says, so it now lives with the read records in the neutral half and both profiles name that one. The vector itself moves to the native reads suite, where the fingerprint it pins is computed. F1 10/85, F2 7/71, F3 24/156, F4 18/107, F5 source 1/1 + build + compiled 1/1, F6 Node 52/52 and Bun 52/52, `git diff --check` clean; lint and check clean. --- packages/cli/tests/repl-pull-request.test.ts | 2 +- .../support/repl-pull-request-journey.ts | 2 +- .../composition/pull-request-read-records.ts | 11 +++++ .../composition/pull-request-operations.ts | 5 +-- .../retained-repository/pull-request-reads.ts | 2 +- packages/git/tests/pull-request-read.test.ts | 2 +- ...ined-repository-pull-request-reads.test.ts | 45 ++++++++++++++++++- ...ed-repository-pull-request-records.test.ts | 40 ----------------- .../retained-repository-pull-request.test.ts | 2 +- 9 files changed, 62 insertions(+), 49 deletions(-) diff --git a/packages/cli/tests/repl-pull-request.test.ts b/packages/cli/tests/repl-pull-request.test.ts index 92c0ea2c1..99bcef284 100644 --- a/packages/cli/tests/repl-pull-request.test.ts +++ b/packages/cli/tests/repl-pull-request.test.ts @@ -34,7 +34,7 @@ import { gitSession } from "../../git/src/deno/composition/git.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; import { REPL_REPOSITORY_PULL_REQUEST_REQUEST } from "../../git/src/retained-repository/pull-request-records.ts"; -import { PULL_REQUEST_READ } from "../../git/src/deno/composition/pull-request-operations.ts"; +import { PULL_REQUEST_READ } from "../../git/src/composition/pull-request-read-records.ts"; import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; import { selectReplEntryHost } from "../src/deno-repositories.ts"; diff --git a/packages/cli/tests/support/repl-pull-request-journey.ts b/packages/cli/tests/support/repl-pull-request-journey.ts index 37f6a0b33..ded2d3b9a 100644 --- a/packages/cli/tests/support/repl-pull-request-journey.ts +++ b/packages/cli/tests/support/repl-pull-request-journey.ts @@ -54,7 +54,7 @@ import { parseGitHostReconciliationRecord } from "../../../git/src/git-host/reco import { parseClosedPullRequestResult } from "../../../git/src/composition/pull-request-records.ts"; import { parsePullRequestReadResult } from "../../../git/src/composition/pull-request-read-records.ts"; import type { PullRequestReadResult, PullRequestResult } from "@executablemd/git/api"; -import { PULL_REQUEST_READ } from "../../../git/src/deno/composition/pull-request-operations.ts"; +import { PULL_REQUEST_READ } from "../../../git/src/composition/pull-request-read-records.ts"; import type { GitHubHttpResponse } from "../../../git/src/deno/composition/github.ts"; import { allocate, showing, showingAcross } from "./pty.ts"; import type { Pty } from "./pty.ts"; diff --git a/packages/git/src/composition/pull-request-read-records.ts b/packages/git/src/composition/pull-request-read-records.ts index fef6425e5..028356624 100644 --- a/packages/git/src/composition/pull-request-read-records.ts +++ b/packages/git/src/composition/pull-request-read-records.ts @@ -331,8 +331,19 @@ function evidenceJson(item: PullRequestEvidence): JsonObject { return record; } +/** + * The durable effect type one evidence read is retained under. + * + * Here with the records rather than beside the describing code: the type name + * is part of what a journal says, so a reader that has the file needs it + * without reaching for a runtime-named module. Both retained profiles name the + * same one, which is what lets a fork read either one's history. + */ +export const PULL_REQUEST_READ = "pull_request_read"; + /** * Whether every check in this envelope is about the head it recorded. + * * A checks envelope says which commit it was read at, and that head is the * whole of what it reports: an item about a different commit makes the envelope diff --git a/packages/git/src/deno/composition/pull-request-operations.ts b/packages/git/src/deno/composition/pull-request-operations.ts index c8595cbc5..265e982f4 100644 --- a/packages/git/src/deno/composition/pull-request-operations.ts +++ b/packages/git/src/deno/composition/pull-request-operations.ts @@ -52,10 +52,12 @@ import { } from "../../composition/pull-request-operations.ts"; import { parsePullRequestReadResult, + PULL_REQUEST_READ, pullRequestReadEnvelopeJson, pullRequestReadRequestJson, readRequest, } from "../../composition/pull-request-read-records.ts"; + import type { PullRequestReadKind, PullRequestReadRequest, @@ -66,9 +68,6 @@ import { parseJsonValue } from "@executablemd/workflow"; import { getWorkflowRun } from "@executablemd/workflow"; 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: "", diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index 4e79a3145..e7262bf86 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -103,6 +103,7 @@ import type { } from "../../composition/pull-request-read-execution.ts"; import { parsePullRequestReadResult, + PULL_REQUEST_READ, pullRequestReadEnvelopeJson, pullRequestReadHeadsAgree, readRequest, @@ -123,7 +124,6 @@ import type { import type { FirstPartyDocumentation, ReturnsSchema } from "@executablemd/core"; import { describePullRequestRead, - PULL_REQUEST_READ, pullRequestReadElement, pullRequestReadName, retainedPullRequestRead, diff --git a/packages/git/tests/pull-request-read.test.ts b/packages/git/tests/pull-request-read.test.ts index 9889ae97b..ee5ff2dec 100644 --- a/packages/git/tests/pull-request-read.test.ts +++ b/packages/git/tests/pull-request-read.test.ts @@ -36,7 +36,7 @@ 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 { PULL_REQUEST_READ } from "../src/composition/pull-request-read-records.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/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts index 32ba61691..45950dd37 100644 --- a/packages/git/tests/retained-repository-pull-request-reads.test.ts +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -57,7 +57,8 @@ import type { CheckEvidence } from "../src/composition/pull-request-read-records import { PullRequestReadExecution } from "../src/composition/pull-request-read-execution.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; -import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; +import { PULL_REQUEST_READ, readRequest } from "../src/composition/pull-request-read-records.ts"; +import { pullRequestReadName } from "../src/deno/composition/pull-request-operations.ts"; const OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","1"]'; const OTHER_OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","2"]'; @@ -68,6 +69,9 @@ const SUBJECT_URL = `${SUBJECT_REPO}/pull/7`; const OTHER_URL = `${SUBJECT_REPO}/pull/9`; const HEAD = "a".repeat(40); +/** The invocation a name vector states, since a name carries one. */ +const SITE = "expansion-9c1"; + const PULL = "/repos/octo/project/pulls/7"; const REVIEWS = "/repos/octo/project/pulls/7/reviews"; const OTHER_PULL = "/repos/octo/project/pulls/9"; @@ -1124,6 +1128,45 @@ describe("one entry's evidence reads", () => { expect(reviewCalls(host)).toBe(1); }); + it("V6f: one read-name vector keeps the length-prefixed fingerprint, odd text and all", function* () { + // The other naming algorithm this feature touches, and deliberately not + // the canonical-JSON digest above: an evidence read is named the way every + // other Git operation in this package is named, so a journal a fork reads + // keeps answering. + // + // The vector is the first 16 hex digits of SHA-256 over the UTF-16 + // big-endian code units of `4:read38:-6:checks` — a null discriminator + // is "-" rather than a length, and the 38 counts code units, so the "ö" + // counts once. + const named = pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", undefined, OWNER, SITE), + ); + expect(named.configuration).toBe("6e1959358a175d22"); + expect(named.name).toBe(`${SITE}:6e1959358a175d22`); + + // The discriminator is part of the question, so naming one changes it. + expect( + pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", "github", OWNER, SITE), + ).configuration, + ).not.toBe(named.configuration); + + // The owner is deliberately not: a fork is a different run asking the same + // question at the same position, and a name carrying the run would make + // every inherited read unforkable. + expect( + pullRequestReadName( + readRequest( + "https://github.com/octo/pröject/pull/7", + "checks", + undefined, + OTHER_OWNER, + SITE, + ), + ), + ).toEqual(named); + }); + it("V9b: the entry's components are ordinary registrations over a kept one", function* () { const fixture = yield* useFixture(); diff --git a/packages/git/tests/retained-repository-pull-request-records.test.ts b/packages/git/tests/retained-repository-pull-request-records.test.ts index 38be801cc..3425989ef 100644 --- a/packages/git/tests/retained-repository-pull-request-records.test.ts +++ b/packages/git/tests/retained-repository-pull-request-records.test.ts @@ -36,7 +36,6 @@ import { type GitPushInputs, } from "../src/composition/git-push-records.ts"; import { locatorFingerprint } from "../src/deno/composition/locator.ts"; -import { pullRequestReadName } from "../src/deno/composition/pull-request-operations.ts"; import { createReplPublicationMemory, type RankedReplPublication, @@ -874,45 +873,6 @@ describe("Tier RPR — admitting a history that published and then opened", () = expect(yield* refusalOf([...prefix, unrelated])).toBeUndefined(); }); - it("RPR24: one read-name vector keeps the length-prefixed fingerprint, odd text and all", function* () { - // The other naming algorithm this feature touches, and deliberately not - // the canonical-JSON digest above: an evidence read is named the way every - // other Git operation in this package is named, so a journal a fork reads - // keeps answering. - // - // The vector is the first 16 hex digits of SHA-256 over the UTF-16 - // big-endian code units of `4:read38:-6:checks` — a null discriminator - // is "-" rather than a length, and the 38 counts code units, so the "ö" - // counts once. - const named = pullRequestReadName( - readRequest("https://github.com/octo/pröject/pull/7", "checks", undefined, OWNER, SITE), - ); - expect(named.configuration).toBe("6e1959358a175d22"); - expect(named.name).toBe(`${SITE}:6e1959358a175d22`); - - // The discriminator is part of the question, so naming one changes it. - expect( - pullRequestReadName( - readRequest("https://github.com/octo/pröject/pull/7", "checks", "github", OWNER, SITE), - ).configuration, - ).not.toBe(named.configuration); - - // The owner is deliberately not: a fork is a different run asking the same - // question at the same position, and a name carrying the run would make - // every inherited read unforkable. - expect( - pullRequestReadName( - readRequest( - "https://github.com/octo/pröject/pull/7", - "checks", - undefined, - OTHER_OWNER, - SITE, - ), - ), - ).toEqual(named); - }); - it("RPR25: a pull request resolved in another authenticated checkout is refused", function* () { // Both requests name a placement this entry holds, which is the most this // feature's placement question can establish. What it cannot establish is diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index 3e132c6fc..f0f37cdfa 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -81,7 +81,7 @@ import { REPL_REPOSITORY_PLACEMENT, } from "../src/retained-repository/placement-records.ts"; import { parsePullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; -import { PULL_REQUEST_READ } from "../src/deno/composition/pull-request-operations.ts"; +import { PULL_REQUEST_READ } from "../src/composition/pull-request-read-records.ts"; import { gitOutput } from "./support/git-remotes.ts"; import { useGitHubServer } from "./support/github.ts"; import { From c94355361116cc4bf4ab6c1ff34d2ee36c33b1ef Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 18 Oct 2026 06:42:08 -0400 Subject: [PATCH 14/17] =?UTF-8?q?=E2=9C=85=20Report=20a=20read's=20outcome?= =?UTF-8?q?=20instead=20of=20raising=20it=20past=20its=20own=20cleanup=20(?= =?UTF-8?q?#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admitted read's attempt boundary decided which outcome it could publish by comparing the failure that left its scope against the one the attempt recorded raising. A body and its cleanup can raise the same Error object, and a teardown error wins over a body error, so that comparison accepted a scope which had failed to close and published an ordinary failed-read record for it. The attempt now reports its own refusal rather than raising it: a normal return from the scope is what proves the attempt and everything it opened are closed, and nothing about the exception a failure arrives as takes part in the decision. A throw reaching the boundary is the boundary failing — a cleanup that threw, or any failure nobody there authored, including one indistinguishable from the read's own — and it publishes nothing and activates the run's existing fail-stop state. The expected-failure encoding, the interruption behaviour and every other cleanup case are unchanged. V8f carries the case, with the Planner's reproducer shape: one Error raised by both the read and its registered cleanup, and no appended outcome. Without the correction V8f fails and V8a–V8e still pass (breakit7.log). --- architecture.md | 14 ++-- .../retained-repository/pull-request-reads.ts | 76 +++++++++++-------- ...ined-repository-pull-request-reads.test.ts | 48 ++++++++++++ 3 files changed, 101 insertions(+), 37 deletions(-) diff --git a/architecture.md b/architecture.md index 586950aad..bbe3df65a 100644 --- a/architecture.md +++ b/architecture.md @@ -7320,12 +7320,14 @@ A read is acknowledged only once the attempt that produced it has finished. The transport's own session and page walk belong to a scope of the read's own, and that scope is closed and joined before either outcome is published — otherwise a reopen could restore a read whose first run was still unwinding when the journal -said it was done. Which outcome may be published is read from that attempt's own -record of what it raised, by object identity: an expected read failure is this -read's answer and is retained as one, while a cleanup that threw and any failure -nobody there authored publish nothing at all and activate the run's existing -fail-stop state. An interruption publishes nothing, which leaves the read -repeatable — safe in the way repeating a mutation is not. +said it was done. The attempt reports its own outcome rather than raising it, +so a scope that returns at all is a scope that closed, and that return is what +admits an outcome to publication: an expected read failure is this read's answer and is +retained as one, while a throw from that boundary — a cleanup that threw, or any +failure nobody there authored, including one indistinguishable from the read's +own — publishes nothing at all and activates the run's existing fail-stop state. +An interruption publishes nothing, which leaves the read repeatable — safe in +the way repeating a mutation is not. What may be acknowledged is also held to the whole evidence contract before it is published, again when it is restored, and again after the outer dispatch has diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index e7262bf86..b37cfc43e 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -328,10 +328,9 @@ function* retain( transport: () => Operation, ): Operation { const description = yield* describePullRequestRead(request); - // One settlement per attempt, reachable only from the two halves of this - // operation. Nothing a provider, a handler or a document holds can put an - // answer in it. - const settlement: ReadSettlement = {}; + // One attempt per read, reachable only from the two halves of this operation. + // Nothing a provider, a handler or a document holds can put an answer in it. + const attempt: ReadAttempt = {}; const stored: unknown = yield createDurableOperation( description, function* (): Operation { @@ -339,21 +338,31 @@ function* retain( const answered = yield* transport(); return validRead(answered, request.kind, element).envelope; } catch (error) { - // What this attempt itself produced, held by object identity so the - // coordinator can tell it from whatever the unwinding raises. - settlement.failure = error; - throw error; + // Reported rather than raised. An expected read failure is this read's + // own answer, and raising it would carry it out through the same + // channel that reports a scope which failed to close — where no + // inspection of the error could tell one of those from the other. A + // refusal has no envelope, and the value it returns is never read. + attempt.refusal = { error }; + return null; } }, - { coordinator: readCoordinator(settlement) }, + { coordinator: readCoordinator(attempt) }, ); return restoredRead(stored, request.kind, element); } -/** What one read's own attempt produced, as only this operation can see it. */ -interface ReadSettlement { - /** The failure the admitted read itself raised, by object identity. */ - failure?: unknown; +/** + * What one read's own attempt reported, as only this operation can see it. + * + * Present and holding the failure when the admitted read raised instead of + * answering, and absent otherwise — boxed, so a read that raised something + * undefined still reports a refusal. It is written before the attempt's scope + * begins to close and read only after that scope has closed, which is what + * keeps it an account of the read rather than of the unwinding. + */ +interface ReadAttempt { + refusal?: { readonly error: unknown }; } /** @@ -366,17 +375,18 @@ interface ReadSettlement { * unwinding when the journal said it was done. So the attempt runs in a scope * of its own and the scope is closed before either outcome is published. * - * Which outcome may be published is read from this attempt's own record of what - * it raised, by object identity — never from the error's name or class, which - * anything in the dispatch above could imitate. An expected read failure is - * this read's answer and is retained as one. A cleanup that threw while the - * attempt unwound, or any failure nobody here authored, is the boundary failing - * rather than a finding: it publishes nothing and activates the run's existing - * fail-stop state, because a live operation that appended nothing would - * otherwise leave the next one to append at this one's position. An - * interruption publishes nothing at all. + * The attempt reports its own outcome rather than raising it, so a scope that + * returns at all is a scope that closed, and that return is what admits an + * outcome to publication. Nothing about the exception a failure arrives as + * takes part in the decision. An expected read failure is this read's answer + * and is retained as one; a throw reaching here is the boundary itself failing + * — a cleanup that threw while the attempt unwound, or any failure nobody here + * authored, including one indistinguishable from the read's own — and it + * publishes nothing and activates the run's existing fail-stop state, because a + * live operation that appended nothing would otherwise leave the next one to + * append at this one's position. An interruption publishes nothing at all. */ -function readCoordinator(settlement: ReadSettlement): LiveDurableOperationCoordinator { +function readCoordinator(attempt: ReadAttempt): LiveDurableOperationCoordinator { return { *run( execute: () => Operation, @@ -385,17 +395,21 @@ function readCoordinator(settlement: ReadSettlement): LiveDurableOperationCoordi _journalProvenance: JournalProvenance | undefined, _abandoned: AbandonedRetainedHistory, ): Operation { - let result: DurableResult; + let answered: T; try { - result = { status: "ok", value: yield* scoped(execute) }; + answered = yield* scoped(execute); } catch (error) { - if (error !== settlement.failure) { - throw activateFailure( - error instanceof Error ? error : new Error("the evidence read boundary failed"), - ); - } - result = { status: "err", error: serializeError(errorOf(error)) }; + throw activateFailure( + error instanceof Error ? error : new Error("the evidence read boundary failed"), + ); } + // The scope returned, so the attempt and everything it opened are closed, + // and what the attempt reported is now this read's outcome. + const refusal = attempt.refusal; + const result: DurableResult = + refusal === undefined + ? { status: "ok", value: answered } + : { status: "err", error: serializeError(errorOf(refusal.error)) }; yield* publish(result); return result; }, diff --git a/packages/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts index 45950dd37..e404f5221 100644 --- a/packages/git/tests/retained-repository-pull-request-reads.test.ts +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -364,6 +364,32 @@ function answering(answer: () => void): Operation { }); } +/** + * Answer by raising this error, and raise the very same object from cleanup. + * + * Both failures are one object, so nothing about the exception that arrives at + * the attempt's boundary separates the read's own refusal from a scope that + * could not close. + */ +function sharing(failure: Error): Operation { + return PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + function* (): Operation { + yield* resource(function* (provide) { + yield* ensure(function* (): Operation { + throw failure; + }); + yield* provide(null); + }); + throw failure; + }, + ); + }, + }); +} + /** Wait for this signal, and fail the row rather than hang if it never comes. */ function* within(signal: Operation, sentence: string): Operation { yield* race([ @@ -1063,6 +1089,28 @@ describe("one entry's evidence reads", () => { expect(failing.events.filter((event) => event.type === "close")).toEqual([]); }); + it("V8f: a cleanup that throws the read's own error publishes nothing", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + const failure = new Error("the collection could not be read"); + const failing = yield* run( + fixture, + `\n`, + stream, + host, + { between: () => sharing(failure) }, + ); + + expect(failing.refusal).toBeDefined(); + // The read's refusal and its cleanup's are the same object, so only what + // the attempt reported says which happened here — and it reports a scope + // that did not close. A comparison of errors would have retained a failed + // read for a position whose cleanup is unaccounted for. + expect(reads(failing.events).length).toBe(0); + expect(failing.events.filter((event) => event.type === "close")).toEqual([]); + }); + it("V6c: a failed read saved under another owner is refused before replay", function* () { const fixture = yield* useFixture(); // A walk that cannot finish, so what the history holds at that position is From 79a9e6a2c1e282c85bcad3a1f1e3aadb536ab6e2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 09:16:05 -0400 Subject: [PATCH 15/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Share=20pull=20reque?= =?UTF-8?q?st=20bodies=20across=20execution=20hosts=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 22 +- .../src/composition/components/PullRequest.ts | 24 +- .../components/PullRequestReads.ts | 37 +- .../git/src/composition/invocation-binding.ts | 17 +- .../src/deno/retained-repository/checkout.ts | 110 ++++++ .../src/deno/retained-repository/provider.ts | 76 ++-- .../retained-repository/pull-request-reads.ts | 154 ++------ .../deno/retained-repository/pull-request.ts | 354 +++--------------- .../git/src/deno/retained-repository/push.ts | 229 ++--------- .../src/deno/retained-repository/requests.ts | 47 +++ .../composition-component-binding.test.ts | 121 +++++- packages/git/tests/module-partition.test.ts | 2 + .../retained-repository-pull-request.test.ts | 70 +++- specs/repl-spec.md | 4 + 14 files changed, 575 insertions(+), 692 deletions(-) create mode 100644 packages/git/src/deno/retained-repository/checkout.ts create mode 100644 packages/git/src/deno/retained-repository/requests.ts diff --git a/architecture.md b/architecture.md index bbe3df65a..3bcec9dd8 100644 --- a/architecture.md +++ b/architecture.md @@ -7238,9 +7238,10 @@ receipt this build cannot read are fatal unreadable history. `` is a statement about a branch a Git host already holds, and this entry never makes one exist. So before anything is written down it has to show -that *this entry* published the head the pull request names. An ordinary run -proves that by scanning its own journal; an entry cannot, because its segment is -reopened hours later beside other entries and what has to be proven is narrower: +that *this entry* published the head the pull request names. A workflow uses its +own journal and an ordinary run uses its provider's private publication state. +An entry's segment is reopened hours later beside other entries, and what has +to be proven is narrower: this entry asked for that publication, its own request was answered by its own acknowledged receipt, and this execution has actually reached it. A bare receipt, a local `` result, a handler's synthetic `Git.push` answer @@ -7268,6 +7269,15 @@ revisit two concurrent children in either order, so arrival order is not that order and ordering by it would let a replayed history authorize a superseded head. +The shared `` body receives the upsert operation from the host's +policy. Ordinary registrations use the public feature Api; an entry declares +that same body through the common invocation binding, with its owner and site +kept inside the private policy. The three read components use the same +construction. There is one authored body and one metadata definition per name. +Push and PR policies share checkout routing, origin authentication, managed-slot +ownership and the request-append boundary; their codecs and reconciliation stay +with their respective protocols. + Everything after that follows publication's shape. The invocation is authenticated, then the body is rendered completely — before the Repository is observed and before this upsert has a position of its own, so a child durable @@ -7289,8 +7299,10 @@ document and nothing more — no request, no receipt, and none of the stability retained result has — and it may bind only the closed result contract. Once the owned terminal has run, its outcome is held privately and that outcome binds or fails: a rewritten return cannot bind a different pull request, and a delegated -failure a handler caught and answered past stays a failure. An outer throw after -a success is ordinary surrounding failure; a persistence failure stays fatal. +failure a handler caught and answered past stays a failure. This includes +selection authentication: its refusal belongs to the accepted delegation even +when no request was resolved. An outer throw after a success is ordinary +surrounding failure; a persistence failure stays fatal. #### The evidence reads an entry retains, and the ceiling they stay under diff --git a/packages/git/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts index e5ad6110a..2257ba552 100644 --- a/packages/git/src/composition/components/PullRequest.ts +++ b/packages/git/src/composition/components/PullRequest.ts @@ -34,13 +34,16 @@ import { content, documented, hasContent } from "@executablemd/core"; import type { ComponentRegistration, FirstPartyDocumentation, - FunctionComponent, PropsSchema, ReturnsSchema, } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { boundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { selectedRepository } from "../context.ts"; import { PullRequestAdmissionError } from "../errors.ts"; @@ -111,8 +114,9 @@ export const documentation: FirstPartyDocumentation = { context: "The pull request's body.", }; -const body: FunctionComponent = function* PullRequest( +const body: ComponentBody = function* PullRequest( props: Record, + upsert: PullRequestOperationsApi["upsert"], ): Operation { // Before a Repository is observed, a checkout selected or retained Push // evidence read: a body that never finished rendering must not become one @@ -129,7 +133,7 @@ const body: FunctionComponent = function* PullRequest( ); } - const result = yield* PullRequestOperations.operations.upsert({ + const result = yield* upsert({ pullRequest: { // Absence is `null` from here on: the durable request is JSON, and a // member that is sometimes missing would be a second shape. @@ -161,4 +165,16 @@ const METADATA = { }; /** An ordinary registered default, over whichever provider the host installed. */ -export const pullRequestRegistration: ComponentRegistration = { ...METADATA, fn: body }; +export const pullRequestRegistration: ComponentRegistration = { + ...METADATA, + fn: (props) => body(props, PullRequestOperations.operations.upsert), +}; + +export function pullRequestDeclaration( + bind: BindOperations, +): IdentityComponent { + return { + ...METADATA, + factory: (claim: IdentityClaimant) => boundComponent(claim, bind, body), + }; +} diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 029dd2e31..2473041d6 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -23,6 +23,7 @@ import type { PropsSchema, ReturnsSchema, } from "@executablemd/core"; +import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; @@ -31,6 +32,9 @@ import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; import { PullRequestOperations } from "../pull-request-operations.ts"; +import type { PullRequestOperationsApi } from "../pull-request-operations.ts"; +import { boundComponent } from "../invocation-binding.ts"; +import type { BindOperations, ComponentBody } from "../invocation-binding.ts"; export const REVIEWS_ELEMENT = ""; export const COMMENTS_ELEMENT = ""; @@ -161,8 +165,14 @@ export const checksReturns: ReturnsSchema = array({ }), ], }); -function read(kind: PullRequestReadKind, element: string): FunctionComponent { - return function* PullRequestRead(props: Record): Operation { +function read( + kind: PullRequestReadKind, + element: string, +): ComponentBody { + return function* PullRequestRead( + props: Record, + read: PullRequestOperationsApi["read"], + ): Operation { // Canonicalized before any provider sees it, so the component, the durable // request and every adapter read one answer. const url = canonicalPullRequestUrl(props.url); @@ -184,7 +194,7 @@ function read(kind: PullRequestReadKind, element: string): FunctionComponent { ); } - const result = yield* PullRequestOperations.operations.read({ url, kind, provider }); + const result = yield* read({ url, kind, provider }); if (result.kind !== kind) { throw new PullRequestReadError( "protocol", @@ -279,7 +289,26 @@ function metadata(kind: PullRequestReadKind) { /** An ordinary registered default, over whichever provider the host installed. */ export function pullRequestReadRegistration(kind: PullRequestReadKind): ComponentRegistration { const element = ELEMENTS[kind]; - return { ...metadata(kind), fn: formDispatcher(declare(read(kind, element), element)) }; + const body = read(kind, element); + return { + ...metadata(kind), + fn: formDispatcher( + declare((props) => body(props, PullRequestOperations.operations.read), element), + ), + }; +} + +export function pullRequestReadDeclaration( + kind: PullRequestReadKind, + bind: BindOperations, +): IdentityComponent { + const element = ELEMENTS[kind]; + const body = read(kind, element); + return { + ...metadata(kind), + factory: (claim: IdentityClaimant) => + formDispatcher(declare(boundComponent(claim, bind, body), element)), + }; } export const PULL_REQUEST_READ_KINDS: readonly PullRequestReadKind[] = Object.freeze([ diff --git a/packages/git/src/composition/invocation-binding.ts b/packages/git/src/composition/invocation-binding.ts index db62b7a1d..fe59b0cc6 100644 --- a/packages/git/src/composition/invocation-binding.ts +++ b/packages/git/src/composition/invocation-binding.ts @@ -1,19 +1,12 @@ /** * How a declaring execution attaches its own policy to a shared component body. * - * `` and `` are the two operations a REPL entry retains, so - * their bodies take the operation they ask as an argument: an ordinary - * registration passes the feature Api's own operations, and the entry's - * declaration passes an envelope that encloses that Api. A body that reached - * the Api itself could not be enclosed by a durable boundary above it, which is - * what retention here means. The other four — `` and its three - * evidence reads — are retained as well, but underneath their Api rather than - * above it: a workflow run makes the upsert durable in the Git-host - * reconciliation beneath it, and each read durable as policy on - * `PullRequestReadExecution`. No host owns a boundary over the chain for them, - * so they need no binding of their own here and ask their Api directly. + * A REPL entry supplies invocation-owned policies for Push, Issue, PullRequest + * and its three reads. Ordinary registrations pass the feature Api's own + * operations. The shared bodies receive those operations as arguments so the + * host can enclose a public middleware chain without replacing authored behavior. * - * Either way the body receives semantic operations and nothing else: no + * The body receives semantic operations and nothing else: no * authenticator, no owner, no site, no native access and no record codec. The * site stays private to the policy it was handed to. */ diff --git a/packages/git/src/deno/retained-repository/checkout.ts b/packages/git/src/deno/retained-repository/checkout.ts new file mode 100644 index 000000000..88d35a070 --- /dev/null +++ b/packages/git/src/deno/retained-repository/checkout.ts @@ -0,0 +1,110 @@ +import { until } from "effection"; +import type { Operation } from "effection"; +import { realpath } from "node:fs/promises"; +import { GitOperationAdmissionError } from "../../composition/errors.ts"; +import type { RepositorySelection } from "../../composition/selection.ts"; +import type { ReplPushPlace } from "../../retained-repository/push-records.ts"; +import type { ReplPushPlacement } from "../../retained-repository/push-admission.ts"; +import { locatorFingerprint } from "../composition/locator.ts"; +import { selectCheckout } from "../run-composition/operations.ts"; +import { holdManagedPlacement } from "./leases.ts"; +import type { ReplGitCheckout } from "./git.ts"; +import type { ReplPushEntry } from "./push.ts"; +import { placementSubject } from "./selections.ts"; +import type { ReplPlacementRegistration } from "./selections.ts"; + +export function* route( + entry: ReplPushEntry, + received: ReplPushPlace, + element: string, +): Operation { + const registered = entry.checkouts(); + const selected = selectCheckout( + registered.map((checkout) => checkout.checkout), + received.repository, + yield* canonical(received.workingDirectory), + element, + ); + const found = registered.find((checkout) => checkout.checkout.root === selected.root); + if (found === undefined) { + throw new GitOperationAdmissionError( + element, + "the checkout it selected is not one this entry holds a placement for", + ); + } + return found; +} + +export function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { + const registration = target.registration; + const slot = registration.slot; + const root = registration.placement.managedRoot; + if (slot === undefined || root === undefined) { + return; + } + yield* holdManagedPlacement(() => + entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), + ); +} + +function* canonical(path: string): Operation { + try { + return yield* until(realpath(path)); + } catch { + return path; + } +} + +function authenticated( + entry: ReplPushEntry, + selection: RepositorySelection, + element: string, +): ReplPlacementRegistration { + return entry.selections.authenticate( + selection, + () => + new GitOperationAdmissionError( + element, + "the Repository in scope is not one this entry selected, so it names no checkout", + ), + ); +} + +export function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { + return entry.checkouts().map((checkout) => ({ + identity: checkout.registration.placement.identity, + checkoutRoot: checkout.registration.placement.checkoutRoot, + commonDirectory: checkout.registration.placement.commonDirectory, + })); +} + +export function placeOf( + entry: ReplPushEntry, + selection: RepositorySelection, + workingDirectory: string, + element: string, +): ReplPushPlace { + const registration = authenticated(entry, selection, element); + return Object.freeze({ + repository: registration.placement.identity, + selectionCheckout: registration.placement.checkoutRoot, + commonDirectory: registration.placement.commonDirectory, + workingDirectory, + }); +} + +export function* checkoutOrigin( + registration: ReplPlacementRegistration, + element: string, + missing: string, + mismatch: string, +): Operation { + const origin = yield* registration.origin(); + if (origin === undefined) { + throw new GitOperationAdmissionError(element, missing); + } + if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { + throw new GitOperationAdmissionError(element, mismatch); + } + return origin; +} diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index 4e21f6351..549ee5180 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -26,12 +26,9 @@ * ``, and, for an entry whose capability was built per entry below, * the publication an authored `` asks for. * - * A pull request is not here: it has its own protocol and its own evidence, and - * a local result and a publication authorize neither. `` and its - * three evidence reads are declared with the ordinary policy beneath them, so - * they refuse through their own unchanged missing-provider default — and what - * is installed here is not the ordinary all-feature provider, which would - * enable them. + * Authored pull requests use the publication this entry has acknowledged; + * their URL-based reads retain the provider's admitted answer independently of + * checkout selection. * * ## Installing it reaches nothing * @@ -81,6 +78,24 @@ import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; import { replObservationMiddleware } from "./observations.ts"; import type { ReplObservationEntry } from "./observations.ts"; import { GitQuery } from "../../git.ts"; +import { + createReplPublicationMemory, + type ReplPublicationMemory, +} from "../../retained-repository/publication-evidence.ts"; +import { denoGitHubSource } from "../composition/github-host.ts"; +import { useGitHubPullRequestReads } from "../composition/pull-request-reads.ts"; +import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; +import { replPullRequestPolicy } from "./pull-request.ts"; +import { + replPullRequestReadAdmission, + replPullRequestReadPolicy, + useReplPullRequestReadOperations, +} from "./pull-request-reads.ts"; +import { pullRequestDeclaration } from "../../composition/components/PullRequest.ts"; +import { + PULL_REQUEST_READ_KINDS, + pullRequestReadDeclaration, +} from "../../composition/components/PullRequestReads.ts"; import { replPushAdmission, replPushPolicy } from "./push.ts"; import type { ReplPushEntry } from "./push.ts"; import { replIssueAdmission, replIssuePolicy, useEntryIssueTransport } from "./issues.ts"; @@ -130,6 +145,7 @@ export interface ReplRecordingOptions { * Context value holds can select or replace it. */ readonly identity?: IdentityReader; + readonly pullRequests?: GitHubPullRequestsOptions; } /** @@ -143,7 +159,7 @@ export interface ReplRecordingOptions { * directory is discovered from that. */ export function useReplRepositoryRecording(options: ReplRecordingOptions = {}): Operation { - return initializeReplRepository(options); + return initializeReplRepository(options, createReplPublicationMemory()); } /** @@ -171,11 +187,8 @@ export function useReplRepositoryRecording(options: ReplRecordingOptions = {}): * never returned to the CLI, published through Context or reachable from a * document. * - * One entry declares the common vocabulary once, with this host's policy under - * the two operations it retains and the ordinary policy under the rest. There - * is no inherited executable default here for an entry registration to shadow: - * the declarations an execution captures are these, and a host that recorded - * nothing for an operation leaves that operation its own refusal. + * The common vocabulary is registered by the Git Plugin. This entry declares + * only the six operations whose policies need an authenticated invocation. */ export function* replRepositoryInstallation( owner: string, @@ -183,6 +196,7 @@ export function* replRepositoryInstallation( ): Operation { const held: { entry?: ReplPushEntry } = {}; let usable = false; + const publications = createReplPublicationMemory(); yield* Execution.around({ *init(_args, next): Operation { @@ -193,7 +207,7 @@ export function* replRepositoryInstallation( usable = false; delete held.entry; }); - yield* initializeReplRepository(options, (entry) => { + yield* initializeReplRepository(options, publications, (entry) => { held.entry = entry; usable = true; }); @@ -201,26 +215,32 @@ export function* replRepositoryInstallation( }, }); - // The two names this entry declares, and only those. Every name this - // vocabulary has is already registered in the command's scope; what an entry - // adds here is the policy it owns, attached to those same two bodies from - // inside its own execution. - // - // Retaining an operation is not by itself a reason to declare its name. This - // entry retains the pages its four Git reads answer with and the file work - // beneath them too, and both are middleware under the component rather than a - // policy named after an invocation — so they keep the registered default and - // appear nowhere here. These two need the invocation: a durable boundary that - // has to enclose the public chain cannot be installed inside it. + const access = () => (usable ? held.entry : undefined); + const reads = replPullRequestReadPolicy(owner); const components: readonly IdentityComponent[] = [ - pushDeclaration(replPushPolicy(owner, () => (usable ? held.entry : undefined))), + pushDeclaration(replPushPolicy(owner, access)), issueDeclaration(replIssuePolicy(owner)), + pullRequestDeclaration( + replPullRequestPolicy(owner, access, { + host: denoGitHubSource, + ...options.pullRequests, + }), + ), + ...PULL_REQUEST_READ_KINDS.map((kind) => pullRequestReadDeclaration(kind, reads)), + ]; + const admissions: JournalAdmission[] = [ + replIssueAdmission(owner), + replPushAdmission(owner, publications), + replPullRequestReadAdmission(owner), ]; - const admissions: JournalAdmission[] = [replIssueAdmission(owner), replPushAdmission(owner)]; return { components, admissions, - install: () => useEntryIssueTransport({}), + *install(): Operation { + yield* useEntryIssueTransport({}); + yield* useGitHubPullRequestReads({ host: denoGitHubSource, ...options.pullRequests }); + yield* useReplPullRequestReadOperations(); + }, }; } @@ -243,6 +263,7 @@ export function* replRepositoryInstallation( */ function* initializeReplRepository( options: ReplRecordingOptions, + publications: ReplPublicationMemory, deliver?: (entry: ReplPushEntry) => void, ): Operation { const host = @@ -549,6 +570,7 @@ function* initializeReplRepository( // which is what leaves `` the same missing-provider refusal it // reaches when nothing selected a checkout. deliver?.({ + publications, host, git: useGit, holdSlot, diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index b37cfc43e..f4e7c01ae 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -69,33 +69,16 @@ import type { Result as DurableResult, } from "@executablemd/durable-streams"; -import { documented, formDispatcher } from "@executablemd/core"; -import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; -import type { - IdentityClaimant, - IdentityComponent, - JournalAdmission, -} from "@executablemd/core/host"; +import type { JournalAdmission } from "@executablemd/core/host"; import type { Around } from "@effectionx/context-api"; -import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; -import { - checksDocumentation, - checksReturns, - CHECKS_ELEMENT, - commentsDocumentation, - commentsReturns, - COMMENTS_ELEMENT, - props as pullRequestReadProps, - pullRequestReadBinding, - pullRequestReadTarget, - reviewsDocumentation, - reviewsReturns, - REVIEWS_ELEMENT, -} from "../../composition/components/PullRequestReads.ts"; +import type { BindOperations } from "../../composition/invocation-binding.ts"; import { PullRequestReadError } from "../../composition/errors.ts"; import { PullRequestAPI } from "../../composition/pull-request-api.ts"; import { PullRequestOperations } from "../../composition/pull-request-operations.ts"; -import type { PullRequestReadInvocation } from "../../composition/pull-request-operations.ts"; +import type { + PullRequestOperationsApi, + PullRequestReadInvocation, +} from "../../composition/pull-request-operations.ts"; import { PullRequestReadExecution } from "../../composition/pull-request-read-execution.ts"; import type { AdmittedPullRequestRead, @@ -121,7 +104,6 @@ import type { PullRequestReadRequest, PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; -import type { FirstPartyDocumentation, ReturnsSchema } from "@executablemd/core"; import { describePullRequestRead, pullRequestReadElement, @@ -139,115 +121,27 @@ interface ReadBridge { open: boolean; } -/** The three collections, with everything their declarations differ by. */ -const COLLECTIONS: readonly { - readonly kind: PullRequestReadKind; - readonly name: string; - readonly element: string; - readonly returns: ReturnsSchema; - readonly documentation: FirstPartyDocumentation; -}[] = [ - { - kind: "reviews", - name: "PullRequest.Reviews", - element: REVIEWS_ELEMENT, - returns: reviewsReturns, - documentation: reviewsDocumentation, - }, - { - kind: "comments", - name: "PullRequest.Comments", - element: COMMENTS_ELEMENT, - returns: commentsReturns, - documentation: commentsDocumentation, - }, - { - kind: "checks", - name: "PullRequest.Checks", - element: CHECKS_ELEMENT, - returns: checksReturns, - documentation: checksDocumentation, - }, -]; - -/** - * The entry's own three evidence reads, as ordinary shadowable components. - * - * Each shadows the inherited bundled registration for this execution's scope - * without touching it, so a repository-local replacement keeps its ordinary - * precedence over both. What a replacement does not get is an identity - * authenticator, so it names no retained read of this entry's. - * - * Inspection describes one component per collection — the same origin, props, - * return contract, self-closing form and prose the bundled registrations carry. - */ -export function replPullRequestReadComponents(owner: string): IdentityComponent[] { - return COLLECTIONS.map((collection) => ({ - name: collection.name, - origin: COMPOSITION_ORIGIN, - props: pullRequestReadProps, - returns: collection.returns, - forms: ["self-closing"] as const, - ...documented(collection.documentation), - factory: (claim: IdentityClaimant) => - // Canonical form dispatch, so the form is settled by the scan before the - // body runs, and a paired invocation is refused with the component's own - // sentence rather than reaching a read. - formDispatcher({ - forms: "self-closing", - fn: readingBody(owner, claim, collection.kind, collection.element), - refuse: () => - new PullRequestReadError( - "unexpected-content", - collection.element, - "it names a pull request to read and renders nothing of its own, so it is written " + - "self-closing.", - ), - }), - })); +export function replPullRequestReadPolicy( + owner: string, +): BindOperations { + return (site) => (invocation) => read(owner, site, invocation); } -function readingBody( +function* read( owner: string, - claim: IdentityClaimant, - kind: PullRequestReadKind, - element: string, -): FunctionComponent { - return function* PullRequestRead( - props: Record, - invocation: ComponentInvocation, - ): Operation { - // The same reading of the same props the bundled component makes, and its - // refusals are the component's own settled sentences. A URL this element - // cannot name is refused here, before a provider is asked anything. - const target = pullRequestReadTarget(props, element); - const site = yield* claim(invocation); - - return yield* scoped(function* (): Operation { - // This read's own bridge, in this read's own scope. It is gone when this - // scope ends, which is what leaves a finished read nothing to borrow. - const bridge: ReadBridge = { owner, site, spent: false, open: true }; - yield* PullRequestReadExecution.around(retaining(bridge, kind), { at: "min" }); - try { - // The ordinary dispatch, so every public handler a document or a plugin - // composed still answers, transforms, refuses and delegates. What comes - // back is whatever composed above the provider — which is why it is - // held to the whole evidence contract here and not only to the - // collection: the outer result is the value a document binds, and a - // handler that rewrote it into something no reader can parse must not - // reach the binding merely because the saved answer underneath it was - // sound. - const answered = yield* PullRequestOperations.operations.read({ - url: target.url, - kind, - provider: target.provider, - }); - return pullRequestReadBinding(validRead(answered, kind, element).result, kind, element); - } finally { - bridge.open = false; - } - }); - }; + site: string, + invocation: PullRequestReadInvocation, +): Operation { + return yield* scoped(function* () { + const bridge: ReadBridge = { owner, site, spent: false, open: true }; + yield* PullRequestReadExecution.around(retaining(bridge, invocation.kind), { at: "min" }); + try { + const answered = yield* PullRequestOperations.operations.read(invocation); + return validRead(answered, invocation.kind, pullRequestReadElement(invocation.kind)).result; + } finally { + bridge.open = false; + } + }); } /** diff --git a/packages/git/src/deno/retained-repository/pull-request.ts b/packages/git/src/deno/retained-repository/pull-request.ts index 3d3140cd4..b6f98511c 100644 --- a/packages/git/src/deno/retained-repository/pull-request.ts +++ b/packages/git/src/deno/retained-repository/pull-request.ts @@ -48,35 +48,19 @@ * external work this entry has already admitted. */ +import { checkoutOrigin, own, placements, route, placeOf } from "./checkout.ts"; +import { savedRequest } from "./requests.ts"; + import { createApi } from "@effectionx/context-api"; -import { Err, Ok, until } from "effection"; +import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; -import { realpath } from "node:fs/promises"; -import { createDurableOperation } from "@executablemd/durable-streams"; -import type { - ActivateDurabilityFailure, - Json, - LiveDurableOperationCoordinator, - Result as ProtocolResult, -} from "@executablemd/durable-streams"; -import { content, documented, hasContent } from "@executablemd/core"; -import type { ComponentInvocation, FunctionComponent } from "@executablemd/core"; -import type { IdentityClaimant, IdentityComponent } from "@executablemd/core/host"; -import { cwd } from "@executablemd/runtime"; -import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; -import { - authoredPullRequest, - documentation as pullRequestDocumentation, - props as pullRequestProps, - PULL_REQUEST_ELEMENT, - returns as pullRequestReturns, -} from "../../composition/components/PullRequest.ts"; -import { selectedRepository } from "../../composition/context.ts"; + +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import type { BindOperations } from "../../composition/invocation-binding.ts"; import { GitOperationAdmissionError, GitOperationInfrastructureError, GitOperationProtocolError, - PullRequestAdmissionError, } from "../../composition/errors.ts"; import { NoPullRequestProvider, PULL_REQUEST_API } from "../../composition/pull-request-api.ts"; import type { @@ -118,7 +102,6 @@ import { type ReplPullRequestRequestRecord, type ReplPullRequestResolution, } from "../../retained-repository/pull-request-records.ts"; -import type { ReplPushPlace } from "../../retained-repository/push-records.ts"; import { locatorFingerprint } from "../composition/locator.ts"; import { currentBranch, resolveCommit } from "../composition/git.ts"; import type { GitHubSource } from "../composition/github.ts"; @@ -126,87 +109,18 @@ import { gitHubPullRequestAccess } from "../composition/pull-request-reads.ts"; import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; import { pullRequestProvider } from "../composition/github-pull-request.ts"; import { gitRefusal } from "../composition/refusals.ts"; -import { liveCheckout, selectCheckout } from "../run-composition/operations.ts"; -import { holdManagedPlacement } from "./leases.ts"; -import type { ReplGitCheckout } from "./git.ts"; +import { liveCheckout } from "../run-composition/operations.ts"; import { retainedRecordAhead } from "./local.ts"; import type { ReplPushAccess, ReplPushEntry } from "./push.ts"; -import { placementSubject } from "./selections.ts"; -import type { ReplPlacementRegistration } from "./selections.ts"; +import { nativeGitTurn } from "./selections.ts"; -/** - * The entry's own `PullRequest`, declared as an ordinary shadowable component. - * - * It shadows the inherited bundled registration for this execution's scope - * without touching it, so a repository-local `` keeps its ordinary - * precedence over both. The name is neither reserved nor protected: what a - * replacement does not get is an identity authenticator, so it names no durable - * work of this entry's. - * - * Inspection describes one `PullRequest` — the same origin, props, return - * contract, forms and prose the bundled registration carries — because it is - * the same component with this entry's identity behind it. - */ -export function replPullRequestComponent( +export function replPullRequestPolicy( owner: string, access: ReplPushAccess, options: GitHubPullRequestsOptions = {}, -): IdentityComponent { +): BindOperations { const upstream = gitHubPullRequestAccess(options); - return { - name: "PullRequest", - origin: COMPOSITION_ORIGIN, - props: pullRequestProps, - returns: pullRequestReturns, - // Both authored forms, as the bundled registration has them: a pull request - // with no body is written `` and its body is the - // empty string. Unlike ``, the form selects no second effect, so - // reading the content contextually decides nothing but what the body says. - forms: ["self-closing", "paired"], - ...documented(pullRequestDocumentation), - factory: (claim: IdentityClaimant) => upsertingBody(owner, access, upstream, claim), - }; -} - -function upsertingBody( - owner: string, - access: ReplPushAccess, - upstream: () => Operation, - claim: IdentityClaimant, -): FunctionComponent { - return function* PullRequest( - props: Record, - invocation: ComponentInvocation, - ): Operation { - // Before this element projects its own content, because an invocation names - // nothing while it is expanding what is inside it — and after it would be - // naming the identity from a frame the children have already left. - const site = yield* claim(invocation); - // Then the body, and completely, before a Repository is observed and before - // this upsert has a position of its own. A child durable operation in that - // content finishes at its own earlier position, and a body that failed to - // render stops everything this component owns before a request exists. - const body = (yield* hasContent()) ? yield* content() : ""; - - const repository = yield* selectedRepository(); - if (repository === undefined) { - throw new PullRequestAdmissionError( - "no-repository-context", - "it is written outside a lexical , so there is no repository in scope for a " + - "pull request to be opened in.", - ); - } - const result = yield* upsert({ - owner, - site, - access, - upstream, - pullRequest: authoredPullRequest(props, body, repository.identity.primaryBranch), - repository, - workingDirectory: yield* cwd(), - }); - return pullRequestResultJson(result); - }; + return (site) => (invocation) => upsert({ owner, site, access, upstream, ...invocation }); } interface Invocation { @@ -230,17 +144,9 @@ interface Invocation { */ function* upsert(invocation: Invocation): Operation { const entry = held(invocation.access); - const registration = authenticated(entry, invocation.repository); const requested: ReplPullRequestAsk = Object.freeze({ pullRequest: invocation.pullRequest, - place: Object.freeze({ - repository: registration.placement.identity, - selectionCheckout: registration.placement.checkoutRoot, - commonDirectory: registration.placement.commonDirectory, - // Exactly as received. Resolving it is live routing, which a continuation - // does not do, so the name cannot depend on it. - workingDirectory: invocation.workingDirectory, - }), + place: placeOf(entry, invocation.repository, invocation.workingDirectory, PULL_REQUEST_ELEMENT), }); const name = yield* replPullRequestRequestName({ owner: invocation.owner, @@ -365,24 +271,10 @@ function* ownedUpsert( } live.delegated = true; - // Read once, here. The arguments are the caller's own objects and everything - // below suspends, so reading them again later would let one pull request be - // authenticated and another opened. - const received: ReplPullRequestAsk = Object.freeze({ - pullRequest: Object.freeze({ - number: pullRequest.number, - title: pullRequest.title, - body: pullRequest.body, - draft: pullRequest.draft, - base: pullRequest.base, - }), - place: placeOf(entry, options.repository, options.workingDirectory), - }); - // Held privately whichever way it goes, before anything up the chain can see // it. A failure recorded here is what stops a handler from catching it and // answering the document with a success instead. - const outcome = yield* attempt(entry, invocation, requested, received, name); + const outcome = yield* attempt(entry, invocation, requested, pullRequest, options, name); live.owned = outcome; if (!outcome.ok) { throw outcome.error; @@ -411,11 +303,26 @@ function* attempt( entry: ReplPushEntry, invocation: Invocation, requested: ReplPullRequestAsk, - received: ReplPullRequestAsk, + pullRequest: PullRequestInput, + options: PullRequestUpsertOptions, name: string, ): Operation> { try { - const resolved = yield* resolve(entry, invocation, received); + // Read once, here. The arguments are the caller's own objects and everything + // below suspends, so reading them again later would let one pull request be + // authenticated and another opened. + const received: ReplPullRequestAsk = Object.freeze({ + pullRequest: Object.freeze({ + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + draft: pullRequest.draft, + base: pullRequest.base, + }), + place: placeOf(entry, options.repository, options.workingDirectory, PULL_REQUEST_ELEMENT), + }); + + const resolved = yield* resolve(entry, received); const record = yield* publishRequest(entry, invocation, requested, received, resolved, name); return Ok(yield* reconcile(entry, invocation, record)); } catch (error) { @@ -438,12 +345,19 @@ function* attempt( */ function* resolve( entry: ReplPushEntry, - invocation: Invocation, received: ReplPullRequestAsk, ): Operation { - const routed = yield* route(entry, received.place); + const routed = yield* route(entry, received.place, PULL_REQUEST_ELEMENT); const placement = routed.registration.placement; - const origin = yield* destination(routed.registration); + const origin = yield* checkoutOrigin( + routed.registration, + PULL_REQUEST_ELEMENT, + "the checkout it selected records no usable origin, so there is no repository for this " + + "pull request to be opened in. No credential was read and nothing was contacted", + "the origin this checkout records is not the one the Repository in scope was selected " + + "from, so a pull request opened against it would name a repository this entry never " + + "authenticated", + ); yield* own(entry, routed); @@ -452,7 +366,7 @@ function* resolve( // `` of the same repository would describe a moment neither of // them is at. The turn is held for these two short reads and released before // anything is contacted: a network round trip never holds it. - const read = yield* entry.serialize(`git ${placement.commonDirectory}`, function* () { + const read = yield* entry.serialize(nativeGitTurn(placement.commonDirectory), function* () { const checkout = liveCheckout(git, routed.checkout, received.place.workingDirectory); const branch = yield* currentBranch(checkout.git, checkout.directory); if (branch === undefined) { @@ -494,34 +408,6 @@ function* resolve( return resolved; } -/** - * Where this checkout's pull requests belong, as this entry can see it now. - * - * The locator the entry admitted, held to the fingerprint the Repository - * identity carries — the same question publication asks, because a pull request - * opened against an origin nothing authenticated would be a statement about - * somebody else's repository. - */ -function* destination(registration: ReplPlacementRegistration): Operation { - const origin = yield* registration.origin(); - if (origin === undefined) { - throw new GitOperationAdmissionError( - PULL_REQUEST_ELEMENT, - "the checkout it selected records no usable origin, so there is no repository for this " + - "pull request to be opened in. No credential was read and nothing was contacted", - ); - } - if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { - throw new GitOperationAdmissionError( - PULL_REQUEST_ELEMENT, - "the origin this checkout records is not the one the Repository in scope was selected " + - "from, so a pull request opened against it would name a repository this entry never " + - "authenticated", - ); - } - return origin; -} - /** * Write this request down, and read it back before it is acknowledged. * @@ -538,22 +424,18 @@ function* publishRequest( resolved: ReplPullRequestResolution, name: string, ): Operation { - const stored: unknown = yield createDurableOperation( + return yield* savedRequest( { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, - function* () { - const payload = replPullRequestRequestJson({ + () => + replPullRequestRequestJson({ owner: invocation.owner, site: invocation.site, requested, received, resolved, - }); - readback(entry, invocation, requested, payload); - return payload; - }, - { coordinator: requestCoordinator }, + }), + (stored) => readback(entry, invocation, requested, stored), ); - return readback(entry, invocation, requested, stored); } /** @@ -570,19 +452,17 @@ function* restored( requested: ReplPullRequestAsk, name: string, ): Operation { - const stored: unknown = yield createDurableOperation( + return yield* savedRequest( { type: REPL_REPOSITORY_PULL_REQUEST_REQUEST, name }, - // deno-lint-ignore require-yield - function* (): Operation { + () => { throw new GitOperationInfrastructureError( PULL_REQUEST_ELEMENT, "its retained history named a saved pull-request request at this position and then had " + "none, so what this entry asked the Git host for is unknown", ); }, - { coordinator: requestCoordinator }, + (stored) => readback(entry, invocation, requested, stored), ); - return readback(entry, invocation, requested, stored); } /** @@ -649,15 +529,6 @@ function placesHeld( ); } -/** The places this entry's reconstructed registry holds, as parsed facts. */ -function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { - return entry.checkouts().map((checkout) => ({ - identity: checkout.registration.placement.identity, - checkoutRoot: checkout.registration.placement.checkoutRoot, - commonDirectory: checkout.registration.placement.commonDirectory, - })); -} - /** * Reconcile this request's external effect, and hold the result to the request. * @@ -748,84 +619,6 @@ function* upstreamProvider( return pullRequestProvider(access, resolved.origin, resolved.inputs); } -/** - * Which registered checkout this repository and working directory select. - * - * The ordinary selector over this entry's own registrations, restricted by the - * whole authenticated Repository identity and resolving the received directory - * first. The longest covering checkout wins, so a subdirectory, a linked - * worktree and a captured path entered through `` each select the checkout - * their own paths belong to. - */ -function* route(entry: ReplPushEntry, received: ReplPushPlace): Operation { - const registered = entry.checkouts(); - const selected = selectCheckout( - registered.map((checkout) => checkout.checkout), - received.repository, - yield* canonical(received.workingDirectory), - PULL_REQUEST_ELEMENT, - ); - const found = registered.find((checkout) => checkout.checkout.root === selected.root); - if (found === undefined) { - throw new GitOperationAdmissionError( - PULL_REQUEST_ELEMENT, - "the checkout it selected is not one this entry holds a placement for", - ); - } - return found; -} - -/** - * Take ownership of the checkout this pull request is opened from. - * - * The managed slot alone, as publication takes it: the slot says which entry - * owns a managed checkout, which is what reading its branch and head needs. No - * mutation lease, because a pull request changes no checkout, index, branch or - * tracking configuration. - */ -function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { - const registration = target.registration; - const slot = registration.slot; - const root = registration.placement.managedRoot; - if (slot === undefined || root === undefined) { - return; - } - yield* holdManagedPlacement(() => - entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), - ); -} - -/** One reading of where this pull request was asked for. */ -function placeOf( - entry: ReplPushEntry, - selection: RepositorySelection, - workingDirectory: string, -): ReplPushPlace { - const registration = authenticated(entry, selection); - return Object.freeze({ - repository: registration.placement.identity, - selectionCheckout: registration.placement.checkoutRoot, - commonDirectory: registration.placement.commonDirectory, - workingDirectory, - }); -} - -/** - * The canonical directory this path resolves to, or the path as written. - * - * Routing resolves the received directory exactly as recorded local Git and - * publication do, because a working directory reached through a link is the - * same place under another name. Only live routing resolves anything: a saved - * request's roots are authenticated against parsed placements instead. - */ -function* canonical(path: string): Operation { - try { - return yield* until(realpath(path)); - } catch { - return path; - } -} - /** The private entry state this execution holds, or the ordinary refusal. */ function held(access: ReplPushAccess): ReplPushEntry { const entry = access(); @@ -837,50 +630,3 @@ function held(access: ReplPushAccess): ReplPushEntry { } return entry; } - -/** What this selection names in this entry's registry, or an admission refusal. */ -function authenticated( - entry: ReplPushEntry, - selection: RepositorySelection, -): ReplPlacementRegistration { - return entry.selections.authenticate( - selection, - () => - new GitOperationAdmissionError( - PULL_REQUEST_ELEMENT, - "the Repository in scope is not one this entry selected, so it names no checkout", - ), - ); -} - -/** - * The live boundary one written request passes through. - * - * The default coordinator would serialize whatever was thrown and publish it as - * this operation's outcome, which is the one thing that must not happen here: a - * request written for work nobody resolved would send a continuation to - * reconcile a pull request this entry never asked for. - * - * A throw therefore activates the run's existing fail-stop state and is - * re-raised with its identity intact, writing nothing. A valid request is - * written once and returned only after the append is acknowledged, which is what - * makes "no service contact before the request exists" a property of the - * ordering rather than a promise. - */ -const requestCoordinator: LiveDurableOperationCoordinator = { - *run( - execute: () => Operation, - publish: (result: ProtocolResult) => Operation, - activateFailure: ActivateDurabilityFailure, - ): Operation { - let value: T; - try { - value = yield* execute(); - } catch (error) { - throw activateFailure(error); - } - const result: ProtocolResult = { status: "ok", value }; - yield* publish(result); - return result; - }, -}; diff --git a/packages/git/src/deno/retained-repository/push.ts b/packages/git/src/deno/retained-repository/push.ts index e0a6affe8..341c922d4 100644 --- a/packages/git/src/deno/retained-repository/push.ts +++ b/packages/git/src/deno/retained-repository/push.ts @@ -55,17 +55,15 @@ * repeating it. */ +import type { ReplPlacementRegistration } from "./selections.ts"; +import type { ReplGitCheckout } from "./git.ts"; +import { checkoutOrigin, own, placements, route, placeOf } from "./checkout.ts"; +import { savedRequest } from "./requests.ts"; + import { createApi } from "@effectionx/context-api"; -import { until, useScope } from "effection"; +import { useScope } from "effection"; import type { Operation, Scope } from "effection"; -import { realpath } from "node:fs/promises"; -import { createDurableOperation } from "@executablemd/durable-streams"; -import type { - ActivateDurabilityFailure, - Json, - LiveDurableOperationCoordinator, - Result as ProtocolResult, -} from "@executablemd/durable-streams"; + import type { JournalAdmission } from "@executablemd/core/host"; import { PUSH } from "../../composition/components/GitPush.ts"; import { @@ -95,7 +93,6 @@ import { admitReplPushHistory, replPushEffectRequest, replPushPlacesHeld, - type ReplPushPlacement, } from "../../retained-repository/push-admission.ts"; import type { ReplPublicationMemory } from "../../retained-repository/publication-evidence.ts"; import { @@ -117,13 +114,10 @@ import { pushProvider } from "../composition/push.ts"; import { gitRefusal } from "../composition/refusals.ts"; import { lazily } from "../run-composition/lazy.ts"; import type { SlotKind } from "../run-composition/leases.ts"; -import { liveCheckout, selectCheckout } from "../run-composition/operations.ts"; +import { liveCheckout } from "../run-composition/operations.ts"; import type { SelectionRegistry } from "../selections.ts"; -import { holdManagedPlacement } from "./leases.ts"; -import type { ReplGitCheckout } from "./git.ts"; import { retainedRecordAhead } from "./local.ts"; -import { nativeGitTurn, placementSubject } from "./selections.ts"; -import type { ReplPlacementRegistration } from "./selections.ts"; +import { nativeGitTurn } from "./selections.ts"; /** * What recorded publication needs from the entry that owns it. @@ -235,19 +229,7 @@ interface Invocation { */ function* publish(invocation: Invocation): Operation { const entry = held(invocation.access); - // Authenticated before anything else, and its *parsed* facts are what the - // request carries. The opaque identifier this entry minted never appears: it - // names nothing once the entry ends, so a durable name built from one would - // differ on every reopen of the same question. - const registration = authenticated(entry, invocation.repository); - const requested: ReplPushPlace = Object.freeze({ - repository: registration.placement.identity, - selectionCheckout: registration.placement.checkoutRoot, - commonDirectory: registration.placement.commonDirectory, - // Exactly as received. Resolving it is live routing, which a continuation - // does not do, so the name cannot depend on it. - workingDirectory: invocation.workingDirectory, - }); + const requested = placeOf(entry, invocation.repository, invocation.workingDirectory, PUSH); const name = yield* replPushRequestName({ owner: invocation.owner, site: invocation.site, @@ -336,15 +318,7 @@ function* delegated( // Read once, here. The invocation is the caller's own object and everything // below suspends, so reading it again later would let one Repository be // authenticated and another published for. - const receivedSelection = request.repository; - const receivedDirectory = request.workingDirectory; - const registration = authenticated(entry, receivedSelection); - const received: ReplPushPlace = Object.freeze({ - repository: registration.placement.identity, - selectionCheckout: registration.placement.checkoutRoot, - commonDirectory: registration.placement.commonDirectory, - workingDirectory: receivedDirectory, - }); + const received = placeOf(entry, request.repository, request.workingDirectory, PUSH); const resolved = yield* resolve(entry, received); const record = yield* publishRequest(entry, invocation, requested, received, resolved, name); @@ -366,9 +340,16 @@ function* delegated( * a checkout with no usable origin both stop here, before a request exists. */ function* resolve(entry: ReplPushEntry, received: ReplPushPlace): Operation { - const routed = yield* route(entry, received); + const routed = yield* route(entry, received, PUSH); const placement = routed.registration.placement; - const origin = yield* destination(routed.registration); + const origin = yield* checkoutOrigin( + routed.registration, + PUSH, + "the checkout it selected records no usable origin, so there is nowhere for this branch " + + "to be published to. No credential was read and nothing was contacted", + "the origin this checkout records is not the one the Repository in scope was selected " + + "from, so a branch published to it would go somewhere this entry never authenticated", + ); yield* own(entry, routed); @@ -408,34 +389,6 @@ function* resolve(entry: ReplPushEntry, received: ReplPushPlace): Operation { - const origin = yield* registration.origin(); - if (origin === undefined) { - throw new GitOperationAdmissionError( - PUSH, - "the checkout it selected records no usable origin, so there is nowhere for this branch " + - "to be published to. No credential was read and nothing was contacted", - ); - } - if (locatorFingerprint(origin) !== registration.placement.identity.locatorFingerprint) { - throw new GitOperationAdmissionError( - PUSH, - "the origin this checkout records is not the one the Repository in scope was selected " + - "from, so a branch published to it would go somewhere this entry never authenticated", - ); - } - return origin; -} - /** * Write this request down, and read it back before it is acknowledged. * @@ -458,22 +411,18 @@ function* publishRequest( resolved: ReplPushResolution, name: string, ): Operation { - const stored: unknown = yield createDurableOperation( + return yield* savedRequest( { type: REPL_REPOSITORY_PUSH_REQUEST, name }, - function* () { - const payload = replPushRequestJson({ + () => + replPushRequestJson({ owner: invocation.owner, site: invocation.site, requested, received, resolved, - }); - readback(entry, invocation, requested, payload); - return payload; - }, - { coordinator: requestCoordinator }, + }), + (stored) => readback(entry, invocation, requested, stored), ); - return readback(entry, invocation, requested, stored); } /** @@ -490,19 +439,17 @@ function* restored( requested: ReplPushPlace, name: string, ): Operation { - const stored: unknown = yield createDurableOperation( + return yield* savedRequest( { type: REPL_REPOSITORY_PUSH_REQUEST, name }, - // deno-lint-ignore require-yield - function* (): Operation { + () => { throw new GitOperationInfrastructureError( PUSH, "its retained history named a saved publication request at this position and then had " + "none, so what this entry asked to publish is unknown", ); }, - { coordinator: requestCoordinator }, + (stored) => readback(entry, invocation, requested, stored), ); - return readback(entry, invocation, requested, stored); } /** @@ -560,15 +507,6 @@ function sameReplPushRequest(saved: ReplPushPlace, asking: ReplPushPlace): boole ); } -/** The places this entry's reconstructed registry holds, as parsed facts. */ -function placements(entry: ReplPushEntry): readonly ReplPushPlacement[] { - return entry.checkouts().map((checkout) => ({ - identity: checkout.registration.placement.identity, - checkoutRoot: checkout.registration.placement.checkoutRoot, - commonDirectory: checkout.registration.placement.commonDirectory, - })); -} - /** * Reconcile this request's external effect, and hold the result to the request. * @@ -690,70 +628,6 @@ function* publication( return pushProvider(source, resolved.origin, inputs, session); } -/** - * Which registered checkout this repository and working directory select. - * - * The ordinary selector over this entry's own registrations, restricted by the - * whole authenticated Repository identity and resolving the received directory - * first. The longest covering checkout wins, so a subdirectory, a linked - * worktree and a captured path entered through `` each select the checkout - * their own paths belong to. - */ -function* route(entry: ReplPushEntry, received: ReplPushPlace): Operation { - const registered = entry.checkouts(); - const selected = selectCheckout( - registered.map((checkout) => checkout.checkout), - received.repository, - yield* canonical(received.workingDirectory), - PUSH, - ); - const found = registered.find((checkout) => checkout.checkout.root === selected.root); - if (found === undefined) { - throw new GitOperationAdmissionError( - PUSH, - "the checkout it selected is not one this entry holds a placement for", - ); - } - return found; -} - -/** - * Take ownership of the checkout this publication reads from. - * - * The managed slot alone. It says which entry owns a managed checkout, which is - * what a publication needs in order to read its objects; the mutation lease - * says which entry is changing a repository, and a publication changes none of - * it. An ambient target has no slot: it is the checkout the person was already - * standing in. - */ -function* own(entry: ReplPushEntry, target: ReplGitCheckout): Operation { - const registration = target.registration; - const slot = registration.slot; - const root = registration.placement.managedRoot; - if (slot === undefined || root === undefined) { - return; - } - yield* holdManagedPlacement(() => - entry.holdSlot(root, slot.kind, slot.path, placementSubject(registration.placement)), - ); -} - -/** - * The canonical directory this path resolves to, or the path as written. - * - * Routing resolves the received directory exactly as recorded local Git does, - * because a working directory reached through a link is the same place under - * another name. Only live routing resolves anything: a saved request's roots - * are authenticated against parsed placements instead. - */ -function* canonical(path: string): Operation { - try { - return yield* until(realpath(path)); - } catch { - return path; - } -} - /** The public default's own refusal, for a member this descriptor never routes. */ function refusing(element: string): () => Operation { // deno-lint-ignore require-yield @@ -773,50 +647,3 @@ function held(access: ReplPushAccess): ReplPushEntry { } return entry; } - -/** What this selection names in this entry's registry, or an admission refusal. */ -function authenticated( - entry: ReplPushEntry, - selection: RepositorySelection, -): ReplPlacementRegistration { - return entry.selections.authenticate( - selection, - () => - new GitOperationAdmissionError( - PUSH, - "the Repository in scope is not one this entry selected, so it names no checkout", - ), - ); -} - -/** - * The live boundary one published request is written through. - * - * The default coordinator would serialize whatever was thrown and publish it as - * this operation's outcome, which is the one thing that must not happen here: a - * request published for work nobody resolved would send a continuation to - * reconcile a branch and a commit this entry never read. - * - * A throw therefore activates the run's existing fail-stop state and is - * re-raised with its identity intact, publishing nothing. A valid request is - * published once and returned only after the append is acknowledged, which is - * what makes "no remote work before the request exists" a property of the - * ordering rather than a promise. - */ -const requestCoordinator: LiveDurableOperationCoordinator = { - *run( - execute: () => Operation, - publish: (result: ProtocolResult) => Operation, - activateFailure: ActivateDurabilityFailure, - ): Operation { - let value: T; - try { - value = yield* execute(); - } catch (error) { - throw activateFailure(error); - } - const result: ProtocolResult = { status: "ok", value }; - yield* publish(result); - return result; - }, -}; diff --git a/packages/git/src/deno/retained-repository/requests.ts b/packages/git/src/deno/retained-repository/requests.ts new file mode 100644 index 000000000..ffc29e2ac --- /dev/null +++ b/packages/git/src/deno/retained-repository/requests.ts @@ -0,0 +1,47 @@ +import { createDurableOperation } from "@executablemd/durable-streams"; +import type { + ActivateDurabilityFailure, + EffectDescription, + Json, + LiveDurableOperationCoordinator, + Result as ProtocolResult, +} from "@executablemd/durable-streams"; +import type { Operation } from "effection"; + +export function* savedRequest( + description: EffectDescription, + produce: () => Json, + readback: (stored: unknown) => T, +): Operation { + const stored: unknown = yield createDurableOperation( + description, + // deno-lint-ignore require-yield + function* (): Operation { + const payload = produce(); + readback(payload); + return payload; + }, + { coordinator: requestCoordinator }, + ); + return readback(stored); +} + +// A request failure cannot become a saved outcome: reconciliation needs a +// complete request, and must run only after the append is acknowledged. +const requestCoordinator: LiveDurableOperationCoordinator = { + *run( + execute: () => Operation, + publish: (result: ProtocolResult) => Operation, + activateFailure: ActivateDurabilityFailure, + ): Operation { + let value: T; + try { + value = yield* execute(); + } catch (error) { + throw activateFailure(error); + } + const result: ProtocolResult = { status: "ok", value }; + yield* publish(result); + return result; + }, +}; diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index 8d54a9bc8..b8d65c42a 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -33,13 +33,22 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { collect, Component, registerComponents } from "@executablemd/core"; -import type { ComponentRegistration, Json } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionInstallation, IdentityComponent } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { COMPOSITION_REGISTRATIONS } from "../src/composition/installation.ts"; import { pushDeclaration } from "../src/composition/components/GitPush.ts"; import { issueDeclaration } from "../src/composition/components/Issue.ts"; +import { pullRequestDeclaration } from "../src/composition/components/PullRequest.ts"; +import { + PULL_REQUEST_READ_KINDS, + pullRequestReadDeclaration, +} from "../src/composition/components/PullRequestReads.ts"; +import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; +import { RepositoryContext } from "@executablemd/git/api"; +import type { RepositorySelection } from "@executablemd/git/api"; +import type { PullRequestResult, PullRequestReadResult } from "@executablemd/git/api"; import type { BindOperations } from "../src/composition/invocation-binding.ts"; // From the subpath that publishes them rather than from the source modules: // these are public types, and reaching them by relative path would bypass the @@ -49,8 +58,15 @@ import type { IssueOperationsApi } from "../src/issue/operations.ts"; import type { PullRequestOperationsApi } from "../src/composition/pull-request-operations.ts"; import { COMPOSITION_ORIGIN } from "../src/composition/definitions.ts"; -/** The two operations A's retained entry owns, by the names a document writes. */ -const OWNED: readonly string[] = ["Git.Push", "Issue"]; +/** The invocation-owned operations, by the names a document writes. */ +const OWNED: readonly string[] = [ + "Git.Push", + "Issue", + "PullRequest", + "PullRequest.Reviews", + "PullRequest.Comments", + "PullRequest.Checks", +]; /** The six operations no local transaction encloses, by the names a document writes. */ const OPERATIONS: readonly string[] = [ @@ -174,7 +190,7 @@ function useRoot(): Operation { } /** - * One declaration of each operation A's retained entry owns. + * One declaration of each invocation-owned operation. * * Built with policy nothing reaches: these rows are about what a declaration * says, and a constructor that had to perform in order to describe would be @@ -186,6 +202,14 @@ function ownedDeclarations(): readonly IdentityComponent[] { throw new Error("a described declaration performed"); }), issueDeclaration(fakeIssuePolicy(watched())), + pullRequestDeclaration(() => () => { + throw new Error("a described declaration performed"); + }), + ...PULL_REQUEST_READ_KINDS.map((kind) => + pullRequestReadDeclaration(kind, () => () => { + throw new Error("a described declaration performed"); + }), + ), ]; } @@ -335,6 +359,95 @@ describe("one registry for every repository operation, and the policy an executi expect(new Set(seen.sites).size).toBe(2); }); + it("B1: PR and read bodies ask the same semantic operations under ordinary and entry policies", function* () { + const root = yield* useRoot(); + const selection: RepositorySelection = { + selection: "selected", + name: "project", + checkoutPath: root, + identity: { + name: "project", + locatorFingerprint: "a".repeat(64), + requestedBase: null, + creationCommit: "b".repeat(40), + primaryBranch: "main", + objectFormat: "sha1", + }, + }; + const answer: PullRequestResult = { + repository: selection.identity, + providerId: "PR_1", + number: 1, + url: `${TRACKER}/pull/1`, + state: "open", + headSha: "c".repeat(40), + baseSha: "d".repeat(40), + }; + const source = [ + '', + ``, + "Ready for review.", + "", + ``, + ``, + "", + "", + "", + "", + ].join("\n"); + const runs: { output: string; calls: unknown[] }[] = []; + for (const owned of [false, true]) { + const sites: string[] = []; + const calls: unknown[] = []; + const operations: PullRequestOperationsApi = { + *upsert(invocation): Operation { + calls.push(invocation); + return answer; + }, + *read(invocation): Operation { + calls.push(invocation); + if (invocation.kind === "checks") { + return { kind: "checks", headSha: answer.headSha, items: [] }; + } + return { kind: invocation.kind, items: [] }; + }, + }; + const installations: ExecutionInstallation[] = owned + ? [ + { + components: [ + pullRequestDeclaration((site) => { + sites.push(site); + return operations.upsert; + }), + ...PULL_REQUEST_READ_KINDS.map((kind) => + pullRequestReadDeclaration(kind, (site) => { + sites.push(site); + return operations.read; + }), + ), + ], + }, + ] + : []; + const ran = yield* run(root, source, installations, function* () { + yield* RepositoryContext.around({ current: () => selection }, { at: "min" }); + if (!owned) { + yield* PullRequestOperations.around({ + upsert: ([invocation]) => operations.upsert(invocation), + read: ([invocation]) => operations.read(invocation), + }); + } + }); + expect(ran.refusal).toBeUndefined(); + expect(calls).toHaveLength(4); + expect(sites).toHaveLength(owned ? 4 : 0); + expect(new Set(sites).size).toBe(owned ? 4 : 0); + runs.push({ output: ran.output, calls }); + } + expect(runs[0]).toEqual(runs[1]); + }); + it("A5: cancelling an invocation stops the operation it was performing", function* (): Operation { const root = yield* useRoot(); const seen = watched(); diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index bebb5f207..21ba24ea1 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -180,6 +180,8 @@ const DENO_ADAPTER: readonly string[] = [ "src/deno/retained-repository/local.ts", "src/deno/retained-repository/observations.ts", "src/deno/retained-repository/provider.ts", + "src/deno/retained-repository/checkout.ts", + "src/deno/retained-repository/requests.ts", "src/deno/retained-repository/pull-request-reads.ts", "src/deno/retained-repository/pull-request.ts", "src/deno/retained-repository/push.ts", diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index f0f37cdfa..51eb6475a 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -44,7 +44,7 @@ import type { FilesHandler } from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { executeInstalled } from "@executablemd/core/host"; -import { collect, Elicitation } from "@executablemd/core"; +import { collect, Elicitation, registerComponents } from "@executablemd/core"; import { useCompositionComponents } from "../src/composition/installation.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome, RepositoryHost } from "../src/deno/composition/host.ts"; @@ -1253,6 +1253,74 @@ describe("one entry's pull request, composed", () => { } }); + for (const chain of ["operations", "api"] as const) { + it(`V5c: a swallowed selection-authentication refusal stays a failure through ${chain}`, function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const stream = new InMemoryStream(); + let caught = ""; + let later = false; + const result = yield* run( + fixture, + openingOn(`auth-refusal/${chain}`).replace("", "\n"), + stream, + forge, + { + *between(): Operation { + yield* registerComponents([ + { + name: "After", + origin: "test", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + later = true; + return ""; + }, + }, + ]); + if (chain === "operations") { + yield* PullRequestOperations.around({ + *upsert([invocation], next): Operation { + try { + return yield* next({ + ...invocation, + repository: { ...invocation.repository, selection: "stale-selection" }, + }); + } catch (error) { + caught = error instanceof Error ? error.message : String(error); + return SYNTHETIC; + } + }, + }); + } else { + yield* PullRequestAPI.around({ + *upsert([input, options], next): Operation { + try { + return yield* next(input, { + ...options, + repository: { ...options.repository, selection: "stale-selection" }, + }); + } catch (error) { + caught = error instanceof Error ? error.message : String(error); + return SYNTHETIC; + } + }, + }); + } + }, + }, + ); + expect(caught).toContain("not one this entry selected"); + expect(result.refusal).toBeDefined(); + expect(result.output).not.toContain("4242"); + expect(later).toBe(false); + expect(requests(result.events)).toEqual([]); + expect(upserts(result.events)).toEqual([]); + expect(mutations(forge)).toEqual([]); + }); + } + it("V5d: one transform records both readings, and a saved request bypasses it", function* () { const fixture = yield* useFixture(); const forge = store(fixture); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index a1d0e6274..3da2938eb 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1596,6 +1596,10 @@ read whose answer arrived but whose own session could not be closed records nothing either, because what it did is in a state its outcome cannot be read from. +The entry uses the same authored PR and read bodies as ordinary runs and +workflows. Its private policies supply retention beneath those bodies through +the shared invocation binding. + What `` binds is evidence of what the upsert settled on, not freshness: the repository, the provider's own identity for the pull request, its From 2f975b01acd33c1085612bf7da3fc848adb571c3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 10:37:42 -0400 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=90=9B=20Hold=20each=20saved=20pull?= =?UTF-8?q?-request=20place=20to=20its=20own=20Repository=20identity=20(#1?= =?UTF-8?q?81,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved request names three places: the checkout the invocation authenticated, the one the live dispatch received, and the one routing resolved. `placesHeld()` held all three to `received.place.repository`, so when a handler legitimately answered the authored question with another Repository this entry had selected and published from, the original checkout was looked for among the delegated Repository's placements, readback threw at the request-append boundary and the entry's durability failure fenced everything after it. A supported composition could resolve a pull request and then fail to retain the request it resolved. Each place is now held to a placement under the identity that place is a place of, on live readback and restored readback alike, which is what `replPullRequestPlacesHeld()` already does over saved history. Full identity comparison stays together with path matching, so a path some other Repository placed is still refused, and owner/site, request-name, origin fingerprint and publication validation are untouched. V5h carries it: one entry publishes from `target`, authors the pull request under `project`, and trusted `PullRequestAPI` middleware delegates with the selection and working directory the Push invocation itself observed. The saved request names `project` and `target` under their own identities, the pull request opens on `target`'s branch, and continuing from a real unanswered question without that middleware restores the same request and asks the service nothing further. Without the correction V5h fails and V5a-V5g still pass. architecture.md's general invocation-binding paragraph said no REPL entry owns a binding over PullRequest and its reads; it now says what the PR-specific section already said. --- architecture.md | 24 ++--- .../deno/retained-repository/pull-request.ts | 44 +++++++-- .../retained-repository-pull-request.test.ts | 99 +++++++++++++++++++ 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index 3bcec9dd8..ed4df6796 100644 --- a/architecture.md +++ b/architecture.md @@ -5225,17 +5225,19 @@ A host that retains nothing named after an invocation runs the same bodies over the public feature operations, with no identity authenticated, because there is nothing for one to name. Only an operation a host retains *above* the public chain, named after an invocation it authenticates, takes its operation as an -argument, and that is `Git.Push` and `Issue`. A workflow run retains -`PullRequest` and its three evidence reads too — the upsert through the Git-host -reconciliation beneath it, each read as policy on `PullRequestReadExecution` — -but it installs that retention under their feature Api instead of owning a -boundary over it, and no REPL entry owns one either, so those four ask their Api -directly as any other component does. A REPL entry retains the four Git reads -the same way — `GitQuery` middleware at its own implementation slot — which is -what lets those four stay ordinary registrations under a host that retains every -page they answer with. That is what makes an ordinary run, a workflow run, a -provider-absent runtime and a REPL entry four policies rather than four -components. +argument. For a REPL entry that is all six: `Git.Push` and `Issue`, and +`PullRequest` with its three evidence reads, each declared through the common +invocation binding so the saved request and the admitted read can be named after +the site that asked for them. A workflow run retains `PullRequest` and its three +evidence reads too — the upsert through the Git-host reconciliation beneath it, +each read as policy on `PullRequestReadExecution` — but it installs that +retention under their feature Api instead of owning a boundary over it, so there +those four ask their Api directly as any other component does. A REPL entry +retains the four Git reads that way as well — `GitQuery` middleware at its own +implementation slot — which is what lets those four stay ordinary registrations +under a host that retains every page they answer with. That is what makes an +ordinary run, a workflow run, a provider-absent runtime and a REPL entry four +policies rather than four components. The lifetime of what a policy hands over is the ordinary one: an operation runs in the scope of the invocation that asked for it, and cancellation, teardown and diff --git a/packages/git/src/deno/retained-repository/pull-request.ts b/packages/git/src/deno/retained-repository/pull-request.ts index b6f98511c..8adc76184 100644 --- a/packages/git/src/deno/retained-repository/pull-request.ts +++ b/packages/git/src/deno/retained-repository/pull-request.ts @@ -85,7 +85,7 @@ import { type PullRequestInputs, type PullRequestResult, } from "../../composition/pull-request-records.ts"; -import { sameRepositoryIdentity } from "../../composition/selection.ts"; +import { sameRepositoryIdentity, type RepositoryIdentity } from "../../composition/selection.ts"; import type { RepositorySelection } from "../../composition/selection.ts"; import { reconcileOwnedGitHostEffect } from "../../git-host/effect.ts"; import type { GitHostProvider } from "../../git-host/api.ts"; @@ -509,22 +509,50 @@ function readback( return record; } -/** Whether every place this saved request names is one this entry holds. */ +/** + * Whether every place this saved request names is one this entry holds. + * + * Each place carries the Repository identity that place is a place *of*, and is + * held to a placement under that one. The three can legitimately disagree: a + * handler may answer the authored question with another Repository this entry + * selected and published from, and the record then names the checkout the + * invocation authenticated under the authored Repository and the checkout the + * dispatch received and routing resolved under the delegated one. Asking for + * all three under a single identity refuses that composition, and asking for + * paths alone would accept a checkout some *other* Repository placed. + * + * `replPullRequestPlacesHeld()` in `../../retained-repository/push-admission.ts` + * is the same rule over saved history, and the two have to keep saying the same + * thing: one of them refusing a request the other admits is a history this + * entry can write and never continue. + */ function placesHeld( record: ReplPullRequestRequestRecord, held: readonly ReplPushPlacement[], ): boolean { - const places: readonly (readonly [string, string])[] = [ - [record.requested.place.selectionCheckout, record.requested.place.commonDirectory], - [record.received.place.selectionCheckout, record.received.place.commonDirectory], - [record.resolved.checkoutRoot, record.resolved.commonDirectory], + const places: readonly (readonly [RepositoryIdentity, string, string])[] = [ + [ + record.requested.place.repository, + record.requested.place.selectionCheckout, + record.requested.place.commonDirectory, + ], + [ + record.received.place.repository, + record.received.place.selectionCheckout, + record.received.place.commonDirectory, + ], + [ + record.received.place.repository, + record.resolved.checkoutRoot, + record.resolved.commonDirectory, + ], ]; - return places.every(([checkoutRoot, commonDirectory]) => + return places.every(([identity, checkoutRoot, commonDirectory]) => held.some( (placement) => placement.checkoutRoot === checkoutRoot && placement.commonDirectory === commonDirectory && - sameRepositoryIdentity(placement.identity, record.received.place.repository), + sameRepositoryIdentity(placement.identity, identity), ), ); } diff --git a/packages/git/tests/retained-repository-pull-request.test.ts b/packages/git/tests/retained-repository-pull-request.test.ts index 51eb6475a..b32a96cf1 100644 --- a/packages/git/tests/retained-repository-pull-request.test.ts +++ b/packages/git/tests/retained-repository-pull-request.test.ts @@ -63,6 +63,7 @@ import type { GitPushOutcome, PullRequestReadResult, PullRequestResult, + RepositorySelection, } from "@executablemd/git/api"; import { PullRequestOperations } from "../src/composition/pull-request-operations.ts"; import type { PullRequestUpsertInvocation } from "../src/composition/pull-request-operations.ts"; @@ -1355,6 +1356,104 @@ describe("one entry's pull request, composed", () => { expect(creations(forge)).toBe(1); }); + it("V5h: a delegated Repository saves each place under its own identity", function* () { + const fixture = yield* useFixture(); + const forge = store(fixture); + const inner = new InMemoryStream(); + const reached = withResolvers(); + let target: { repository: RepositorySelection; workingDirectory: string } | undefined; + + // One entry, two Repositories. The branch is published from `target`; the + // pull request is authored under `project`, and trusted middleware answers + // the authored question with `target`'s own selection and working + // directory. That is ordinary input transformation: it fabricates no + // selection, no identity and no publication, and the typed Api permits it. + const source = [ + ``, + ' ', + " ", + ' Ready for review.', + ' ', + ' ', + " ", + " ", + "", + ``, + ' Ready for review.', + " ", + QUESTION, + "", + ].join("\n"); + + const delegating = function* (): Operation { + // Where `target` published from, taken from the Push invocation itself so + // the document stays the document a person would write: the selection and + // working directory below are the ones that operation observed, not ones + // this row composed. + yield* Git.around({ + *push([invocation], next): Operation { + target = { + repository: invocation.repository, + workingDirectory: invocation.workingDirectory, + }; + return yield* next(invocation); + }, + }); + yield* PullRequestAPI.around({ + *upsert([pullRequest, options], next): Operation { + if (target === undefined) { + throw new Error("the target Repository never published anything"); + } + return yield* next(pullRequest, { ...options, ...target }); + }, + }); + }; + + // A genuine unfinished history: the pull request is open and acknowledged, + // and the entry is standing at a question it has not answered. + const prefix = yield* cutAt(fixture, source, inner, forge, reached, { + atQuestion: reached, + between: delegating, + }); + expect(requests(prefix).length).toBe(1); + expect(upserts(prefix).length).toBe(1); + expect(creations(forge)).toBe(1); + + const record = savedRequest(requests(prefix)[0]); + expect(record).toBeDefined(); + // Three places, and each is a place of something different: the invocation + // authenticated `project`, the dispatch received `target`'s own selection, + // and routing resolved the worktree that selection was working in. All + // three are placements this entry holds, and no single identity holds them + // all — which is the composition the old validation refused. + expect(record?.requested.place.repository.name).toBe("project"); + expect(record?.received.place.repository.name).toBe("target"); + expect(record?.received.place.selectionCheckout).toBe(target?.repository.checkoutPath); + expect(record?.resolved.checkoutRoot).toBe(target?.workingDirectory); + expect(record?.resolved.checkoutRoot).not.toBe(record?.requested.place.selectionCheckout); + // And the pull request that opened is the delegated Repository's own work. + expect(record?.resolved.inputs.headBranch).toBe("review/redirect"); + expect(forge.pullRequests.at(-1)?.headRef).toBe("review/redirect"); + + // Continuing without the middleware that delegated: the saved request is + // consumed before anything is dispatched, so the restored pull request is + // still the one `target` published, and nothing is asked of the service + // a second time. + const resumed = yield* run(fixture, source, replaying(prefix), forge, { + answer: { choice: "continue" }, + }); + + expect(resumed.refusal).toBeUndefined(); + const restored = savedRequest(requests(resumed.events)[0]); + expect(requests(resumed.events).length).toBe(1); + expect(restored?.requested.place.repository.name).toBe("project"); + expect(restored?.received.place.repository.name).toBe("target"); + expect(upserts(resumed.events).length).toBe(1); + expect(creations(forge)).toBe(1); + expect(patches(forge)).toBe(0); + expect(mutations(forge)).toEqual(["create"]); + }); + it("V5e: a body child effect precedes the request, and a failed body contacts nothing", function* () { const fixture = yield* useFixture(); const forge = store(fixture); From 1aae56ac65e5fa9fdf29eaf019f048ac538705ba Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 19 Oct 2026 14:54:49 -0400 Subject: [PATCH 17/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Retain=20a=20pull-re?= =?UTF-8?q?quest=20read=20once,=20for=20every=20host=20that=20retains=20on?= =?UTF-8?q?e=20(#181,=205/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow and REPL entries each had their own answer to the same questions: what a sound evidence read is, when one may be acknowledged, what a saved one has to be before it is restored, and how a failure is written down. Two answers to one question is one answer too many, and the drift was invisible until now: the workflow half checked only the collection kind and acknowledged from inside its durable operation, so a checks envelope describing another commit was saved as a success and a cleanup that failed could not take back what had already been published. The corrections this delivery made were real, and they existed on one host only. The rule now lives in the feature and both hosts call it. One worker, one attempt coordinator, one validator, one restoration path, one request name, one history reader and one failure rule, in the package's neutral half: retainPullRequestRead(request, transport) pullRequestReadAdmission(acceptOwner) validatePullRequestReadResult(answered, kind) What a host still supplies is what only it can know: the identity a fresh read is asked under, and which recorded owners its own history may inherit. The Git Plugin installs the general admission for every execution it declares for, and a REPL entry installs the same factory with its own strict owner predicate. Both old algorithms are deleted rather than kept behind a wrapper, the entry-only admission module with them. So a workflow run now refuses a checks envelope describing another commit, refuses an answer it cannot record, and closes a read's attempt before acknowledging it; and an ordinary read is held to the same contract after the public chain composes over it, before `items` becomes a binding. One saved shape changes meaning: a checks success whose items contradict its recorded head is refused rather than continued from, with no repair, deletion, coercion or re-fetch — what it says about one commit and what it says about the other cannot both be true, so there is no reading of it to continue from. The read's name is unchanged, byte for byte, now computed over the platform's crypto rather than a synchronous native digest so a journal's own vocabulary is readable wherever a record is read. It still omits the owner, so a fork inherits what it was forked from. Three histories captured on the unedited tree are committed under packages/git/tests/fixtures/pull-request-reads/ with their hashes, including one the old worker accepted and this one deliberately refuses. They are never to be regenerated: a changed name, payload or position has to show up as a failure rather than be absorbed by a new capture. --- architecture.md | 72 ++- packages/cli/tests/repl-pull-request.test.ts | 71 ++- .../components/PullRequestReads.ts | 16 +- .../pull-request-read-retention.ts | 437 ++++++++++++++++++ .../composition/pull-request-operations.ts | 162 +------ .../src/deno/retained-repository/provider.ts | 9 +- .../retained-repository/pull-request-reads.ts | 249 +--------- packages/git/src/plugin.ts | 9 + .../pull-request-read-admission.ts | 166 ------- .../composition-component-binding.test.ts | 58 +++ .../fixtures/pull-request-reads/PROVENANCE.md | 29 ++ .../legacy-inconsistent-checks.json | 105 +++++ .../legacy-saved-error.json | 90 ++++ .../legacy-valid-success.json | 99 ++++ packages/git/tests/module-partition.test.ts | 2 +- packages/git/tests/plugin.test.ts | 100 +++- packages/git/tests/pull-request-read.test.ts | 326 ++++++++++++- ...ined-repository-pull-request-reads.test.ts | 89 ++-- ...ed-repository-pull-request-records.test.ts | 240 +++++++--- packages/git/tests/support/composition.ts | 4 +- specs/executable-mdx-spec.md | 13 +- specs/repl-spec.md | 8 +- specs/workflow-workspace-spec.md | 23 + 23 files changed, 1648 insertions(+), 729 deletions(-) create mode 100644 packages/git/src/composition/pull-request-read-retention.ts delete mode 100644 packages/git/src/retained-repository/pull-request-read-admission.ts create mode 100644 packages/git/tests/fixtures/pull-request-reads/PROVENANCE.md create mode 100644 packages/git/tests/fixtures/pull-request-reads/legacy-inconsistent-checks.json create mode 100644 packages/git/tests/fixtures/pull-request-reads/legacy-saved-error.json create mode 100644 packages/git/tests/fixtures/pull-request-reads/legacy-valid-success.json diff --git a/architecture.md b/architecture.md index ed4df6796..b4e90adba 100644 --- a/architecture.md +++ b/architecture.md @@ -2591,6 +2591,17 @@ Git-host evidence read is an ordinary durable effect, and the machinery that makes a mutation exactly-once is deliberately absent rather than reused where it would only add a second identity to keep in agreement. +One implementation of that read serves every host that retains one. A workflow +run and a REPL entry ask the same questions — what a sound answer is, when one +may be acknowledged, what a saved one has to be before it is restored, how a +failure is written down, and what a composed answer has to be before it binds — +and those questions have one answer each, in the feature. What a host supplies +is only what nothing else can know: the identity a fresh read is asked under, +and which recorded owners its own history may inherit. A host supplies no +validator, no coordinator, no serializer and no choice of worker, because two +implementations of one rule is one implementation too many: they drift, and the +drift is invisible until a journal written under one is read under the other. + What such a read must not do is answer partially. A collection the host completed and a collection nobody could finish reading are opposite facts, and only one of them is `[]`; a truncated list would report the first while meaning @@ -7330,29 +7341,41 @@ on continuation and may bind a different array from the same saved snapshot, and nothing presents those transformations as saved. A handler that never reaches the seam retains nothing and runs again. -A read is acknowledged only once the attempt that produced it has finished. The -transport's own session and page walk belong to a scope of the read's own, and -that scope is closed and joined before either outcome is published — otherwise a -reopen could restore a read whose first run was still unwinding when the journal -said it was done. The attempt reports its own outcome rather than raising it, -so a scope that returns at all is a scope that closed, and that return is what -admits an outcome to publication: an expected read failure is this read's answer and is -retained as one, while a throw from that boundary — a cleanup that threw, or any -failure nobody there authored, including one indistinguishable from the read's -own — publishes nothing at all and activates the run's existing fail-stop state. -An interruption publishes nothing, which leaves the read repeatable — safe in -the way repeating a mutation is not. +From the seam inward, an entry's read is the feature's read and nothing about it +is this host's own. A read is acknowledged only once the attempt that produced +it has finished: the transport's own session and page walk belong to a scope of +the read's own, and that scope is closed and joined before either outcome is +published — otherwise a reopen could restore a read whose first run was still +unwinding when the journal said it was done. The attempt reports its own outcome +rather than raising it, so a scope that returns at all is a scope that closed, +and that return is what admits an outcome to publication: an expected read +failure is this read's answer and is retained as one, while a throw from that +boundary — a cleanup that threw, or any failure nobody there authored, +including one indistinguishable from the read's own — publishes nothing and +activates the run's existing fail-stop state. An interruption publishes nothing, +which leaves the read repeatable — safe in the way repeating a mutation is not. What may be acknowledged is also held to the whole evidence contract before it -is published, again when it is restored, and again after the outer dispatch has -composed over it. A collection whose items no reader can parse, and a checks -envelope holding an item about a commit other than the head it recorded, are -answers whose meaning is unknown rather than answers to acknowledge; the head -question in particular is one the element's own return contract cannot ask, -because the envelope's head never reaches the binding. Continuing a saved read -asks the same of its description: the input, the name and the configuration the -engine's own divergence policy reads all have to be the ones its own question -produces. +is published, again when it is restored, again when a history is admitted before +any of it replays, and again after the outer dispatch has composed over it. A +collection whose items no reader can parse, and a checks envelope holding an +item about a commit other than the head it recorded, are answers whose meaning +is unknown rather than answers to acknowledge; the head question in particular +is one the element's own return contract cannot ask, because the envelope's head +never reaches the binding. Continuing a saved read asks the same of its +description: the input, the name and the configuration the engine's own +divergence policy reads all have to be the ones its own question produces. + +Those are the feature's rules, so a workflow run obeys them too. It had its own +read worker once, checking only the collection kind and acknowledging from +inside the durable operation, and the difference was not a policy — it was a +drift. A workflow run therefore now refuses a checks envelope describing another +commit, refuses an answer it cannot record, and closes a read's attempt before +acknowledging it, and a saved checks success whose items contradict its recorded +head is refused rather than continued from. That last one is deliberate about +old records: what such a record says about one commit and what it says about the +other cannot both be true, so there is no reading of it to continue from, and +nothing repairs, deletes, coerces or re-fetches it. Who is asking travels nowhere public. Each authored read installs a bridge in a @@ -7365,7 +7388,12 @@ borrow. An entry-wide pointer, a value on `next` or a Context identity would each have to be correct about an ordering nothing guarantees. The read's own durable name deliberately omits the owner, so a fork can inherit it; ownership is therefore checked explicitly from the record, including on a failed read's -description, which is the only thing a refusal retains. +description, which is the only thing a refusal retains — by an owner policy the +host supplies to the one history reader, strictly here and permissively for a +run whose own journal construction already settled whose history it inherited. +The name's digest is computed where every host can reach it, over the platform's +`crypto` rather than a synchronous native one, because a journal's own +vocabulary has to be readable wherever a record is read. ### What one entry retains about the issue it filed diff --git a/packages/cli/tests/repl-pull-request.test.ts b/packages/cli/tests/repl-pull-request.test.ts index 99bcef284..cf8142e7c 100644 --- a/packages/cli/tests/repl-pull-request.test.ts +++ b/packages/cli/tests/repl-pull-request.test.ts @@ -34,7 +34,16 @@ import { gitSession } from "../../git/src/deno/composition/git.ts"; import { gitPlugin } from "../../git/src/plugin.ts"; import { replRepositoryInstallation } from "../../git/src/deno/retained-repository/provider.ts"; import { REPL_REPOSITORY_PULL_REQUEST_REQUEST } from "../../git/src/retained-repository/pull-request-records.ts"; -import { PULL_REQUEST_READ } from "../../git/src/composition/pull-request-read-records.ts"; +import { + PULL_REQUEST_READ, + pullRequestReadRequestJson, + readRequest, +} from "../../git/src/composition/pull-request-read-records.ts"; +import type { PullRequestReadRequest } from "../../git/src/composition/pull-request-read-records.ts"; +import { + PullRequestReadHistoryError, + pullRequestReadName, +} from "../../git/src/composition/pull-request-read-retention.ts"; import { GIT_HOST_EFFECT } from "../../git/src/git-host/effect-type.ts"; import type { HelperAssembly } from "../../git/src/deno/composition/credential-helper.ts"; import { selectReplEntryHost } from "../src/deno-repositories.ts"; @@ -385,17 +394,36 @@ describe("how a REPL entry gets its pull-request capability", () => { expect(passive.initialized).toBe(0); }); - it("D5: the entry declares one admission for its reads beside the others", function* () { + it("D5: the entry's own admission accepts its reads and refuses another entry's", function* () { const fixture = yield* useFixture(); + const owner = '["executablemd.repl.issue-owner",1,"execution-pr-cli-5","1"]'; const built = yield* scoped(function* (): Operation { - return yield* replRepositoryInstallation( - '["executablemd.repl.issue-owner",1,"execution-pr-cli-5","1"]', - { host: denoRepositoryHost(), root: fixture.managed }, - ); + return yield* replRepositoryInstallation(owner, { + host: denoRepositoryHost(), + root: fixture.managed, + }); }); // Three: the issues history, this entry's publication and pull-request // history, and its evidence reads. expect((built.admissions ?? []).length).toBe(3); + + // What the third one is for. The read name deliberately omits the owner so + // an inherited read can restore, so the question a name cannot answer — + // whose read this is — is the one this entry's own policy asks, through the + // same reader every host uses. + const url = "https://github.com/octo/project/pull/7"; + const mine = yield* readEvent(readRequest(url, "reviews", undefined, owner, "7")); + const theirs = yield* readEvent(readRequest(url, "reviews", undefined, `${owner}-other`, "7")); + expect(yield* admitted(built, [mine])).toBeUndefined(); + expect(yield* admitted(built, [theirs])).toBe("foreign-owner"); + + // Asked of a failed read's description too: a refusal retains no result, + // and its description is the only thing that says who asked. + expect( + yield* admitted(built, [ + { ...theirs, result: { status: "err", error: { message: "unreadable" } } }, + ]), + ).toBe("foreign-owner"); }); }); @@ -408,3 +436,34 @@ function* nothingUnder(path: string): Operation { return true; } } + +/** One retained evidence read, described the way the feature describes its own. */ +function* readEvent(request: PullRequestReadRequest): Operation { + const named = yield* pullRequestReadName(request); + return { + type: "yield", + coroutineId: "root", + description: { + type: PULL_REQUEST_READ, + name: named.name, + configuration: named.configuration, + input: pullRequestReadRequestJson(request), + }, + result: { status: "ok", value: { kind: "reviews", items: [] } }, + }; +} + +/** What every admission this entry contributed makes of a history. */ +function* admitted( + installation: ExecutionInstallation, + events: readonly DurableEvent[], +): Operation { + try { + for (const admission of installation.admissions ?? []) { + yield* admission(events); + } + } catch (error) { + return error instanceof PullRequestReadHistoryError ? error.reason : "unexpected"; + } + return undefined; +} diff --git a/packages/git/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts index 2473041d6..a116b52ed 100644 --- a/packages/git/src/composition/components/PullRequestReads.ts +++ b/packages/git/src/composition/components/PullRequestReads.ts @@ -28,6 +28,7 @@ import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; import { COMPOSITION_ORIGIN } from "../definitions.ts"; import { PullRequestReadError } from "../errors.ts"; +import { validatePullRequestReadResult } from "../pull-request-read-retention.ts"; import { pullRequestReadResultJson } from "../pull-request-read-records.ts"; import { canonicalPullRequestUrl, pullRequestProviderName } from "../pull-request-target.ts"; import type { PullRequestReadKind } from "../pull-request-read-records.ts"; @@ -194,15 +195,12 @@ function read( ); } - const result = yield* read({ url, kind, provider }); - if (result.kind !== kind) { - throw new PullRequestReadError( - "protocol", - element, - "the selected provider answered with a different collection than the one this element " + - "asked for.", - ); - } + // Whatever the public chain composed over the answer, what this element + // binds has to be evidence. The same validator the retained path appends + // and restores through is asked here, after every live transformation and + // before any of it becomes a binding — a question the element's own return + // contract cannot ask, because an envelope's head never reaches the array. + const result = validatePullRequestReadResult(yield* read({ url, kind, provider }), kind); return pullRequestReadResultJson(result); }; } diff --git a/packages/git/src/composition/pull-request-read-retention.ts b/packages/git/src/composition/pull-request-read-retention.ts new file mode 100644 index 000000000..0d92fde18 --- /dev/null +++ b/packages/git/src/composition/pull-request-read-retention.ts @@ -0,0 +1,437 @@ +/** + * One retained pull-request read, for every host that retains one. + * + * `PullRequest.Reviews`, `.Comments` and `.Checks` are read by a workflow run + * and by a REPL entry, and until this module existed each of those hosts had + * its own answer to the same questions: what a sound answer is, when one may be + * acknowledged, what a saved one has to be before it is restored, and what a + * failure encodes as. Two answers to one question is one answer too many — the + * workflow half checked only the collection kind and acknowledged from inside + * the durable operation, so a checks envelope describing another commit was + * saved as a success and a cleanup that failed could not take back what had + * already been published. Those are not two policies. They are one rule and one + * host that had drifted from it. + * + * So this is where the rule lives, and both hosts call it. What a host still + * owns is exactly what only it can know: the identity a fresh read is asked + * under, and which recorded owners its own history may inherit. A host does not + * decide what a valid answer is, when one is settled, how a failure is written + * down, or which worker performs the read. + * + * ## Four places, one validator + * + * The same answer is held to the same contract wherever it appears: before a + * success is appended, when a saved one is restored, when a history is admitted + * before any of it replays, and after the public chain has composed over it and + * before items reach a binding. The last of those is why the validator takes + * the value rather than a parsed record — an outer handler may answer with + * anything, and what the element binds has to be evidence whatever produced it. + * + * Validation runs *through the codec that writes the record* rather than over + * the object in hand, because what has to be the evidence a read produces is + * the value the journal would hold, not the shape a provider happened to build. + * + * ## The attempt, and what may be acknowledged + * + * A read opens things: an authenticated session, a page walk, whatever a + * provider acquires. The attempt therefore runs in a scope of its own, and that + * scope is closed before either outcome is published — otherwise a reopen could + * restore a read whose first run was still unwinding when the journal said it + * was done. + * + * The attempt reports its own refusal rather than raising it, so a scope that + * returns at all is a scope that closed, and that return is what admits an + * outcome to publication. Nothing about the exception a failure arrives as + * takes part in the decision; a body and its cleanup can raise one object, and + * a teardown error wins over a body error, so no inspection could separate + * them. A throw reaching the boundary is the boundary failing — a cleanup that + * threw, or a failure nobody here authored — and it publishes nothing and + * activates the run's existing fail-stop state. An interruption publishes + * nothing at all, which leaves the read repeatable: safe, in the way repeating + * a mutation is not. + * + * ## Why the name omits the owner + * + * A read is named by its expansion and the question it asks, and deliberately + * not by who asked. A fork is a different run reaching the same position with + * the same question, and a name carrying the run would make every inherited + * read unforkable. Which history a record belongs to is therefore asked + * separately, by the owner policy a host supplies — strictly, for an entry that + * must not present another entry's read; permissively, for a run whose journal + * construction already settled its provenance. + */ + +import { scoped, until } from "effection"; +import type { Operation } from "effection"; +import { getExpansion, isJsonObject, sourceDescription } from "@executablemd/core"; +import type { JournalAdmission } from "@executablemd/core/api"; +import { + createDurableOperation, + serializeError, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { + AbandonedRetainedHistory, + ActivateDurabilityFailure, + DurableEvent, + EffectDescription, + Json, + JournalProvenance, + LiveDurableOperationCoordinator, + Result as DurableResult, +} from "@executablemd/durable-streams"; +import { PullRequestReadError } from "./errors.ts"; +import { + parsePullRequestReadRequest, + parsePullRequestReadResult, + PULL_REQUEST_READ, + pullRequestReadEnvelopeJson, + pullRequestReadHeadsAgree, + pullRequestReadRequestJson, +} from "./pull-request-read-records.ts"; +import type { + PullRequestReadKind, + PullRequestReadRequest, + PullRequestReadResult, +} from "./pull-request-read-records.ts"; + +/** Which element a refusal names, by the collection it was reading. */ +const ELEMENT: Readonly> = Object.freeze({ + reviews: "", + comments: "", + checks: "", +}); + +/** Which element a pull-request read refusal names, by its collection. */ +export function pullRequestReadElement(kind: PullRequestReadKind): string { + return ELEMENT[kind]; +} + +/** + * Which recorded owners a host's own history may inherit. + * + * A predicate and nothing more: it answers about an owner this module has + * already parsed out of a record, and it never reads a record, replaces a + * value, reaches another execution's state or decides anything else. + */ +export type ReadOwnerPolicy = (recordedOwner: string) => boolean; + +/** Why a retained evidence-read history cannot be continued. */ +export type PullRequestReadHistoryRefusal = + /** A retained read whose description this host cannot read. */ + | "malformed-record" + /** A retained read naming a history that is not this one's. */ + | "foreign-owner" + /** A retained read journaled under a name or configuration its own question does not produce. */ + | "request-fingerprint" + /** A settled read holding a value that is not the evidence a read produces. */ + | "malformed-result"; + +/** + * A retained evidence-read history that cannot be continued from. + * + * Fatal by construction: it extends `StaleInputError`, so it ends the attempt + * rather than becoming a value a document can print away and carry on past. + */ +export class PullRequestReadHistoryError extends StaleInputError { + override name = "PullRequestReadHistoryError"; + + readonly reason: PullRequestReadHistoryRefusal; + + constructor(reason: PullRequestReadHistoryRefusal, sentence: string) { + super(sentence); + this.reason = reason; + } +} + +/** + * The evidence this value is, or the refusal that it is none. + * + * One validator, four callers. The value is parsed, written through the record + * codec and parsed again, because the contract is about what this position + * would append rather than about the object a provider built. A collection + * other than the one asked for, an item shape no reader can parse, and a checks + * envelope holding an item about a commit other than the head it recorded are + * each answers whose meaning is unknown, and an unknown answer is not one to + * acknowledge, restore or bind. + */ +export function validatePullRequestReadResult( + answered: unknown, + kind: PullRequestReadKind, +): PullRequestReadResult { + const offered = readValue(answered); + if (offered === undefined || offered.kind !== kind) { + throw new PullRequestReadError( + "protocol", + ELEMENT[kind], + "the answer it received is not the evidence that read produces, so nothing was retained " + + "for it.", + ); + } + const written = parsePullRequestReadResult(pullRequestReadEnvelopeJson(offered)); + if (written === undefined || written.kind !== kind) { + throw new PullRequestReadError( + "protocol", + ELEMENT[kind], + "the answer it received is not a value this read can record, so nothing was retained " + + "for it.", + ); + } + if (!pullRequestReadHeadsAgree(written)) { + throw new PullRequestReadError( + "protocol", + ELEMENT[kind], + "the checks it received include one about a commit other than the head they were read " + + "at, so what they say about that commit is unknown and nothing was retained for it.", + ); + } + return written; +} + +/** + * This value as the codec can read it, or nothing. + * + * The final boundary is handed whatever an outer handler answered with, so the + * value arrives as `unknown` and the parse below is what decides. Narrowing it + * here keeps that one question in one place. + */ +function readValue(answered: unknown): PullRequestReadResult | undefined { + return isJsonObject(answered as Json) ? parsePullRequestReadResult(answered as Json) : undefined; +} + +/** + * The durable name and configuration one read's question produces. + * + * The platform's `crypto`, adapted with `until`: this is shared code and it + * names no host, so the digest cannot be the synchronous native one. The bytes + * are the ones every existing record was named under — each value as its + * JavaScript UTF-16 length, a colon and the value, `null` as `-`, concatenated, + * encoded as big-endian UTF-16 code units, SHA-256, first sixteen hex + * characters — because a name that moved would make every saved read a + * different read. + */ +export function* pullRequestReadName(request: PullRequestReadRequest): Operation<{ + readonly name: string; + readonly configuration: string; +}> { + const canonical = [request.operation, request.url, request.provider, request.kind] + .map((value) => (value === null ? "-" : `${value.length}:${value}`)) + .join(""); + const digest = yield* until(crypto.subtle.digest("SHA-256", codeUnits(canonical))); + const configuration = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ) + .join("") + .slice(0, 16); + return { name: `${request.expansionId}:${configuration}`, configuration }; +} + +/** One string's UTF-16 code units, big-endian, two bytes each. */ +function codeUnits(value: string): Uint8Array { + const bytes = new Uint8Array(new ArrayBuffer(value.length * 2)); + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + bytes[index * 2] = unit >> 8; + bytes[index * 2 + 1] = unit & 0xff; + } + return bytes; +} + +/** How this read is described where it is journaled. */ +export function* describePullRequestRead( + request: PullRequestReadRequest, +): Operation { + const expansion = yield* getExpansion(); + const { name, configuration } = yield* pullRequestReadName(request); + return { + type: PULL_REQUEST_READ, + name, + input: pullRequestReadRequestJson(request), + configuration, + ...sourceDescription(expansion.position), + }; +} + +/** + * Retain this read, or restore what is already retained. + * + * Reached only after a transport has matched the request, admitted it against + * the host's current ceiling and validated the target — so the effect this + * creates always describes a question the execution was permitted to ask, and a + * refusal before this point saves nothing and opens no credential. Everything + * from here is durable: an expected failure is retained as this read's own + * answer and binds no partial collection, and a completed read restores its + * snapshot without opening a session or reaching the network. + */ +export function* retainPullRequestRead( + request: PullRequestReadRequest, + transport: () => Operation, +): Operation { + const description = yield* describePullRequestRead(request); + // One attempt per read, reachable only from the two halves of this operation. + // Nothing a provider, a handler or a document holds can put an answer in it. + const attempt: ReadAttempt = {}; + const stored: unknown = yield createDurableOperation( + description, + function* (): Operation { + try { + const answered = yield* transport(); + return pullRequestReadEnvelopeJson(validatePullRequestReadResult(answered, request.kind)); + } catch (error) { + // Reported rather than raised. An expected read failure is this read's + // own answer, and raising it would carry it out through the same + // channel that reports a scope which failed to close — where no + // inspection of the error could tell one of those from the other. A + // refusal has no envelope, and the value it returns is never read. + attempt.refusal = { error: readFailure(error) }; + return null; + } + }, + { coordinator: readCoordinator(attempt) }, + ); + return validatePullRequestReadResult(stored, request.kind); +} + +/** + * What one read's own attempt reported, as only this operation can see it. + * + * Present and holding the failure when the read raised instead of answering, + * and absent otherwise — boxed, so a read that raised something undefined still + * reports a refusal. It is written before the attempt's scope begins to close + * and read only after that scope has closed, which is what keeps it an account + * of the read rather than of the unwinding. + */ +interface ReadAttempt { + refusal?: { readonly error: Error }; +} + +/** + * This read's attempt boundary: finish, tear down, join, then acknowledge. + * + * A live operation that appended nothing would leave the next one to append at + * this one's position, so a boundary failure activates the run's existing + * first-failure fence rather than quietly continuing. + */ +function readCoordinator(attempt: ReadAttempt): LiveDurableOperationCoordinator { + return { + *run( + execute: () => Operation, + publish: (result: DurableResult) => Operation, + activateFailure: ActivateDurabilityFailure, + _journalProvenance: JournalProvenance | undefined, + _abandoned: AbandonedRetainedHistory, + ): Operation { + let answered: T; + try { + answered = yield* scoped(execute); + } catch (error) { + throw activateFailure( + error instanceof Error ? error : new Error("the evidence read boundary failed"), + ); + } + // The scope returned, so the attempt and everything it opened are closed, + // and what the attempt reported is now this read's outcome. + const refusal = attempt.refusal; + const result: DurableResult = + refusal === undefined + ? { status: "ok", value: answered } + : { status: "err", error: serializeError(refusal.error) }; + yield* publish(result); + return result; + }, + }; +} + +/** + * The failure a new attempt records. + * + * An actual Error keeps its own name, message and stack, because that is what + * the reader of a failed read needs. Anything else carries no diagnosis worth + * encoding, and one fixed sentence is better than a host-specific rendering of + * a value nobody can interpret. + */ +function readFailure(raised: unknown): Error { + return raised instanceof Error ? raised : new Error("the pull-request evidence read failed"); +} + +/** + * What a host requires of the evidence reads its history holds. + * + * Integrity, ownership and result, asked of every retained read before any of + * it replays. The description is held to the name and configuration its own + * question produces — the two are not redundant, because the name a position + * publishes under is the invocation plus the fingerprint, so a record moved to + * another position keeps a fingerprint its name no longer exposes. + * + * Ownership is the host's policy and nothing else here is. It is asked of a + * failed read's description too: a failure retains no result to check, but its + * description still says who asked. Nothing here contacts a URL, opens a + * session or hands a result out, and whether the ceiling still authorizes a + * saved read is a different question the adapter asks on every execution. + */ +export function pullRequestReadAdmission(acceptOwner: ReadOwnerPolicy): JournalAdmission { + return function* (retained: readonly DurableEvent[]): Operation { + for (const event of retained) { + if (event.type !== "yield" || event.description.type !== PULL_REQUEST_READ) { + continue; + } + const request = parsePullRequestReadRequest(event.description["input"]); + if (request === undefined) { + throw historyRefusal( + "malformed-record", + "a retained evidence read in this history is not one this host can read, so what it " + + "says was read is unknown.", + ); + } + if (!acceptOwner(request.runId)) { + throw historyRefusal( + "foreign-owner", + "a retained evidence read in this history was written under another owner, so the " + + "collection it describes was not read here and its record is not one this history " + + "can present again.", + ); + } + // The name and the configuration, but not the whole description: this + // runs over a retained history before any element is expanding, and the + // source position a description also carries is only available there. + const named = yield* pullRequestReadName(request); + if (event.description.name !== named.name) { + throw historyRefusal( + "request-fingerprint", + "a retained evidence read is journaled under a name that is not the name its own " + + "question produces, so the record and the read it names do not match.", + ); + } + if (event.description.configuration !== named.configuration) { + throw historyRefusal( + "request-fingerprint", + "a retained evidence read is journaled with a configuration that is not the one its " + + "own question produces, so the record and the read it describes do not match.", + ); + } + if (event.result.status !== "ok") { + // A refused read retains no result of its own: its outcome is the + // durable operation's failed result, and there is nothing in it to hold + // to a collection. Its description was still held to this history above. + continue; + } + try { + validatePullRequestReadResult(event.result.value, request.kind); + } catch { + throw historyRefusal( + "malformed-result", + "a retained evidence read holds a value that is not the collection its own question " + + "asked for, or a checks item for a different head than the one it recorded, so " + + "what it says was read is unknown.", + ); + } + } + }; +} + +function historyRefusal( + reason: PullRequestReadHistoryRefusal, + sentence: string, +): PullRequestReadHistoryError { + return new PullRequestReadHistoryError(reason, sentence); +} diff --git a/packages/git/src/deno/composition/pull-request-operations.ts b/packages/git/src/deno/composition/pull-request-operations.ts index 265e982f4..ccfccf760 100644 --- a/packages/git/src/deno/composition/pull-request-operations.ts +++ b/packages/git/src/deno/composition/pull-request-operations.ts @@ -37,12 +37,8 @@ * 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 { getExpansion } from "@executablemd/core"; 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 { @@ -50,160 +46,12 @@ import { type PullRequestReadInvocation, type PullRequestUpsertInvocation, } from "../../composition/pull-request-operations.ts"; -import { - parsePullRequestReadResult, - PULL_REQUEST_READ, - pullRequestReadEnvelopeJson, - pullRequestReadRequestJson, - readRequest, -} from "../../composition/pull-request-read-records.ts"; +import { readRequest } from "../../composition/pull-request-read-records.ts"; +import { retainPullRequestRead } from "../../composition/pull-request-read-retention.ts"; -import type { - PullRequestReadKind, - PullRequestReadRequest, - PullRequestReadResult, -} from "../../composition/pull-request-read-records.ts"; +import type { PullRequestReadResult } from "../../composition/pull-request-read-records.ts"; import type { PullRequestResult } from "../../composition/pull-request-records.ts"; -import { parseJsonValue } from "@executablemd/workflow"; import { getWorkflowRun } from "@executablemd/workflow"; -import { gitOperationFingerprint } from "./operations.ts"; - -/** Which element a refusal names, by the collection it was reading. */ -const ELEMENT: Readonly> = Object.freeze({ - reviews: "", - comments: "", - checks: "", -}); - -/** - * The durable description one read is retained under. - * - * Shared with the entry's own retained reads: the name is already in journals a - * fork reads, so there is one algorithm for it and this is where it lives. The - * entry supplies its owner as the run and its authenticated invocation as the - * expansion; nothing else about it differs. - */ -/** - * The name one read is published under, and the configuration it covers. - * - * Separate from the description below because a name has to be computable - * where a description is not. An entry's admission recomputes it over a - * retained history, before any element is expanding, and the source position a - * description also carries is diagnostic and only available there. - * - * 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. - */ -export function pullRequestReadName(request: PullRequestReadRequest): { - readonly name: string; - readonly configuration: string; -} { - const configuration = gitOperationFingerprint([ - request.operation, - request.url, - request.provider, - request.kind, - ]); - return { name: `${request.expansionId}:${configuration}`, configuration }; -} - -export function* describePullRequestRead( - request: PullRequestReadRequest, -): Operation { - const expansion = yield* getExpansion(); - const { name, configuration } = pullRequestReadName(request); - return { - type: PULL_REQUEST_READ, - name, - input: pullRequestReadRequestJson(request), - configuration, - ...sourceDescription(expansion.position), - }; -} - -/** - * 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* () { - const description = yield* describePullRequestRead(request); - - const stored = yield createDurableOperation( - description, - function* (): Operation { - const answered = yield* perform(); - 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); - }, - ); - - return retainedPullRequestRead(stored, request.kind, element); - }); -} - -/** - * The read this retained value describes, or the refusal that it describes none. - * - * Shared by both retained profiles, and applied on both of their paths: a live - * answer is held to it before it is appended, and a restored one before it is - * handed back. A retained value that is not the evidence that read produces, - * or that is a different collection than the one asked for, is refused rather - * than bound. - */ -export function retainedPullRequestRead( - stored: unknown, - kind: PullRequestReadKind, - element: string, -): PullRequestReadResult { - 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 !== kind) { - throw new PullRequestReadError( - "protocol", - element, - "what this run retained for it is not the evidence that read produces.", - ); - } - return result; -} - -/** Which element a pull-request read refusal names, by its collection. */ -export function pullRequestReadElement(kind: PullRequestReadKind): string { - return ELEMENT[kind]; -} /** * Make an admitted read durable, for this scope and below. @@ -218,7 +66,7 @@ export function useRetainedPullRequestReads(): Operation { *perform([admitted, transport]): Operation { const run = yield* getWorkflowRun(); const expansion = yield* getExpansion(); - return yield* retainDurableRead( + return yield* retainPullRequestRead( readRequest(admitted.url, admitted.kind, admitted.provider, run.runId, expansion.id), transport, ); diff --git a/packages/git/src/deno/retained-repository/provider.ts b/packages/git/src/deno/retained-repository/provider.ts index 549ee5180..56725b6c9 100644 --- a/packages/git/src/deno/retained-repository/provider.ts +++ b/packages/git/src/deno/retained-repository/provider.ts @@ -87,10 +87,10 @@ import { useGitHubPullRequestReads } from "../composition/pull-request-reads.ts" import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; import { replPullRequestPolicy } from "./pull-request.ts"; import { - replPullRequestReadAdmission, replPullRequestReadPolicy, useReplPullRequestReadOperations, } from "./pull-request-reads.ts"; +import { pullRequestReadAdmission } from "../../composition/pull-request-read-retention.ts"; import { pullRequestDeclaration } from "../../composition/components/PullRequest.ts"; import { PULL_REQUEST_READ_KINDS, @@ -231,7 +231,12 @@ export function* replRepositoryInstallation( const admissions: JournalAdmission[] = [ replIssueAdmission(owner), replPushAdmission(owner, publications), - replPullRequestReadAdmission(owner), + // This entry's own reads and nobody else's. The read name deliberately + // omits the owner so an inherited read can restore, so the one question a + // name cannot answer is asked here, against the owner this entry was + // derived for — of a failed read's description too, which still says who + // asked. + pullRequestReadAdmission((recordedOwner) => recordedOwner === owner), ]; return { components, diff --git a/packages/git/src/deno/retained-repository/pull-request-reads.ts b/packages/git/src/deno/retained-repository/pull-request-reads.ts index f4e7c01ae..d22e219f0 100644 --- a/packages/git/src/deno/retained-repository/pull-request-reads.ts +++ b/packages/git/src/deno/retained-repository/pull-request-reads.ts @@ -58,18 +58,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; -import { createDurableOperation, serializeError } from "@executablemd/durable-streams"; -import type { - AbandonedRetainedHistory, - ActivateDurabilityFailure, - DurableEvent, - Json, - JournalProvenance, - LiveDurableOperationCoordinator, - Result as DurableResult, -} from "@executablemd/durable-streams"; -import type { JournalAdmission } from "@executablemd/core/host"; import type { Around } from "@effectionx/context-api"; import type { BindOperations } from "../../composition/invocation-binding.ts"; import { PullRequestReadError } from "../../composition/errors.ts"; @@ -84,32 +73,16 @@ import type { AdmittedPullRequestRead, PullRequestReadExecutionApi, } from "../../composition/pull-request-read-execution.ts"; -import { - parsePullRequestReadResult, - PULL_REQUEST_READ, - pullRequestReadEnvelopeJson, - pullRequestReadHeadsAgree, - readRequest, -} from "../../composition/pull-request-read-records.ts"; - -import { - admitReplPullRequestReadRequest, - admitReplPullRequestReadResult, - replPullRequestReadConfigurationRefusal, - replPullRequestReadNameRefusal, -} from "../../retained-repository/pull-request-read-admission.ts"; +import { readRequest } from "../../composition/pull-request-read-records.ts"; import type { PullRequestReadKind, - PullRequestReadRequest, PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; import { - describePullRequestRead, pullRequestReadElement, - pullRequestReadName, - retainedPullRequestRead, -} from "../composition/pull-request-operations.ts"; + retainPullRequestRead, +} from "../../composition/pull-request-read-retention.ts"; /** What one authored read carries that nothing public may name. */ interface ReadBridge { @@ -136,8 +109,7 @@ function* read( const bridge: ReadBridge = { owner, site, spent: false, open: true }; yield* PullRequestReadExecution.around(retaining(bridge, invocation.kind), { at: "min" }); try { - const answered = yield* PullRequestOperations.operations.read(invocation); - return validRead(answered, invocation.kind, pullRequestReadElement(invocation.kind)).result; + return yield* PullRequestOperations.operations.read(invocation); } finally { bridge.open = false; } @@ -189,230 +161,19 @@ function retaining( } bridge.spent = true; - return yield* retain( + return yield* retainPullRequestRead( // Owner in the run, and the authenticated invocation in the expansion, // which is the existing request shape. The question is the one that was // *admitted*: a handler that rewrote the URL or the discriminator before // the adapter matched it changed which read this is, and the record says // so. readRequest(admitted.url, admitted.kind, admitted.provider, bridge.owner, bridge.site), - element, transport, ); }, }; } -/** - * Retain this admitted read, or restore what is already retained. - * - * One durable position, described and named by the existing algorithm so a - * journal a fork reads keeps saying the same thing. The whole envelope is - * validated before it is appended and again when it is read back: a value that - * is not the evidence this read produces is refused rather than bound, and only - * `items` ever becomes the component's binding. - * - * An expected failure after admission is retained as this read's own and binds - * no partial array; an interruption before acknowledgement leaves the read - * repeatable, which is safe in the way repeating a mutation is not. - */ -function* retain( - request: PullRequestReadRequest, - element: string, - transport: () => Operation, -): Operation { - const description = yield* describePullRequestRead(request); - // One attempt per read, reachable only from the two halves of this operation. - // Nothing a provider, a handler or a document holds can put an answer in it. - const attempt: ReadAttempt = {}; - const stored: unknown = yield createDurableOperation( - description, - function* (): Operation { - try { - const answered = yield* transport(); - return validRead(answered, request.kind, element).envelope; - } catch (error) { - // Reported rather than raised. An expected read failure is this read's - // own answer, and raising it would carry it out through the same - // channel that reports a scope which failed to close — where no - // inspection of the error could tell one of those from the other. A - // refusal has no envelope, and the value it returns is never read. - attempt.refusal = { error }; - return null; - } - }, - { coordinator: readCoordinator(attempt) }, - ); - return restoredRead(stored, request.kind, element); -} - -/** - * What one read's own attempt reported, as only this operation can see it. - * - * Present and holding the failure when the admitted read raised instead of - * answering, and absent otherwise — boxed, so a read that raised something - * undefined still reports a refusal. It is written before the attempt's scope - * begins to close and read only after that scope has closed, which is what - * keeps it an account of the read rather than of the unwinding. - */ -interface ReadAttempt { - refusal?: { readonly error: unknown }; -} - -/** - * This read's attempt boundary: finish, tear down, join, then acknowledge. - * - * A read opens things — an authenticated session, a page walk, whatever a - * provider acquires to answer. Publishing while those are still open would - * acknowledge an answer the attempt that produced it has not finished - * producing, and a reopen would then restore a read whose first run was still - * unwinding when the journal said it was done. So the attempt runs in a scope - * of its own and the scope is closed before either outcome is published. - * - * The attempt reports its own outcome rather than raising it, so a scope that - * returns at all is a scope that closed, and that return is what admits an - * outcome to publication. Nothing about the exception a failure arrives as - * takes part in the decision. An expected read failure is this read's answer - * and is retained as one; a throw reaching here is the boundary itself failing - * — a cleanup that threw while the attempt unwound, or any failure nobody here - * authored, including one indistinguishable from the read's own — and it - * publishes nothing and activates the run's existing fail-stop state, because a - * live operation that appended nothing would otherwise leave the next one to - * append at this one's position. An interruption publishes nothing at all. - */ -function readCoordinator(attempt: ReadAttempt): LiveDurableOperationCoordinator { - return { - *run( - execute: () => Operation, - publish: (result: DurableResult) => Operation, - activateFailure: ActivateDurabilityFailure, - _journalProvenance: JournalProvenance | undefined, - _abandoned: AbandonedRetainedHistory, - ): Operation { - let answered: T; - try { - answered = yield* scoped(execute); - } catch (error) { - throw activateFailure( - error instanceof Error ? error : new Error("the evidence read boundary failed"), - ); - } - // The scope returned, so the attempt and everything it opened are closed, - // and what the attempt reported is now this read's outcome. - const refusal = attempt.refusal; - const result: DurableResult = - refusal === undefined - ? { status: "ok", value: answered } - : { status: "err", error: serializeError(errorOf(refusal.error)) }; - yield* publish(result); - return result; - }, - }; -} - -function errorOf(value: unknown): Error { - return value instanceof Error ? value : new Error("the evidence read failed"); -} - -/** - * The envelope this answer retains, or this read's own refusal that it is none. - * - * Held to the whole contract before a success may be published, and held to it - * through the codec that writes the record rather than over the object in hand: - * what has to be the evidence that read produces is the value this position - * would append. An item shape no reader can parse and a checks collection - * describing a commit other than the head it was read at are both answers whose - * meaning is unknown, and an unknown answer is not one to acknowledge. - */ -function validRead( - answered: PullRequestReadResult, - kind: PullRequestReadKind, - element: string, -): { readonly result: PullRequestReadResult; readonly envelope: Json } { - if (answered.kind !== kind) { - throw new PullRequestReadError( - "protocol", - element, - "the selected provider answered with a different collection than the one this element " + - "asked for.", - ); - } - const envelope = pullRequestReadEnvelopeJson(answered); - const parsed = parsePullRequestReadResult(envelope); - if (parsed === undefined || parsed.kind !== kind) { - throw new PullRequestReadError( - "protocol", - element, - "the answer it received is not the evidence that read produces, so nothing was retained " + - "for it.", - ); - } - if (!pullRequestReadHeadsAgree(parsed)) { - throw new PullRequestReadError( - "protocol", - element, - "the checks it received include one about a commit other than the head they were read " + - "at, so what they say about that commit is unknown and nothing was retained for it.", - ); - } - return { result: parsed, envelope }; -} - -/** The retained value this read restores, held to the same whole contract. */ -function restoredRead( - stored: unknown, - kind: PullRequestReadKind, - element: string, -): PullRequestReadResult { - const result = retainedPullRequestRead(stored, kind, element); - if (!pullRequestReadHeadsAgree(result)) { - throw new PullRequestReadError( - "protocol", - element, - "what this entry retained for it includes a check about a commit other than the head it " + - "recorded, so what it says about that commit is unknown.", - ); - } - return result; -} - -/** - * What this entry requires of the evidence reads its history holds. - * - * The record, the owner and the retained collection are the neutral - * admission's questions; what is asked here is the one it cannot ask, because - * the read's name is a synchronous native digest that belongs to this half of - * the package. - */ -export function replPullRequestReadAdmission(owner: string): JournalAdmission { - return function* (retained: readonly DurableEvent[]): Operation { - for (const event of retained) { - if (event.type !== "yield" || event.description.type !== PULL_REQUEST_READ) { - continue; - } - const request = admitReplPullRequestReadRequest(owner, event.description["input"]); - // The name and the configuration, but not the whole description: this - // runs over a retained history before any element is expanding, and the - // source position a description also carries is only available there. - const named = pullRequestReadName(request); - if (event.description.name !== named.name) { - throw replPullRequestReadNameRefusal(); - } - if (event.description.configuration !== named.configuration) { - throw replPullRequestReadConfigurationRefusal(); - } - - if (event.result.status !== "ok") { - // A refused read retains no result of its own: its outcome is the - // durable operation's failed result, and there is nothing in it to hold - // to a collection. Its description was still held to this entry above. - continue; - } - admitReplPullRequestReadResult(request, event.result.value); - } - }; -} - /** * Forward this entry's profile-level reads to the transport surface. * diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts index cbbe2fc2e..78082d239 100644 --- a/packages/git/src/plugin.ts +++ b/packages/git/src/plugin.ts @@ -36,6 +36,7 @@ import type { import { useCompositionComponents } from "./composition/installation.ts"; import { admitReplRepositoryHistory } from "./retained-repository/admission.ts"; import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.ts"; +import { pullRequestReadAdmission } from "./composition/pull-request-read-retention.ts"; /** * The commands that execute a document, or describe what one could write. @@ -206,6 +207,14 @@ export const gitPlugin: Plugin = Plugin({ admissions: [ gitHostIdentityAdmission(), issueIdentityAdmission(), + // Every retained evidence read, wherever it was retained: that its + // description parses, is named by the question it carries and holds the + // collection that question asked for. Who may have asked is the only + // part a host decides, and this contribution decides nothing about it — + // a run's own journal construction already settled whose history it + // inherited, and an entry adds its stricter policy from its own + // installation. + pullRequestReadAdmission(() => true), // The REPL's own: what an entry retained about the checkout it was // standing in has to be readable before any of it is replayed. It // belongs to this contribution because an admission is applied inside diff --git a/packages/git/src/retained-repository/pull-request-read-admission.ts b/packages/git/src/retained-repository/pull-request-read-admission.ts deleted file mode 100644 index af2c36567..000000000 --- a/packages/git/src/retained-repository/pull-request-read-admission.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Whether one entry's retained evidence reads may be continued at all. - * - * Three questions. **Integrity**: every retained read's description parses as - * the closed normalized request. **Ownership**: every one of them names this - * entry's owner. **Result**: a settled one holds the envelope its own - * collection produces, with every check it carries about the head it recorded. - * - * Ownership is asked explicitly, and that is the whole reason this boundary - * exists. The read name deliberately omits the owner — a fork is a different - * run reaching the same position with the same question, and a name carrying - * the run would make every inherited read unforkable — so the name alone cannot - * tell this entry's read from another's. A record whose `runId` is somebody - * else's is refused however correct its name is, and that is asked of a failed - * read's description too: a failure retains no result to check, but its - * description still says who asked. - * - * Nothing here contacts a URL, opens a session or hands a result out. - * Replaying a completed read is the durable engine's own positional matching, - * and this runs before any of it. Whether the ceiling still authorizes a saved - * read is a different question, asked by the adapter on every execution. - * - * ## Why the name check is not here - * - * The read's name is a synchronous native digest, and that digest belongs to - * the runtime-named half of this package rather than to this graph — the same - * split the saved publication request keeps when it leaves its origin's - * fingerprint to the provider that reads it back. So the caller computes the - * name and this module answers everything a name cannot: what the record is, - * whose it is, and whether its value is the evidence that read produces. - */ - -import { StaleInputError } from "@executablemd/durable-streams"; -import type { Json } from "@executablemd/durable-streams"; -import { - parsePullRequestReadRequest, - parsePullRequestReadResult, - pullRequestReadHeadsAgree, -} from "../composition/pull-request-read-records.ts"; - -import type { - PullRequestReadRequest, - PullRequestReadResult, -} from "../composition/pull-request-read-records.ts"; - -/** Why one entry's retained evidence-read history cannot be continued. */ -export type ReplPullRequestReadRefusal = - /** A retained read whose description this host cannot read. */ - | "malformed-record" - /** A retained read naming a history that is not this entry's. */ - | "foreign-owner" - /** A retained read journaled under a name its own question does not produce. */ - | "request-fingerprint" - /** A settled read holding a value that is not the evidence a read produces. */ - | "malformed-result"; - -/** - * A retained evidence-read history this entry cannot continue from. - * - * Fatal by construction: it extends `StaleInputError`, so it ends the attempt - * rather than becoming a value a document can print away and carry on past. - */ -export class ReplPullRequestReadHistoryError extends StaleInputError { - override name = "ReplPullRequestReadHistoryError"; - - readonly reason: ReplPullRequestReadRefusal; - - constructor(reason: ReplPullRequestReadRefusal, sentence: string) { - super(sentence); - this.reason = reason; - } -} - -/** - * The question one retained read asked, held to this entry, or the refusal. - * - * Asked of every retained read, settled or failed, because a description says - * who asked whether or not there is a result beside it. - */ -export function admitReplPullRequestReadRequest( - owner: string, - input: Json | undefined, -): PullRequestReadRequest { - const request = parsePullRequestReadRequest(input); - if (request === undefined) { - throw readRefusal( - "malformed-record", - "a retained evidence read in this entry's history is not one this host can read, so what " + - "it says was read is unknown.", - ); - } - if (request.runId !== owner) { - throw readRefusal( - "foreign-owner", - "a retained evidence read in this entry's history was written under another owner, so the " + - "collection it describes was not read by this entry and its record is not one this " + - "entry can present again.", - ); - } - return request; -} - -/** The refusal a read journaled under the wrong name gets. */ -export function replPullRequestReadNameRefusal(): ReplPullRequestReadHistoryError { - return readRefusal( - "request-fingerprint", - "a retained evidence read is journaled under a name that is not the name its own question " + - "produces, so the record and the read it names do not match.", - ); -} - -/** - * The refusal a read whose saved configuration is not its question's gets. - * - * A description carries the configuration beside the name, and the two are not - * redundant: the name a position publishes under is the invocation plus the - * fingerprint, so a record moved to another position keeps a fingerprint the - * name no longer exposes. Both are asked, because the engine's own divergence - * policy reads the configuration and a record whose configuration disagrees - * with the question beside it describes two different reads. - */ -export function replPullRequestReadConfigurationRefusal(): ReplPullRequestReadHistoryError { - return readRefusal( - "request-fingerprint", - "a retained evidence read is journaled with a configuration that is not the one its own " + - "question produces, so the record and the read it describes do not match.", - ); -} - -/** - * The collection one settled read retained, or the refusal that it retained - * something else. - * - * The envelope has to be the one this question asked for, and a checks envelope - * has to be internally consistent: every item about the head it recorded. That - * is asked even of an empty collection, because an empty checks result still - * says which head was read and that head is the whole of what it reports. - */ -export function admitReplPullRequestReadResult( - request: PullRequestReadRequest, - value: Json | undefined, -): PullRequestReadResult { - const envelope = value === undefined ? undefined : parsePullRequestReadResult(value); - if (envelope === undefined || envelope.kind !== request.kind) { - throw readRefusal( - "malformed-result", - "a retained evidence read holds a value that is not the collection its own question asked " + - "for, so what it says was read is unknown.", - ); - } - if (!pullRequestReadHeadsAgree(envelope)) { - throw readRefusal( - "malformed-result", - "a retained checks read holds an item for a different head than the one it recorded, so " + - "what it says about that commit is unknown.", - ); - } - return envelope; -} - -function readRefusal( - reason: ReplPullRequestReadRefusal, - sentence: string, -): ReplPullRequestReadHistoryError { - return new ReplPullRequestReadHistoryError(reason, sentence); -} diff --git a/packages/git/tests/composition-component-binding.test.ts b/packages/git/tests/composition-component-binding.test.ts index b8d65c42a..6ff9eb0a8 100644 --- a/packages/git/tests/composition-component-binding.test.ts +++ b/packages/git/tests/composition-component-binding.test.ts @@ -224,6 +224,24 @@ function paired(name: string): { }; } +/** One check run, in the shape a checks envelope carries. */ +function check(headSha: string) { + return { + kind: "check-run" as const, + id: "1", + headSha, + name: "build", + status: "completed" as const, + conclusion: "success" as const, + url: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + }; +} + describe("one registry for every repository operation, and the policy an execution owns", () => { it("A1: describes one component whichever path carries it", function* (): Operation { // The whole vocabulary is registered ordinarily, and an execution that owns @@ -448,6 +466,46 @@ describe("one registry for every repository operation, and the policy an executi expect(runs[0]).toEqual(runs[1]); }); + it("B2: an ordinary read binds evidence or nothing, whatever answered it", function* () { + const root = yield* useRoot(); + const head = "c".repeat(40); + const other = "e".repeat(40); + const source = [ + ``, + "", + ].join("\n"); + + // An ordinary run retains nothing, so this answer is never written down — + // and it still has to be evidence before it becomes a binding. The + // envelope says it was read at one commit and carries an item about + // another, which is two answers at once, and whichever a reader picked + // would be wrong about the other. + const refused = yield* run(root, source, [], function* () { + yield* PullRequestOperations.around({ + // deno-lint-ignore require-yield + *read(): Operation { + return { kind: "checks", headSha: head, items: [check(other)] }; + }, + }); + }); + expect(refused.refusal).toBeDefined(); + expect(refused.output).not.toContain("build"); + + // The control: the same shape, agreeing with the head it was read at, and + // answered entirely by a handler. An ordinary read is fresh — nothing here + // is restored from anywhere — and a sound answer binds. + const bound = yield* run(root, source, [], function* () { + yield* PullRequestOperations.around({ + // deno-lint-ignore require-yield + *read(): Operation { + return { kind: "checks", headSha: head, items: [check(head)] }; + }, + }); + }); + expect(bound.refusal).toBeUndefined(); + expect(bound.output).toContain("build"); + }); + it("A5: cancelling an invocation stops the operation it was performing", function* (): Operation { const root = yield* useRoot(); const seen = watched(); diff --git a/packages/git/tests/fixtures/pull-request-reads/PROVENANCE.md b/packages/git/tests/fixtures/pull-request-reads/PROVENANCE.md new file mode 100644 index 000000000..ebc1403d0 --- /dev/null +++ b/packages/git/tests/fixtures/pull-request-reads/PROVENANCE.md @@ -0,0 +1,29 @@ +# Legacy Workflow read histories + +Captured from pristine `2f975b01acd33c1085612bf7da3fc848adb571c3` *before* the +shared-read amendment, because the third history is one the corrected worker +deliberately refuses and therefore cannot be produced again. Never regenerate +these files: a changed name, payload or source position must show up as a +failure here rather than be absorbed by a new capture. + +Each file holds the document that produced it and the replayable journal prefix +— every event the run appended except the root's own close, which is what an +unfinished history has. + +| File | SHA-256 of the file | What it is | +| --- | --- | --- | +| `legacy-valid-success.json` | `8d6377772a94584e7dbf1ec378c80b321a5392a0e65688a3df7d8545db2d2696` | one completed `` read, proven to restore on the old worker with no further request | +| `legacy-saved-error.json` | `76e53571a10a897ff4b2010e0a30168eefba472d4d68e1a134be25114f30874a` | one expected read failure saved as this read's own (`status: "err"`), proven to restore on the old worker with no further request | +| `legacy-inconsistent-checks.json` | `3ba5a6f70b450532ff81c89896b98250034835ecb08c80ef617e0743872bf6f9` | one `` success whose item names another commit, which the old kind-only validation appended without noticing | + +Capture command, run from the implementation worktree against the unedited tree: + + deno test --allow-all --frozen --config deno.json \ + /capture-legacy-reads.test.ts + +The capture used the real Workflow profile — `gitPluginAdmissions()` plus +`retainedWorkflowInstallation` through `runWorkflowDocument` — and the suite's +own GitHub payload shapes. The third history's envelope was handed to the +retained seam by a handler in front of the adapter, because the provider itself +refuses a check naming another commit: the inconsistent envelope is what +middleware above a provider could always have saved. diff --git a/packages/git/tests/fixtures/pull-request-reads/legacy-inconsistent-checks.json b/packages/git/tests/fixtures/pull-request-reads/legacy-inconsistent-checks.json new file mode 100644 index 000000000..0f8507858 --- /dev/null +++ b/packages/git/tests/fixtures/pull-request-reads/legacy-inconsistent-checks.json @@ -0,0 +1,105 @@ +{ + "source": "\n\n{checks.length} checks\n", + "events": [ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "workflow_run", + "name": "workflow_run", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + }, + "result": { + "status": "ok", + "value": { + "runId": "legacy-checks", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "", + "content": "\n\n{checks.length} checks\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "PullRequest.Checks", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "pull_request_read", + "name": "a8ba70b07ca74f5df8e9e9bfb000d4bab32f17a5657f4d4d4275b747183b6674:9ef3d4bde8322ffc", + "input": { + "operation": "read", + "url": "https://github.com/octo/project/pull/7", + "provider": null, + "kind": "checks", + "runId": "legacy-checks", + "expansionId": "a8ba70b07ca74f5df8e9e9bfb000d4bab32f17a5657f4d4d4275b747183b6674" + }, + "configuration": "9ef3d4bde8322ffc", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "checks", + "headSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "items": [ + { + "kind": "check-run", + "id": "1", + "headSha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "build", + "status": "completed", + "conclusion": "success", + "url": null, + "startedAt": null, + "completedAt": null, + "title": null, + "summary": null, + "text": null + } + ] + } + } + } + ] +} diff --git a/packages/git/tests/fixtures/pull-request-reads/legacy-saved-error.json b/packages/git/tests/fixtures/pull-request-reads/legacy-saved-error.json new file mode 100644 index 000000000..41066b062 --- /dev/null +++ b/packages/git/tests/fixtures/pull-request-reads/legacy-saved-error.json @@ -0,0 +1,90 @@ +{ + "source": "\n", + "events": [ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "workflow_run", + "name": "workflow_run", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + }, + "result": { + "status": "ok", + "value": { + "runId": "legacy-error", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "", + "content": "\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "PullRequest.Reviews", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "pull_request_read", + "name": "46c689c2e470e024ba6344caffd83ee83b6e376a20b1157365115b20c95aa097:6af2737ac2d30964", + "input": { + "operation": "read", + "url": "https://github.com/octo/project/pull/7", + "provider": null, + "kind": "reviews", + "runId": "legacy-error", + "expansionId": "46c689c2e470e024ba6344caffd83ee83b6e376a20b1157365115b20c95aa097" + }, + "configuration": "6af2737ac2d30964", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "err", + "error": { + "message": " could not read the evidence it names: the Git host did not answer with the complete collection. None of what it did answer is evidence that there is nothing there.", + "name": "PullRequestReadError", + "stack": "PullRequestReadError: could not read the evidence it names: the Git host did not answer with the complete collection. None of what it did answer is evidence that there is nothing there.\n at file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-pr945-sharedread/packages/git/src/deno/composition/pull-request-reads.ts:339:19\n at Generator.next ()\n at file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-pr945-sharedread/packages/git/src/deno/composition/pull-request-operations.ts:150:33\n at Generator.next ()\n at Object.run (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-pr945-sharedread/packages/durable-streams/live-coordinator.ts:60:46)\n at run.next ()\n at file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-pr945-sharedread/packages/durable-streams/effect.ts:405:45\n at Generator.next ()\n at TaskGroup (file:///Users/tarasmankovski/Repositories/taras/executable.md/.claude/worktrees/issue-181-pr945-sharedread/node_modules/.deno/effection@4.1.0/node_modules/effection/esm/lib/task-group.js:35:27)\n at TaskGroup.next ()" + } + } + } + ] +} diff --git a/packages/git/tests/fixtures/pull-request-reads/legacy-valid-success.json b/packages/git/tests/fixtures/pull-request-reads/legacy-valid-success.json new file mode 100644 index 000000000..14e0a29d9 --- /dev/null +++ b/packages/git/tests/fixtures/pull-request-reads/legacy-valid-success.json @@ -0,0 +1,99 @@ +{ + "source": "\n\n{reviews.length} reviews\n", + "events": [ + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "workflow_run", + "name": "workflow_run", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + }, + "result": { + "status": "ok", + "value": { + "runId": "release-1.4", + "bundleHash": "e22b9d94280c8b07aac19569452576323e1662e729d36609f72fd8be44a74d6c" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "__root__" + }, + "result": { + "status": "ok", + "value": { + "kind": "repository", + "path": "", + "content": "\n\n{reviews.length} reviews\n" + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "import_component", + "name": "PullRequest.Reviews", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "registered", + "origin": "@executablemd/workflow/composition", + "reserved": false + } + } + }, + { + "type": "yield", + "coroutineId": "root", + "description": { + "type": "pull_request_read", + "name": "46c689c2e470e024ba6344caffd83ee83b6e376a20b1157365115b20c95aa097:6af2737ac2d30964", + "input": { + "operation": "read", + "url": "https://github.com/octo/project/pull/7", + "provider": null, + "kind": "reviews", + "runId": "release-1.4", + "expansionId": "46c689c2e470e024ba6344caffd83ee83b6e376a20b1157365115b20c95aa097" + }, + "configuration": "6af2737ac2d30964", + "executablemd.source-position": { + "path": "", + "offset": 0, + "line": 1, + "column": 1 + } + }, + "result": { + "status": "ok", + "value": { + "kind": "reviews", + "items": [ + { + "id": "1", + "author": "reviewer", + "state": "approved", + "body": "ship it", + "submittedAt": "2026-08-24T00:00:00Z", + "commitSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "url": "https://github.test/pr/7#r1" + } + ] + } + } + } + ] +} diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 21ba24ea1..35e45ba42 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -73,6 +73,7 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/composition/pull-request-operations.ts", "src/composition/pull-request-read-execution.ts", "src/composition/pull-request-read-records.ts", + "src/composition/pull-request-read-retention.ts", "src/composition/pull-request-records.ts", "src/composition/pull-request-target.ts", "src/composition/push-evidence.ts", @@ -102,7 +103,6 @@ const PROVIDER_NEUTRAL: readonly string[] = [ "src/retained-repository/observation-records.ts", "src/retained-repository/placement-records.ts", "src/retained-repository/publication-evidence.ts", - "src/retained-repository/pull-request-read-admission.ts", "src/retained-repository/pull-request-records.ts", "src/retained-repository/push-admission.ts", "src/retained-repository/push-records.ts", diff --git a/packages/git/tests/plugin.test.ts b/packages/git/tests/plugin.test.ts index 97b7c247d..66b5bf091 100644 --- a/packages/git/tests/plugin.test.ts +++ b/packages/git/tests/plugin.test.ts @@ -25,6 +25,16 @@ import type { PluginInstallRequest } from "@executablemd/core/api"; import { retainedWorkflowInstallation } from "../../workflow/src/run.ts"; import type { WorkflowRun } from "../../workflow/src/run.ts"; import { declaresFor, gitPlugin } from "../src/plugin.ts"; +import { + PullRequestReadHistoryError, + pullRequestReadName, +} from "../src/composition/pull-request-read-retention.ts"; +import { + PULL_REQUEST_READ, + pullRequestReadRequestJson, + readRequest, +} from "../src/composition/pull-request-read-records.ts"; +import type { PullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; import { retainedGitHostIdentitiesHere, retainedIssueIdentitiesHere } from "../src/identities.ts"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; import { reconcileGitHostEffect, withGitHostProvider } from "@executablemd/git/api"; @@ -365,10 +375,98 @@ describe("the Git Plugin's command profile", () => { yield* install.call(gitPlugin, { command: "workflow", args: ["workflow", "list"] }), ).toBe(undefined); const contribution = yield* install.call(gitPlugin, { command: "run", args: ["run"] }); - expect(contribution?.admissions).toHaveLength(2); + expect(contribution?.admissions).toHaveLength(3); + }); + + it("admits every retained evidence read, under any owner, for a command it declares for", function* () { + const contribution = yield* installed({ command: "run", args: ["run"] }); + const readable = yield* readEvent( + readRequest(READ_URL, "reviews", undefined, "some-other-execution", "7"), + { status: "ok", value: { kind: "reviews", items: [] } }, + ); + + // The general contribution restricts no owner: a run's own journal + // construction already settled whose history it inherited, which is what + // lets a fork read the reads it was forked from. + expect(yield* admit(contribution, [readable])).toBeUndefined(); + + // What it does require is that the record is readable and says what it is. + // This is the shared reader, so a checks envelope describing another commit + // is refused here exactly as it is in an entry. + const inconsistent = yield* readEvent( + readRequest(READ_URL, "checks", undefined, "some-other-execution", "7"), + { + status: "ok", + value: { + kind: "checks", + headSha: "a".repeat(40), + items: [ + { + kind: "check-run", + id: "1", + headSha: "b".repeat(40), + name: "build", + status: "completed", + conclusion: "success", + url: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + }, + ], + }, + }, + ); + expect(yield* admit(contribution, [inconsistent])).toBe("malformed-result"); + + const misnamed = yield* readEvent( + readRequest(READ_URL, "reviews", undefined, "some-other-execution", "7"), + { status: "ok", value: { kind: "reviews", items: [] } }, + "7:0123456789abcdef", + ); + expect(yield* admit(contribution, [misnamed])).toBe("request-fingerprint"); }); }); +const READ_URL = "https://github.com/octo/project/pull/7"; + +/** One retained evidence read, described the way the feature describes its own. */ +function* readEvent( + request: PullRequestReadRequest, + result: DurableEvent["result"], + name?: string, +): Operation { + const named = yield* pullRequestReadName(request); + return { + type: "yield", + coroutineId: "root", + description: { + type: PULL_REQUEST_READ, + name: name ?? named.name, + configuration: named.configuration, + input: pullRequestReadRequestJson(request), + }, + result, + }; +} + +/** What every admission this contribution carries makes of a history. */ +function* admit( + contribution: ExecutionInstallation, + events: readonly DurableEvent[], +): Operation { + try { + for (const admission of contribution.admissions ?? []) { + yield* admission(events); + } + } catch (error) { + return error instanceof PullRequestReadHistoryError ? error.reason : "unexpected"; + } + return undefined; +} + describe("one Plugin value, one execution's retained identities", () => { it("derives each execution's identities from its own history and no other", function* () { // One installation, asked of the Plugin once, and used by every execution diff --git a/packages/git/tests/pull-request-read.test.ts b/packages/git/tests/pull-request-read.test.ts index ee5ff2dec..9f5acecc4 100644 --- a/packages/git/tests/pull-request-read.test.ts +++ b/packages/git/tests/pull-request-read.test.ts @@ -12,13 +12,15 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { call, race, resource, scoped, suspend, until, withResolvers } from "effection"; +import { call, ensure, race, resource, scoped, suspend, until, withResolvers } from "effection"; import { exists } from "@effectionx/fs"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import type { Operation } from "effection"; import type { PullRequestReadResult } from "@executablemd/git/api"; import { PullRequestAPI } from "@executablemd/git/api"; +import { PullRequestReadExecution } from "../src/composition/pull-request-read-execution.ts"; import { parseGitHubPullRequestUrl, recognizesGitHubPullRequestUrl, @@ -240,6 +242,98 @@ function headOf(reading: EvidenceReading): string | undefined { : undefined; } +const MOVED = "c".repeat(40); + +/** One check run, in the shape a checks envelope carries. */ +function check(headSha: string) { + return { + kind: "check-run" as const, + id: "1", + headSha, + name: "build", + status: "completed" as const, + conclusion: "success" as const, + url: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + }; +} + +/** + * Run this document with the retained seam handed exactly this answer. + * + * In front of the adapter, so what the retained read is given is the answer a + * case names rather than a provider's — which is how a case says what the + * boundary must make of an answer it could otherwise never receive. The + * adapter below is configured and simply never asked. + */ +function answering( + database: WorkflowRunDatabase, + source: string, + envelope?: PullRequestReadResult, + transport?: () => Operation, +): Operation { + return scoped(function* () { + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + transport ?? + // deno-lint-ignore require-yield + function* (): Operation { + if (envelope === undefined) { + throw new Error("this case named no answer"); + } + return envelope; + }, + ); + }, + }); + return yield* runWorkflowDocument(database, source, reading(server({}))); + }); +} + +/** Whether this event is a retained evidence read. */ +function isRead(event: DurableEvent): boolean { + return event.type === "yield" && event.description.type === PULL_REQUEST_READ; +} + +/** + * One history captured before this boundary existed. + * + * Read from the bytes committed beside this suite rather than produced again: + * the whole question these rows ask is what today's code makes of what + * yesterday's wrote, and a regenerated fixture would answer it with itself. + */ +function* fixture(name: string): Operation<{ + readonly source: string; + readonly events: DurableEvent[]; + /** The run this history belongs to, which it is replayed as rather than onto. */ + readonly runId: string; +}> { + const path = join(import.meta.dirname ?? ".", "fixtures", "pull-request-reads", `${name}.json`); + const saved: { source: string; events: DurableEvent[] } = JSON.parse( + new TextDecoder().decode(yield* until(readFile(path))), + ); + const read = saved.events.find(isRead); + const input = read?.type === "yield" ? read.description.input : undefined; + const runId = isJsonObject(input) ? input.runId : undefined; + if (typeof runId !== "string") { + throw new Error(`${name} holds no retained read naming the run it belongs to`); + } + return { ...saved, runId }; +} + +/** Put a saved history in front of a fresh run, as an unfinished one. */ +function* seed(database: WorkflowRunDatabase, events: readonly DurableEvent[]): Operation { + for (const event of events) { + yield* database.journal.append(event); + } +} + describe("Tier PRR — pull-request evidence", () => { it("PRR3: every body and every check survives normalization byte for byte", function* () { const body = " Two spaces, a\ttab, and a trailing newline.\n"; @@ -1362,19 +1456,132 @@ describe("Tier PRR — pull-request evidence", () => { }); }); - it("PRR24: the access session is opened once per read and disposed with it", function* () { + it("PRR22b: an inconsistent checks envelope appends no success and binds nothing", function* () { + // Through the actual Workflow profile, with the envelope handed to the + // retained seam by a handler in front of the adapter: each item is well + // formed on its own, and what the collection cannot be is two answers at + // once — one about the head it recorded and one about another commit. + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const refused = yield* raised( + answering( + database, + `\n{checks.length}\n`, + { + kind: "checks", + headSha: HEAD, + items: [check(MOVED)], + }, + ), + ); + expect(String(refused)).toContain("other than the head"); + + // A live protocol failure, which is this read's own answer: the position + // holds a failed read rather than a success, and no array was bound. + const retained = yield* reads(database); + expect(retained).toHaveLength(1); + const outcome = retained[0]?.result; + expect(outcome?.status).toBe("err"); + expect(outcome && "value" in outcome).toBe(false); + }); + }); + + it("PRR22c: a malformed whole envelope appends no success and binds nothing", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const refused = yield* raised( + answering( + database, + `\n{reviews.length}\n`, + { + kind: "reviews", + // An item shape no reader can parse: the envelope is the collection + // asked for and still is not evidence. + items: [{ kind: "review", id: 1 }], + } as unknown as PullRequestReadResult, + ), + ); + expect(String(refused)).toContain("not the evidence that read produces"); + + const retained = yield* reads(database); + expect(retained).toHaveLength(1); + expect(retained[0]?.result.status).toBe("err"); + }); + }); + + it("PRR22d: a meaningful failure keeps its own details, and anything else gets one sentence", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + // An actual Error: name, message and stack are what a reader of a failed + // read needs, so they are preserved through the existing encoding. + const database = yield* createRun(); + const named = new Error("the review index is being rebuilt"); + named.name = "EvidenceUnavailable"; + yield* raised( + answering( + database, + `\n`, + undefined, + function* (): Operation { + throw named; + }, + ), + ); + const first = (yield* reads(database))[0]?.result; + expect(first?.status === "err" && first.error.name).toBe("EvidenceUnavailable"); + expect(first?.status === "err" && first.error.message).toBe( + "the review index is being rebuilt", + ); + + // Anything that is not an Error carries no diagnosis worth encoding, and + // one fixed sentence is better than a rendering of a value nobody can + // interpret. The same rule runs for an entry's reads: one normalization, + // not one per host. + const second = yield* createRun({ runId: "not-an-error" }); + yield* raised( + answering( + second, + `\n`, + undefined, + // deno-lint-ignore require-yield + function* (): Operation { + throw "the transport said no"; + }, + ), + ); + const outcome = (yield* reads(second))[0]?.result; + expect(outcome?.status === "err" && outcome.error.message).toBe( + "the pull-request evidence read failed", + ); + }); + }); + + it("PRR24: the session closes before the read it answered is acknowledged", function* () { const host = server({ [REVIEWS]: { body: "[]" } }); const events: string[] = []; + // What this read holds the boundary to is not that the session eventually + // closed, but that it had closed *before* the answer was acknowledged: a + // reopen that restored a read whose first run was still unwinding would be + // reading a journal that claimed work which had not finished. So the + // session's own teardown is the barrier, and it asks the journal what it + // holds at the moment it runs. + let acknowledgedAtClose: number | undefined; + let watched: WorkflowRunDatabase | undefined; const counted: GitHubSource = { endpoint: host.access.endpoint, open(): Operation { return resource(function* (provide) { events.push("open"); - try { - yield* provide(host.access); - } finally { + yield* ensure(function* (): Operation { events.push("close"); - } + const journal = watched === undefined ? [] : yield* watched.journal.readAll(); + acknowledgedAtClose = journal.filter( + (event) => event.type === "yield" && event.description.type === PULL_REQUEST_READ, + ).length; + }); + yield* provide(host.access); }); }, }; @@ -1382,6 +1589,7 @@ describe("Tier PRR — pull-request evidence", () => { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { const database = yield* createRun(); + watched = database; yield* runWorkflowDocument( database, `\n`, @@ -1390,9 +1598,113 @@ describe("Tier PRR — pull-request evidence", () => { gitHubPullRequests: { access: counted, configuration: { allowed: [SUBJECT_REPO] } }, }, ); + // Opened once, disposed with the read, and nothing was acknowledged + // while it was still open. + expect(events).toEqual(["open", "close"]); + expect(acknowledgedAtClose).toBe(0); + expect(yield* reads(database)).toHaveLength(1); + }); + }); + + it("PRR24b: a cleanup that throws the read's own error acknowledges nothing", function* () { + const root = yield* useStorageRoot(); + const failure = new Error("the collection could not be read"); + yield* withStorage(root, function* () { + const database = yield* createRun(); + // One Error object raised by the read and again by its registered + // cleanup. Nothing about the exception separates the read's own refusal + // from a scope that could not close, so only what the attempt reported + // may decide — and a scope that did not close reports nothing. + const refused = yield* raised( + answering( + database, + `\n` + + `\n`, + undefined, + function* (): Operation { + yield* resource(function* (provide) { + yield* ensure(function* (): Operation { + throw failure; + }); + yield* provide(null); + }); + throw failure; + }, + ), + ); + expect(refused).toBeDefined(); + + // No outcome at all for the read whose attempt failed to close, and the + // fence the first failure activates means the read after it acknowledges + // nothing either. + expect(yield* reads(database)).toHaveLength(0); + }); + }); + + it("PRR32: a valid history captured before this boundary existed restores unchanged", function* () { + const legacy = yield* fixture("legacy-valid-success"); + const host = server({ [REVIEWS]: { body: "{}" } }); + + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun({ runId: legacy.runId }); + yield* seed(database, legacy.events); + const output = yield* runWorkflowDocument(database, legacy.source, reading(host)); + + // The saved answer, restored: the collection it recorded is what the + // document printed, nothing was asked of the service, and the record is + // the one that was already there rather than a new one beside it. + expect(String(output)).toContain("1 reviews"); + expect(host.requests).toEqual([]); + const retained = yield* reads(database); + expect(retained).toHaveLength(1); + expect(retained[0]).toEqual(legacy.events.find(isRead)); }); + }); + + it("PRR33: a failure captured before this boundary existed replays its own payload", function* () { + const legacy = yield* fixture("legacy-saved-error"); + const host = server({ [REVIEWS]: { body: "{}" } }); - expect(events).toEqual(["open", "close"]); + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun({ runId: legacy.runId }); + yield* seed(database, legacy.events); + const refused = yield* raised(runWorkflowDocument(database, legacy.source, reading(host))); + + // The old failure is this read's answer and stays exactly as it was + // written: no normalization of a payload that is already saved, no + // second append, and nothing asked of the service. + expect(String(refused)).toContain("did not answer with the complete collection"); + expect(host.requests).toEqual([]); + const retained = yield* reads(database); + expect(retained).toHaveLength(1); + expect(retained[0]).toEqual(legacy.events.find(isRead)); + }); + }); + + it("PRR34: a checks success whose item names another commit is refused, not repaired", function* () { + const legacy = yield* fixture("legacy-inconsistent-checks"); + const host = server({ [REVIEWS]: { body: "{}" } }); + + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun({ runId: legacy.runId }); + yield* seed(database, legacy.events); + const refused = yield* raised(runWorkflowDocument(database, legacy.source, reading(host))); + + // This record was appended by a worker that asked only for the + // collection kind. It is refused before any of the history replays, and + // deliberately: what it says about one commit and what it says about the + // other cannot both be true, so there is no reading of it to continue + // from. Refused is the whole of it — the journal is not rewritten, the + // record is not deleted or coerced, and nothing is fetched again. + expect(String(refused)).toContain("different head"); + expect(host.requests).toEqual([]); + const retained = yield* reads(database); + expect(retained).toHaveLength(1); + expect(retained[0]).toEqual(legacy.events.find(isRead)); + }); }); it("PRR26: an interruption in flight unwinds and retains no result", function* () { diff --git a/packages/git/tests/retained-repository-pull-request-reads.test.ts b/packages/git/tests/retained-repository-pull-request-reads.test.ts index e404f5221..0518d3fd9 100644 --- a/packages/git/tests/retained-repository-pull-request-reads.test.ts +++ b/packages/git/tests/retained-repository-pull-request-reads.test.ts @@ -35,7 +35,6 @@ import type { DurableEvent, DurableStream } from "@executablemd/durable-streams" import { executeInstalled } from "@executablemd/core/host"; import { collect, registerComponents } from "@executablemd/core"; import type { ComponentRegistration } from "@executablemd/core"; -import { useCompositionComponents } from "../src/composition/installation.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; import type { GitHubAccess, @@ -56,9 +55,8 @@ import type { CheckEvidence } from "../src/composition/pull-request-read-records import { PullRequestReadExecution } from "../src/composition/pull-request-read-execution.ts"; import { replRepositoryInstallation } from "../src/deno/retained-repository/provider.ts"; -import { admitReplRepositoryHistory } from "../src/retained-repository/admission.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; import { PULL_REQUEST_READ, readRequest } from "../src/composition/pull-request-read-records.ts"; -import { pullRequestReadName } from "../src/deno/composition/pull-request-operations.ts"; const OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","1"]'; const OTHER_OWNER = '["executablemd.repl.issue-owner",1,"execution-pr-reads","2"]'; @@ -70,7 +68,6 @@ const OTHER_URL = `${SUBJECT_REPO}/pull/9`; const HEAD = "a".repeat(40); /** The invocation a name vector states, since a name carries one. */ -const SITE = "expansion-9c1"; const PULL = "/repos/octo/project/pulls/7"; const REVIEWS = "/repos/octo/project/pulls/7/reviews"; @@ -226,7 +223,10 @@ function run( }, { at: "min" }, ); - yield* useCompositionComponents(); + // The Plugin's own contribution, for the command an entry runs under: its + // declarations and its general admissions, rather than a hand-assembled + // stand-in for them. + const git = yield* gitPluginAdmissions("repl"); if (options.between !== undefined) { yield* options.between(); } @@ -244,7 +244,7 @@ function run( output = String( yield* collect( yield* executeInstalled({ path, stream, includes: [fixture.root] }, [ - { admissions: [(retained) => admitReplRepositoryHistory(retained)] }, + git, ...(options.entry === false ? [] : [installation]), ...(options.shadowing === undefined ? [] @@ -940,6 +940,44 @@ describe("one entry's evidence reads", () => { expect(collection?.items.length).toBe(1); }); + it("V8g: one failure rule, whichever host the read belongs to", function* () { + const fixture = yield* useFixture(); + const host = reviewing(); + const stream = new InMemoryStream(); + // An actual Error keeps its own name and message, because that is what the + // reader of a failed read needs; anything else carries no diagnosis worth + // encoding and gets the one sentence both hosts use. The workflow half of + // this pair is PRR22d — one rule, not one per host. + const refused = yield* run( + fixture, + `\n`, + stream, + host, + { + *between(): Operation { + yield* PullRequestAPI.around({ + *read([url, options]): Operation { + return yield* PullRequestReadExecution.operations.perform( + { url, kind: options.kind, provider: options.provider }, + // deno-lint-ignore require-yield + function* (): Operation { + throw "the transport said no"; + }, + ); + }, + }); + }, + }, + ); + + expect(refused.refusal).toBeDefined(); + const saved = reads(yield* stream.readAll())[0]?.result; + expect(saved?.status).toBe("err"); + expect(saved?.status === "err" && saved.error.message).toBe( + "the pull-request evidence read failed", + ); + }); + it("V8b: cancelling a blocked read publishes nothing and joins its cleanup", function* () { const fixture = yield* useFixture(); const host = reviewing(); @@ -1176,45 +1214,6 @@ describe("one entry's evidence reads", () => { expect(reviewCalls(host)).toBe(1); }); - it("V6f: one read-name vector keeps the length-prefixed fingerprint, odd text and all", function* () { - // The other naming algorithm this feature touches, and deliberately not - // the canonical-JSON digest above: an evidence read is named the way every - // other Git operation in this package is named, so a journal a fork reads - // keeps answering. - // - // The vector is the first 16 hex digits of SHA-256 over the UTF-16 - // big-endian code units of `4:read38:-6:checks` — a null discriminator - // is "-" rather than a length, and the 38 counts code units, so the "ö" - // counts once. - const named = pullRequestReadName( - readRequest("https://github.com/octo/pröject/pull/7", "checks", undefined, OWNER, SITE), - ); - expect(named.configuration).toBe("6e1959358a175d22"); - expect(named.name).toBe(`${SITE}:6e1959358a175d22`); - - // The discriminator is part of the question, so naming one changes it. - expect( - pullRequestReadName( - readRequest("https://github.com/octo/pröject/pull/7", "checks", "github", OWNER, SITE), - ).configuration, - ).not.toBe(named.configuration); - - // The owner is deliberately not: a fork is a different run asking the same - // question at the same position, and a name carrying the run would make - // every inherited read unforkable. - expect( - pullRequestReadName( - readRequest( - "https://github.com/octo/pröject/pull/7", - "checks", - undefined, - OTHER_OWNER, - SITE, - ), - ), - ).toEqual(named); - }); - it("V9b: the entry's components are ordinary registrations over a kept one", function* () { const fixture = yield* useFixture(); diff --git a/packages/git/tests/retained-repository-pull-request-records.test.ts b/packages/git/tests/retained-repository-pull-request-records.test.ts index 3425989ef..c8c05d772 100644 --- a/packages/git/tests/retained-repository-pull-request-records.test.ts +++ b/packages/git/tests/retained-repository-pull-request-records.test.ts @@ -25,7 +25,7 @@ import { expect } from "@executablemd/test-support/expect"; import { Ok } from "effection"; import type { Operation } from "effection"; import { serializeError } from "@executablemd/durable-streams"; -import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import type { DurableEvent, Json, Result } from "@executablemd/durable-streams"; import { destinationRefFor, gitPushObservationsJson, @@ -84,14 +84,16 @@ import { } from "../src/retained-repository/push-records.ts"; import { parseReplPushRequestRecord } from "../src/retained-repository/push-records.ts"; import { - admitReplPullRequestReadRequest, - admitReplPullRequestReadResult, - ReplPullRequestReadHistoryError, -} from "../src/retained-repository/pull-request-read-admission.ts"; + PullRequestReadHistoryError, + pullRequestReadAdmission, + pullRequestReadName, +} from "../src/composition/pull-request-read-retention.ts"; import { + PULL_REQUEST_READ, pullRequestReadRequestJson, readRequest, } from "../src/composition/pull-request-read-records.ts"; +import type { PullRequestReadRequest } from "../src/composition/pull-request-read-records.ts"; import { replPlacementFingerprint, replPlacementRecordJson, @@ -159,10 +161,6 @@ const PUSH_INPUTS: GitPushInputs = Object.freeze({ sourceCommit: HEAD, }); -const HELD: readonly ReplPushPlacement[] = [ - { identity: IDENTITY, checkoutRoot: CHECKOUT, commonDirectory: COMMON }, -]; - const PLACEMENT: ReplPlacement = Object.freeze({ kind: "repository", managedRoot: "/home/dev/.xmd/repositories", @@ -932,68 +930,133 @@ describe("Tier RPR — admitting a history that published and then opened", () = describe("Tier RPR — whose retained evidence read is whose", () => { const request5 = readRequest(URL, "reviews", undefined, OWNER, SITE); + /** One retained read, described the way the feature describes its own. */ + function* readEvent( + request: PullRequestReadRequest, + result: Result, + overrides: { readonly name?: string; readonly configuration?: string } = {}, + ): Operation { + const named = yield* pullRequestReadName(request); + return { + type: "yield", + coroutineId: "root", + description: { + type: PULL_REQUEST_READ, + name: overrides.name ?? named.name, + configuration: overrides.configuration ?? named.configuration, + input: pullRequestReadRequestJson(request), + }, + result, + }; + } + + /** What this entry's own policy makes of a history, as a reason or nothing. */ + function* refusalOfReads( + events: readonly DurableEvent[], + owner: string = OWNER, + ): Operation { + try { + yield* pullRequestReadAdmission((recorded) => recorded === owner)(events); + } catch (error) { + return error instanceof PullRequestReadHistoryError ? error.reason : "unexpected"; + } + return undefined; + } + it("RPR19: admits this entry's own read, settled or failed", function* () { - const admitted = admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(request5)); - expect(admitted.runId).toBe(OWNER); - expect(admitted.url).toBe(URL); - expect(admitted.kind).toBe("reviews"); - expect(admitted.provider).toBeNull(); + expect( + yield* refusalOfReads([ + yield* readEvent(request5, { status: "ok", value: { kind: "reviews", items: [] } }), + yield* readEvent(request5, { + status: "err", + error: { message: "the collection could not be read" }, + }), + ]), + ).toBeUndefined(); }); it("RPR20: refuses a foreign owner, which the name alone cannot say", function* () { // The name omits the owner, so two entries reading the same URL at the same - // position produce the same name. What tells them apart is `runId`, and - // this is the case that proves the check is independent of the name. + // position produce the same name. What tells them apart is `runId`, and the + // owner policy is the only thing that asks. const foreign = readRequest(URL, "reviews", undefined, OTHER_OWNER, SITE); - let refusal: ReplPullRequestReadHistoryError | undefined; - try { - admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(foreign)); - } catch (error) { - refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; - } - expect(refusal?.reason).toBe("foreign-owner"); - + const settled = yield* readEvent(foreign, { + status: "ok", + value: { kind: "reviews", items: [] }, + }); // Asked of a failed read's description too: a refusal retains no result, // and its description is the only thing that says who asked. - let failedRefusal: ReplPullRequestReadHistoryError | undefined; - try { - admitReplPullRequestReadRequest(OWNER, pullRequestReadRequestJson(foreign)); - } catch (error) { - failedRefusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; - } - expect(failedRefusal?.reason).toBe("foreign-owner"); + const failed = yield* readEvent(foreign, { status: "err", error: { message: "unreadable" } }); + + expect(yield* refusalOfReads([settled])).toBe("foreign-owner"); + expect(yield* refusalOfReads([failed])).toBe("foreign-owner"); + + // And a host whose journal construction already settled its provenance + // inherits the very same records: the policy is the whole difference. + expect(yield* pullRequestReadAdmission(() => true)([settled, failed])).toBeUndefined(); }); it("RPR21: refuses a description this host cannot read", function* () { - for (const value of [ + for (const input of [ undefined, "a read", { ...pullRequestReadRequestJson(request5), surplus: true }, { ...pullRequestReadRequestJson(request5), operation: "write" }, { ...pullRequestReadRequestJson(request5), kind: "labels" }, ]) { - let refusal: ReplPullRequestReadHistoryError | undefined; - try { - admitReplPullRequestReadRequest(OWNER, value as Json); - } catch (error) { - refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; - } - expect(refusal?.reason).toBe("malformed-record"); + const event: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type: PULL_REQUEST_READ, name: `${SITE}:0123456789abcdef` }, + result: { status: "ok", value: { kind: "reviews", items: [] } }, + }; + expect( + yield* refusalOfReads([ + input === undefined + ? event + : { ...event, description: { ...event.description, input: input as Json } }, + ]), + ).toBe("malformed-record"); } }); + it("RPR21b: refuses a read journaled under another question's name or configuration", function* () { + // Both, because they are not redundant: the name a position publishes under + // is the invocation plus the fingerprint, so a record moved to another + // position keeps a fingerprint its name no longer exposes. + expect( + yield* refusalOfReads([ + yield* readEvent( + request5, + { status: "ok", value: { kind: "reviews", items: [] } }, + { name: `${SITE}:0123456789abcdef` }, + ), + ]), + ).toBe("request-fingerprint"); + expect( + yield* refusalOfReads([ + yield* readEvent( + request5, + { status: "ok", value: { kind: "reviews", items: [] } }, + { configuration: "0123456789abcdef" }, + ), + ]), + ).toBe("request-fingerprint"); + }); + it("RPR22: holds a settled read to the collection its own question asked for", function* () { - const reviews = admitReplPullRequestReadResult(request5, { kind: "reviews", items: [] }); - expect(reviews.kind).toBe("reviews"); + expect( + yield* refusalOfReads([ + yield* readEvent(request5, { status: "ok", value: { kind: "reviews", items: [] } }), + ]), + ).toBeUndefined(); // Another collection entirely, however well-formed. - let refusal: ReplPullRequestReadHistoryError | undefined; - try { - admitReplPullRequestReadResult(request5, { kind: "comments", items: [] }); - } catch (error) { - refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; - } - expect(refusal?.reason).toBe("malformed-result"); + expect( + yield* refusalOfReads([ + yield* readEvent(request5, { status: "ok", value: { kind: "comments", items: [] } }), + ]), + ).toBe("malformed-result"); }); it("RPR23: holds every check to the head the read recorded, empty or not", function* () { @@ -1015,24 +1078,75 @@ describe("Tier RPR — whose retained evidence read is whose", () => { // An empty collection still says which head was read, and that is admitted. expect( - admitReplPullRequestReadResult(checks, { kind: "checks", headSha: HEAD, items: [] }).kind, - ).toBe("checks"); + yield* refusalOfReads([ + yield* readEvent(checks, { + status: "ok", + value: { kind: "checks", headSha: HEAD, items: [] }, + }), + ]), + ).toBeUndefined(); + expect( + yield* refusalOfReads([ + yield* readEvent(checks, { + status: "ok", + value: { kind: "checks", headSha: HEAD, items: [run] }, + }), + ]), + ).toBeUndefined(); expect( - admitReplPullRequestReadResult(checks, { kind: "checks", headSha: HEAD, items: [run] }).kind, - ).toBe("checks"); + yield* refusalOfReads([ + yield* readEvent(checks, { + status: "ok", + value: { + kind: "checks", + headSha: HEAD, + items: [{ ...run, headSha: MOVED_HEAD }], + }, + }), + ]), + ).toBe("malformed-result"); + }); - let refusal: ReplPullRequestReadHistoryError | undefined; - try { - admitReplPullRequestReadResult(checks, { - kind: "checks", - headSha: HEAD, - items: [{ ...run, headSha: MOVED_HEAD }], - }); - } catch (error) { - refusal = error instanceof ReplPullRequestReadHistoryError ? error : undefined; - } - expect(refusal?.reason).toBe("malformed-result"); + it("RPR24: one read-name vector keeps the length-prefixed fingerprint, odd text and all", function* () { + // The other naming algorithm this feature touches, and deliberately not + // the canonical-JSON digest above: an evidence read is named the way every + // other Git operation in this package is named, so a journal a fork reads + // keeps answering. It is computed here rather than in a runtime-named half, + // because a journal's own vocabulary has to be readable wherever a record + // is read. + // + // The vector is the first 16 hex digits of SHA-256 over the UTF-16 + // big-endian code units of `4:read38:-6:checks` — a null discriminator + // is "-" rather than a length, and the 38 counts code units, so the "ö" + // counts once. + const named = yield* pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", undefined, OWNER, SITE), + ); + expect(named.configuration).toBe("6e1959358a175d22"); + expect(named.name).toBe(`${SITE}:6e1959358a175d22`); + + // The discriminator is part of the question, so naming one changes it. + expect( + (yield* pullRequestReadName( + readRequest("https://github.com/octo/pröject/pull/7", "checks", "github", OWNER, SITE), + )).configuration, + ).not.toBe(named.configuration); + + // The owner is deliberately not: a fork is a different run asking the same + // question at the same position, and a name carrying the run would make + // every inherited read unforkable. + expect( + yield* pullRequestReadName( + readRequest( + "https://github.com/octo/pröject/pull/7", + "checks", + undefined, + OTHER_OWNER, + SITE, + ), + ), + ).toEqual(named); }); }); diff --git a/packages/git/tests/support/composition.ts b/packages/git/tests/support/composition.ts index 6a2dba5ee..8eca5ae5a 100644 --- a/packages/git/tests/support/composition.ts +++ b/packages/git/tests/support/composition.ts @@ -70,12 +70,12 @@ import { type WorkflowRun, type WorkflowRunRecord } from "@executablemd/workflow * admission that derives this execution's identities from this execution's own * retained history. */ -export function* gitPluginAdmissions(): Operation { +export function* gitPluginAdmissions(command = "run"): Operation { const install = gitPlugin.install; if (install === undefined) { throw new Error("the Git Plugin installed nothing"); } - const installed = yield* install.call(gitPlugin, { command: "run", args: [] }); + const installed = yield* install.call(gitPlugin, { command, args: [command] }); return { admissions: [...(installed?.admissions ?? [])] }; } diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 6752f85b5..c2095f786 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3079,7 +3079,11 @@ default. The names, props, forms, return contracts, origins and prose are the same either way, and a repository-local component of one of those names is still chosen ahead of the default. A Workspace attachment installs that run's providers and durable state — the retention its pull-request lifecycle puts -beneath the feature operation among them — and declares none of them. +beneath the feature operation among them — and declares none of them. The +retained evidence read beneath that operation is one implementation for every +host, so an attachment installs where a read is performed and never what a read +is: the Plugin's own contribution is what makes a retained read's history +readable, for every execution it declares for. Registering them installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential — describing an environment mints nothing. What each name *does* is decided by whichever repository provider the @@ -10454,8 +10458,11 @@ Repository, working directory or checkout at all, so they are installed for an entry holding none — but they stay under today's configured allowed list on every execution, including the one that reopens a read already saved, and what a reopened read answers with is the snapshot the provider gave inside that -boundary rather than whatever composition above it now returns. Constructing the -provider acquires nothing, including no managed root, no commit identity, no +boundary rather than whatever composition above it now returns. Inside that +boundary the read is the feature's own: one implementation validates, retains, +restores and settles it for every host that retains one, and this entry supplies +only its identity and which recorded owners its history may inherit. +Constructing the provider acquires nothing, including no managed root, no commit identity, no credential and no configuration read, and a passive reading of a settled history initializes nothing at all. diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 3da2938eb..fbdd51ef8 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -2434,7 +2434,13 @@ resolved against the frame that published its coordinates. is the answer the provider gave inside that boundary: middleware composed above it runs again and may bind a different array from the same saved snapshot, and an answer a handler produced without ever reaching the provider - is retained not at all and runs again. + is retained not at all and runs again. From that boundary inward the read is + the feature's, not this host's: one implementation decides what a sound + answer is, acknowledges one only after the attempt that produced it has + closed, and holds a saved one to the same contract before restoring it. What + an entry supplies is its own identity and the one question a read's name + cannot answer — whose read this is — because the name omits the owner so a + fork can inherit it. - `` is the same component here as under every other host: one implementation, the same props, forms and return contract, asking a semantic issue operation for its work. The `repl` command registers it as an ordinary diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index e23c1047c..30b11abf8 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -1883,6 +1883,29 @@ The retained result is a discriminated envelope: `{ kind: "reviews", items }`, checks result has a head, so the other two carry no member a reader must know to ignore. Only `items` becomes the component's binding. +**One implementation, whichever host retains the read.** A workflow run and a +REPL entry retain these reads through the same feature implementation. What a +host supplies is the identity a fresh read is asked under — this run and this +expansion — and which recorded owners its journal may inherit. A run accepts +the owners its journal construction already settled, which is what keeps an +inherited read inheritable, and it supplies no validator, no settlement policy +and no serializer. + +So these hold here as they hold anywhere the feature is retained. An answer is +held to the whole envelope contract before a success is appended, again when one +is restored, again when a history is admitted before any of it replays, and +again after the public chain has composed over it and before `items` reaches a +binding. A checks envelope carrying an item about a commit other than the head +it recorded is two answers at once and is refused rather than acknowledged — +including a saved one, which is refused rather than continued from, with no +repair, deletion, coercion or re-fetch of the record. A read is acknowledged +only after the attempt that produced it has closed: an expected failure is the +read's own answer and is retained as one, while a cleanup that threw — even +throwing the read's own error object — appends nothing and activates the run's +existing first-failure fence. An actual failure keeps its own name, message and +stack; anything else that is raised is recorded as "the pull-request evidence +read failed". + The records are closed and provider-neutral. Bodies and provider messages are retained byte-for-byte, because a reviewer reading a summary of an objection has not read the objection; identifiers are decimal strings, because a provider