diff --git a/examples/adversarial-implementation/components/Ticket/Checkout.md b/examples/adversarial-implementation/components/Ticket/Checkout.md index f0807f640..ac5d26ae9 100644 --- a/examples/adversarial-implementation/components/Ticket/Checkout.md +++ b/examples/adversarial-implementation/components/Ticket/Checkout.md @@ -14,8 +14,27 @@ returns: {type: object} Use the repository selected by this entry's original workspace or its enclosing Repository/Worktree. Enter the assigned checkout without creating or switching it. +The staged and unstaged reads do not depend on each other, so they run at the +same time. Each read renders its result as JSON, and `` joins the two in +the order they are written, staged first, whichever finishes first. The pair is +parsed back into values for the same result a serial read would return. + + + + +, + + + + + + + +{json} + + diff --git a/packages/cli/tests/repl-git-observations.test.ts b/packages/cli/tests/repl-git-observations.test.ts index feab2adb0..909957437 100644 --- a/packages/cli/tests/repl-git-observations.test.ts +++ b/packages/cli/tests/repl-git-observations.test.ts @@ -7,7 +7,10 @@ * own contribution for the `repl` command, and the per-entry recording core * initialization establishes. What that combination has to do is answer a * reopened entry from what it recorded, carry those answers to the entry after - * it, and read the repository again when a new entry asks. + * it, and read the repository again when a new entry asks. The last row runs + * the adversarial workflow's ``, whose two reads run at the + * same time, so a reopened entry must answer each from its own record whichever + * read asks first. * * ## Why the counters are on the host * @@ -20,19 +23,21 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, until } from "effection"; +import { ensure, resource, scoped, until, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import { readTextFile, 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 { fileURLToPath } from "node:url"; +import { API, Permissions, useHostFiles } from "@executablemd/runtime"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; import { Execution } from "@executablemd/core/api"; import type { ExecutionInstallation } from "@executablemd/core/host"; import { useDenoRepositoryOperations } from "@executablemd/git/deno"; import { runGit } from "./support/fixture-git.ts"; import type { DenoRepositoryOperations } from "@executablemd/git/deno"; +import { GitQuery } from "@executablemd/git/api"; import type { GitObservationRequest } from "@executablemd/git/api"; import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; import { gitPlugin } from "@executablemd/git"; @@ -570,3 +575,129 @@ describe("RGO5 — the secret gate still decides what reaches the journal", () = expect(observationCount(records)).toBe(0); }); }); + +const WORKFLOW_COMPONENTS = fileURLToPath( + new URL("../../../examples/adversarial-implementation/components", import.meta.url), +); + +/** + * Hold one changes read at the Git boundary until its sibling has answered, so + * the two concurrent reads reach the provider in a chosen order — live, that + * decides the order they are journaled; on reopen, the order they ask for + * their records. + */ +function useLateChanges(late: "staged" | "unstaged"): Operation { + return Execution.around({ + *init(_args, initialize): Operation { + const answered = withResolvers(); + yield* GitQuery.around({ + *changes(args, next) { + if (args[0].request.comparison.compare === late) { + yield* answered.operation; + return yield* next(...args); + } + const result = yield* next(...args); + answered.resolve(); + return result; + }, + }); + yield* initialize(); + }, + }); +} + +/** Which comparison each observation record holds, and the coroutine that wrote it. */ +function changesRecords( + records: readonly DurableEvent[], +): { compare: string; coroutine: string }[] { + return records + .filter( + (record) => + record.type === "yield" && record.description.type === REPL_REPOSITORY_OBSERVATION, + ) + .map((record) => ({ + compare: JSON.stringify(record).match(/"compare":"(staged|unstaged)"/)?.[1] ?? "?", + coroutine: String(record.coroutineId), + })); +} + +describe("RGO6 — concurrent checkout reads replay whichever answers first", () => { + it("reopens an unfinished entry when the reads arrive in the other order", function* () { + const question = + 'Continue?'; + for (const [live, reopen] of [ + ["staged", "unstaged"], + ["unstaged", "staged"], + ] as const) { + const fixture = yield* useFixture(); + const source = [ + ``, + "", + question, + "", + "Head {checked.head}, clean {checked.clean}, answered {answer.decision}.", + "", + ].join("\n"); + const opening = function* (counted: Counted, late: "staged" | "unstaged"): Operation { + yield* prepare(fixture, counted); + yield* Permissions.around( + { files: ([operation], next) => (operation === "absolute" ? true : next(operation)) }, + { at: "min" }, + ); + yield* useLateChanges(late); + }; + + yield* scoped(function* () { + yield* opening(counters(), live); + const created = yield* createExecutionFile(fixture.journals, "execution-1"); + const session = opened( + yield* submitReplEntry({ + execution: created, + source, + includes: [WORKFLOW_COMPONENTS], + installations: yield* replInstallations(), + generated: ordinaryGeneratedSelection(), + }), + ); + yield* waitForQuestion(session); + }); + + // The late read was journaled second, each read by a coroutine of its own. + const prefix = changesRecords( + yield* readRecords(executionPath(fixture.journals, "execution-1")), + ); + expect(prefix.map((record) => record.compare)).toEqual([reopen, live]); + expect(prefix[0].coroutine).not.toBe(prefix[1].coroutine); + + // Reopened with the other read held back, so the records are asked for + // in the opposite order to the one they were written in. + const resumed = counters(); + const settled = yield* scoped(function* (): Operation { + yield* opening(resumed, reopen); + const file = yield* openExecutionFile(fixture.journals, "execution-1"); + const session = opened( + yield* openReplSession({ + execution: file, + includes: [WORKFLOW_COMPONENTS], + installations: yield* replInstallations(), + generated: ordinaryGeneratedSelection(), + }), + ); + yield* waitForQuestion(session); + session.elicitation.pending?.submit({ decision: "go" }); + const outcome = yield* session.join(); + if (!outcome.ok) { + throw outcome.error; + } + return session.model.entries[0]?.terminal?.output ?? ""; + }); + + expect(settled).toContain(`Head ${fixture.first}, clean true, answered go.`); + expect(reads(resumed)).toEqual([]); + const after = changesRecords( + yield* readRecords(executionPath(fixture.journals, "execution-1")), + ); + expect(after).toEqual(prefix); + } + }); +}); diff --git a/packages/cli/tests/ticket-checkout-deno.test.ts b/packages/cli/tests/ticket-checkout-deno.test.ts new file mode 100644 index 000000000..d1b2c1316 --- /dev/null +++ b/packages/cli/tests/ticket-checkout-deno.test.ts @@ -0,0 +1,362 @@ +/** + * ``, the adversarial workflow's read of HEAD and tracked + * changes, run as the actual Markdown component from its include directory + * under the ordinary run's repository provider. + * + * The staged and unstaged reads are independent: each owns its containment and + * private index. The first case holds every read until both have entered, so a + * component that waits for one before starting the other cannot answer it. The + * rest prove the paired answer itself is unchanged — exact HEAD, both tracked + * comparisons, untracked files excluded, a fresh read every invocation, the + * source index untouched — and that the pair is one owner: a failed or + * cancelled read is not reported until its sibling's cleanup has finished. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensure, race, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { readFile, realpath } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { writeTextFile } from "@effectionx/fs"; +import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; +import type { ComponentRegistration } from "@executablemd/core"; +import { API, Permissions, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { useCompositionComponents } from "@executablemd/git"; +import { useRunComposition } from "@executablemd/git/deno"; +import { GitQuery } from "@executablemd/git/api"; +import type { GitChangesInvocation, GitChangesResult } from "@executablemd/git/api"; +import { git } from "../../git/tests/support/git-remotes.ts"; +import { actionProbe, capturingProbe } from "../../git/tests/support/observations.ts"; + +const COMPONENTS = fileURLToPath( + new URL("../../../examples/adversarial-implementation/components", import.meta.url), +); + +interface Checkout { + readonly root: string; + run(...args: string[]): string; + write(path: string, content: string): Operation; + index(): Operation; +} + +function* useCheckout(prefix: string): Operation { + const home = yield* useTempDirectory(`${prefix}-home-`); + const parent = yield* until(realpath(yield* useTempDirectory(`${prefix}-`))); + const root = join(parent, "checkout"); + git(["init", "--initial-branch=main", root], parent, home); + git(["config", "user.name", "Fixture"], root, home); + git(["config", "user.email", "fixture@example.invalid"], root, home); + yield* writeTextFile(join(root, "tracked.txt"), "first\n"); + git(["add", "--", "tracked.txt"], root, home); + git(["commit", "--quiet", "-m", "first"], root, home); + return { + root, + run: (...args: string[]) => git(args, root, home), + write: (path: string, content: string) => writeTextFile(join(root, path), content), + *index(): Operation { + const bytes = yield* until(readFile(join(root, ".git", "index"))); + return createHash("sha256").update(bytes).digest("hex"); + }, + }; +} + +interface RunOptions { + readonly components?: readonly ComponentRegistration[]; + readonly around?: () => Operation; +} + +function runChecked(source: string, checkout: Checkout, options: RunOptions = {}): Operation { + return scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return checkout.root; + }, + }, + { at: "min" }, + ); + yield* Permissions.around( + { files: ([operation], next) => (operation === "absolute" ? true : next(operation)) }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useCompositionComponents(); + const managed = yield* until(realpath(yield* useTempDirectory("xmd-checkout-managed-"))); + yield* useRunComposition({ + root: join(managed, "repositories"), + cwd: checkout.root, + // deno-lint-ignore require-yield + *identity(): Operation { + return "Fixture 0 +0000"; + }, + }); + if (options.components !== undefined) { + yield* registerComponents([...options.components]); + } + if (options.around !== undefined) { + yield* options.around(); + } + return yield* collect( + yield* execute({ + ...inlineSource(source), + stream: new InMemoryStream(), + includes: [COMPONENTS], + }), + ); + }); +} + +function checkoutOf(repository: string, as = "checkout"): string { + return ``; +} + +/** Run `` once against this checkout and answer its bound value. */ +function* readCheckout(checkout: Checkout, options: RunOptions = {}): Operation { + const seen: Json[] = []; + yield* runChecked( + [checkoutOf(checkout.root), "", "", ""].join("\n"), + checkout, + { + ...options, + components: [capturingProbe(seen), ...(options.components ?? [])], + }, + ); + expect(seen.length).toBe(1); + return seen[0]; +} + +function change(path: string, status: string): Json { + return { path, status, previousPath: null }; +} + +function expected(head: string, staged: Json[], unstaged: Json[]): Json { + return { + head, + clean: staged.length === 0 && unstaged.length === 0, + staged: { comparison: { compare: "staged", base: head }, changes: staged, next: null }, + unstaged: { comparison: { compare: "unstaged" }, changes: unstaged, next: null }, + }; +} + +function requested(invocation: GitChangesInvocation): string { + return invocation.request.comparison.compare; +} + +describe("Ticket.Checkout — staged and unstaged reads", () => { + it("starts both reads before either answers", function* () { + const checkout = yield* useCheckout("xmd-checkout-barrier"); + const entered: string[] = []; + const both = withResolvers(); + const value = yield* readCheckout(checkout, { + *around() { + yield* GitQuery.around({ + *changes(args, next): Operation { + entered.push(requested(args[0])); + if (entered.length === 2) { + both.resolve(); + } + // A component that waits for one read to answer before asking the + // other never lets the second one enter. + yield* race([ + both.operation, + (function* () { + yield* sleep(10_000); + throw new Error( + `only ${entered.join(", ")} entered before the first read answered`, + ); + })(), + ]); + return yield* next(...args); + }, + }); + }, + }); + expect(new Set(entered)).toEqual(new Set(["staged", "unstaged"])); + expect(value).toEqual(expected(checkout.run("rev-parse", "HEAD"), [], [])); + }); + + it("answers exact HEAD and tracked changes, fresh each time, leaving the index alone", function* () { + const checkout = yield* useCheckout("xmd-checkout-states"); + const observe = function* (staged: Json[], unstaged: Json[]): Operation { + const index = yield* checkout.index(); + const value = yield* readCheckout(checkout); + expect(value).toEqual(expected(checkout.run("rev-parse", "HEAD"), staged, unstaged)); + expect(yield* checkout.index()).toBe(index); + }; + + yield* observe([], []); + + // Untracked files are in neither comparison, so the checkout stays clean. + yield* checkout.write("untracked.txt", "untracked\n"); + yield* observe([], []); + + yield* checkout.write("tracked.txt", "staged\n"); + checkout.run("add", "--", "tracked.txt"); + yield* observe([change("tracked.txt", "M")], []); + + checkout.run("commit", "--quiet", "-m", "second"); + yield* checkout.write("tracked.txt", "unstaged\n"); + yield* observe([], [change("tracked.txt", "M")]); + + // A staged edit whose working copy went back to HEAD is dirty both ways, + // which a single HEAD-to-working-tree comparison would call clean. + yield* checkout.write("tracked.txt", "staged again\n"); + checkout.run("add", "--", "tracked.txt"); + yield* checkout.write("tracked.txt", "staged\n"); + yield* observe([change("tracked.txt", "M")], [change("tracked.txt", "M")]); + + // Each read reaches the result as rendered JSON text, so a path full of + // Markdown, MDX and HTML syntax must come back unchanged. + const odd = "odd *_[x](y){z}&`q`\\é.txt"; + yield* checkout.write(odd, "odd\n"); + checkout.run("add", "--", odd); + yield* observe([change(odd, "A"), change("tracked.txt", "M")], [change("tracked.txt", "M")]); + }); + + it("reads again after HEAD and the working tree move within one execution", function* () { + const checkout = yield* useCheckout("xmd-checkout-fresh"); + const before = checkout.run("rev-parse", "HEAD"); + const seen: Json[] = []; + yield* runChecked( + [ + checkoutOf(checkout.root, "first"), + "", + "", + "", + "", + "", + checkoutOf(checkout.root, "second"), + "", + "", + "", + ].join("\n"), + checkout, + { + components: [ + capturingProbe(seen), + actionProbe("Move", function* () { + yield* checkout.write("tracked.txt", "moved\n"); + checkout.run("commit", "--quiet", "-am", "moved"); + yield* checkout.write("tracked.txt", "dirty\n"); + }), + ], + }, + ); + const after = checkout.run("rev-parse", "HEAD"); + expect(after).not.toBe(before); + expect(seen).toEqual([ + expected(before, [], []), + expected(after, [], [change("tracked.txt", "M")]), + ]); + }); + + it("refuses a checkout the ambient repository does not select", function* () { + const checkout = yield* useCheckout("xmd-checkout-ambient"); + const foreign = yield* useCheckout("xmd-checkout-foreign"); + let refusal: unknown; + try { + yield* runChecked(checkoutOf(foreign.root), checkout); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect(String(refusal)).toContain( + "GitOperationAdmissionError: is not admitted against this run's retained " + + "state: the directory it was written in is inside none of the checkouts this execution " + + "selected for the repository in scope.", + ); + }); +}); + +describe("Ticket.Checkout — one owner for both reads", () => { + it("reports a failed read only after its sibling's cleanup has finished", function* () { + const checkout = yield* useCheckout("xmd-checkout-failure"); + const held = withResolvers(); + const gate = withResolvers(); + const order: string[] = []; + const task = yield* spawn(function* () { + try { + yield* readCheckout(checkout, { + *around() { + yield* GitQuery.around({ + *changes(args, _next): Operation { + if (requested(args[0]) === "staged") { + yield* ensure(function* () { + order.push("cleanup started"); + yield* gate.operation; + order.push("cleanup finished"); + }); + held.resolve(); + yield* suspend(); + } + yield* held.operation; + throw new Error("the unstaged read failed"); + }, + }); + }, + }); + } catch (error) { + order.push("caller settled"); + return error; + } + throw new Error("the checkout read did not fail"); + }); + yield* held.operation; + // Long enough for a caller that does not wait for cleanup to settle first. + yield* sleep(200); + expect(order).toEqual(["cleanup started"]); + gate.resolve(); + const error = yield* task; + expect(String(error)).toContain("the unstaged read failed"); + expect(order).toEqual(["cleanup started", "cleanup finished", "caller settled"]); + }); + + it("does not finish halting until every held read has cleaned up", function* () { + const checkout = yield* useCheckout("xmd-checkout-cancel"); + const entered: string[] = []; + const both = withResolvers(); + const gate = withResolvers(); + const order: string[] = []; + const task = yield* spawn(function* () { + yield* readCheckout(checkout, { + *around() { + yield* GitQuery.around({ + *changes(args, _next): Operation { + const compare = requested(args[0]); + yield* ensure(function* () { + yield* gate.operation; + order.push(`${compare} cleaned up`); + }); + entered.push(compare); + if (entered.length === 2) { + both.resolve(); + } + yield* suspend(); + throw new Error("a held read resumed"); + }, + }); + }, + }); + }); + yield* both.operation; + const halting = yield* spawn(function* () { + yield* task.halt(); + order.push("halted"); + }); + yield* sleep(200); + expect(order).toEqual([]); + gate.resolve(); + yield* halting; + expect(new Set(order.slice(0, 2))).toEqual( + new Set(["staged cleaned up", "unstaged cleaned up"]), + ); + expect(order[2]).toBe("halted"); + }); +}); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 0f0b0200e..c577ba00c 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -718,6 +718,12 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ "the same provider reached through an ordinary run, asked the questions only a runtime that operates repositories can answer: managed-Worktree session resolution, an ordinary journal's diagnostic status, and diagnostic-trace non-authority", issue: DERIVED_SCOPE, }, + { + path: "packages/cli/tests/ticket-checkout-deno.test.ts", + reason: + "the same provider answering the adversarial workflow's `` reads: an ordinary run discovers the ambient checkout and reads it with real `git` through the Deno runtime, which is the only runtime the provider is installed for", + issue: DERIVED_SCOPE, + }, { path: "packages/cli/tests/run-composition-nested.test.ts", reason: