From d05014bfd19d5b057cd7b75868dea8ddce27b3eb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 10 Nov 2026 16:02:07 -0500 Subject: [PATCH 1/2] Overlap Ticket.Checkout's staged and unstaged reads Ticket.Checkout asked for its staged and unstaged changes one after the other, although each read owns its own containment and private index and neither depends on the other. A local Ticket.CheckoutReads value component now starts both through the existing GitQuery API inside one Effection all(), and Ticket.Checkout returns the same {head, clean, staged, unstaged} value from it. The new Deno suite holds every read until both have entered, which the serial component cannot satisfy, and proves exact HEAD, both tracked comparisons, untracked exclusion, fresh reads, an unchanged index, the foreign-checkout refusal, and that a failed or halted pair settles only after its sibling's cleanup. Refs #1143 Co-Authored-By: Claude Opus 5.5 --- .../components/Ticket/Checkout.md | 6 +- .../components/Ticket/CheckoutReads.ts | 56 +++ .../cli/tests/ticket-checkout-deno.test.ts | 357 ++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + 4 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 examples/adversarial-implementation/components/Ticket/CheckoutReads.ts create mode 100644 packages/cli/tests/ticket-checkout-deno.test.ts diff --git a/examples/adversarial-implementation/components/Ticket/Checkout.md b/examples/adversarial-implementation/components/Ticket/Checkout.md index f0807f640..cad24b5f7 100644 --- a/examples/adversarial-implementation/components/Ticket/Checkout.md +++ b/examples/adversarial-implementation/components/Ticket/Checkout.md @@ -13,9 +13,9 @@ 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 run together, because neither depends on the other. - - - + + diff --git a/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts b/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts new file mode 100644 index 000000000..06e6e1d17 --- /dev/null +++ b/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts @@ -0,0 +1,56 @@ +import { all } from "effection"; +import type { Operation } from "effection"; +import { z } from "zod"; +import { cwd } from "@executablemd/runtime"; +import { currentRepository, GitQuery, Repository } from "@executablemd/git/api"; +import type { GitChangesResult, GitCompareMode, RepositorySelection } from "@executablemd/git/api"; +import { gitObservationValueJson, GitOperationAdmissionError } from "@executablemd/git"; + +export const props = { + type: "object", + properties: {}, + additionalProperties: false, +}; + +function changes( + repository: RepositorySelection, + workingDirectory: string, + compare: Extract, +): Operation { + return GitQuery.operations.changes({ + repository, + workingDirectory, + request: { kind: "changes", comparison: { compare }, paths: null, limit: 100, cursor: null }, + }); +} + +function complete(result: GitChangesResult): boolean { + return result.changes.length === 0 && result.next === null; +} + +export default function* CheckoutReads(value: unknown) { + z.object({}).strict().parse(value); + const repository = (yield* currentRepository()) ?? (yield* Repository.operations.ambient()); + if (repository === undefined) { + throw new GitOperationAdmissionError( + "", + "it is written outside a lexical , so there is no repository in scope to read", + ); + } + const workingDirectory = yield* cwd(); + // The staged and unstaged reads are independent observations, each with its + // own containment and private index, so neither waits for the other. + const [staged, unstaged] = yield* all([ + changes(repository, workingDirectory, "staged"), + changes(repository, workingDirectory, "unstaged"), + ]); + if (staged.comparison.compare !== "staged") { + throw new TypeError("a staged read answered a comparison other than staged"); + } + return { + head: staged.comparison.base, + clean: complete(staged) && complete(unstaged), + staged: gitObservationValueJson("changes", staged), + unstaged: gitObservationValueJson("changes", unstaged), + }; +} 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..c0b5c82be --- /dev/null +++ b/packages/cli/tests/ticket-checkout-deno.test.ts @@ -0,0 +1,357 @@ +/** + * ``, 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 { useComponentModuleLoader } from "@executablemd/test-support/host"; +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* useComponentModuleLoader(); + 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")]); + }); + + 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: From 35dd4e87628ac369fb3cd2cecf18412f6c0cc228 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 10 Nov 2026 16:50:18 -0500 Subject: [PATCH 2/2] Run Ticket.Checkout's reads as two Spawns Ticket.Checkout overlapped its staged and unstaged reads through a TypeScript helper, which loader-free hosts such as the REPL cannot import, and whose two durable reads shared one coroutine, so a reopened REPL entry could diverge when they arrived in the other order. The component now runs two inside /: each read gets its own child coroutine and record, and joins their JSON in authored order. RGO6 reopens an unfinished entry with the reads held in each order and expects zero native reads; the checkout suite runs without a module loader and round-trips a Markdown-hostile path. Co-Authored-By: Claude Opus 5.5 --- .../components/Ticket/Checkout.md | 25 +++- .../components/Ticket/CheckoutReads.ts | 56 ------- .../cli/tests/repl-git-observations.test.ts | 137 +++++++++++++++++- .../cli/tests/ticket-checkout-deno.test.ts | 9 +- 4 files changed, 163 insertions(+), 64 deletions(-) delete mode 100644 examples/adversarial-implementation/components/Ticket/CheckoutReads.ts diff --git a/examples/adversarial-implementation/components/Ticket/Checkout.md b/examples/adversarial-implementation/components/Ticket/Checkout.md index cad24b5f7..ac5d26ae9 100644 --- a/examples/adversarial-implementation/components/Ticket/Checkout.md +++ b/examples/adversarial-implementation/components/Ticket/Checkout.md @@ -13,9 +13,28 @@ 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 run together, because neither depends on the other. + +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/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts b/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts deleted file mode 100644 index 06e6e1d17..000000000 --- a/examples/adversarial-implementation/components/Ticket/CheckoutReads.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { all } from "effection"; -import type { Operation } from "effection"; -import { z } from "zod"; -import { cwd } from "@executablemd/runtime"; -import { currentRepository, GitQuery, Repository } from "@executablemd/git/api"; -import type { GitChangesResult, GitCompareMode, RepositorySelection } from "@executablemd/git/api"; -import { gitObservationValueJson, GitOperationAdmissionError } from "@executablemd/git"; - -export const props = { - type: "object", - properties: {}, - additionalProperties: false, -}; - -function changes( - repository: RepositorySelection, - workingDirectory: string, - compare: Extract, -): Operation { - return GitQuery.operations.changes({ - repository, - workingDirectory, - request: { kind: "changes", comparison: { compare }, paths: null, limit: 100, cursor: null }, - }); -} - -function complete(result: GitChangesResult): boolean { - return result.changes.length === 0 && result.next === null; -} - -export default function* CheckoutReads(value: unknown) { - z.object({}).strict().parse(value); - const repository = (yield* currentRepository()) ?? (yield* Repository.operations.ambient()); - if (repository === undefined) { - throw new GitOperationAdmissionError( - "", - "it is written outside a lexical , so there is no repository in scope to read", - ); - } - const workingDirectory = yield* cwd(); - // The staged and unstaged reads are independent observations, each with its - // own containment and private index, so neither waits for the other. - const [staged, unstaged] = yield* all([ - changes(repository, workingDirectory, "staged"), - changes(repository, workingDirectory, "unstaged"), - ]); - if (staged.comparison.compare !== "staged") { - throw new TypeError("a staged read answered a comparison other than staged"); - } - return { - head: staged.comparison.base, - clean: complete(staged) && complete(unstaged), - staged: gitObservationValueJson("changes", staged), - unstaged: gitObservationValueJson("changes", unstaged), - }; -} 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 index c0b5c82be..d1b2c1316 100644 --- a/packages/cli/tests/ticket-checkout-deno.test.ts +++ b/packages/cli/tests/ticket-checkout-deno.test.ts @@ -14,7 +14,6 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { useComponentModuleLoader } from "@executablemd/test-support/host"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { ensure, race, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; @@ -88,7 +87,6 @@ function runChecked(source: string, checkout: Checkout, options: RunOptions = {} { at: "min" }, ); yield* useHostFiles(); - yield* useComponentModuleLoader(); yield* useCompositionComponents(); const managed = yield* until(realpath(yield* useTempDirectory("xmd-checkout-managed-"))); yield* useRunComposition({ @@ -213,6 +211,13 @@ describe("Ticket.Checkout — staged and unstaged reads", () => { 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* () {