From 217a3e35a07fd225fe72b49bff1158d4ba0a540c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 17:47:41 -0400 Subject: [PATCH 1/6] Add scoped REPL workspace mounts and retained reads --- architecture.md | 59 ++- packages/cli/src/cli.ts | 21 +- packages/cli/src/repl-entry-installation.ts | 7 +- packages/cli/src/repl-profile.ts | 5 + packages/cli/src/repl/file-recorder.ts | 290 +++++++++++++ packages/cli/src/repl/mount-files.ts | 344 +++++++++++++++ packages/cli/src/repl/mount-records.ts | 243 +++++++++++ packages/cli/src/repl/program.ts | 1 + .../cli/src/repl/repositories/admission.ts | 2 + .../cli/src/repl/repositories/deno/files.ts | 355 +++------------- .../cli/src/repl/repositories/deno/local.ts | 2 +- .../src/repl/repositories/deno/provider.ts | 41 +- packages/cli/src/repl/session.ts | 11 +- .../src/repl/workspace-mount-installation.ts | 91 ++++ packages/cli/src/workspace-mounts.ts | 60 +++ packages/cli/tests/repl-mounts-pty.test.ts | 9 + .../cli/tests/retained-mount-files.test.ts | 389 +++++++++++++++++ .../cli/tests/retained-mount-records.test.ts | 395 ++++++++++++++++++ packages/cli/tests/support/mount-run.ts | 98 +++++ .../cli/tests/support/repl-files-journey.ts | 10 +- .../cli/tests/support/repl-mounts-journey.ts | 98 +++++ packages/cli/tests/workspace-mounts.test.ts | 80 ++++ packages/core/host.ts | 10 + packages/core/src/components/Dir.ts | 35 +- packages/core/src/components/Evaluate.ts | 7 +- packages/core/src/components/File.ts | 13 +- .../core/src/components/fixed-filesystem.ts | 62 ++- packages/core/src/execute.ts | 69 +++ packages/core/src/expand.ts | 90 +++- packages/core/src/files.ts | 14 +- packages/core/src/filesystem-host.ts | 38 ++ packages/core/src/filesystem-placement.ts | 86 ++++ packages/core/src/generated-xmd.ts | 12 + packages/core/src/invocation-identity.ts | 55 ++- .../core/tests/filesystem-placement.test.ts | 321 ++++++++++++++ packages/core/tests/loaded-copy-files.test.ts | 42 +- packages/runtime/deno.json | 1 + packages/runtime/files-api.LICENSE | 23 + packages/runtime/files-api.ts | 147 +++++++ packages/runtime/files-host.ts | 9 + packages/runtime/files.ts | 11 +- packages/runtime/package.json | 1 + packages/runtime/tests/files-call.test.ts | 130 ++++++ packages/test-support/mount-fixture.ts | 8 + .../tests/repl-repositories-compiled.test.ts | 6 + specs/executable-mdx-spec.md | 29 ++ specs/repl-spec.md | 57 ++- 47 files changed, 3520 insertions(+), 367 deletions(-) create mode 100644 packages/cli/src/repl/file-recorder.ts create mode 100644 packages/cli/src/repl/mount-files.ts create mode 100644 packages/cli/src/repl/mount-records.ts create mode 100644 packages/cli/src/repl/workspace-mount-installation.ts create mode 100644 packages/cli/src/workspace-mounts.ts create mode 100644 packages/cli/tests/repl-mounts-pty.test.ts create mode 100644 packages/cli/tests/retained-mount-files.test.ts create mode 100644 packages/cli/tests/retained-mount-records.test.ts create mode 100644 packages/cli/tests/support/mount-run.ts create mode 100644 packages/cli/tests/support/repl-mounts-journey.ts create mode 100644 packages/cli/tests/workspace-mounts.test.ts create mode 100644 packages/core/src/filesystem-host.ts create mode 100644 packages/core/src/filesystem-placement.ts create mode 100644 packages/core/tests/filesystem-placement.test.ts create mode 100644 packages/runtime/files-api.LICENSE create mode 100644 packages/runtime/files-api.ts create mode 100644 packages/runtime/files-host.ts create mode 100644 packages/runtime/tests/files-call.test.ts create mode 100644 packages/test-support/mount-fixture.ts diff --git a/architecture.md b/architecture.md index 221423a00..1627e9a5c 100644 --- a/architecture.md +++ b/architecture.md @@ -3015,6 +3015,24 @@ Document filesystem operations cross a provider boundary. Ordinary managed check ### The document filesystem boundary +The REPL's repeatable `--mount SOURCE:TARGET` interprets explicit document +addresses through captured host directories. It uses the existing Dir, File, +Glob and foreground command components. The execution carries an immutable +private authored/actual directory frame beside its expansion environment, through +ordinary bodies, projections and admitted generated work. No Context publishes +that frame or grants filesystem permission. A canonical Dir prepares its effective +request and candidate frame together; only its own validated ensure Unit, after +applicable acknowledgement, permits the candidate's content. Nested `..` follows +the authored parent: `/work/manuals/docs:/reference` followed by `..` ensures and +enters ordinary host `/`, then restores the enclosing pair on every exit. + +Interpreted document paths preserve components when converted to host paths. +On Windows, document backslashes, colons and components ending in a dot or space +refuse before an effective request or child frame exists. Plain `.` and `..` +remain document navigation. Native SOURCE anchors keep their native spelling; +the captured host drive root supplies an unmapped document root. Ordinary hosts +and workflow logical paths keep their existing interpretation. + Contextual routing is not permission. `API.Files` decides *which* provider answers a document's file operations; what a provider is allowed to do is decided by the provider, from identities the contextual layer cannot supply. @@ -6652,6 +6670,9 @@ reinstalling Agent or Plugin stacks. Protected names and the ordinary resolution tiers retain their existing precedence. Only genuine absence falls through; a selected unreadable or malformed definition fails. +The command also captures the normalized, frozen mount table once. It is separate +from includes, permissions, Repository selection and held native Agent cwd. + The profile carries data and installations. No provider, writer or live request reaches the application model, route or Journal through it. @@ -7161,6 +7182,36 @@ exactly once. ### What one entry retains about the checkout it is standing in +A selected mapped File read also retains its composed result outside existing +checkout/session coverage. Acknowledged text or ordinary refusal answers the same +unfinished entry without consulting its old source, current source or host folder. +Reassignment changes genuinely new reads to the current source; omission places +new reads in the ordinary host folder. A new entry reads live. Enclosing +unrecorded Dir work may still execute or refuse; saved reads promise no passive +prefix. No mapped write, deletion, Glob, process, service or external ensure gains +retention. + +A prospectively recorded mapped ensure already within existing coverage retains +its own Unit or refusal under `repl_mount_file/v1`. The same marker serves mapped +reads. One ordinary durable operation contains independent authored occurrence, +form and operands, original binding, the existing physical result, and original +coverage. Active replay compares authored expectations before admitting original +physical attribution; both admission and replay validate the original component +conversion, physical fingerprint and result. Only the engine consumes a slot. +A saved ensure's Unit permits current-placement content under the same TARGET +without checking or creating today's directory; refusal stops it. Omitting that +ensure's TARGET refuses continuation. Physical-only legacy records keep physical +matching and acquire no reconstructed association. Malformed association and +abandoned replay never authorize live fallback. + +The entry's shared recorder receives canonical read/ensure calls directly. Its +live continuation collects the ordinary Files chain in the invoking scope, +excludes exactly its own registration, and preserves every other middleware, +terminal, Context change and resource lifetime inline. Completed replay collects +none of it. Raw calls use the same recording body and validators. Appends and +first fatal failures keep the ordinary durable boundary; an uncertain append +establishes no saved value or Unit. + The REPL is foreground experimentation, and what that means for files is one sentence: a completed operation answers again, and a new one executes live. An entry that read a file, wrote three and stopped at a question is reopened at the @@ -7178,8 +7229,8 @@ alone. Three local Git operations are retained beside them — ``, request and whose external outcome are two records of their own. Those counts are separate: the file rules below are about the four, the local Git rules further down are about the three, and the publication rules after them are about -the one. A call outside every such checkout and the marked entry's owned workspace is -ordinary filesystem behavior, recorded by nothing, so it runs again during a +the one. Except for the selected mapped reads described above, a call outside every such +checkout and the marked entry's owned workspace is ordinary filesystem behavior, recorded by nothing, so it runs again during a partial continuation. A managed placement is looked for first and the most specific one wins; the ambient route answers everything no placement covers, so an entry that selects nothing works exactly as it did @@ -8731,6 +8782,7 @@ and categorization rather than clarify them, so both stay exactly as written. | Term | Meaning | | --- | --- | +| workspace mount | A captured SOURCE:TARGET interpretation for existing document filesystem components, without an OS mount or filesystem permission grant. | | behavioral predictability | a plan as a program: ~80% deterministic, ~20% probabilistic [Reference](#three-axes). | | replay determinism | the journal preserves the execution chain: replay restores the recorded execution state from which execution can continue; correspondence with the live environment is a separate host contract [Reference](#three-axes). | | replay | using the durable journal to restore the recorded portion of a document execution without repeating durable effects; after the recorded entries are consumed, execution may continue live [Reference](#replay-and-effect-transactions). | @@ -8894,7 +8946,8 @@ Status is measured against main. | `GitQuery.resolve()` | verifies and resolves one Git revision expression contextually [Interface and evidence](specs/workflow-spec.md#7-the-git-capability--moved). | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry [Invariant](#workflow-run-storage); [Interface and evidence](specs/workflow-spec.md#9-retained-run-storage). | built on the #365 stack; the CLI lifecycle reaches it on the #366 stack | | caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in [Invariant](#one-database-at-a-time-and-a-transaction-a-caller-can-hold). | built on main | -| REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside the captured project ambient checkout, a selected managed checkout, or the marked entry's owned session workspace — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. Recording is ordinary ancestor-max `Files` middleware, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. The local Git and issue work an entry retains have rows of their own beside this one; work outside every selected checkout and the marked entry's owned workspace stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | +| REPL workspace mounts | Repeatable explicit SOURCE:TARGET pairs interpret existing Dir scopes through a private authored/actual expansion frame. Actual cwd reaches File, Glob and foreground processes; captured native Agent placement and ordinary/workflow hosts remain separate. Selected reads and originally covered ensures use a closed authored-first association in their entry's ordered engine slots; legacy physical records retain physical matching. [Boundary](#the-document-filesystem-boundary); [retention](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | implemented on the workspace-mount branch | +| REPL entry retained file work | one executing REPL entry's own recording, established by core initialization in the scope the invocation owns and ending with it: it retains the `` reads, `` writes, `` deletions and `` ensures that land inside the captured project ambient checkout, a selected managed checkout, or the marked entry's owned session workspace — authored and admitted-generated alike, because both cross the one document filesystem. A completed operation answers from its saved result without live path canonicalization, ambient discovery, a lease, a permission lookup or a filesystem call — so only *new* work needs the environment, a continuation still binds the text its read answered with while the file says something else, and a new entry reading that path sees what is there now. A record's name is the digest of the record version, the operation kind and the complete received request, so two equal requests in one entry are one name at two ordered positions and the durable engine's positional matching tells them apart; equal work in two entries is the same name, and each entry executing against its own stream segment is what keeps those apart. The shared raw/direct recorder preserves ordinary ancestor-max `Files` composition, so a provider beneath that answers directly or transforms and delegates is kept as provider behavior while the received request stays the operation's identity. A shared Git directory admits one live *mutating* entry at a time, held lazily for as long as the execution lasts and refused rather than queued; no read acquires it, so a new read proceeds while another entry holds it. The local Git and issue work an entry retains have rows of their own beside this one; except for selected mapped reads, work outside every selected checkout and the marked entry's owned workspace stays ordinary and unrecorded, and a passive reading initializes nothing [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained selection | one executing REPL entry's own `` and `` selections, recorded beside its file work as small routing records: the canonical managed root, the checkout root, the common Git directory, the credential-free repository identity, the display name and the owner checkout, with a root-level ``'s ambient owner recorded as its own answer. The selector is the ordinary managed one, with its own creation, compatible-reuse, conflict and interrupted-adoption policy; what this adds is that a completed selection answers from its record, calling no selector and reaching no clone, `worktree add`, sidecar, credential or lock — so a continuation works in a checkout somebody has since changed or removed, while only new work meets today's filesystem. Parsed facts are held to the call they stand at with pure slot arithmetic against the root the record itself carries, registered in entry-private state and minted as a fresh selection, so a copied or edited Repository value can misname a target and never reach one. One entry at a time *owns* a managed slot, held until teardown and reacquired lazily by the first new operation inside a restored placement; that is a different hold from the shared Git directory's mutation lease, which only a new `` selector and a new mutation take. A selection is acknowledged before its path is bound, and native work that was never acknowledged runs live again under the ordinary partial-creation policy [Reference](#what-one-entry-retains-about-the-checkout-it-is-standing-in). | built on the #181 stack | | REPL entry retained issue work | one executing REPL entry's own `` work, retained under the ordinary `issue_effect` records beside its file work and needing no checkout at all: no repository is discovered for it, no managed root is made and no hold is taken. A completed read answers with the snapshot it saw and a completed upsert with the URL it was filed under, neither resolving a provider, reading configuration nor opening a credential. What names it is the entry's own owner — derived by the session from the execution and the entry's admission-order position — with the identity canonical execution minted where it selected this implementation for this invocation; the `runId` and `expansionId` spellings are a compatibility encoding of those two, and a REPL entry installs no synthetic WorkflowRun. Neither member is reachable from a prop, a tracker, a Context, a middleware request or a record field a document can write, and the entry's `` is the one shared component with this entry's policy declared over the command's registered default, which a repository component still outranks. One durable boundary encloses the operation and the live attempt inside it composes `IssueOperations` over a transport-facing default of the entry's own, bound in the attempt's own scope under the attempt's own idempotency key and gone before the envelope publishes; canonical form dispatch selects which of the two effects an invocation asks for. An upsert the service accepted and this end never acknowledged retains nothing and presents the same key next time, which is what the provider adopts under; a read has no key and may be made again. The entry's retained issue records are held to its own owner before any of them are replayed [Reference](#what-one-entry-retains-about-the-issue-it-filed). | built on the #181 stack | | REPL entry retained local Git | one executing REPL entry's own ``, `` and ``, recorded beside its file work and its selections. The performers are the ordinary ones — exactly the pathspecs a document wrote, the ordinary branch rules, the current index and nothing else — installed as ordinary Git dispatch at the implementation slot, so middleware above may answer, refuse, or transform and delegate, and a call answered wholly by a replacement acquires no retention. One durable operation per call and no outer envelope, so a staging child inside a Commit is an earlier journal operation of its own. A completed operation answers from its saved result and runs no `git` at all, writing no tree and reading no identity; a saved commit object id names the commit that was made then and is never re-resolved against today's HEAD. A result is fixed-size evidence — the checkout, the branch, commit, HEAD tree and index tree on both sides, and the operation's own arguments and products — never a file list, a checkout snapshot or Git output, and a commit message appears as a digest and a byte length rather than a second copy of the text. Routing is private and live-only: the received working directory is canonicalized and the ordinary selector chooses the longest covering checkout among this entry's registrations under the whole authenticated Repository identity, and a saved result's checkout is held to those registrations with parsed facts alone. The invoking person's author and committer are read once per entry, lazily, and only by a genuinely live Commit; a host that cannot say becomes the existing Commit-only refusal. A live operation takes the selected placement's managed slot and then the shared Git directory's mutation lease, keeps both until teardown and takes neither on replay, with this entry's own native attempts serialized per shared Git directory. An ordinary refusal is retained under the route that reported it, so a native ignored-path Add says what `git` reported and promises no rollback; contention, admission and infrastructure failures are fatal and unrecorded. Native completion and acknowledgement are separate: an unacknowledged Commit runs live again and may instead refuse empty-index, and a failed append fences later work with no success binding and no compensating Close. A local result confers no publication evidence, and publication is the row below rather than part of these three [Reference](#the-three-local-git-operations-and-what-a-saved-object-id-means). | built on the #181 stack | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 66aa0fa29..1d16aa6c5 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; +import { parseWorkspaceMounts } from "./workspace-mounts.ts"; /** * CLI — run an executable markdown document. * @@ -320,6 +322,10 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te * their entries is not the same choice as who their entries talk to. */ const replConfig = object({ + mount: { + description: "external SOURCE:TARGET directory; repeat for each explicit TARGET", + ...field(z.array(z.string()), field.default([]), field.array()), + }, location: { description: "a location this REPL printed earlier — `xmd://repl//...` — to reopen " + @@ -598,6 +604,8 @@ const REPL_HELP = [ " Each one starts from the bindings the entries before it published, so a", " later entry can read and revise what an earlier one left behind.", "", + " --mount SOURCE:TARGET adds an external directory at a required document path.", + " Repeat it: --mount /work/manuals:/reference --mount /work/fixtures:/fixtures.", " --include and --include= select component directories.", " Repeat them in search order; explicit values replace the default list [components, .].", " Relative directories resolve from the invocation working directory.", @@ -657,7 +665,7 @@ export interface ReplHostAssembly { * silence would let them believe they got it. */ /** - * The four REPL options that carry a value, by the spelling a caller writes. + * The five REPL options that carry a value, by the spelling a caller writes. * * Exported because the documentation check reads this command's own examples, * and a token after one of these is that option's value rather than a location. @@ -665,6 +673,7 @@ export interface ReplHostAssembly { * command about what it had been handed. */ export const REPL_VALUES: readonly string[] = [ + "--mount", "--include", "--agent-provider", "--default-agent", @@ -722,7 +731,7 @@ export function replGrammarError( return ( `unrecognized option for xmd repl: ${name} — xmd repl takes one optional location, ` + "the agent options --agent-provider, --default-agent, --approve-all, --approve-reads " + - "and --deny-all, --include for component directories, and --sidekick-agent for the Sidekick's own conversations" + "and --deny-all, --include for component directories, --mount SOURCE:TARGET for workspace directories, and --sidekick-agent for the Sidekick's own conversations" ); } if (positional.length > 1) { @@ -2943,6 +2952,13 @@ function* dispatch( yield* exit(1); break; } + const mountCwd = yield* cwd(); + const mounts = parseWorkspaceMounts(command.config.mount, mountCwd, { resolve }); + if (!mounts.ok) { + console.error(mounts.error.message); + yield* exit(1); + break; + } // The Agent configuration, settled before a terminal exists: mutual // exclusion, an unknown provider and `DEFAULT_AGENT_NAME` precedence are // all decided here, so an invocation nobody could run refuses without @@ -3015,6 +3031,7 @@ function* dispatch( [testAgentChildDeclaration()], ); }, + mounts: mounts.value, includes: command.config.include, ...(command.config.sidekickAgent === undefined ? {} diff --git a/packages/cli/src/repl-entry-installation.ts b/packages/cli/src/repl-entry-installation.ts index fdf185f85..477fcffaa 100644 --- a/packages/cli/src/repl-entry-installation.ts +++ b/packages/cli/src/repl-entry-installation.ts @@ -1,3 +1,4 @@ +import type { WorkspaceMountTable } from "./workspace-mounts.ts"; /** * The entry capability this host carries, as inert code. * @@ -45,7 +46,11 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; */ export type ReplEntryInstallation = ( owner: string, - placement?: { readonly projectCwd?: string; readonly workspace?: string }, + placement?: { + readonly projectCwd?: string; + readonly workspace?: string; + readonly mounts?: WorkspaceMountTable; + }, ) => Operation; /** The factory this host runs REPL entries with, or absence. */ diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index d551a7f44..a49f99799 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -1,3 +1,4 @@ +import type { WorkspaceMountTable } from "./workspace-mounts.ts"; /** * Capture command selections once, then finalize one execution's component paths. * @@ -55,6 +56,7 @@ export interface ReplProfileSeams { * parameter: a caller states what it knows and leaves out the rest. */ export interface ReplProfileFacts extends ReplProfileSeams { + readonly mounts?: WorkspaceMountTable; readonly testingHarness?: (includes: readonly string[]) => Operation; readonly includes?: readonly string[]; /** @@ -97,6 +99,7 @@ export interface ReplProfileAssembly { /** One finalized execution profile, shared by validation, descriptions and entries. */ export interface ReplExecutionProfile { + readonly mounts?: WorkspaceMountTable; readonly testing?: ReplTesting; readonly projectCwd?: string; /** Where a declared name is looked for, outermost first. */ @@ -202,6 +205,7 @@ export function* assembleReplProfile( facts: ReplProfileFacts = {}, ): Operation { const projectCwd = yield* cwd(); + const mounts = facts.mounts; const configuredIncludes = Object.freeze( (facts.includes ?? REPL_INCLUDES).map((path) => resolve(projectCwd, path)), ); @@ -258,6 +262,7 @@ export function* assembleReplProfile( }); const profile = Object.freeze({ + mounts, includes, projectCwd, vocabulary: entryVocabulary(includes, plugins), diff --git a/packages/cli/src/repl/file-recorder.ts b/packages/cli/src/repl/file-recorder.ts new file mode 100644 index 000000000..58d80a50c --- /dev/null +++ b/packages/cli/src/repl/file-recorder.ts @@ -0,0 +1,290 @@ +import { Err, Ok } from "effection"; +import type { Operation, Result } from "effection"; +import { + FilesInvariantError, + filesFailure, + fileWriteFailure, + parseFilesFailure, + parseFileWriteFailure, + parseFileWriteSuccess, +} from "@executablemd/runtime"; +import type { + FilesHandler, + FilesOperation, + FilesPhase, + FilePathInput, + FileWriteSuccess, +} from "@executablemd/runtime"; +import { registerFileRecorder, assembleFileCall } from "@executablemd/runtime/files-host"; +import type { Around } from "@effectionx/context-api"; +import type { AuthoredFileOperation } from "@executablemd/core/host"; +import type { + ReplLocalOperation, + ReplLocalRequest, + ReplLocalValue, +} from "./repositories/records.ts"; + +export type FileRecordingRoute = ( + operation: ReplLocalOperation, + request: ReplLocalRequest, + perform: () => Operation>, + authored?: AuthoredFileOperation, +) => Operation>; +export interface EntryFileCalls { + read(authored: AuthoredFileOperation, input: FilePathInput): Operation>; + ensure(authored: AuthoredFileOperation, input: FilePathInput): Operation>; +} +function recordingBody(route: FileRecordingRoute) { + return { + *read( + input: FilePathInput, + perform: () => Operation>, + authored?: AuthoredFileOperation, + ): Operation> { + const result = yield* route( + "file-read", + { ...input }, + () => textOutcome(perform()), + authored, + ); + return result.ok ? Ok(expectText(result.value)) : result; + }, + *ensure( + input: FilePathInput, + perform: () => Operation>, + authored?: AuthoredFileOperation, + ): Operation> { + const result = yield* route( + "dir-ensure", + { ...input }, + () => unitOutcome(perform(), { operation: "ensure-directory", phase: "access" }), + authored, + ); + return result.ok ? Ok(undefined) : result; + }, + boundary(): Partial> { + const body = this; + return { + readTextFile: ([input], next) => body.read(input, () => next(input)), + ensureDirectory: ([input], next) => body.ensure(input, () => next(input)), + *writeTextFile([input], next) { + const result = yield* route("file-write", { ...input }, () => writeOutcome(next(input))); + return result.ok ? Ok(expectWritten(result.value)) : result; + }, + *deleteFile([input], next) { + const result = yield* route("file-delete", { ...input }, () => + unitOutcome(next(input), { operation: "delete", phase: "access" }), + ); + return result.ok ? Ok(undefined) : result; + }, + }; + }, + }; +} +export function fileRecordingMiddleware(route: FileRecordingRoute): Partial> { + return recordingBody(route).boundary(); +} +export function* useEntryFileRecorder(route: FileRecordingRoute): Operation { + const body = recordingBody(route); + const handle = yield* registerFileRecorder(body.boundary()); + const read = assembleFileCall(handle, "readTextFile"); + const ensure = assembleFileCall(handle, "ensureDirectory"); + return Object.freeze({ + read: (authored: AuthoredFileOperation, input: FilePathInput) => + body.read(input, () => read(input), authored), + ensure: (authored: AuthoredFileOperation, input: FilePathInput) => + body.ensure(input, () => ensure(input), authored), + }); +} +function* writeOutcome(call: Operation>): Operation> { + const produced: unknown = yield* call; + const settled = settlement(produced); + if (settled === undefined) { + throw new FilesInvariantError("protocol"); + } + if (settled) { + const carried = read(produced, "value"); + if (carried === undefined) { + throw new FilesInvariantError("protocol"); + } + const success = parseFileWriteSuccess(carried.value); + if (success === undefined) { + throw new FilesInvariantError("protocol"); + } + return Ok(success); + } + const reported = read(produced, "error"); + if (reported === undefined) { + throw new FilesInvariantError("protocol"); + } + const data = parseFileWriteFailure(reported.value); + if (data === undefined) { + throw new FilesInvariantError("protocol"); + } + return Err( + fileWriteFailure({ + phase: data.phase, + ...(data.reason === undefined ? {} : { reason: data.reason }), + ...(data.cleanup === undefined ? {} : { cleanup: data.cleanup }), + }), + ); +} + +/** + * The read outcome the composition answered with, normalized before it is kept. + * + * Core's rules for a payload-carrying non-write operation: a readable boolean + * settlement, a `value` that is really a string, and — where it failed — the + * generic ordinary refusal for failure data this version does not recognize, + * because an unrecognized reason still described a failure and the document + * carries on past it. + */ +function* textOutcome(call: Operation>): Operation> { + const produced: unknown = yield* call; + const settled = settlement(produced); + if (settled === undefined) { + throw new FilesInvariantError("protocol"); + } + if (!settled) { + const reported = read(produced, "error"); + if (reported === undefined) { + throw new FilesInvariantError("protocol"); + } + const data = parseFilesFailure(reported.value); + if (data === undefined || data.operation !== "read") { + return Err(filesFailure({ operation: "read", phase: "access", reason: "operation-failed" })); + } + return Err(filesFailure({ operation: data.operation, phase: data.phase, reason: data.reason })); + } + const carried = read(produced, "value"); + if (carried === undefined || typeof carried.value !== "string") { + throw new FilesInvariantError("protocol"); + } + return Ok(carried.value); +} + +/** + * The outcome of the two operations that answer with nothing, normalized. + * + * Core's rules, kept exactly. A success carries no payload at all, so an + * *absent* `value` is the ordinary answer; one that cannot be read, and one + * that is present but is not `undefined`, are both provider-contract failures. + * + * A failure is where the two differ from a write, and the difference matters: an + * `error` that reads fine but carries data this version does not recognize *did* + * describe a failure, just not a known one, so it becomes the generic ordinary + * refusal and the document carries on. Only an `error` that is absent or + * unreadable is fatal. + */ +function* unitOutcome( + call: Operation>, + contract: { readonly operation: FilesOperation; readonly phase: FilesPhase }, +): Operation> { + const produced: unknown = yield* call; + const settled = settlement(produced); + if (settled === undefined) { + throw new FilesInvariantError("protocol"); + } + if (!settled) { + const reported = read(produced, "error"); + if (reported === undefined) { + throw new FilesInvariantError("protocol"); + } + const data = parseFilesFailure(reported.value); + if (data === undefined || data.operation !== contract.operation) { + return Err( + filesFailure({ + operation: contract.operation, + phase: contract.phase, + reason: "operation-failed", + }), + ); + } + return Err(filesFailure({ operation: data.operation, phase: data.phase, reason: data.reason })); + } + const carried = present(produced, "value"); + if (carried === undefined) { + throw new FilesInvariantError("protocol"); + } + if (carried) { + // Present, so it has to be readable *and* be the absent payload. Asking + // only whether the value is `undefined` would let a member that refused to + // be read pass as one that read as nothing. + const value = read(produced, "value"); + if (value === undefined || value.value !== undefined) { + throw new FilesInvariantError("protocol"); + } + } + return Ok(undefined); +} + +/** + * Whether a member is there at all, or `undefined` when asking threw. + * + * Core's reader, because this boundary reads the same provider-controlled + * values it does. Absence and unreadability are different answers and only one + * of them is ever legitimate, and a `has` trap can refuse the question. + */ +function present(target: unknown, name: string): boolean | undefined { + if (typeof target !== "object" || target === null) { + return undefined; + } + try { + return Reflect.has(target, name); + } catch { + return undefined; + } +} + +/** + * A member of a value this code did not create, boxed so that an absent member + * is distinguishable from one that could not be read. + */ +function read(target: unknown, name: string): { readonly value: unknown } | undefined { + if (present(target, name) !== true) { + return undefined; + } + try { + return { value: Reflect.get(Object(target), name) }; + } catch { + return undefined; + } +} + +/** + * Whether this container said it succeeded, as a boolean it really carries. + * + * Truthiness is not the question: a provider whose `ok` is a string has not + * described a settlement, and reading it as success would record an outcome + * core is about to refuse. + */ +function settlement(result: unknown): boolean | undefined { + const ok = read(result, "ok"); + return typeof ok?.value === "boolean" ? ok.value : undefined; +} + +/** + * The write success a recorded write answers with. + * + * A record of a write carries one by construction, so an absent payload here is + * this host's own invariant rather than anything a provider did. + */ +function expectWritten(value: ReplLocalValue): FileWriteSuccess { + if (value === undefined || typeof value === "string") { + throw new FilesInvariantError("protocol"); + } + return value; +} + +/** + * The text a recorded read answers with. + * + * A record of a read carries a string by construction, so anything else here is + * this host's own invariant rather than anything a provider did. + */ +function expectText(value: ReplLocalValue): string { + if (typeof value !== "string") { + throw new FilesInvariantError("protocol"); + } + return value; +} diff --git a/packages/cli/src/repl/mount-files.ts b/packages/cli/src/repl/mount-files.ts new file mode 100644 index 000000000..9a831e728 --- /dev/null +++ b/packages/cli/src/repl/mount-files.ts @@ -0,0 +1,344 @@ +import { Ok } from "effection"; +import type { Operation, Result } from "effection"; +import { cwd, platform } from "@executablemd/runtime"; +import type { FilePathInput } from "@executablemd/runtime"; +import { DurableContext, createDurableOperation } from "@executablemd/durable-streams"; +import type { + DurableEvent, + Json, + LiveDurableOperationCoordinator, +} from "@executablemd/durable-streams"; +import { posix, win32 } from "node:path"; +import { canonicalJson } from "@executablemd/workflow"; +import { admitDocumentHostPath } from "@executablemd/core/host"; +import type { + AuthoredFileOperation, + DirectoryFrame, + PreparedDirectory, +} from "@executablemd/core/host"; +import type { WorkspaceMountTable } from "../workspace-mounts.ts"; +import { + localOperationCoordinator, + recordedLocalOperation, + localRecordName, +} from "./repositories/deno/local.ts"; +import { + parseReplLocalRecord, + replLocalFingerprint, + replLocalRecordJson, + REPL_REPOSITORY_LOCAL, +} from "./repositories/records.ts"; +import type { ReplLocalValue } from "./repositories/records.ts"; +import { + admitMountRecords, + mountAuthored, + mountFingerprint, + mountName, + mountRecordJson, + MountRecordError, + parseMountEnvelope, + parseMountRecord, + MOUNT_FILE, +} from "./mount-records.ts"; +import type { MountBinding, MountCoverage, MountRecord } from "./mount-records.ts"; + +const coordinator: LiveDurableOperationCoordinator = { + *run(execute, publish, activate, provenance, abandoned) { + if (abandoned.entry !== undefined || abandoned.steppedOver) { + throw activate(new MountRecordError()); + } + return yield* localOperationCoordinator.run(execute, publish, activate, provenance, abandoned); + }, +}; +export type AwaitedMountPolicy = ReturnType; +export function* createMountPolicy( + mounts: WorkspaceMountTable, + directory?: string, +): Operation { + const captured = directory ?? (yield* cwd()); + const pathStyle = (yield* platform()).os === "win32" ? "win32" : "posix"; + return mountPolicy(mounts, captured, pathStyle); +} +export function mountPolicy( + mounts: WorkspaceMountTable, + directory: string, + pathStyle: "posix" | "win32", +) { + const host = pathStyle === "win32" ? win32 : posix; + const root: DirectoryFrame = Object.freeze({ + authoredDirectory: directory, + actualDirectory: directory, + namespace: "host", + interpreting: false, + }); + let hasHistory = false; + const select = (destination: string) => + mounts.find((pair) => destination === pair.target || destination.startsWith(pair.target + "/")); + function prepareDirectory( + a: AuthoredFileOperation, + enclosing: DirectoryFrame, + ): Result { + const namespace = a.path.startsWith("/") + ? "document" + : /^[A-Za-z]:[\\/]/.test(a.path) + ? "host" + : enclosing.namespace; + const selected = namespace === "document" ? select(a.destination) : undefined; + const interpreting = + namespace === "document" && (selected !== undefined || enclosing.interpreting); + if (interpreting) { + const actual = admitDocumentHostPath( + pathStyle, + a.path, + a.namespace === "document" ? a.directory : "/", + a.destination, + selected?.source ?? host.parse(directory).root, + selected?.target ?? "/", + host, + ); + if (!actual.ok) { + return actual; + } + return Ok({ + request: Object.freeze({ cwd: enclosing.actualDirectory, path: actual.value }), + candidate: Object.freeze({ + authoredDirectory: a.destination, + actualDirectory: actual.value, + namespace, + interpreting, + ...(selected === undefined ? {} : { selectedPair: selected }), + }), + }); + } + const actual = a.path.startsWith("/") + ? a.path + : `${enclosing.actualDirectory.replace(/\/$/, "")}/${a.path}`; + return Ok({ + request: Object.freeze({ cwd: enclosing.actualDirectory, path: a.path }), + candidate: Object.freeze({ + authoredDirectory: a.destination, + actualDirectory: actual, + namespace, + interpreting: false, + }), + }); + } + function bindingFor(a: AuthoredFileOperation, request: FilePathInput): MountBinding | undefined { + const selected = select(a.destination); + if (selected === undefined) { + return undefined; + } + const enclosingPair = a.namespace === "document" ? select(a.directory) : undefined; + if ( + a.operation === "file-read" && + (a.namespace !== "document" || + enclosingPair === undefined || + enclosingPair.source !== selected.source || + enclosingPair.target !== selected.target || + host.isAbsolute(a.path)) + ) { + return undefined; + } + const destination = admitDocumentHostPath( + pathStyle, + a.path, + a.namespace === "document" ? a.directory : "/", + a.destination, + selected.source, + selected.target, + host, + ); + if (!destination.ok) { + return undefined; + } + if (enclosingPair !== undefined) { + const enclosing = admitDocumentHostPath( + pathStyle, + a.directory, + a.directory, + a.directory, + enclosingPair.source, + enclosingPair.target, + host, + ); + if (!enclosing.ok || enclosing.value !== request.cwd) { + return undefined; + } + } + if ( + a.operation === "dir-ensure" + ? request.path !== destination.value + : host.resolve(request.cwd, request.path) !== destination.value + ) { + return undefined; + } + return { + pathStyle, + selected: { ...selected }, + enclosing: { + authoredDirectory: a.directory, + actualDirectory: request.cwd, + namespace: a.namespace, + pair: enclosingPair ?? null, + }, + destination: { authored: a.destination, actual: destination.value }, + }; + } + function* guard(): Operation { + const ctx = yield* DurableContext.expect(); + if (hasHistory && ctx.replayIndex.abandonedHistory(ctx.coroutineId)) { + throw new MountRecordError(); + } + } + function* consume( + expected: AuthoredFileOperation, + record: MountRecord, + ): Operation> { + const restored: unknown = yield createDurableOperation( + { type: MOUNT_FILE, name: mountName(record) }, + function* (): Operation { + throw new MountRecordError(); + }, + { coordinator }, + ); + const parsed = yield* parseMountRecord(restored, mountName(record)); + if (canonicalJson(parsed.authored) !== canonicalJson(mountAuthored(expected))) { + throw new MountRecordError(); + } + const physical = parseReplLocalRecord(parsed.physical); + if (physical === undefined) { + throw new MountRecordError(); + } + return physical.outcome; + } + return { + root, + prepareDirectory, + pathStyle, + *admit(events: readonly DurableEvent[]): Operation { + hasHistory = events.some( + (event) => event.type === "yield" && event.description.type.startsWith("repl_mount_file/"), + ); + yield* admitMountRecords(events); + for (const event of events) { + if ( + event.type === "yield" && + event.description.type === MOUNT_FILE && + event.result.status === "ok" + ) { + const record = yield* parseMountRecord(event.result.value, event.description.name); + if (record.binding.pathStyle !== pathStyle) { + throw new MountRecordError(); + } + } + } + }, + guard, + *replay( + a: AuthoredFileOperation, + request: FilePathInput, + ): Operation> | undefined> { + yield* guard(); + const ctx = yield* DurableContext.expect(); + const pending = ctx.replayIndex.peekYield(ctx.coroutineId); + if (pending?.description.type === REPL_REPOSITORY_LOCAL) { + const name = yield* localRecordName({ operation: a.operation, request }); + if (pending.description.name === name) { + return recordedLocalOperation({ + operation: a.operation, + request, + perform: function* (): Operation> { + throw new MountRecordError(); + }, + }); + } + const old = + pending.result.status === "ok" ? parseReplLocalRecord(pending.result.value) : undefined; + if ( + a.operation === "dir-ensure" && + old?.operation === "dir-ensure" && + bindingFor(a, request) !== undefined + ) { + throw new MountRecordError(); + } + } + if (pending === undefined || !pending.description.type.startsWith("repl_mount_file/")) { + return undefined; + } + if (pending.description.type !== MOUNT_FILE || pending.result.status !== "ok") { + throw new MountRecordError(); + } + const envelope = parseMountEnvelope(pending.result.value); + if ( + envelope.operation !== a.operation || + canonicalJson(envelope.authored) !== canonicalJson(mountAuthored(a)) || + envelope.authoredFingerprint !== + (yield* mountFingerprint({ operation: a.operation, authored: mountAuthored(a) })) + ) { + throw new MountRecordError(); + } + const record = yield* parseMountRecord(pending.result.value, pending.description.name); + if ( + record.binding.pathStyle !== pathStyle || + (a.operation === "dir-ensure" && + !mounts.some((pair) => pair.target === record.binding.selected.target)) + ) { + throw new MountRecordError(); + } + return consume(a, record); + }, + *record( + a: AuthoredFileOperation, + request: FilePathInput, + perform: () => Operation>, + coverage: MountCoverage, + ): Operation> { + yield* guard(); + const binding = bindingFor(a, request); + if ( + binding === undefined || + (a.operation === "dir-ensure" && coverage.kind !== "existing") || + (coverage.kind === "existing" && coverage.target !== binding.destination.actual) + ) { + return coverage.kind === "existing" + ? yield* recordedLocalOperation({ operation: a.operation, request, perform }) + : yield* perform(); + } + const fingerprint = yield* mountFingerprint({ + operation: a.operation, + authored: mountAuthored(a), + }); + const physicalFingerprint = yield* replLocalFingerprint({ operation: a.operation, request }); + const name = mountName({ operation: a.operation, authoredFingerprint: fingerprint }); + const value: unknown = yield createDurableOperation( + { type: MOUNT_FILE, name }, + function* () { + const outcome = yield* perform(); + const record: MountRecord = { + version: 1, + operation: a.operation, + authored: mountAuthored(a), + authoredFingerprint: fingerprint, + binding, + coverage, + physical: replLocalRecordJson({ + operation: a.operation, + fingerprint: physicalFingerprint, + request, + outcome, + }), + }; + yield* parseMountRecord(record, name); + return mountRecordJson(record); + }, + { coordinator }, + ); + const parsed = yield* parseMountRecord(value, name); + const physical = parseReplLocalRecord(parsed.physical); + if (physical === undefined) { + throw new MountRecordError(); + } + return physical.outcome; + }, + }; +} diff --git a/packages/cli/src/repl/mount-records.ts b/packages/cli/src/repl/mount-records.ts new file mode 100644 index 000000000..c938f9df8 --- /dev/null +++ b/packages/cli/src/repl/mount-records.ts @@ -0,0 +1,243 @@ +import { Ok } from "effection"; +import { parseJson } from "@executablemd/core"; +import { z } from "zod"; +import { posix, win32 } from "node:path"; +import { canonicalJson } from "@executablemd/workflow"; +import type { Operation } from "effection"; +import type { DurableEvent, Json } from "@executablemd/durable-streams"; +import { StaleInputError } from "@executablemd/durable-streams"; +import { admitDocumentHostPath, authoredOperation } from "@executablemd/core/host"; +import type { AuthoredFileOperation, DirectoryFrame } from "@executablemd/core/host"; +import { digest, parseReplLocalRecord, replLocalFingerprint } from "./repositories/records.ts"; + +export const MOUNT_FILE = "repl_mount_file/v1"; +const text = z.string().min(1); +const pair = z.object({ target: text, source: text }).strict(); +const namespace = z.enum(["document", "host"]); +const authored = z + .object({ + occurrence: text, + form: z.enum(["self-closing", "paired"]), + path: z.string(), + directory: text, + namespace, + destination: text, + }) + .strict(); +const binding = z + .object({ + pathStyle: z.enum(["posix", "win32"]), + selected: pair, + enclosing: z + .object({ authoredDirectory: text, actualDirectory: text, namespace, pair: pair.nullable() }) + .strict(), + destination: z.object({ authored: text, actual: text }).strict(), + }) + .strict(); +const coverage = z.union([ + z.object({ kind: z.literal("external-mapped-read") }).strict(), + z + .object({ + kind: z.literal("existing"), + owner: z.enum(["checkout", "session", "placement"]), + root: text, + target: text, + }) + .strict(), +]); +const shape = z + .object({ + version: z.literal(1), + operation: z.enum(["file-read", "dir-ensure"]), + authored, + authoredFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + binding, + physical: z.unknown(), + coverage, + }) + .strict(); +export type MountBinding = z.infer; +export type MountCoverage = z.infer; +export type MountRecord = z.infer; +export class MountRecordError extends StaleInputError { + override name = "MountRecordError"; + constructor() { + super("The saved mounted filesystem operation does not describe this authored occurrence."); + } +} +export function mountAuthored(input: AuthoredFileOperation): z.infer { + return { + occurrence: input.occurrence, + form: input.form, + path: input.path, + directory: input.directory, + namespace: input.namespace, + destination: input.destination, + }; +} +export function mountFingerprint( + input: Pick, +): Operation { + return digest({ version: 1, operation: input.operation, authored: { ...input.authored } }); +} +export function mountName(input: Pick): string { + return canonicalJson([ + "executablemd.repl.mount-file", + 1, + input.operation, + input.authoredFingerprint, + ]); +} +function within(style: "posix" | "win32", root: string, target: string): boolean { + const host = style === "win32" ? win32 : posix; + const relative = host.relative(root, target); + return ( + !host.isAbsolute(relative) && + !relative.split(host.sep).includes("..") && + host.resolve(root, relative) === host.resolve(target) + ); +} +export function parseMountEnvelope(value: unknown): MountRecord { + const parsed = shape.safeParse(value); + if (!parsed.success) { + throw new MountRecordError(); + } + return parsed.data; +} +export function* parseMountRecord(value: unknown, name?: string): Operation { + const record = parseMountEnvelope(value); + const physical = parseReplLocalRecord(record.physical); + const b = record.binding; + const a = record.authored; + const host = b.pathStyle === "win32" ? win32 : posix; + const frame: DirectoryFrame = { ...b.enclosing, interpreting: true }; + const expected = authoredOperation(record.operation, a.occurrence, a.path, frame); + if ( + canonicalJson(mountAuthored(expected)) !== canonicalJson(a) || + record.authoredFingerprint !== (yield* mountFingerprint(record)) || + (name !== undefined && mountName(record) !== name) || + physical === undefined || + physical.operation !== record.operation || + physical.fingerprint !== (yield* replLocalFingerprint(physical)) || + b.destination.authored !== a.destination || + b.enclosing.namespace !== a.namespace || + a.form !== (record.operation === "file-read" ? "self-closing" : "paired") + ) { + throw new MountRecordError(); + } + const normalizeTarget = (target: string) => + target.startsWith("/") && + !target.startsWith("//") && + !/[\\\0:]/.test(target) && + !target.split("/").includes("..") && + target !== "/" && + posix.normalize(target) === target; + if ( + !normalizeTarget(b.selected.target) || + !host.isAbsolute(b.selected.source) || + host.resolve(b.selected.source) !== b.selected.source + ) { + throw new MountRecordError(); + } + const enclosingPair = b.enclosing.pair; + if (enclosingPair !== null) { + if ( + !normalizeTarget(enclosingPair.target) || + !host.isAbsolute(enclosingPair.source) || + host.resolve(enclosingPair.source) !== enclosingPair.source + ) { + throw new MountRecordError(); + } + const enclosing = admitDocumentHostPath( + b.pathStyle, + b.enclosing.authoredDirectory, + b.enclosing.authoredDirectory, + b.enclosing.authoredDirectory, + enclosingPair.source, + enclosingPair.target, + host, + ); + if (!enclosing.ok || enclosing.value !== b.enclosing.actualDirectory) { + throw new MountRecordError(); + } + } + if (enclosingPair === null) { + const actual = + b.enclosing.namespace === "host" + ? Ok(host.resolve(b.enclosing.authoredDirectory)) + : admitDocumentHostPath( + b.pathStyle, + b.enclosing.authoredDirectory, + b.enclosing.authoredDirectory, + b.enclosing.authoredDirectory, + host.parse(b.enclosing.actualDirectory).root, + "/", + host, + ); + if (!actual.ok || actual.value !== host.resolve(b.enclosing.actualDirectory)) { + throw new MountRecordError(); + } + } + const actual = admitDocumentHostPath( + b.pathStyle, + a.path, + a.namespace === "document" ? a.directory : "/", + a.destination, + b.selected.source, + b.selected.target, + host, + ); + if ( + !actual.ok || + actual.value !== b.destination.actual || + physical.request.cwd !== b.enclosing.actualDirectory || + (record.operation === "dir-ensure" + ? physical.request.path !== actual.value + : physical.request.path !== a.path) || + (record.operation === "file-read" && + (a.namespace !== "document" || + enclosingPair === null || + canonicalJson(enclosingPair) !== canonicalJson(b.selected) || + host.isAbsolute(a.path) || + !within( + b.pathStyle, + b.enclosing.actualDirectory, + host.resolve(b.enclosing.actualDirectory, a.path), + ) || + host.resolve(b.enclosing.actualDirectory, a.path) !== actual.value)) + ) { + throw new MountRecordError(); + } + if (record.operation === "dir-ensure" && record.coverage.kind !== "existing") { + throw new MountRecordError(); + } + if ( + record.coverage.kind === "existing" && + (record.coverage.target !== actual.value || + !host.isAbsolute(record.coverage.root) || + !within(b.pathStyle, record.coverage.root, actual.value)) + ) { + throw new MountRecordError(); + } + return record; +} +export function* admitMountRecords(events: readonly DurableEvent[]): Operation { + for (const event of events) { + if (event.type === "yield" && event.description.type.startsWith("repl_mount_file/")) { + if (event.description.type !== MOUNT_FILE) { + throw new MountRecordError(); + } + if (event.result.status !== "ok") { + throw new MountRecordError(); + } + yield* parseMountRecord(event.result.value, event.description.name); + } + } +} +export function mountRecordJson(record: MountRecord): Json { + const physical = record.physical; + if (physical === undefined) { + throw new MountRecordError(); + } + return parseJson(record); +} diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 5d4c45521..cd42fb8b9 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -308,6 +308,7 @@ export function* runReplProgram(options: ReplProgramOptions): Operation { + yield* admitMountRecords(retained); // The placements this history has selected *so far*. An observation names the // checkout it read, and nothing outside this snapshot may authorize one: the // route has to be a placement a preceding record in the same history made. diff --git a/packages/cli/src/repl/repositories/deno/files.ts b/packages/cli/src/repl/repositories/deno/files.ts index e007f4121..c6fe4c6cc 100644 --- a/packages/cli/src/repl/repositories/deno/files.ts +++ b/packages/cli/src/repl/repositories/deno/files.ts @@ -1,3 +1,9 @@ +import type { AwaitedMountPolicy } from "../../mount-files.ts"; +import { fileRecordingMiddleware, useEntryFileRecorder } from "../../file-recorder.ts"; +import type { MountFileEntry } from "../../workspace-mount-installation.ts"; +import type { WorkspaceMountTable } from "../../../workspace-mounts.ts"; +import type { AuthoredFileOperation } from "@executablemd/core/host"; +import { createMountPolicy } from "../../mount-files.ts"; /** * The file work one REPL entry records, and the work it simply performs. * @@ -40,25 +46,11 @@ * received request stays the operation's name. */ -import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import { isAbsolute, join, relative, resolve } from "node:path"; import { realpath } from "node:fs/promises"; import { until } from "effection"; -import { - FilesInvariantError, - fileWriteFailure, - filesFailure, - parseFilesFailure, - parseFileWriteFailure, - parseFileWriteSuccess, -} from "@executablemd/runtime"; -import type { - FilesHandler, - FilesOperation, - FilesPhase, - FileWriteSuccess, -} from "@executablemd/runtime"; +import type { FilesHandler } from "@executablemd/runtime"; import type { Around } from "@effectionx/context-api"; import { localRecordName, @@ -123,59 +115,9 @@ export function replFileMiddleware( recorder: ReplLocalRecorder, workspace?: string, ): Partial> { - return { - *readTextFile([input], next): Operation> { - const produced = yield* routed( - recorder, - "file-read", - { cwd: input.cwd, path: input.path }, - function* () { - return yield* textOutcome(next(input)); - }, - workspace, - ); - return produced.ok ? Ok(expectText(produced.value)) : produced; - }, - *writeTextFile([input], next): Operation> { - const produced = yield* routed( - recorder, - "file-write", - { cwd: input.cwd, path: input.path, content: input.content }, - function* () { - return yield* writeOutcome(next(input)); - }, - workspace, - ); - return produced.ok ? Ok(expectWritten(produced.value)) : produced; - }, - *deleteFile([input], next): Operation> { - const produced = yield* routed( - recorder, - "file-delete", - { cwd: input.cwd, path: input.path }, - function* () { - return yield* unitOutcome(next(input), { operation: "delete", phase: "access" }); - }, - workspace, - ); - return produced.ok ? Ok(undefined) : produced; - }, - *ensureDirectory([input], next): Operation> { - const produced = yield* routed( - recorder, - "dir-ensure", - { cwd: input.cwd, path: input.path }, - function* () { - return yield* unitOutcome(next(input), { - operation: "ensure-directory", - phase: "access", - }); - }, - workspace, - ); - return produced.ok ? Ok(undefined) : produced; - }, - }; + return fileRecordingMiddleware((operation, request, perform) => + routed(recorder, operation, request, perform, workspace), + ); } /** @@ -193,15 +135,30 @@ function* routed( request: ReplLocalRequest, perform: () => Operation>, workspace: string | undefined, + authored?: AuthoredFileOperation, + mounts?: AwaitedMountPolicy, ): Operation> { + if (mounts !== undefined) { + yield* mounts.guard(); + } const name = yield* localRecordName({ operation, request }); if (yield* retainedSelectionAhead()) { yield* recorder.selection(); } + if (authored !== undefined && mounts !== undefined) { + const replay = yield* mounts.replay(authored, request); + if (replay !== undefined) { + return yield* replay; + } + } if (yield* retainedResultAhead(name)) { return yield* recordedLocalOperation({ operation, request, perform }); } + const retain = (coverage: import("../../mount-records.ts").MountCoverage) => + authored === undefined || mounts === undefined + ? recordedLocalOperation({ operation, request, perform }) + : mounts.record(authored, request, perform, coverage); const target = yield* targetOf(request); const managed = covering(recorder.placements(), target); if (managed !== undefined) { @@ -209,7 +166,15 @@ function* routed( // answers is that one entry at a time owns this managed checkout, which an // observation inside it needs as much as a change does. yield* managed.own(); - return yield* held(recorder, managed.commonDirectory, { operation, request, perform }); + if (operation !== "file-read") { + yield* recorder.hold(managed.commonDirectory); + } + return yield* retain({ + kind: "existing", + owner: "placement", + root: managed.checkoutRoot, + target, + }); } const checkout = yield* recorder.selection(); if (checkout === undefined || !inside(checkout.checkoutRoot, target)) { @@ -221,35 +186,22 @@ function* routed( ? resolve(root, relative(lexical, target)) : target; if (root === resolvedTarget || inside(root, resolvedTarget)) { - return yield* recordedLocalOperation({ operation, request, perform }); + return yield* retain({ kind: "existing", owner: "session", root, target: resolvedTarget }); } } - return yield* perform(); + return authored !== undefined && mounts !== undefined + ? yield* mounts.record(authored, request, perform, { kind: "external-mapped-read" }) + : yield* perform(); } - return yield* held(recorder, checkout.commonDirectory, { operation, request, perform }); -} - -/** - * Record this operation, holding the shared Git directory when it changes one. - * - * Only a mutation takes the lease. A read is retained like the rest, but the - * lease is about one entry at a time *changing* a repository, and an - * observation is not a change: a read that took it would refuse while somebody - * else is working, and refuse them while it is only looking. - */ -function* held( - recorder: ReplLocalRecorder, - commonDirectory: string, - operation: { - readonly operation: ReplLocalOperation; - readonly request: ReplLocalRequest; - perform(): Operation>; - }, -): Operation> { - if (operation.operation !== "file-read") { - yield* recorder.hold(commonDirectory); + if (operation !== "file-read") { + yield* recorder.hold(checkout.commonDirectory); } - return yield* recordedLocalOperation(operation); + return yield* retain({ + kind: "existing", + owner: "checkout", + root: checkout.checkoutRoot, + target, + }); } /** @@ -326,210 +278,15 @@ function inside(checkoutRoot: string, target: string): boolean { return at !== "" && !at.startsWith("..") && !isAbsolute(at); } -/** - * The write outcome the composition answered with, normalized before it is kept. - * - * Canonical core validates this same value again on the way out, and it is the - * authority: `packages/core/src/files.ts` decides what a settlement, a Unit - * success and an unrecognized failure mean. The readers below are its readers, - * because what arrives here is an arbitrary runtime value rather than the - * `Result` the signature promises — a `Proxy` can refuse to be read — and a - * wrapper that normalized differently would either record an outcome core is - * about to refuse or erase the invalid data core detects it by. - * - * A write's report is the one outcome that says something about the world, so - * both a malformed success and a malformed failure are fatal: a provider that - * cannot describe what it did may not have done it. - */ -function* writeOutcome(call: Operation>): Operation> { - const produced: unknown = yield* call; - const settled = settlement(produced); - if (settled === undefined) { - throw new FilesInvariantError("protocol"); - } - if (settled) { - const carried = read(produced, "value"); - if (carried === undefined) { - throw new FilesInvariantError("protocol"); - } - const success = parseFileWriteSuccess(carried.value); - if (success === undefined) { - throw new FilesInvariantError("protocol"); - } - return Ok(success); - } - const reported = read(produced, "error"); - if (reported === undefined) { - throw new FilesInvariantError("protocol"); - } - const data = parseFileWriteFailure(reported.value); - if (data === undefined) { - throw new FilesInvariantError("protocol"); - } - return Err( - fileWriteFailure({ - phase: data.phase, - ...(data.reason === undefined ? {} : { reason: data.reason }), - ...(data.cleanup === undefined ? {} : { cleanup: data.cleanup }), - }), +export function* useReplFileRecorder( + recorder: ReplLocalRecorder, + workspace: string | undefined, + mounts: WorkspaceMountTable, + directory: string, +): Operation { + const policy = yield* createMountPolicy(mounts, directory); + const files = yield* useEntryFileRecorder((operation, request, perform, authored) => + routed(recorder, operation, request, perform, workspace, authored, policy), ); -} - -/** - * The read outcome the composition answered with, normalized before it is kept. - * - * Core's rules for a payload-carrying non-write operation: a readable boolean - * settlement, a `value` that is really a string, and — where it failed — the - * generic ordinary refusal for failure data this version does not recognize, - * because an unrecognized reason still described a failure and the document - * carries on past it. - */ -function* textOutcome(call: Operation>): Operation> { - const produced: unknown = yield* call; - const settled = settlement(produced); - if (settled === undefined) { - throw new FilesInvariantError("protocol"); - } - if (!settled) { - const reported = read(produced, "error"); - if (reported === undefined) { - throw new FilesInvariantError("protocol"); - } - const data = parseFilesFailure(reported.value); - if (data === undefined || data.operation !== "read") { - return Err(filesFailure({ operation: "read", phase: "access", reason: "operation-failed" })); - } - return Err(filesFailure({ operation: data.operation, phase: data.phase, reason: data.reason })); - } - const carried = read(produced, "value"); - if (carried === undefined || typeof carried.value !== "string") { - throw new FilesInvariantError("protocol"); - } - return Ok(carried.value); -} - -/** - * The outcome of the two operations that answer with nothing, normalized. - * - * Core's rules, kept exactly. A success carries no payload at all, so an - * *absent* `value` is the ordinary answer; one that cannot be read, and one - * that is present but is not `undefined`, are both provider-contract failures. - * - * A failure is where the two differ from a write, and the difference matters: an - * `error` that reads fine but carries data this version does not recognize *did* - * describe a failure, just not a known one, so it becomes the generic ordinary - * refusal and the document carries on. Only an `error` that is absent or - * unreadable is fatal. - */ -function* unitOutcome( - call: Operation>, - contract: { readonly operation: FilesOperation; readonly phase: FilesPhase }, -): Operation> { - const produced: unknown = yield* call; - const settled = settlement(produced); - if (settled === undefined) { - throw new FilesInvariantError("protocol"); - } - if (!settled) { - const reported = read(produced, "error"); - if (reported === undefined) { - throw new FilesInvariantError("protocol"); - } - const data = parseFilesFailure(reported.value); - if (data === undefined || data.operation !== contract.operation) { - return Err( - filesFailure({ - operation: contract.operation, - phase: contract.phase, - reason: "operation-failed", - }), - ); - } - return Err(filesFailure({ operation: data.operation, phase: data.phase, reason: data.reason })); - } - const carried = present(produced, "value"); - if (carried === undefined) { - throw new FilesInvariantError("protocol"); - } - if (carried) { - // Present, so it has to be readable *and* be the absent payload. Asking - // only whether the value is `undefined` would let a member that refused to - // be read pass as one that read as nothing. - const value = read(produced, "value"); - if (value === undefined || value.value !== undefined) { - throw new FilesInvariantError("protocol"); - } - } - return Ok(undefined); -} - -/** - * Whether a member is there at all, or `undefined` when asking threw. - * - * Core's reader, because this boundary reads the same provider-controlled - * values it does. Absence and unreadability are different answers and only one - * of them is ever legitimate, and a `has` trap can refuse the question. - */ -function present(target: unknown, name: string): boolean | undefined { - if (typeof target !== "object" || target === null) { - return undefined; - } - try { - return Reflect.has(target, name); - } catch { - return undefined; - } -} - -/** - * A member of a value this code did not create, boxed so that an absent member - * is distinguishable from one that could not be read. - */ -function read(target: unknown, name: string): { readonly value: unknown } | undefined { - if (present(target, name) !== true) { - return undefined; - } - try { - return { value: Reflect.get(Object(target), name) }; - } catch { - return undefined; - } -} - -/** - * Whether this container said it succeeded, as a boolean it really carries. - * - * Truthiness is not the question: a provider whose `ok` is a string has not - * described a settlement, and reading it as success would record an outcome - * core is about to refuse. - */ -function settlement(result: unknown): boolean | undefined { - const ok = read(result, "ok"); - return typeof ok?.value === "boolean" ? ok.value : undefined; -} - -/** - * The write success a recorded write answers with. - * - * A record of a write carries one by construction, so an absent payload here is - * this host's own invariant rather than anything a provider did. - */ -function expectWritten(value: ReplLocalValue): FileWriteSuccess { - if (value === undefined || typeof value === "string") { - throw new FilesInvariantError("protocol"); - } - return value; -} - -/** - * The text a recorded read answers with. - * - * A record of a read carries a string by construction, so anything else here is - * this host's own invariant rather than anything a provider did. - */ -function expectText(value: ReplLocalValue): string { - if (typeof value !== "string") { - throw new FilesInvariantError("protocol"); - } - return value; + return { files, policy }; } diff --git a/packages/cli/src/repl/repositories/deno/local.ts b/packages/cli/src/repl/repositories/deno/local.ts index 581ad2bbe..e785d7730 100644 --- a/packages/cli/src/repl/repositories/deno/local.ts +++ b/packages/cli/src/repl/repositories/deno/local.ts @@ -250,7 +250,7 @@ function* recording(operation: LocalOperation): Operation * returned. A failing append is the engine's own persistence fence and is not * caught here. */ -const localOperationCoordinator: LiveDurableOperationCoordinator = { +export const localOperationCoordinator: LiveDurableOperationCoordinator = { *run( execute: () => Operation, publish: (result: ProtocolResult) => Operation, diff --git a/packages/cli/src/repl/repositories/deno/provider.ts b/packages/cli/src/repl/repositories/deno/provider.ts index 3b5c846fb..cbc9a5376 100644 --- a/packages/cli/src/repl/repositories/deno/provider.ts +++ b/packages/cli/src/repl/repositories/deno/provider.ts @@ -1,3 +1,6 @@ +import { workspaceMountInstallation } from "../../workspace-mount-installation.ts"; +import type { MountFileEntry } from "../../workspace-mount-installation.ts"; +import type { WorkspaceMountTable } from "../../../workspace-mounts.ts"; /** * One REPL entry's recording: what the entry executing right now installs. * @@ -70,7 +73,7 @@ import { selectionRegistry } from "@executablemd/git/deno"; import { holdManagedPlacement, useCommonDirectoryLease } from "./leases.ts"; import type { ReplAmbientCheckout } from "../records.ts"; import { currentCoroutine, selectedCheckout } from "./local.ts"; -import { replFileMiddleware } from "./files.ts"; +import { useReplFileRecorder } from "./files.ts"; import type { ReplLocalRecorder, ReplManagedPlacement } from "./files.ts"; import { replGitMiddleware } from "./git.ts"; import type { ReplGitCheckout, ReplGitEntry } from "./git.ts"; @@ -101,6 +104,7 @@ import type { ReplPlacementRegistration, ReplSelectionEntry } from "./selections /** What a caller may state about one entry's recording. */ export interface ReplRecordingOptions { + readonly mounts?: WorkspaceMountTable; readonly projectCwd?: string; readonly workspace?: string; /** @@ -185,7 +189,7 @@ export function* replRepositoryInstallation( owner: string, options: ReplRecordingOptions = {}, ): Operation { - const held: { entry?: ReplPushEntry } = {}; + const held: { entry?: ReplPushEntry; files?: MountFileEntry } = {}; let usable = false; yield* Execution.around({ @@ -196,11 +200,18 @@ export function* replRepositoryInstallation( yield* ensure(() => { usable = false; delete held.entry; + delete held.files; }); - yield* initializeReplRepository(options, (entry) => { - held.entry = entry; - usable = true; - }); + yield* initializeReplRepository( + options, + (entry) => { + held.entry = entry; + usable = true; + }, + (files) => { + held.files = files; + }, + ); yield* next(); }, }); @@ -244,9 +255,16 @@ export function* replRepositoryInstallation( replPushAdmission(owner), replPullRequestReadAdmission(owner), ]; + const filesystem = workspaceMountInstallation(options.mounts ?? [], options.workspace, () => { + if (held.files === undefined) { + throw new Error("Entry file recorder is not initialized"); + } + return held.files; + }); return { + filesystem: filesystem.filesystem, components, - admissions, + admissions: [...admissions, ...(filesystem.admissions ?? [])], install: () => useEntryIssueTransport({}), }; } @@ -271,6 +289,7 @@ export function* replRepositoryInstallation( function* initializeReplRepository( options: ReplRecordingOptions, deliver?: (entry: ReplPushEntry) => void, + deliverFiles?: (entry: MountFileEntry) => void, ): Operation { const directory = options.projectCwd ?? (yield* cwd()); const configuredRoot = options.root; @@ -358,7 +377,13 @@ function* initializeReplRepository( // The outermost band, so what is recorded is the result the whole composition // answered with rather than one layer's own. - yield* Files.around(replFileMiddleware(recorder, options.workspace), { at: "max" }); + const files = yield* useReplFileRecorder( + recorder, + options.workspace, + options.mounts ?? [], + directory, + ); + deliverFiles?.(files); // A recorder with no managed root records file work and installs no selector, // so `` and `` reach their ordinary missing-provider diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index c37fbcc72..2c81c9f33 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -1,3 +1,5 @@ +import { workspaceMountInstallation } from "./workspace-mount-installation.ts"; +import type { WorkspaceMountTable } from "../workspace-mounts.ts"; /** * One execution, running or reconstructed, and what the application reads. * @@ -511,6 +513,7 @@ export interface ReplSessionOptions { readonly execution: ReplExecution; /** Where components are looked for. */ readonly includes?: readonly string[]; + readonly mounts?: WorkspaceMountTable; /** What this host installs around the document. */ readonly installations?: readonly ExecutionInstallation[]; /** @@ -1438,9 +1441,15 @@ function* start( // the entry rather than to a scope that has already closed. const owned = entryInstallation === undefined - ? [] + ? [ + workspaceMountInstallation( + options.mounts ?? [], + workspace === true ? options.workspace : undefined, + ), + ] : [ yield* entryInstallation(entryOwner(execution.id, key), { + mounts: options.mounts ?? [], ...(options.projectCwd === undefined ? {} : { projectCwd: options.projectCwd }), ...(workspace === true && options.workspace !== undefined ? { workspace: options.workspace } diff --git a/packages/cli/src/repl/workspace-mount-installation.ts b/packages/cli/src/repl/workspace-mount-installation.ts new file mode 100644 index 000000000..f262b6877 --- /dev/null +++ b/packages/cli/src/repl/workspace-mount-installation.ts @@ -0,0 +1,91 @@ +import { admitReplRepositoryHistory } from "./repositories/admission.ts"; +import { cwd } from "@executablemd/runtime"; +import type { Operation, Result } from "effection"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { posix, win32 } from "node:path"; +import type { WorkspaceMountTable } from "../workspace-mounts.ts"; +import { useEntryFileRecorder } from "./file-recorder.ts"; +import type { EntryFileCalls } from "./file-recorder.ts"; +import { createMountPolicy } from "./mount-files.ts"; +import type { AwaitedMountPolicy } from "./mount-files.ts"; +import { recordedLocalOperation } from "./repositories/deno/local.ts"; +import type { ReplLocalValue } from "./repositories/records.ts"; +import { MountRecordError } from "./mount-records.ts"; + +export interface MountFileEntry { + readonly files: EntryFileCalls; + readonly policy: AwaitedMountPolicy; +} +export function workspaceMountInstallation( + mounts: WorkspaceMountTable, + workspace?: string, + access?: () => MountFileEntry, +): ExecutionInstallation { + mounts = Object.freeze(mounts.map((pair) => Object.freeze({ ...pair }))); + let entry: MountFileEntry | undefined; + return { + admissions: [ + function* (events: readonly DurableEvent[]) { + if (entry === undefined) { + throw new MountRecordError(); + } + if (access === undefined) { + yield* admitReplRepositoryHistory(events); + } + yield* entry.policy.admit(events); + }, + ], + *filesystem(deliver) { + if (access !== undefined) { + entry = access(); + } else { + const directory = yield* cwd(); + const policy = yield* createMountPolicy(mounts, directory); + const files = yield* useEntryFileRecorder( + function* (operation, request, perform, authored): Operation> { + yield* policy.guard(); + if (authored !== undefined) { + const replay = yield* policy.replay(authored, request); + if (replay !== undefined) { + return yield* replay; + } + } + const host = policy.pathStyle === "win32" ? win32 : posix; + const target = host.resolve(request.cwd, request.path); + const relative = workspace === undefined ? undefined : host.relative(workspace, target); + const covered = + relative !== undefined && + !host.isAbsolute(relative) && + !relative.split(host.sep).includes(".."); + if (authored !== undefined) { + return yield* policy.record( + authored, + request, + perform, + covered && workspace !== undefined + ? { kind: "existing", owner: "session", root: workspace, target } + : { kind: "external-mapped-read" }, + ); + } + return covered + ? yield* recordedLocalOperation({ operation, request, perform }) + : yield* perform(); + }, + ); + entry = { files, policy }; + } + const held = entry; + deliver({ + root: held.policy.root, + prepareDirectory: held.policy.prepareDirectory, + *read(authored, request) { + return yield* held.files.read(authored, request); + }, + *ensure(authored, request) { + return yield* held.files.ensure(authored, request); + }, + }); + }, + }; +} diff --git a/packages/cli/src/workspace-mounts.ts b/packages/cli/src/workspace-mounts.ts new file mode 100644 index 000000000..bdeb83bad --- /dev/null +++ b/packages/cli/src/workspace-mounts.ts @@ -0,0 +1,60 @@ +import { Err, Ok } from "effection"; +import type { Result } from "effection"; +import { posix } from "node:path"; + +export interface WorkspaceMount { + readonly source: string; + readonly target: string; +} +export type WorkspaceMountTable = readonly WorkspaceMount[]; +export interface MountHostPath { + resolve(...paths: string[]): string; +} + +export function parseWorkspaceMounts( + values: readonly string[], + capturedCwd: string, + hostPath: MountHostPath, +): Result { + const pairs: WorkspaceMount[] = []; + for (const value of values) { + const colon = value.lastIndexOf(":"); + const source = value.slice(0, colon); + const target = value.slice(colon + 1); + if ( + colon < 1 || + !target.startsWith("/") || + target.startsWith("//") || + target.includes("\\") || + target.includes(":") || + value.includes("\0") || + target.split("/").includes("..") + ) { + return Err( + new Error("--mount needs SOURCE:TARGET with an explicit absolute document TARGET."), + ); + } + const normalized = posix.normalize(target); + if (normalized === "/") { + return Err(new Error("--mount TARGET cannot be the document root /.")); + } + const destination = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; + if ( + pairs.some( + (pair) => + pair.target === destination || + pair.target.startsWith(destination + "/") || + destination.startsWith(pair.target + "/"), + ) + ) { + return Err( + new Error(`--mount TARGET ${JSON.stringify(destination)} overlaps another TARGET.`), + ); + } + pairs.push( + Object.freeze({ source: hostPath.resolve(capturedCwd, source), target: destination }), + ); + } + pairs.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0)); + return Ok(Object.freeze(pairs)); +} diff --git a/packages/cli/tests/repl-mounts-pty.test.ts b/packages/cli/tests/repl-mounts-pty.test.ts new file mode 100644 index 000000000..b54e9fa16 --- /dev/null +++ b/packages/cli/tests/repl-mounts-pty.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { cliShellCommand } from "@executablemd/test-support/launch"; +import { replMountsJourney } from "./support/repl-mounts-journey.ts"; + +describe("workspace mounts through the production terminal", () => { + it("retains historical reads while current placement controls new work", function* () { + yield* replMountsJourney((args) => cliShellCommand([...args])); + }); +}); diff --git a/packages/cli/tests/retained-mount-files.test.ts b/packages/cli/tests/retained-mount-files.test.ts new file mode 100644 index 000000000..e401f5ecf --- /dev/null +++ b/packages/cli/tests/retained-mount-files.test.ts @@ -0,0 +1,389 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { mountedRun, trace } from "./support/mount-run.ts"; +import { MOUNT_FILE } from "../src/repl/mount-records.ts"; + +const question = 'Continue?'; +describe("retained mount files", () => { + for (const current of ["/old", "/new", undefined]) { + it(`restores saved read before a new read under ${current ?? "omission"}`, function* () { + const stream = new InMemoryStream(); + const source = `${question}{saved}`; + const first = trace(); + yield* mountedRun(source, stream, [{ source: "/old", target: "/reference" }], first, { + pause: true, + onRead: () => "historical", + }); + const before = yield* stream.readAll(); + expect( + before.filter((event) => event.type === "yield" && event.description.type === MOUNT_FILE), + ).toHaveLength(1); + const second = trace(); + const mounts = current === undefined ? [] : [{ source: current, target: "/reference" }]; + const output = yield* mountedRun(source, stream, mounts, second, { onRead: () => "live" }); + expect(output).toContain("historical"); + expect(output).toContain("live"); + expect(second.reads).toEqual([{ cwd: current ?? "/reference", path: "next.md" }]); + const fresh = trace(); + yield* mountedRun( + '', + new InMemoryStream(), + mounts, + fresh, + ); + expect(fresh.reads).toHaveLength(1); + }); + } + it("replays a covered ensure's own Unit after reassignment without current ensure IO", function* () { + const stream = new InMemoryStream(); + const source = `${question}`; + const first = trace(); + yield* mountedRun(source, stream, [{ source: "/owned/old", target: "/reference" }], first, { + pause: true, + workspace: "/owned", + }); + const second = trace(); + const output = yield* mountedRun( + source, + stream, + [{ source: "/missing/new", target: "/reference" }], + second, + { workspace: "/owned" }, + ); + expect(second.ensures).toEqual([]); + expect(second.reads).toEqual([{ cwd: "/missing/new", path: "next.md" }]); + expect(output).toContain("/missing/new/next.md"); + }); + it("keeps an external ensure live and gates children on its refusal", function* () { + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + '', + new InMemoryStream(), + [{ source: "/external", target: "/reference" }], + calls, + { refuseEnsure: true }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(calls.reads).toEqual([]); + }); +}); + +import { API, filesFailure } from "@executablemd/runtime"; +import { Err, Ok, scoped, spawn, suspend, withResolvers, ensure } from "effection"; +import { DurablePersistenceError } from "@executablemd/durable-streams"; +describe("additional focused boundary 1", () => { + it("an ensure whose append fails binds no child and preserves the first persistence failure", function* () { + const stream = new InMemoryStream(); + const original = new Error("fixture append failure"); + stream.onAppend = (event) => { + if (event.type === "yield" && event.description.type === MOUNT_FILE) { + throw original; + } + }; + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + '', + stream, + [{ source: "/owned/source", target: "/reference" }], + calls, + { workspace: "/owned" }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(DurablePersistenceError); + expect(failure instanceof Error && failure.cause).toBe(original); + expect(calls.ensures).toHaveLength(1); + expect(calls.reads).toEqual([]); + expect( + (yield* stream.readAll()).filter( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ), + ).toEqual([]); + }); +}); +describe("additional focused boundary 2", () => { + it("cancellation joins a pending ensure before any child and restores enclosing cwd", function* () { + const entered = withResolvers(); + let released = false; + const calls = trace(); + const task = yield* spawn(() => + mountedRun( + '', + new InMemoryStream(), + [{ source: "/external", target: "/reference" }], + calls, + { + *before() { + yield* API.Files.around({ + *ensureDirectory() { + yield* ensure(() => { + released = true; + }); + entered.resolve(); + yield* suspend(); + return Ok(undefined); + }, + }); + }, + }, + ), + ); + yield* entered.operation; + yield* task.halt(); + expect(released).toBe(true); + expect(calls.reads).toEqual([]); + }); +}); +describe("additional focused boundary 3", () => { + it("retains a composed safe refusal and bypasses all middleware when replaying it", function* () { + const source = ''; + const stream = new InMemoryStream(); + const first = trace(); + const options = { + *before() { + yield* API.Files.around({ + *readTextFile() { + return Err( + filesFailure({ operation: "read", phase: "access", reason: "permission-denied" }), + ); + }, + }); + }, + }; + const output = yield* mountedRun( + source, + stream, + [{ source: "/external", target: "/reference" }], + first, + options, + ); + expect(output).toContain("permission denied"); + const events = yield* stream.readAll(); + const partial = new InMemoryStream(events.filter((event) => event.type !== "close")); + let reads = 0; + yield* mountedRun(source, partial, [{ source: "/different", target: "/reference" }], trace(), { + *before() { + yield* API.Files.around({ + *readTextFile() { + reads++; + throw new Error("replayed read reached middleware"); + }, + }); + }, + }); + expect(reads).toBe(0); + }); +}); + +import { Component } from "@executablemd/core/api"; +import { cwd } from "@executablemd/runtime"; +describe("additional focused boundary 4", () => { + it("an acknowledged Unit precedes child admission and survives joined cancellation", function* () { + const stream = new InMemoryStream(); + const calls = trace(); + const child = withResolvers(); + let restored = false; + const task = yield* spawn(() => + mountedRun( + '', + stream, + [{ source: "/owned/source", target: "/reference" }], + calls, + { + workspace: "/owned", + *before() { + yield* Component.around({ + *expand([request], next) { + if (request.expansion.name === "File") { + expect(yield* cwd()).toBe("/owned/source"); + yield* ensure(() => { + restored = true; + }); + child.resolve(); + yield* suspend(); + } + yield* next(request); + }, + }); + }, + }, + ), + ); + yield* child.operation; + expect( + (yield* stream.readAll()).filter( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ), + ).toHaveLength(1); + yield* task.halt(); + expect(restored).toBe(true); + expect(calls.reads).toEqual([]); + const resumed = trace(); + yield* mountedRun( + '', + stream, + [{ source: "/owned/new", target: "/reference" }], + resumed, + { workspace: "/owned" }, + ); + expect(resumed.ensures).toEqual([]); + expect(resumed.reads).toEqual([{ cwd: "/owned/new", path: "next.md" }]); + }); +}); +describe("additional focused boundary 5", () => { + it("replays its own ordinary ensure refusal without installing children", function* () { + const source = `${question}`; + const stream = new InMemoryStream(); + yield* mountedRun(source, stream, [{ source: "/owned/old", target: "/reference" }], trace(), { + workspace: "/owned", + pause: true, + refuseEnsure: true, + }); + const calls = trace(); + yield* mountedRun(source, stream, [{ source: "/owned/new", target: "/reference" }], calls, { + workspace: "/owned", + }); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + }); +}); + +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { Operation } from "effection"; +import { FilesInvariantError } from "@executablemd/runtime"; +describe("additional focused boundary 6", () => { + it("joins cancellation during append without granting a historical Unit", function* () { + const entered = withResolvers(); + let settled = false; + const enclosing = yield* cwd(); + class PendingAppend extends InMemoryStream { + override *append(event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === MOUNT_FILE) { + yield* ensure(() => { + settled = true; + }); + entered.resolve(); + yield* suspend(); + } + yield* super.append(event); + } + } + const stream = new PendingAppend(); + const calls = trace(); + const task = yield* spawn(() => + mountedRun( + '', + stream, + [{ source: "/owned/source", target: "/reference" }], + calls, + { workspace: "/owned" }, + ), + ); + yield* entered.operation; + expect(calls.ensures).toHaveLength(1); + expect(calls.reads).toEqual([]); + yield* task.halt(); + expect(settled).toBe(true); + expect(yield* cwd()).toBe(enclosing); + expect( + (yield* stream.readAll()).some( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ), + ).toBe(false); + }); +}); +describe("additional focused boundary 7", () => { + it("fences malformed provider settlement without publishing a success or admitting children", function* () { + const stream = new InMemoryStream(); + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + '', + stream, + [{ source: "/owned/source", target: "/reference" }], + calls, + { + workspace: "/owned", + *before() { + yield* API.Files.around({ + *ensureDirectory() { + return new Proxy(Ok(undefined), { + get(target, property, receiver) { + if (property === "ok") { + throw new Error("invalid fixture getter"); + } + return Reflect.get(target, property, receiver); + }, + }); + }, + }); + }, + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(FilesInvariantError); + expect(calls.reads).toEqual([]); + expect( + (yield* stream.readAll()).some( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ), + ).toBe(false); + }); +}); +describe("whole composed answer retention", () => { + it("retains one outer answer and the original request around transformed repeated delegation", function* () { + const stream = new InMemoryStream(); + const first = trace(); + const source = `${question}`; + yield* mountedRun(source, stream, [{ source: "/external", target: "/reference" }], first, { + pause: true, + *before() { + yield* API.Files.around({ + *readTextFile([input], next) { + const one = yield* next({ ...input, path: "one.md" }); + const two = yield* next({ ...input, path: "two.md" }); + return one.ok && two.ok ? Ok(`outer:${one.value}|${two.value}`) : one; + }, + }); + }, + }); + expect(first.reads).toEqual([ + { cwd: "/external", path: "one.md" }, + { cwd: "/external", path: "two.md" }, + ]); + const events = (yield* stream.readAll()).filter( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ); + expect(events).toHaveLength(1); + const calls = trace(); + const output = yield* mountedRun( + source, + stream, + [{ source: "/new", target: "/reference" }], + calls, + { + *before() { + yield* API.Files.around({ + *readTextFile() { + throw new Error("composed replay ran middleware"); + }, + }); + }, + }, + ); + expect(output).toContain("outer:/external/one.md|/external/two.md"); + expect(calls.reads).toEqual([]); + }); +}); diff --git a/packages/cli/tests/retained-mount-records.test.ts b/packages/cli/tests/retained-mount-records.test.ts new file mode 100644 index 000000000..dd823ec63 --- /dev/null +++ b/packages/cli/tests/retained-mount-records.test.ts @@ -0,0 +1,395 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Divergence, type DivergenceDecision } from "@executablemd/durable-streams"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { authoredOperation } from "@executablemd/core/host"; +import { R1_WIN32 } from "../../test-support/mount-fixture.ts"; +import { + mountAuthored, + mountFingerprint, + mountName, + mountRecordJson, + parseMountRecord, + MountRecordError, +} from "../src/repl/mount-records.ts"; +import { + parseReplLocalRecord, + replLocalFingerprint, + replLocalRecordJson, +} from "../src/repl/repositories/records.ts"; +import { Ok } from "effection"; +import { mountedRun, trace } from "./support/mount-run.ts"; + +describe("mount record consistency", () => { + it("R1-WIN32 refuses a fully hashed but component-changing original binding", function* () { + const f = R1_WIN32; + const a = authoredOperation("dir-ensure", "r1", f.operand, { + authoredDirectory: f.target, + actualDirectory: f.source, + namespace: "document", + interpreting: true, + }); + const request = { cwd: f.source, path: f.escaped }; + const record: MountRecord = { + version: 1, + operation: "dir-ensure", + authored: mountAuthored(a), + authoredFingerprint: yield* mountFingerprint({ + operation: "dir-ensure", + authored: mountAuthored(a), + }), + binding: { + pathStyle: "win32", + selected: { source: f.source, target: f.target }, + enclosing: { + authoredDirectory: f.target, + actualDirectory: f.source, + namespace: "document", + pair: { source: f.source, target: f.target }, + }, + destination: { authored: a.destination, actual: f.escaped }, + }, + coverage: { kind: "existing", owner: "session", root: "C:\\work", target: f.escaped }, + physical: replLocalRecordJson({ + operation: "dir-ensure", + request, + outcome: Ok(undefined), + fingerprint: yield* replLocalFingerprint({ operation: "dir-ensure", request }), + }), + }; + let failure: unknown; + try { + yield* parseMountRecord(record); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(MountRecordError); + const calls = trace(); + let admission: unknown; + try { + yield* mountedRun( + 'unreachable', + new InMemoryStream([ + { + type: "yield", + coroutineId: "root", + description: { type: "repl_mount_file/v1", name: mountName(record) }, + result: { status: "ok", value: mountRecordJson(record) }, + }, + ]), + [{ source: "/owned", target: "/reference" }], + calls, + ); + } catch (error) { + admission = error; + } + expect(admission).toBeInstanceOf(MountRecordError); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + }); + it("rejects changed authorship before any live read or ensure", function* () { + const stream = new InMemoryStream(); + const source = + 'wait'; + yield* mountedRun( + source, + stream, + [{ source: "/owned/source", target: "/reference" }], + trace(), + { pause: true, workspace: "/owned" }, + ); + const changed = yield* stream.readAll(); + for (const event of changed) { + if ( + event.type !== "yield" || + event.description.type !== "repl_mount_file/v1" || + event.result.status !== "ok" + ) { + continue; + } + const record = yield* parseMountRecord(event.result.value); + if (record.operation !== "file-read") { + continue; + } + record.authored.path = "changed.md"; + record.authored.destination = "/reference/changed.md"; + record.binding.destination.authored = record.authored.destination; + record.binding.destination.actual = "/owned/source/changed.md"; + record.coverage = { + kind: "existing", + owner: "session", + root: "/owned", + target: "/owned/source/changed.md", + }; + const request = { cwd: "/owned/source", path: "changed.md" }; + record.physical = replLocalRecordJson({ + operation: "file-read", + request, + outcome: Ok("retained"), + fingerprint: yield* replLocalFingerprint({ operation: "file-read", request }), + }); + record.authoredFingerprint = yield* mountFingerprint(record); + event.description.name = mountName(record); + event.result = { status: "ok", value: mountRecordJson(record) }; + } + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + source, + new InMemoryStream(changed), + [{ source: "/owned/new", target: "/reference" }], + calls, + { workspace: "/owned" }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(calls.reads).toEqual([]); + expect(calls.ensures).toEqual([]); + }); +}); + +import type { MountRecord } from "../src/repl/mount-records.ts"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { REPL_REPOSITORY_LOCAL, replLocalRecordName } from "../src/repl/repositories/records.ts"; +const pausedSource = + 'wait'; +function* fixture() { + const stream = new InMemoryStream(); + yield* mountedRun( + pausedSource, + stream, + [{ source: "/owned/source", target: "/reference" }], + trace(), + { pause: true, workspace: "/owned" }, + ); + return yield* stream.readAll(); +} +function* replaceEnsure(events: DurableEvent[], mutate: (record: MountRecord) => void) { + for (const event of events) { + if ( + event.type !== "yield" || + event.description.type !== "repl_mount_file/v1" || + event.result.status !== "ok" + ) { + continue; + } + const record = yield* parseMountRecord(event.result.value); + if (record.operation !== "dir-ensure") { + continue; + } + mutate(record); + record.authoredFingerprint = yield* mountFingerprint(record); + event.description.name = mountName(record); + event.result = { status: "ok", value: mountRecordJson(record) }; + } +} +describe("closed association admission", () => { + const changes: { name: string; mutate(record: MountRecord): void }[] = [ + { + name: "occurrence", + mutate(record) { + record.authored.occurrence += "-changed"; + }, + }, + { + name: "form", + mutate(record) { + record.authored.form = "self-closing"; + }, + }, + { + name: "operand", + mutate(record) { + record.authored.path = "/reference/."; + }, + }, + { + name: "enclosing authored directory", + mutate(record) { + record.authored.directory = "/changed"; + }, + }, + { + name: "namespace", + mutate(record) { + record.authored.namespace = "document"; + record.binding.enclosing.namespace = "document"; + }, + }, + { + name: "absolute destination", + mutate(record) { + record.authored.destination = "/reference/changed"; + }, + }, + { + name: "original binding", + mutate(record) { + record.binding.destination.actual = "/owned/wrong"; + }, + }, + { + name: "ensure coverage", + mutate(record) { + record.coverage = { kind: "external-mapped-read" }; + }, + }, + { + name: "version", + mutate(record) { + Reflect.set(record, "version", 2); + }, + }, + { + name: "closed shape", + mutate(record) { + Reflect.set(record, "extra", true); + }, + }, + ]; + changes.push({ + name: "physical hash", + mutate(record) { + if (typeof record.physical === "object" && record.physical !== null) { + Reflect.set(record.physical, "fingerprint", "0".repeat(64)); + } + }, + }); + function* allowRunLive() { + yield* Divergence.around({ + decide(): DivergenceDecision { + return { type: "run-live" }; + }, + }); + } + for (const row of changes) { + it(`refuses changed ${row.name} before any provider effect`, function* () { + const events = yield* fixture(); + yield* replaceEnsure(events, row.mutate); + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + pausedSource, + new InMemoryStream(events), + [{ source: "/owned/new", target: "/reference" }], + calls, + { workspace: "/owned", before: allowRunLive }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + }); + } + it("refuses a recomputed physical hash whose request disagrees with the original binding", function* () { + const events = yield* fixture(); + for (const event of events) { + if ( + event.type !== "yield" || + event.description.type !== "repl_mount_file/v1" || + event.result.status !== "ok" + ) { + continue; + } + const record = yield* parseMountRecord(event.result.value); + if (record.operation !== "dir-ensure") { + continue; + } + const request = { cwd: "/host", path: "/owned/wrong" }; + record.physical = replLocalRecordJson({ + operation: "dir-ensure", + request, + outcome: Ok(undefined), + fingerprint: yield* replLocalFingerprint({ operation: "dir-ensure", request }), + }); + event.result = { status: "ok", value: mountRecordJson(record) }; + } + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun( + pausedSource, + new InMemoryStream(events), + [{ source: "/owned/new", target: "/reference" }], + calls, + { workspace: "/owned" }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(MountRecordError); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + }); + it("refuses covered-ensure omission at its own boundary", function* () { + const calls = trace(); + let failure: unknown; + try { + yield* mountedRun(pausedSource, new InMemoryStream(yield* fixture()), [], calls, { + workspace: "/owned", + }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(MountRecordError); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + }); + it("keeps physical-only v1 results physical and refuses a changed request without upgrade", function* () { + const events = yield* fixture(); + for (const event of events) { + if ( + event.type !== "yield" || + event.description.type !== "repl_mount_file/v1" || + event.result.status !== "ok" + ) { + continue; + } + const record = yield* parseMountRecord(event.result.value); + const physical = parseReplLocalRecord(record.physical); + if (physical === undefined) { + throw new Error("fixture physical record missing"); + } + event.description = { type: REPL_REPOSITORY_LOCAL, name: replLocalRecordName(physical) }; + event.result = { status: "ok", value: replLocalRecordJson(physical) }; + } + const same = new InMemoryStream(events); + const calls = trace(); + yield* mountedRun( + pausedSource, + same, + [{ source: "/owned/source", target: "/reference" }], + calls, + { workspace: "/owned" }, + ); + expect(calls.ensures).toEqual([]); + expect(calls.reads).toEqual([]); + expect( + (yield* same.readAll()).some( + (event) => event.type === "yield" && event.description.type === "repl_mount_file/v1", + ), + ).toBe(false); + const changed = trace(); + let failure: unknown; + try { + yield* mountedRun( + pausedSource, + new InMemoryStream(events), + [{ source: "/owned/new", target: "/reference" }], + changed, + { workspace: "/owned" }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(changed.ensures).toEqual([]); + expect(changed.reads).toEqual([]); + }); +}); diff --git a/packages/cli/tests/support/mount-run.ts b/packages/cli/tests/support/mount-run.ts new file mode 100644 index 000000000..1d43d659e --- /dev/null +++ b/packages/cli/tests/support/mount-run.ts @@ -0,0 +1,98 @@ +import { Err, Ok, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { API, fileWriteSuccess, filesFailure } from "@executablemd/runtime"; +import type { FilePathInput } from "@executablemd/runtime"; +import { collect, Elicitation, inlineSource } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { DurableStream } from "@executablemd/durable-streams"; +import { workspaceMountInstallation } from "../../src/repl/workspace-mount-installation.ts"; +import type { WorkspaceMountTable } from "../../src/workspace-mounts.ts"; + +export interface MountTrace { + reads: FilePathInput[]; + ensures: FilePathInput[]; + writes: unknown[]; + deletes: unknown[]; +} +export const trace = (): MountTrace => ({ reads: [], ensures: [], writes: [], deletes: [] }); +export function mountedRun( + source: string, + stream: DurableStream, + mounts: WorkspaceMountTable, + calls: MountTrace, + options: { + pause?: boolean; + workspace?: string; + refuseEnsure?: boolean; + onRead?: (request: FilePathInput) => string; + before?: () => Operation; + } = {}, +): Operation { + return scoped(function* () { + yield* API.Env.around( + { + *cwd() { + return "/host"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *checkFilePath() { + return Ok(undefined); + }, + *readTextFile([input]) { + calls.reads.push({ ...input }); + return Ok(options.onRead?.(input) ?? `${input.cwd}/${input.path}`); + }, + *ensureDirectory([input]) { + calls.ensures.push({ ...input }); + return options.refuseEnsure + ? Err( + filesFailure({ + operation: "ensure-directory", + phase: "access", + reason: "permission-denied", + }), + ) + : Ok(undefined); + }, + *writeTextFile([input]) { + calls.writes.push({ ...input }); + return Ok(fileWriteSuccess("host-committed")); + }, + *deleteFile([input]) { + calls.deletes.push({ ...input }); + return Ok(undefined); + }, + *globFiles() { + return Ok([]); + }, + }, + { at: "min" }, + ); + const paused = withResolvers(); + yield* Elicitation.around({ + *elicit() { + if (options.pause) { + paused.resolve(); + return yield* suspend(); + } + return {}; + }, + }); + if (options.before !== undefined) { + yield* options.before(); + } + const execution = yield* executeInstalled({ ...inlineSource(source), includes: [], stream }, [ + workspaceMountInstallation(mounts, options.workspace), + ]); + if (options.pause) { + yield* spawn(() => collect(execution)); + yield* paused.operation; + return "paused"; + } + return String(yield* collect(execution)); + }); +} diff --git a/packages/cli/tests/support/repl-files-journey.ts b/packages/cli/tests/support/repl-files-journey.ts index 3bbeeb52a..e83b6f9e6 100644 --- a/packages/cli/tests/support/repl-files-journey.ts +++ b/packages/cli/tests/support/repl-files-journey.ts @@ -81,7 +81,7 @@ function* seedCheckout(home: string): Operation { } /** Walk the focus ring until this control is the marked one. */ -function* reach(pty: Pty, label: string): Operation { +export 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) { @@ -104,7 +104,7 @@ function* reach(pty: Pty, label: string): Operation { * values: they are drawn and pointable, and a pointer is how a keyboard-only * harness reaches one. */ -function* click(pty: Pty, text: string): Operation { +export function* click(pty: Pty, text: string): Operation { const screen = yield* pty.shows(); const row = screen.findIndex((line) => line.includes(text)); if (row < 0) { @@ -120,7 +120,7 @@ function* click(pty: Pty, text: string): Operation { } /** Wait until this file holds this exact text. */ -function* holding(path: string, content: string): Operation { +export function* holding(path: string, content: string): Operation { const deadline = Date.now() + ACT_MS; while (true) { if ((yield* exists(path)) && (yield* readTextFile(path)) === content) { @@ -159,7 +159,7 @@ function* removed(path: string): Operation { } /** Wait for the command to end, and answer with its status. */ -function* ended(pty: Pty): Operation { +export function* ended(pty: Pty): Operation { const deadline = Date.now() + ACT_MS; while (true) { const status = yield* pty.status(); @@ -177,7 +177,7 @@ function* ended(pty: Pty): Operation { } /** The one location this command printed for a caller to come back with. */ -function printedLocation(screen: string): string { +export function printedLocation(screen: string): string { const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); if (found === null || found[1] === undefined) { throw new Error( diff --git a/packages/cli/tests/support/repl-mounts-journey.ts b/packages/cli/tests/support/repl-mounts-journey.ts new file mode 100644 index 000000000..d35aaba0b --- /dev/null +++ b/packages/cli/tests/support/repl-mounts-journey.ts @@ -0,0 +1,98 @@ +import { expect } from "@executablemd/test-support/expect"; +import { shellQuote } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensureDir, exists, readTextFile, writeTextFile } from "@effectionx/fs"; +import { scoped, sleep, until } from "effection"; +import type { Operation } from "effection"; +import { join, parse, relative, sep } from "node:path"; +import { realpath } from "node:fs/promises"; +import { allocate, showing } from "./pty.ts"; +import { reach, click, holding, ended, printedLocation } from "./repl-files-journey.ts"; +import type { ReplCommand } from "./repl-files-journey.ts"; + +export function replMountsJourney(repl: ReplCommand): Operation { + return scoped(function* () { + const root = yield* until(realpath(yield* useTempDirectory("xmd-mounts-pty-"))); + for (const mode of ["unchanged", "reassigned", "omitted"]) { + const home = join(root, mode); + const project = join(home, "project"); + const old = join(home, "old"); + const next = join(home, "new"); + const ordinary = join(home, "reference"); + const fixtures = join(home, "fixtures"); + for (const directory of [project, old, next, ordinary, fixtures]) { + yield* ensureDir(directory); + } + const target = "/" + relative(parse(ordinary).root, ordinary).split(sep).join("/"); + const fixtureTarget = "/" + relative(parse(fixtures).root, fixtures).split(sep).join("/"); + for (const directory of [old, next, ordinary]) { + yield* writeTextFile( + join(directory, "read.md"), + directory === old ? "historical" : "current", + ); + yield* writeTextFile(join(directory, "next.md"), "frontier"); + } + const args = (source?: string) => + source === undefined + ? [] + : ["--mount", `${source}:${target}`, `--mount=${fixtures}:${fixtureTarget}`]; + const line = (flags: readonly string[]) => + `cd ${shellQuote(project)} && ${repl(["repl", "--deny-all", ...flags])}`; + const first = yield* allocate(line(args(old)), home); + yield* showing(first, "Ready for Entry 1", 180_000); + const source = [ + ``, + '', + "```bash exec", + 'printf %s "$PWD" > process-cwd.txt', + "```", + 'Saved {saved}. Continue?', + '', + '{saved}|{current}', + "", + ].join("\n"); + yield* first.type(source); + yield* sleep(500); + yield* first.type("\r"); + yield* showing(first, "waiting for an answer", 30_000); + expect(yield* readTextFile(join(old, "process-cwd.txt"))).toBe(old); + yield* reach(first, "[exit]"); + yield* first.type("\r"); + expect(yield* ended(first)).toBe(0); + const location = printedLocation((yield* first.shows()).join("\n")); + yield* writeTextFile(join(old, "read.md"), "changed"); + const selected = mode === "unchanged" ? old : mode === "reassigned" ? next : ordinary; + const flags = mode === "omitted" ? [] : args(selected); + const second = yield* allocate(line([location, ...flags]), home); + yield* showing(second, "waiting for an answer", 180_000); + yield* reach(second, "[answer]"); + yield* second.type("\r"); + yield* showing(second, "Saved historical.", 30_000); + yield* click(second, "( ) continue"); + yield* holding(join(selected, "result.md"), "historical|frontier"); + expect(yield* readTextFile(join(old, "read.md"))).toBe("changed"); + if (selected !== old) { + expect(yield* exists(join(selected, "process-cwd.txt"))).toBe(false); + } + yield* showing(second, "Ready for Entry 2", 30_000); + yield* reach(second, "[exit]"); + yield* second.type("\r"); + expect(yield* ended(second)).toBe(0); + const nextLocation = printedLocation((yield* second.shows()).join("\n")); + const third = yield* allocate(line([nextLocation, ...flags]), home); + yield* showing(third, "Ready for Entry 2", 180_000); + yield* third.type( + `{fresh}fresh is {fresh}`, + ); + yield* sleep(500); + yield* third.type("\r"); + yield* showing(third, "Ready for Entry 3", 30_000); + yield* holding(join(selected, "fresh.md"), selected === old ? "changed" : "current"); + yield* click(third, "2. [ok]"); + yield* showing(third, selected === old ? "fresh is changed" : "fresh is current", 30_000); + yield* reach(third, "[exit]"); + yield* third.type("\r"); + expect(yield* ended(third)).toBe(0); + } + }); +} diff --git a/packages/cli/tests/workspace-mounts.test.ts b/packages/cli/tests/workspace-mounts.test.ts new file mode 100644 index 000000000..14a50eb6c --- /dev/null +++ b/packages/cli/tests/workspace-mounts.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { posix, win32 } from "node:path"; +import { parseWorkspaceMounts } from "../src/workspace-mounts.ts"; + +describe("workspace mount table", () => { + it("requires disjoint explicit document targets and resolves sources once", function* () { + const parsed = parseWorkspaceMounts( + ["../manuals:/reference/./", "fixtures:/fixtures"], + "/work/app", + posix, + ); + expect(parsed.ok && parsed.value).toEqual([ + { source: "/work/app/fixtures", target: "/fixtures" }, + { source: "/work/manuals", target: "/reference" }, + ]); + expect(parsed.ok && Object.isFrozen(parsed.value)).toBe(true); + for (const input of [ + "source", + "source:", + "source:/", + "source://root", + "source:/a/../b", + "source:/a\\b", + "source:/a:b", + ]) { + expect(parseWorkspaceMounts([input], "/work", posix).ok).toBe(false); + } + for (const target of ["/a", "/a/b"]) { + expect(parseWorkspaceMounts(["one:/a", `two:${target}`], "/work", posix).ok).toBe(false); + } + expect(parseWorkspaceMounts(["C:\\manuals:/reference"], "C:\\work", win32)).toEqual({ + ok: true, + value: [{ source: "C:\\manuals", target: "/reference" }], + }); + }); +}); + +import { replGrammarError } from "../src/cli.ts"; +describe("additional focused boundary 1", () => { + it("admits both repeatable flag spellings before host preparation", function* () { + expect( + replGrammarError( + ["repl", "--mount", "manuals:/reference", "--mount=fixtures:/fixtures"], + undefined, + ), + ).toBeUndefined(); + for (const args of [["--mount"], ["--mount="], ["--unknown", "x"]]) { + expect(replGrammarError(["repl", ...args], undefined)).toBeDefined(); + } + }); +}); + +import { runCli } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { readdir } from "@effectionx/fs"; +describe("additional focused boundary 2", () => { + it("refuses invalid mount declarations before a history or terminal exists", function* () { + const home = yield* useTempDirectory("mount-grammar-"); + const before = yield* readdir(home); + for (const value of ["no-target", "source:/", "source:/bad/../target"]) { + const result = yield* runCli(["repl", "--mount", value], { + cwd: home, + env: { HOME: home }, + inheritEnv: false, + }).join(); + expect(result.code).toBe(1); + expect(result.stderr).toContain("--mount"); + } + expect(yield* readdir(home)).toEqual(before); + }); +}); +describe("additional focused boundary 3", () => { + it("advertises repeated explicit mount targets in real REPL help", function* () { + const result = yield* runCli(["repl", "--help"]).join(); + expect(result.code).toBe(0); + expect(result.stdout).toContain("--mount"); + expect(result.stdout).toContain("SOURCE:TARGET"); + }); +}); diff --git a/packages/core/host.ts b/packages/core/host.ts index 28914b2b5..738c4fd6c 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -427,3 +427,13 @@ export type { AnswersState, DeclarationScanner } from "./src/declaration-scan.ts * confers no execution authority. */ export { useSyntax } from "./src/use-syntax.ts"; + +export type { + AuthoredFileOperation, + DirectoryFrame, + DirectoryPair, + FilesystemHost, + FilesystemInstaller, + PreparedDirectory, +} from "./src/filesystem-host.ts"; +export { admitDocumentHostPath, authoredOperation } from "./src/filesystem-placement.ts"; diff --git a/packages/core/src/components/Dir.ts b/packages/core/src/components/Dir.ts index 11b58faf4..46e73c8db 100644 --- a/packages/core/src/components/Dir.ts +++ b/packages/core/src/components/Dir.ts @@ -74,7 +74,19 @@ function reason(value: string): string { } } -function* Dir(props: Record): Operation { +export function* dirBody( + props: Record, + site?: { + prepare( + enclosing: string, + path: string, + ): import("effection").Result; + ensure( + input: import("@executablemd/runtime").FilePathInput, + ): Operation>; + project(frame: import("../filesystem-host.ts").DirectoryFrame): Operation; + }, +): Operation { const path = props.path; if (typeof path !== "string" || path === "") { throw new DirInvocationError(" needs a non-empty path."); @@ -86,9 +98,15 @@ function* Dir(props: Record): Operation { // therefore means that Repository's `nested`, and a `` that meant the // Workspace root would be the surprising one. const enclosing = yield* cwd(); - const target = path.startsWith("/") - ? path - : `${enclosing.endsWith("/") ? enclosing.slice(0, -1) : enclosing}/${path}`; + const prepared = site?.prepare(enclosing, path); + if (prepared !== undefined && !prepared.ok) { + throw new DirInvocationError(prepared.error.message); + } + const target = prepared?.ok + ? prepared.value.candidate.actualDirectory + : path.startsWith("/") + ? path + : `${enclosing.endsWith("/") ? enclosing.slice(0, -1) : enclosing}/${path}`; // Before the working directory is installed and before any content expands. // A document that names a directory has asked for it to exist, and content @@ -99,7 +117,8 @@ function* Dir(props: Record): Operation { // — the caller's own or a workflow run's logical one — is the installed // provider's business, and a component that asked would be a component that // behaves differently under the two profiles. - const refusal = yield* ensureDirectory({ cwd: enclosing, path }); + const request = prepared?.ok ? prepared.value.request : { cwd: enclosing, path }; + const refusal = yield* ensureDirectory(request, site?.ensure(request)); if (!refusal.ok) { const failure = parseFilesFailure(refusal.error); throw new DirInvocationError( @@ -118,7 +137,9 @@ function* Dir(props: Record): Operation { }, { at: "min" }, ); - return yield* content(); + return prepared?.ok && site !== undefined + ? yield* site.project(prepared.value.candidate) + : yield* content(); } /** @@ -136,7 +157,7 @@ function* Dir(props: Record): Operation { */ export const form: FormDeclaration = { forms: "paired", - fn: Dir, + fn: (props) => dirBody(props), refuse: (props: Record, written: InvocationForm | undefined) => new DirInvocationError( written === "self-closing" diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index 9c6b4837a..ed10ca511 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -246,7 +246,12 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { entries.admitted.map((entry) => entry.definition.fn), ); try { - return yield* evaluateProtectedGeneratedXmd(request, narrowedBodies, narrowedSyntax); + return yield* evaluateProtectedGeneratedXmd( + request, + narrowedBodies, + narrowedSyntax, + site.filesystem, + ); } finally { narrowedBodies?.close(); } diff --git a/packages/core/src/components/File.ts b/packages/core/src/components/File.ts index c650bbcce..f8f081ccc 100644 --- a/packages/core/src/components/File.ts +++ b/packages/core/src/components/File.ts @@ -88,15 +88,22 @@ export class FileAccessError extends Error { } /** The self-closing body: it reads, and there is no path from here to a write. */ -const read = printErrors(function* (props: Record): Operation { +export function* readBody( + props: Record, + owned?: ( + input: import("@executablemd/runtime").FilePathInput, + ) => Operation>, +): Operation { const requested = String(props.path); const directory = yield* cwd(); - const text = yield* readFileText({ cwd: directory, path: requested }); + const input = { cwd: directory, path: requested }; + const text = yield* readFileText(input, owned?.(input)); if (!text.ok) { throw new FileAccessError(refusal(requested, "read", parseFilesFailure(text.error))); } return text.value; -}); +} +const read = printErrors((props) => readBody(props)); /** The paired body: it writes, and there is no path from here to a read. */ const write = printErrors(function* (props: Record): Operation { diff --git a/packages/core/src/components/fixed-filesystem.ts b/packages/core/src/components/fixed-filesystem.ts index 7bdd4042a..716ce01a9 100644 --- a/packages/core/src/components/fixed-filesystem.ts +++ b/packages/core/src/components/fixed-filesystem.ts @@ -1,3 +1,8 @@ +import { cwd } from "@executablemd/runtime"; +import { runSelectedForm } from "../invocation-identity.ts"; +import { authoredOperation } from "../filesystem-placement.ts"; +import { readBody } from "./File.ts"; +import { dirBody } from "./Dir.ts"; /** * The three filesystem components canonical core reserves the names of (spec * §§6.13, 6.13.1, 6.13.2). @@ -19,10 +24,10 @@ * * The other two protected components are. `` and `` are * handed a site canonical execution built for them, so their bodies exist only - * once that execution does. These three need nothing of the kind: each one is - * an ordinary component whose body reaches the filesystem through contextual - * operations, and the only engine-owned part is the dispatcher that decides - * which authored form it was written as. So each definition is built once per + * once that execution does. These definitions remain shared. An executing REPL + * privately supplies its filesystem call site to the canonical body only after + * the same invocation and form checks; ordinary hosts use contextual operations. + * No execution state is stored on the definition. Each is built once per * loaded copy of core, exactly as the registry builds its own, and the * execution's protected table answers with this object rather than a rebuilt * one. @@ -198,3 +203,52 @@ export const FIXED_FILESYSTEM_ORIGIN = CORE_ORIGIN; export function fixedFilesystemDefinition(name: string): FunctionComponentDefinition | undefined { return BY_NAME.get(name); } + +export function invokeFixedFilesystem( + definition: FunctionComponentDefinition, + props: Record, + invocation: import("../invocation-identity.ts").ComponentInvocation, + site: { + filesystem: import("../filesystem-placement.ts").FilesystemExpansion; + occurrence: string; + project( + frame: import("../filesystem-host.ts").DirectoryFrame, + ): import("effection").Operation; + }, +): import("effection").Operation | undefined { + if (!DEFINITIONS.includes(definition) || typeof definition.fn !== "function") { + return undefined; + } + const declaration = + definition.name === "Dir" ? dirForm : definition.name === "File" ? fileForm : fileDeleteForm; + return runSelectedForm(definition.fn, declaration, props, invocation, function* (form, selected) { + const directory = yield* cwd(); + if (directory !== site.filesystem.frame.actualDirectory || typeof props.path !== "string") { + return yield* selected(props, invocation); + } + const operation = definition.name === "Dir" ? "dir-ensure" : "file-read"; + const authored = authoredOperation( + operation, + site.occurrence, + props.path, + site.filesystem.frame, + ); + if (definition.name === "File" && form === "self-closing") { + return yield* readBody(props, (request) => + site.filesystem.host.read(authored, Object.freeze({ ...request })), + ); + } + if (definition.name === "Dir") { + return yield* dirBody(props, { + prepare: () => site.filesystem.host.prepareDirectory(authored, site.filesystem.frame), + ensure: (request) => site.filesystem.host.ensure(authored, Object.freeze({ ...request })), + project: site.project, + }); + } + return yield* selected(props, invocation); + }); +} + +export function fixedFilesystemByFunction(fn: unknown): FunctionComponentDefinition | undefined { + return DEFINITIONS.find((definition) => definition.fn === fn); +} diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 73ac342d4..b1a301532 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -1,3 +1,5 @@ +import type { FilesystemHost, FilesystemInstaller } from "./filesystem-host.ts"; +import type { FilesystemExpansion } from "./filesystem-placement.ts"; /** * Entry point — execute (spec §7). * @@ -2111,6 +2113,7 @@ function* runValueRoot( importTerminal: ComponentImportTerminal, /** This execution's own binding owner, for the byproduct its body computes. */ computed: ComputedBindings, + filesystem?: FilesystemExpansion, ): Operation { // Created outside the scope the body runs in, so the value it selected is // still readable after that scope — and its teardown — has finished. @@ -2142,6 +2145,7 @@ function* runValueRoot( environment, importTerminal, ownBody, + filesystem, ); const exactRecord = environment.sourceSegments; for (const resolved of expanded) { @@ -2204,6 +2208,7 @@ function* documentWorkflow( * a document, a component or middleware can name reaches it. */ computed: ComputedBindings, + filesystem?: FilesystemExpansion, ): Workflow { // This run's memory of a checked command failure it never authorized. Passed // by value into core's own expansion and reachable from nowhere else, so no @@ -2356,6 +2361,7 @@ function* documentWorkflow( environment, importTerminal, computed, + filesystem, ); } @@ -2381,6 +2387,7 @@ function* documentWorkflow( environment, importTerminal, undefined, + filesystem, ); // An empty buffered root emits no output event. const runs = emissions(exactRecord, selected); @@ -2410,6 +2417,7 @@ function* documentWorkflow( environment, importTerminal, undefined, + filesystem, ); while (emittedThrough < produced.length) { @@ -2848,6 +2856,7 @@ function* executeDocument( * *against*, and these are values the document's own namespace starts with. */ initialBindings: Readonly = NO_INITIAL_BINDINGS, + filesystem?: FilesystemExpansion, ): Operation { const { stream, @@ -3161,6 +3170,7 @@ function* executeDocument( ); } : bindGeneratedRootEvaluator({ + filesystem, // The selection this execution captured, resolved per evaluation // through the one machinery the authored site also goes through, and // complete: canonical `` is in the table a root is admitted @@ -3240,6 +3250,7 @@ function* executeDocument( initialBindings, roots, computed, + filesystem, ), ); try { @@ -3425,6 +3436,7 @@ export type JournalAdmission = (retained: readonly DurableEvent[]) => Operation< * `install` runs contextual behavior the document inherits. */ export interface ExecutionInstallation { + readonly filesystem?: FilesystemInstaller; /** * Retained-history requirements, copied into an execution-owned collection * that installed code cannot observe, add to or remove from. @@ -3984,6 +3996,19 @@ function* invoke( */ initialBindings: Readonly = NO_INITIAL_BINDINGS, ): Operation { + const filesystemFactories = installations.flatMap((installation) => { + const factory = installation.filesystem; + return factory === undefined ? [] : [factory.bind(installation)]; + }); + if (filesystemFactories.length > 1) { + throw new Error("Multiple filesystem owners"); + } + let filesystem: FilesystemExpansion | undefined; + let filesystemLive = true; + yield* ensure(() => { + filesystemLive = false; + filesystem = undefined; + }); const admissions = Object.freeze( installations.flatMap((installation) => [...(installation.admissions ?? [])]), ); @@ -4118,6 +4143,49 @@ function* invoke( // layer acquires is torn down with this execution and joined before it // settles, and a failure here reaches no policy, no root import and no body. yield* Execution.operations.init(); + if (filesystemFactories[0] !== undefined) { + let accepting = true; + let delivered: FilesystemHost | undefined; + try { + yield* filesystemFactories[0]((host) => { + if (!accepting || delivered !== undefined || !filesystemLive) { + throw new Error("Invalid filesystem delivery"); + } + delivered = host; + }); + } finally { + accepting = false; + } + if (delivered === undefined) { + throw new Error("Filesystem owner supplied no host"); + } + const selected = delivered; + const prepare = selected.prepareDirectory.bind(selected); + const read = selected.read.bind(selected); + const ensure = selected.ensure.bind(selected); + const host = Object.freeze({ + root: Object.freeze({ ...selected.root }), + prepareDirectory(authored, enclosing) { + if (!filesystemLive) { + throw new Error("Filesystem owner has closed"); + } + return prepare(authored, enclosing); + }, + *read(authored, request) { + if (!filesystemLive) { + throw new Error("Filesystem owner has closed"); + } + return yield* read(authored, request); + }, + *ensure(authored, request) { + if (!filesystemLive) { + throw new Error("Filesystem owner has closed"); + } + return yield* ensure(authored, request); + }, + }); + filesystem = Object.freeze({ host, frame: host.root }); + } // Each provider installer first, then that installation's ordinary `install`, // in the order the installations were captured. A provider registers @@ -4190,6 +4258,7 @@ function* invoke( issued.completions(), preparations, initialBindings, + filesystem, ); } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index f7e641726..f8e208305 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -1,3 +1,5 @@ +import type { FilesystemExpansion } from "./filesystem-placement.ts"; +import { invokeFixedFilesystem, fixedFilesystemByFunction } from "./components/fixed-filesystem.ts"; /** * Expansion engine (spec §5). * @@ -292,6 +294,7 @@ function expandChildrenScoped( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return scoped(function* () { const overrideEnv = override === undefined ? undefined : { values: override }; @@ -316,6 +319,7 @@ function expandChildrenScoped( environment, imports, returnBody, + filesystem, ); }); } @@ -372,6 +376,7 @@ interface ProjectionState { */ checkedFailures: CheckedFailures | undefined; environment: ExecutionEnvironment | undefined; + filesystem?: FilesystemExpansion; /** * The admission a protected producer's content is narrowed to, when one * narrowed it. @@ -536,6 +541,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.environment, state.imports, options.returnFrame, + state.filesystem, ); outcome.resolve({ segments: rendered }); } catch (error) { @@ -650,6 +656,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { state.environment, state.imports, request.kind === "markdown" ? undefined : state.callerReturn, + state.filesystem, ); outcome.resolve({ segments: [...errors, ...rendered] }); } catch (error) { @@ -843,6 +850,7 @@ export function expandSegments( indexBase: number = 0, checkedFailures?: CheckedFailures, environment?: ExecutionEnvironment, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return expandSegmentsWithin( segments, @@ -859,6 +867,7 @@ export function expandSegments( // through the ordinary public Component Api, exactly as it always has. undefined, undefined, + filesystem, ); } @@ -939,6 +948,7 @@ function* expandListSegments( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { // An execution opens the table its printed errors record their causes in. // Expansion driven directly — a test, a tool describing a document — has no @@ -963,6 +973,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); }); } @@ -1085,6 +1096,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, )), ); break; @@ -1106,6 +1118,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, )), ); break; @@ -1128,6 +1141,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); break; } @@ -1162,6 +1176,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); break; } @@ -1181,6 +1196,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); break; } @@ -1203,6 +1219,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); // Which state this is, answered by identity rather than by name: // a trusted harness knows which expansions its own declaration scan @@ -1250,6 +1267,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); break; } @@ -1287,6 +1305,7 @@ function* expandListSegments( checkedFailures, environment, imports, + filesystem, ); break; } @@ -1341,6 +1360,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); break; } @@ -1364,6 +1384,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ); // A printed error the callee produced is data, and stays data here: it // was decided once, where it was raised, under the error mode governing @@ -1509,6 +1530,7 @@ function* expandListSegments( environment, imports, returnBody, + filesystem, ), ); } @@ -1572,6 +1594,7 @@ function* checkedCommandFailure( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { // Written down before it is raised or projected, and before the error mode is // consulted at all: the mode decides how this is reported, never whether the @@ -1678,6 +1701,7 @@ function* expandLet( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { // Every one of these is decided from what the author wrote, so the whole // catalog lives in `structural-rules.ts` where validation reads it too. The @@ -1711,6 +1735,7 @@ function* expandLet( environment, imports, returnBody, + filesystem, ), ); @@ -1823,6 +1848,7 @@ function* expandEach( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -1886,6 +1912,7 @@ function* expandEach( environment, imports, returnBody, + filesystem, ); // A `` in the body exits the enclosing ``, so the remaining // items are part of the work that iteration no longer does. @@ -2014,6 +2041,7 @@ function* expandIf( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2090,6 +2118,7 @@ function* expandIf( environment, imports, returnBody, + filesystem, ); }; // The `` is an element of its own, and only when it is the arm that @@ -2170,6 +2199,7 @@ function* expandSwitch( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2244,6 +2274,7 @@ function* expandSwitch( environment, imports, returnBody, + filesystem, ), ); } @@ -2322,6 +2353,7 @@ function* expandLoop( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2383,6 +2415,7 @@ function* expandLoop( environment, imports, returnBody, + filesystem, ); if (frame.broken) { break; @@ -2506,6 +2539,7 @@ function spawnChild( inherited: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return scoped(function* () { yield* provideEnv(spawnEnvironment(incoming)); @@ -2539,6 +2573,7 @@ function spawnChild( environment, imports, undefined, + filesystem, ), ); yield* refuseCheckedFailure(ledger); @@ -2584,6 +2619,7 @@ function* expandAll( checkedFailures: CheckedFailures | undefined, environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return yield* structural(segment, path, () => work()); @@ -2614,6 +2650,7 @@ function* expandAll( checkedFailures, environment, imports, + filesystem, ), ); @@ -2718,6 +2755,7 @@ function* expandPrintErrors( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { const refusal = printErrorsViolations(segment)[0]; if (refusal !== undefined) { @@ -2745,6 +2783,7 @@ function* expandPrintErrors( environment, imports, returnBody, + filesystem, ); }); } @@ -2784,6 +2823,7 @@ function* expandComponent( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { // Surrounded before resolution, props validation or any body: what an // observer is offered is the whole of this element's expansion, including @@ -2969,6 +3009,7 @@ function* expandComponent( returnBody, selected, dispatcher, + filesystem, ); } @@ -3128,6 +3169,7 @@ function* expandComponent( printedErrors: bodyContentErrors, checkedFailures, environment, + filesystem, imports, }); // Published on the eval scope, which every task the invocation owns @@ -3206,6 +3248,7 @@ function* expandComponent( bodyEnvironment, imports, returnBody, + filesystem, ); }, observed.settled), ); @@ -3257,6 +3300,7 @@ function* expandComponent( bodyEnvironment, imports, returnBody, + filesystem, ); }, observed.settled), ); @@ -3450,6 +3494,7 @@ function* expandFunctionComponent( * form-specific bodies (`invocation-identity.ts`). */ dispatcher: FunctionComponent | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { // An invocation of core's own `` keeps its checked failures to itself: // they become that invocation's failure, which is how a failing test is the @@ -3648,6 +3693,7 @@ function* expandFunctionComponent( ownPath: path, checkedFailures, environment, + filesystem, imports, }; // Only the ordinary handle is published. The richer projection a @@ -3808,6 +3854,7 @@ function* expandFunctionComponent( try { return yield* dispatchBody( guarded(validatedProps, issued.invocation, { + filesystem, syntax: environment?.syntax, evaluationProfile: environment?.evaluationProfile, projectContent: lease?.project, @@ -3826,7 +3873,35 @@ function* expandFunctionComponent( // Ended in the same breath the body is: an issuance a wrapper kept // from a finished element authorizes nothing when it is routed here. try { - return yield* dispatchBody(definition.fn(validatedProps, issued.invocation)); + const fixed = + filesystem === undefined ? undefined : fixedFilesystemByFunction(dispatcher); + const direct = + fixed === undefined || filesystem === undefined + ? undefined + : invokeFixedFilesystem(fixed, validatedProps, issued.invocation, { + filesystem, + occurrence: expansion.id, + project: function* (frame) { + const projected = createProjectionHandle({ + ...projectionState, + filesystem: { ...filesystem, frame }, + }); + const outcome = yield* projected.tryProject({ + kind: "slot", + name: undefined, + mode: siteErrorMode, + }); + if (outcome.failure !== undefined) { + throw outcome.failure; + } + const errors = errorSegments(outcome.segments); + if (errors.length > 0) { + throw new ContentExpansionFailure(errors, undefined, outcome.segments); + } + return renderSegments(outcome.segments); + }, + }); + return yield* dispatchBody(direct ?? definition.fn(validatedProps, issued.invocation)); } finally { issued.close(); } @@ -4507,6 +4582,7 @@ export function* expandBody( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { if (!bodyHasOutput(bodySegments)) { const substituted = substituteContent(bodySegments, children, callerEnv, claim); @@ -4523,6 +4599,7 @@ export function* expandBody( environment, imports, returnBody, + filesystem, ); } @@ -4546,6 +4623,7 @@ export function* expandBody( environment, imports, returnBody, + filesystem, ); } else if (chunk.output) { yield* scoped(function* () { @@ -4563,6 +4641,7 @@ export function* expandBody( environment, imports, returnBody, + filesystem, ); }); } else { @@ -4582,6 +4661,7 @@ export function* expandBody( environment, imports, returnBody, + filesystem, ); }); } @@ -4611,6 +4691,7 @@ function runDocumentation( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { return scoped(function* () { yield* ErrorMode.set("throw"); @@ -4627,6 +4708,7 @@ function runDocumentation( environment, imports, returnBody, + filesystem, ); }); } @@ -4680,6 +4762,7 @@ function* expandValueBody( environment: ExecutionEnvironment | undefined, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { const slots = partitionBySlot(children); const state: SubstitutionState = { errorsEmitted: false }; @@ -4707,6 +4790,7 @@ function* expandValueBody( environment, imports, ownBody, + filesystem, ); } @@ -4749,6 +4833,7 @@ function* expandInstalledStructural( environment: ExecutionEnvironment, imports: ComponentImportTerminal | undefined, returnBody: ReturnBody | undefined, + filesystem: FilesystemExpansion | undefined = undefined, ): Operation { const name = segment.name; @@ -4849,6 +4934,7 @@ function* expandInstalledStructural( path, checkedFailures, environment, + filesystem, imports, returnBody, children: segment.children, @@ -4902,6 +4988,7 @@ interface RegionContext { readonly path: string; readonly checkedFailures: CheckedFailures | undefined; readonly environment: ExecutionEnvironment; + readonly filesystem?: FilesystemExpansion; readonly imports: ComponentImportTerminal | undefined; readonly returnBody: ReturnBody | undefined; readonly children: Segment[]; @@ -5069,6 +5156,7 @@ function* produceRegion( context.environment, context.imports, context.returnBody, + context.filesystem, ); } catch (error) { failure = { error }; diff --git a/packages/core/src/files.ts b/packages/core/src/files.ts index 331fb764c..830106edc 100644 --- a/packages/core/src/files.ts +++ b/packages/core/src/files.ts @@ -335,15 +335,21 @@ export function deleteFile(input: FilePathInput): Operation> { * this operation exists for — a refusal the vocabulary does not recognize * happened while trying to make it, not while reading the path. */ -export function ensureDirectory(input: FilePathInput): Operation> { - return unit(Files.operations.ensureDirectory(input), { +export function ensureDirectory( + input: FilePathInput, + owned?: Operation>, +): Operation> { + return unit(owned ?? Files.operations.ensureDirectory(input), { operation: "ensure-directory", phase: "access", }); } -export function readFileText(input: FilePathInput): Operation> { - return outcome(Files.operations.readTextFile(input), { +export function readFileText( + input: FilePathInput, + owned?: Operation>, +): Operation> { + return outcome(owned ?? Files.operations.readTextFile(input), { operation: "read", phase: "access", payload: text, diff --git a/packages/core/src/filesystem-host.ts b/packages/core/src/filesystem-host.ts new file mode 100644 index 000000000..b60773524 --- /dev/null +++ b/packages/core/src/filesystem-host.ts @@ -0,0 +1,38 @@ +import type { Operation, Result } from "effection"; +import type { FilePathInput } from "@executablemd/runtime"; + +export interface DirectoryPair { + readonly source: string; + readonly target: string; +} +export interface DirectoryFrame { + readonly authoredDirectory: string; + readonly actualDirectory: string; + readonly namespace: "document" | "host"; + readonly interpreting: boolean; + readonly selectedPair?: DirectoryPair; +} +export interface AuthoredFileOperation { + readonly version: 1; + readonly operation: "file-read" | "dir-ensure"; + readonly occurrence: string; + readonly form: "self-closing" | "paired"; + readonly path: string; + readonly directory: string; + readonly namespace: "document" | "host"; + readonly destination: string; +} +export interface PreparedDirectory { + readonly request: FilePathInput; + readonly candidate: DirectoryFrame; +} +export interface FilesystemHost { + readonly root: DirectoryFrame; + prepareDirectory( + authored: AuthoredFileOperation, + enclosing: DirectoryFrame, + ): Result; + read(authored: AuthoredFileOperation, request: FilePathInput): Operation>; + ensure(authored: AuthoredFileOperation, request: FilePathInput): Operation>; +} +export type FilesystemInstaller = (deliver: (host: FilesystemHost) => void) => Operation; diff --git a/packages/core/src/filesystem-placement.ts b/packages/core/src/filesystem-placement.ts new file mode 100644 index 000000000..6a08c908a --- /dev/null +++ b/packages/core/src/filesystem-placement.ts @@ -0,0 +1,86 @@ +import { Err, Ok } from "effection"; +import type { Result } from "effection"; +import { posix, win32 } from "node:path"; +import type { AuthoredFileOperation, DirectoryFrame, FilesystemHost } from "./filesystem-host.ts"; + +export interface FilesystemExpansion { + readonly frame: DirectoryFrame; + readonly host: FilesystemHost; +} +export interface HostPathArithmetic { + readonly sep: string; + join(...paths: string[]): string; + relative(from: string, to: string): string; + isAbsolute(path: string): boolean; +} +export class DocumentPathRepresentationError extends Error { + override name = "DocumentPathRepresentationError"; + constructor() { + super("This document path cannot be represented by this host."); + } +} + +export function authoredOperation( + operation: AuthoredFileOperation["operation"], + occurrence: string, + path: string, + frame: DirectoryFrame, +): AuthoredFileOperation { + const namespace = path.startsWith("/") + ? "document" + : /^[a-zA-Z]:[\\\\/]/.test(path) + ? "host" + : frame.namespace; + const arithmetic = + namespace === "document" + ? posix + : /^[a-zA-Z]:[\\\\/]|^\\\\\\\\/.test(path) || + /^[a-zA-Z]:[\\\\/]|^\\\\\\\\/.test(frame.authoredDirectory) + ? win32 + : posix; + return Object.freeze({ + version: 1, + operation, + occurrence, + form: operation === "file-read" ? "self-closing" : "paired", + path, + directory: frame.authoredDirectory, + namespace: frame.namespace, + destination: arithmetic.resolve(frame.authoredDirectory, path), + }); +} + +export function admitDocumentHostPath( + pathStyle: "posix" | "win32", + operand: string, + base: string, + destination: string, + hostAnchor: string, + documentAnchor = "/", + host: HostPathArithmetic = pathStyle === "win32" ? win32 : posix, +): Result { + const invalid = (value: string) => + value.includes("\0") || + (pathStyle === "win32" && + (/[\\\\:]/.test(value) || + value.split("/").some((part) => part !== "." && part !== ".." && /[. ]$/.test(part)))); + if (hostAnchor.includes("\0") || [operand, base, destination, documentAnchor].some(invalid)) { + return Err(new DocumentPathRepresentationError()); + } + const relative = posix.relative(documentAnchor, destination); + const components = relative === "" ? [] : relative.split("/"); + if (components.some((part) => part === "" || part === "." || part === ".." || invalid(part))) { + return Err(new DocumentPathRepresentationError()); + } + const actual = host.join(hostAnchor, ...components); + const observed = host.relative(hostAnchor, actual); + const observedComponents = observed === "" ? [] : observed.split(host.sep); + if ( + host.isAbsolute(observed) || + observedComponents.length !== components.length || + observedComponents.some((part, index) => part !== components[index]) + ) { + return Err(new DocumentPathRepresentationError()); + } + return Ok(actual); +} diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index f3bf128cd..97c6c5cb9 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -1,3 +1,4 @@ +import type { FilesystemExpansion } from "./filesystem-placement.ts"; /** * Evaluating XMD an Agent generated, under a constrained allowlist * (specs/workflow-workspace-spec.md §8.4). @@ -708,6 +709,7 @@ interface RootRendering { * hands it over across one Core-private call and keeps it. */ export interface GeneratedRootBinding { + readonly filesystem?: FilesystemExpansion; /** * The execution's captured selection, resolved for the classes one root asks * for. @@ -736,6 +738,7 @@ export interface GeneratedRootBinding { * execution. */ interface ResolvedRoot { + readonly filesystem?: FilesystemExpansion; readonly describing: readonly CapturedEntry[]; readonly componentRouting: ComponentRouting; readonly reference: SyntaxReference | undefined; @@ -2324,6 +2327,7 @@ function expand( * its own owner outside the expansion. */ owner: Segment[] | undefined, + filesystem?: FilesystemExpansion, ): Operation { return scoped(function* () { yield* SourceOwner.set(undefined); @@ -2415,6 +2419,7 @@ function expand( environment, undefined, undefined, + filesystem, ); return renderSegments(owner ?? []); } @@ -2436,6 +2441,7 @@ function expand( // A generated fragment is the engine's own text, so it owns no value body // and a written into it satisfies no declaration. undefined, + filesystem, ); return renderSegments(expanded); }); @@ -2463,6 +2469,7 @@ export function* evaluateProtectedGeneratedXmd( request: GeneratedFragmentRequest, componentRouting: ComponentRouting | undefined, syntax: SyntaxReference | undefined, + filesystem?: FilesystemExpansion, ): Operation { return yield* evaluateGenerated( request, @@ -2471,6 +2478,8 @@ export function* evaluateProtectedGeneratedXmd( componentRouting, syntax, undefined, + undefined, + filesystem, ); } @@ -2492,6 +2501,7 @@ function* evaluateGenerated( rendering: RootRendering | undefined, /** What this root was resolved to, for a root evaluation. */ resolved?: ResolvedRoot, + filesystem?: FilesystemExpansion, ): Operation { const allow = selection(request.allow); const entries = selectedEntries(request, allow); @@ -2584,6 +2594,7 @@ function* evaluateGenerated( narrowed, reference, rendering?.owner, + filesystem, ); } @@ -2707,6 +2718,7 @@ function* evaluateRoot( reference: binding.reference, live: binding.live, }, + binding.filesystem, ); }); return Ok({ output, hasOutput }); diff --git a/packages/core/src/invocation-identity.ts b/packages/core/src/invocation-identity.ts index f41f055ea..74211d04b 100644 --- a/packages/core/src/invocation-identity.ts +++ b/packages/core/src/invocation-identity.ts @@ -210,6 +210,7 @@ export type ProjectProtectedContent = (syntax: SyntaxReference) => Operation, invocation: ComponentInvocation, ): Operation { - const issuance = stateOf(invocation); - if ( - issuance === undefined || - !issuance.live || - issuance.selection !== dispatch || - issuance.frame !== (yield* useScope()) - ) { - throw refuse(props, undefined); - } - const form: InvocationForm = issuance.content ? "paired" : "self-closing"; - const handler = handlers[form]; - if (handler === undefined) { - throw refuse(props, form); - } - return yield* handler(props, invocation); + return yield* runSelectedForm(dispatch, declaration, props, invocation); } // The declaration a component wears travels to the dispatcher, because the @@ -691,6 +673,39 @@ export function formDispatcher(declaration: FormDeclaration): FunctionComponent return dispatch; } +export function* runSelectedForm( + dispatcher: FunctionComponent, + declaration: FormDeclaration, + props: Record, + invocation: ComponentInvocation, + body?: (form: InvocationForm, selected: FunctionComponent) => Operation, +): Operation { + const issuance = stateOf(invocation); + const refuse: FormRefusal = + declaration.forms === "self-closing" || declaration.forms === "paired" + ? declaration.refuse + : unusable; + if ( + issuance === undefined || + !issuance.live || + issuance.selection !== dispatcher || + issuance.frame !== (yield* useScope()) + ) { + throw refuse(props, undefined); + } + const form: InvocationForm = issuance.content ? "paired" : "self-closing"; + const handler = + declaration.forms === "both" + ? declaration[form] + : declaration.forms === "either" || declaration.forms === form + ? declaration.fn + : undefined; + if (handler === undefined) { + throw refuse(props, form); + } + return yield* body === undefined ? handler(props, invocation) : body(form, handler); +} + /** The fallback refusal, for a declaration that names no sentence of its own. */ function unusable(_props: Record, form: InvocationForm | undefined): Error { return new ComponentInvocationError( diff --git a/packages/core/tests/filesystem-placement.test.ts b/packages/core/tests/filesystem-placement.test.ts new file mode 100644 index 000000000..e659d396a --- /dev/null +++ b/packages/core/tests/filesystem-placement.test.ts @@ -0,0 +1,321 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { admitDocumentHostPath, authoredOperation } from "../src/filesystem-placement.ts"; +import { R1_WIN32 } from "../../test-support/mount-fixture.ts"; + +describe("document component preservation", () => { + it("R1-WIN32 refuses the unnormalized authored backslash operand", function* () { + const f = R1_WIN32; + expect( + admitDocumentHostPath("win32", f.operand, f.target, f.destination, f.source, f.target).ok, + ).toBe(false); + expect(admitDocumentHostPath("win32", "..", f.target, "/", "C:\\", "/")).toEqual({ + ok: true, + value: "C:\\", + }); + const operation = authoredOperation("dir-ensure", "occurrence", "..", { + authoredDirectory: "/reference", + actualDirectory: "/work/manuals/docs", + namespace: "document", + interpreting: true, + }); + expect(operation.path).toBe(".."); + expect(operation.destination).toBe("/"); + }); +}); + +import { API, cwd, fileWriteSuccess } from "@executablemd/runtime"; +import { Ok, ensure, spawn, suspend, withResolvers } from "effection"; +import { collect, inlineSource } from "@executablemd/core"; +import { Component } from "../src/component-api.ts"; +import { executeInstalled } from "../host.ts"; +import { workspaceMountInstallation } from "../../cli/src/repl/workspace-mount-installation.ts"; +import { mountPolicy } from "../../cli/src/repl/mount-files.ts"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { ordinaryGeneratedSelection } from "../../cli/src/evaluation-profile.ts"; + +describe("mapped canonical component expansion", () => { + it("delivers actual cwd to all existing consumers and follows the authored parent", function* () { + const seen: unknown[] = []; + yield* API.Env.around( + { + *cwd() { + return "/host"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *checkFilePath([input]) { + seen.push(["check", input]); + return Ok(undefined); + }, + *ensureDirectory([input]) { + seen.push(["ensure", input]); + return Ok(undefined); + }, + *readTextFile([input]) { + seen.push(["read", input]); + return Ok("read"); + }, + *writeTextFile([input]) { + seen.push(["write", input]); + return Ok(fileWriteSuccess("host-committed")); + }, + *deleteFile([input]) { + seen.push(["delete", input]); + return Ok(undefined); + }, + *globFiles([input]) { + seen.push(["glob", input]); + return Ok([]); + }, + }, + { at: "min" }, + ); + yield* API.Process.around({ + *exec([options]) { + seen.push(["process", options]); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + const source = [ + '', + '', + 'text', + '', + '', + '', + '', + "```bash exec", + "printf unchanged", + "```", + "", + '', + "", + '', + ].join("\n"); + yield* collect( + yield* executeInstalled( + { ...inlineSource(source), includes: [], stream: new InMemoryStream() }, + [workspaceMountInstallation([{ source: "/work/manuals/docs", target: "/reference" }])], + ), + ); + expect(seen).toContainEqual(["ensure", { cwd: "/work/manuals/docs", path: "/" }]); + expect(seen).toContainEqual(["read", { cwd: "/", path: "next.md" }]); + expect(seen).toContainEqual(["glob", { cwd: "/", include: ["*.md"], exclude: ["skip.md"] }]); + expect(seen).toContainEqual([ + "write", + { cwd: "/work/manuals/docs", path: "two.md", content: "text" }, + ]); + expect(seen).toContainEqual(["delete", { cwd: "/work/manuals/docs", path: "three.md" }]); + expect(seen).toContainEqual(["read", { cwd: "/work/manuals/docs", path: "restored.md" }]); + expect(seen).toContainEqual(["read", { cwd: "/host", path: "outer.md" }]); + const process = seen.find((one) => Array.isArray(one) && one[0] === "process"); + expect(process).toEqual([ + "process", + expect.objectContaining({ cwd: "/", command: ["bash", "-c", "printf unchanged\n"] }), + ]); + expect(yield* cwd()).toBe("/host"); + }); + it("rejects R1-WIN32 before any ensure or child and preserves the ordinary '..' case", function* () { + const f = R1_WIN32; + const policy = mountPolicy([{ source: f.source, target: f.target }], "C:\\host", "win32"); + const entered = policy.prepareDirectory( + authoredOperation("dir-ensure", "initial", f.target, policy.root), + policy.root, + ); + expect(entered.ok && entered.value.request.path).toBe(f.source); + let calls = 0; + const installation = { + *filesystem( + deliver: Parameters< + NonNullable + >[0], + ) { + deliver({ + root: { + authoredDirectory: f.target, + actualDirectory: f.source, + namespace: "document", + interpreting: true, + }, + prepareDirectory: policy.prepareDirectory, + *ensure() { + calls++; + return Ok(undefined); + }, + *read() { + calls++; + return Ok("unexpected"); + }, + }); + }, + }; + let failure: unknown; + yield* API.Env.around( + { + *cwd() { + return f.source; + }, + }, + { at: "min" }, + ); + try { + yield* collect( + yield* executeInstalled( + { + ...inlineSource(``), + includes: [], + stream: new InMemoryStream(), + }, + [installation], + ), + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(calls).toBe(0); + }); + it("carries the private frame through generated File forms", function* () { + const seen: string[] = []; + yield* API.Env.around( + { + *cwd() { + return "/host"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *ensureDirectory() { + return Ok(undefined); + }, + *readTextFile([input]) { + seen.push(input.cwd); + return Ok("generated"); + }, + }, + { at: "min" }, + ); + const install = { + *install() { + yield* Component.around({ generated: () => ordinaryGeneratedSelection() }); + }, + }; + const source = '\'} />'; + const output = yield* collect( + yield* executeInstalled( + { ...inlineSource(source), includes: [], stream: new InMemoryStream() }, + [install, workspaceMountInstallation([{ source: "/external", target: "/reference" }])], + ), + ); + expect(output).toContain("generated"); + expect(seen).toEqual(["/external"]); + }); +}); + +describe("mapped scope exits", () => { + it("restores both enclosing levels after a nested child failure", function* () { + const seen: string[] = []; + let fail = true; + yield* API.Env.around( + { + *cwd() { + return "/host"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *ensureDirectory() { + return Ok(undefined); + }, + *readTextFile([input]) { + seen.push(input.cwd); + return Ok("read"); + }, + }, + { at: "min" }, + ); + yield* Component.around({ + *expand([request], next) { + if (request.expansion.name === "File" && fail) { + fail = false; + expect(yield* cwd()).toBe("/"); + throw new Error("owned child failure"); + } + yield* next(request); + }, + }); + const output = yield* collect( + yield* executeInstalled( + { + ...inlineSource( + '', + ), + includes: [], + stream: new InMemoryStream(), + }, + [workspaceMountInstallation([{ source: "/external", target: "/reference" }])], + ), + ); + expect(output).toContain("owned child failure"); + expect(seen).toEqual(["/external", "/host"]); + expect(yield* cwd()).toBe("/host"); + }); + it("joins cancelled child work and restores the caller's cwd", function* () { + const entered = withResolvers(); + let released = false; + yield* API.Env.around( + { + *cwd() { + return "/host"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *ensureDirectory() { + return Ok(undefined); + }, + }, + { at: "min" }, + ); + yield* Component.around({ + *expand([request], next) { + if (request.expansion.name === "File") { + expect(yield* cwd()).toBe("/external"); + yield* ensure(() => { + released = true; + }); + entered.resolve(); + yield* suspend(); + } + yield* next(request); + }, + }); + const task = yield* spawn(function* () { + return yield* collect(yield* execute()); + }); + function* execute() { + return yield* executeInstalled( + { + ...inlineSource(''), + includes: [], + stream: new InMemoryStream(), + }, + [workspaceMountInstallation([{ source: "/external", target: "/reference" }])], + ); + } + yield* entered.operation; + yield* task.halt(); + expect(released).toBe(true); + expect(yield* cwd()).toBe("/host"); + }); +}); diff --git a/packages/core/tests/loaded-copy-files.test.ts b/packages/core/tests/loaded-copy-files.test.ts index e16f18d88..bb63fee49 100644 --- a/packages/core/tests/loaded-copy-files.test.ts +++ b/packages/core/tests/loaded-copy-files.test.ts @@ -22,7 +22,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, Err, resource, scoped, until } from "effection"; +import { ensure, Err, Ok, resource, scoped, until } from "effection"; import type { Operation, Result } from "effection"; import { exec } from "@effectionx/process"; import { rm } from "@effectionx/fs"; @@ -35,6 +35,7 @@ import { parseFilesFailure, parseFilesFatal, } from "@executablemd/runtime"; +import { registerFileRecorder, assembleFileCall } from "@executablemd/runtime/files-host"; import { InvocationTeardownError } from "../src/invocation.ts"; import { deleteFile } from "../src/files.ts"; import { DocumentationError, fatalCause, filesFatalFailure } from "../src/errors.ts"; @@ -49,6 +50,7 @@ const REPOSITORY = fileURLToPath(new URL("../../../", import.meta.url)); /** What a separately loaded copy of the module exposes to this test. */ interface LoadedCopy { + Files: typeof Files; FilesProviderUnavailableError: new () => Error; FilesInvariantError: new (category: string) => Error; parseFilesFatal: (error: unknown) => unknown; @@ -61,6 +63,9 @@ function isLoadedCopy(value: unknown): value is LoadedCopy { } const module: Record = Object.fromEntries(Object.entries(value)); return ( + typeof module.Files === "object" && + module.Files !== null && + typeof Reflect.get(module.Files, "around") === "function" && typeof module.FilesProviderUnavailableError === "function" && typeof module.FilesInvariantError === "function" && typeof module.parseFilesFatal === "function" && @@ -109,6 +114,41 @@ function useSeparateCopy(): Operation { } describe("Tier LC — a separately loaded runtime copy", () => { + it("LC4: a foreign Files layer composes inline with the exact local recorder", function* () { + const copy = yield* useSeparateCopy(); + const seen: string[] = []; + const boundary = { + *readTextFile( + [input]: Parameters, + next: typeof Files.operations.readTextFile, + ) { + seen.push("boundary"); + return yield* next(input); + }, + }; + const handle = yield* registerFileRecorder(boundary); + yield* copy.Files.around({ + *readTextFile([input], next) { + seen.push("foreign"); + const result = yield* next({ ...input, path: "changed" }); + return result.ok ? Ok(`outer:${result.value}`) : result; + }, + }); + yield* Files.around( + { + *readTextFile([input]) { + seen.push(input.path); + return Ok("terminal"); + }, + }, + { at: "min" }, + ); + expect(yield* assembleFileCall(handle, "readTextFile")({ cwd: "/", path: "original" })).toEqual( + Ok("outer:terminal"), + ); + expect(seen).toEqual(["foreign", "changed"]); + }); + it("LC1: a failure built by another copy is discovered as fatal, by identity", function* () { const copy = yield* useSeparateCopy(); const foreign = new copy.FilesProviderUnavailableError(); diff --git a/packages/runtime/deno.json b/packages/runtime/deno.json index 75cf874b2..76a3ac8b3 100644 --- a/packages/runtime/deno.json +++ b/packages/runtime/deno.json @@ -5,6 +5,7 @@ ".": "./mod.ts", "./api": "./api.ts", "./files": "./files.ts", + "./files-host": "./files-host.ts", "./test": "./test/mod.ts" } } diff --git a/packages/runtime/files-api.LICENSE b/packages/runtime/files-api.LICENSE new file mode 100644 index 000000000..38f32eab0 --- /dev/null +++ b/packages/runtime/files-api.LICENSE @@ -0,0 +1,23 @@ +context-api 0.6.0 + +MIT License + +Copyright 2024 The Frontside Software, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +middleware 0.1.1 + +MIT License + +Copyright 2024 The Frontside Software, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/runtime/files-api.ts b/packages/runtime/files-api.ts new file mode 100644 index 000000000..90c0e8d23 --- /dev/null +++ b/packages/runtime/files-api.ts @@ -0,0 +1,147 @@ +import { createContext, ensure, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import type { Api, Around, Middleware } from "@effectionx/context-api"; +import type { FilesHandler } from "./files.ts"; + +type Layers = Partial>; +interface State { + max: Layers[]; + min: Layers[]; +} +const context = createContext("$api:executablemd.runtime.files", { max: [], min: [] }); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} +function isLayers(value: unknown): value is Layers { + return ( + isRecord(value) && + Object.values(value).every((member) => member === undefined || typeof member === "function") + ); +} +function isState(value: unknown): value is State { + return ( + isRecord(value) && + Array.isArray(value.max) && + value.max.every(isLayers) && + Array.isArray(value.min) && + value.min.every(isLayers) + ); +} +function windowOf(scope: Scope): Record { + const contexts: unknown = Reflect.get(scope, "contexts"); + if (isRecord(contexts)) { + return contexts; + } + throw new Error("Unsupported Effection scope internals"); +} +function collect(scope: Scope): Layers[] { + const max: Layers[] = []; + const min: Layers[] = []; + let window: Record | null = windowOf(scope); + while (window !== null) { + if (Object.prototype.hasOwnProperty.call(window, context.name)) { + const state: unknown = window[context.name]; + if (!isState(state)) { + throw new Error("Invalid Files middleware composition"); + } + max.unshift(...state.max); + min.push(...state.min); + } + window = Object.getPrototypeOf(window); + } + return [...max, ...min]; +} + +// Composition follows context-api 0.6.0 and middleware 0.1.1; see files-api.LICENSE. +function combine(layers: Middleware[]): Middleware { + if (layers.length === 0) { + return (args, next) => next(...args); + } + return layers.reduceRight( + (inner, layer) => (args, next) => layer(args, (...values) => inner(values, next)), + ); +} +function isOperation(value: Operation | T): value is Operation { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + !(value instanceof Map) && + !(value instanceof Set) && + typeof Reflect.get(value, Symbol.iterator) === "function" + ); +} + +export interface FileRecorderHandle { + assemble(method: K): FilesHandler[K]; +} +export interface FilesApi extends Api { + register(boundary: Layers): Operation; +} + +export function createFilesApi(terminal: FilesHandler): FilesApi { + function* around( + layers: Layers, + options: { at: "min" | "max" } = { at: "max" }, + ): Operation { + const scope = yield* useScope(); + const current = scope.hasOwn(context) ? scope.expect(context) : { max: [], min: [] }; + scope.set( + context, + options.at === "min" + ? { max: [...current.max], min: [layers, ...current.min] } + : { max: [...current.max, layers], min: [...current.min] }, + ); + } + function operations(exclude?: Layers, active: () => boolean = () => true): FilesHandler { + function call( + terminal: (...args: A) => Operation, + select: (layer: Layers) => Middleware> | undefined, + ): (...args: A) => Operation { + return (...args) => ({ + *[Symbol.iterator]() { + if (!active()) { + throw new Error("Files recorder has closed"); + } + const layers = collect(yield* useScope()); + if (exclude !== undefined && layers.filter((layer) => layer === exclude).length !== 1) { + throw new Error("Files recorder is not attached exactly once"); + } + const handlers = layers + .filter((layer) => layer !== exclude) + .flatMap((layer) => { + const handler = select(layer); + return handler === undefined ? [] : [handler]; + }); + const result = combine(handlers)(args, terminal); + return isOperation(result) ? yield* result : result; + }, + }); + } + return { + checkFilePath: call(terminal.checkFilePath, (layer) => layer.checkFilePath), + readTextFile: call(terminal.readTextFile, (layer) => layer.readTextFile), + writeTextFile: call(terminal.writeTextFile, (layer) => layer.writeTextFile), + deleteFile: call(terminal.deleteFile, (layer) => layer.deleteFile), + ensureDirectory: call(terminal.ensureDirectory, (layer) => layer.ensureDirectory), + globFiles: call(terminal.globFiles, (layer) => layer.globFiles), + temporaryDirectory: call(terminal.temporaryDirectory, (layer) => layer.temporaryDirectory), + }; + } + return { + operations: operations(), + around, + *register(boundary): Operation { + let live = true; + yield* ensure(() => { + live = false; + }); + yield* around(boundary); + const selected = operations(boundary, () => live); + return Object.freeze({ + assemble: (method: K) => selected[method], + }); + }, + }; +} diff --git a/packages/runtime/files-host.ts b/packages/runtime/files-host.ts new file mode 100644 index 000000000..f66da9c72 --- /dev/null +++ b/packages/runtime/files-host.ts @@ -0,0 +1,9 @@ +import { registerFileRecorder } from "./files.ts"; +import type { FilesHandler } from "./files.ts"; +import type { FileRecorderHandle } from "./files-api.ts"; +export type { FileRecorderHandle } from "./files-api.ts"; +export { registerFileRecorder }; +export const assembleFileCall = ( + handle: FileRecorderHandle, + method: K, +): FilesHandler[K] => handle.assemble(method); diff --git a/packages/runtime/files.ts b/packages/runtime/files.ts index db5cc3a26..8a35425cc 100644 --- a/packages/runtime/files.ts +++ b/packages/runtime/files.ts @@ -66,7 +66,8 @@ * failure into an unrecognized throw exactly when it matters most. */ -import { type Api, createApi } from "@effectionx/context-api"; +import type { Api } from "@effectionx/context-api"; +import { createFilesApi } from "./files-api.ts"; import type { Operation, Result } from "effection"; /** The stable discriminant on ordinary filesystem failure data. */ @@ -850,7 +851,7 @@ export function parseFileWriteSuccess(value: unknown): FileWriteSuccess | undefi * indistinguishable from an installed one, and the whole point of the boundary * is that a workflow run cannot silently touch the caller's filesystem. */ -export const Files: Api = createApi("executablemd.runtime.files", { +const composedFiles = createFilesApi({ // deno-lint-ignore require-yield *checkFilePath(_input: FilePathInput): Operation> { throw new FilesProviderUnavailableError(); @@ -880,3 +881,9 @@ export const Files: Api = createApi("executablemd.ru throw new FilesProviderUnavailableError(); }, }); + +export const Files: Api = { + operations: composedFiles.operations, + around: composedFiles.around, +}; +export const registerFileRecorder = composedFiles.register; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f85506687..e2bbf1b79 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -7,6 +7,7 @@ ".": "./mod.ts", "./api": "./api.ts", "./files": "./files.ts", + "./files-host": "./files-host.ts", "./test": "./test/mod.ts" }, "dependencies": { diff --git a/packages/runtime/tests/files-call.test.ts b/packages/runtime/tests/files-call.test.ts new file mode 100644 index 000000000..82de7bdc3 --- /dev/null +++ b/packages/runtime/tests/files-call.test.ts @@ -0,0 +1,130 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createContext, ensure, Ok, scoped } from "effection"; +import { Files } from "../files.ts"; +import { registerFileRecorder, assembleFileCall } from "../files-host.ts"; + +describe("entry Files continuation", () => { + it("excludes only its exact recorder and keeps inline scope and teardown", function* () { + const value = createContext("test.mount.files.inline", "initial"); + const order: string[] = []; + let direct = Files.operations.readTextFile; + yield* scoped(function* () { + yield* Files.around({ + *readTextFile([input], next) { + order.push("outer"); + const result = yield* next(input); + order.push("outer-after"); + return result; + }, + }); + const boundary = { + *readTextFile( + [input]: Parameters, + next: typeof Files.operations.readTextFile, + ) { + order.push("recorder"); + return yield* next(input); + }, + }; + const handle = yield* registerFileRecorder(boundary); + direct = assembleFileCall(handle, "readTextFile"); + yield* Files.around({ + *readTextFile([input], next) { + yield* ensure(() => { + order.push("released"); + }); + yield* value.set(input.path); + const result = yield* next({ ...input, path: "terminal" }); + expect(yield* value.get()).toBe("selected"); + return result; + }, + }); + yield* Files.around( + { + *readTextFile([input]) { + order.push(input.path); + return Ok("text"); + }, + }, + { at: "min" }, + ); + expect(yield* direct({ cwd: "/", path: "selected" })).toEqual(Ok("text")); + expect(yield* value.get()).toBe("selected"); + expect(order).toEqual(["outer", "terminal", "outer-after"]); + }); + expect(order.at(-1)).toBe("released"); + let error: unknown; + try { + yield* direct({ cwd: "/", path: "late" }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + }); +}); + +import { Err, spawn } from "effection"; +import { filesFailure } from "../files.ts"; +describe("additional focused boundary 1", () => { + it("keeps synthetic outcomes, repeated delegation and descendant calls inline", function* () { + const values = createContext("test.mount.files.repeat", 0); + const entered: string[] = []; + const boundary = { + *readTextFile( + [input]: Parameters, + next: typeof Files.operations.readTextFile, + ) { + entered.push("B"); + return yield* next(input); + }, + }; + const handle = yield* registerFileRecorder(boundary); + const call = assembleFileCall(handle, "readTextFile"); + yield* Files.around({ + *readTextFile([input], next) { + const before = yield* values.expect(); + const first = yield* next({ ...input, path: "one" }); + const second = yield* next({ ...input, path: "two" }); + if (first.ok && second.ok) { + expect(yield* values.get()).toBe(before + 2); + } + return first.ok && second.ok ? Ok(first.value + second.value) : first; + }, + }); + yield* Files.around( + { + *readTextFile([input]) { + yield* values.set((yield* values.expect()) + 1); + return Ok(input.path); + }, + }, + { at: "min" }, + ); + expect(yield* call({ cwd: "/", path: "original" })).toEqual(Ok("onetwo")); + expect(entered).toEqual([]); + yield* scoped(function* () { + yield* Files.around({ + *readTextFile() { + return Err(filesFailure({ operation: "read", phase: "access", reason: "missing" })); + }, + }); + const result = yield* call({ cwd: "/", path: "synthetic" }); + expect(result.ok).toBe(false); + }); + const task = yield* spawn(() => call({ cwd: "/", path: "descendant" })); + expect(yield* task).toEqual(Ok("onetwo")); + expect(yield* Files.operations.readTextFile({ cwd: "/", path: "raw" })).toEqual(Ok("onetwo")); + expect(entered).toEqual(["B"]); + entered.length = 0; + yield* Files.around(boundary); + let refused: unknown; + try { + yield* call({ cwd: "/", path: "duplicate" }); + } catch (error) { + refused = error; + } + expect(refused).toBeInstanceOf(Error); + expect(entered).toEqual([]); + }); +}); diff --git a/packages/test-support/mount-fixture.ts b/packages/test-support/mount-fixture.ts new file mode 100644 index 000000000..5b5454670 --- /dev/null +++ b/packages/test-support/mount-fixture.ts @@ -0,0 +1,8 @@ +export const R1_WIN32 = Object.freeze({ + pathStyle: "win32", + source: "C:\\work\\manuals", + target: "/reference", + operand: String.fromCodePoint(46, 46, 92, 111, 117, 116, 115, 105, 100, 101), + destination: "/reference/..\\outside", + escaped: "C:\\work\\outside", +}); diff --git a/scripts/tests/repl-repositories-compiled.test.ts b/scripts/tests/repl-repositories-compiled.test.ts index e34e8f01a..657e5649e 100644 --- a/scripts/tests/repl-repositories-compiled.test.ts +++ b/scripts/tests/repl-repositories-compiled.test.ts @@ -1,3 +1,4 @@ +import { replMountsJourney } from "../../packages/cli/tests/support/repl-mounts-journey.ts"; /** * The same journey, against the compiled binary (#181 E1). * @@ -72,3 +73,8 @@ describe("the compiled binary's REPL entry files", () => { yield* replGitJourney(compiled, "worktree"); }); }); + +it("the compiled binary carries workspace mount replay and live placement", function* () { + yield* requireBinary(); + yield* replMountsJourney(compiled); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index effec5995..337b4544e 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1022,6 +1022,9 @@ block starts no process. An `exec` block is a foreground child process. +A REPL workspace mount delivers the actual scoped cwd to process start. It +rewrites no argv and supplies no subprocess sandbox or new process retention. + **The boundary.** A command's output passes through several hands: ``` @@ -8635,6 +8638,12 @@ it was. Which class is asked for is the form — reading asks `read`, writing as `write` — and the pure path check a write performs before its children asks nothing at all. +The REPL's explicit workspace mounts deliver actual SOURCE cwd through this same +provider boundary. Selected mapped reads retain acknowledged values in the same +unfinished entry, including across source reassignment or pair omission. Genuinely +new reads use current placement and current permission; a new entry reads live. +Writes and deletions gain no mapped retention or containment exemption. + Because the path is contextual, `` composes with `` (§6.11) without either component knowing about the other: @@ -9262,6 +9271,23 @@ selects no repository, which is what lets the self-closing `` spelling compose with it: the Repository a later Git element acts on is still the enclosing one. +#### REPL workspace mounts + +A REPL invoked with `--mount SOURCE:TARGET` interprets an absolute document Dir +destination under TARGET as SOURCE plus its document suffix. Its private authored +and actual directory scopes travel together. Ensure precedes binding and content; +a refusal installs neither. A new nested `..` under `/reference` mapped from +`/work/manuals/docs` ensures and enters authored and actual `/`, then restores +the enclosing pair on success, child failure or cancellation. The written operand +is preserved separately from the effective absolute request. Ordinary hosts retain +their relative-escape refusal, and workflow paths remain logical Workspace paths. + +A mapped ensure gains no external retention. Where existing coverage retains a +prospective association, its own validated acknowledged Unit can permit +current-placement content after source reassignment without current ensure I/O or +an existence assertion. Its own refusal still stops content; an unacknowledged +operation supplies no saved Unit. Restoration does not roll directory effects back. + #### Ownership, and what restoring does not do The enclosing working directory is restored when the invocation ends — on @@ -9308,6 +9334,9 @@ The search is a `read`, asked of `Permissions` by the provider before it touches the directory it would walk (§1.2); a refusal traverses nothing and reports the ordinary sanitized sentence. +A REPL mount changes the actual cwd delivered here, without rewriting either +pattern list or adding retention, merged roots or outward symlink traversal. + `include` is required and holds at least one pattern. `exclude` is optional and defaults to empty. Both are lists of glob patterns, and both are evaluated relative to `Env.cwd`, so `` composes with `` (§6.11) and with diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 699214157..e455b85ae 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -52,10 +52,36 @@ only genuine absence falls through, and a selected malformed or unreadable component fails. These directories select ordinary components and grant no permission; canonical protected components keep their implementations. +Add an external host directory with a required document destination: + +```sh +xmd repl --mount /work/manuals:/reference --mount /work/fixtures:/fixtures +``` + +Both `--mount SOURCE:TARGET` and `--mount=SOURCE:TARGET` repeat. Split at the last +colon: drive-qualified SOURCE paths retain their drive colon. SOURCE resolves +lexically against the captured invocation cwd, without stat, creation, realpath +or permission. No tilde, environment, URI or escape expansion is performed. +TARGET is absolute POSIX document spelling: reject NUL, backslash, colon, +`//`, `..` segments, normalized `/`, duplicates and overlapping destinations. +Normalize repeated separators, dot segments and trailing separators. Disjoint +pairs are sorted; equality is exact normalized spelling, without filesystem +identity, symlink or case-fold equivalence. Sources may coincide. + +`` ensures the selected SOURCE before children use it as +actual cwd. File reads/writes/deletions retain their relative operands; Glob +retains its patterns and a foreground process its argv. The mapping grants no +permission, Git operation or native Agent tooling. The command's own cwd and a +held conversation's cwd never move. Under the unchanged +`/work/manuals/docs:/reference` pair, a new nested `..` follows authored `/`, +ensures ordinary host `/`, and only its own Unit allows children there. Scope +exit restores `/reference` and its source. On Windows, component-changing document +spellings refuse before provider work; native SOURCE paths keep native spelling. + Everything that can be refused is refused in this order, and each step happens before anything the next one would touch: -1. **The line.** A token that is not the location or one of those seven options, +1. **The line.** A token that is not the location or one of those eight options, a switch given a value, a value option given none or an empty one, and a second location are each refused here — before a per-user directory is formed, a history file is created or the terminal's modes are touched. @@ -63,7 +89,8 @@ before anything the next one would touch: `--agent-provider` are refused next, before any adapter exists. 3. **The command assembly.** Invocation cwd, configured absolute directories, selected Plugins, Agent stack, provider dependencies and permission mode are - captured once in the command scope. After storage supplies the execution id, + captured once in the command scope. The mount table is admitted at this boundary + before any terminal, history or provider adapter exists. After storage supplies the execution id, one immutable execution profile adds session/components and constructs the includes-dependent Plan declaration and vocabulary. No Plugin or Agent stack is installed again, and every entry uses the same finalized search list. @@ -1393,8 +1420,30 @@ selected and promises no row of it. ### The files an entry wrote in the checkout you are standing in +Mapped File reads retain their composed values even outside checkout/session +coverage. Continuing the same unfinished entry restores an acknowledged value +without reading or rewriting the original source. Reassigning an unchanged +TARGET to another distinct, unaliased source leaves that value historical and +routes the next new read to the current source. Omitting the pair leaves the +saved read historical and routes new work to ordinary host placement. A fresh +entry reads live; there is no cache by path and history restores no missing pair. +An enclosing unrecorded ensure may execute or refuse on continuation. + +A prospectively associated mapped ensure already covered by the existing recorder +restores its own acknowledged Unit or ordinary refusal under source reassignment. +Replay independently validates authored occurrence and every operand before +original binding and physical result. Unit permits current-placement children +without ensuring or proving the new folder exists; refusal permits none. +Omitting that ensure's TARGET is unsupported. This adds no ensure coverage and +no saved outcome for unacknowledged work. A separate current ensure is new authored +work, not insertion at an old slot. Legacy physical-only history remains physical; +malformed or inconsistent association refuses before live work rather than +falling back. Current paths still require provider permission, actual containment +and disclosure checks. + Start the REPL inside a Git checkout and an entry's file work is retained too, -under the ordinary records above and no record type of its own. Four things are +under the ordinary physical records. The prospective mapped class above uses its +closed association record. Four operations are kept: a self-closing `` text read; a paired `…` write, after its content has expanded; a self-closing ``; and the directory a paired `…` @@ -1425,7 +1474,7 @@ is already done. Type a *new* entry that reads the same path and it reads what is there now. What is retained is each operation, not a value the session remembers for a path. -Any of the four anywhere else — beside the document, in a temporary directory, +Except for selected mapped reads, any of the four anywhere else — beside the document, in a temporary directory, in any directory outside the selected checkouts and the marked entry's owned workspace — is ordinary file work, is retained by nothing here, and happens again if the entry is continued. A marked workspace entry retains the same four From 687c05ffce677d4d6994cc48ff5ecbde7b3c2cff Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 18:12:48 -0400 Subject: [PATCH 2/6] Preserve mapped read refusals and register compiled journey --- packages/cli/src/repl/mount-files.ts | 4 +- packages/cli/src/repl/mount-records.ts | 15 ++-- .../cli/tests/retained-mount-files.test.ts | 78 +++++++++++++++++++ .../tests/repl-repositories-compiled.test.ts | 9 +-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/repl/mount-files.ts b/packages/cli/src/repl/mount-files.ts index 9a831e728..16c54f57d 100644 --- a/packages/cli/src/repl/mount-files.ts +++ b/packages/cli/src/repl/mount-files.ts @@ -31,6 +31,7 @@ import { import type { ReplLocalValue } from "./repositories/records.ts"; import { admitMountRecords, + containedMountRead, mountAuthored, mountFingerprint, mountName, @@ -135,7 +136,8 @@ export function mountPolicy( enclosingPair === undefined || enclosingPair.source !== selected.source || enclosingPair.target !== selected.target || - host.isAbsolute(a.path)) + request.path !== a.path || + !containedMountRead(pathStyle, request.cwd, a.path)) ) { return undefined; } diff --git a/packages/cli/src/repl/mount-records.ts b/packages/cli/src/repl/mount-records.ts index c938f9df8..d798aaa29 100644 --- a/packages/cli/src/repl/mount-records.ts +++ b/packages/cli/src/repl/mount-records.ts @@ -97,6 +97,14 @@ function within(style: "posix" | "win32", root: string, target: string): boolean host.resolve(root, relative) === host.resolve(target) ); } +export function containedMountRead( + pathStyle: "posix" | "win32", + cwd: string, + path: string, +): boolean { + const host = pathStyle === "win32" ? win32 : posix; + return !host.isAbsolute(path) && within(pathStyle, cwd, host.resolve(cwd, path)); +} export function parseMountEnvelope(value: unknown): MountRecord { const parsed = shape.safeParse(value); if (!parsed.success) { @@ -198,12 +206,7 @@ export function* parseMountRecord(value: unknown, name?: string): Operation { + it("keeps nested lexical read refusals ordinary and retains only applicable physical results", function* () { + const source = + 'AFTER'; + const observed: FilePathInput[] = []; + const refusal = { + *before() { + yield* API.Files.around({ + *readTextFile([input]) { + observed.push({ ...input }); + return Err( + filesFailure({ operation: "read", phase: "access", reason: "lexical-escape" }), + ); + }, + }); + }, + }; + const ordinary = yield* mountedRun(source, new InMemoryStream(), [], trace(), refusal); + expect(ordinary).toContain('"../outside.md" resolves outside the working directory.'); + for (const workspace of [undefined, "/external"]) { + const stream = new InMemoryStream(); + observed.length = 0; + const output = yield* mountedRun( + source, + stream, + [{ source: "/external", target: "/reference" }], + trace(), + { ...refusal, workspace }, + ); + expect(output).toBe(ordinary); + expect(output).not.toContain("/external"); + expect(observed).toEqual([{ cwd: "/external/sub", path: "../outside.md" }]); + const events = yield* stream.readAll(); + const physicalReads = events.flatMap((event) => { + if ( + event.type !== "yield" || + event.description.type !== REPL_REPOSITORY_LOCAL || + event.result.status !== "ok" + ) { + return []; + } + const record = parseReplLocalRecord(event.result.value); + return record?.operation === "file-read" ? [record] : []; + }); + expect(physicalReads).toHaveLength(workspace === undefined ? 0 : 1); + for (const event of events) { + if ( + event.type === "yield" && + event.description.type === MOUNT_FILE && + event.result.status === "ok" + ) { + expect(Reflect.get(Object(event.result.value), "operation")).toBe("dir-ensure"); + } + } + if (workspace !== undefined) { + const record = physicalReads[0]; + expect(record?.request).toEqual({ cwd: "/external/sub", path: "../outside.md" }); + expect(record?.outcome.ok).toBe(false); + if (record !== undefined && !record.outcome.ok) { + expect(parseFilesFailure(record.outcome.error)?.reason).toBe("lexical-escape"); + } + observed.length = 0; + const calls = trace(); + const replayed = yield* mountedRun( + source, + new InMemoryStream(events.filter((event) => event.type !== "close")), + [{ source: "/external", target: "/reference" }], + calls, + { ...refusal, workspace }, + ); + expect(replayed).toBe(ordinary); + expect(observed).toEqual([]); + expect(calls.ensures).toEqual([]); + } + } + }); for (const current of ["/old", "/new", undefined]) { it(`restores saved read before a new read under ${current ?? "omission"}`, function* () { const stream = new InMemoryStream(); diff --git a/scripts/tests/repl-repositories-compiled.test.ts b/scripts/tests/repl-repositories-compiled.test.ts index 657e5649e..9803dd4ae 100644 --- a/scripts/tests/repl-repositories-compiled.test.ts +++ b/scripts/tests/repl-repositories-compiled.test.ts @@ -72,9 +72,8 @@ describe("the compiled binary's REPL entry files", () => { yield* requireBinary(); yield* replGitJourney(compiled, "worktree"); }); -}); - -it("the compiled binary carries workspace mount replay and live placement", function* () { - yield* requireBinary(); - yield* replMountsJourney(compiled); + it("the compiled binary carries workspace mount replay and live placement", function* () { + yield* requireBinary(); + yield* replMountsJourney(compiled); + }); }); From 443c6703ff20d091557637cc2166eab66c1909da Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 18:44:57 -0400 Subject: [PATCH 3/6] Bind mounts to entry cwd and preserve absent retention --- .../src/repl/repositories/deno/provider.ts | 7 +- packages/cli/src/repl/session.ts | 7 +- .../src/repl/workspace-mount-installation.ts | 27 ++----- .../cli/tests/retained-mount-files.test.ts | 61 +++++++++++---- .../cli/tests/retained-mount-records.test.ts | 16 ++-- .../tests/retained-repository-local.test.ts | 77 ++++++++++++++++++- packages/cli/tests/support/mount-run.ts | 37 ++++++++- 7 files changed, 173 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/repl/repositories/deno/provider.ts b/packages/cli/src/repl/repositories/deno/provider.ts index cbc9a5376..924c6a3b9 100644 --- a/packages/cli/src/repl/repositories/deno/provider.ts +++ b/packages/cli/src/repl/repositories/deno/provider.ts @@ -255,7 +255,7 @@ export function* replRepositoryInstallation( replPushAdmission(owner), replPullRequestReadAdmission(owner), ]; - const filesystem = workspaceMountInstallation(options.mounts ?? [], options.workspace, () => { + const filesystem = workspaceMountInstallation(options.mounts ?? [], () => { if (held.files === undefined) { throw new Error("Entry file recorder is not initialized"); } @@ -291,7 +291,8 @@ function* initializeReplRepository( deliver?: (entry: ReplPushEntry) => void, deliverFiles?: (entry: MountFileEntry) => void, ): Operation { - const directory = options.projectCwd ?? (yield* cwd()); + const entryDirectory = yield* cwd(); + const directory = options.projectCwd ?? entryDirectory; const configuredRoot = options.root; // The entry task. Every capability below is acquired into it, so the session, @@ -381,7 +382,7 @@ function* initializeReplRepository( recorder, options.workspace, options.mounts ?? [], - directory, + entryDirectory, ); deliverFiles?.(files); diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index 2c81c9f33..ae146a97b 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -1441,12 +1441,7 @@ function* start( // the entry rather than to a scope that has already closed. const owned = entryInstallation === undefined - ? [ - workspaceMountInstallation( - options.mounts ?? [], - workspace === true ? options.workspace : undefined, - ), - ] + ? [workspaceMountInstallation(options.mounts ?? [])] : [ yield* entryInstallation(entryOwner(execution.id, key), { mounts: options.mounts ?? [], diff --git a/packages/cli/src/repl/workspace-mount-installation.ts b/packages/cli/src/repl/workspace-mount-installation.ts index f262b6877..a561b301b 100644 --- a/packages/cli/src/repl/workspace-mount-installation.ts +++ b/packages/cli/src/repl/workspace-mount-installation.ts @@ -3,13 +3,11 @@ import { cwd } from "@executablemd/runtime"; import type { Operation, Result } from "effection"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { posix, win32 } from "node:path"; import type { WorkspaceMountTable } from "../workspace-mounts.ts"; import { useEntryFileRecorder } from "./file-recorder.ts"; import type { EntryFileCalls } from "./file-recorder.ts"; import { createMountPolicy } from "./mount-files.ts"; import type { AwaitedMountPolicy } from "./mount-files.ts"; -import { recordedLocalOperation } from "./repositories/deno/local.ts"; import type { ReplLocalValue } from "./repositories/records.ts"; import { MountRecordError } from "./mount-records.ts"; @@ -19,7 +17,6 @@ export interface MountFileEntry { } export function workspaceMountInstallation( mounts: WorkspaceMountTable, - workspace?: string, access?: () => MountFileEntry, ): ExecutionInstallation { mounts = Object.freeze(mounts.map((pair) => Object.freeze({ ...pair }))); @@ -43,7 +40,7 @@ export function workspaceMountInstallation( const directory = yield* cwd(); const policy = yield* createMountPolicy(mounts, directory); const files = yield* useEntryFileRecorder( - function* (operation, request, perform, authored): Operation> { + function* (_operation, request, perform, authored): Operation> { yield* policy.guard(); if (authored !== undefined) { const replay = yield* policy.replay(authored, request); @@ -51,26 +48,12 @@ export function workspaceMountInstallation( return yield* replay; } } - const host = policy.pathStyle === "win32" ? win32 : posix; - const target = host.resolve(request.cwd, request.path); - const relative = workspace === undefined ? undefined : host.relative(workspace, target); - const covered = - relative !== undefined && - !host.isAbsolute(relative) && - !relative.split(host.sep).includes(".."); if (authored !== undefined) { - return yield* policy.record( - authored, - request, - perform, - covered && workspace !== undefined - ? { kind: "existing", owner: "session", root: workspace, target } - : { kind: "external-mapped-read" }, - ); + return yield* policy.record(authored, request, perform, { + kind: "external-mapped-read", + }); } - return covered - ? yield* recordedLocalOperation({ operation, request, perform }) - : yield* perform(); + return yield* perform(); }, ); entry = { files, policy }; diff --git a/packages/cli/tests/retained-mount-files.test.ts b/packages/cli/tests/retained-mount-files.test.ts index a79ad0b01..797492021 100644 --- a/packages/cli/tests/retained-mount-files.test.ts +++ b/packages/cli/tests/retained-mount-files.test.ts @@ -27,7 +27,7 @@ describe("retained mount files", () => { }; const ordinary = yield* mountedRun(source, new InMemoryStream(), [], trace(), refusal); expect(ordinary).toContain('"../outside.md" resolves outside the working directory.'); - for (const workspace of [undefined, "/external"]) { + for (const coverageRoot of [undefined, "/external"]) { const stream = new InMemoryStream(); observed.length = 0; const output = yield* mountedRun( @@ -35,7 +35,10 @@ describe("retained mount files", () => { stream, [{ source: "/external", target: "/reference" }], trace(), - { ...refusal, workspace }, + { + ...refusal, + ownedFileCoverage: coverageRoot === undefined ? undefined : { root: coverageRoot }, + }, ); expect(output).toBe(ordinary); expect(output).not.toContain("/external"); @@ -52,7 +55,7 @@ describe("retained mount files", () => { const record = parseReplLocalRecord(event.result.value); return record?.operation === "file-read" ? [record] : []; }); - expect(physicalReads).toHaveLength(workspace === undefined ? 0 : 1); + expect(physicalReads).toHaveLength(coverageRoot === undefined ? 0 : 1); for (const event of events) { if ( event.type === "yield" && @@ -62,7 +65,7 @@ describe("retained mount files", () => { expect(Reflect.get(Object(event.result.value), "operation")).toBe("dir-ensure"); } } - if (workspace !== undefined) { + if (coverageRoot !== undefined) { const record = physicalReads[0]; expect(record?.request).toEqual({ cwd: "/external/sub", path: "../outside.md" }); expect(record?.outcome.ok).toBe(false); @@ -76,7 +79,10 @@ describe("retained mount files", () => { new InMemoryStream(events.filter((event) => event.type !== "close")), [{ source: "/external", target: "/reference" }], calls, - { ...refusal, workspace }, + { + ...refusal, + ownedFileCoverage: coverageRoot === undefined ? undefined : { root: coverageRoot }, + }, ); expect(replayed).toBe(ordinary); expect(observed).toEqual([]); @@ -119,7 +125,7 @@ describe("retained mount files", () => { const first = trace(); yield* mountedRun(source, stream, [{ source: "/owned/old", target: "/reference" }], first, { pause: true, - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, }); const second = trace(); const output = yield* mountedRun( @@ -127,7 +133,7 @@ describe("retained mount files", () => { stream, [{ source: "/missing/new", target: "/reference" }], second, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); expect(second.ensures).toEqual([]); expect(second.reads).toEqual([{ cwd: "/missing/new", path: "next.md" }]); @@ -172,7 +178,7 @@ describe("additional focused boundary 1", () => { stream, [{ source: "/owned/source", target: "/reference" }], calls, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); } catch (error) { failure = error; @@ -277,7 +283,7 @@ describe("additional focused boundary 4", () => { [{ source: "/owned/source", target: "/reference" }], calls, { - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, *before() { yield* Component.around({ *expand([request], next) { @@ -311,7 +317,7 @@ describe("additional focused boundary 4", () => { stream, [{ source: "/owned/new", target: "/reference" }], resumed, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); expect(resumed.ensures).toEqual([]); expect(resumed.reads).toEqual([{ cwd: "/owned/new", path: "next.md" }]); @@ -322,13 +328,13 @@ describe("additional focused boundary 5", () => { const source = `${question}`; const stream = new InMemoryStream(); yield* mountedRun(source, stream, [{ source: "/owned/old", target: "/reference" }], trace(), { - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, pause: true, refuseEnsure: true, }); const calls = trace(); yield* mountedRun(source, stream, [{ source: "/owned/new", target: "/reference" }], calls, { - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, }); expect(calls.ensures).toEqual([]); expect(calls.reads).toEqual([]); @@ -363,7 +369,7 @@ describe("additional focused boundary 6", () => { stream, [{ source: "/owned/source", target: "/reference" }], calls, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ), ); yield* entered.operation; @@ -391,7 +397,7 @@ describe("additional focused boundary 7", () => { [{ source: "/owned/source", target: "/reference" }], calls, { - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, *before() { yield* API.Files.around({ *ensureDirectory() { @@ -465,3 +471,30 @@ describe("whole composed answer retention", () => { expect(calls.reads).toEqual([]); }); }); + +describe("portable entry without a retention adapter", () => { + it("keeps marked workspace mutations live even with an empty mount table", function* () { + for (const mounts of [[], [{ source: "/external", target: "/reference" }]]) { + const source = `datacontent${question}`; + const stream = new InMemoryStream(); + const first = trace(); + yield* mountedRun(source, stream, mounts, first, { entryCwd: "/workspace", pause: true }); + expect(first.writes).toHaveLength(1); + expect(first.deletes).toHaveLength(1); + expect(first.ensures).toEqual([{ cwd: "/workspace", path: "nested" }]); + expect( + (yield* stream.readAll()).filter( + (event) => + event.type === "yield" && + (event.description.type === REPL_REPOSITORY_LOCAL || + event.description.type === MOUNT_FILE), + ), + ).toEqual([]); + const resumed = trace(); + yield* mountedRun(source, stream, mounts, resumed, { entryCwd: "/workspace" }); + expect(resumed.writes).toHaveLength(1); + expect(resumed.deletes).toHaveLength(1); + expect(resumed.ensures).toEqual(first.ensures); + } + }); +}); diff --git a/packages/cli/tests/retained-mount-records.test.ts b/packages/cli/tests/retained-mount-records.test.ts index dd823ec63..de156831f 100644 --- a/packages/cli/tests/retained-mount-records.test.ts +++ b/packages/cli/tests/retained-mount-records.test.ts @@ -96,7 +96,7 @@ describe("mount record consistency", () => { stream, [{ source: "/owned/source", target: "/reference" }], trace(), - { pause: true, workspace: "/owned" }, + { pause: true, ownedFileCoverage: { root: "/owned" } }, ); const changed = yield* stream.readAll(); for (const event of changed) { @@ -140,7 +140,7 @@ describe("mount record consistency", () => { new InMemoryStream(changed), [{ source: "/owned/new", target: "/reference" }], calls, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); } catch (error) { failure = error; @@ -163,7 +163,7 @@ function* fixture() { stream, [{ source: "/owned/source", target: "/reference" }], trace(), - { pause: true, workspace: "/owned" }, + { pause: true, ownedFileCoverage: { root: "/owned" } }, ); return yield* stream.readAll(); } @@ -277,7 +277,7 @@ describe("closed association admission", () => { new InMemoryStream(events), [{ source: "/owned/new", target: "/reference" }], calls, - { workspace: "/owned", before: allowRunLive }, + { ownedFileCoverage: { root: "/owned" }, before: allowRunLive }, ); } catch (error) { failure = error; @@ -318,7 +318,7 @@ describe("closed association admission", () => { new InMemoryStream(events), [{ source: "/owned/new", target: "/reference" }], calls, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); } catch (error) { failure = error; @@ -332,7 +332,7 @@ describe("closed association admission", () => { let failure: unknown; try { yield* mountedRun(pausedSource, new InMemoryStream(yield* fixture()), [], calls, { - workspace: "/owned", + ownedFileCoverage: { root: "/owned" }, }); } catch (error) { failure = error; @@ -366,7 +366,7 @@ describe("closed association admission", () => { same, [{ source: "/owned/source", target: "/reference" }], calls, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); expect(calls.ensures).toEqual([]); expect(calls.reads).toEqual([]); @@ -383,7 +383,7 @@ describe("closed association admission", () => { new InMemoryStream(events), [{ source: "/owned/new", target: "/reference" }], changed, - { workspace: "/owned" }, + { ownedFileCoverage: { root: "/owned" } }, ); } catch (error) { failure = error; diff --git a/packages/cli/tests/retained-repository-local.test.ts b/packages/cli/tests/retained-repository-local.test.ts index b31e78edc..d38aad517 100644 --- a/packages/cli/tests/retained-repository-local.test.ts +++ b/packages/cli/tests/retained-repository-local.test.ts @@ -51,7 +51,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { Execution } from "@executablemd/core/api"; import { executeInstalled } from "@executablemd/core/host"; -import { collect, Elicitation, useNormalizedOutput } from "@executablemd/core"; +import { collect, Elicitation, inlineSource, useNormalizedOutput } from "@executablemd/core"; import { useDenoRepositoryOperations } from "@executablemd/git/deno"; import type { DenoRepositoryOperations } from "@executablemd/git/deno"; import { denoReplEntry } from "../src/deno-repositories.ts"; @@ -64,7 +64,10 @@ import { mountedReading, } from "./fixtures/repl/issue-883/mounted-program.ts"; import { createRepositoryRoot, runPhase } from "./fixtures/repl/issue-883/host.ts"; -import { useReplRepositoryRecording } from "../src/repl/repositories/deno/provider.ts"; +import { + replRepositoryInstallation, + useReplRepositoryRecording, +} from "../src/repl/repositories/deno/provider.ts"; import { admitReplRepositoryHistory } from "../src/repl/repositories/admission.ts"; import { useCompositionComponents } from "@executablemd/git"; import { @@ -1248,3 +1251,73 @@ describe("975 — workspace recording crosses the process boundary", () => { yield* root.discard(); }); }); + +describe("marked workspace mount initialization", () => { + it("seeds the frame from entry cwd while project discovery stays captured", function* () { + const calls: unknown[] = []; + const discoveries: string[] = []; + yield* API.Env.around( + { + *cwd() { + return "/caller"; + }, + }, + { at: "min" }, + ); + yield* scoped(function* () { + yield* API.Env.around( + { + *cwd() { + return "/workspace"; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *ensureDirectory([input]) { + calls.push(["ensure", { ...input }]); + return Ok(undefined); + }, + *readTextFile([input]) { + calls.push(["read", { ...input }]); + return Ok("text"); + }, + }, + { at: "min" }, + ); + const installation = yield* replRepositoryInstallation("workspace-mount-fixture", { + projectCwd: "/project", + workspace: "/workspace", + mounts: [{ source: "/external", target: "/reference" }], + *operations() { + const ordinary = yield* useDenoRepositoryOperations(); + return { + ...ordinary, + *ambientCheckout(from: string) { + discoveries.push(from); + return undefined; + }, + }; + }, + }); + yield* collect( + yield* executeInstalled( + { + ...inlineSource(''), + includes: [], + stream: new InMemoryStream(), + }, + [installation], + ), + ); + expect(yield* cwd()).toBe("/workspace"); + }); + expect(yield* cwd()).toBe("/caller"); + expect(discoveries).toEqual(["/project"]); + expect(calls).toEqual([ + ["ensure", { cwd: "/workspace", path: "/external" }], + ["read", { cwd: "/external", path: "one.md" }], + ]); + }); +}); diff --git a/packages/cli/tests/support/mount-run.ts b/packages/cli/tests/support/mount-run.ts index 1d43d659e..5944a4e41 100644 --- a/packages/cli/tests/support/mount-run.ts +++ b/packages/cli/tests/support/mount-run.ts @@ -1,12 +1,14 @@ import { Err, Ok, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; -import { API, fileWriteSuccess, filesFailure } from "@executablemd/runtime"; +import { API, cwd, fileWriteSuccess, filesFailure } from "@executablemd/runtime"; import type { FilePathInput } from "@executablemd/runtime"; import { collect, Elicitation, inlineSource } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { DurableStream } from "@executablemd/durable-streams"; import { workspaceMountInstallation } from "../../src/repl/workspace-mount-installation.ts"; import type { WorkspaceMountTable } from "../../src/workspace-mounts.ts"; +import { useReplFileRecorder } from "../../src/repl/repositories/deno/files.ts"; +import { admitReplRepositoryHistory } from "../../src/repl/repositories/admission.ts"; export interface MountTrace { reads: FilePathInput[]; @@ -22,7 +24,8 @@ export function mountedRun( calls: MountTrace, options: { pause?: boolean; - workspace?: string; + entryCwd?: string; + ownedFileCoverage?: { root: string }; refuseEnsure?: boolean; onRead?: (request: FilePathInput) => string; before?: () => Operation; @@ -32,7 +35,7 @@ export function mountedRun( yield* API.Env.around( { *cwd() { - return "/host"; + return options.entryCwd ?? "/host"; }, }, { at: "min" }, @@ -85,8 +88,34 @@ export function mountedRun( if (options.before !== undefined) { yield* options.before(); } + const owner = + options.ownedFileCoverage === undefined + ? undefined + : yield* useReplFileRecorder( + { + *selection() { + return undefined; + }, + *hold() { + throw new Error("the synthetic session recorder has no Git lease"); + }, + placements: () => [], + }, + options.ownedFileCoverage.root, + mounts, + yield* cwd(), + ); + const installation = workspaceMountInstallation( + mounts, + owner === undefined ? undefined : () => owner, + ); const execution = yield* executeInstalled({ ...inlineSource(source), includes: [], stream }, [ - workspaceMountInstallation(mounts, options.workspace), + owner === undefined + ? installation + : { + ...installation, + admissions: [admitReplRepositoryHistory, ...(installation.admissions ?? [])], + }, ]); if (options.pause) { yield* spawn(() => collect(execution)); From 5b1932fa86d44c143f2fe8a8bc334e746be51a33 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 20:10:58 -0400 Subject: [PATCH 4/6] Repair mounts admission and CI publication boundaries --- packages/cli/src/repl/file-recorder.ts | 3 -- .../cli/src/repl/repositories/deno/files.ts | 14 +----- .../src/repl/workspace-mount-installation.ts | 10 ++--- packages/cli/tests/repl-push.test.ts | 44 ++++++++++++++++--- packages/cli/tests/workspace-mounts.test.ts | 25 ++++++++--- packages/runtime/files.ts | 3 +- .../tests/jsr-consumer-documentation.test.ts | 5 +++ 7 files changed, 69 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/repl/file-recorder.ts b/packages/cli/src/repl/file-recorder.ts index 58d80a50c..8412f90b5 100644 --- a/packages/cli/src/repl/file-recorder.ts +++ b/packages/cli/src/repl/file-recorder.ts @@ -81,9 +81,6 @@ function recordingBody(route: FileRecordingRoute) { }, }; } -export function fileRecordingMiddleware(route: FileRecordingRoute): Partial> { - return recordingBody(route).boundary(); -} export function* useEntryFileRecorder(route: FileRecordingRoute): Operation { const body = recordingBody(route); const handle = yield* registerFileRecorder(body.boundary()); diff --git a/packages/cli/src/repl/repositories/deno/files.ts b/packages/cli/src/repl/repositories/deno/files.ts index c6fe4c6cc..97e31bffc 100644 --- a/packages/cli/src/repl/repositories/deno/files.ts +++ b/packages/cli/src/repl/repositories/deno/files.ts @@ -1,5 +1,5 @@ import type { AwaitedMountPolicy } from "../../mount-files.ts"; -import { fileRecordingMiddleware, useEntryFileRecorder } from "../../file-recorder.ts"; +import { useEntryFileRecorder } from "../../file-recorder.ts"; import type { MountFileEntry } from "../../workspace-mount-installation.ts"; import type { WorkspaceMountTable } from "../../../workspace-mounts.ts"; import type { AuthoredFileOperation } from "@executablemd/core/host"; @@ -50,8 +50,6 @@ import type { Operation, Result } from "effection"; import { isAbsolute, join, relative, resolve } from "node:path"; import { realpath } from "node:fs/promises"; import { until } from "effection"; -import type { FilesHandler } from "@executablemd/runtime"; -import type { Around } from "@effectionx/context-api"; import { localRecordName, recordedLocalOperation, @@ -110,16 +108,6 @@ export interface ReplLocalRecorder { placements(): readonly ReplManagedPlacement[]; } -/** The middleware that records this entry's authored mutations. */ -export function replFileMiddleware( - recorder: ReplLocalRecorder, - workspace?: string, -): Partial> { - return fileRecordingMiddleware((operation, request, perform) => - routed(recorder, operation, request, perform, workspace), - ); -} - /** * Answer this request from the journal, or route it and perform it live. * diff --git a/packages/cli/src/repl/workspace-mount-installation.ts b/packages/cli/src/repl/workspace-mount-installation.ts index a561b301b..487c60db8 100644 --- a/packages/cli/src/repl/workspace-mount-installation.ts +++ b/packages/cli/src/repl/workspace-mount-installation.ts @@ -9,7 +9,6 @@ import type { EntryFileCalls } from "./file-recorder.ts"; import { createMountPolicy } from "./mount-files.ts"; import type { AwaitedMountPolicy } from "./mount-files.ts"; import type { ReplLocalValue } from "./repositories/records.ts"; -import { MountRecordError } from "./mount-records.ts"; export interface MountFileEntry { readonly files: EntryFileCalls; @@ -24,13 +23,12 @@ export function workspaceMountInstallation( return { admissions: [ function* (events: readonly DurableEvent[]) { - if (entry === undefined) { - throw new MountRecordError(); - } - if (access === undefined) { + if (access === undefined || entry === undefined) { yield* admitReplRepositoryHistory(events); } - yield* entry.policy.admit(events); + if (entry !== undefined) { + yield* entry.policy.admit(events); + } }, ], *filesystem(deliver) { diff --git a/packages/cli/tests/repl-push.test.ts b/packages/cli/tests/repl-push.test.ts index 469df390f..1001995af 100644 --- a/packages/cli/tests/repl-push.test.ts +++ b/packages/cli/tests/repl-push.test.ts @@ -33,7 +33,9 @@ import { runGit } from "./support/fixture-git.ts"; import { replRepositoryPlugin } from "../src/repl/repositories/plugin.ts"; import { replRepositoryInstallation } from "../src/repl/repositories/deno/provider.ts"; import { REPL_REPOSITORY_PUSH_REQUEST } from "../src/repl/repositories/push-records.ts"; -import { GIT_HOST_EFFECT } from "@executablemd/git"; +import { MOUNT_FILE } from "../src/repl/mount-records.ts"; +import { PULL_REQUEST_READ } from "@executablemd/git/deno"; +import { ISSUE_EFFECT, GIT_HOST_EFFECT } from "@executablemd/git"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { selectReplEntryHost } from "../src/deno-repositories.ts"; import { assembleReplProfile } from "../src/repl-profile.ts"; @@ -449,11 +451,41 @@ describe("how a REPL entry gets its publication capability", () => { "PullRequest.Comments", "PullRequest.Reviews", ]); - // Three admissions: the Issue history, the one walk that admits this - // entry's saved Push and pull-request requests beside their outcomes, and - // the retained reads, which are named by a question rather than paired with - // an outcome. - expect((built.admissions ?? []).length).toBe(3); + expect(typeof built.filesystem).toBe("function"); + const admissions = built.admissions ?? []; + for (const admit of admissions) { + yield* admit([]); + } + const owners = new Set(); + const records: readonly (readonly [string, string])[] = [ + [ISSUE_EFFECT, "ReplIssueHistoryError"], + [REPL_REPOSITORY_PUSH_REQUEST, "ReplPushHistoryError"], + [PULL_REQUEST_READ, "ReplPullRequestReadHistoryError"], + [MOUNT_FILE, "MountRecordError"], + ["repl_mount_file/v999", "MountRecordError"], + ]; + for (const [type, errorName] of records) { + const event: DurableEvent = { + type: "yield", + coroutineId: "root", + description: { type, name: "malformed" }, + result: { status: "ok", value: null }, + }; + const refusals: number[] = []; + for (const [index, admit] of admissions.entries()) { + try { + yield* admit([event]); + } catch (error) { + expect(error instanceof Error && error.name).toBe(errorName); + refusals.push(index); + } + } + expect(refusals).toHaveLength(1); + for (const index of refusals) { + owners.add(index); + } + } + expect(owners.size).toBe(admissions.length); expect(yield* nothingUnder(fixture.managed)).toBe(true); }); }); diff --git a/packages/cli/tests/workspace-mounts.test.ts b/packages/cli/tests/workspace-mounts.test.ts index 14a50eb6c..7051922f1 100644 --- a/packages/cli/tests/workspace-mounts.test.ts +++ b/packages/cli/tests/workspace-mounts.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { posix, win32 } from "node:path"; +import { join, posix, win32 } from "node:path"; import { parseWorkspaceMounts } from "../src/workspace-mounts.ts"; describe("workspace mount table", () => { @@ -53,21 +53,34 @@ describe("additional focused boundary 1", () => { import { runCli } from "@executablemd/test-support/launch"; import { useTempDirectory } from "@executablemd/test-support/temp"; -import { readdir } from "@effectionx/fs"; +import { exists } from "@effectionx/fs"; describe("additional focused boundary 2", () => { it("refuses invalid mount declarations before a history or terminal exists", function* () { const home = yield* useTempDirectory("mount-grammar-"); - const before = yield* readdir(home); + const data = yield* useTempDirectory("mount-grammar-data-"); for (const value of ["no-target", "source:/", "source:/bad/../target"]) { const result = yield* runCli(["repl", "--mount", value], { cwd: home, - env: { HOME: home }, + env: { HOME: home, XDG_DATA_HOME: data, LOCALAPPDATA: data }, inheritEnv: false, }).join(); expect(result.code).toBe(1); - expect(result.stderr).toContain("--mount"); + const parsed = parseWorkspaceMounts([value], home, posix); + expect(parsed.ok).toBe(false); + expect(result.stderr.trim().endsWith(parsed.ok ? "" : parsed.error.message)).toBe(true); + expect(result.stderr.includes(String.fromCharCode(27))).toBe(false); + expect(result.stdout).toBe(""); } - expect(yield* readdir(home)).toEqual(before); + // Child runtimes may populate their own caches; application data stays absent. + for (const root of [ + data, + join(home, ".local", "share"), + join(home, "Library", "Application Support"), + join(home, "AppData", "Local"), + ]) { + expect(yield* exists(join(root, "xmd"))).toBe(false); + } + expect(yield* exists(join(home, ".xmd"))).toBe(false); }); }); describe("additional focused boundary 3", () => { diff --git a/packages/runtime/files.ts b/packages/runtime/files.ts index 8a35425cc..cb0110f68 100644 --- a/packages/runtime/files.ts +++ b/packages/runtime/files.ts @@ -67,6 +67,7 @@ */ import type { Api } from "@effectionx/context-api"; +import type { FilesApi } from "./files-api.ts"; import { createFilesApi } from "./files-api.ts"; import type { Operation, Result } from "effection"; @@ -851,7 +852,7 @@ export function parseFileWriteSuccess(value: unknown): FileWriteSuccess | undefi * indistinguishable from an installed one, and the whole point of the boundary * is that a workflow run cannot silently touch the caller's filesystem. */ -const composedFiles = createFilesApi({ +const composedFiles: FilesApi = createFilesApi({ // deno-lint-ignore require-yield *checkFilePath(_input: FilePathInput): Operation> { throw new FilesProviderUnavailableError(); diff --git a/scripts/tests/jsr-consumer-documentation.test.ts b/scripts/tests/jsr-consumer-documentation.test.ts index 228d09642..1072c5257 100644 --- a/scripts/tests/jsr-consumer-documentation.test.ts +++ b/scripts/tests/jsr-consumer-documentation.test.ts @@ -62,6 +62,11 @@ function* publishedFiles(pkg: string): Operation { if (dry.timeout) { throw new Error(`deno publish --dry-run timed out for ${pkg}`); } + if (dry.value.code !== 0) { + throw new Error( + `deno publish --dry-run failed for ${pkg}:\n${dry.value.stdout}\n${dry.value.stderr}`, + ); + } const listed: string[] = []; for (const line of `${dry.value.stdout}\n${dry.value.stderr}`.split("\n")) { const trimmed = line.trim(); From 8d9b6a1d43fa424c7d8bc1ad3f71ee96b442cf56 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 21:16:07 -0400 Subject: [PATCH 5/6] Distinguish runtime diagnostic styles from REPL terminal controls --- packages/cli/tests/repl-boundaries.test.ts | 3 +++ packages/cli/tests/workspace-mounts.test.ts | 27 ++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index c08365ef3..951f8ebb6 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -806,6 +806,9 @@ describe("REPL command: what runs before the host is reached", () => { ["repl", "--include", ""], ["repl", "--include", "--deny-all"], ["repl", "xmd://nope"], + ["repl", "--mount", "no-target"], + ["repl", "--mount", "source:/"], + ["repl", "--mount", "source:/bad/../target"], ]) { const ran = yield* invoke(args); expect(ran.status).toBe(1); diff --git a/packages/cli/tests/workspace-mounts.test.ts b/packages/cli/tests/workspace-mounts.test.ts index 7051922f1..07595c276 100644 --- a/packages/cli/tests/workspace-mounts.test.ts +++ b/packages/cli/tests/workspace-mounts.test.ts @@ -54,21 +54,42 @@ describe("additional focused boundary 1", () => { import { runCli } from "@executablemd/test-support/launch"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { exists } from "@effectionx/fs"; +function withoutDiagnosticStyles(text: string): string { + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + describe("additional focused boundary 2", () => { + it("allows diagnostic styling while preserving terminal-control escapes", function* () { + const escape = String.fromCharCode(27); + expect(withoutDiagnosticStyles(`${escape}[0m${escape}[32mDownload${escape}[0m package`)).toBe( + "Download package", + ); + for (const control of ["[?1049h", "[?25l", "[2J", "[H", "]0;title\u0007"]) { + expect(withoutDiagnosticStyles(escape + control).includes(escape)).toBe(true); + } + }); it("refuses invalid mount declarations before a history or terminal exists", function* () { const home = yield* useTempDirectory("mount-grammar-"); const data = yield* useTempDirectory("mount-grammar-data-"); for (const value of ["no-target", "source:/", "source:/bad/../target"]) { const result = yield* runCli(["repl", "--mount", value], { cwd: home, - env: { HOME: home, XDG_DATA_HOME: data, LOCALAPPDATA: data }, + env: { + HOME: home, + XDG_DATA_HOME: data, + LOCALAPPDATA: data, + CI: "1", + FORCE_COLOR: "1", + TERM: "xterm-256color", + }, inheritEnv: false, }).join(); expect(result.code).toBe(1); const parsed = parseWorkspaceMounts([value], home, posix); expect(parsed.ok).toBe(false); - expect(result.stderr.trim().endsWith(parsed.ok ? "" : parsed.error.message)).toBe(true); - expect(result.stderr.includes(String.fromCharCode(27))).toBe(false); + const diagnostic = withoutDiagnosticStyles(result.stderr); + expect(diagnostic.trim().endsWith(parsed.ok ? "" : parsed.error.message)).toBe(true); + expect(diagnostic.includes(String.fromCharCode(27))).toBe(false); expect(result.stdout).toBe(""); } // Child runtimes may populate their own caches; application data stays absent. From e8fd4f09a0df2f03f4580cb04c3893793dc610ad Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 25 Oct 2026 23:53:54 -0400 Subject: [PATCH 6/6] Preserve routed mount coverage and projected failure output --- packages/cli/src/repl/mount-files.ts | 8 +- packages/cli/src/repl/mount-records.ts | 34 +++-- packages/cli/src/workspace-mounts.ts | 19 +-- .../cli/tests/retained-mount-files.test.ts | 120 +++++++++++++++++- .../cli/tests/retained-mount-records.test.ts | 54 ++++++++ packages/cli/tests/support/mount-run.ts | 29 +++-- packages/cli/tests/workspace-mounts.test.ts | 14 ++ packages/core/src/expand.ts | 82 ++++-------- 8 files changed, 269 insertions(+), 91 deletions(-) diff --git a/packages/cli/src/repl/mount-files.ts b/packages/cli/src/repl/mount-files.ts index 16c54f57d..e7f947984 100644 --- a/packages/cli/src/repl/mount-files.ts +++ b/packages/cli/src/repl/mount-files.ts @@ -31,6 +31,7 @@ import { import type { ReplLocalValue } from "./repositories/records.ts"; import { admitMountRecords, + validateMountCoverage, containedMountRead, mountAuthored, mountFingerprint, @@ -297,15 +298,12 @@ export function mountPolicy( ): Operation> { yield* guard(); const binding = bindingFor(a, request); - if ( - binding === undefined || - (a.operation === "dir-ensure" && coverage.kind !== "existing") || - (coverage.kind === "existing" && coverage.target !== binding.destination.actual) - ) { + if (binding === undefined || (a.operation === "dir-ensure" && coverage.kind !== "existing")) { return coverage.kind === "existing" ? yield* recordedLocalOperation({ operation: a.operation, request, perform }) : yield* perform(); } + validateMountCoverage(coverage, pathStyle, a.operation); const fingerprint = yield* mountFingerprint({ operation: a.operation, authored: mountAuthored(a), diff --git a/packages/cli/src/repl/mount-records.ts b/packages/cli/src/repl/mount-records.ts index d798aaa29..ae8798414 100644 --- a/packages/cli/src/repl/mount-records.ts +++ b/packages/cli/src/repl/mount-records.ts @@ -97,6 +97,28 @@ function within(style: "posix" | "win32", root: string, target: string): boolean host.resolve(root, relative) === host.resolve(target) ); } +export function validateMountCoverage( + coverage: MountCoverage, + pathStyle: "posix" | "win32", + operation: "file-read" | "dir-ensure", +): void { + if (coverage.kind === "external-mapped-read") { + if (operation !== "file-read") { + throw new MountRecordError(); + } + return; + } + const host = pathStyle === "win32" ? win32 : posix; + const normalized = (path: string) => + path.length > 0 && !path.includes("\0") && host.isAbsolute(path) && host.resolve(path) === path; + if ( + !normalized(coverage.root) || + !normalized(coverage.target) || + !within(pathStyle, coverage.root, coverage.target) + ) { + throw new MountRecordError(); + } +} export function containedMountRead( pathStyle: "posix" | "win32", cwd: string, @@ -211,17 +233,7 @@ export function* parseMountRecord(value: unknown, name?: string): Operation { diff --git a/packages/cli/src/workspace-mounts.ts b/packages/cli/src/workspace-mounts.ts index bdeb83bad..d66fc1170 100644 --- a/packages/cli/src/workspace-mounts.ts +++ b/packages/cli/src/workspace-mounts.ts @@ -39,16 +39,17 @@ export function parseWorkspaceMounts( return Err(new Error("--mount TARGET cannot be the document root /.")); } const destination = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; - if ( - pairs.some( - (pair) => - pair.target === destination || - pair.target.startsWith(destination + "/") || - destination.startsWith(pair.target + "/"), - ) - ) { + const conflicting = pairs.find( + (pair) => + pair.target === destination || + pair.target.startsWith(destination + "/") || + destination.startsWith(pair.target + "/"), + ); + if (conflicting !== undefined) { return Err( - new Error(`--mount TARGET ${JSON.stringify(destination)} overlaps another TARGET.`), + new Error( + `--mount TARGET ${JSON.stringify(destination)} overlaps ${JSON.stringify(conflicting.target)}.`, + ), ); } pairs.push( diff --git a/packages/cli/tests/retained-mount-files.test.ts b/packages/cli/tests/retained-mount-files.test.ts index 797492021..e7220c2d6 100644 --- a/packages/cli/tests/retained-mount-files.test.ts +++ b/packages/cli/tests/retained-mount-files.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { mountedRun, trace } from "./support/mount-run.ts"; -import { MOUNT_FILE } from "../src/repl/mount-records.ts"; +import { MOUNT_FILE, parseMountRecord } from "../src/repl/mount-records.ts"; import { parseReplLocalRecord, REPL_REPOSITORY_LOCAL } from "../src/repl/repositories/records.ts"; import { parseFilesFailure } from "@executablemd/runtime"; import type { FilePathInput } from "@executablemd/runtime"; @@ -498,3 +498,121 @@ describe("portable entry without a retention adapter", () => { } }); }); + +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensureDir } from "@effectionx/fs"; +import { until } from "effection"; +import { symlink, realpath } from "node:fs/promises"; +import { join } from "node:path"; + +describe("symlinked SOURCE association", () => { + it("keeps covered ensure/read associations and replays their own results after reassignment", function* () { + const temp = yield* useTempDirectory("mount-source-link-"); + const root = yield* until(realpath(temp)); + const owned = join(root, "owned"); + const alias = join(root, "alias"); + yield* ensureDir(join(owned, "old")); + yield* until(symlink(owned, alias, "dir")); + const stream = new InMemoryStream(); + const source = + 'Continue?{saved}'; + yield* mountedRun( + source, + stream, + [{ source: join(alias, "old"), target: "/reference" }], + trace(), + { + pause: true, + ownedFileCoverage: { root: alias }, + onRead: () => "historical", + }, + ); + const saved = yield* stream.readAll(); + const associations = saved.filter( + (event) => event.type === "yield" && event.description.type === MOUNT_FILE, + ); + expect(associations).toHaveLength(2); + for (const event of associations) { + if (event.type === "yield" && event.result.status === "ok") { + const record = yield* parseMountRecord(event.result.value, event.description.name); + expect(record.binding.selected.source).toBe(join(alias, "old")); + expect(record.coverage.kind).toBe("existing"); + if (record.coverage.kind === "existing") { + expect(record.coverage.root).toBe(owned); + expect(record.coverage.target.startsWith(owned)).toBe(true); + expect(record.coverage.target).not.toBe(record.binding.destination.actual); + } + } + } + const resumed = trace(); + const output = yield* mountedRun( + source, + stream, + [{ source: join(root, "missing-new"), target: "/reference" }], + resumed, + { + ownedFileCoverage: { root: alias }, + onRead: () => "current", + }, + ); + expect(resumed.ensures).toEqual([]); + expect(resumed.reads).toEqual([{ cwd: join(root, "missing-new"), path: "next.md" }]); + expect(output).toContain("historical"); + expect(output).toContain("current"); + }); +}); + +describe("mounted Dir output failure", () => { + it("keeps its rendered prefix just like the ordinary host", function* () { + const source = [ + "", + "", + '', + "", + "PREFIX", + "", + "```bash exec", + "FAIL", + "```", + "", + "AFTER", + "", + "", + "", + "", + ].join("\n"); + const outputs: string[] = []; + for (const ordinaryHost of [true, false]) { + let output = ""; + let failure: unknown; + try { + yield* mountedRun( + source, + new InMemoryStream(), + [{ source: "/external", target: "/reference" }], + trace(), + { + ordinaryHost, + before: function* () { + yield* API.Process.around({ + *exec() { + return { exitCode: 1, stdout: "", stderr: "stage failed" }; + }, + }); + }, + onOutput: (chunk) => { + output += chunk; + }, + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(output).toContain("PREFIX"); + expect(output).not.toContain("AFTER"); + outputs.push(output); + } + expect(outputs[1]).toBe(outputs[0]); + }); +}); diff --git a/packages/cli/tests/retained-mount-records.test.ts b/packages/cli/tests/retained-mount-records.test.ts index de156831f..8f111f156 100644 --- a/packages/cli/tests/retained-mount-records.test.ts +++ b/packages/cli/tests/retained-mount-records.test.ts @@ -393,3 +393,57 @@ describe("closed association admission", () => { expect(changed.reads).toEqual([]); }); }); + +describe("canonical coverage and lexical physical attribution", () => { + it("validates the saved router pair independently and still refuses malformed coverage", function* () { + const authored = mountAuthored( + authoredOperation("dir-ensure", "ensure", "/reference", { + authoredDirectory: "/host", + actualDirectory: "/host", + namespace: "host", + interpreting: true, + }), + ); + const request = { cwd: "/host", path: "/alias/old" }; + const record: MountRecord = { + version: 1, + operation: "dir-ensure", + authored, + authoredFingerprint: yield* mountFingerprint({ operation: "dir-ensure", authored }), + binding: { + pathStyle: "posix", + selected: { source: "/alias/old", target: "/reference" }, + enclosing: { + authoredDirectory: "/host", + actualDirectory: "/host", + namespace: "host", + pair: null, + }, + destination: { authored: "/reference", actual: "/alias/old" }, + }, + physical: replLocalRecordJson({ + operation: "dir-ensure", + request, + outcome: Ok(undefined), + fingerprint: yield* replLocalFingerprint({ operation: "dir-ensure", request }), + }), + coverage: { kind: "existing", owner: "session", root: "/owned", target: "/owned/old" }, + }; + + expect((yield* parseMountRecord(record)).coverage).toEqual(record.coverage); + for (const coverage of [ + { kind: "existing", owner: "session", root: "/owned", target: "/alias/old" }, + { kind: "existing", owner: "session", root: "/owned/../owned", target: "/owned/old" }, + { kind: "existing", owner: "session", root: "owned", target: "/owned/old" }, + { kind: "existing", owner: "session", root: "/owned", target: "/owned/old\0hidden" }, + ]) { + let refusal: unknown; + try { + yield* parseMountRecord({ ...record, coverage }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(MountRecordError); + } + }); +}); diff --git a/packages/cli/tests/support/mount-run.ts b/packages/cli/tests/support/mount-run.ts index 5944a4e41..28b6a6193 100644 --- a/packages/cli/tests/support/mount-run.ts +++ b/packages/cli/tests/support/mount-run.ts @@ -3,6 +3,7 @@ import type { Operation } from "effection"; import { API, cwd, fileWriteSuccess, filesFailure } from "@executablemd/runtime"; import type { FilePathInput } from "@executablemd/runtime"; import { collect, Elicitation, inlineSource } from "@executablemd/core"; +import { forEach } from "@effectionx/stream-helpers"; import { executeInstalled } from "@executablemd/core/host"; import type { DurableStream } from "@executablemd/durable-streams"; import { workspaceMountInstallation } from "../../src/repl/workspace-mount-installation.ts"; @@ -24,6 +25,8 @@ export function mountedRun( calls: MountTrace, options: { pause?: boolean; + ordinaryHost?: boolean; + onOutput?: (chunk: string) => void; entryCwd?: string; ownedFileCoverage?: { root: string }; refuseEnsure?: boolean; @@ -109,19 +112,29 @@ export function mountedRun( mounts, owner === undefined ? undefined : () => owner, ); - const execution = yield* executeInstalled({ ...inlineSource(source), includes: [], stream }, [ - owner === undefined - ? installation - : { - ...installation, - admissions: [admitReplRepositoryHistory, ...(installation.admissions ?? [])], - }, - ]); + const execution = yield* executeInstalled( + { ...inlineSource(source), includes: [], stream }, + options.ordinaryHost + ? [] + : [ + owner === undefined + ? installation + : { + ...installation, + admissions: [admitReplRepositoryHistory, ...(installation.admissions ?? [])], + }, + ], + ); if (options.pause) { yield* spawn(() => collect(execution)); yield* paused.operation; return "paused"; } + if (options.onOutput !== undefined) { + yield* forEach(function* (chunk: string) { + options.onOutput?.(chunk); + }, execution.output); + } return String(yield* collect(execution)); }); } diff --git a/packages/cli/tests/workspace-mounts.test.ts b/packages/cli/tests/workspace-mounts.test.ts index 07595c276..1fca69270 100644 --- a/packages/cli/tests/workspace-mounts.test.ts +++ b/packages/cli/tests/workspace-mounts.test.ts @@ -29,6 +29,20 @@ describe("workspace mount table", () => { for (const target of ["/a", "/a/b"]) { expect(parseWorkspaceMounts(["one:/a", `two:${target}`], "/work", posix).ok).toBe(false); } + for (const targets of [ + ["/a", "/a/b"], + ["/a/b", "/a"], + ["/a", "/a"], + ]) { + const first = targets[0]; + const second = targets[1]; + const result = parseWorkspaceMounts([`one:${first}`, `two:${second}`], "/work", posix); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain(`TARGET ${JSON.stringify(second)}`); + expect(result.error.message).toContain(`overlaps ${JSON.stringify(first)}`); + } + } expect(parseWorkspaceMounts(["C:\\manuals:/reference"], "C:\\work", win32)).toEqual({ ok: true, value: [{ source: "C:\\manuals", target: "/reference" }], diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index f8e208305..7cc50f82a 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -1,5 +1,3 @@ -import type { FilesystemExpansion } from "./filesystem-placement.ts"; -import { invokeFixedFilesystem, fixedFilesystemByFunction } from "./components/fixed-filesystem.ts"; /** * Expansion engine (spec §5). * @@ -15,6 +13,9 @@ import { invokeFixedFilesystem, fixedFilesystemByFunction } from "./components/f * middleware installation) execute before children's code blocks. */ +import type { FilesystemExpansion } from "./filesystem-placement.ts"; +import { invokeFixedFilesystem, fixedFilesystemByFunction } from "./components/fixed-filesystem.ts"; + import { all, ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result, Stream } from "effection"; import type { @@ -3399,6 +3400,25 @@ class ContentExpansionFailure extends ContentError { } } +function* projectSlot(handle: ProjectionHandle, mode: ErrorMode, name?: string): Operation { + const outcome = yield* handle.tryProject({ kind: "slot", name, mode }); + if (outcome.failure !== undefined) { + if (outcome.failure instanceof DocumentationError) { + throw new ContentExpansionFailure( + [outcome.failure.segment], + outcome.failure, + outcome.segments, + ); + } + throw outcome.failure; + } + const errors = errorSegments(outcome.segments); + if (errors.length > 0) { + throw new ContentExpansionFailure(errors, undefined, outcome.segments); + } + return renderSegments(outcome.segments); +} + function errorSegments(segments: Segment[]): ErrorSegment[] { return segments.filter((segment) => segment.type === "error"); } @@ -3710,35 +3730,7 @@ function* expandFunctionComponent( yield* provideRetain(siteEvalScope); yield* Component.around( { - *content([slotName], _next) { - // Asked for as an outcome, so a failure arrives with what the - // projection rendered before it. `content()` still presents the - // failure at the author's call — the difference is that the text - // is no longer lost when the component does not recover. - const outcome = yield* handle.tryProject({ - kind: "slot", - name: slotName, - mode: siteErrorMode, - }); - if (outcome.failure !== undefined) { - // A throwing error mode already decided this execution fails; the call - // site still sees the public shape, and the original failure - // travels as the cause so the boundary can restore it. - if (outcome.failure instanceof DocumentationError) { - throw new ContentExpansionFailure( - [outcome.failure.segment], - outcome.failure, - outcome.segments, - ); - } - throw outcome.failure; - } - const errors = errorSegments(outcome.segments); - if (errors.length > 0) { - throw new ContentExpansionFailure(errors, undefined, outcome.segments); - } - return renderSegments(outcome.segments); - }, + content: ([slotName]) => projectSlot(handle, siteErrorMode, slotName), // The element's shape, not its rendered result: content that renders // an empty string is still content. // deno-lint-ignore require-yield @@ -3836,19 +3828,7 @@ function* expandFunctionComponent( // one narrows nothing because it admitted nothing. environment: environment === undefined ? undefined : { ...environment, syntax }, }); - const outcome = yield* narrowed.tryProject({ - kind: "slot", - name: undefined, - mode: siteErrorMode, - }); - if (outcome.failure !== undefined) { - throw outcome.failure; - } - const errors = errorSegments(outcome.segments); - if (errors.length > 0) { - throw new ContentExpansionFailure(errors, undefined, outcome.segments); - } - return renderSegments(outcome.segments); + return yield* projectSlot(narrowed, siteErrorMode); }); let active = true; try { @@ -3886,19 +3866,7 @@ function* expandFunctionComponent( ...projectionState, filesystem: { ...filesystem, frame }, }); - const outcome = yield* projected.tryProject({ - kind: "slot", - name: undefined, - mode: siteErrorMode, - }); - if (outcome.failure !== undefined) { - throw outcome.failure; - } - const errors = errorSegments(outcome.segments); - if (errors.length > 0) { - throw new ContentExpansionFailure(errors, undefined, outcome.segments); - } - return renderSegments(outcome.segments); + return yield* projectSlot(projected, siteErrorMode); }, }); return yield* dispatchBody(direct ?? definition.fn(validatedProps, issued.invocation));