From da3804c9cbec8d968fd4fa74eb7dc9bdd1a0b8b1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 17 Sep 2026 13:13:47 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Publish=20Workflow=20e?= =?UTF-8?q?xtension=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three trusted host boundaries replace the private cross-feature coupling that `@executablemd/workflow` still carries, so a package outside it can state what its own run is, perform one Workspace-coordinated durable mutation, and read the Workspace without performing one — none of them holding the authority underneath. `createWorkflowRunInstallation(preparation)` is the generic constructor both existing installations are now built on. A host supplies the durable description, whether a successful record is required, how the run is allocated when nothing is recorded yet, and what a recorded run has to agree with; Workflow keeps the installation slot, root admission timing, the durable record parser and the current-run context. `createWorkflowWorkspaceEffect(database, description, mutate)` runs a mutation inside the existing lease, savepoint, capture/publication, journal enlistment and rollback boundary. The callback receives the Workspace filesystem, a storage view with parsed `get`/`all` reads and parameterized `run` writes, and a nested `savepoint()` bound to that transaction rather than resolved from the scope — no connection, lease, journal route or transaction token. `readWorkflowWorkspace(database, options, inspect)` opens the same authenticated short transaction for attachment and export without creating a durable effect. `options.rootId` names a retained root, which Workflow materializes inside a rollback-only savepoint and always takes back. Repository and Worktree attachment, ``'s retained-root export and ``'s export now go through it, so no source moving to `@executablemd/git` needs a private transaction, a root restoration or a writable filesystem to export a checkout. A read-only view is proven read-only rather than narrowed by its interface. `INSERT … RETURNING` is a statement that returns rows, so `all()` would run it and keep the insertion; the inspection's storage therefore compiles every statement under a SQLite authorizer admitting only `SQLITE_SELECT`, `SQLITE_READ` and `SQLITE_FUNCTION`, and an adapter without an authorizer is refused a read view rather than given one that cannot enforce what it says. Every capability handed to either callback is revoked when that callback returns, and each one that hands back an operation rechecks when the operation begins — the mutation's filesystem included, so a retained writer cannot write while the transaction is still capturing and publishing its root. Workflow also publishes the generic journaled-failure base and predicate that decide whether a failure is the effect's durable result or the run's, and names its Workspace filesystem types so a package outside this one can spell them. The repository and worktree mutations are reimplemented through these seams with the same SQL and the same journal output. The generated-fragment profile takes its directory write entry from a new captured `directory` option; `GeneratedEvaluationOptions.writes` keeps its released additive meaning. No public behavior changes: the same components, records, journal values, identities and write-table order as before. --- packages/workflow/deno.ts | 56 ++ packages/workflow/mod.ts | 10 + packages/workflow/src/deno/composition/add.ts | 8 +- .../workflow/src/deno/composition/commit.ts | 2 +- .../workflow/src/deno/composition/effects.ts | 37 +- .../src/deno/composition/materialize.ts | 7 +- .../src/deno/composition/operations.ts | 8 +- .../workflow/src/deno/composition/provider.ts | 10 +- .../src/deno/composition/pull-request.ts | 11 +- .../workflow/src/deno/composition/push.ts | 51 +- .../src/deno/composition/repository.ts | 10 +- .../workflow/src/deno/composition/switch.ts | 2 +- .../workflow/src/deno/composition/worktree.ts | 14 +- .../workflow/src/deno/workspace/effect.ts | 81 ++- .../workflow/src/deno/workspace/evaluate.ts | 63 +- packages/workflow/src/deno/workspace/files.ts | 6 +- packages/workflow/src/deno/workspace/guard.ts | 122 ++++ .../workflow/src/deno/workspace/inspect.ts | 100 +++ .../workflow/src/deno/workspace/private.ts | 50 +- .../src/deno/workspace/repositories.ts | 150 ++-- .../workflow/src/deno/workspace/storage.ts | 211 ++++++ packages/workflow/src/run.ts | 61 +- .../tests/generated-agent-component.test.ts | 94 ++- .../workflow/tests/public-entrypoint.test.ts | 45 ++ .../workflow/tests/support/composition.ts | 17 +- .../workflow/tests/support/git-crash-child.ts | 5 +- .../tests/support/workspace-crash-child.ts | 33 +- .../tests/support/workspace-restart-child.ts | 13 +- .../tests/workflow-fork-source.test.ts | 126 ++++ .../tests/workflow-run-storage.test.ts | 11 +- packages/workflow/tests/workflow-run.test.ts | 110 ++- .../workspace-effect-transaction.test.ts | 664 +++++++++++++++++- 32 files changed, 1963 insertions(+), 225 deletions(-) create mode 100644 packages/workflow/src/deno/workspace/guard.ts create mode 100644 packages/workflow/src/deno/workspace/inspect.ts create mode 100644 packages/workflow/src/deno/workspace/storage.ts diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index a00857af5..6d527ed7f 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -91,6 +91,62 @@ export { } from "./src/deno/composition/pull-request-reads.ts"; export type { GitHubPullRequestsOptions } from "./src/deno/composition/pull-request-reads.ts"; export { withWorkflowWorkspace } from "./src/deno/workspace/published.ts"; +/** + * One durable effect inside this run's Workspace transaction. + * + * The boundary a feature outside this package performs a Workspace-coordinated + * mutation through. It receives the authoritative filesystem and a storage view + * valid only while it runs; the lease, the transaction and savepoint, the root + * capture and publication, the journal enlistment and the rollback are this + * package's and are not projected through it. + */ +export { createWorkflowWorkspaceEffect } from "./src/deno/workspace/effect.ts"; +export type { + WorkflowWorkspaceMutation, + WorkflowWorkspaceTransaction, +} from "./src/deno/workspace/effect.ts"; +/** + * Reading the Workspace, at the current root or at one this run retains. + * + * The other half of the same boundary, for work that exports a checkout or + * proves a record still describes what is there. It journals nothing, publishes + * nothing, and can write neither bytes nor rows; a retained root is + * materialized inside a rollback-only savepoint this package owns and is always + * taken back. + */ +export { readWorkflowWorkspace } from "./src/deno/workspace/inspect.ts"; +export type { + WorkflowWorkspaceReadOptions, + WorkflowWorkspaceReads, + WorkflowWorkspaceSnapshot, +} from "./src/deno/workspace/inspect.ts"; +export type { + WorkflowWorkspaceParameter, + WorkflowWorkspaceReadStorage, + WorkflowWorkspaceRow, + WorkflowWorkspaceStorage, +} from "./src/deno/workspace/storage.ts"; +/** + * The Workspace filesystem a mutation writes through, under names a package + * outside this one can spell. + */ +export type { + DenoWorkspaceEntry as WorkflowWorkspaceEntry, + DenoWorkspaceFilesystem as WorkflowWorkspaceFilesystem, + DenoWorkspaceStat as WorkflowWorkspaceStat, +} from "./src/deno/workspace/filesystem.ts"; +/** + * Whether a failure is the effect's own durable outcome or the run failing. + * + * The base class is what a feature extends to declare that its refusal is + * publishable; the predicate is how that feature tells a Workspace condition it + * may journal from infrastructure it may not. Both are generic: neither knows + * what any feature's refusal means. + */ +export { + JournaledEffectFailure, + isJournalableWorkspaceFailure, +} from "./src/deno/workspace/errors.ts"; /** * What a host declares to the execution so an authored workflow document has * ``: its implementation names durable work after its own invocation, diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index 7f189a6cb..f3c98fdf1 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -69,6 +69,16 @@ export { } from "./src/git.ts"; export type { GitApi, GitObjectFormat } from "./src/git.ts"; export { getWorkflowRun, retainedWorkflowInstallation, workflowInstallation } from "./src/run.ts"; +/** + * How a trusted host states what its own run is. + * + * The generic constructor behind both installations above: a host supplies the + * durable description, whether a successful record is required, how the run is + * allocated when nothing is recorded yet, and what a recorded run has to agree + * with. Everything the run is then held to stays in this package. + */ +export { createWorkflowRunInstallation } from "./src/run.ts"; +export type { WorkflowRunPreparation } from "./src/run.ts"; export { workflowBundleInstallation, WorkflowBundleHistoryError } from "./src/bundle.ts"; export type { WorkflowRun } from "./src/run.ts"; export { isGitWorkflowRun, workflowRunValue } from "./src/journal.ts"; diff --git a/packages/workflow/src/deno/composition/add.ts b/packages/workflow/src/deno/composition/add.ts index f53ab87b5..3d3c86e63 100644 --- a/packages/workflow/src/deno/composition/add.ts +++ b/packages/workflow/src/deno/composition/add.ts @@ -117,12 +117,8 @@ export function* createGitAdd( paths: admitPathspecs(request.paths), }); - const outcome = yield* settled( - "git", - ADD, - database, - yield* describeAdd(admitted), - (filesystem, metadata) => performGitAdd({ filesystem, metadata }, host, admitted), + const outcome = yield* settled("git", ADD, database, yield* describeAdd(admitted), (context) => + performGitAdd(context, host, admitted), ); const result = parseGitAddResult(outcome, admitted); if (result === undefined || !placedCheckout(result.checkout, admitted.repository)) { diff --git a/packages/workflow/src/deno/composition/commit.ts b/packages/workflow/src/deno/composition/commit.ts index 765502bee..7bd9f99ec 100644 --- a/packages/workflow/src/deno/composition/commit.ts +++ b/packages/workflow/src/deno/composition/commit.ts @@ -284,7 +284,7 @@ export function* createGitCommit( COMMIT, database, yield* describeCommit(admitted), - (filesystem, metadata) => performGitCommit({ filesystem, metadata }, host, admitted, evidence), + (context) => performGitCommit(context, host, admitted, evidence), ); // Read for this request rather than merely read: a result whose checkout, // message evidence or object graph does not describe this invocation is not diff --git a/packages/workflow/src/deno/composition/effects.ts b/packages/workflow/src/deno/composition/effects.ts index efc86fc88..117441c26 100644 --- a/packages/workflow/src/deno/composition/effects.ts +++ b/packages/workflow/src/deno/composition/effects.ts @@ -22,11 +22,10 @@ import { } from "../../composition/errors.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; import { WorkflowStorageError } from "../../storage/errors.ts"; -import { savepoint } from "../transaction.ts"; -import { createWorkspaceEffect } from "../workspace/effect.ts"; +import { createWorkflowWorkspaceEffect } from "../workspace/effect.ts"; import { isJournalableWorkspaceFailure } from "../workspace/errors.ts"; import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; -import type { WorkspaceMetadata } from "../workspace/repositories.ts"; +import { createWorkspaceMetadata, type WorkspaceMetadata } from "../workspace/repositories.ts"; import { GitRefusal } from "./git.ts"; import { CompositionRefusal, @@ -66,6 +65,14 @@ export type CompositionOutcome = { readonly kind: "created"; readonly record: Js export interface MutationContext { readonly filesystem: DenoWorkspaceFilesystem; readonly metadata: WorkspaceMetadata; + /** + * This effect's own nested savepoint, for work an attempt may have to discard. + * + * Carried rather than resolved when it is needed: it belongs to the + * transaction this effect is performing inside, and a savepoint looked up + * from the scope would be whichever one the scope happened to be under. + */ + savepoint(body: Operation): Operation; } /** A Workspace that could not retain what native Git produced. */ @@ -121,13 +128,14 @@ export function parentOf(path: string): string { * document asked for. */ export function* attempted( + context: MutationContext, kind: RefusalKind, name: string, subject: string, work: Operation, ): Operation { try { - return created(yield* savepoint(work)); + return created(yield* context.savepoint(work)); } catch (error) { if (error instanceof GitRefusal) { return refused(kind, name, error.reason); @@ -152,12 +160,18 @@ function refused(kind: RefusalKind, name: string, reason: string): never { function* compositionEffect( database: WorkflowRunDatabase, description: EffectDescription, - perform: ( - filesystem: DenoWorkspaceFilesystem, - metadata: WorkspaceMetadata, - ) => Operation, + perform: (context: MutationContext) => Operation, ): Workflow { - return yield createWorkspaceEffect(database, description, perform); + // What these two tables mean is this subsystem's, so the rows are read and + // written here, through the generic storage view the transaction hands over. + // The savepoint travels with them because it belongs to the same transaction: + // an attempt discards its bytes and its rows together or discards neither. + return yield createWorkflowWorkspaceEffect( + database, + description, + ({ filesystem, storage, savepoint }) => + perform({ filesystem, metadata: createWorkspaceMetadata(storage), savepoint }), + ); } /** @@ -192,10 +206,7 @@ export function* settled( name: string, database: WorkflowRunDatabase, description: EffectDescription, - perform: ( - filesystem: DenoWorkspaceFilesystem, - metadata: WorkspaceMetadata, - ) => Operation, + perform: (context: MutationContext) => Operation, ): Operation { let value: unknown; try { diff --git a/packages/workflow/src/deno/composition/materialize.ts b/packages/workflow/src/deno/composition/materialize.ts index f048e8b13..8fc645b00 100644 --- a/packages/workflow/src/deno/composition/materialize.ts +++ b/packages/workflow/src/deno/composition/materialize.ts @@ -36,6 +36,7 @@ import { chmod, readFile, readlink, realpath, symlink, writeFile } from "node:fs import { RepositoryStaleStateError } from "../../composition/errors.ts"; import { canonicalWorkspacePath } from "../../composition/parse.ts"; import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; +import type { WorkflowWorkspaceReads } from "../workspace/inspect.ts"; /** Where the two administration files a linked worktree needs are written. */ const GITDIR_PREFIX = "gitdir: "; @@ -89,7 +90,7 @@ function* entry(path: string): Operation { * than missing: what matters is whether the entry the record names is there. */ export function* workspaceEntryPresent( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceReads, workspacePath: string, ): Operation { try { @@ -107,7 +108,7 @@ export function* workspaceEntryPresent( * exported read-only cannot be written into while the export is still running. */ function* exportEntry( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceReads, workspacePath: string, target: string, ): Operation { @@ -154,7 +155,7 @@ function* exportEntry( * would trust. */ export function* exportTree( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceReads, root: string, workspacePath: string, subject: string, diff --git a/packages/workflow/src/deno/composition/operations.ts b/packages/workflow/src/deno/composition/operations.ts index 958694c90..d163207c4 100644 --- a/packages/workflow/src/deno/composition/operations.ts +++ b/packages/workflow/src/deno/composition/operations.ts @@ -69,7 +69,8 @@ import { GitOperationInfrastructureError, } from "../../composition/errors.ts"; import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; -import type { StoredRepository, WorkspaceMetadata } from "../workspace/repositories.ts"; +import type { WorkflowWorkspaceReads } from "../workspace/inspect.ts"; +import type { StoredRepository, WorkspaceMetadataReads } from "../workspace/repositories.ts"; import { currentBranch, gitSession, @@ -204,7 +205,7 @@ function refuseAdmission(operation: string, reason: string): never { * on. */ export function selectGitCheckout( - metadata: WorkspaceMetadata, + metadata: WorkspaceMetadataReads, operation: string, request: GitOperationRequest, ): GitCheckoutSelection { @@ -394,7 +395,7 @@ export interface ExportedCheckouts { * holds the run's database open. */ export function* exportCheckoutFamily( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceReads, root: string, selection: GitCheckoutSelection, ): Operation { @@ -538,6 +539,7 @@ export function* performGitOperation( ): Operation { const selection = selectGitCheckout(context.metadata, operation, request); return yield* attempted( + context, "git", operation, selection.subject, diff --git a/packages/workflow/src/deno/composition/provider.ts b/packages/workflow/src/deno/composition/provider.ts index c70a0b89e..0c21abf51 100644 --- a/packages/workflow/src/deno/composition/provider.ts +++ b/packages/workflow/src/deno/composition/provider.ts @@ -60,8 +60,8 @@ import { import { GitOperationAdmissionError, RepositorySelectionError } from "../../composition/errors.ts"; import { selectionRegistry, type SelectionRegistry } from "../selections.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { transactWorkspaceRoots } from "../workspace/private.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import { readWorkflowWorkspace } from "../workspace/inspect.ts"; +import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; import { gitSession, type GitSession } from "./git.ts"; import { denoRepositoryHost, type RepositoryHost } from "./host.ts"; import type { GitAuthentication } from "./authentication.ts"; @@ -144,11 +144,13 @@ function* attach( database: WorkflowRunDatabase, host: RepositoryHost, subject: string, - prepare: (workspace: PrivateWorkspaceTransaction, root: string) => Operation, + prepare: (workspace: WorkflowWorkspaceSnapshot, root: string) => Operation, disagreement: (git: GitSession, attached: Attached) => Operation, ): Operation { const root = yield* host.useDirectory(); - const prepared = yield* transactWorkspaceRoots(database, (workspace) => prepare(workspace, root)); + const prepared = yield* readWorkflowWorkspace(database, {}, (workspace) => + prepare(workspace, root), + ); if (!prepared.ok) { throw prepared.error; } diff --git a/packages/workflow/src/deno/composition/pull-request.ts b/packages/workflow/src/deno/composition/pull-request.ts index aa85c8960..6fc2df824 100644 --- a/packages/workflow/src/deno/composition/pull-request.ts +++ b/packages/workflow/src/deno/composition/pull-request.ts @@ -75,7 +75,8 @@ import type { GitHostObservation, } from "../../git-host/records.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { transactWorkspaceRoots } from "../workspace/private.ts"; +import { readWorkflowWorkspace } from "../workspace/inspect.ts"; +import { readWorkspaceMetadata } from "../workspace/repositories.ts"; import { currentBranch, gitSession, resolveCommit } from "./git.ts"; import { denoGitHubSource, @@ -375,8 +376,12 @@ export function* upsertPullRequest( // Held open for the export alone. Everything after this reads files, and a // network round trip must never keep the run's database locked. - const prepared = yield* transactWorkspaceRoots(database, function* (workspace) { - const selection = selectGitCheckout(workspace.metadata, PULL_REQUEST_ELEMENT, admitted); + const prepared = yield* readWorkflowWorkspace(database, {}, function* (workspace) { + const selection = selectGitCheckout( + readWorkspaceMetadata(workspace.storage), + PULL_REQUEST_ELEMENT, + admitted, + ); return { selection, exported: yield* exportCheckoutFamily(workspace.filesystem, root, selection), diff --git a/packages/workflow/src/deno/composition/push.ts b/packages/workflow/src/deno/composition/push.ts index df3b0ce55..f1856743d 100644 --- a/packages/workflow/src/deno/composition/push.ts +++ b/packages/workflow/src/deno/composition/push.ts @@ -83,8 +83,8 @@ import { type GitHostObservation, } from "../../git-host/records.ts"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { transactWorkspaceRoots } from "../workspace/private.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import { readWorkflowWorkspace } from "../workspace/inspect.ts"; +import { readWorkspaceMetadata } from "../workspace/repositories.ts"; import { commitPresent, currentBranch, @@ -104,6 +104,7 @@ import { type ExportedCheckouts, type GitCheckout, type GitCheckoutSelection, + type GitOperationRequest, } from "./operations.ts"; import { gitRefusal } from "./refusals.ts"; @@ -218,18 +219,36 @@ function* retainedPushRoot( return selected; } -/** The checkout family this invocation names its request from, exported to `root`. */ -function* exportRequestSource( - workspace: PrivateWorkspaceTransaction, +/** + * The selection and the checkout family this invocation names its request from. + * + * One inspection, at the root this invocation is speaking from. A retained Push + * names the moment it published, and reading the Workspace as it was then — + * without the run returning to it — is the inspection boundary's own + * responsibility: it materializes that root, hands it to this callback, and + * rolls the materialization back however the callback ends. + * + * The rows the selection reads are the same either way. Retained Repository and + * Worktree identity is creation identity, which a Workspace root does not hold + * and a restoration does not move. + */ +function prepareRequestSource( + database: WorkflowRunDatabase, retained: string | undefined, root: string, - selection: GitCheckoutSelection, -): Operation { - const family = () => exportCheckoutFamily(workspace.filesystem, root, selection); - if (retained === undefined || retained === (yield* workspace.currentRoot())) { - return yield* family(); - } - return yield* workspace.readRetainedRoot(retained, family); + admitted: GitOperationRequest, +): Operation> { + return readWorkflowWorkspace( + database, + retained === undefined ? {} : { rootId: retained }, + function* (workspace) { + const selection = selectGitCheckout(readWorkspaceMetadata(workspace.storage), PUSH, admitted); + return { + selection, + exported: yield* exportCheckoutFamily(workspace.filesystem, root, selection), + }; + }, + ); } /** @@ -432,13 +451,7 @@ export function* createGitPush( // Held open for the export alone. Everything after this reads files, and a // remote round trip must never keep the run's database locked. - const prepared = yield* transactWorkspaceRoots(database, function* (workspace) { - const selection = selectGitCheckout(workspace.metadata, PUSH, admitted); - return { - selection, - exported: yield* exportRequestSource(workspace, retained, root, selection), - }; - }); + const prepared = yield* prepareRequestSource(database, retained, root, admitted); if (!prepared.ok) { throw prepared.error; } diff --git a/packages/workflow/src/deno/composition/repository.ts b/packages/workflow/src/deno/composition/repository.ts index e64c5d148..d61c49c5c 100644 --- a/packages/workflow/src/deno/composition/repository.ts +++ b/packages/workflow/src/deno/composition/repository.ts @@ -23,7 +23,8 @@ import { type RepositoryCreationRequest, type RepositoryRecord, } from "../../composition/records.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; +import { readWorkspaceMetadata } from "../workspace/repositories.ts"; import { checkoutPrimary, checkoutReadable, @@ -157,6 +158,7 @@ export function* performRepository( } return yield* attempted( + context, "repository", request.name, repositorySubject(request.name), @@ -165,12 +167,12 @@ export function* performRepository( } export function* prepareRepositoryAttachment( - workspace: PrivateWorkspaceTransaction, + workspace: WorkflowWorkspaceSnapshot, root: string, record: RepositoryRecord, subject: string, ): Operation { - const stored = workspace.metadata.readRepository(record.name); + const stored = readWorkspaceMetadata(workspace.storage).readRepository(record.name); if (stored === undefined || !sameRepositoryRecord(stored.record, record)) { throw stale(subject, "metadata"); } @@ -248,7 +250,7 @@ export function* createRepository( request.name, database, yield* describeRepository(request, admitted === undefined ? "" : locatorFingerprint(admitted)), - (filesystem, metadata) => performRepository({ filesystem, metadata }, host, request), + (context) => performRepository(context, host, request), ); const record = parseRepositoryRecord(outcome); if (record === undefined) { diff --git a/packages/workflow/src/deno/composition/switch.ts b/packages/workflow/src/deno/composition/switch.ts index ffdf95159..2064ffc9b 100644 --- a/packages/workflow/src/deno/composition/switch.ts +++ b/packages/workflow/src/deno/composition/switch.ts @@ -171,7 +171,7 @@ export function* createGitSwitch( SWITCH, database, yield* describeSwitch(admitted), - (filesystem, metadata) => performGitSwitch({ filesystem, metadata }, host, admitted), + (context) => performGitSwitch(context, host, admitted), ); // Read for this request rather than merely read: a result that does not // describe the checkout, branch, base and transition this invocation asked for diff --git a/packages/workflow/src/deno/composition/worktree.ts b/packages/workflow/src/deno/composition/worktree.ts index 3c26bd456..2b5a935b3 100644 --- a/packages/workflow/src/deno/composition/worktree.ts +++ b/packages/workflow/src/deno/composition/worktree.ts @@ -22,8 +22,8 @@ import { type WorktreeCreationRequest, type WorktreeRecord, } from "../../composition/records.ts"; -import type { StoredRepository } from "../workspace/repositories.ts"; -import type { PrivateWorkspaceTransaction } from "../workspace/private.ts"; +import { readWorkspaceMetadata, type StoredRepository } from "../workspace/repositories.ts"; +import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; import { addWorktree, checkoutReadable, @@ -181,6 +181,7 @@ export function* performWorktree( } return yield* attempted( + context, "worktree", request.name, worktreeSubject(request.name), @@ -189,16 +190,17 @@ export function* performWorktree( } export function* prepareWorktreeAttachment( - workspace: PrivateWorkspaceTransaction, + workspace: WorkflowWorkspaceSnapshot, root: string, record: WorktreeRecord, subject: string, ): Operation { - const stored = workspace.metadata.readWorktree(record.repositoryName, record.name); + const metadata = readWorkspaceMetadata(workspace.storage); + const stored = metadata.readWorktree(record.repositoryName, record.name); if (stored === undefined || !sameWorktreeRecord(stored, record)) { throw stale(subject, "metadata"); } - const repository = workspace.metadata.readRepository(record.repositoryName); + const repository = metadata.readRepository(record.repositoryName); if (repository === undefined) { throw stale(subject, "repository"); } @@ -269,7 +271,7 @@ export function* createWorktree( request.name, database, yield* describeWorktree(request), - (filesystem, metadata) => performWorktree({ filesystem, metadata }, host, request), + (context) => performWorktree(context, host, request), ); const record = parseWorktreeRecord(outcome); if (record === undefined) { diff --git a/packages/workflow/src/deno/workspace/effect.ts b/packages/workflow/src/deno/workspace/effect.ts index 5643751c5..9494cad37 100644 --- a/packages/workflow/src/deno/workspace/effect.ts +++ b/packages/workflow/src/deno/workspace/effect.ts @@ -21,7 +21,8 @@ import { currentWorkspaceRoot, retainedWorkspaceRoots } from "./root.ts"; import { savepoint } from "../transaction.ts"; import { isJournaledEffectFailure } from "./errors.ts"; import type { DenoWorkspaceFilesystem } from "./filesystem.ts"; -import type { WorkspaceMetadata } from "./repositories.ts"; +import { guardedWorkflowWorkspaceStorage, type WorkflowWorkspaceStorage } from "./storage.ts"; +import { gatedOperation, guardedWorkflowWorkspaceFilesystem, revocation } from "./guard.ts"; import { type PrivateWorkspaceTransaction, withPrivateWorkspaceTransaction, @@ -32,18 +33,46 @@ import { * What a Workspace mutation is given. * * The authoritative filesystem first, because most mutations are only about - * bytes. Retained Repository and Worktree identity follows it, in the same - * transaction, so a mutation that needs both commits both or neither. + * bytes. The run's own storage follows it, in the same transaction, so a + * mutation that needs both commits both or neither. + * + * Three members and no more. There is no database connection here, no lease, no + * journal route and no transaction token: what a feature owns is the meaning of + * the rows it writes, and everything that decides whether those rows may be + * written at all stays on Workflow's side of this call. */ -export type DenoWorkspaceMutation = ( - filesystem: DenoWorkspaceFilesystem, - metadata: WorkspaceMetadata, +export interface WorkflowWorkspaceTransaction { + readonly filesystem: DenoWorkspaceFilesystem; + /** + * This run's storage, for as long as the mutation that received it is running. + * + * Revoked when the callback returns, before the root is captured and + * published. A view a mutation kept is therefore an object that answers + * nothing rather than a way into the transaction that is still open around it. + */ + readonly storage: WorkflowWorkspaceStorage; + /** + * Run `body` inside a nested savepoint of this same transaction. + * + * What lets a mutation discard an attempt without discarding the effect. A + * failure rolls back everything the body wrote — bytes and rows together — + * and propagates, leaving this transaction open and still able to publish the + * failed result the attempt became. Bound to this transaction rather than + * resolved from the scope, so it is the mutation's own savepoint and ends + * with the mutation. + */ + savepoint(body: Operation): Operation; +} + +/** What a feature performs inside one Workspace effect's transaction. */ +export type WorkflowWorkspaceMutation = ( + transaction: WorkflowWorkspaceTransaction, ) => Operation; interface WorkspaceMutationApi { run( database: WorkflowRunDatabase, - mutate: DenoWorkspaceMutation, + mutate: WorkflowWorkspaceMutation, ): Operation; } @@ -59,7 +88,7 @@ const WorkspaceMutation: Api = createApi( _database: WorkflowRunDatabase, - _mutate: DenoWorkspaceMutation, + _mutate: WorkflowWorkspaceMutation, ): Operation { return unavailable(); }, @@ -130,12 +159,31 @@ function* runMutation( { *run([candidate, mutate]: [ WorkflowRunDatabase, - DenoWorkspaceMutation, + WorkflowWorkspaceMutation, ]): Operation { if (candidate !== database) { return unavailable(); } - return yield* mutate(workspace.filesystem, workspace.metadata); + // Ended on every path out, including a refusal that becomes this + // effect's failed durable outcome: the transaction stays open past + // this call to capture and publish a root, and a view that outlived + // the callback would still be inside it. + const gate = revocation(); + try { + return yield* mutate({ + // The filesystem is wrapped rather than handed over: it is the + // one capability here that writes, and a mutation that kept it + // would be holding a live writer inside a transaction that is + // still capturing and publishing a root. + filesystem: guardedWorkflowWorkspaceFilesystem(workspace.filesystem, gate.held), + storage: guardedWorkflowWorkspaceStorage(workspace.storage, gate.held), + savepoint(body: Operation): Operation { + return gatedOperation(gate.held, () => workspace.savepoint(body)); + }, + }); + } finally { + gate.revoke(); + } }, }, { at: "min" }, @@ -260,10 +308,19 @@ export function* workspaceRootSelection( }; } -export function createWorkspaceEffect( +/** + * One durable effect performed inside this run's Workspace transaction. + * + * The trusted boundary a feature outside this package reaches: it states what + * the effect is and what to do, and Workflow supplies the authenticated lease, + * the transaction and savepoint, the Workspace capture and publication, the + * journal enlistment and the rollback. A mutation that refuses leaves the + * Workspace root exactly where it found it. + */ +export function createWorkflowWorkspaceEffect( database: WorkflowRunDatabase, description: EffectDescription, - mutate: DenoWorkspaceMutation, + mutate: WorkflowWorkspaceMutation, ): DurableEffect { const execute = () => WorkspaceMutation.operations.run(database, mutate); const executionIdentity = Object.freeze({}); diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index 125935abc..98ba6036d 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -96,6 +96,16 @@ export interface GeneratedEvaluationOptions { readonly reads?: readonly FragmentEntry[]; /** Further mutation components this host admits beside the standard profile's. */ readonly writes?: readonly FragmentEntry[]; + /** + * The directory component this host admits, between core's write and delete. + * + * Its own member rather than one of the additive entries above, because it is + * not an addition: it occupies the position the standard profile has always + * had a directory entry in, and a continuation compares that table position by + * position. A host that captures none is admitted under the entry released + * builds retained, which this package still states. + */ + readonly directory?: FragmentEntry; } /** @@ -154,6 +164,35 @@ function workspaceFiles(database: WorkflowRunDatabase): FragmentFileAccess { }; } +/** + * The directory entry a host that captures none of its own is admitted under. + * + * Built from the same definition the ordinary registration owns, so the two + * cannot drift. Versioned in its revision because what the entry authorizes + * changed: the former `Dir` authorized placement that created nothing, and + * `` now recursively creates the directory it names. A continuation + * granted under the earlier revision must not silently receive the wider + * permission, and the retained comparison refuses it before generated + * execution. + * + * Revision 3: the grant is the workflow's, so the identity names this package. + * What changed from revision 2 is the operation behind it — the body is now + * closed over the `ensureDirectory` this profile handed over rather than + * resolving a Files provider when it runs — so a continuation granted under the + * older, composable one is refused rather than re-granted. + * + * The version-1 alias is the exact string released builds retained for this + * entry, written out rather than assembled: that is what those journals hold, + * and nothing derives it. The pre-`dir-v2` spelling is deliberately absent — it + * named the placement-only ``, which created nothing, so answering for it + * here would hand a narrower grant the wider one. + */ +function retainedDirectoryEntry(): FragmentEntry { + return directoryEntry({ origin: COMPOSITION_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ + "@executablemd/workflow/composition/dir-v2#Dir", + ]); +} + /** * The ceiling a workflow run's generated fragments are admitted under. * @@ -172,13 +211,6 @@ export function* evaluationProfile( // the workflow host installed, not whichever one a document later composes // around itself. const transport = yield* fetchAccess(); - // Built from the same definition the ordinary registration owns, so the two - // cannot drift. Versioned in its revision because what the entry authorizes - // changed: the former `Dir` authorized placement that created nothing, and - // `` now recursively creates the directory it names. A continuation - // granted under the earlier revision must not silently receive the wider - // permission, and the retained comparison refuses it before generated - // execution. const timeout = yield* timeoutFetch; return { read: [ @@ -188,22 +220,7 @@ export function* evaluationProfile( ], write: [ fileWriteEntry(), - // Revision 3: the grant is the workflow's, so the identity names this - // package. What changed from revision 2 is the operation behind it — the - // body is now closed over the `ensureDirectory` this profile handed over - // rather than resolving a Files provider when it runs — so a continuation - // granted under the older, composable one is refused rather than - // re-granted. - // - // The version-1 alias is the exact string released builds retained for - // this entry, written out rather than assembled: that is what those - // journals hold, and nothing derives it. The pre-`dir-v2` spelling is - // deliberately absent — it named the placement-only ``, which - // created nothing, so answering for it here would hand a narrower grant - // the wider one. - directoryEntry({ origin: COMPOSITION_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ - "@executablemd/workflow/composition/dir-v2#Dir", - ]), + options.directory ?? retainedDirectoryEntry(), fileDeleteEntry(), ...(options.writes ?? []), ], diff --git a/packages/workflow/src/deno/workspace/files.ts b/packages/workflow/src/deno/workspace/files.ts index 49ff0fd13..b01a17e1d 100644 --- a/packages/workflow/src/deno/workspace/files.ts +++ b/packages/workflow/src/deno/workspace/files.ts @@ -74,7 +74,7 @@ import type { import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; import { savepoint } from "../transaction.ts"; -import { createWorkspaceEffect } from "./effect.ts"; +import { createWorkflowWorkspaceEffect } from "./effect.ts"; import { journalableWorkspaceCode } from "./errors.ts"; import type { DenoWorkspaceFilesystem, DenoWorkspaceStat } from "./filesystem.ts"; import { @@ -306,7 +306,9 @@ function* fileEffect( description: EffectDescription, perform: (filesystem: DenoWorkspaceFilesystem) => Operation>, ): Workflow { - return yield createWorkspaceEffect(database, description, (filesystem) => perform(filesystem)); + return yield createWorkflowWorkspaceEffect(database, description, ({ filesystem }) => + perform(filesystem), + ); } /** diff --git a/packages/workflow/src/deno/workspace/guard.ts b/packages/workflow/src/deno/workspace/guard.ts new file mode 100644 index 000000000..b032b60f9 --- /dev/null +++ b/packages/workflow/src/deno/workspace/guard.ts @@ -0,0 +1,122 @@ +/** + * Holding a capability to the callback it was handed to. + * + * A Workspace transaction stays open after the mutation inside it returns — it + * goes on to capture a root, publish it and enlist the journal — and an + * inspection's transaction is open for the same reason. So a filesystem, a + * storage view or a savepoint that a callback kept is not merely a stale + * object: it is a live capability inside a transaction that is still running, + * and using it would write work no effect publishes and no journal records. + * + * Revocation is what makes "for this callback" a fact. One gate serves every + * projection built from one callback's argument, so the transaction ends all of + * them together rather than each object having a lifetime of its own. + * + * ## Why an operation is checked twice + * + * Most of these members do not do the work; they return an `Operation` that + * does it when something yields it. Checking only when the member is *called* + * would leave the obvious hole open: a callback creates the operations it wants + * while it is still valid, returns, and yields them afterwards. So a gated + * member checks when it is called — which fails fast, and is what a caller + * holding a revoked object sees — and the operation it returns checks again + * when its body begins. + */ + +import type { Operation } from "effection"; +import { WorkflowTransactionError } from "../../storage/errors.ts"; +import type { + DenoWorkspaceEntry, + DenoWorkspaceFilesystem, + DenoWorkspaceStat, +} from "./filesystem.ts"; + +/** The gate a revocable capability closes over. */ +export interface Revocation { + held(): void; + revoke(): void; +} + +export function revocation(): Revocation { + let open = true; + return { + held(): void { + if (!open) { + throw new WorkflowTransactionError( + "the Workspace view is no longer the one this callback was given. It is valid only " + + "while the callback that received it is running.", + ); + } + }, + revoke(): void { + open = false; + }, + }; +} + +function* gate(held: () => void, make: () => Operation): Operation { + held(); + return yield* make(); +} + +/** An operation this gate admits when it is created and again when it runs. */ +export function gatedOperation(held: () => void, make: () => Operation): Operation { + held(); + return gate(held, make); +} + +/** + * The read half of a Workspace filesystem. + * + * Its own type rather than the whole filesystem with a promise not to write: an + * inspection that could write would be a mutation whose work no effect + * publishes and no journal records. + */ +export interface WorkflowWorkspaceReads { + readFile(path: string): Operation; + readTextFile(path: string): Operation; + stat(path: string): Operation; + lstat(path: string): Operation; + readlink(path: string): Operation; + readdir(path: string): Operation; +} + +/** The reading members alone, each held to the callback that received them. */ +export function guardedWorkflowWorkspaceReads( + filesystem: WorkflowWorkspaceReads, + held: () => void, +): WorkflowWorkspaceReads { + return { + readFile: (path) => gatedOperation(held, () => filesystem.readFile(path)), + readTextFile: (path) => gatedOperation(held, () => filesystem.readTextFile(path)), + stat: (path) => gatedOperation(held, () => filesystem.stat(path)), + lstat: (path) => gatedOperation(held, () => filesystem.lstat(path)), + readlink: (path) => gatedOperation(held, () => filesystem.readlink(path)), + readdir: (path) => gatedOperation(held, () => filesystem.readdir(path)), + }; +} + +/** + * The whole filesystem, held to the mutation that received it. + * + * Every member, not only the writing ones. A read performed after the mutation + * returned would be reading a Workspace mid-publication, and answering it would + * make the boundary's own ordering observable. + */ +export function guardedWorkflowWorkspaceFilesystem( + filesystem: DenoWorkspaceFilesystem, + held: () => void, +): DenoWorkspaceFilesystem { + return { + ...guardedWorkflowWorkspaceReads(filesystem, held), + writeFile: (path, content, mode) => + gatedOperation(held, () => filesystem.writeFile(path, content, mode)), + mkdir: (path, options) => gatedOperation(held, () => filesystem.mkdir(path, options)), + remove: (path, options) => gatedOperation(held, () => filesystem.remove(path, options)), + rename: (from, to) => gatedOperation(held, () => filesystem.rename(from, to)), + chmod: (path, mode) => gatedOperation(held, () => filesystem.chmod(path, mode)), + symlink: (target, path) => gatedOperation(held, () => filesystem.symlink(target, path)), + link: (existingPath, newPath) => + gatedOperation(held, () => filesystem.link(existingPath, newPath)), + }; +} diff --git a/packages/workflow/src/deno/workspace/inspect.ts b/packages/workflow/src/deno/workspace/inspect.ts new file mode 100644 index 000000000..557975349 --- /dev/null +++ b/packages/workflow/src/deno/workspace/inspect.ts @@ -0,0 +1,100 @@ +/** + * Reading this run's Workspace without performing a durable effect. + * + * Some work has to look at retained bytes and retained rows without changing + * anything: exporting a checkout so a subprocess can run against files, or + * proving that what a record names is still there. That is not an effect — it + * journals nothing, publishes no root and has no result to replay — but it does + * need the same authority as one, because the bytes it reads are the run's. + * + * So this opens the run's ordinary authenticated transaction, hands the + * callback a snapshot that can only read, and closes it. The transaction is + * held for the inspection alone: everything a caller does afterwards runs + * against host files, so a Git subprocess never keeps the run's database open. + * + * "Can only read" is proven rather than promised. The filesystem is the six + * reading members and no others, and the storage view compiles every statement + * under a SQLite authorizer that refuses to admit anything but a read, so + * `INSERT … RETURNING` is rejected during compilation instead of returning the + * row it just wrote. Both are revoked when the callback returns, and both + * recheck when an operation they produced actually begins — a callback that + * built its writes early and yielded them late is the hole that closes. + * + * `rootId` is what makes a historical read possible. A run sometimes has to + * name what it published at an earlier moment, and the Workspace has moved on + * since — so the retained root is materialized inside a rollback-only + * savepoint, the callback reads it, and everything that materialization wrote + * is discarded. The current root, the retained roots, the rows and the journal + * are what they were on every path out, including a failure and a + * cancellation. Nothing here publishes, and there is no ordering in which + * anything else observes the Workspace selected at the historical root. + */ + +import type { Operation, Result } from "effection"; +import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import { guardedWorkflowWorkspaceReads, revocation, type WorkflowWorkspaceReads } from "./guard.ts"; +import { transactWorkspaceRoots } from "./private.ts"; +import { + guardedWorkflowWorkspaceReadStorage, + type WorkflowWorkspaceReadStorage, +} from "./storage.ts"; + +export type { WorkflowWorkspaceReads } from "./guard.ts"; + +/** + * What one inspection is given. + * + * The two reading surfaces and nothing else: no publication, no restoration, no + * connection, no lease, no journal route and no transaction token. Which root + * it is reading was decided before the callback began. + */ +export interface WorkflowWorkspaceSnapshot { + readonly filesystem: WorkflowWorkspaceReads; + readonly storage: WorkflowWorkspaceReadStorage; +} + +/** Which root an inspection reads. */ +export interface WorkflowWorkspaceReadOptions { + /** + * The retained root to read, or the current one when omitted. + * + * Named rather than searched for: a root this run does not retain is refused + * by the same restoration that a resumed run is held to, and the root the + * Workspace is on afterwards is the one it was on before. + */ + readonly rootId?: string; +} + +/** + * Read this run's Workspace, at the current root or at one it retains. + * + * Answers with what `inspect` answered, inside the run's own short transaction. + * A failure of the transaction is the `Result`'s, exactly as every other + * transacted read hands one back. + */ +export function* readWorkflowWorkspace( + database: WorkflowRunDatabase, + options: WorkflowWorkspaceReadOptions, + inspect: (snapshot: WorkflowWorkspaceSnapshot) => Operation, +): Operation> { + const wanted = options.rootId; + return yield* transactWorkspaceRoots(database, function* (workspace) { + const gate = revocation(); + const snapshot: WorkflowWorkspaceSnapshot = { + filesystem: guardedWorkflowWorkspaceReads(workspace.filesystem, gate.held), + storage: guardedWorkflowWorkspaceReadStorage(workspace.reads, gate.held), + }; + const read = () => inspect(snapshot); + try { + // The current root is read directly. Materializing it would be a + // restoration of the root the Workspace is already on — work with an + // outcome identical to doing nothing, and a rollback to undo it. + if (wanted === undefined || wanted === (yield* workspace.currentRoot())) { + return yield* read(); + } + return yield* workspace.readRetainedRoot(wanted, read); + } finally { + gate.revoke(); + } + }); +} diff --git a/packages/workflow/src/deno/workspace/private.ts b/packages/workflow/src/deno/workspace/private.ts index 637045308..67a2dac08 100644 --- a/packages/workflow/src/deno/workspace/private.ts +++ b/packages/workflow/src/deno/workspace/private.ts @@ -4,7 +4,12 @@ import type { WorkflowRunDatabase, WorkflowRunTransaction } from "../../storage/ import { WorkflowTransactionError } from "../../storage/errors.ts"; import type { WorkflowRunConnections, WorkflowRunTransactionToken } from "../connections.ts"; import { createDenoWorkspaceFilesystem, type DenoWorkspaceFilesystem } from "./filesystem.ts"; -import { createWorkspaceMetadata, type WorkspaceMetadata } from "./repositories.ts"; +import { + createWorkflowWorkspaceReadStorage, + createWorkflowWorkspaceStorage, + type WorkflowWorkspaceReadStorage, + type WorkflowWorkspaceStorage, +} from "./storage.ts"; import { createAgentSessions, type AgentSessions } from "./agent-sessions.ts"; import { createAgentPromptCheckpoints, type AgentPromptCheckpoints } from "./agent-checkpoints.ts"; import { type StoredWorkspaceRoot } from "./manifest.ts"; @@ -20,20 +25,34 @@ import { restoreWorkspaceRoot, type RestoreWorkspaceRootOptions } from "./restor export interface PrivateWorkspaceTransaction { readonly filesystem: DenoWorkspaceFilesystem; /** - * Retained Repository and Worktree identity, inside this same transaction. + * The run's own storage, inside this same transaction. * * Beside the filesystem rather than behind a second boundary, because a * checkout's bytes and the row that names it are one fact. Retaining either * without the other would leave a run whose history describes a Repository it * does not have, or holds one it never recorded. + * + * Generic on purpose. Which rows exist and what they mean belongs to the + * feature that writes them; what this transaction owns is the lease, the + * savepoint and the journal every one of those writes goes through. + */ + readonly storage: WorkflowWorkspaceStorage; + /** + * The same rows, through a view SQLite refuses to let write. + * + * Beside the writing one rather than derived from it, because what makes a + * read a read is not which members an interface offers. `INSERT … RETURNING` + * is a statement that returns rows, so a view that merely lacked `run` would + * still write through `all()`; this one compiles every statement under an + * authorizer that refuses anything but reading. */ - readonly metadata: WorkspaceMetadata; + readonly reads: WorkflowWorkspaceReadStorage; /** * The Agent sessions this run retains, inside this same transaction. * - * Beside the filesystem for the same reason the metadata is: a conversation - * and the row naming it are one fact, and a mapping that could commit while - * the run did not would describe a session this run never had. + * Beside the filesystem for the same reason the storage view is: a + * conversation and the row naming it are one fact, and a mapping that could + * commit while the run did not would describe a session this run never had. */ readonly agentSessions: AgentSessions; /** @@ -52,6 +71,16 @@ export interface PrivateWorkspaceTransaction { * position and calling it identity. */ appendedEventIds(): readonly string[]; + /** + * Run `body` inside a nested savepoint of *this* transaction. + * + * Bound to the transaction this object describes rather than resolved from + * the scope. The ambient `savepoint()` answers with whichever transaction's + * savepoint manager is installed where it is called, which is the right + * answer for code running inside one transaction and the wrong one for a + * closure a caller kept — so what is handed across a boundary is this. + */ + savepoint(body: Operation): Operation; currentRoot(): Operation; capture(options?: CaptureWorkspaceRootOptions): Operation; publish(rootId: string): Operation; @@ -165,7 +194,9 @@ export function usePrivateWorkspace( const workspace: PrivateWorkspaceTransaction = { filesystem: decorate(createDenoWorkspaceFilesystem(connection, authorize)), - metadata: createWorkspaceMetadata(connection.database, authorize), + storage: createWorkflowWorkspaceStorage(connection.database, authorize), + + reads: createWorkflowWorkspaceReadStorage(connection.database, authorize), agentSessions: createAgentSessions(connection.database, authorize), @@ -176,6 +207,11 @@ export function usePrivateWorkspace( return [...active.appended]; }, + savepoint(body: Operation): Operation { + authorize(); + return connection.savepoints.operation(active, body); + }, + // deno-lint-ignore require-yield *currentRoot(): Operation { authorize(); diff --git a/packages/workflow/src/deno/workspace/repositories.ts b/packages/workflow/src/deno/workspace/repositories.ts index 82befb9a7..0a6fdbfe1 100644 --- a/packages/workflow/src/deno/workspace/repositories.ts +++ b/packages/workflow/src/deno/workspace/repositories.ts @@ -18,7 +18,6 @@ * recover, and it is reported through the same channel schema recognition uses. */ -import type { DatabaseSync } from "node:sqlite"; import { WorkflowRecordMalformedError } from "../../storage/errors.ts"; import { parseCheckoutPath, @@ -28,7 +27,11 @@ import { type RepositoryRecord, type WorktreeRecord, } from "../../composition/records.ts"; -import { reading } from "../reading.ts"; +import type { + WorkflowWorkspaceReadStorage, + WorkflowWorkspaceRow, + WorkflowWorkspaceStorage, +} from "./storage.ts"; /** A Repository row: its journal-safe record, and the locator only storage sees. */ export interface StoredRepository { @@ -62,7 +65,7 @@ function malformed(table: string, column: string, expectation: string): never { throw new WorkflowRecordMalformedError(`${table}.${column}`, expectation); } -function text(row: Record, column: string, table: string): string { +function text(row: WorkflowWorkspaceRow, column: string, table: string): string { const value = row[column]; if (typeof value !== "string" || value === "") { return malformed(table, column, "expected a non-empty text value"); @@ -70,7 +73,7 @@ function text(row: Record, column: string, table: string): stri return value; } -function optionalText(row: Record, column: string, table: string): string | null { +function optionalText(row: WorkflowWorkspaceRow, column: string, table: string): string | null { const value = row[column]; if (value === null || value === undefined) { return null; @@ -81,11 +84,7 @@ function optionalText(row: Record, column: string, table: strin return value; } -function objectFormat( - row: Record, - column: string, - table: string, -): GitObjectFormat { +function objectFormat(row: WorkflowWorkspaceRow, column: string, table: string): GitObjectFormat { const value = parseObjectFormat(row[column]); if (value === undefined) { return malformed(table, column, `expected "sha1" or "sha256"`); @@ -93,7 +92,7 @@ function objectFormat( return value; } -function fingerprint(row: Record, column: string, table: string): string { +function fingerprint(row: WorkflowWorkspaceRow, column: string, table: string): string { const value = parseFingerprint(row[column]); if (value === undefined) { return malformed(table, column, "expected a 64-character lowercase hex fingerprint"); @@ -101,7 +100,7 @@ function fingerprint(row: Record, column: string, table: string return value; } -function checkoutPath(row: Record, column: string, table: string): string { +function checkoutPath(row: WorkflowWorkspaceRow, column: string, table: string): string { const value = parseCheckoutPath(row[column]); if (value === undefined) { return malformed(table, column, "expected a Workspace-relative path beginning with /"); @@ -109,7 +108,7 @@ function checkoutPath(row: Record, column: string, table: strin return value; } -function readRepositoryRow(row: Record): StoredRepository { +function readRepositoryRow(row: WorkflowWorkspaceRow): StoredRepository { const table = "workspace_repositories"; return Object.freeze({ locator: text(row, "locator", table), @@ -125,7 +124,7 @@ function readRepositoryRow(row: Record): StoredRepository { }); } -function readWorktreeRow(row: Record): WorktreeRecord { +function readWorktreeRow(row: WorkflowWorkspaceRow): WorktreeRecord { const table = "workspace_worktrees"; return Object.freeze({ repositoryName: text(row, "repository_name", table), @@ -137,62 +136,62 @@ function readWorktreeRow(row: Record): WorktreeRecord { }); } -export function readRepository(database: DatabaseSync, name: string): StoredRepository | undefined { - const row = reading(database, SELECT_REPOSITORY).get(name); +export function readRepository( + storage: WorkflowWorkspaceReadStorage, + name: string, +): StoredRepository | undefined { + const row = storage.get(SELECT_REPOSITORY, name); return row === undefined ? undefined : readRepositoryRow(row); } -export function readRepositories(database: DatabaseSync): StoredRepository[] { - return reading(database, SELECT_REPOSITORIES) - .all() - .map((row) => readRepositoryRow(row)); +export function readRepositories(storage: WorkflowWorkspaceReadStorage): StoredRepository[] { + return storage.all(SELECT_REPOSITORIES).map((row) => readRepositoryRow(row)); } -export function insertRepository(database: DatabaseSync, stored: StoredRepository): void { +export function insertRepository( + storage: WorkflowWorkspaceStorage, + stored: StoredRepository, +): void { const { record } = stored; - database - .prepare(INSERT_REPOSITORY) - .run( - record.name, - stored.locator, - record.locatorFingerprint, - record.requestedBase, - record.creationCommit, - record.primaryBranch, - record.objectFormat, - record.checkoutPath, - ); + storage.run( + INSERT_REPOSITORY, + record.name, + stored.locator, + record.locatorFingerprint, + record.requestedBase, + record.creationCommit, + record.primaryBranch, + record.objectFormat, + record.checkoutPath, + ); } export function readWorktree( - database: DatabaseSync, + storage: WorkflowWorkspaceReadStorage, repositoryName: string, name: string, ): WorktreeRecord | undefined { - const row = reading(database, SELECT_WORKTREE).get(repositoryName, name); + const row = storage.get(SELECT_WORKTREE, repositoryName, name); return row === undefined ? undefined : readWorktreeRow(row); } export function readWorktreesForRepository( - database: DatabaseSync, + storage: WorkflowWorkspaceReadStorage, repositoryName: string, ): WorktreeRecord[] { - return reading(database, SELECT_WORKTREES) - .all(repositoryName) - .map((row) => readWorktreeRow(row)); + return storage.all(SELECT_WORKTREES, repositoryName).map((row) => readWorktreeRow(row)); } -export function insertWorktree(database: DatabaseSync, record: WorktreeRecord): void { - database - .prepare(INSERT_WORKTREE) - .run( - record.repositoryName, - record.name, - record.requestedBranch, - record.requestedBase, - record.creationCommit, - record.checkoutPath, - ); +export function insertWorktree(storage: WorkflowWorkspaceStorage, record: WorktreeRecord): void { + storage.run( + INSERT_WORKTREE, + record.repositoryName, + record.name, + record.requestedBranch, + record.requestedBase, + record.creationCommit, + record.checkoutPath, + ); } /** @@ -203,32 +202,49 @@ export function insertWorktree(database: DatabaseSync, record: WorktreeRecord): * surface and not a document's: a component reaches it only by asking the * composition provider to perform an effect. */ -export interface WorkspaceMetadata { +export interface WorkspaceMetadataReads { readRepository(name: string): StoredRepository | undefined; readRepositories(): StoredRepository[]; - insertRepository(stored: StoredRepository): void; readWorktree(repositoryName: string, name: string): WorktreeRecord | undefined; readWorktreesForRepository(repositoryName: string): WorktreeRecord[]; - insertWorktree(record: WorktreeRecord): void; } -export function createWorkspaceMetadata( - database: DatabaseSync, - authorize: () => void, -): WorkspaceMetadata { - function guarded(read: () => T): T { - authorize(); - return read(); - } +export interface WorkspaceMetadata extends WorkspaceMetadataReads { + insertRepository(stored: StoredRepository): void; + insertWorktree(record: WorktreeRecord): void; +} +/** + * These tables, read through a storage view that may only read. + * + * What attachment and export need, and the whole of it. Selecting a checkout + * and proving a record still describes one are questions about rows that + * already exist; neither writes, and neither is inside an effect that could + * publish a row if it did. + */ +export function readWorkspaceMetadata( + storage: WorkflowWorkspaceReadStorage, +): WorkspaceMetadataReads { return { - readRepository: (name) => guarded(() => readRepository(database, name)), - readRepositories: () => guarded(() => readRepositories(database)), - insertRepository: (stored) => guarded(() => insertRepository(database, stored)), - readWorktree: (repositoryName, name) => - guarded(() => readWorktree(database, repositoryName, name)), + readRepository: (name) => readRepository(storage, name), + readRepositories: () => readRepositories(storage), + readWorktree: (repositoryName, name) => readWorktree(storage, repositoryName, name), readWorktreesForRepository: (repositoryName) => - guarded(() => readWorktreesForRepository(database, repositoryName)), - insertWorktree: (record) => guarded(() => insertWorktree(database, record)), + readWorktreesForRepository(storage, repositoryName), + }; +} + +/** + * These tables, read and written through one Workspace transaction's storage. + * + * The view is the whole of what this needs: the lease, the transaction, the + * savepoint and the journal are already around every call it makes, so what is + * left here is the SQL for these two tables and the parsers for their columns. + */ +export function createWorkspaceMetadata(storage: WorkflowWorkspaceStorage): WorkspaceMetadata { + return { + ...readWorkspaceMetadata(storage), + insertRepository: (stored) => insertRepository(storage, stored), + insertWorktree: (record) => insertWorktree(storage, record), }; } diff --git a/packages/workflow/src/deno/workspace/storage.ts b/packages/workflow/src/deno/workspace/storage.ts new file mode 100644 index 000000000..a06f503eb --- /dev/null +++ b/packages/workflow/src/deno/workspace/storage.ts @@ -0,0 +1,211 @@ +/** + * The storage one Workspace transaction or inspection may reach. + * + * A generic view rather than a table-shaped one. Workflow owns the database, + * the executor lease, the transaction and the journal; what a feature owns is + * the meaning of its own rows, and this is the narrowest surface that lets it + * keep that meaning without holding any of the rest. Statements are the + * caller's, parameters are bound rather than interpolated, and a read comes + * back as the stored row for the caller's own parser to read — nothing here + * knows what a column means. + * + * ## Why a read view is not merely a view without `run` + * + * Because SQLite does not agree that `get` and `all` only read. `INSERT INTO + * workspace_repositories (…) VALUES (…) RETURNING name` is a statement that + * returns rows, and `StatementSync.all()` runs it and commits the insertion. + * The same is true of `UPDATE … RETURNING` and `DELETE … RETURNING`. Removing + * `run` from an interface removes a spelling, not a capability. + * + * So a read view proves the statement reads before it executes, and SQLite is + * what proves it. Each read compiles under an authorizer that admits + * `SQLITE_SELECT`, `SQLITE_READ` and `SQLITE_FUNCTION` and refuses every other + * action, so an insertion, an update, a deletion, a schema change or a pragma + * is rejected during compilation — before a row is touched. A lexical check for + * a leading `SELECT` would be the wrong tool twice over: it would admit + * `WITH x AS (INSERT …)` and it would be a parser this package would then own. + * + * The authorizer is a property of the connection, so it is installed around one + * read and removed in a `finally`. Nothing can run between those two points: a + * read is synchronous from the call to the returned rows, and this runtime does + * not interleave another coroutine inside it. It stays installed across + * execution as well as compilation, because SQLite recompiles a statement by + * itself when the schema changes underneath it, and a recompilation is exactly + * the moment the check must still be there. + * + * An adapter that offers no authorizer cannot support a read view, and this + * refuses to build one rather than handing back a view that would allow what it + * says it forbids. + * + * Every call re-authorizes against the transaction as well. The view is built + * inside an active transaction and closes over that transaction's own check, so + * a retained view whose transaction has ended, whose lease has moved, or whose + * database is not the one that issued it refuses rather than reaching SQLite. + */ + +import { constants, type DatabaseSync, type StatementSync } from "node:sqlite"; +import { WorkflowTransactionError } from "../../storage/errors.ts"; +import { reading } from "../reading.ts"; + +/** A value SQLite binds to one statement parameter. */ +export type WorkflowWorkspaceParameter = null | number | bigint | string | Uint8Array; + +/** + * One stored row, exactly as it is stored. + * + * `unknown` per column on purpose: a caller that knows what the column means + * parses it, and one that does not cannot mistake a cast for a check. Integers + * arrive as `bigint`, so a value outside JavaScript's safe range reaches the + * parser that has to refuse it instead of raising inside the read. + */ +export type WorkflowWorkspaceRow = Readonly>; + +/** The reads an inspection performs against this run's storage. */ +export interface WorkflowWorkspaceReadStorage { + get( + sql: string, + ...parameters: readonly WorkflowWorkspaceParameter[] + ): WorkflowWorkspaceRow | undefined; + all(sql: string, ...parameters: readonly WorkflowWorkspaceParameter[]): WorkflowWorkspaceRow[]; +} + +/** The same reads, and the writes a durable mutation performs beside them. */ +export interface WorkflowWorkspaceStorage extends WorkflowWorkspaceReadStorage { + run(sql: string, ...parameters: readonly WorkflowWorkspaceParameter[]): void; +} + +function sqliteConstant(name: string): number | undefined { + const value = Reflect.get(constants, name); + return typeof value === "number" ? value : undefined; +} + +/** The actions compiling a read performs, and the answer to everything else. */ +const READ_ACTIONS: ReadonlySet = new Set( + ["SQLITE_SELECT", "SQLITE_READ", "SQLITE_FUNCTION"].flatMap((name) => { + const action = sqliteConstant(name); + return action === undefined ? [] : [action]; + }), +); + +const SQLITE_OK = sqliteConstant("SQLITE_OK"); +const SQLITE_DENY = sqliteConstant("SQLITE_DENY"); + +function unsupported(): never { + throw new WorkflowTransactionError( + "this Deno node:sqlite adapter cannot prove a statement only reads, so there is no " + + "read-only Workspace view to give. A view that could not refuse a write would allow " + + "exactly what it says it forbids.", + ); +} + +/** + * Compile and run one statement with writing refused. + * + * The authorizer is removed however the read ends, so a failure inside it + * leaves the connection the way every other caller expects to find it. + */ +function readOnly(database: DatabaseSync, use: () => T): T { + const install = Reflect.get(database, "setAuthorizer"); + if (typeof install !== "function" || SQLITE_OK === undefined || SQLITE_DENY === undefined) { + return unsupported(); + } + Reflect.apply(install, database, [ + (action: number) => (READ_ACTIONS.has(action) ? SQLITE_OK : SQLITE_DENY), + ]); + try { + return use(); + } finally { + Reflect.apply(install, database, [null]); + } +} + +function readingStatement(database: DatabaseSync, sql: string): StatementSync { + return reading(database, sql); +} + +/** A view of this run's storage that may only read, valid while `authorize` says so. */ +export function createWorkflowWorkspaceReadStorage( + database: DatabaseSync, + authorize: () => void, +): WorkflowWorkspaceReadStorage { + return { + get(sql, ...parameters) { + authorize(); + return readOnly(database, () => readingStatement(database, sql).get(...parameters)); + }, + + all(sql, ...parameters) { + authorize(); + return readOnly(database, () => readingStatement(database, sql).all(...parameters)); + }, + }; +} + +/** A view of this run's storage, valid for as long as `authorize` says it is. */ +export function createWorkflowWorkspaceStorage( + database: DatabaseSync, + authorize: () => void, +): WorkflowWorkspaceStorage { + return { + get(sql, ...parameters) { + authorize(); + return readingStatement(database, sql).get(...parameters); + }, + + all(sql, ...parameters) { + authorize(); + return readingStatement(database, sql).all(...parameters); + }, + + run(sql, ...parameters) { + authorize(); + database.prepare(sql).run(...parameters); + }, + }; +} + +/** + * The same storage, ended when the callback it was made for returns. + * + * Synchronous throughout, so a gate checked at the call is a gate checked at + * execution — unlike the filesystem beside it, which hands back operations. + */ +export function guardedWorkflowWorkspaceStorage( + storage: WorkflowWorkspaceStorage, + held: () => void, +): WorkflowWorkspaceStorage { + return { + get(sql, ...parameters) { + held(); + return storage.get(sql, ...parameters); + }, + + all(sql, ...parameters) { + held(); + return storage.all(sql, ...parameters); + }, + + run(sql, ...parameters) { + held(); + storage.run(sql, ...parameters); + }, + }; +} + +/** The reading half of the same storage, held to its own callback. */ +export function guardedWorkflowWorkspaceReadStorage( + storage: WorkflowWorkspaceReadStorage, + held: () => void, +): WorkflowWorkspaceReadStorage { + return { + get(sql, ...parameters) { + held(); + return storage.get(sql, ...parameters); + }, + + all(sql, ...parameters) { + held(); + return storage.all(sql, ...parameters); + }, + }; +} diff --git a/packages/workflow/src/run.ts b/packages/workflow/src/run.ts index a30e9ef7a..94a7d6b62 100644 --- a/packages/workflow/src/run.ts +++ b/packages/workflow/src/run.ts @@ -44,9 +44,9 @@ * and may still reject the history this admits; none of them can widen it. * * The two installations differ in what they require, not in how strictly it is - * enforced. See `RunHistoryRules`: a base that would not resolve is recorded as - * a failed effect (§6), so a programmatic run replays that failure rather than - * demanding a successful record it never wrote. + * enforced. A base that would not resolve is recorded as a failed effect (§6), + * so a programmatic run replays that failure rather than demanding a successful + * record it never wrote. * * All of it is operation-scoped. The value is installed in the scope that owns * the document execution, so every descendant of the expansion reads it, the @@ -80,7 +80,7 @@ import { retainedRunMismatch, workflowRunValue, } from "./journal.ts"; -import type { RunHistoryRules, WorkflowRun } from "./journal.ts"; +import type { WorkflowRun } from "./journal.ts"; export type { WorkflowRun } from "./journal.ts"; @@ -163,14 +163,20 @@ export function* getWorkflowRun(): Operation { /** * How one installation decides what the run is, and what the journal is held to. * - * Two hosts need different answers to both questions. A programmatic caller - * supplies a base and lets the first live execution allocate an id and resolve - * that base, so the only thing a record can disagree about is the base it was - * made from. A workflow host has already created the storage record, so the run - * is not the execution's to allocate: it arrives whole, and a journal that - * records a different one is not this run's journal. + * Two hosts need different answers to both questions. A caller that resolves a + * base lets the first live execution allocate an id and resolve that base, so + * the only thing a record can disagree about is the base it was made from. A + * workflow host has already created the storage record, so the run is not the + * execution's to allocate: it arrives whole, and a journal that records a + * different one is not this run's journal. + * + * Both answers are the host's, and neither is this package's to invent. What + * stays here is everything underneath them: the installation slot, when root + * admission happens, the durable record's parser, and where the current run is + * published. A host that resolves a repository supplies the resolution; it does + * not supply the journal. */ -interface RunPreparation extends RunHistoryRules { +export interface WorkflowRunPreparation { /** * How the durable record identifies itself. * @@ -178,12 +184,19 @@ interface RunPreparation extends RunHistoryRules { * its description says which version it is and what bundle it retains. */ readonly description: EffectDescription; + /** Whether a non-empty history must carry a successful record. */ + readonly required: boolean; + /** The recorded run, or a refusal naming what it disagrees about. */ + agree(recorded: WorkflowRun): WorkflowRun; /** The run this execution is of, reached only when nothing is recorded yet. */ allocate(): Operation; } /** Append the run to the journal, and answer with what the journal holds. */ -function* record(description: EffectDescription, preparation: RunPreparation): Workflow { +function* record( + description: EffectDescription, + preparation: WorkflowRunPreparation, +): Workflow { return yield createDurableOperation(description, function* (): Operation { // Reached only when nothing is recorded yet: a replay hands the stored // value back without running this at all, so neither the identifier nor Git @@ -192,7 +205,7 @@ function* record(description: EffectDescription, preparation: RunPreparation): W }); } -function allocating(base: string): RunPreparation { +function allocating(base: string): WorkflowRunPreparation { return { description: describeGitWorkflowRun(base), // A base that would not resolve is recorded as a failed effect (§6), and a @@ -224,7 +237,7 @@ function allocating(base: string): RunPreparation { }; } -function retaining(run: WorkflowRun): RunPreparation { +function retaining(run: WorkflowRun): WorkflowRunPreparation { return { description: describeWorkflowRun(run), // The host created this run before anything executed, so a history of its @@ -289,7 +302,7 @@ function readingRetainedValue(read: () => T): T | undefined { } /** Read the record this run is held to, refusing anything that is not it. */ -function held(stored: unknown, preparation: RunPreparation): WorkflowRun { +function held(stored: unknown, preparation: WorkflowRunPreparation): WorkflowRun { const run = readWorkflowRun(stored); if (run === undefined) { throw malformedRecord(); @@ -316,7 +329,7 @@ function same(left: WorkflowRun, right: WorkflowRun): boolean { * terminal replay core never enters the durable body at all, so this does not * run and the admission is what installs the recorded run. */ -function* prepare(preparation: RunPreparation): Workflow { +function* prepare(preparation: WorkflowRunPreparation): Workflow { const description = preparation.description; // Which run this is, and whether the journal agrees, are decided by the // captured `preparation` and the durable record — never by what the slot @@ -349,7 +362,7 @@ function* prepare(preparation: RunPreparation): Workflow { * the durable body, so preparation does not run and this is the only place * inside the execution where the run a recorded result belongs to is known. */ -function admits(preparation: RunPreparation): JournalAdmission { +function admits(preparation: WorkflowRunPreparation): JournalAdmission { return function* (retained: readonly DurableEvent[]): Operation { // What the history is held to is decided by the captured `preparation`, and // by nothing that is read here. The slot is reached only afterwards, to @@ -407,8 +420,16 @@ function* publish(run: WorkflowRun): Operation { * core captures it before any middleware or document code exists, and a second * loaded copy of this package composes by handing over its own closure rather * than by agreeing on a name. + * + * The preparation is the host's half and the only half. Everything the run is + * held to — the admission captured before any installation, the durable record + * inside the root, the parser that reads it back, the slot the execution + * publishes into — stays here, so a host that decides what its run is decides + * nothing about when or how strictly that decision is enforced. */ -function installation(preparation: RunPreparation): ExecutionInstallation { +export function createWorkflowRunInstallation( + preparation: WorkflowRunPreparation, +): ExecutionInstallation { return { admissions: [admits(preparation)], prepare: () => prepare(preparation), @@ -433,7 +454,7 @@ function installation(preparation: RunPreparation): ExecutionInstallation { * ``` */ export function workflowInstallation(options: { base: string }): ExecutionInstallation { - return installation(allocating(options.base)); + return createWorkflowRunInstallation(allocating(options.base)); } /** @@ -450,7 +471,7 @@ export function workflowInstallation(options: { base: string }): ExecutionInstal * Git is not consulted, and no identifier is generated. */ export function retainedWorkflowInstallation(run: WorkflowRun): ExecutionInstallation { - return installation(retaining(retainedRun(run))); + return createWorkflowRunInstallation(retaining(retainedRun(run))); } /** diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index befddf121..8946d0e8a 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -14,7 +14,7 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; -import { executeInstalled } from "@executablemd/core/host"; +import { directoryEntry, executeInstalled } from "@executablemd/core/host"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; @@ -1042,6 +1042,98 @@ describe("Tier WGAC — the standard write table", () => { }); }); + it("WGAC16: the directory entry a host captures is the one the run admits", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + + // A host states the component behind ``; this package states the + // rest of the ceiling. The entry above is the one released builds + // retained and is what a host that captures none is admitted under. + const attempt = yield* runDocument(database, evaluates(WRITES, ["write"]), { + directory: directoryEntry({ origin: "tier-wgac", key: "Dir", revision: "1" }, "Dir"), + }); + + expect(attempt.failure).toBe(undefined); + expect(yield* stored(database, "/nested/out.md")).toBe("the fragment wrote this"); + + // Same position in the same table: the captured entry replaces the one + // this package states rather than being appended beside it, so a + // continuation compares one directory grant and not two. + const policy = policyOf(admissions(attempt.events)[0]!); + expect(policy?.allowed).toEqual([ + { + name: "Json", + identity: capability("@executablemd/core", "Json", "2"), + forms: ["self-closing"], + }, + { + name: "File", + identity: capability("@executablemd/core", "File:write", "2"), + forms: ["paired"], + }, + { + name: "Dir", + identity: capability("tier-wgac", "Dir", "1"), + forms: ["paired"], + }, + { + name: "File.Delete", + identity: capability("@executablemd/core", "File.Delete", "2"), + forms: ["self-closing"], + }, + ]); + }); + }); + + it("WGAC17: a captured write is admitted beside the standard table, not instead of it", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + + // `writes` is additive and stays additive: a host that states one of its + // own keeps core's write and delete and this package's directory entry, + // and its own entry follows them. + const attempt = yield* runDocument(database, evaluates(WRITES, ["write"]), { + writes: [directoryEntry({ origin: "tier-wgac", key: "Nested", revision: "1" }, "Nested")], + }); + + expect(attempt.failure).toBe(undefined); + expect(yield* stored(database, "/nested/out.md")).toBe("the fragment wrote this"); + + const policy = policyOf(admissions(attempt.events)[0]!); + expect(policy?.allowed).toEqual([ + { + name: "Json", + identity: capability("@executablemd/core", "Json", "2"), + forms: ["self-closing"], + }, + { + name: "File", + identity: capability("@executablemd/core", "File:write", "2"), + forms: ["paired"], + }, + // The released entry, still in its own position and unmoved by the + // addition beside it. + { + name: "Dir", + identity: capability("@executablemd/workflow/composition", "Dir", "3"), + forms: ["paired"], + }, + { + name: "File.Delete", + identity: capability("@executablemd/core", "File.Delete", "2"), + forms: ["self-closing"], + }, + { + name: "Nested", + identity: capability("tier-wgac", "Nested", "1"), + forms: ["paired"], + }, + ]); + }); + }); + it("WGAC10: nothing outside the write table is admitted with it", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { diff --git a/packages/workflow/tests/public-entrypoint.test.ts b/packages/workflow/tests/public-entrypoint.test.ts index 2232c1d65..4561a3621 100644 --- a/packages/workflow/tests/public-entrypoint.test.ts +++ b/packages/workflow/tests/public-entrypoint.test.ts @@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url"; import { withWorkflowWorkspace } from "@executablemd/workflow/deno"; import type { WorkflowWorkspaceOptions } from "@executablemd/workflow/deno"; import * as published from "@executablemd/workflow/deno"; +import * as root from "@executablemd/workflow"; import { useInvokingHome } from "./support/credential-home.ts"; import { readdir, readTextFile, stat } from "@effectionx/fs"; import type { Operation } from "effection"; @@ -161,6 +162,50 @@ describe("workflow published Deno entrypoint", () => { expect(COMPOSITION_IS_NOT_A_KEY).toBe(false); expect(yield* until(Promise.resolve(true))).toBe(true); }); + + it("publishes the three generic extension boundaries and no seam behind them", function* () { + const shared = Object.keys(root); + const deno = Object.keys(published); + + // What a trusted host outside this package composes with: how it states + // what its own run is, how it performs one Workspace-coordinated durable + // mutation, how it reads the Workspace without performing one, and how it + // tells a failure it may journal from one that fails the run. + expect(shared).toContain("createWorkflowRunInstallation"); + expect(deno).toContain("createWorkflowWorkspaceEffect"); + expect(deno).toContain("readWorkflowWorkspace"); + expect(deno).toContain("JournaledEffectFailure"); + expect(deno).toContain("isJournalableWorkspaceFailure"); + + // And what it still cannot reach. A mutation receives its storage view from + // the transaction that owns it; a caller that could build one, open a + // private transaction, mint a transaction token, restore a root, or install + // the private provider would be holding the authority this boundary exists + // to keep. + for (const seam of [ + "createWorkflowWorkspaceStorage", + "guardedWorkflowWorkspaceStorage", + "guardedWorkflowWorkspaceReadStorage", + "usePrivateWorkspace", + "withPrivateWorkspaceTransaction", + "transactWorkspaceRoots", + "workflowRunTransactionToken", + "validateWorkflowRunTransactionToken", + "useWorkspaceEffects", + "withWorkspaceEffects", + "createWorkflowRunConnections", + "restoreWorkspaceRoot", + "captureWorkspaceRoot", + "setCurrentWorkspaceRoot", + "savepoint", + ]) { + expect({ seam, reachable: deno.includes(seam) || shared.includes(seam) }).toEqual({ + seam, + reachable: false, + }); + } + expect(yield* until(Promise.resolve(true))).toBe(true); + }); }); /** diff --git a/packages/workflow/tests/support/composition.ts b/packages/workflow/tests/support/composition.ts index 80ea13d6d..fcfbe9d58 100644 --- a/packages/workflow/tests/support/composition.ts +++ b/packages/workflow/tests/support/composition.ts @@ -48,7 +48,10 @@ import { importTree, localizeAdministration, } from "../../src/deno/composition/materialize.ts"; -import type { StoredRepository } from "../../src/deno/workspace/repositories.ts"; +import { + createWorkspaceMetadata, + type StoredRepository, +} from "../../src/deno/workspace/repositories.ts"; import type { WorktreeRecord } from "../../src/composition/records.ts"; import { isGitWorkflowRunRecord, type WorkflowRun, type WorkflowRunRecord } from "../../mod.ts"; @@ -325,7 +328,7 @@ export function* retainedRepositories( database: WorkflowRunDatabase, ): Operation { const read = yield* transactWorkspaceRoots(database, function* (workspace) { - return workspace.metadata.readRepositories(); + return createWorkspaceMetadata(workspace.storage).readRepositories(); }); if (!read.ok) { throw read.error; @@ -338,7 +341,7 @@ export function* retainedWorktrees( repositoryName: string, ): Operation { const read = yield* transactWorkspaceRoots(database, function* (workspace) { - return workspace.metadata.readWorktreesForRepository(repositoryName); + return createWorkspaceMetadata(workspace.storage).readWorktreesForRepository(repositoryName); }); if (!read.ok) { throw read.error; @@ -442,7 +445,7 @@ export function* inCheckout( const host = denoRepositoryHost(); const root = yield* host.useDirectory(); const exported = yield* transactWorkspaceRoots(database, function* (workspace) { - const [repository] = workspace.metadata.readRepositories(); + const [repository] = createWorkspaceMetadata(workspace.storage).readRepositories(); if (repository === undefined) { throw new Error("the run retains no repository to read a checkout from"); } @@ -453,7 +456,7 @@ export function* inCheckout( "inspection", ); const worktrees: string[] = []; - for (const worktree of workspace.metadata.readWorktreesForRepository( + for (const worktree of createWorkspaceMetadata(workspace.storage).readWorktreesForRepository( repository.record.name, )) { worktrees.push( @@ -492,7 +495,7 @@ export function* checkoutConfig( const host = denoRepositoryHost(); const root = yield* host.useDirectory(); const exported = yield* transactWorkspaceRoots(database, function* (workspace) { - const [repository] = workspace.metadata.readRepositories(); + const [repository] = createWorkspaceMetadata(workspace.storage).readRepositories(); if (repository === undefined) { throw new Error("the run retains no repository to read configuration from"); } @@ -503,7 +506,7 @@ export function* checkoutConfig( "inspection", ); const worktrees: string[] = []; - for (const worktree of workspace.metadata.readWorktreesForRepository( + for (const worktree of createWorkspaceMetadata(workspace.storage).readWorktreesForRepository( repository.record.name, )) { worktrees.push( diff --git a/packages/workflow/tests/support/git-crash-child.ts b/packages/workflow/tests/support/git-crash-child.ts index fd6636060..a50772e8c 100644 --- a/packages/workflow/tests/support/git-crash-child.ts +++ b/packages/workflow/tests/support/git-crash-child.ts @@ -53,6 +53,7 @@ import { useWorkspaceEffects } from "../../src/deno/workspace/effect.ts"; import { withWorkflowWorkspace } from "../../src/deno/workspace/host.ts"; import { currentWorkspaceRoot } from "../../src/deno/workspace/root.ts"; import { transactWorkspaceRoots, usePrivateWorkspace } from "../../src/deno/workspace/private.ts"; +import { createWorkspaceMetadata } from "../../src/deno/workspace/repositories.ts"; import { executeInstalled } from "@executablemd/core/host"; import { retainedWorkflowInstallation } from "../../src/run.ts"; import { denoRepositoryHost, useGitAuthentication } from "../../src/deno/composition/host.ts"; @@ -264,10 +265,10 @@ function* inspect(root: string, runId: string): Operation { } const observed = yield* transactWorkspaceRoots(database, function* (workspace) { - const [repository] = workspace.metadata.readRepositories(); + const [repository] = createWorkspaceMetadata(workspace.storage).readRepositories(); return { currentRoot: yield* workspace.currentRoot(), - repositories: workspace.metadata.readRepositories().length, + repositories: createWorkspaceMetadata(workspace.storage).readRepositories().length, checkoutPath: repository?.record.checkoutPath, which: repository === undefined diff --git a/packages/workflow/tests/support/workspace-crash-child.ts b/packages/workflow/tests/support/workspace-crash-child.ts index 500fff7b9..53d2ba60f 100644 --- a/packages/workflow/tests/support/workspace-crash-child.ts +++ b/packages/workflow/tests/support/workspace-crash-child.ts @@ -43,11 +43,11 @@ import { useJournalRouting } from "../../src/deno/journal-route.ts"; import { readTransaction } from "../../src/deno/reading.ts"; import { verifySchema } from "../../src/deno/schema.ts"; import { - createWorkspaceEffect, + createWorkflowWorkspaceEffect, useWorkspaceEffects, withWorkspaceEffects, } from "../../src/deno/workspace/effect.ts"; -import type { DenoWorkspaceFilesystem } from "../../src/deno/workspace/filesystem.ts"; +import { createDenoWorkspaceFilesystem } from "../../src/deno/workspace/filesystem.ts"; import { currentWorkspaceRoot } from "../../src/deno/workspace/root.ts"; import { setPrivateWorkspaceClock, @@ -68,18 +68,31 @@ const CLOCK = 1_750_000_100_000; function* crash(root: string, runId: string): Operation { const path = workflowRunPath(root, runId); - let filesystem: DenoWorkspaceFilesystem | undefined; + /** + * Whether the crash effect has run, so this hook knows which append is its. + * + * A flag rather than the mutation's filesystem. That projection is valid only + * while the mutation that received it runs, and what this hook needs is not + * the capability the mutation held but the fact that it has been here. + */ + let crashing = false; let gateCalls = 0; let baselineExecutions = 0; const connections = createWorkflowRunConnections(() => {}, { *afterRoutedJournalAppend(_database, event): Operation { - if (event.type !== "yield" || filesystem === undefined) { + if (event.type !== "yield" || !crashing) { return; } // Every read below is on the connection that opened the transaction, so // it sees that transaction's own uncommitted writes. Nothing else can. const sqlite = connection.database; + // Built here, from this harness's own connection, and read here. The + // point of the case is what this database holds at *this* moment, with + // the mutation returned and the root captured and published — so a value + // the mutation read earlier would answer a different question, and would + // still say `CRASH_CONTENT` if publication had since removed the file. + const live = createDenoWorkspaceFilesystem(connection, () => {}); const currentRoot = currentWorkspaceRoot(sqlite, path); const journalRow = sqlite .prepare( @@ -89,7 +102,7 @@ function* crash(root: string, runId: string): Operation { .get(`%"name":"${CRASH_EFFECT}"%`); report({ ready: true, - content: yield* filesystem.readTextFile(CRASH_PATH), + content: yield* live.readTextFile(CRASH_PATH), currentRoot, retainedRoots: count( sqlite.prepare("SELECT COUNT(*) AS count FROM workspace_roots").get()?.["count"], @@ -146,7 +159,7 @@ function* crash(root: string, runId: string): Operation { // The run this process resumes already holds this effect's result, so it // replays. Executing it would mean the crash effect below is not the // first live work of the process, and the count says which happened. - yield createWorkspaceEffect( + yield createWorkflowWorkspaceEffect( database, { type: "workspace-proof", name: BASELINE_EFFECT }, // deno-lint-ignore require-yield @@ -155,12 +168,12 @@ function* crash(root: string, runId: string): Operation { return null; }, ); - yield createWorkspaceEffect( + yield createWorkflowWorkspaceEffect( database, { type: "workspace-proof", name: CRASH_EFFECT }, - function* (selected) { - filesystem = selected; - yield* selected.writeFile(CRASH_PATH, CRASH_CONTENT, 0o640); + function* ({ filesystem }) { + crashing = true; + yield* filesystem.writeFile(CRASH_PATH, CRASH_CONTENT, 0o640); return null; }, ); diff --git a/packages/workflow/tests/support/workspace-restart-child.ts b/packages/workflow/tests/support/workspace-restart-child.ts index 41173de67..6aea1c2c8 100644 --- a/packages/workflow/tests/support/workspace-restart-child.ts +++ b/packages/workflow/tests/support/workspace-restart-child.ts @@ -25,7 +25,10 @@ import { durableRun, type Workflow } from "@executablemd/durable-streams"; import { main, type Operation, until } from "effection"; import { WorkflowRunStorage, type WorkflowRunDatabase } from "../../mod.ts"; import { useWorkflowRunStorage } from "../../deno.ts"; -import { createWorkspaceEffect, withWorkspaceEffects } from "../../src/deno/workspace/effect.ts"; +import { + createWorkflowWorkspaceEffect, + withWorkspaceEffects, +} from "../../src/deno/workspace/effect.ts"; import { setPrivateWorkspaceClock, transactWorkspaceRoots, @@ -59,10 +62,10 @@ const DEFINITION = { */ function workflow(database: WorkflowRunDatabase, marker: string, clock: { now: number }) { return function* (): Workflow { - yield createWorkspaceEffect( + yield createWorkflowWorkspaceEffect( database, { type: "workspace-proof", name: "seed" }, - function* (filesystem) { + function* ({ filesystem }) { yield* until(appendFile(marker, "seed\n")); yield* filesystem.mkdir("/tree", { mode: 0o750 }); yield* filesystem.writeFile(HISTORICAL_PATH, HISTORICAL_CONTENT, 0o640); @@ -71,10 +74,10 @@ function workflow(database: WorkflowRunDatabase, marker: string, clock: { now: n return null; }, ); - yield createWorkspaceEffect( + yield createWorkflowWorkspaceEffect( database, { type: "workspace-proof", name: "revise" }, - function* (filesystem) { + function* ({ filesystem }) { yield* until(appendFile(marker, "revise\n")); clock.now = REVISE_CLOCK; yield* filesystem.writeFile(HISTORICAL_PATH, "later bytes"); diff --git a/packages/workflow/tests/workflow-fork-source.test.ts b/packages/workflow/tests/workflow-fork-source.test.ts index 59d467776..a218ef4cb 100644 --- a/packages/workflow/tests/workflow-fork-source.test.ts +++ b/packages/workflow/tests/workflow-fork-source.test.ts @@ -42,6 +42,7 @@ import { } from "./support/storage.ts"; import { DatabaseSync } from "node:sqlite"; import { workflowForkStaging } from "../deno.ts"; +import { readRepositories, readWorktrees } from "../src/deno/fork-source.ts"; /** One retained yield, as the journal holds it. */ function retained(type: string, name = type, result: Json = null): DurableEvent { @@ -298,6 +299,131 @@ describe("Tier WFK — a source-bundle fork", () => { }); }); +/** + * Tier WFO — the checkout tables a fork and an export carry. + * + * These two tables are the physical shape a released database has, and this + * package copies them so a fork and an export of an existing run still open. + * What the columns *mean* is the checkout feature's, so nothing here parses a + * fingerprint, a locator, a branch or a commit: a row is well formed when it is + * text of the right kind, and its content travels through untouched. + * + * The case is what stops that from drifting back. A semantic parser added to + * this copy path would refuse these rows, and a column dropped or rewritten on + * the way through would come back different. + */ +describe("Tier WFO — retained checkout rows travel as stored", () => { + /** Values no Git parser would accept, in columns the physical schema allows. */ + const REPOSITORY = Object.freeze({ + name: "kept", + locator: "not://a locator anything resolves", + // The exact shape a fingerprint column holds. Its *value* is not asked + // about, and the copy path is not the place a digest is recomputed. + locator_fingerprint: "0".repeat(64), + requested_base: null, + creation_commit: "not-a-commit", + primary_branch: "refs/heads/anything at all", + object_format: "sha1", + checkout_path: "/kept", + }); + + const WORKTREE = Object.freeze({ + repository_name: "kept", + name: "branchless", + requested_branch: "a branch name Git would refuse", + requested_base: null, + creation_commit: "also-not-a-commit", + checkout_path: "/kept-worktree", + }); + + function seeded(): DatabaseSync { + const database = new DatabaseSync(":memory:"); + database.exec(` + CREATE TABLE workspace_repositories ( + name TEXT PRIMARY KEY, locator TEXT NOT NULL, locator_fingerprint TEXT NOT NULL, + requested_base TEXT, creation_commit TEXT NOT NULL, primary_branch TEXT NOT NULL, + object_format TEXT NOT NULL, checkout_path TEXT NOT NULL + ) STRICT; + CREATE TABLE workspace_worktrees ( + repository_name TEXT NOT NULL, name TEXT NOT NULL, requested_branch TEXT NOT NULL, + requested_base TEXT, creation_commit TEXT NOT NULL, checkout_path TEXT NOT NULL, + PRIMARY KEY (repository_name, name) + ) STRICT, WITHOUT ROWID; + `); + database + .prepare(`INSERT INTO workspace_repositories VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + REPOSITORY.name, + REPOSITORY.locator, + REPOSITORY.locator_fingerprint, + REPOSITORY.requested_base, + REPOSITORY.creation_commit, + REPOSITORY.primary_branch, + REPOSITORY.object_format, + REPOSITORY.checkout_path, + ); + database + .prepare(`INSERT INTO workspace_worktrees VALUES (?, ?, ?, ?, ?, ?)`) + .run( + WORKTREE.repository_name, + WORKTREE.name, + WORKTREE.requested_branch, + WORKTREE.requested_base, + WORKTREE.creation_commit, + WORKTREE.checkout_path, + ); + return database; + } + + // deno-lint-ignore require-yield + it("WFO1: a row this package never interprets is read back exactly", function* () { + const database = seeded(); + try { + const path = "/runs/opaque.sqlite"; + expect(readRepositories(database, path)).toEqual([ + { + name: REPOSITORY.name, + locator: REPOSITORY.locator, + locatorFingerprint: REPOSITORY.locator_fingerprint, + requestedBase: null, + creationCommit: REPOSITORY.creation_commit, + primaryBranch: REPOSITORY.primary_branch, + objectFormat: REPOSITORY.object_format, + checkoutPath: REPOSITORY.checkout_path, + }, + ]); + expect(readWorktrees(database, path)).toEqual([ + { + repositoryName: WORKTREE.repository_name, + name: WORKTREE.name, + requestedBranch: WORKTREE.requested_branch, + requestedBase: null, + creationCommit: WORKTREE.creation_commit, + checkoutPath: WORKTREE.checkout_path, + }, + ]); + } finally { + database.close(); + } + }); + + // deno-lint-ignore require-yield + it("WFO2: a fork's selection is by checkout path and by nothing else", function* () { + const database = seeded(); + try { + const path = "/runs/opaque.sqlite"; + // The root a fork starts from has the repository's directory and not the + // worktree's, so one row travels and the other does not — decided by the + // manifest rather than by anything read out of the rows themselves. + const selected = new Set([REPOSITORY.checkout_path]); + expect(readRepositories(database, path, selected).map((row) => row.name)).toEqual(["kept"]); + expect(readWorktrees(database, path, selected)).toEqual([]); + } finally { + database.close(); + } + }); +}); + /** Settle one begun execution, so a source run stops before it is forked. */ function* transitionsSettle( transitions: WorkflowExecutionTransitions, diff --git a/packages/workflow/tests/workflow-run-storage.test.ts b/packages/workflow/tests/workflow-run-storage.test.ts index 811f2333c..1ba9546be 100644 --- a/packages/workflow/tests/workflow-run-storage.test.ts +++ b/packages/workflow/tests/workflow-run-storage.test.ts @@ -47,6 +47,7 @@ import { WorkflowRunRecognition } from "../src/deno/provider.ts"; import { holdRecoveryCoordination } from "../src/deno/recovery-coordination.ts"; import { EXPECTED_SCHEMA, initializeSchema } from "../src/deno/schema.ts"; import { readRepositories } from "../src/deno/workspace/repositories.ts"; +import { createWorkflowWorkspaceStorage } from "../src/deno/workspace/storage.ts"; import { EMPTY_WORKSPACE_MANIFEST, EMPTY_WORKSPACE_ROOT_ID, @@ -1719,16 +1720,20 @@ describe("Tier WS — version 1 amended in place", () => { const insert = database.prepare( `INSERT INTO workspace_repositories VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); + // This table was rebuilt outside any run, so there is no transaction to + // authorize against — the parser under test is what this case is about, + // and it is reached through the same storage view a mutation holds. + const storage = createWorkflowWorkspaceStorage(database, () => {}); insert.run("project", "/remote.git", "f".repeat(64), null, "abc", "main", "md5", "/r/p"); - expect(() => readRepositories(database)).toThrow(WorkflowRecordMalformedError); + expect(() => readRepositories(storage)).toThrow(WorkflowRecordMalformedError); database.exec("DELETE FROM workspace_repositories"); insert.run("project", "/remote.git", "not-a-digest", null, "abc", "main", "sha1", "/r/p"); - expect(() => readRepositories(database)).toThrow(WorkflowRecordMalformedError); + expect(() => readRepositories(storage)).toThrow(WorkflowRecordMalformedError); database.exec("DELETE FROM workspace_repositories"); insert.run("project", "/remote.git", "f".repeat(64), null, "abc", "main", "sha1", "relative"); - expect(() => readRepositories(database)).toThrow(WorkflowRecordMalformedError); + expect(() => readRepositories(storage)).toThrow(WorkflowRecordMalformedError); } finally { database.close(); } diff --git a/packages/workflow/tests/workflow-run.test.ts b/packages/workflow/tests/workflow-run.test.ts index 4b011ad02..8291623e6 100644 --- a/packages/workflow/tests/workflow-run.test.ts +++ b/packages/workflow/tests/workflow-run.test.ts @@ -32,7 +32,8 @@ import type { Api } from "@effectionx/context-api"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionInstallation } from "@executablemd/core/host"; import { Git } from "../src/git.ts"; -import { getWorkflowRun, workflowInstallation } from "../src/run.ts"; +import { createWorkflowRunInstallation, getWorkflowRun, workflowInstallation } from "../src/run.ts"; +import { describeGitWorkflowRun } from "../src/journal.ts"; import type { WorkflowRun } from "../src/run.ts"; import { type GitWorkflowRunV1, isGitWorkflowRun } from "../mod.ts"; @@ -1030,6 +1031,113 @@ describe("Tier WR — workflow runs", () => { expect(seen.map((run) => gitRun(run).base).sort()).toEqual(["fast", "slow"]); }); + + /** + * What a host outside this package supplies, and what it does not. + * + * `workflowInstallation({ base })` and `retainedWorkflowInstallation(run)` are + * both this constructor with a different preparation, so a host that resolves + * its own repository supplies the same four terms. What it never supplies is + * when the admission runs, what parses the record, or where the run is + * published — so this preparation reaches no Git at all, and the journal is + * still held to it before the document is imported. + */ + it("WR21: a host preparation allocates the run, and this package records it", function* () { + const seen: WorkflowRun[] = []; + const stream = new InMemoryStream(); + const allocations: string[] = []; + + yield* scoped(function* () { + // Nothing about this preparation resolves a revision, and Git fails the + // test if the constructor reaches it on its own account. + yield* useForbiddenGit(); + yield* useProbe(seen); + yield* collect( + yield* executeInstalled({ ...inlineSource("\n"), stream }, [ + createWorkflowRunInstallation({ + description: describeGitWorkflowRun("host-base"), + required: false, + // deno-lint-ignore require-yield + *allocate(): Operation { + allocations.push("allocated"); + return { runId: "host-allocated", base: "host-base", pinnedCommit: COMMIT }; + }, + agree: (recorded) => recorded, + }), + ]), + ); + }); + + expect(allocations).toEqual(["allocated"]); + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ + runId: "host-allocated", + base: "host-base", + pinnedCommit: COMMIT, + }); + // Recorded under this package's canonical identity, from the host's own + // description — which is what makes it the record a later run is held to. + expect(recordedRun(stream)).toEqual({ + runId: "host-allocated", + base: "host-base", + pinnedCommit: COMMIT, + }); + }); + + it("WR22: a host preparation's agreement decides a retained history", function* () { + const stream = new InMemoryStream(); + yield* stream.append({ + type: "yield", + coroutineId: "root", + description: { type: "workflow_run", name: "workflow_run", base: "host-base" }, + result: { + status: "ok", + value: { runId: "somebody-elses", base: "host-base", pinnedCommit: COMMIT }, + }, + }); + + const refusals: string[] = []; + let allocated = 0; + let expanded = 0; + + const result = yield* scoped(function* () { + yield* useForbiddenGit(); + yield* registerComponents([ + { + name: "Probe", + origin: "tier-wr", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn() { + expanded += 1; + return ""; + }, + }, + ]); + return yield* yield* executeInstalled({ ...inlineSource("\n"), stream }, [ + createWorkflowRunInstallation({ + description: describeGitWorkflowRun("host-base"), + required: false, + // deno-lint-ignore require-yield + *allocate(): Operation { + allocated += 1; + return { runId: "host-allocated", base: "host-base", pinnedCommit: COMMIT }; + }, + agree(recorded): WorkflowRun { + refusals.push(gitRun(recorded).runId); + throw new Error("this journal records a run this host did not create"); + }, + }), + ]); + }); + + expect(result.ok).toBe(false); + // The host's agreement ran inside this package's own admission — before the + // document was imported and before anything could be allocated. + expect(refusals).toEqual(["somebody-elses"]); + expect(allocated).toBe(0); + expect(expanded).toBe(0); + }); }); /** diff --git a/packages/workflow/tests/workspace-effect-transaction.test.ts b/packages/workflow/tests/workspace-effect-transaction.test.ts index 3f833c3b5..4227f8ba6 100644 --- a/packages/workflow/tests/workspace-effect-transaction.test.ts +++ b/packages/workflow/tests/workspace-effect-transaction.test.ts @@ -37,10 +37,19 @@ import { useJournalRouting } from "../src/deno/journal-route.ts"; import { SavepointObservation, type SavepointObserver } from "../src/deno/savepoints.ts"; import { initializeSchema } from "../src/deno/schema.ts"; import { - createWorkspaceEffect, + createWorkflowWorkspaceEffect, useWorkspaceEffects, withWorkspaceEffects, + type WorkflowWorkspaceMutation, + type WorkflowWorkspaceTransaction, } from "../src/deno/workspace/effect.ts"; +import { readWorkflowWorkspace } from "../src/deno/workspace/inspect.ts"; +import type { WorkflowWorkspaceSnapshot } from "../src/deno/workspace/inspect.ts"; +import type { + WorkflowWorkspaceParameter, + WorkflowWorkspaceStorage, +} from "../src/deno/workspace/storage.ts"; +import { WorkflowTransactionError } from "../src/storage/errors.ts"; import { definitionToJson } from "../src/storage/definition.ts"; import { canonicalJson } from "../src/storage/record.ts"; import { type DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; @@ -132,7 +141,62 @@ function* workspaceStep( name: string, mutate: (filesystem: DenoWorkspaceFilesystem) => Operation, ): Workflow { - yield createWorkspaceEffect(database, { type: "workspace-proof", name }, mutate); + yield createWorkflowWorkspaceEffect( + database, + { type: "workspace-proof", name }, + ({ filesystem }) => mutate(filesystem), + ); +} + +/** The same effect, given the whole transaction rather than its filesystem. */ +function* workspaceTransactionStep( + database: WorkflowRunDatabase, + name: string, + mutate: WorkflowWorkspaceMutation, +): Workflow { + yield createWorkflowWorkspaceEffect(database, { type: "workspace-proof", name }, mutate); +} + +/** + * A row this suite writes through the storage view, in the view's own terms. + * + * Raw SQL and bound parameters rather than a repository helper: what is being + * proven is the generic boundary, and a feature's own parser reaching it would + * make the case about that parser instead. + */ +const INSERT_PROOF_REPOSITORY = `INSERT INTO workspace_repositories + (name, locator, locator_fingerprint, requested_base, + creation_commit, primary_branch, object_format, checkout_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; + +const PROOF_REPOSITORY = [ + "proof", + "/remote.git", + "f".repeat(64), + null, + "abcdef0", + "main", + "sha1", + "/kept", +] as const; + +function asText(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function retainedRepositoryNames(path: string): string[] { + const sqlite = new DatabaseSync(path); + try { + return sqlite + .prepare("SELECT name FROM workspace_repositories ORDER BY name") + .all() + .flatMap((row) => { + const name = asText(row["name"]); + return name === undefined ? [] : [name]; + }); + } finally { + sqlite.close(); + } } function* inspectWorkspace( @@ -1589,4 +1653,600 @@ describe("Tier WAC — atomic provider-level Workspace effects", () => { expect(yield* database.journal.readAll()).toEqual([]); }); }); + + it("WAC24: a row written through the storage view commits with the effect's bytes", function* () { + const root = yield* useStorageRoot(); + const runId = "atomic-storage-commit"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + const baseline = yield* inspectWorkspace(database, "/kept/marker.txt"); + let readBackInside: string | undefined; + + function* workflow(): Workflow { + yield* workspaceTransactionStep( + database, + "retain", + function* ({ filesystem, storage }): Operation { + yield* filesystem.mkdir("/kept", { mode: 0o750 }); + yield* filesystem.writeFile("/kept/marker.txt", "checkout bytes", 0o640); + storage.run(INSERT_PROOF_REPOSITORY, ...PROOF_REPOSITORY); + // Inside the same transaction and before any commit: the write is + // visible to the mutation that made it, which is what makes the + // two halves one fact rather than two. + readBackInside = asText( + storage.get("SELECT name FROM workspace_repositories WHERE name = ?", "proof")?.[ + "name" + ], + ); + return null; + }, + ); + } + + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: database.journal })); + + expect(readBackInside).toBe("proof"); + const committed = yield* inspectWorkspace(database, "/kept/marker.txt"); + expect(committed.content).toBe("checkout bytes"); + expect(committed.root).not.toBe(baseline.root); + // The row and the bytes are published together, against the same root the + // journal entry names. + expect(retainedRepositoryNames(path)).toEqual(["proof"]); + expect(journalRoot(path, "retain")).toBe(committed.root); + }); + }); + + it("WAC25: a refused mutation takes its rows back with its bytes", function* () { + const root = yield* useStorageRoot(); + const runId = "atomic-storage-rollback"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + const baseline = yield* inspectWorkspace(database, "/kept/marker.txt"); + const roots = retainedRootCount(path); + let caught: unknown; + + function* workflow(): Workflow { + try { + yield* workspaceTransactionStep( + database, + "refused", + function* ({ filesystem, storage }): Operation { + yield* filesystem.mkdir("/kept", { mode: 0o750 }); + yield* filesystem.writeFile("/kept/marker.txt", "must roll back", 0o640); + storage.run(INSERT_PROOF_REPOSITORY, ...PROOF_REPOSITORY); + // A known Workspace failure, which is this effect's *failed* + // durable outcome rather than infrastructure. + yield* filesystem.readTextFile("/missing.txt"); + return null; + }, + ); + } catch (error) { + caught = error; + } + } + + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: database.journal })); + + expect(caught).toBeInstanceOf(Error); + // Nothing the attempt wrote survives: not the bytes, not the row, and not + // a published root — and the failed Yield names the root it started from. + expect(yield* inspectWorkspace(database, "/kept/marker.txt")).toEqual(baseline); + expect(retainedRepositoryNames(path)).toEqual([]); + expect(retainedRootCount(path)).toBe(roots); + const events = workspaceYields(yield* database.journal.readAll()); + expect(events).toHaveLength(1); + expect(events[0]?.result.status).toBe("err"); + expect(journalRoot(path, "refused")).toBe(baseline.root); + }); + }); + + /** + * The window a retained view would otherwise still be inside. + * + * Not after the run — by then the transaction has ended and its own + * authorization would refuse anyway, so a case that only looked there would + * pass with no fence at all. This reaches the view from `beforeCommit`, while + * the mutation has returned but the transaction that captured and published + * its root is still open, which is exactly where "for this callback" has to + * be a fact rather than a convention. + */ + it("WAC26: every capability a mutation retained answers nothing once it returned", function* () { + const root = yield* useStorageRoot(); + const runId = "atomic-storage-retained"; + const path = join(root, `${runId}.sqlite`); + let selected: WorkflowRunDatabase | undefined; + let retained: WorkflowWorkspaceTransaction | undefined; + /** + * Operations built while the mutation was still valid. + * + * The hole a call-time check alone leaves open: a callback creates every + * operation it wants before returning, and yields them afterwards. So these + * are made inside the callback and executed from `beforeCommit`, where the + * mutation has returned and the transaction is still open. + */ + let prepared: { name: string; operation: Operation }[] = []; + let members: string[] = []; + let storageMembers: string[] = []; + let attempted = false; + const refusals: { name: string; message: string }[] = []; + + yield* withDirectWorkspaceStorage( + root, + runId, + () => {}, + function* (database) { + selected = database; + + function* workflow(): Workflow { + yield* workspaceTransactionStep( + database, + "retain-view", + // deno-lint-ignore require-yield + function* (transaction): Operation { + members = Object.keys(transaction).sort(); + storageMembers = Object.keys(transaction.storage).sort(); + retained = transaction; + prepared = [ + { + name: "readTextFile", + operation: transaction.filesystem.readTextFile("/kept.txt"), + }, + { + name: "writeFile", + operation: transaction.filesystem.writeFile("/escaped.txt", "escaped", 0o640), + }, + { name: "mkdir", operation: transaction.filesystem.mkdir("/escaped") }, + { + name: "savepoint", + operation: transaction.savepoint( + // deno-lint-ignore require-yield + (function* (): Operation { + throw new Error("the retained savepoint entered its body"); + })(), + ), + }, + ]; + return null; + }, + ); + } + + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: database.journal })); + + // Three members and no more: no connection, no lease, no journal route + // and no transaction token travel with the mutation. + expect(members).toEqual(["filesystem", "savepoint", "storage"]); + expect(storageMembers).toEqual(["all", "get", "run"]); + + expect(attempted).toBe(true); + // Every retained capability, synchronous and deferred alike. + expect(refusals.map((refusal) => refusal.name).sort()).toEqual([ + "all", + "get", + "mkdir", + "readTextFile", + "run", + "savepoint", + "writeFile", + ]); + for (const refusal of refusals) { + expect({ + name: refusal.name, + held: refusal.message.includes( + "valid only while the callback that received it is running", + ), + }).toEqual({ name: refusal.name, held: true }); + } + // And nothing any of them attempted reached the run. + expect(retainedRepositoryNames(path)).toEqual([]); + const settled = yield* inspectWorkspace(database, "/escaped.txt"); + expect(settled.content).toBe(undefined); + }, + { + *beforeCommit(candidate): Operation { + const transaction = retained; + if (candidate !== selected || transaction === undefined || attempted) { + return; + } + attempted = true; + + function record(name: string, reach: () => void): void { + try { + reach(); + } catch (error) { + refusals.push({ + name, + message: error instanceof WorkflowTransactionError ? error.message : "", + }); + } + } + + // The synchronous half: called after the callback returned. + record("run", () => + transaction.storage.run(INSERT_PROOF_REPOSITORY, ...PROOF_REPOSITORY), + ); + record("get", () => { + transaction.storage.get("SELECT name FROM workspace_repositories"); + }); + record("all", () => { + transaction.storage.all("SELECT name FROM workspace_repositories"); + }); + + // The deferred half: operations built while the callback was valid, + // executed now. A wrapper that only checked at creation admits these. + for (const { name, operation } of prepared) { + try { + yield* operation; + refusals.push({ name, message: "" }); + } catch (error) { + refusals.push({ + name, + message: error instanceof WorkflowTransactionError ? error.message : "", + }); + } + } + }, + }, + ); + }); +}); + +/** + * Tier WAR — reading the Workspace without performing an effect. + * + * The other half of the same authority. An inspection opens the run's own + * transaction, reads, and closes it; it journals nothing, publishes nothing, + * and has no writing surface at all. What these measure is that the narrowing + * is real — not a promise the callback is trusted to keep — and that reading a + * root the run has moved on from leaves the run exactly where it was. + */ +describe("Tier WAR — read-only Workspace inspection", () => { + /** One effect that writes a file and a row, so there is history to read back. */ + function* retain( + database: WorkflowRunDatabase, + content: string, + row?: readonly WorkflowWorkspaceParameter[], + ): Operation { + function* workflow(): Workflow { + yield* workspaceTransactionStep( + database, + "retain", + function* ({ filesystem, storage }): Operation { + yield* filesystem.writeFile("/kept.txt", content, 0o640); + if (row !== undefined) { + storage.run(INSERT_PROOF_REPOSITORY, ...row); + } + return null; + }, + ); + } + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: database.journal })); + } + + /** + * Two effects in one run, and the root the first of them published. + * + * One `durableRun` rather than two: a second run over the same journal reads + * a recorded `Close` and replays its result without entering the body, so two + * calls would leave the Workspace holding only what the first one wrote. + */ + function* retainTwice(database: WorkflowRunDatabase, path: string): Operation { + function* workflow(): Workflow { + yield* workspaceTransactionStep( + database, + "first", + function* ({ filesystem }): Operation { + yield* filesystem.writeFile("/kept.txt", "the earlier bytes", 0o640); + return null; + }, + ); + yield* workspaceTransactionStep( + database, + "second", + function* ({ filesystem }): Operation { + yield* filesystem.writeFile("/kept.txt", "the later bytes", 0o640); + return null; + }, + ); + } + yield* withWorkspaceEffects(database, durableRun(workflow, { stream: database.journal })); + const earlier = journalRoot(path, "first"); + if (earlier === undefined) { + throw new Error("the first effect published no Workspace root"); + } + return earlier; + } + + it("WAR1: reads the current root's bytes and rows, and writes through neither", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-current"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + yield* retain(database, "current bytes", PROOF_REPOSITORY); + const current = (yield* inspectWorkspace(database, "/kept.txt")).root; + const roots = retainedRootCount(path); + + let members: string[] = []; + let storageMembers: string[] = []; + const read = yield* readWorkflowWorkspace(database, {}, function* (snapshot) { + members = Object.keys(snapshot).sort(); + storageMembers = Object.keys(snapshot.storage).sort(); + return { + content: yield* snapshot.filesystem.readTextFile("/kept.txt"), + names: snapshot.storage + .all("SELECT name FROM workspace_repositories ORDER BY name") + .flatMap((entry) => { + const name = asText(entry["name"]); + return name === undefined ? [] : [name]; + }), + }; + }); + if (!read.ok) { + throw read.error; + } + + expect(read.value).toEqual({ content: "current bytes", names: ["proof"] }); + expect(members).toEqual(["filesystem", "storage"]); + // No `run`, and no write on the filesystem either: an inspection holds + // objects that have no writing member for a guard to be the only thing in + // front of. + expect(storageMembers).toEqual(["all", "get"]); + // Nothing moved: not the current root, and not the retained ones. + expect((yield* inspectWorkspace(database, "/kept.txt")).root).toBe(current); + expect(retainedRootCount(path)).toBe(roots); + }); + }); + + it("WAR2: reads a retained root and leaves the run on the one it was on", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-retained"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + const earlier = yield* retainTwice(database, path); + const current = yield* inspectWorkspace(database, "/kept.txt"); + expect(current.content).toBe("the later bytes"); + expect(current.root).not.toBe(earlier); + const roots = retainedRootCount(path); + + const read = yield* readWorkflowWorkspace(database, { rootId: earlier }, (snapshot) => + snapshot.filesystem.readTextFile("/kept.txt"), + ); + if (!read.ok) { + throw read.error; + } + + // The historical bytes, and the run still on the root it was on. The + // materialization that produced them is rolled back before anything else + // can observe the Workspace selected at the earlier root. + expect(read.value).toBe("the earlier bytes"); + expect(yield* inspectWorkspace(database, "/kept.txt")).toEqual(current); + expect(retainedRootCount(path)).toBe(roots); + }); + }); + + /** + * What takes the materialization back on these two paths, and what does not. + * + * WAR2 is the case that discriminates the rollback-only savepoint: there the + * inspection *succeeds*, the transaction commits, and only that savepoint + * stands between a historical read and a run left on a root it never + * published. Here the transaction itself ends without committing, so the + * rollback is over-determined — which is the point. The claim is the outcome + * the caller is owed on every exit, and the case also proves the run is still + * usable afterwards rather than poisoned by a materialization that was + * interrupted half way through. + */ + it("WAR3: a failure and a cancellation each leave the run where they found it", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-rollback"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + const earlier = yield* retainTwice(database, path); + const current = yield* inspectWorkspace(database, "/kept.txt"); + const roots = retainedRootCount(path); + + // A failure inside the callback, after the retained root materialized. + const raised = new Error("the inspection failed after materializing"); + const failed = yield* readWorkflowWorkspace( + database, + { rootId: earlier }, + // deno-lint-ignore require-yield + function* (snapshot): Operation { + void snapshot; + throw raised; + }, + ); + expect(failed.ok).toBe(false); + expect(failed.ok ? undefined : failed.error).toBe(raised); + expect(yield* inspectWorkspace(database, "/kept.txt")).toEqual(current); + expect(retainedRootCount(path)).toBe(roots); + + // And a cancellation, halted while the callback is suspended inside the + // materialized root. + const entered = withResolvers(); + yield* scoped(function* () { + const inspecting = yield* spawn(() => + readWorkflowWorkspace(database, { rootId: earlier }, function* (snapshot) { + void snapshot; + entered.resolve(); + yield* suspend(); + return "unreachable"; + }), + ); + yield* entered.operation; + yield* inspecting.halt(); + }); + + expect(yield* inspectWorkspace(database, "/kept.txt")).toEqual(current); + expect(retainedRootCount(path)).toBe(roots); + + // And the connection is still usable: a later inspection opens the run's + // transaction again and reads the root the cancellation left it on, + // rather than finding a half-materialized Workspace or a poisoned handle. + const after = yield* readWorkflowWorkspace(database, {}, (snapshot) => + snapshot.filesystem.readTextFile("/kept.txt"), + ); + if (!after.ok) { + throw after.error; + } + expect(after.value).toBe(current.content); + }); + }); + + it("WAR4: a retained snapshot answers nothing once its inspection returned", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-retained-view"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + yield* retain(database, "current bytes", PROOF_REPOSITORY); + + let escaped: WorkflowWorkspaceSnapshot | undefined; + const read = yield* readWorkflowWorkspace(database, {}, function* (snapshot) { + escaped = snapshot; + return yield* snapshot.filesystem.readTextFile("/kept.txt"); + }); + if (!read.ok) { + throw read.error; + } + expect(read.value).toBe("current bytes"); + + const held = escaped; + if (held === undefined) { + throw new Error("the inspection was never given a snapshot"); + } + expect(() => held.storage.all("SELECT name FROM workspace_repositories")).toThrow( + WorkflowTransactionError, + ); + expect(() => held.storage.get("SELECT name FROM workspace_repositories")).toThrow( + WorkflowTransactionError, + ); + // The filesystem half is revoked by the same gate, before the operation + // it would return is ever entered. + expect(() => held.filesystem.readTextFile("/kept.txt")).toThrow(WorkflowTransactionError); + }); + }); + + it("WAR5: an operation built inside the inspection refuses when it is run after", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-deferred"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + yield* retain(database, "current bytes", PROOF_REPOSITORY); + const current = yield* inspectWorkspace(database, "/kept.txt"); + const roots = retainedRootCount(path); + + // Built while the snapshot was valid, carried out of the callback, and + // yielded afterwards. A gate checked only when the member is called would + // let this one through. + let deferred: Operation | undefined; + const read = yield* readWorkflowWorkspace(database, {}, function* (snapshot) { + deferred = snapshot.filesystem.readTextFile("/kept.txt"); + return yield* snapshot.filesystem.readTextFile("/kept.txt"); + }); + if (!read.ok) { + throw read.error; + } + expect(read.value).toBe("current bytes"); + + const escaped = deferred; + if (escaped === undefined) { + throw new Error("the inspection built no deferred operation"); + } + let refusal: unknown; + try { + yield* escaped; + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(WorkflowTransactionError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "valid only while the callback that received it is running", + ); + expect(yield* inspectWorkspace(database, "/kept.txt")).toEqual(current); + expect(retainedRootCount(path)).toBe(roots); + }); + }); + + /** + * Why removing `run` is not what makes a view read-only. + * + * `INSERT … RETURNING` is a statement that returns rows, and + * `StatementSync.all()` runs it and keeps the insertion. A view narrowed by + * its interface alone would therefore write through the member named `all`. + * So the refusal has to come from SQLite, before the statement executes. + */ + it("WAR6: a write dressed as a read is refused before it executes", function* () { + const root = yield* useStorageRoot(); + const runId = "inspect-write-as-read"; + + yield* withStorage(root, function* () { + const database = yield* createRun({ runId }); + const path = runPath(root, runId); + yield* retain(database, "current bytes"); + const current = yield* inspectWorkspace(database, "/kept.txt"); + const roots = retainedRootCount(path); + expect(retainedRepositoryNames(path)).toEqual([]); + + const attempts: { sql: string; refused: boolean }[] = []; + const read = yield* readWorkflowWorkspace(database, {}, function* (snapshot) { + const WRITES: readonly string[] = [ + `INSERT INTO workspace_repositories + (name, locator, locator_fingerprint, requested_base, + creation_commit, primary_branch, object_format, checkout_path) + VALUES ('proof', '/remote.git', '${"f".repeat(64)}', NULL, + 'abcdef0', 'main', 'sha1', '/kept') RETURNING name`, + "UPDATE workspace_repositories SET locator = 'moved' RETURNING name", + "DELETE FROM workspace_repositories RETURNING name", + "CREATE TABLE smuggled (a TEXT)", + "DROP TABLE workspace_worktrees", + "PRAGMA user_version = 99", + ]; + for (const sql of WRITES) { + try { + snapshot.storage.all(sql); + attempts.push({ sql, refused: false }); + } catch { + attempts.push({ sql, refused: true }); + } + } + // The same view still reads, so the refusal is about what the statement + // does rather than about the view being broken. + return snapshot.storage + .all("SELECT name FROM workspace_repositories ORDER BY name") + .flatMap((row) => { + const name = asText(row["name"]); + return name === undefined ? [] : [name]; + }); + }); + if (!read.ok) { + throw read.error; + } + + expect(attempts.every((attempt) => attempt.refused)).toBe(true); + expect(attempts).toHaveLength(6); + expect(read.value).toEqual([]); + // Nothing was written, nothing was dropped, and the run did not move. + expect(retainedRepositoryNames(path)).toEqual([]); + expect(retainedRootCount(path)).toBe(roots); + + // And the connection is the one every other caller expects. This runs on + // the run's own handle and opens a private transaction whose verification + // reads the schema and its pragmas — all of which an authorizer left + // installed by the read above would refuse. + expect(yield* inspectWorkspace(database, "/kept.txt")).toEqual(current); + }); + }); }); From b35d294f094acfe12dce7cd581937c9c62d36085 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 18 Sep 2026 11:14:10 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20Extract=20the=20Git=20Plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repositories, worktrees, Git operations, pull requests and issues are now `@executablemd/git`, a package of their own whose default export is the Plugin named `@executablemd/git`. `@executablemd/workflow` keeps run lifecycle, storage, replay, schema recognition, artifact handling and Workspace coordination, and imports none of it. The whole graph moves as one: the provider-neutral components, APIs, records, effects and errors; the local Git subprocess, materialization, run-composition and Git-host adapters; the repository and worktree rows; the credential helper; and `workflowInstallation({ base })`, which is now built on Workflow's generic `createWorkflowRunInstallation()`. Nothing durable moves with the source. Component names, forms, props, documentation and registration order are unchanged, and every released origin is preserved exactly — including `@executablemd/workflow/composition` and the `@executablemd/workflow/composition/dir-v2#Dir` alias. Effect types, journal records, SQLite tables and artifact bytes are untouched, so an existing run replays as it did. Three seams replace what Workflow used to reach directly: - `WorkflowWorkspaceOptions.attachments` lets a host name the features that install into a run's Workspace. `gitWorkspaceAttachment()` is this package's, installed in the same position and the same order the providers always were. - `gitIdentityInstallation()` carries the per-execution Git-host and Issue identity queues that used to sit beside the run. It is an `ExecutionInstallation` rather than part of the Plugin because a Plugin installs once per command while those queues belong to one execution. - Workflow publishes the version-1 run description and its two refusals, the advisory lock, and its Workspace attachment types, because a host that states what its own run is still needs what that statement is compared against. Two places in Workflow classify retained rows a feature it no longer owns wrote. The generated-fragment profile states the released `` origin, and fork classification recognizes a completed Git-host reconciliation by its exact declared shape. Both are written out as this package's own compatibility data, the way every other retained-record string in those files already is. The publish workflow is regenerated from the manifests: Workflow publishes before Git, and Git before the CLI. --- .github/workflows/publish-packages.yml | 25 +- deno.lock | 9 + packages/cli/package.json | 1 + packages/cli/src/cli.ts | 3 +- packages/cli/src/compiled.ts | 7 +- packages/cli/src/credential-helper-entry.ts | 5 +- packages/cli/src/deno-repositories.ts | 4 +- packages/cli/src/deno-workflow.ts | 18 +- packages/cli/src/deno.ts | 7 +- packages/cli/src/git-plugin-installation.ts | 38 ++ packages/cli/src/github-issues-config.ts | 4 +- .../cli/src/github-pull-requests-config.ts | 4 +- packages/cli/src/syntax.ts | 2 +- packages/cli/src/workflow-bundle.ts | 11 +- packages/cli/src/workflow-definition.ts | 5 +- packages/cli/src/workflow-fork.ts | 25 +- packages/cli/src/workflow.ts | 17 + .../cli/tests/run-composition-deno.test.ts | 4 +- .../cli/tests/run-composition-nested.test.ts | 4 +- packages/cli/tests/run-composition.test.ts | 2 +- packages/cli/tests/testing-activation.test.ts | 3 +- packages/cli/tests/workflow-host.test.ts | 6 +- .../cli/tests/workflow-installation.test.ts | 26 +- .../tests/workflow-lifecycle-control.test.ts | 3 +- .../cli/tests/workflow-suspension.test.ts | 3 +- packages/git/credential-helper.ts | 12 + packages/git/deno.json | 10 + packages/git/deno.ts | 50 +++ packages/git/mod.ts | 298 ++++++++++++ packages/git/package.json | 20 + .../{workflow => git}/src/composition/api.ts | 0 .../src/composition/components.md | 0 .../src/composition/components/Dir.ts | 0 .../src/composition/components/GitAdd.ts | 0 .../src/composition/components/GitCommit.ts | 0 .../src/composition/components/GitPush.ts | 0 .../src/composition/components/GitSwitch.ts | 0 .../src/composition/components/Issue.ts | 0 .../composition/components/IssueTracker.ts | 0 .../src/composition/components/PullRequest.ts | 0 .../components/PullRequestReads.ts | 0 .../src/composition/components/Repository.ts | 0 .../src/composition/components/Worktree.ts | 0 .../src/composition/context.ts | 0 .../src/composition/definitions.ts | 0 .../src/composition/errors.ts | 2 +- .../src/composition/git-api.ts | 0 .../src/composition/git-push-records.ts | 0 .../src/composition/git-records.ts | 0 .../src/composition/installation.ts | 2 +- .../src/composition/parse.ts | 0 .../src/composition/pull-request-api.ts | 0 .../composition/pull-request-operations.ts | 0 .../pull-request-read-execution.ts | 0 .../composition/pull-request-read-records.ts | 0 .../src/composition/pull-request-records.ts | 0 .../src/composition/pull-request-target.ts | 0 .../src/composition/push-evidence.ts | 0 .../src/composition/records.ts | 0 .../src/composition/selection.ts | 0 packages/git/src/deno/attachment.ts | 118 +++++ .../src/deno/composition/add.ts | 2 +- .../src/deno/composition/authentication.ts | 0 .../src/deno/composition/commit.ts | 2 +- .../src/deno/composition/credential-helper.ts | 0 .../src/deno/composition/effects.ts | 14 +- .../src/deno/composition/git.ts | 0 .../src/deno/composition/github.ts | 0 .../src/deno/composition/host.ts | 0 .../src/deno/composition/identity.ts | 2 +- .../src/deno/composition/locator.ts | 0 .../src/deno/composition/materialize.ts | 8 +- .../src/deno/composition/object-source.ts | 0 .../src/deno/composition/operations.ts | 6 +- .../src/deno/composition/placement.ts | 0 .../src/deno/composition/provider.ts | 6 +- .../deno/composition/pull-request-evidence.ts | 0 .../composition/pull-request-operations.ts | 4 +- .../deno/composition/pull-request-reads.ts | 2 +- .../src/deno/composition/pull-request.ts | 6 +- .../src/deno/composition/push.ts | 6 +- .../src/deno/composition/refusals.ts | 2 +- .../src/deno/composition/repository.ts | 6 +- .../src/deno/composition/subprocess.ts | 0 .../src/deno/composition/switch.ts | 2 +- .../src/deno/composition/worktree.ts | 6 +- .../src/deno/issue/github.ts | 0 .../src/deno}/repositories.ts | 6 +- .../src/deno/run-composition/ambient.ts | 0 .../src/deno/run-composition/checkouts.ts | 0 .../src/deno/run-composition/errors.ts | 0 .../src/deno/run-composition/identity.ts | 0 .../src/deno/run-composition/leases.ts | 4 +- .../src/deno/run-composition/metadata.ts | 0 .../src/deno/run-composition/operations.ts | 0 .../src/deno/run-composition/placement.ts | 0 .../src/deno/run-composition/provider.ts | 0 .../src/deno/run-composition/pull-request.ts | 0 .../{workflow => git}/src/deno/selections.ts | 0 .../{workflow => git}/src/git-host/api.ts | 0 .../src/git-host/effect-type.ts | 0 .../{workflow => git}/src/git-host/effect.ts | 3 +- .../{workflow => git}/src/git-host/errors.ts | 0 .../src/git-host/identities.ts | 2 +- .../{workflow => git}/src/git-host/records.ts | 2 +- packages/{workflow => git}/src/git.ts | 0 packages/git/src/identities.ts | 83 ++++ packages/git/src/installation.ts | 74 +++ packages/{workflow => git}/src/issue/api.ts | 0 .../{workflow => git}/src/issue/context.ts | 0 .../src/issue/effect-type.ts | 0 .../{workflow => git}/src/issue/effect.ts | 3 +- .../{workflow => git}/src/issue/errors.ts | 0 .../{workflow => git}/src/issue/identities.ts | 2 +- .../{workflow => git}/src/issue/operations.ts | 0 .../{workflow => git}/src/issue/records.ts | 2 +- .../{workflow => git}/src/issue/tracker.ts | 0 packages/git/src/plugin.ts | 194 ++++++++ .../tests/ambient-authentication.test.ts | 6 +- .../tests/credential-helper.test.ts | 0 .../tests/git-add-crash.test.ts | 7 +- .../tests/git-add-durability.test.ts | 19 +- .../{workflow => git}/tests/git-add.test.ts | 19 +- .../tests/git-commit-crash.test.ts | 7 +- .../tests/git-commit-durability.test.ts | 19 +- .../tests/git-commit.test.ts | 23 +- .../tests/git-host-effect.test.ts | 47 +- .../tests/git-push-crash.test.ts | 9 +- .../tests/git-push-durability.test.ts | 10 +- .../{workflow => git}/tests/git-push.test.ts | 13 +- .../tests/git-switch-crash.test.ts | 7 +- .../tests/git-switch-durability.test.ts | 19 +- .../tests/git-switch.test.ts | 30 +- packages/{workflow => git}/tests/git.test.ts | 0 .../tests/issue-github.test.ts | 0 .../tests/issue-markdown.test.ts | 0 .../tests/issue-records.test.ts | 0 .../tests/materialization.test.ts | 6 +- packages/git/tests/plugin.test.ts | 424 ++++++++++++++++++ .../git/tests/provider-neutrality.test.ts | 114 +++++ .../tests/pull-request-crash.test.ts | 9 +- .../tests/pull-request-durability.test.ts | 8 +- .../tests/pull-request-github.test.ts | 0 .../tests/pull-request-read.test.ts | 9 +- .../tests/pull-request-records.test.ts | 0 .../tests/pull-request.test.ts | 4 +- .../tests/repository-components.test.ts | 4 +- .../tests/repository-control-plane.test.ts | 16 +- .../tests/repository-materialization.test.ts | 16 +- .../tests/repository-replay.test.ts | 16 +- .../tests/repository-storage.test.ts | 2 +- .../tests/run-composition-ambient.test.ts | 0 .../tests/run-composition-managed.test.ts | 0 .../tests/run-composition-remote.test.ts | 0 .../IssueDurability.attempt.stage.md | 0 .../tests/scenarios/IssueDurability.test.md | 0 .../tests/scenarios/IssueHttp.test.md | 0 .../tests/scenarios/IssueRead.test.md | 0 .../IssueReadDurability.attempt.stage.md | 0 .../scenarios/IssueReadDurability.test.md | 0 .../IssueReadSubstituted.attempt.stage.md | 0 .../scenarios/IssueReadSubstituted.test.md | 0 .../scenarios/IssueRecovery.attempt.stage.md | 0 .../tests/scenarios/IssueRecovery.test.md | 0 .../IssueRecoveryClosed.attempt.stage.md | 0 .../scenarios/IssueRecoveryClosed.test.md | 0 .../IssueRecoveryDuplicated.attempt.stage.md | 0 .../scenarios/IssueRecoveryDuplicated.test.md | 0 .../IssueRecoveryEdited.attempt.stage.md | 0 .../scenarios/IssueRecoveryEdited.test.md | 0 .../IssueRecoveryMoved.attempt.stage.md | 0 .../scenarios/IssueRecoveryMoved.test.md | 0 .../IssueRecoveryUnavailable.attempt.stage.md | 0 .../IssueRecoveryUnavailable.test.md | 0 .../tests/scenarios/IssueRouting.test.md | 0 .../tests/scenarios/IssueUpsert.test.md | 0 .../tests/scenarios/sessions/Failing.test.md | 0 .../tests/scenarios/sessions/First.test.md | 0 .../tests/scenarios/sessions/Passing.test.md | 0 .../tests/scenarios/sessions/Second.test.md | 0 .../tests/scenarios/sessions/Untested.md | 0 .../tests/selection-authentication.test.ts | 0 .../tests/support/composition.ts | 53 ++- .../tests/support/credential-helper-entry.ts | 0 .../tests/support/credential-home.ts | 0 .../tests/support/git-crash-child.ts | 35 +- .../tests/support/git-crash-process.ts | 0 .../tests/support/git-http.ts | 0 .../tests/support/git-remotes.ts | 0 .../{workflow => git}/tests/support/github.ts | 0 .../tests/support/issue-providers.ts | 0 .../tests/support/issue-scenario.ts | 4 +- .../tests/support/issue-tracker-server.ts | 0 .../tests/support/pull-request-crash-child.ts | 19 +- .../tests/support/pull-requests.ts | 4 +- .../{workflow => git}/tests/support/replay.ts | 4 +- .../tests/support/run-composition-child.ts | 0 .../tests/support/run-composition-tier.ts | 0 .../tests/support/run-composition.ts | 0 .../tests/worktree-replay.test.ts | 16 +- packages/test-support/host-boundary.ts | 349 ++++++++++++++ packages/test-support/package.json | 1 + packages/workflow/deno.json | 3 +- packages/workflow/deno.ts | 48 +- packages/workflow/mod.ts | 255 +---------- packages/workflow/package.json | 3 +- .../workflow/src/deno/workspace/evaluate.ts | 24 +- packages/workflow/src/deno/workspace/host.ts | 138 ++---- .../workflow/src/deno/workspace/published.ts | 62 +-- .../workflow/src/lifecycle/forkability.ts | 157 ++++++- packages/workflow/src/run.ts | 133 +----- .../workflow/tests/public-entrypoint.test.ts | 196 ++++---- packages/workflow/tests/retained-run.test.ts | 2 +- .../tests/workflow-checkpoint.test.ts | 2 +- .../workflow/tests/workflow-export.test.ts | 4 +- packages/workflow/tests/workflow-fork.test.ts | 102 +++++ .../workflow-lifecycle-inspection.test.ts | 2 +- .../tests/workflow-run-storage.test.ts | 2 +- packages/workflow/tests/workflow-run.test.ts | 11 +- .../workspace-effect-loaded-copy.test.ts | 63 ++- .../workflow/tests/workspace-effect.test.ts | 360 +-------------- .../workflow/tests/workspace-files.test.ts | 4 + pnpm-lock.yaml | 27 ++ scripts/lib/compile.ts | 2 +- scripts/runtime-test-exclusions.ts | 56 +-- .../tests/documentation-validation.test.ts | 2 +- 226 files changed, 2882 insertions(+), 1321 deletions(-) create mode 100644 packages/cli/src/git-plugin-installation.ts create mode 100644 packages/git/credential-helper.ts create mode 100644 packages/git/deno.json create mode 100644 packages/git/deno.ts create mode 100644 packages/git/mod.ts create mode 100644 packages/git/package.json rename packages/{workflow => git}/src/composition/api.ts (100%) rename packages/{workflow => git}/src/composition/components.md (100%) rename packages/{workflow => git}/src/composition/components/Dir.ts (100%) rename packages/{workflow => git}/src/composition/components/GitAdd.ts (100%) rename packages/{workflow => git}/src/composition/components/GitCommit.ts (100%) rename packages/{workflow => git}/src/composition/components/GitPush.ts (100%) rename packages/{workflow => git}/src/composition/components/GitSwitch.ts (100%) rename packages/{workflow => git}/src/composition/components/Issue.ts (100%) rename packages/{workflow => git}/src/composition/components/IssueTracker.ts (100%) rename packages/{workflow => git}/src/composition/components/PullRequest.ts (100%) rename packages/{workflow => git}/src/composition/components/PullRequestReads.ts (100%) rename packages/{workflow => git}/src/composition/components/Repository.ts (100%) rename packages/{workflow => git}/src/composition/components/Worktree.ts (100%) rename packages/{workflow => git}/src/composition/context.ts (100%) rename packages/{workflow => git}/src/composition/definitions.ts (100%) rename packages/{workflow => git}/src/composition/errors.ts (99%) rename packages/{workflow => git}/src/composition/git-api.ts (100%) rename packages/{workflow => git}/src/composition/git-push-records.ts (100%) rename packages/{workflow => git}/src/composition/git-records.ts (100%) rename packages/{workflow => git}/src/composition/installation.ts (99%) rename packages/{workflow => git}/src/composition/parse.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-api.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-operations.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-read-execution.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-read-records.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-records.ts (100%) rename packages/{workflow => git}/src/composition/pull-request-target.ts (100%) rename packages/{workflow => git}/src/composition/push-evidence.ts (100%) rename packages/{workflow => git}/src/composition/records.ts (100%) rename packages/{workflow => git}/src/composition/selection.ts (100%) create mode 100644 packages/git/src/deno/attachment.ts rename packages/{workflow => git}/src/deno/composition/add.ts (98%) rename packages/{workflow => git}/src/deno/composition/authentication.ts (100%) rename packages/{workflow => git}/src/deno/composition/commit.ts (99%) rename packages/{workflow => git}/src/deno/composition/credential-helper.ts (100%) rename packages/{workflow => git}/src/deno/composition/effects.ts (94%) rename packages/{workflow => git}/src/deno/composition/git.ts (100%) rename packages/{workflow => git}/src/deno/composition/github.ts (100%) rename packages/{workflow => git}/src/deno/composition/host.ts (100%) rename packages/{workflow => git}/src/deno/composition/identity.ts (99%) rename packages/{workflow => git}/src/deno/composition/locator.ts (100%) rename packages/{workflow => git}/src/deno/composition/materialize.ts (98%) rename packages/{workflow => git}/src/deno/composition/object-source.ts (100%) rename packages/{workflow => git}/src/deno/composition/operations.ts (99%) rename packages/{workflow => git}/src/deno/composition/placement.ts (100%) rename packages/{workflow => git}/src/deno/composition/provider.ts (98%) rename packages/{workflow => git}/src/deno/composition/pull-request-evidence.ts (100%) rename packages/{workflow => git}/src/deno/composition/pull-request-operations.ts (98%) rename packages/{workflow => git}/src/deno/composition/pull-request-reads.ts (99%) rename packages/{workflow => git}/src/deno/composition/pull-request.ts (98%) rename packages/{workflow => git}/src/deno/composition/push.ts (99%) rename packages/{workflow => git}/src/deno/composition/refusals.ts (99%) rename packages/{workflow => git}/src/deno/composition/repository.ts (97%) rename packages/{workflow => git}/src/deno/composition/subprocess.ts (100%) rename packages/{workflow => git}/src/deno/composition/switch.ts (99%) rename packages/{workflow => git}/src/deno/composition/worktree.ts (98%) rename packages/{workflow => git}/src/deno/issue/github.ts (100%) rename packages/{workflow/src/deno/workspace => git/src/deno}/repositories.ts (98%) rename packages/{workflow => git}/src/deno/run-composition/ambient.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/checkouts.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/errors.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/identity.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/leases.ts (97%) rename packages/{workflow => git}/src/deno/run-composition/metadata.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/operations.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/placement.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/provider.ts (100%) rename packages/{workflow => git}/src/deno/run-composition/pull-request.ts (100%) rename packages/{workflow => git}/src/deno/selections.ts (100%) rename packages/{workflow => git}/src/git-host/api.ts (100%) rename packages/{workflow => git}/src/git-host/effect-type.ts (100%) rename packages/{workflow => git}/src/git-host/effect.ts (99%) rename packages/{workflow => git}/src/git-host/errors.ts (100%) rename packages/{workflow => git}/src/git-host/identities.ts (99%) rename packages/{workflow => git}/src/git-host/records.ts (99%) rename packages/{workflow => git}/src/git.ts (100%) create mode 100644 packages/git/src/identities.ts create mode 100644 packages/git/src/installation.ts rename packages/{workflow => git}/src/issue/api.ts (100%) rename packages/{workflow => git}/src/issue/context.ts (100%) rename packages/{workflow => git}/src/issue/effect-type.ts (100%) rename packages/{workflow => git}/src/issue/effect.ts (98%) rename packages/{workflow => git}/src/issue/errors.ts (100%) rename packages/{workflow => git}/src/issue/identities.ts (99%) rename packages/{workflow => git}/src/issue/operations.ts (100%) rename packages/{workflow => git}/src/issue/records.ts (99%) rename packages/{workflow => git}/src/issue/tracker.ts (100%) create mode 100644 packages/git/src/plugin.ts rename packages/{workflow => git}/tests/ambient-authentication.test.ts (99%) rename packages/{workflow => git}/tests/credential-helper.test.ts (100%) rename packages/{workflow => git}/tests/git-add-crash.test.ts (98%) rename packages/{workflow => git}/tests/git-add-durability.test.ts (96%) rename packages/{workflow => git}/tests/git-add.test.ts (98%) rename packages/{workflow => git}/tests/git-commit-crash.test.ts (98%) rename packages/{workflow => git}/tests/git-commit-durability.test.ts (96%) rename packages/{workflow => git}/tests/git-commit.test.ts (98%) rename packages/{workflow => git}/tests/git-host-effect.test.ts (97%) rename packages/{workflow => git}/tests/git-push-crash.test.ts (99%) rename packages/{workflow => git}/tests/git-push-durability.test.ts (99%) rename packages/{workflow => git}/tests/git-push.test.ts (99%) rename packages/{workflow => git}/tests/git-switch-crash.test.ts (98%) rename packages/{workflow => git}/tests/git-switch-durability.test.ts (97%) rename packages/{workflow => git}/tests/git-switch.test.ts (97%) rename packages/{workflow => git}/tests/git.test.ts (100%) rename packages/{workflow => git}/tests/issue-github.test.ts (100%) rename packages/{workflow => git}/tests/issue-markdown.test.ts (100%) rename packages/{workflow => git}/tests/issue-records.test.ts (100%) rename packages/{workflow => git}/tests/materialization.test.ts (96%) create mode 100644 packages/git/tests/plugin.test.ts create mode 100644 packages/git/tests/provider-neutrality.test.ts rename packages/{workflow => git}/tests/pull-request-crash.test.ts (97%) rename packages/{workflow => git}/tests/pull-request-durability.test.ts (99%) rename packages/{workflow => git}/tests/pull-request-github.test.ts (100%) rename packages/{workflow => git}/tests/pull-request-read.test.ts (99%) rename packages/{workflow => git}/tests/pull-request-records.test.ts (100%) rename packages/{workflow => git}/tests/pull-request.test.ts (99%) rename packages/{workflow => git}/tests/repository-components.test.ts (99%) rename packages/{workflow => git}/tests/repository-control-plane.test.ts (96%) rename packages/{workflow => git}/tests/repository-materialization.test.ts (95%) rename packages/{workflow => git}/tests/repository-replay.test.ts (98%) rename packages/{workflow => git}/tests/repository-storage.test.ts (99%) rename packages/{workflow => git}/tests/run-composition-ambient.test.ts (100%) rename packages/{workflow => git}/tests/run-composition-managed.test.ts (100%) rename packages/{workflow => git}/tests/run-composition-remote.test.ts (100%) rename packages/{workflow => git}/tests/scenarios/IssueDurability.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueDurability.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueHttp.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRead.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueReadDurability.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueReadDurability.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueReadSubstituted.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueReadSubstituted.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecovery.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecovery.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryClosed.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryClosed.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryDuplicated.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryDuplicated.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryEdited.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryEdited.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryMoved.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryMoved.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryUnavailable.attempt.stage.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRecoveryUnavailable.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueRouting.test.md (100%) rename packages/{workflow => git}/tests/scenarios/IssueUpsert.test.md (100%) rename packages/{workflow => git}/tests/scenarios/sessions/Failing.test.md (100%) rename packages/{workflow => git}/tests/scenarios/sessions/First.test.md (100%) rename packages/{workflow => git}/tests/scenarios/sessions/Passing.test.md (100%) rename packages/{workflow => git}/tests/scenarios/sessions/Second.test.md (100%) rename packages/{workflow => git}/tests/scenarios/sessions/Untested.md (100%) rename packages/{workflow => git}/tests/selection-authentication.test.ts (100%) rename packages/{workflow => git}/tests/support/composition.ts (93%) rename packages/{workflow => git}/tests/support/credential-helper-entry.ts (100%) rename packages/{workflow => git}/tests/support/credential-home.ts (100%) rename packages/{workflow => git}/tests/support/git-crash-child.ts (89%) rename packages/{workflow => git}/tests/support/git-crash-process.ts (100%) rename packages/{workflow => git}/tests/support/git-http.ts (100%) rename packages/{workflow => git}/tests/support/git-remotes.ts (100%) rename packages/{workflow => git}/tests/support/github.ts (100%) rename packages/{workflow => git}/tests/support/issue-providers.ts (100%) rename packages/{workflow => git}/tests/support/issue-scenario.ts (99%) rename packages/{workflow => git}/tests/support/issue-tracker-server.ts (100%) rename packages/{workflow => git}/tests/support/pull-request-crash-child.ts (84%) rename packages/{workflow => git}/tests/support/pull-requests.ts (97%) rename packages/{workflow => git}/tests/support/replay.ts (98%) rename packages/{workflow => git}/tests/support/run-composition-child.ts (100%) rename packages/{workflow => git}/tests/support/run-composition-tier.ts (100%) rename packages/{workflow => git}/tests/support/run-composition.ts (100%) rename packages/{workflow => git}/tests/worktree-replay.test.ts (91%) create mode 100644 packages/test-support/host-boundary.ts diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 0c81d5f20..e40486d91 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do + for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/workflow/deno.json packages/git/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -89,6 +89,20 @@ jobs: package: packages/acp version: ${{ needs.version.outputs.value }} + workflow: + needs: [version, core, durable-streams, runtime] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/workflow + version: ${{ needs.version.outputs.value }} + + git: + needs: [version, core, durable-streams, runtime, workflow] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/git + version: ${{ needs.version.outputs.value }} + testing: needs: [version, core, durable-streams, runtime] uses: ./.github/workflows/publish-one.yml @@ -110,15 +124,8 @@ jobs: package: packages/web version: ${{ needs.version.outputs.value }} - workflow: - needs: [version, core, durable-streams, runtime] - uses: ./.github/workflows/publish-one.yml - with: - package: packages/workflow - version: ${{ needs.version.outputs.value }} - cli: - needs: [version, acp, core, durable-streams, runtime, test-agent, testing, web, workflow] + needs: [version, acp, core, durable-streams, git, runtime, test-agent, testing, web, workflow] uses: ./.github/workflows/publish-one.yml with: package: packages/cli diff --git a/deno.lock b/deno.lock index 0e2a94b20..ec96c9990 100644 --- a/deno.lock +++ b/deno.lock @@ -4126,6 +4126,15 @@ ] } }, + "packages/git": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/fs@0.3.0", + "npm:effection@4.1.0" + ] + } + }, "packages/runtime": { "packageJson": { "dependencies": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index b908ff897..c31447982 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,7 @@ "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/git": "workspace:*", "@executablemd/runtime": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f7e938153..469a22de4 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -160,7 +160,8 @@ import type { HostWorkflowInstaller, WorkflowHost, WorkflowStart } from "./workf import { runWorkflowManagement } from "./workflow-management.ts"; import { establishDefinition } from "./workflow-definition.ts"; import type { EstablishedDefinition } from "./workflow-definition.ts"; -import { useCompositionComponents, useWorkflowServiceDenial } from "@executablemd/workflow"; +import { useWorkflowServiceDenial } from "@executablemd/workflow"; +import { useCompositionComponents } from "@executablemd/git"; import denoJson from "../deno.json" with { type: "json" }; const SECRET_DETECTION_OPTION = "--secret-detection"; diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index fffd4800b..34f62cd56 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -16,11 +16,8 @@ import { compiledUpgradeAssembly } from "./compiled-upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; import { denoRunRepositories } from "./deno-repositories.ts"; -import { - isCredentialHelperMode, - runCredentialHelper, -} from "@executablemd/workflow/credential-helper"; -import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; /** diff --git a/packages/cli/src/credential-helper-entry.ts b/packages/cli/src/credential-helper-entry.ts index 95c62b10d..3e936ddf5 100644 --- a/packages/cli/src/credential-helper-entry.ts +++ b/packages/cli/src/credential-helper-entry.ts @@ -13,10 +13,7 @@ import process from "node:process"; import { main } from "effection"; -import { - isCredentialHelperMode, - runCredentialHelper, -} from "@executablemd/workflow/credential-helper"; +import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; // The launcher already names the mode; what follows it is Git's operation. The // whole of what this program does is awaited, so a failure to read the request diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index 1b8346769..c3e898f00 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -16,8 +16,8 @@ import type { Operation } from "effection"; import { cwd } from "@executablemd/runtime"; -import { useRunComposition } from "@executablemd/workflow/deno"; -import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import { useRunComposition } from "@executablemd/git/deno"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; diff --git a/packages/cli/src/deno-workflow.ts b/packages/cli/src/deno-workflow.ts index c3fd8d355..5ea0120d0 100644 --- a/packages/cli/src/deno-workflow.ts +++ b/packages/cli/src/deno-workflow.ts @@ -29,9 +29,10 @@ import { useWorkflowRunHost, withWorkflowWorkspace, } from "@executablemd/workflow/deno"; +import { gitWorkspaceAttachment } from "@executablemd/git/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; -import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { readLegacyDefinitionSource } from "./workflow-source.ts"; import type { WorkflowHost } from "./workflow.ts"; import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; @@ -74,9 +75,18 @@ export function* useDenoWorkflowHost(helper: HelperAssembly): Operation(database: WorkflowRunDatabase, operation: Operation): Operation { return withWorkflowWorkspace(database, operation, { - ...(gitHubIssues === undefined ? {} : { gitHubIssues }), - ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), - helper, + // The Git vocabulary is a feature this host attaches, not something the + // run's own package installs: what `` and `` mean + // belongs to `@executablemd/git`, and the credential helper and the two + // GitHub ceilings are configuration for that feature rather than for + // the run. + attachments: [ + gitWorkspaceAttachment({ + ...(gitHubIssues === undefined ? {} : { gitHubIssues }), + ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), + helper, + }), + ], // Only a live or partial attachment reaches this, which is what keeps a // completed replay from starting an agent process to restore a turn it // already has the answer to. diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 481854411..d13039f80 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -19,11 +19,8 @@ import type { UpgradeAssembly } from "./upgrade.ts"; import { useMachineSessions } from "./session-coordinator.ts"; import { useDenoWorkflowHost } from "./deno-workflow.ts"; import { denoRunRepositories } from "./deno-repositories.ts"; -import { - isCredentialHelperMode, - runCredentialHelper, -} from "@executablemd/workflow/credential-helper"; -import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; +import { isCredentialHelperMode, runCredentialHelper } from "@executablemd/git/credential-helper"; +import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { useDenoService } from "./deno-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); diff --git a/packages/cli/src/git-plugin-installation.ts b/packages/cli/src/git-plugin-installation.ts new file mode 100644 index 000000000..b944f8970 --- /dev/null +++ b/packages/cli/src/git-plugin-installation.ts @@ -0,0 +1,38 @@ +/** + * What the bundled Git Plugin contributes to one workflow execution. + * + * Asked of the Plugin value rather than assembled here. Its admissions are what + * let a replay recognize the Git-host and Issue records a run retained or + * inherited, and each one derives an execution's identities from that + * execution's own snapshot — so one Plugin value serves every execution the + * command runs without any of them reading another's history. + * + * The workflow command reaches the Plugin directly because it assembles its + * execution itself. The ordinary run profile does not need this: a Plugin the + * command selected is installed by `plugin-host.ts`, and what it returns + * already travels on `CommandPlugins.installations`. + */ + +import type { Operation } from "effection"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { gitPlugin } from "@executablemd/git"; + +/** + * The Git Plugin's contribution for one workflow action, as installations. + * + * The action is stated as the argv that names it rather than on its own, so + * what the Plugin answers here is what it answers for the real command line. + */ +export function* gitPluginInstallation(action: string): Operation { + const install = gitPlugin.install; + if (install === undefined) { + return {}; + } + // The command token and the action, as the Plugin's own predicate reads an + // argv: it locates `workflow` and then finds the first positional after it. + const installed = yield* install.call(gitPlugin, { + command: "workflow", + args: ["workflow", action], + }); + return { admissions: [...(installed?.admissions ?? [])] }; +} diff --git a/packages/cli/src/github-issues-config.ts b/packages/cli/src/github-issues-config.ts index 51c2a6c39..896267076 100644 --- a/packages/cli/src/github-issues-config.ts +++ b/packages/cli/src/github-issues-config.ts @@ -18,8 +18,8 @@ import { env as readEnv } from "@executablemd/runtime"; import type { Operation } from "effection"; -import { canonicalIssueTarget } from "@executablemd/workflow"; -import type { GitHubIssuesOptions } from "@executablemd/workflow/deno"; +import { canonicalIssueTarget } from "@executablemd/git"; +import type { GitHubIssuesOptions } from "@executablemd/git/deno"; /** The variable that configures GitHub issue handling. */ export const GITHUB_ISSUES_ENV = "XMD_WORKFLOW_GITHUB_ISSUES"; diff --git a/packages/cli/src/github-pull-requests-config.ts b/packages/cli/src/github-pull-requests-config.ts index d4e6f667d..5563c8fe4 100644 --- a/packages/cli/src/github-pull-requests-config.ts +++ b/packages/cli/src/github-pull-requests-config.ts @@ -31,8 +31,8 @@ import { env as readEnv } from "@executablemd/runtime"; import type { Operation } from "effection"; -import { canonicalPullRequestUrl } from "@executablemd/workflow"; -import type { GitHubPullRequestsOptions } from "@executablemd/workflow/deno"; +import { canonicalPullRequestUrl } from "@executablemd/git"; +import type { GitHubPullRequestsOptions } from "@executablemd/git/deno"; /** The variable that configures GitHub pull-request reading. */ export const GITHUB_PULL_REQUESTS_ENV = "XMD_WORKFLOW_GITHUB_PULL_REQUESTS"; diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index b9ebf0840..565ce336d 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -44,7 +44,7 @@ import type { SyntaxSymbols } from "@executablemd/core"; import { useTestingComponents } from "@executablemd/testing"; import { useWebComponents } from "@executablemd/web"; import { useVerboseComponent } from "./verbose-component.ts"; -import { useCompositionComponents } from "@executablemd/workflow"; +import { useCompositionComponents } from "@executablemd/git"; import type { ExecutionDeclaration } from "@executablemd/core/host"; import { NO_PLUGINS } from "./plugin-host.ts"; import type { CommandPlugins } from "./plugin-host.ts"; diff --git a/packages/cli/src/workflow-bundle.ts b/packages/cli/src/workflow-bundle.ts index 370f3a584..28b504090 100644 --- a/packages/cli/src/workflow-bundle.ts +++ b/packages/cli/src/workflow-bundle.ts @@ -45,13 +45,10 @@ import { RESERVED_STRUCTURAL, } from "@executablemd/core"; import type { WorkflowBundleComponent } from "@executablemd/core/host"; -import { - decodeSourceText, - readGitObject, - revParse, - sourceContentHash, -} from "@executablemd/workflow"; -import type { GitObjectFormat, WorkflowComponentEntry } from "@executablemd/workflow"; +import { decodeSourceText, sourceContentHash } from "@executablemd/workflow"; +import { readGitObject, revParse } from "@executablemd/git"; +import type { WorkflowComponentEntry } from "@executablemd/workflow"; +import type { GitObjectFormat } from "@executablemd/git"; import type { EstablishedComponent } from "./workflow-definition.ts"; /** Hexadecimal digits per object id, by the format that names them. */ diff --git a/packages/cli/src/workflow-definition.ts b/packages/cli/src/workflow-definition.ts index 4d384adcf..4f8194327 100644 --- a/packages/cli/src/workflow-definition.ts +++ b/packages/cli/src/workflow-definition.ts @@ -45,14 +45,11 @@ import type { WorkflowBundleComponent } from "@executablemd/core/host"; import { decodeSourceText, definitionComponents, - gitObjectFormat, parseSourceBundleDefinition, - readGitObject, - repositoryRoot, - revParse, sourceBundleHash, sourceContentHash, } from "@executablemd/workflow"; +import { gitObjectFormat, readGitObject, repositoryRoot, revParse } from "@executablemd/git"; import type { GitWorkflowDefinitionV1, SourceBundleEntryV2, diff --git a/packages/cli/src/workflow-fork.ts b/packages/cli/src/workflow-fork.ts index e5c0ed09e..eb1686356 100644 --- a/packages/cli/src/workflow-fork.ts +++ b/packages/cli/src/workflow-fork.ts @@ -62,6 +62,7 @@ import { workflowBundleInstallation, WorkflowLifecycle, } from "@executablemd/workflow"; +import type { ExecutionInstallation } from "@executablemd/core/host"; import type { ForkSelection, WorkflowRun } from "@executablemd/workflow"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import type { @@ -133,6 +134,7 @@ export function* preflightFork( request: ForkRequest, host: ForkPreflightHost, execute: (execution: WorkflowExecution) => Operation>, + git: ExecutionInstallation, ): Operation> { const history = yield* WorkflowLifecycle.operations.history(request.sourceRunId); if (!history.ok) { @@ -154,7 +156,7 @@ export function* preflightFork( ? {} : { targetPath: request.creation.definition.targetPath }), }; - const imported = yield* captureRootImport(request, run, execute); + const imported = yield* captureRootImport(request, run, execute, git); if (!imported.ok) { return imported; } @@ -180,8 +182,14 @@ export function* preflightFork( return staged; } const database = staged.value; - return yield* replayPrefix(request, run, journal, identities, execute, (operation) => - host.attach(database, operation), + return yield* replayPrefix( + request, + run, + journal, + identities, + execute, + (operation) => host.attach(database, operation), + git, ); }); if (!checked.ok) { @@ -208,6 +216,7 @@ function* captureRootImport( request: ForkRequest, run: WorkflowRun, execute: (execution: WorkflowExecution) => Operation>, + git: ExecutionInstallation, ): Operation> { const record = forkRunRecordEvent(run); let captured: DurableEvent | undefined; @@ -229,7 +238,7 @@ function* captureRootImport( }, }; - const attempted = yield* execute(execution(request, run, stream, passThrough)); + const attempted = yield* execute(execution(request, run, stream, passThrough, git)); if (captured !== undefined) { return Ok(captured); } @@ -254,6 +263,7 @@ function* replayPrefix( identities: readonly string[], execute: (execution: WorkflowExecution) => Operation>, attach: (operation: Operation) => Operation, + git: ExecutionInstallation, ): Operation> { const total = journal.filter((event) => event.type === "yield").length; const progress = { consumed: 0 }; @@ -290,7 +300,7 @@ function* replayPrefix( ); } - const attempted = yield* execute(execution(request, run, stream, boundary)); + const attempted = yield* execute(execution(request, run, stream, boundary, git)); if (attempted.ok) { return Err( new Error( @@ -324,6 +334,7 @@ function execution( run: WorkflowRun, stream: DurableStream, around: (operation: Operation) => Operation, + git: ExecutionInstallation, ): WorkflowExecution { return { root: retainedSource(request.creation.definition.entrypoint, request.established.source, { @@ -335,6 +346,10 @@ function execution( stream, installations: [ retainedWorkflowInstallation(run), + // What the Git Plugin admits of the inherited history. Without its + // admissions an inherited effect is named by a live identity and the + // candidate diverges from its own history. + git, ...(request.established.components.length === 0 ? [] : [workflowBundleInstallation(request.established.components)]), diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts index a49c880ef..602371ee4 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -79,6 +79,7 @@ import { WORKFLOW_RUN_STATUSES, WorkflowLifecycle, } from "@executablemd/workflow"; +import { gitPluginInstallation } from "./git-plugin-installation.ts"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { ExecutorLock, @@ -922,6 +923,13 @@ export function runWorkflow( return { exitCode: 1 }; } + // Asked of the Plugin once, here, because a Plugin installs once per + // command. Its declarations are the command's and belong in this scope; + // asking again lower down would register the same names in the same scope + // a second time. Both the fork preflight and the run's own execution are + // handed this one value. + const git = yield* gitPluginInstallation(request.action); + // Before the executor lock, and before a destination exists: a fork that // cannot reproduce the prefix it asked to inherit is a request being // refused, not a run that failed. @@ -933,6 +941,7 @@ export function runWorkflow( host, transitions, execute, + git, ); if (!inheritance.ok) { report(inheritance.error.message); @@ -1064,6 +1073,12 @@ export function runWorkflow( // real adapter. installations: [ retainedWorkflowInstallation(installedRun(record)), + // What the Git Plugin admits of this run's retained history, taken from + // the Plugin value itself. Its admissions are what let a replay + // recognize the Git-host and Issue records it inherited, and each one + // derives this execution's identities from this execution's own + // snapshot. + git, // The bundle this run is a run of, when it is a run of one. Both start // and resume install it, and a completed replay installs it too: the // retained history is held to the same components before its recorded @@ -1219,6 +1234,7 @@ function* forkInheritance( host: WorkflowHost, transitions: WorkflowExecutionTransitions, execute: (execution: WorkflowExecution) => Operation>, + git: ExecutionInstallation, ): Operation> { if (request.action !== "fork") { return Ok(undefined); @@ -1240,6 +1256,7 @@ function* forkInheritance( }, { transitions, attach: (database, operation) => host.attach(database, operation) }, execute, + git, ); if (!checked.ok) { return checked; diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 58d10bbe4..50f80a5b8 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -33,8 +33,8 @@ import { } from "@executablemd/core"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { deriveSessionKey, sessionCandidates } from "../../acp/src/session-key.ts"; -import { useCompositionComponents } from "@executablemd/workflow"; -import { useRunComposition } from "@executablemd/workflow/deno"; +import { useCompositionComponents } from "@executablemd/git"; +import { useRunComposition } from "@executablemd/git/deno"; import { FileStream } from "../src/file-stream.ts"; /** Git, with an environment a caller's own configuration cannot reach into. */ diff --git a/packages/cli/tests/run-composition-nested.test.ts b/packages/cli/tests/run-composition-nested.test.ts index a4b701dbe..68784321d 100644 --- a/packages/cli/tests/run-composition-nested.test.ts +++ b/packages/cli/tests/run-composition-nested.test.ts @@ -23,8 +23,8 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { collect, execute, inlineSource, installAgentComponents } from "@executablemd/core"; import { runCli } from "@executablemd/test-support/launch"; import { useTempDirectory } from "@executablemd/test-support/temp"; -import { useCompositionComponents } from "@executablemd/workflow"; -import { useRunComposition } from "@executablemd/workflow/deno"; +import { useCompositionComponents } from "@executablemd/git"; +import { useRunComposition } from "@executablemd/git/deno"; import { FileStream } from "../src/file-stream.ts"; /** Git, with an environment a caller's own configuration cannot reach into. */ diff --git a/packages/cli/tests/run-composition.test.ts b/packages/cli/tests/run-composition.test.ts index 2dc7d3eca..d23a72285 100644 --- a/packages/cli/tests/run-composition.test.ts +++ b/packages/cli/tests/run-composition.test.ts @@ -26,7 +26,7 @@ import type { RuntimeFetchResponse } from "@executablemd/runtime"; import { exists, readdir, readTextFile, writeTextFile } from "@effectionx/fs"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { join } from "node:path"; -import { COMPOSITION_REGISTRATIONS } from "@executablemd/workflow"; +import { COMPOSITION_REGISTRATIONS } from "@executablemd/git"; import { syntaxSymbols, useCommandComponents } from "../src/syntax.ts"; import { DEFAULT_REPOSITORY_ROOT, unsupportedRepositories } from "../src/run-repositories.ts"; diff --git a/packages/cli/tests/testing-activation.test.ts b/packages/cli/tests/testing-activation.test.ts index e25769443..84063db49 100644 --- a/packages/cli/tests/testing-activation.test.ts +++ b/packages/cli/tests/testing-activation.test.ts @@ -19,7 +19,8 @@ import type { DurableEvent } from "@executablemd/durable-streams"; import { inlineSource, registerComponents, useTempFileCompiler } from "@executablemd/core"; import type { Json } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { Git, retainedWorkflowInstallation } from "@executablemd/workflow"; +import { retainedWorkflowInstallation } from "@executablemd/workflow"; +import { Git } from "@executablemd/git"; import { installTestingComponents } from "@executablemd/testing"; import type { TestResult } from "@executablemd/testing"; diff --git a/packages/cli/tests/workflow-host.test.ts b/packages/cli/tests/workflow-host.test.ts index f28d379e3..0d53edd6a 100644 --- a/packages/cli/tests/workflow-host.test.ts +++ b/packages/cli/tests/workflow-host.test.ts @@ -40,13 +40,13 @@ import { isCredentialHelperMode, launcherName, launcherProgram, -} from "@executablemd/workflow/credential-helper"; +} from "@executablemd/git/credential-helper"; import type { HelperAssembly, HelperPlatform, HelperRuntime, -} from "@executablemd/workflow/credential-helper"; -import { HELPER_VARIABLES } from "@executablemd/workflow/credential-helper"; +} from "@executablemd/git/credential-helper"; +import { HELPER_VARIABLES } from "@executablemd/git/credential-helper"; /** The one sentence a host without workflow support says. */ const UNSUPPORTED = diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts index fe68b0589..408410301 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -27,7 +27,8 @@ import { useWorkflowRunHost, } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; -import { Git, WorkflowLifecycle, WorkflowRunStorage } from "@executablemd/workflow"; +import { WorkflowLifecycle, WorkflowRunStorage } from "@executablemd/workflow"; +import { Git } from "@executablemd/git"; import type { WorkflowRunDatabase, WorkflowRunStatus } from "@executablemd/workflow"; import { runWorkflow } from "../src/workflow.ts"; import type { WorkflowExecution, WorkflowHost, WorkflowRequest } from "../src/workflow.ts"; @@ -290,12 +291,14 @@ describe("Tier WFI — what a run hands to canonical core", () => { ); expect(preparing.length).toEqual(1); expect(preparing[0]?.admissions?.length).toEqual(1); - // Every installation this run was given is one of three things, and none of + // Every installation this run was given is one of four things, and none of // them is a second execution: one `executeInstalled()`, not one per phase. // A run-contract installation carries its admission; a bundle carries its - // own admission and no preparation; and the fragment-evaluation profile - // carries a ceiling and no admission at all, because stating what a - // generated fragment may do is not a claim about this run's history. + // own admission and no preparation; the bundled Git Plugin carries the two + // journal admissions that let a replay recognize the Git-host and Issue + // records this history holds; and the fragment-evaluation profile carries a + // ceiling and no admission at all, because stating what a generated + // fragment may do is not a claim about this run's history. const profiles = (execution?.installations ?? []).filter( (candidate) => candidate.evaluation !== undefined, ); @@ -304,10 +307,19 @@ describe("Tier WFI — what a run hands to canonical core", () => { expect(candidate.admissions).toBe(undefined); expect(candidate.prepare).toBe(undefined); expect(candidate.components).toBe(undefined); - continue; } - expect(candidate.admissions?.length).toEqual(1); } + // The exact contribution, rather than a rule each one satisfies: this run + // installs no bundle, so what reaches core is the run contract's one + // admission and the Git Plugin's two. A contribution that went missing, an + // installation that arrived twice, or an admission that was dropped on the + // way all change this list. + expect( + (execution?.installations ?? []) + .filter((candidate) => candidate.evaluation === undefined) + .map((candidate) => candidate.admissions?.length ?? 0) + .toSorted((left, right) => left - right), + ).toEqual([1, 2]); // The ceiling is stated exactly once, and it is a real one: a run that // installed no profile, or an empty one, would leave `` with diff --git a/packages/cli/tests/workflow-lifecycle-control.test.ts b/packages/cli/tests/workflow-lifecycle-control.test.ts index d6e7226f0..2164be52b 100644 --- a/packages/cli/tests/workflow-lifecycle-control.test.ts +++ b/packages/cli/tests/workflow-lifecycle-control.test.ts @@ -25,7 +25,8 @@ import { useWorkflowRunHost, } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; -import { Git, suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; +import { suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; +import { Git } from "@executablemd/git"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { collect, inlineSource, registerComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; diff --git a/packages/cli/tests/workflow-suspension.test.ts b/packages/cli/tests/workflow-suspension.test.ts index 8ce29b4fd..2c57d12e2 100644 --- a/packages/cli/tests/workflow-suspension.test.ts +++ b/packages/cli/tests/workflow-suspension.test.ts @@ -50,7 +50,8 @@ import { useWorkflowRunHost, } from "@executablemd/workflow/deno"; import type { WorkflowExecutionTransitions } from "@executablemd/workflow/deno"; -import { Git, SUSPENSION_REQUEST, suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; +import { SUSPENSION_REQUEST, suspendFor, WorkflowLifecycle } from "@executablemd/workflow"; +import { Git } from "@executablemd/git"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { workflowRunPath } from "@executablemd/workflow/deno"; import { withWorkflowWorkspace, WORKSPACE_FILE } from "@executablemd/workflow/deno"; diff --git a/packages/git/credential-helper.ts b/packages/git/credential-helper.ts new file mode 100644 index 000000000..9c3e38620 --- /dev/null +++ b/packages/git/credential-helper.ts @@ -0,0 +1,12 @@ +/** + * @module + * + * The credential helper this package's Git invocations run. + * + * A separate entrypoint because it is an executable: Git starts it as its own + * process and speaks the credential-helper protocol to it over standard IO. + * Nothing that imports `@executablemd/git` loads this, and nothing here reaches + * the rest of the package. + */ + +export * from "./src/deno/composition/credential-helper.ts"; diff --git a/packages/git/deno.json b/packages/git/deno.json new file mode 100644 index 000000000..b6178b605 --- /dev/null +++ b/packages/git/deno.json @@ -0,0 +1,10 @@ +{ + "name": "@executablemd/git", + "version": "0.12.1", + "license": "MIT", + "exports": { + ".": "./mod.ts", + "./deno": "./deno.ts", + "./credential-helper": "./credential-helper.ts" + } +} diff --git a/packages/git/deno.ts b/packages/git/deno.ts new file mode 100644 index 000000000..3e70d388e --- /dev/null +++ b/packages/git/deno.ts @@ -0,0 +1,50 @@ +/** + * @module + * + * The Deno host's Git capability. + * + * Native Git, managed checkouts, the local repository provider and the Git-host + * adapters this platform can reach. Keeping them behind their own entrypoint is + * what lets `@executablemd/git` stay provider-neutral: subprocesses, filesystem + * paths and Deno's own behavior live here and nowhere above. + * + * Importing this module performs nothing. It discovers no repository, creates + * no directory, runs no `git`, reads no credential and reaches no network — + * every one of those happens inside the first operation that actually needs + * repository state. + */ + +export { + GITHUB as GITHUB_PULL_REQUEST_PROVIDER, + parseGitHubPullRequestUrl, + pullRequestAllowed, + recognizesGitHubPullRequestUrl, + useGitHubPullRequests, +} from "./src/deno/composition/pull-request-reads.ts"; +export type { GitHubPullRequestsOptions } from "./src/deno/composition/pull-request-reads.ts"; +export { + WORKSPACE_GIT_ADD, + WORKSPACE_GIT_SWITCH, + WORKSPACE_REPOSITORY, + WORKSPACE_WORKTREE, +} from "./src/deno/composition/provider.ts"; +export { + GITHUB, + parseGitHubIssueTarget, + recognizesGitHubUrl, + useGitHubIssues, +} from "./src/deno/issue/github.ts"; +export type { GitHubIssuesOptions } from "./src/deno/issue/github.ts"; +/** + * The ordinary run's repository provider. + * + * The installer alone, and the options a trusted entrypoint supplies to it. + * What the provider holds — the leases, the credential assembly, the selection + * registry, the live Push evidence and the metadata writer — stays inside it: + * a package that could reach one of those could authorize a publication this + * execution never made. + */ +export { gitWorkspaceAttachment } from "./src/deno/attachment.ts"; +export type { GitWorkspaceOptions } from "./src/deno/attachment.ts"; +export { useRunComposition } from "./src/deno/run-composition/provider.ts"; +export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; diff --git a/packages/git/mod.ts b/packages/git/mod.ts new file mode 100644 index 000000000..30eae3391 --- /dev/null +++ b/packages/git/mod.ts @@ -0,0 +1,298 @@ +/** + * @module + * + * The Git Plugin for Executable.md. + * + * Repositories, worktrees, Git operations, pull requests and issues, as + * vocabulary a document writes and as durable effects a run retains. The + * default export is the Plugin itself; everything beside it is the surface a + * provider adapter composes against. + * + * ```md + * + * + * what this run decided + * + * + * ``` + * + * A `` is retained in the run's Workspace: its bytes, the row that + * names it, and the Workspace root they were published against all commit + * together or not at all. So a resumed run continues from the checkout it + * recorded rather than from whatever is on the machine now, and a replay + * performs no Git at all. + * + * ## Git-host effects + * + * A **Git host** is an external service that owns remote Git repositories and + * associated collaboration objects such as branches, pull requests and issues. + * GitHub is one Git-host adapter; a Git host is not the local Git capability + * and not the trusted workflow host. + * + * A Git host owns state no local transaction can enclose, so pushing, opening a + * pull request and filing an issue all face the same question after an + * interruption: did the previous attempt already succeed? + * `reconcileGitHostEffect()` answers it once, for all three. A live attempt + * observes under an identity derived from the run and the expansion, then + * adopts a proven compatible completion, performs a proven absence exactly + * once, or refuses. + * + * `withGitHostProvider()` installs the provider that answers those phases. A + * provider need not implement every kind: a plain Git server may support + * `git-push` and refuse pull requests and issues. Routing is one contextual + * operation that settles no completion — middleware may inspect, narrow or + * refuse a request, and nothing it can hold or combine can answer one. + * + * ## Durable identity does not move with the source + * + * These components were published by `@executablemd/workflow` before this + * package existed, and every journal, database row and artifact a released + * build wrote names that origin. Those strings identify retained history rather + * than current source ownership, so they are preserved exactly here — including + * the `@executablemd/workflow/composition` origin and the released + * `@executablemd/workflow/composition/dir-v2#Dir` alias. + */ + +export { default } from "./src/plugin.ts"; +export { gitPlugin } from "./src/plugin.ts"; +export { workflowInstallation } from "./src/installation.ts"; +export { + Git, + gitObjectFormat, + GitObjectError, + GitRepositoryError, + GitRevisionError, + readGitObject, + repositoryRoot, + revParse, +} from "./src/git.ts"; +export type { GitApi, GitObjectFormat } from "./src/git.ts"; +export { RepositoryComposition } from "./src/composition/api.ts"; +export type { RepositoryCompositionApi } from "./src/composition/api.ts"; +export { currentRepository, RepositoryContext } from "./src/composition/context.ts"; +export type { RepositoryContextApi } from "./src/composition/context.ts"; +export { + GitCompositionProviderError, + GitOperationError, + GitOperationProtocolError, + PullRequestAdmissionError, + RepositoryCompositionError, + RepositoryCompositionProtocolError, + RepositoryCompositionProviderError, + RepositoryStaleStateError, + WorktreeCompositionError, +} from "./src/composition/errors.ts"; +export type { + GitFailureReason, + PullRequestAdmissionReason, + RepositoryFailureReason, + WorktreeFailureReason, +} from "./src/composition/errors.ts"; +export { + parseRepositoryRecord, + parseWorktreeRecord, + repositoryRecordJson, + sameRepositoryRecord, + sameWorktreeRecord, + worktreeRecordJson, +} from "./src/composition/records.ts"; +export type { + RepositoryCreationRequest, + RepositoryRecord, + WorktreeCreationRequest, + WorktreeRecord, +} from "./src/composition/records.ts"; +export { + NoPullRequestProvider, + PULL_REQUEST_API, + PullRequestAPI, +} from "./src/composition/pull-request-api.ts"; +export type { + PullRequestApi, + PullRequestInput, + PullRequestReadOptions, + PullRequestUpsertOptions, +} from "./src/composition/pull-request-api.ts"; +export { + canonicalPullRequestUrl, + pullRequestProviderName, +} from "./src/composition/pull-request-target.ts"; +export type { PullRequestTarget } from "./src/composition/pull-request-target.ts"; +export { GitComposition } from "./src/composition/git-api.ts"; +export type { GitCompositionApi } from "./src/composition/git-api.ts"; +export { + gitAddResultJson, + gitCommitResultJson, + gitSwitchResultJson, + parseGitAddResult, + parseGitCheckoutIdentity, + parseGitCheckoutState, + parseGitCommitMessageSource, + parseGitCommitResult, + parseGitSwitchResult, +} from "./src/composition/git-records.ts"; +export type { + GitAddExpectation, + GitAddRequest, + GitAddResult, + GitCheckoutExpectation, + GitCheckoutIdentity, + GitCheckoutState, + GitCommitExpectation, + GitCommitMessageSource, + GitCommitRequest, + GitCommitResult, + GitSwitchExpectation, + GitSwitchRequest, + GitSwitchResult, +} from "./src/composition/git-records.ts"; +export { + destinationRefFor, + GIT_PUSH, + gitPushInputsJson, + gitPushNaturalKeyJson, + gitPushObservationsJson, + gitPushPreStateJson, + gitPushResultJson, + parseGitPushInputs, + parseGitPushNaturalKey, + parseGitPushObservations, + parseGitPushPreState, + parseGitPushRecord, + parseGitPushResult, + PUSH_REMOTE, + pushExpectation, + refspecFor, +} from "./src/composition/git-push-records.ts"; +export type { + GitPushExpectation, + GitPushInputs, + GitPushNaturalKey, + GitPushObservations, + GitPushOutcome, + GitPushPreState, + GitPushRequest, + GitPushResult, +} from "./src/composition/git-push-records.ts"; +export { + OPEN, + parsePullRequestInputs, + parsePullRequestNaturalKey, + parsePullRequestObservations, + parsePullRequestPreState, + parsePullRequestRecord, + parsePullRequestResult, + parsePullRequestSnapshot, + PULL_REQUEST, + pullRequestAgrees, + pullRequestMode, + pullRequestInputsJson, + pullRequestNaturalKey, + pullRequestNaturalKeyJson, + pullRequestNumber, + pullRequestObservationsJson, + pullRequestPreStateJson, + pullRequestResultJson, + pullRequestResultOf, + pullRequestSnapshotJson, + sameNaturalKey, + samePullRequestIdentity, +} from "./src/composition/pull-request-records.ts"; +export type { + PullRequestCreateKey, + PullRequestExpectation, + PullRequestInputs, + PullRequestMode, + PullRequestNaturalKey, + PullRequestObservations, + PullRequestOutcome, + PullRequestPreState, + PullRequestRequest, + PullRequestResult, + PullRequestSnapshot, + PullRequestUpdateKey, +} from "./src/composition/pull-request-records.ts"; +export { admitPushEvidence } from "./src/composition/push-evidence.ts"; +export { + COMPOSITION_REGISTRATIONS, + compositionDocumentation, + useCompositionComponents, +} from "./src/composition/installation.ts"; +export { ISSUE_API, IssueApi, NoIssueProvider } from "./src/issue/api.ts"; +export type { + IssueDetails, + IssueInput, + IssueOperation, + IssueReadOptions, + IssueReference, + IssueUpsertOptions, +} from "./src/issue/api.ts"; +export { + ISSUE_TRACKER_CONTEXT, + IssueTrackerContext, + currentIssueTracker, +} from "./src/issue/context.ts"; +export { ISSUE_EFFECT } from "./src/issue/effect-type.ts"; +export { + IssueAmbiguousError, + IssueConflictError, + IssueContentError, + IssueProtocolError, + IssueTrackerError, + IssueUnavailableError, +} from "./src/issue/errors.ts"; +export type { IssueTrackerReason } from "./src/issue/errors.ts"; +export { + canonicalIssueTarget, + issueProviderName, + resolveIssueDestination, + withinIssueCeiling, +} from "./src/issue/tracker.ts"; +export type { IssueDestination, IssueTracker } from "./src/issue/tracker.ts"; +export { GIT_HOST_API, GitHost } from "./src/git-host/api.ts"; +export type { + GitHostApi, + GitHostCall, + GitHostPhase, + GitHostPhaseDetails, + GitHostProvider, + GitHostRoutingRequest, +} from "./src/git-host/api.ts"; +export { + GitHostAmbiguousError, + GitHostConflictError, + GitHostProtocolError, + GitHostProviderError, + GitHostUnavailableError, +} from "./src/git-host/errors.ts"; +export { + completeGitHostEffectRequestJson, + gitHostReconciliationRecordJson, + parseCompleteGitHostEffectRequest, + parseGitHostCompletion, + parseGitHostEffectIdentity, + parseGitHostObservation, + parseGitHostReconciliationRecord, + sameGitHostEffectRequest, +} from "./src/git-host/records.ts"; +export type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostDecision, + GitHostEffectIdentity, + GitHostEffectRequest, + GitHostObservation, + GitHostReconciliationRecord, +} from "./src/git-host/records.ts"; +export { + GIT_HOST_EFFECT, + reconcileGitHostEffect, + withGitHostProvider, +} from "./src/git-host/effect.ts"; +export { + filteredRepositoryIdentity, + parseRepositoryIdentity, + repositoryIdentityJson, + sameRepositoryIdentity, +} from "./src/composition/selection.ts"; +export type { RepositoryIdentity } from "./src/composition/selection.ts"; diff --git a/packages/git/package.json b/packages/git/package.json new file mode 100644 index 000000000..c4da9d32e --- /dev/null +++ b/packages/git/package.json @@ -0,0 +1,20 @@ +{ + "name": "@executablemd/git", + "version": "0.12.1", + "description": "The Git Plugin for executable.md: repositories, worktrees, Git operations, pull requests and issues, retained in a workflow run's Workspace.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./deno": "./deno.ts", + "./credential-helper": "./credential-helper.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@executablemd/core": "workspace:*", + "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", + "@executablemd/workflow": "workspace:*", + "effection": "4.1.0" + } +} diff --git a/packages/workflow/src/composition/api.ts b/packages/git/src/composition/api.ts similarity index 100% rename from packages/workflow/src/composition/api.ts rename to packages/git/src/composition/api.ts diff --git a/packages/workflow/src/composition/components.md b/packages/git/src/composition/components.md similarity index 100% rename from packages/workflow/src/composition/components.md rename to packages/git/src/composition/components.md diff --git a/packages/workflow/src/composition/components/Dir.ts b/packages/git/src/composition/components/Dir.ts similarity index 100% rename from packages/workflow/src/composition/components/Dir.ts rename to packages/git/src/composition/components/Dir.ts diff --git a/packages/workflow/src/composition/components/GitAdd.ts b/packages/git/src/composition/components/GitAdd.ts similarity index 100% rename from packages/workflow/src/composition/components/GitAdd.ts rename to packages/git/src/composition/components/GitAdd.ts diff --git a/packages/workflow/src/composition/components/GitCommit.ts b/packages/git/src/composition/components/GitCommit.ts similarity index 100% rename from packages/workflow/src/composition/components/GitCommit.ts rename to packages/git/src/composition/components/GitCommit.ts diff --git a/packages/workflow/src/composition/components/GitPush.ts b/packages/git/src/composition/components/GitPush.ts similarity index 100% rename from packages/workflow/src/composition/components/GitPush.ts rename to packages/git/src/composition/components/GitPush.ts diff --git a/packages/workflow/src/composition/components/GitSwitch.ts b/packages/git/src/composition/components/GitSwitch.ts similarity index 100% rename from packages/workflow/src/composition/components/GitSwitch.ts rename to packages/git/src/composition/components/GitSwitch.ts diff --git a/packages/workflow/src/composition/components/Issue.ts b/packages/git/src/composition/components/Issue.ts similarity index 100% rename from packages/workflow/src/composition/components/Issue.ts rename to packages/git/src/composition/components/Issue.ts diff --git a/packages/workflow/src/composition/components/IssueTracker.ts b/packages/git/src/composition/components/IssueTracker.ts similarity index 100% rename from packages/workflow/src/composition/components/IssueTracker.ts rename to packages/git/src/composition/components/IssueTracker.ts diff --git a/packages/workflow/src/composition/components/PullRequest.ts b/packages/git/src/composition/components/PullRequest.ts similarity index 100% rename from packages/workflow/src/composition/components/PullRequest.ts rename to packages/git/src/composition/components/PullRequest.ts diff --git a/packages/workflow/src/composition/components/PullRequestReads.ts b/packages/git/src/composition/components/PullRequestReads.ts similarity index 100% rename from packages/workflow/src/composition/components/PullRequestReads.ts rename to packages/git/src/composition/components/PullRequestReads.ts diff --git a/packages/workflow/src/composition/components/Repository.ts b/packages/git/src/composition/components/Repository.ts similarity index 100% rename from packages/workflow/src/composition/components/Repository.ts rename to packages/git/src/composition/components/Repository.ts diff --git a/packages/workflow/src/composition/components/Worktree.ts b/packages/git/src/composition/components/Worktree.ts similarity index 100% rename from packages/workflow/src/composition/components/Worktree.ts rename to packages/git/src/composition/components/Worktree.ts diff --git a/packages/workflow/src/composition/context.ts b/packages/git/src/composition/context.ts similarity index 100% rename from packages/workflow/src/composition/context.ts rename to packages/git/src/composition/context.ts diff --git a/packages/workflow/src/composition/definitions.ts b/packages/git/src/composition/definitions.ts similarity index 100% rename from packages/workflow/src/composition/definitions.ts rename to packages/git/src/composition/definitions.ts diff --git a/packages/workflow/src/composition/errors.ts b/packages/git/src/composition/errors.ts similarity index 99% rename from packages/workflow/src/composition/errors.ts rename to packages/git/src/composition/errors.ts index 7d262621f..384853cae 100644 --- a/packages/workflow/src/composition/errors.ts +++ b/packages/git/src/composition/errors.ts @@ -23,7 +23,7 @@ */ import { StaleInputError } from "@executablemd/durable-streams"; -import { WorkflowStorageError } from "../storage/errors.ts"; +import { WorkflowStorageError } from "@executablemd/workflow"; /** A word from the fixed vocabulary a Repository refusal is reported under. */ export type RepositoryFailureReason = diff --git a/packages/workflow/src/composition/git-api.ts b/packages/git/src/composition/git-api.ts similarity index 100% rename from packages/workflow/src/composition/git-api.ts rename to packages/git/src/composition/git-api.ts diff --git a/packages/workflow/src/composition/git-push-records.ts b/packages/git/src/composition/git-push-records.ts similarity index 100% rename from packages/workflow/src/composition/git-push-records.ts rename to packages/git/src/composition/git-push-records.ts diff --git a/packages/workflow/src/composition/git-records.ts b/packages/git/src/composition/git-records.ts similarity index 100% rename from packages/workflow/src/composition/git-records.ts rename to packages/git/src/composition/git-records.ts diff --git a/packages/workflow/src/composition/installation.ts b/packages/git/src/composition/installation.ts similarity index 99% rename from packages/workflow/src/composition/installation.ts rename to packages/git/src/composition/installation.ts index ff26a1860..5fd9105ab 100644 --- a/packages/workflow/src/composition/installation.ts +++ b/packages/git/src/composition/installation.ts @@ -78,7 +78,7 @@ export function* compositionDocumentation( new URL("./components.md", import.meta.url), { owner: COMPOSITION_ORIGIN, - asset: "packages/workflow/src/composition/components.md", + asset: "packages/git/src/composition/components.md", }, COMPOSITION_REGISTRATIONS.map((registration) => registration.name), read, diff --git a/packages/workflow/src/composition/parse.ts b/packages/git/src/composition/parse.ts similarity index 100% rename from packages/workflow/src/composition/parse.ts rename to packages/git/src/composition/parse.ts diff --git a/packages/workflow/src/composition/pull-request-api.ts b/packages/git/src/composition/pull-request-api.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-api.ts rename to packages/git/src/composition/pull-request-api.ts diff --git a/packages/workflow/src/composition/pull-request-operations.ts b/packages/git/src/composition/pull-request-operations.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-operations.ts rename to packages/git/src/composition/pull-request-operations.ts diff --git a/packages/workflow/src/composition/pull-request-read-execution.ts b/packages/git/src/composition/pull-request-read-execution.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-read-execution.ts rename to packages/git/src/composition/pull-request-read-execution.ts diff --git a/packages/workflow/src/composition/pull-request-read-records.ts b/packages/git/src/composition/pull-request-read-records.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-read-records.ts rename to packages/git/src/composition/pull-request-read-records.ts diff --git a/packages/workflow/src/composition/pull-request-records.ts b/packages/git/src/composition/pull-request-records.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-records.ts rename to packages/git/src/composition/pull-request-records.ts diff --git a/packages/workflow/src/composition/pull-request-target.ts b/packages/git/src/composition/pull-request-target.ts similarity index 100% rename from packages/workflow/src/composition/pull-request-target.ts rename to packages/git/src/composition/pull-request-target.ts diff --git a/packages/workflow/src/composition/push-evidence.ts b/packages/git/src/composition/push-evidence.ts similarity index 100% rename from packages/workflow/src/composition/push-evidence.ts rename to packages/git/src/composition/push-evidence.ts diff --git a/packages/workflow/src/composition/records.ts b/packages/git/src/composition/records.ts similarity index 100% rename from packages/workflow/src/composition/records.ts rename to packages/git/src/composition/records.ts diff --git a/packages/workflow/src/composition/selection.ts b/packages/git/src/composition/selection.ts similarity index 100% rename from packages/workflow/src/composition/selection.ts rename to packages/git/src/composition/selection.ts diff --git a/packages/git/src/deno/attachment.ts b/packages/git/src/deno/attachment.ts new file mode 100644 index 000000000..63259cab5 --- /dev/null +++ b/packages/git/src/deno/attachment.ts @@ -0,0 +1,118 @@ +/** + * What this package installs into one workflow run's Workspace. + * + * The block `@executablemd/workflow` used to hold: the Repository and Git + * composition providers, the retained lifecycles for the two service-reaching + * vocabularies, the component registrations and the Git-host middleware this + * platform can reach. It is the same sequence in the same order — a run that + * attaches it gets the providers it always had, beneath the run's own effect + * coordinator and above nothing. + * + * It is a `WorkflowWorkspaceInstaller`, so the host names it and Workflow + * decides when it runs. What it is handed is the run's database and nothing + * else: every durable thing it does goes through one published boundary — a + * Workspace effect, or a read-only inspection. + * + * A completed replay never reaches this path, which is why it contacts no + * remote and spawns no Git: the providers that could are never installed. + */ + +import type { Operation } from "effection"; +import type { WorkflowWorkspaceAttachment } from "@executablemd/workflow/deno"; +import { useCompositionComponents } from "../composition/installation.ts"; +import { useRetainedIssueOperations } from "../issue/effect.ts"; +import { denoRepositoryHost } from "./composition/host.ts"; +import { useGitHubPullRequests } from "./composition/pull-request-reads.ts"; +import type { GitHubPullRequestsOptions } from "./composition/pull-request-reads.ts"; +import type { HelperAssembly } from "./composition/credential-helper.ts"; +import { + useGitComposition, + useRepositoryComposition, + workflowSelections, + type CompositionProviderOptions, +} from "./composition/provider.ts"; +import { + useRetainedPullRequestOperations, + useRetainedPullRequestReads, +} from "./composition/pull-request-operations.ts"; +import { useGitHubIssues, type GitHubIssuesOptions } from "./issue/github.ts"; + +/** + * Installation options a host owns and a document cannot reach. + * + * Supplied where the provider is installed, which is before any document + * exists. A suite substitutes the leaf host dependencies here — the Git + * subprocess and the temporary directory — because those are the two things a + * repository arranged on disk cannot make behave deterministically. + */ +export interface GitWorkspaceOptions { + readonly composition?: CompositionProviderOptions; + /** + * What GitHub issue handling this host installs, and what it may reach. + * + * Separate from `composition` because `` is not Repository + * composition: it reaches a service that need not own a Git repository, so + * its middleware, its ceiling and its credentials are configured on their + * own. Absent installs none, and a document that writes `` then + * reaches `IssueApi`'s own base error. + */ + readonly gitHubIssues?: GitHubIssuesOptions; + /** + * The pull-request destinations this host allows a document to read. + * + * Absent authorizes no URL read, so a document naming one reaches + * `PullRequestAPI`'s own base error rather than a host that quietly read + * somewhere nobody allowed. It does not disable ``, whose + * admission is this run's own Push evidence rather than a configured URL. + */ + readonly gitHubPullRequests?: GitHubPullRequestsOptions; + /** + * How this host writes and starts its own credential helper. + * + * Supplied by the runtime entrypoint, which is the only place that knows + * whether this is Deno source or a compiled binary and which platform it is + * standing on. + */ + readonly helper?: HelperAssembly; +} + +/** The attachment a host names to give a workflow run this vocabulary. */ +export function gitWorkspaceAttachment( + options: GitWorkspaceOptions = {}, +): (attachment: WorkflowWorkspaceAttachment) => Operation { + return function* ({ database }): Operation { + // One registry for the whole attachment: `` is handed what + // `` minted, and two registries would be two providers that + // could not recognize each other's selections. + const selections = options.composition?.selections ?? workflowSelections(); + const composition = { + ...options.composition, + ...(options.helper === undefined ? {} : { helper: options.helper }), + selections, + }; + yield* useRepositoryComposition(database, composition); + yield* useGitComposition(database, composition); + if (options.gitHubIssues !== undefined) { + yield* useGitHubIssues(options.gitHubIssues); + } + // The retained lifecycle for both service-reaching vocabularies, above + // whichever transport middleware this host installed for them. + yield* useRetainedIssueOperations(); + yield* useRetainedPullRequestOperations(); + // Durability for an admitted read, installed beside the transport rather + // than above it: the adapter admits, this retains. + yield* useRetainedPullRequestReads(); + yield* useCompositionComponents(); + // Ordinary middleware, installed the way the Issue adapter is: it owns + // the URLs it recognizes and delegates the rest. + // Installed on every live or partial attachment, configured or not: the + // configuration governs URL reads, and `` must keep working + // on a host that authorizes none. + yield* useGitHubPullRequests( + database, + composition.host ?? denoRepositoryHost(), + options.gitHubPullRequests ?? {}, + selections, + ); + }; +} diff --git a/packages/workflow/src/deno/composition/add.ts b/packages/git/src/deno/composition/add.ts similarity index 98% rename from packages/workflow/src/deno/composition/add.ts rename to packages/git/src/deno/composition/add.ts index 3d3c86e63..b704ea4ea 100644 --- a/packages/workflow/src/deno/composition/add.ts +++ b/packages/git/src/deno/composition/add.ts @@ -24,7 +24,7 @@ import { type GitCheckoutState, } from "../../composition/git-records.ts"; import { ADD, admitPathspecs } from "../../composition/components/GitAdd.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { addPaths } from "./git.ts"; import type { RepositoryHost } from "./host.ts"; import { settled, type CompositionOutcome, type MutationContext } from "./effects.ts"; diff --git a/packages/workflow/src/deno/composition/authentication.ts b/packages/git/src/deno/composition/authentication.ts similarity index 100% rename from packages/workflow/src/deno/composition/authentication.ts rename to packages/git/src/deno/composition/authentication.ts diff --git a/packages/workflow/src/deno/composition/commit.ts b/packages/git/src/deno/composition/commit.ts similarity index 99% rename from packages/workflow/src/deno/composition/commit.ts rename to packages/git/src/deno/composition/commit.ts index 7bd9f99ec..8d18dd712 100644 --- a/packages/workflow/src/deno/composition/commit.ts +++ b/packages/git/src/deno/composition/commit.ts @@ -41,7 +41,7 @@ import { admitMessageSource, COMMIT, } from "../../composition/components/GitCommit.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { commitIndex, readCommit, readCommitMessage, resolveCommit } from "./git.ts"; import type { GitCommitIdentity, RepositoryHost } from "./host.ts"; import { settled, type CompositionOutcome, type MutationContext } from "./effects.ts"; diff --git a/packages/workflow/src/deno/composition/credential-helper.ts b/packages/git/src/deno/composition/credential-helper.ts similarity index 100% rename from packages/workflow/src/deno/composition/credential-helper.ts rename to packages/git/src/deno/composition/credential-helper.ts diff --git a/packages/workflow/src/deno/composition/effects.ts b/packages/git/src/deno/composition/effects.ts similarity index 94% rename from packages/workflow/src/deno/composition/effects.ts rename to packages/git/src/deno/composition/effects.ts index 117441c26..b6bb2332f 100644 --- a/packages/workflow/src/deno/composition/effects.ts +++ b/packages/git/src/deno/composition/effects.ts @@ -20,12 +20,12 @@ import { GitOperationProtocolError, RepositoryCompositionProtocolError, } from "../../composition/errors.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { WorkflowStorageError } from "../../storage/errors.ts"; -import { createWorkflowWorkspaceEffect } from "../workspace/effect.ts"; -import { isJournalableWorkspaceFailure } from "../workspace/errors.ts"; -import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; -import { createWorkspaceMetadata, type WorkspaceMetadata } from "../workspace/repositories.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { WorkflowStorageError } from "@executablemd/workflow"; +import { createWorkflowWorkspaceEffect } from "@executablemd/workflow/deno"; +import { isJournalableWorkspaceFailure } from "@executablemd/workflow/deno"; +import type { WorkflowWorkspaceFilesystem } from "@executablemd/workflow/deno"; +import { createWorkspaceMetadata, type WorkspaceMetadata } from "../repositories.ts"; import { GitRefusal } from "./git.ts"; import { CompositionRefusal, @@ -63,7 +63,7 @@ export type CompositionOutcome = { readonly kind: "created"; readonly record: Js * every function, so no step can reach one without the other. */ export interface MutationContext { - readonly filesystem: DenoWorkspaceFilesystem; + readonly filesystem: WorkflowWorkspaceFilesystem; readonly metadata: WorkspaceMetadata; /** * This effect's own nested savepoint, for work an attempt may have to discard. diff --git a/packages/workflow/src/deno/composition/git.ts b/packages/git/src/deno/composition/git.ts similarity index 100% rename from packages/workflow/src/deno/composition/git.ts rename to packages/git/src/deno/composition/git.ts diff --git a/packages/workflow/src/deno/composition/github.ts b/packages/git/src/deno/composition/github.ts similarity index 100% rename from packages/workflow/src/deno/composition/github.ts rename to packages/git/src/deno/composition/github.ts diff --git a/packages/workflow/src/deno/composition/host.ts b/packages/git/src/deno/composition/host.ts similarity index 100% rename from packages/workflow/src/deno/composition/host.ts rename to packages/git/src/deno/composition/host.ts diff --git a/packages/workflow/src/deno/composition/identity.ts b/packages/git/src/deno/composition/identity.ts similarity index 99% rename from packages/workflow/src/deno/composition/identity.ts rename to packages/git/src/deno/composition/identity.ts index 72632b5de..135d6ce30 100644 --- a/packages/workflow/src/deno/composition/identity.ts +++ b/packages/git/src/deno/composition/identity.ts @@ -11,7 +11,7 @@ import { RepositoryStaleStateError } from "../../composition/errors.ts"; import type { RepositoryRecord, WorktreeRecord } from "../../composition/records.ts"; import type { GitCheckoutIdentity } from "../../composition/git-records.ts"; -import type { StoredRepository } from "../workspace/repositories.ts"; +import type { StoredRepository } from "../repositories.ts"; import { admitLocator, locatorFingerprint } from "./locator.ts"; import { repositoryCheckoutPath, worktreeCheckoutPath } from "./placement.ts"; diff --git a/packages/workflow/src/deno/composition/locator.ts b/packages/git/src/deno/composition/locator.ts similarity index 100% rename from packages/workflow/src/deno/composition/locator.ts rename to packages/git/src/deno/composition/locator.ts diff --git a/packages/workflow/src/deno/composition/materialize.ts b/packages/git/src/deno/composition/materialize.ts similarity index 98% rename from packages/workflow/src/deno/composition/materialize.ts rename to packages/git/src/deno/composition/materialize.ts index 8fc645b00..5d5b3f6f0 100644 --- a/packages/workflow/src/deno/composition/materialize.ts +++ b/packages/git/src/deno/composition/materialize.ts @@ -35,8 +35,8 @@ import type { Stats } from "node:fs"; import { chmod, readFile, readlink, realpath, symlink, writeFile } from "node:fs/promises"; import { RepositoryStaleStateError } from "../../composition/errors.ts"; import { canonicalWorkspacePath } from "../../composition/parse.ts"; -import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; -import type { WorkflowWorkspaceReads } from "../workspace/inspect.ts"; +import type { WorkflowWorkspaceFilesystem } from "@executablemd/workflow/deno"; +import type { WorkflowWorkspaceReads } from "@executablemd/workflow/deno"; /** Where the two administration files a linked worktree needs are written. */ const GITDIR_PREFIX = "gitdir: "; @@ -190,7 +190,7 @@ export function* exportTree( } function* importEntry( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceFilesystem, source: string, workspacePath: string, ): Operation { @@ -229,7 +229,7 @@ function* importEntry( * checkout Git never produced. */ export function* importTree( - filesystem: DenoWorkspaceFilesystem, + filesystem: WorkflowWorkspaceFilesystem, root: string, workspacePath: string, ): Operation { diff --git a/packages/workflow/src/deno/composition/object-source.ts b/packages/git/src/deno/composition/object-source.ts similarity index 100% rename from packages/workflow/src/deno/composition/object-source.ts rename to packages/git/src/deno/composition/object-source.ts diff --git a/packages/workflow/src/deno/composition/operations.ts b/packages/git/src/deno/composition/operations.ts similarity index 99% rename from packages/workflow/src/deno/composition/operations.ts rename to packages/git/src/deno/composition/operations.ts index d163207c4..59388c5fb 100644 --- a/packages/workflow/src/deno/composition/operations.ts +++ b/packages/git/src/deno/composition/operations.ts @@ -68,9 +68,9 @@ import { GitOperationAdmissionError, GitOperationInfrastructureError, } from "../../composition/errors.ts"; -import type { DenoWorkspaceFilesystem } from "../workspace/filesystem.ts"; -import type { WorkflowWorkspaceReads } from "../workspace/inspect.ts"; -import type { StoredRepository, WorkspaceMetadataReads } from "../workspace/repositories.ts"; +import type { WorkflowWorkspaceFilesystem } from "@executablemd/workflow/deno"; +import type { WorkflowWorkspaceReads } from "@executablemd/workflow/deno"; +import type { StoredRepository, WorkspaceMetadataReads } from "../repositories.ts"; import { currentBranch, gitSession, diff --git a/packages/workflow/src/deno/composition/placement.ts b/packages/git/src/deno/composition/placement.ts similarity index 100% rename from packages/workflow/src/deno/composition/placement.ts rename to packages/git/src/deno/composition/placement.ts diff --git a/packages/workflow/src/deno/composition/provider.ts b/packages/git/src/deno/composition/provider.ts similarity index 98% rename from packages/workflow/src/deno/composition/provider.ts rename to packages/git/src/deno/composition/provider.ts index 0c21abf51..4264e6f4d 100644 --- a/packages/workflow/src/deno/composition/provider.ts +++ b/packages/git/src/deno/composition/provider.ts @@ -59,9 +59,9 @@ import { } from "../../composition/selection.ts"; import { GitOperationAdmissionError, RepositorySelectionError } from "../../composition/errors.ts"; import { selectionRegistry, type SelectionRegistry } from "../selections.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { readWorkflowWorkspace } from "../workspace/inspect.ts"; -import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { readWorkflowWorkspace } from "@executablemd/workflow/deno"; +import type { WorkflowWorkspaceSnapshot } from "@executablemd/workflow/deno"; import { gitSession, type GitSession } from "./git.ts"; import { denoRepositoryHost, type RepositoryHost } from "./host.ts"; import type { GitAuthentication } from "./authentication.ts"; diff --git a/packages/workflow/src/deno/composition/pull-request-evidence.ts b/packages/git/src/deno/composition/pull-request-evidence.ts similarity index 100% rename from packages/workflow/src/deno/composition/pull-request-evidence.ts rename to packages/git/src/deno/composition/pull-request-evidence.ts diff --git a/packages/workflow/src/deno/composition/pull-request-operations.ts b/packages/git/src/deno/composition/pull-request-operations.ts similarity index 98% rename from packages/workflow/src/deno/composition/pull-request-operations.ts rename to packages/git/src/deno/composition/pull-request-operations.ts index 346838c93..bb63af11c 100644 --- a/packages/workflow/src/deno/composition/pull-request-operations.ts +++ b/packages/git/src/deno/composition/pull-request-operations.ts @@ -62,8 +62,8 @@ import type { PullRequestReadResult, } from "../../composition/pull-request-read-records.ts"; import type { PullRequestResult } from "../../composition/pull-request-records.ts"; -import { parseJsonValue } from "../../storage/members.ts"; -import { getWorkflowRun } from "../../run.ts"; +import { parseJsonValue } from "@executablemd/workflow"; +import { getWorkflowRun } from "@executablemd/workflow"; import { gitOperationFingerprint } from "./operations.ts"; /** The durable effect type one evidence read is retained under. */ diff --git a/packages/workflow/src/deno/composition/pull-request-reads.ts b/packages/git/src/deno/composition/pull-request-reads.ts similarity index 99% rename from packages/workflow/src/deno/composition/pull-request-reads.ts rename to packages/git/src/deno/composition/pull-request-reads.ts index 1ad00f1ba..c08d92dc9 100644 --- a/packages/workflow/src/deno/composition/pull-request-reads.ts +++ b/packages/git/src/deno/composition/pull-request-reads.ts @@ -37,7 +37,7 @@ */ import type { Operation } from "effection"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { GitOperationAdmissionError, PullRequestReadError } from "../../composition/errors.ts"; import type { PullRequestReadKind, diff --git a/packages/workflow/src/deno/composition/pull-request.ts b/packages/git/src/deno/composition/pull-request.ts similarity index 98% rename from packages/workflow/src/deno/composition/pull-request.ts rename to packages/git/src/deno/composition/pull-request.ts index 6fc2df824..1e443f9e8 100644 --- a/packages/workflow/src/deno/composition/pull-request.ts +++ b/packages/git/src/deno/composition/pull-request.ts @@ -74,9 +74,9 @@ import type { GitHostCompletion, GitHostObservation, } from "../../git-host/records.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { readWorkflowWorkspace } from "../workspace/inspect.ts"; -import { readWorkspaceMetadata } from "../workspace/repositories.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { readWorkflowWorkspace } from "@executablemd/workflow/deno"; +import { readWorkspaceMetadata } from "../repositories.ts"; import { currentBranch, gitSession, resolveCommit } from "./git.ts"; import { denoGitHubSource, diff --git a/packages/workflow/src/deno/composition/push.ts b/packages/git/src/deno/composition/push.ts similarity index 99% rename from packages/workflow/src/deno/composition/push.ts rename to packages/git/src/deno/composition/push.ts index f1856743d..208477257 100644 --- a/packages/workflow/src/deno/composition/push.ts +++ b/packages/git/src/deno/composition/push.ts @@ -82,9 +82,9 @@ import { type GitHostCompletion, type GitHostObservation, } from "../../git-host/records.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { readWorkflowWorkspace } from "../workspace/inspect.ts"; -import { readWorkspaceMetadata } from "../workspace/repositories.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { readWorkflowWorkspace } from "@executablemd/workflow/deno"; +import { readWorkspaceMetadata } from "../repositories.ts"; import { commitPresent, currentBranch, diff --git a/packages/workflow/src/deno/composition/refusals.ts b/packages/git/src/deno/composition/refusals.ts similarity index 99% rename from packages/workflow/src/deno/composition/refusals.ts rename to packages/git/src/deno/composition/refusals.ts index 5226109b0..371aaae9b 100644 --- a/packages/workflow/src/deno/composition/refusals.ts +++ b/packages/git/src/deno/composition/refusals.ts @@ -17,7 +17,7 @@ import { type RepositoryFailureReason, type WorktreeFailureReason, } from "../../composition/errors.ts"; -import { JournaledEffectFailure } from "../workspace/errors.ts"; +import { JournaledEffectFailure } from "@executablemd/workflow/deno"; const REPOSITORY_REASONS: ReadonlyMap = new Map([ ["invalid-locator", "invalid-locator"], diff --git a/packages/workflow/src/deno/composition/repository.ts b/packages/git/src/deno/composition/repository.ts similarity index 97% rename from packages/workflow/src/deno/composition/repository.ts rename to packages/git/src/deno/composition/repository.ts index d61c49c5c..dabde882b 100644 --- a/packages/workflow/src/deno/composition/repository.ts +++ b/packages/git/src/deno/composition/repository.ts @@ -14,7 +14,7 @@ import { getExpansion, sourceDescription } from "@executablemd/core"; import type { EffectDescription, Json } from "@executablemd/durable-streams"; import { ensureDir } from "@effectionx/fs"; import { RepositoryCompositionProtocolError } from "../../composition/errors.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { parseRepositoryRecord, repositoryRecordJson, @@ -23,8 +23,8 @@ import { type RepositoryCreationRequest, type RepositoryRecord, } from "../../composition/records.ts"; -import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; -import { readWorkspaceMetadata } from "../workspace/repositories.ts"; +import type { WorkflowWorkspaceSnapshot } from "@executablemd/workflow/deno"; +import { readWorkspaceMetadata } from "../repositories.ts"; import { checkoutPrimary, checkoutReadable, diff --git a/packages/workflow/src/deno/composition/subprocess.ts b/packages/git/src/deno/composition/subprocess.ts similarity index 100% rename from packages/workflow/src/deno/composition/subprocess.ts rename to packages/git/src/deno/composition/subprocess.ts diff --git a/packages/workflow/src/deno/composition/switch.ts b/packages/git/src/deno/composition/switch.ts similarity index 99% rename from packages/workflow/src/deno/composition/switch.ts rename to packages/git/src/deno/composition/switch.ts index 2064ffc9b..02e104141 100644 --- a/packages/workflow/src/deno/composition/switch.ts +++ b/packages/git/src/deno/composition/switch.ts @@ -25,7 +25,7 @@ import { type GitSwitchResult, } from "../../composition/git-records.ts"; import { SWITCH } from "../../composition/components/GitSwitch.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { branchExists, resolveBaseCommit, resolveCommit, switchBranch } from "./git.ts"; import type { RepositoryHost } from "./host.ts"; import { settled, type CompositionOutcome, type MutationContext } from "./effects.ts"; diff --git a/packages/workflow/src/deno/composition/worktree.ts b/packages/git/src/deno/composition/worktree.ts similarity index 98% rename from packages/workflow/src/deno/composition/worktree.ts rename to packages/git/src/deno/composition/worktree.ts index 2b5a935b3..c213b182a 100644 --- a/packages/workflow/src/deno/composition/worktree.ts +++ b/packages/git/src/deno/composition/worktree.ts @@ -14,7 +14,7 @@ import { getExpansion, sourceDescription } from "@executablemd/core"; import type { EffectDescription, Json } from "@executablemd/durable-streams"; import { ensureDir } from "@effectionx/fs"; import { RepositoryCompositionProtocolError } from "../../composition/errors.ts"; -import type { WorkflowRunDatabase } from "../../storage/api.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { parseWorktreeRecord, sameWorktreeRecord, @@ -22,8 +22,8 @@ import { type WorktreeCreationRequest, type WorktreeRecord, } from "../../composition/records.ts"; -import { readWorkspaceMetadata, type StoredRepository } from "../workspace/repositories.ts"; -import type { WorkflowWorkspaceSnapshot } from "../workspace/inspect.ts"; +import { readWorkspaceMetadata, type StoredRepository } from "../repositories.ts"; +import type { WorkflowWorkspaceSnapshot } from "@executablemd/workflow/deno"; import { addWorktree, checkoutReadable, diff --git a/packages/workflow/src/deno/issue/github.ts b/packages/git/src/deno/issue/github.ts similarity index 100% rename from packages/workflow/src/deno/issue/github.ts rename to packages/git/src/deno/issue/github.ts diff --git a/packages/workflow/src/deno/workspace/repositories.ts b/packages/git/src/deno/repositories.ts similarity index 98% rename from packages/workflow/src/deno/workspace/repositories.ts rename to packages/git/src/deno/repositories.ts index 0a6fdbfe1..372e9c51a 100644 --- a/packages/workflow/src/deno/workspace/repositories.ts +++ b/packages/git/src/deno/repositories.ts @@ -18,7 +18,7 @@ * recover, and it is reported through the same channel schema recognition uses. */ -import { WorkflowRecordMalformedError } from "../../storage/errors.ts"; +import { WorkflowRecordMalformedError } from "@executablemd/workflow"; import { parseCheckoutPath, parseFingerprint, @@ -26,12 +26,12 @@ import { type GitObjectFormat, type RepositoryRecord, type WorktreeRecord, -} from "../../composition/records.ts"; +} from "../composition/records.ts"; import type { WorkflowWorkspaceReadStorage, WorkflowWorkspaceRow, WorkflowWorkspaceStorage, -} from "./storage.ts"; +} from "@executablemd/workflow/deno"; /** A Repository row: its journal-safe record, and the locator only storage sees. */ export interface StoredRepository { diff --git a/packages/workflow/src/deno/run-composition/ambient.ts b/packages/git/src/deno/run-composition/ambient.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/ambient.ts rename to packages/git/src/deno/run-composition/ambient.ts diff --git a/packages/workflow/src/deno/run-composition/checkouts.ts b/packages/git/src/deno/run-composition/checkouts.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/checkouts.ts rename to packages/git/src/deno/run-composition/checkouts.ts diff --git a/packages/workflow/src/deno/run-composition/errors.ts b/packages/git/src/deno/run-composition/errors.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/errors.ts rename to packages/git/src/deno/run-composition/errors.ts diff --git a/packages/workflow/src/deno/run-composition/identity.ts b/packages/git/src/deno/run-composition/identity.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/identity.ts rename to packages/git/src/deno/run-composition/identity.ts diff --git a/packages/workflow/src/deno/run-composition/leases.ts b/packages/git/src/deno/run-composition/leases.ts similarity index 97% rename from packages/workflow/src/deno/run-composition/leases.ts rename to packages/git/src/deno/run-composition/leases.ts index 27d353806..2e5565f2a 100644 --- a/packages/workflow/src/deno/run-composition/leases.ts +++ b/packages/git/src/deno/run-composition/leases.ts @@ -33,8 +33,8 @@ */ import { race, suspend, useScope, withResolvers, type Operation, type Scope } from "effection"; -import { useAdvisoryLock } from "../advisory-lock.ts"; -import type { AdvisoryLockFile } from "../advisory-lock.ts"; +import { useAdvisoryLock } from "@executablemd/workflow/deno"; +import type { AdvisoryLockFile } from "@executablemd/workflow/deno"; import { ManagedCheckoutError } from "./errors.ts"; import { lockOf } from "./placement.ts"; diff --git a/packages/workflow/src/deno/run-composition/metadata.ts b/packages/git/src/deno/run-composition/metadata.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/metadata.ts rename to packages/git/src/deno/run-composition/metadata.ts diff --git a/packages/workflow/src/deno/run-composition/operations.ts b/packages/git/src/deno/run-composition/operations.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/operations.ts rename to packages/git/src/deno/run-composition/operations.ts diff --git a/packages/workflow/src/deno/run-composition/placement.ts b/packages/git/src/deno/run-composition/placement.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/placement.ts rename to packages/git/src/deno/run-composition/placement.ts diff --git a/packages/workflow/src/deno/run-composition/provider.ts b/packages/git/src/deno/run-composition/provider.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/provider.ts rename to packages/git/src/deno/run-composition/provider.ts diff --git a/packages/workflow/src/deno/run-composition/pull-request.ts b/packages/git/src/deno/run-composition/pull-request.ts similarity index 100% rename from packages/workflow/src/deno/run-composition/pull-request.ts rename to packages/git/src/deno/run-composition/pull-request.ts diff --git a/packages/workflow/src/deno/selections.ts b/packages/git/src/deno/selections.ts similarity index 100% rename from packages/workflow/src/deno/selections.ts rename to packages/git/src/deno/selections.ts diff --git a/packages/workflow/src/git-host/api.ts b/packages/git/src/git-host/api.ts similarity index 100% rename from packages/workflow/src/git-host/api.ts rename to packages/git/src/git-host/api.ts diff --git a/packages/workflow/src/git-host/effect-type.ts b/packages/git/src/git-host/effect-type.ts similarity index 100% rename from packages/workflow/src/git-host/effect-type.ts rename to packages/git/src/git-host/effect-type.ts diff --git a/packages/workflow/src/git-host/effect.ts b/packages/git/src/git-host/effect.ts similarity index 99% rename from packages/workflow/src/git-host/effect.ts rename to packages/git/src/git-host/effect.ts index b384c0c0a..2c4631636 100644 --- a/packages/workflow/src/git-host/effect.ts +++ b/packages/git/src/git-host/effect.ts @@ -92,7 +92,8 @@ import type { import { Err, ensure, Ok, scoped } from "effection"; import type { Operation, Result } from "effection"; import { getExpansion, sourceDescription } from "@executablemd/core"; -import { getWorkflowRun, retainedGitHostIdentitiesHere } from "../run.ts"; +import { getWorkflowRun } from "@executablemd/workflow"; +import { retainedGitHostIdentitiesHere } from "../identities.ts"; import { GIT_HOST_EFFECT } from "./effect-type.ts"; import { claimRetainedGitHostIdentity, exhaustRetainedGitHostIdentities } from "./identities.ts"; diff --git a/packages/workflow/src/git-host/errors.ts b/packages/git/src/git-host/errors.ts similarity index 100% rename from packages/workflow/src/git-host/errors.ts rename to packages/git/src/git-host/errors.ts diff --git a/packages/workflow/src/git-host/identities.ts b/packages/git/src/git-host/identities.ts similarity index 99% rename from packages/workflow/src/git-host/identities.ts rename to packages/git/src/git-host/identities.ts index d36e1a6aa..726202eec 100644 --- a/packages/workflow/src/git-host/identities.ts +++ b/packages/git/src/git-host/identities.ts @@ -62,7 +62,7 @@ */ import type { DurableEvent } from "@executablemd/durable-streams"; -import { canonicalJson } from "../storage/record.ts"; +import { canonicalJson } from "@executablemd/workflow"; import { GIT_HOST_EFFECT } from "./effect-type.ts"; import { parseGitHostReconciliationRecord } from "./records.ts"; import type { CompleteGitHostEffectRequest } from "./records.ts"; diff --git a/packages/workflow/src/git-host/records.ts b/packages/git/src/git-host/records.ts similarity index 99% rename from packages/workflow/src/git-host/records.ts rename to packages/git/src/git-host/records.ts index 4160b34b2..d0cd3c2b6 100644 --- a/packages/workflow/src/git-host/records.ts +++ b/packages/git/src/git-host/records.ts @@ -24,7 +24,7 @@ import { until } from "effection"; import type { Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { canonicalJson } from "../storage/record.ts"; +import { canonicalJson } from "@executablemd/workflow"; /** * Where one external effect sits: the run it belongs to and the expansion that diff --git a/packages/workflow/src/git.ts b/packages/git/src/git.ts similarity index 100% rename from packages/workflow/src/git.ts rename to packages/git/src/git.ts diff --git a/packages/git/src/identities.ts b/packages/git/src/identities.ts new file mode 100644 index 000000000..10eb9a878 --- /dev/null +++ b/packages/git/src/identities.ts @@ -0,0 +1,83 @@ +/** + * The identities this execution's admitted history holds. + * + * A Git-host effect is named by a digest that includes the run id, so a record + * a fork inherited has to be recognized by the identity it was written under. + * An Issue effect is a different request shape reconciled through a different + * boundary, and what it needs from the history is the same association. + * + * Both are read out of the retained snapshot by a journal admission, which + * canonical core applies inside the execution's own journal read — on the exact + * frozen events every later phase consumes, before any middleware, any retained + * Yield reaching execution, any authored work and any append. The value the + * admission installs is therefore this execution's, computed from this + * execution's history, and a second execution under the same Plugin computes + * its own from its own. + * + * Nothing durable rests on either. A wrong answer is held to the record it + * consumed, and one that reaches live execution performs nothing: what holds an + * effect to its history is the admission and the record, neither of which is + * reachable from a name a document could bind. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import type { JournalAdmission } from "@executablemd/core/host"; +import { retainedGitHostIdentities } from "./git-host/identities.ts"; +import type { RetainedIdentity } from "./git-host/identities.ts"; +import { retainedIssueIdentities } from "./issue/identities.ts"; +import type { RetainedIssueIdentity } from "./issue/identities.ts"; + +/** + * Where this execution's retained Git-host identities are kept. + * + * A stable, namespaced name and a plain value, so a second physical copy of + * this package reads the same binding through its own descriptor and finds the + * same answer rather than one queue per module object. By the same property a + * descendant may bind the name for its own descendants — which is why nothing + * durable depends on what it holds. + */ +const RetainedGitHostIdentities: Context = createContext< + readonly RetainedIdentity[] | undefined +>("executablemd.git.git-host.retained-identities", undefined); + +/** Where this execution's retained Issue identities are kept, on the same terms. */ +const RetainedIssueIdentities: Context = + createContext( + "executablemd.git.issue.retained-identities", + undefined, + ); + +/** The Git-host identities this execution's admitted history holds. */ +export function* retainedGitHostIdentitiesHere(): Operation { + const held = yield* RetainedGitHostIdentities.get(); + return held === undefined ? undefined : [...held]; +} + +/** The Issue identities this execution's admitted history holds. */ +export function* retainedIssueIdentitiesHere(): Operation { + const held = yield* RetainedIssueIdentities.get(); + return held === undefined ? undefined : [...held]; +} + +/** + * Publish the Git-host identities this execution's history holds. + * + * One value per execution, derived from the snapshot this admission was handed + * and set into the scope that owns the document. A Plugin is installed once per + * command and may run two documents; each of those is its own execution with + * its own journal read, so each runs this and each installs its own value. + */ +export function gitHostIdentityAdmission(): JournalAdmission { + return function* (retained: readonly DurableEvent[]): Operation { + yield* RetainedGitHostIdentities.set(Object.freeze(retainedGitHostIdentities(retained))); + }; +} + +/** Publish the Issue identities this execution's history holds, on the same terms. */ +export function issueIdentityAdmission(): JournalAdmission { + return function* (retained: readonly DurableEvent[]): Operation { + yield* RetainedIssueIdentities.set(Object.freeze(retainedIssueIdentities(retained))); + }; +} diff --git a/packages/git/src/installation.ts b/packages/git/src/installation.ts new file mode 100644 index 000000000..1a229f8b2 --- /dev/null +++ b/packages/git/src/installation.ts @@ -0,0 +1,74 @@ +/** + * Associating one document execution with a Git-defined workflow run. + * + * `workflowInstallation({ base })` is a value, not an installation act. It + * creates no workflow run: a run comes into being when a document execution + * reaches its first durable operation, which resolves the base once, records + * one immutable value, and only then lets the root document be imported. + * + * What this package supplies is the half that knows about Git — how the run is + * described, how it is allocated when nothing is recorded yet, and what a + * recorded run has to agree with. Everything the run is then held to is + * `@executablemd/workflow`'s: the installation slot, when retained history is + * admitted, the parser that reads the durable record back, and where the + * current run is published. A base that would not resolve is recorded as a + * failed effect, so a run replays that failure rather than resolving again. + */ + +import type { Operation } from "effection"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { + baseMismatch, + createWorkflowRunInstallation, + describeGitWorkflowRun, + isGitWorkflowRun, + retainedRunMismatch, +} from "@executablemd/workflow"; +import type { WorkflowRun, WorkflowRunPreparation } from "@executablemd/workflow"; +import { revParse } from "./git.ts"; + +function allocating(base: string): WorkflowRunPreparation { + return { + description: describeGitWorkflowRun(base), + // A base that would not resolve is recorded as a failed effect (§6), and a + // history whose only record is that failure is this run's own. Requiring a + // successful one would retry Git instead of replaying what happened. + required: false, + *allocate(): Operation { + const pinnedCommit = yield* revParse(`${base}^{commit}`); + // Web Crypto rather than `node:crypto`: a run id is allocated in shared + // code, which names no host. + return { runId: crypto.randomUUID(), base, pinnedCommit }; + }, + /** + * The description carries the base for a reader; divergence detection + * compares only type and name, so the base this run supplied is checked + * against the stored *value* rather than against the entry's identity. + */ + agree(recorded: WorkflowRun): WorkflowRun { + // This installation allocates a Git run, so a recorded source bundle is + // not a base disagreement — it is a different kind of run entirely. + if (!isGitWorkflowRun(recorded)) { + throw retainedRunMismatch(["definition version"]); + } + if (recorded.base !== base) { + throw baseMismatch(recorded.base, base); + } + return recorded; + }, + }; +} + +/** + * The installation that associates one document execution with a workflow run + * resolved from a Git base. + * + * Constructing it creates nothing. Executing a document under it does. + * + * ```ts + * yield* executeInstalled(options, [workflowInstallation({ base: "main" })]); + * ``` + */ +export function workflowInstallation(options: { base: string }): ExecutionInstallation { + return createWorkflowRunInstallation(allocating(options.base)); +} diff --git a/packages/workflow/src/issue/api.ts b/packages/git/src/issue/api.ts similarity index 100% rename from packages/workflow/src/issue/api.ts rename to packages/git/src/issue/api.ts diff --git a/packages/workflow/src/issue/context.ts b/packages/git/src/issue/context.ts similarity index 100% rename from packages/workflow/src/issue/context.ts rename to packages/git/src/issue/context.ts diff --git a/packages/workflow/src/issue/effect-type.ts b/packages/git/src/issue/effect-type.ts similarity index 100% rename from packages/workflow/src/issue/effect-type.ts rename to packages/git/src/issue/effect-type.ts diff --git a/packages/workflow/src/issue/effect.ts b/packages/git/src/issue/effect.ts similarity index 98% rename from packages/workflow/src/issue/effect.ts rename to packages/git/src/issue/effect.ts index 0ee0091ed..00057335e 100644 --- a/packages/workflow/src/issue/effect.ts +++ b/packages/git/src/issue/effect.ts @@ -44,7 +44,8 @@ import { type Workflow, } from "@executablemd/durable-streams"; import { getExpansion, sourceDescription } from "@executablemd/core"; -import { getWorkflowRun, retainedIssueIdentitiesHere } from "../run.ts"; +import { getWorkflowRun } from "@executablemd/workflow"; +import { retainedIssueIdentitiesHere } from "../identities.ts"; import { claimRetainedIssueIdentity, exhaustRetainedIssueIdentities } from "./identities.ts"; import { ISSUE_EFFECT } from "./effect-type.ts"; import { IssueApi } from "./api.ts"; diff --git a/packages/workflow/src/issue/errors.ts b/packages/git/src/issue/errors.ts similarity index 100% rename from packages/workflow/src/issue/errors.ts rename to packages/git/src/issue/errors.ts diff --git a/packages/workflow/src/issue/identities.ts b/packages/git/src/issue/identities.ts similarity index 99% rename from packages/workflow/src/issue/identities.ts rename to packages/git/src/issue/identities.ts index d4c722946..1ed2d4e2a 100644 --- a/packages/workflow/src/issue/identities.ts +++ b/packages/git/src/issue/identities.ts @@ -31,7 +31,7 @@ */ import type { DurableEvent } from "@executablemd/durable-streams"; -import { canonicalJson } from "../storage/record.ts"; +import { canonicalJson } from "@executablemd/workflow"; import { ISSUE_EFFECT } from "./effect-type.ts"; import { parseIssueRequest } from "./records.ts"; import type { IssueRequest } from "./records.ts"; diff --git a/packages/workflow/src/issue/operations.ts b/packages/git/src/issue/operations.ts similarity index 100% rename from packages/workflow/src/issue/operations.ts rename to packages/git/src/issue/operations.ts diff --git a/packages/workflow/src/issue/records.ts b/packages/git/src/issue/records.ts similarity index 99% rename from packages/workflow/src/issue/records.ts rename to packages/git/src/issue/records.ts index a18acede9..3ed14b0ce 100644 --- a/packages/workflow/src/issue/records.ts +++ b/packages/git/src/issue/records.ts @@ -39,7 +39,7 @@ import { until, type Operation } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { canonicalJson } from "../storage/record.ts"; +import { canonicalJson } from "@executablemd/workflow"; import type { IssueDetails, IssueInput, IssueOperation, IssueReference } from "./api.ts"; /** Where one Issue effect sits: the run it belongs to, and its expansion. */ diff --git a/packages/workflow/src/issue/tracker.ts b/packages/git/src/issue/tracker.ts similarity index 100% rename from packages/workflow/src/issue/tracker.ts rename to packages/git/src/issue/tracker.ts diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts new file mode 100644 index 000000000..721a1a9a1 --- /dev/null +++ b/packages/git/src/plugin.ts @@ -0,0 +1,194 @@ +/** + * The Git Plugin: what writing `` in a document means. + * + * Installing it declares vocabulary and admits history, and does nothing else. + * The Repository, Worktree, Dir, Git, PullRequest, IssueTracker and Issue + * components become ordinary defaults for the command, exactly as they were + * when this package was part of `@executablemd/workflow` — same names, same + * order, same props, same documentation and the same durable origins. A + * repository-local component of one of those names is still chosen ahead of + * them. + * + * What installing does *not* do is reach a repository. No ambient checkout is + * discovered, no managed directory is created, no `git` runs, no credential is + * read and no request is made. Those belong to the first operation that + * actually needs repository state, which is why `xmd syntax` and `` can + * describe this vocabulary without touching a machine's repositories at all. + * + * ## One Plugin value, one execution's identities + * + * The admissions this returns are what let a replay recognize the Git-host and + * Issue records it inherited. They are admissions rather than anything + * installed alongside them because canonical core applies an admission inside + * each execution's own journal read: a host that installs this Plugin once and + * runs two documents gets two journal reads, so each execution derives its + * identities from its own retained history and neither can read the other's. + */ + +import type { Operation } from "effection"; +import { Plugin } from "@executablemd/core/api"; +import type { PluginInstallRequest, PluginInstallation } from "@executablemd/core/api"; +import { useCompositionComponents } from "./composition/installation.ts"; +import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.ts"; + +/** + * The commands that execute a document, or describe what one could write. + * + * `workflow` is not one of them on its own: most of that command reads and + * manages runs without executing anything, and declaring a vocabulary into + * `xmd workflow list` would advertise components nothing there can expand. + * Which workflow *action* was asked for decides it, and + * {@link executesDocument} is what reads that off the argv. + */ +const DOCUMENT_COMMANDS: ReadonlySet = new Set(["run", "plan", "syntax"]); + +/** The workflow actions that execute a document. */ +const EXECUTING_ACTIONS: ReadonlySet = new Set(["start", "resume", "fork"]); + +/** + * The workflow options whose value is the token after them. + * + * The same list the review Plugin's own scan carries, for the same reason and + * written out for the same reason: it is every non-boolean field of the + * command's grammar (`workflowConfig` in `packages/cli/src/workflow.ts`) plus + * the aggregate and generated root properties, which are read out of argv + * before that grammar exists. The boolean switches take no value and are + * skipped as the options they are. `--plugin` is not here: the host has already + * taken it out of the argv this scans. + * + * It is written out here rather than read from the command because the CLI + * depends on this package and not the other way round. What makes the omission + * of one visible is the rule it breaks: `xmd workflow --output start export + * run-1` exports a run, and a scan that read `start` as the action would have + * declared this vocabulary for a command that executes no document. + */ +const VALUED_OPTIONS: ReadonlySet = new Set([ + "--id", + "--at", + "--status", + "--artifact", + "--output", +]); + +/** The generated root-property options, which take a separated value too. */ +const PROPERTY_OPTION = "--props"; + +/** Whether this token is an option that takes the token after it. */ +function takesValue(token: string): boolean { + // An assigned spelling carries its own value and is one token. Checked first + // because `--props-name=alice` matches the generated-property prefix, and + // stepping over the word after it would skip the action. + if (token.includes("=")) { + return false; + } + return ( + VALUED_OPTIONS.has(token) || + token === PROPERTY_OPTION || + token.startsWith(`${PROPERTY_OPTION}-`) + ); +} + +/** The pre-command grammar, which is read before any other scanner. */ +const PLUGIN_OPTION = "--plugin"; +const PLUGIN_ASSIGNMENT = `${PLUGIN_OPTION}=`; + +/** + * The argv with the Plugin selection removed, as the host reads it. + * + * `--plugin` is answered before every other scanner, so the command token is + * the first token *left* once those are gone — not the first token that happens + * to spell a command. `xmd --plugin workflow workflow start flow.md` selects a + * module named `workflow` and then runs `workflow start`, and searching the raw + * argv for the word would find the specifier and read the command as the + * action. + * + * The same scan the host performs (`selectPlugins` in + * `packages/cli/src/plugin-selection.ts`), written out here rather than + * imported because the CLI depends on this package and not the other way round. + * It stops at `--` for the same reason: every token after the separator keeps + * the meaning the caller gave it. + */ +function withoutPluginSelection(args: readonly string[]): string[] { + const rest: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === undefined) { + continue; + } + if (arg === "--") { + rest.push(...args.slice(index)); + break; + } + if (arg === PLUGIN_OPTION) { + const value = args[index + 1]; + // A missing value, and a value that reads as another option, are what the + // host refuses the whole command line for. Nothing is selected, nothing + // is installed, and there is no command here to declare for. + if (value === undefined || value.length === 0 || value.startsWith("-")) { + return []; + } + index += 1; + continue; + } + if (arg.startsWith(PLUGIN_ASSIGNMENT)) { + continue; + } + rest.push(arg); + } + return rest; +} + +/** + * Whether this workflow command line executes a document. + * + * The action is the first positional after the command: the first token that is + * neither an option nor an option's value. Everything after `--` is positional + * by definition and is not scanned for one, and a token this command defines no + * action for is not one — a malformed command line executes no document either + * way, and the command itself is what says so. + */ +function executesDocument(args: readonly string[]): boolean { + const [command, ...tokens] = withoutPluginSelection(args); + if (command !== "workflow") { + return false; + } + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token === undefined || token === "--") { + return false; + } + if (takesValue(token)) { + index += 1; + continue; + } + if (token.startsWith("-")) { + continue; + } + return EXECUTING_ACTIONS.has(token); + } + return false; +} + +/** Whether this command's profile is one this vocabulary belongs to. */ +export function declaresFor(request: PluginInstallRequest): boolean { + if (DOCUMENT_COMMANDS.has(request.command)) { + return true; + } + return request.command === "workflow" && executesDocument(request.args); +} + +export const gitPlugin: Plugin = Plugin({ + name: "@executablemd/git", + *install(request: PluginInstallRequest): Operation { + if (!declaresFor(request)) { + return undefined; + } + // Declarations only. Every one of these is a name and a description; the + // provider that performs what they name is attached by the host, lazily, + // when a run actually has a Workspace to perform it in. + yield* useCompositionComponents(); + return { admissions: [gitHostIdentityAdmission(), issueIdentityAdmission()] }; + }, +}); + +export default gitPlugin; diff --git a/packages/workflow/tests/ambient-authentication.test.ts b/packages/git/tests/ambient-authentication.test.ts similarity index 99% rename from packages/workflow/tests/ambient-authentication.test.ts rename to packages/git/tests/ambient-authentication.test.ts index 70a2dc3aa..2c47113b8 100644 --- a/packages/workflow/tests/ambient-authentication.test.ts +++ b/packages/git/tests/ambient-authentication.test.ts @@ -54,9 +54,9 @@ import type { } from "../src/deno/composition/github.ts"; import { GITHUB, useGitHubIssues } from "../src/deno/issue/github.ts"; import { IssueApi } from "../src/issue/api.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; import { useGitHttpRemote } from "./support/git-http.ts"; import type { GitHttpRemote } from "./support/git-http.ts"; diff --git a/packages/workflow/tests/credential-helper.test.ts b/packages/git/tests/credential-helper.test.ts similarity index 100% rename from packages/workflow/tests/credential-helper.test.ts rename to packages/git/tests/credential-helper.test.ts diff --git a/packages/workflow/tests/git-add-crash.test.ts b/packages/git/tests/git-add-crash.test.ts similarity index 98% rename from packages/workflow/tests/git-add-crash.test.ts rename to packages/git/tests/git-add-crash.test.ts index e453000be..c34a93eaa 100644 --- a/packages/workflow/tests/git-add-crash.test.ts +++ b/packages/git/tests/git-add-crash.test.ts @@ -22,7 +22,12 @@ import { exec as execProcess } from "@effectionx/process"; import { exec } from "@executablemd/runtime"; import { call, type Operation, race, scoped, spawn, withResolvers } from "effection"; import { WORKSPACE_GIT_ADD } from "../src/deno/composition/provider.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { CRASH_PATH, MAIN_CONTENT } from "./support/git-crash-process.ts"; diff --git a/packages/workflow/tests/git-add-durability.test.ts b/packages/git/tests/git-add-durability.test.ts similarity index 96% rename from packages/workflow/tests/git-add-durability.test.ts rename to packages/git/tests/git-add-durability.test.ts index 0d144fb68..34b8748f2 100644 --- a/packages/workflow/tests/git-add-durability.test.ts +++ b/packages/git/tests/git-add-durability.test.ts @@ -31,11 +31,18 @@ import type { RepositoryRecord } from "../src/composition/records.ts"; import { WORKSPACE_GIT_ADD } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, @@ -525,6 +532,7 @@ describe("workflow Git.Add composition routing", () => { }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }); @@ -553,6 +561,7 @@ describe("workflow Git.Add composition routing", () => { }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }), ); diff --git a/packages/workflow/tests/git-add.test.ts b/packages/git/tests/git-add.test.ts similarity index 98% rename from packages/workflow/tests/git-add.test.ts rename to packages/git/tests/git-add.test.ts index 6ae75682d..1927bbbbd 100644 --- a/packages/workflow/tests/git-add.test.ts +++ b/packages/git/tests/git-add.test.ts @@ -38,10 +38,11 @@ import type { RepositoryRecord } from "../src/composition/records.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { GitWorkspaceOptions } from "../src/deno/attachment.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import type { RepositorySelection } from "../src/composition/selection.ts"; import { @@ -138,7 +139,7 @@ function runForged( database: WorkflowRunDatabase, record: RepositorySelection, source: string, - options: WorkflowWorkspaceOptions, + options: GitWorkspaceOptions, ): Operation { return scoped(function* () { return yield* withWorkflowWorkspace( @@ -149,7 +150,7 @@ function runForged( yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } @@ -638,7 +639,7 @@ describe("workflow Git.Add selection", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); @@ -874,7 +875,7 @@ describe("workflow Git.Add pathspec text", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); @@ -987,7 +988,7 @@ describe("workflow Git.Add request ownership", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); diff --git a/packages/workflow/tests/git-commit-crash.test.ts b/packages/git/tests/git-commit-crash.test.ts similarity index 98% rename from packages/workflow/tests/git-commit-crash.test.ts rename to packages/git/tests/git-commit-crash.test.ts index 2b43e5646..921bfe946 100644 --- a/packages/workflow/tests/git-commit-crash.test.ts +++ b/packages/git/tests/git-commit-crash.test.ts @@ -22,7 +22,12 @@ import { exec as execProcess } from "@effectionx/process"; import { exec } from "@executablemd/runtime"; import { call, type Operation, race, scoped, spawn, withResolvers } from "effection"; import { WORKSPACE_GIT_COMMIT } from "../src/deno/composition/provider.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { CRASH_MESSAGE, CRASH_PATH, MAIN_CONTENT } from "./support/git-crash-process.ts"; diff --git a/packages/workflow/tests/git-commit-durability.test.ts b/packages/git/tests/git-commit-durability.test.ts similarity index 96% rename from packages/workflow/tests/git-commit-durability.test.ts rename to packages/git/tests/git-commit-durability.test.ts index a3e50568a..c48632b1c 100644 --- a/packages/workflow/tests/git-commit-durability.test.ts +++ b/packages/git/tests/git-commit-durability.test.ts @@ -31,11 +31,18 @@ import type { RepositoryRecord } from "../src/composition/records.ts"; import { WORKSPACE_GIT_COMMIT } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, @@ -520,6 +527,7 @@ describe("workflow Git.Commit composition routing", () => { yield* execute({ ...inlineSource(staged), stream: database.journal }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }); @@ -541,6 +549,7 @@ describe("workflow Git.Commit composition routing", () => { yield* execute({ ...inlineSource(staged), stream: forged.journal }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }), ); diff --git a/packages/workflow/tests/git-commit.test.ts b/packages/git/tests/git-commit.test.ts similarity index 98% rename from packages/workflow/tests/git-commit.test.ts rename to packages/git/tests/git-commit.test.ts index f14d647ac..ad8574fb6 100644 --- a/packages/workflow/tests/git-commit.test.ts +++ b/packages/git/tests/git-commit.test.ts @@ -44,10 +44,16 @@ import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; import { gitCommitMessageEvidence } from "../src/deno/composition/commit.ts"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { GitWorkspaceOptions } from "../src/deno/attachment.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, @@ -167,7 +173,7 @@ function runForged( database: WorkflowRunDatabase, record: RepositorySelection, source: string, - options: WorkflowWorkspaceOptions, + options: GitWorkspaceOptions, ): Operation { return scoped(function* () { return yield* withWorkflowWorkspace( @@ -178,7 +184,7 @@ function runForged( yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } @@ -630,6 +636,7 @@ describe("workflow Git.Commit leading content", () => { yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }); } @@ -985,7 +992,7 @@ describe("workflow Git.Commit selection", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); @@ -1179,7 +1186,7 @@ describe("workflow Git.Commit request ownership", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); diff --git a/packages/workflow/tests/git-host-effect.test.ts b/packages/git/tests/git-host-effect.test.ts similarity index 97% rename from packages/workflow/tests/git-host-effect.test.ts rename to packages/git/tests/git-host-effect.test.ts index 3c420d904..f3ed0163c 100644 --- a/packages/workflow/tests/git-host-effect.test.ts +++ b/packages/git/tests/git-host-effect.test.ts @@ -34,8 +34,10 @@ import { registerComponents, } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { retainedWorkflowInstallation } from "../src/run.ts"; -import type { WorkflowRun } from "../src/run.ts"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { retainedWorkflowInstallation } from "../../workflow/src/run.ts"; +import { gitPlugin } from "../src/plugin.ts"; +import type { WorkflowRun } from "../../workflow/src/run.ts"; import { GIT_HOST_EFFECT, reconcileGitHostEffect, @@ -67,6 +69,24 @@ import type { GitHostReconciliationRecord, } from "../src/git-host/records.ts"; +/** + * The admissions the Git Plugin contributes, as a host installing it receives + * them. + * + * Asked of the Plugin value rather than assembled here, so what these cases + * exercise is the same contribution a command gets — one Plugin value, and an + * admission that derives this execution's identities from this execution's own + * retained history. + */ +function* gitPluginAdmissions(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "run", args: [] }); + return { admissions: [...(installed?.admissions ?? [])] }; +} + const RUN: WorkflowRun = Object.freeze({ runId: "run-297-git-host", base: "main", @@ -289,6 +309,7 @@ function* collectSource(source: string, stream: DurableStream): Operation { // nothing to recognize the retained record by. What it must not be able to // do is turn that into a live effect at a position the history already // holds one for. + // + // The name is the one the Git-host journal admission installs. #822 gave + // each admission its own stable name and its own fresh value; what this + // case is about — a holder of the name erasing what it holds — is + // unchanged, and so is every assertion below. const sourceRunId = "run-297-git-host-source"; const seedStream = new InMemoryStream(); const seedHost = recordingProvider(answering(Ok(ABSENT)), answering(Ok(PERFORMED))); @@ -1293,14 +1319,15 @@ describe("Tier GH — shared external Git-host effect reconciliation", () => { origin: "tier-fe", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { - // The run is preserved exactly; only what sits beside it moves. - const slot = createContext | undefined>( - "executablemd.workflow.run", + // The queue is bound by name, so a document could rebind it. The run + // itself is untouched; only what sits beside it moves. + const slot = createContext( + "executablemd.git.git-host.retained-identities", undefined, ); const genuine = yield* slot.get(); expect(genuine).toBeDefined(); - yield* slot.set({ ...genuine, gitHostIdentities: substitute }); + yield* slot.set(substitute); // And every mismatch becomes live execution. yield* Divergence.around({ decide: () => ({ type: "run-live" }) }); seen.expansions.push((yield* getExpansion()).id); @@ -1392,12 +1419,14 @@ function* markedRun(options: { props: { type: "object", properties: {}, additionalProperties: false }, *fn() { if (options.erase === true) { - const slot = createContext | undefined>( - "executablemd.workflow.run", + const slot = createContext( + "executablemd.git.git-host.retained-identities", undefined, ); const genuine = yield* slot.get(); - yield* slot.set({ ...genuine, gitHostIdentities: [] }); + // The name really was bound, so what follows erases something. + expect(genuine).toBeDefined(); + yield* slot.set([]); yield* Divergence.around({ decide: () => ({ type: "run-live" }) }); } seen.expansions.push((yield* getExpansion()).id); diff --git a/packages/workflow/tests/git-push-crash.test.ts b/packages/git/tests/git-push-crash.test.ts similarity index 99% rename from packages/workflow/tests/git-push-crash.test.ts rename to packages/git/tests/git-push-crash.test.ts index 8f0e5b05e..7fd569b81 100644 --- a/packages/workflow/tests/git-push-crash.test.ts +++ b/packages/git/tests/git-push-crash.test.ts @@ -36,10 +36,15 @@ import { suspend, withResolvers, } from "effection"; -import { WorkflowRunStorage } from "../mod.ts"; +import { WorkflowRunStorage } from "@executablemd/workflow"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { remoteBranch, useBareRemote } from "./support/git-remotes.ts"; import { countingHost, diff --git a/packages/workflow/tests/git-push-durability.test.ts b/packages/git/tests/git-push-durability.test.ts similarity index 99% rename from packages/workflow/tests/git-push-durability.test.ts rename to packages/git/tests/git-push-durability.test.ts index ddd4b1a96..e35f5f6c5 100644 --- a/packages/workflow/tests/git-push-durability.test.ts +++ b/packages/git/tests/git-push-durability.test.ts @@ -40,8 +40,14 @@ import { GitComposition } from "../src/composition/git-api.ts"; import type { RepositoryRecord } from "../src/composition/records.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; import { currentRepository } from "../src/composition/context.ts"; import type { RepositorySelection } from "../src/composition/selection.ts"; diff --git a/packages/workflow/tests/git-push.test.ts b/packages/git/tests/git-push.test.ts similarity index 99% rename from packages/workflow/tests/git-push.test.ts rename to packages/git/tests/git-push.test.ts index e45781bac..ae419e61d 100644 --- a/packages/workflow/tests/git-push.test.ts +++ b/packages/git/tests/git-push.test.ts @@ -33,8 +33,13 @@ import type { RepositoryRecord } from "../src/composition/records.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { git as nativeGit, moveRemoteBranch, @@ -43,8 +48,8 @@ import { useBareRemote, } from "./support/git-remotes.ts"; import type { BareRemote } from "./support/git-remotes.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { PrivateWorkspaceTransaction } from "../src/deno/workspace/private.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { PrivateWorkspaceTransaction } from "../../workflow/src/deno/workspace/private.ts"; import { causedBy, checkoutConfig, diff --git a/packages/workflow/tests/git-switch-crash.test.ts b/packages/git/tests/git-switch-crash.test.ts similarity index 98% rename from packages/workflow/tests/git-switch-crash.test.ts rename to packages/git/tests/git-switch-crash.test.ts index d28e2fa4f..a95b66bc3 100644 --- a/packages/workflow/tests/git-switch-crash.test.ts +++ b/packages/git/tests/git-switch-crash.test.ts @@ -22,7 +22,12 @@ import { exec as execProcess } from "@effectionx/process"; import { exec } from "@executablemd/runtime"; import { call, type Operation, race, scoped, spawn, withResolvers } from "effection"; import { WORKSPACE_GIT_SWITCH } from "../src/deno/composition/provider.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { MAIN_CONTENT, RELEASE_CONTENT } from "./support/git-crash-process.ts"; diff --git a/packages/workflow/tests/git-switch-durability.test.ts b/packages/git/tests/git-switch-durability.test.ts similarity index 97% rename from packages/workflow/tests/git-switch-durability.test.ts rename to packages/git/tests/git-switch-durability.test.ts index a178a394f..dc4ae4379 100644 --- a/packages/workflow/tests/git-switch-durability.test.ts +++ b/packages/git/tests/git-switch-durability.test.ts @@ -31,9 +31,10 @@ import { RepositoryComposition } from "../src/composition/api.ts"; import { GitComposition } from "../src/composition/git-api.ts"; import type { RepositorySelection } from "../src/composition/selection.ts"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { GitWorkspaceOptions } from "../src/deno/attachment.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { collect, execute, inlineSource, registerComponents } from "@executablemd/core"; import { cwd } from "@executablemd/runtime"; import type { ComponentRegistration } from "@executablemd/core"; @@ -41,7 +42,13 @@ import type { Json } from "@executablemd/durable-streams"; import { WORKSPACE_GIT_SWITCH } from "../src/deno/composition/provider.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { @@ -212,7 +219,7 @@ function runObserved( database: WorkflowRunDatabase, observe: (selection: RepositorySelection) => RepositorySelection, locator: string, - options: WorkflowWorkspaceOptions, + options: GitWorkspaceOptions, ): Operation { return scoped(function* () { return yield* withWorkflowWorkspace( @@ -223,7 +230,7 @@ function runObserved( yield* execute({ ...inlineSource(observedSource(locator)), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } diff --git a/packages/workflow/tests/git-switch.test.ts b/packages/git/tests/git-switch.test.ts similarity index 97% rename from packages/workflow/tests/git-switch.test.ts rename to packages/git/tests/git-switch.test.ts index 1dc53bfa1..38bef31e8 100644 --- a/packages/workflow/tests/git-switch.test.ts +++ b/packages/git/tests/git-switch.test.ts @@ -38,13 +38,19 @@ import { useCompositionComponents } from "../src/composition/installation.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; import { gitOperationFingerprint } from "../src/deno/composition/operations.ts"; -import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; -import type { WorkflowWorkspaceOptions } from "../src/deno/workspace/host.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import type { GitWorkspaceOptions } from "../src/deno/attachment.ts"; import type { RepositoryRecord } from "../src/composition/records.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, @@ -139,7 +145,7 @@ function runForged( database: WorkflowRunDatabase, record: RepositorySelection, source: string, - options: WorkflowWorkspaceOptions, + options: GitWorkspaceOptions, ): Operation { return scoped(function* () { return yield* withWorkflowWorkspace( @@ -150,7 +156,7 @@ function runForged( yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } @@ -752,7 +758,7 @@ describe("workflow Git.Switch selection", () => { }), ); }), - countingOptions(counting), + { attachments: [gitWorkspaceAttachment(countingOptions(counting))] }, ); }); @@ -949,6 +955,7 @@ describe("workflow Git composition routing", () => { }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }); @@ -975,6 +982,7 @@ describe("workflow Git composition routing", () => { }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); }), ); @@ -1077,7 +1085,7 @@ describe("workflow Git.Switch request ownership", () => { yield* withStorage(root, function* () { const database = yield* createRun(); - const perform = (options: WorkflowWorkspaceOptions): Operation => + const perform = (options: GitWorkspaceOptions): Operation => scoped(function* () { return yield* withWorkflowWorkspace( database, @@ -1087,7 +1095,7 @@ describe("workflow Git.Switch request ownership", () => { yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); diff --git a/packages/workflow/tests/git.test.ts b/packages/git/tests/git.test.ts similarity index 100% rename from packages/workflow/tests/git.test.ts rename to packages/git/tests/git.test.ts diff --git a/packages/workflow/tests/issue-github.test.ts b/packages/git/tests/issue-github.test.ts similarity index 100% rename from packages/workflow/tests/issue-github.test.ts rename to packages/git/tests/issue-github.test.ts diff --git a/packages/workflow/tests/issue-markdown.test.ts b/packages/git/tests/issue-markdown.test.ts similarity index 100% rename from packages/workflow/tests/issue-markdown.test.ts rename to packages/git/tests/issue-markdown.test.ts diff --git a/packages/workflow/tests/issue-records.test.ts b/packages/git/tests/issue-records.test.ts similarity index 100% rename from packages/workflow/tests/issue-records.test.ts rename to packages/git/tests/issue-records.test.ts diff --git a/packages/workflow/tests/materialization.test.ts b/packages/git/tests/materialization.test.ts similarity index 96% rename from packages/workflow/tests/materialization.test.ts rename to packages/git/tests/materialization.test.ts index b4bee0c6e..12d96e368 100644 --- a/packages/workflow/tests/materialization.test.ts +++ b/packages/git/tests/materialization.test.ts @@ -24,9 +24,9 @@ import { join } from "node:path"; import { ensure, resource, until } from "effection"; import { RepositoryStaleStateError } from "../src/composition/errors.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; /** A host directory that exists for the test that asked for it. */ function useHostRoot(): Operation { diff --git a/packages/git/tests/plugin.test.ts b/packages/git/tests/plugin.test.ts new file mode 100644 index 000000000..9b8179981 --- /dev/null +++ b/packages/git/tests/plugin.test.ts @@ -0,0 +1,424 @@ +/** + * The Git Plugin: which commands it declares for, and what each execution + * under it can see of another execution's history. + * + * Installing the Plugin is one act per command, and a command may execute more + * than one document. What a Git-host record needs in order to replay is the run + * it was written under, and that answer is one execution's — so it is carried + * by journal admissions, which canonical core applies inside each execution's + * own journal read. These cases hold the Plugin to both halves of that: it + * declares only where a document is executed or described, and the value its + * admissions install is derived from the snapshot that execution was handed and + * from no other. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { Ok, scoped } from "effection"; +import type { Operation, Result } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { collect, inlineSource, registerComponents } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { PluginInstallRequest } from "@executablemd/core/api"; +import { retainedWorkflowInstallation } from "../../workflow/src/run.ts"; +import type { WorkflowRun } from "../../workflow/src/run.ts"; +import { declaresFor, gitPlugin } from "../src/plugin.ts"; +import { retainedGitHostIdentitiesHere, retainedIssueIdentitiesHere } from "../src/identities.ts"; +import { + GIT_HOST_EFFECT, + reconcileGitHostEffect, + withGitHostProvider, +} from "../src/git-host/effect.ts"; +import type { GitHostProvider } from "../src/git-host/api.ts"; +import type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostEffectRequest, + GitHostObservation, +} from "../src/git-host/records.ts"; + +const SOURCE = "\n"; + +const PUSH: GitHostEffectRequest = Object.freeze({ + kind: "git-push", + inputs: { remote: "origin", branch: "release-1.4", commit: "9fceb02" }, + naturalKey: { ref: "refs/heads/release-1.4" }, +}); + +const ABSENT: GitHostObservation = Object.freeze({ state: "absent", preState: { ref: null } }); + +const PERFORMED: GitHostCompletion = Object.freeze({ + observations: { ref: "refs/heads/release-1.4", commit: "9fceb02" }, + result: { ref: "refs/heads/release-1.4", commit: "9fceb02", updated: true }, +}); + +function run(runId: string): WorkflowRun { + return Object.freeze({ + runId, + base: "main", + pinnedCommit: "9fceb02d0ae598e95dc970b74767f19372d61af8", + }); +} + +/** The two runs whose histories these cases keep apart. */ +const SOURCE_A = run("run-plugin-source-a"); +const SOURCE_B = run("run-plugin-source-b"); + +/** What one execution saw, at the boundaries a claim can be made about. */ +interface Seen { + readonly gitHost: (readonly { runId: string; claimed: boolean }[] | undefined)[]; + readonly issue: (readonly unknown[] | undefined)[]; + readonly failures: unknown[]; +} + +function seen(): Seen { + return { gitHost: [], issue: [], failures: [] }; +} + +/** A provider that fails the test if any phase reaches it. */ +const FORBIDDEN: GitHostProvider = { + // deno-lint-ignore require-yield + *observe(): Operation> { + throw new Error("the Git host was observed where nothing may be observed"); + }, + // deno-lint-ignore require-yield + *perform(): Operation> { + throw new Error("the Git host performed where nothing may be performed"); + }, +}; + +/** A provider that answers absent and performs, for the recording pass only. */ +const LIVE: GitHostProvider = { + // deno-lint-ignore require-yield + *observe(_request: CompleteGitHostEffectRequest): Operation> { + return Ok(ABSENT); + }, + // deno-lint-ignore require-yield + *perform(): Operation> { + return Ok(PERFORMED); + }, +}; + +/** + * One `` that reports what its execution admitted before it + * reconciles. + * + * Reading a context journals nothing, so the recording pass and every replay + * expand the same document and retain the same events. + */ +function useEffectComponent(observed: Seen): Operation { + return registerComponents([ + { + name: "Effect", + origin: "git-plugin-tests", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + // Copied at the moment of reading. The list is a copy but its entries + // are not, and the reconciliation below claims one of them — so an + // entry held onto here would report what this execution did rather + // than what it was handed. + observed.gitHost.push( + (yield* retainedGitHostIdentitiesHere())?.map((identity) => ({ + runId: identity.runId, + claimed: identity.claimed, + })), + ); + observed.issue.push(yield* retainedIssueIdentitiesHere()); + yield* reconcileGitHostEffect(PUSH); + return ""; + }, + }, + ]); +} + +/** The history a run leaves behind when its root has not closed. */ +function partial(events: DurableEvent[]): DurableEvent[] { + return events.filter((event) => !(event.type === "close" && event.coroutineId === "root")); +} + +function gitHostYields(events: DurableEvent[]): DurableEvent[] { + return events.filter( + (event) => event.type === "yield" && event.description.type === GIT_HOST_EFFECT, + ); +} + +/** + * One document execution, under one retained run and one Plugin contribution. + * + * `installation` is the same value in every call a case makes, because that is + * the question: a host installs this Plugin once and may run many documents. + */ +function* execute(options: { + readonly stream: InMemoryStream; + readonly run: WorkflowRun; + readonly installation: ExecutionInstallation; + readonly provider: GitHostProvider; + readonly observed: Seen; +}): Operation { + yield* scoped(function* () { + yield* useEffectComponent(options.observed); + try { + yield* withGitHostProvider(options.provider, document(options)); + } catch (error) { + // A failed document is one of the outcomes under test. What each case + // measures is what the execution saw and what the journal holds, and both + // outlive the failure. + options.observed.failures.push(error); + } + }); +} + +/** + * The document itself, as an operation the provider scope encloses. + * + * Deferred rather than started here: an execution built before + * {@link withGitHostProvider} runs would look for a provider that is not + * installed yet. + */ +function* document(options: { + readonly stream: InMemoryStream; + readonly run: WorkflowRun; + readonly installation: ExecutionInstallation; +}): Operation { + return yield* collect( + yield* executeInstalled({ ...inlineSource(SOURCE), stream: options.stream }, [ + retainedWorkflowInstallation(options.run), + options.installation, + ]), + ); +} + +/** The Plugin's contribution, asked of the Plugin value exactly once. */ +function* installed(request: PluginInstallRequest): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const contribution = yield* install.call(gitPlugin, request); + if (contribution === undefined) { + throw new Error("the Git Plugin declared for no command"); + } + return { admissions: [...(contribution.admissions ?? [])] }; +} + +/** A history holding one settled Git-host record, written under `source`. */ +function* record( + source: WorkflowRun, + installation: ExecutionInstallation, +): Operation { + const stream = new InMemoryStream(); + const observed = seen(); + yield* execute({ stream, run: source, installation, provider: LIVE, observed }); + expect(observed.failures).toEqual([]); + // The recording pass admitted an empty history, so it saw no identity at all + // and named itself. That is also what makes the replays below meaningful. + expect(observed.gitHost[0]).toEqual([]); + const events = partial(stream.snapshot()); + expect(gitHostYields(events)).toHaveLength(1); + return events; +} + +describe("the Git Plugin's command profile", () => { + it("declares for the commands that execute or describe a document", function* () { + for (const command of ["run", "plan", "syntax"]) { + expect(declaresFor({ command, args: [command] })).toBe(true); + } + }); + + it("declares for the workflow actions that execute a document, and no others", function* () { + for (const action of ["start", "resume", "fork"]) { + expect(declaresFor({ command: "workflow", args: ["workflow", action, "flow.md"] })).toBe( + true, + ); + } + // Management and read-only actions expand nothing, and a vocabulary + // declared into them would advertise components nothing there can run. + for (const action of ["list", "status", "history", "cancel", "delete", "export", "answer"]) { + expect(`${action}: ${declaresFor({ command: "workflow", args: ["workflow", action] })}`).toBe( + `${action}: false`, + ); + } + // `workflow` with no action at all is the command's own help. + expect(declaresFor({ command: "workflow", args: ["workflow"] })).toBe(false); + // And a command this Plugin knows nothing about stays untouched, even when + // its argv contains a word that would be an executing action elsewhere. + for (const command of ["test", "upgrade", "init", "agent"]) { + expect(declaresFor({ command, args: [command, "start"] })).toBe(false); + } + }); + + it("reads an option's value as a value, never as the action", function* () { + // Every option the command defines that takes a separated value, each with + // an executing action's own name as that value and a management action + // after it. Reading the first recognized word would have declared this + // vocabulary for a command that executes no document. + const valued: readonly (readonly [string, readonly string[]])[] = [ + ["--plugin", ["workflow", "--plugin", "start", "list"]], + ["--output", ["workflow", "--output", "start", "export", "run-1"]], + ["--status", ["workflow", "--status", "resume", "list"]], + ["--id", ["workflow", "--id", "fork", "list"]], + ["--at", ["workflow", "--at", "start", "history", "run-1"]], + ["--artifact", ["workflow", "--artifact", "start", "status"]], + ["--props", ["workflow", "--props", "start", "list"]], + ["--props-name", ["workflow", "--props-name", "start", "list"]], + ]; + for (const [option, args] of valued) { + expect(`${option}: ${declaresFor({ command: "workflow", args })}`).toBe(`${option}: false`); + } + + // The assigned spelling is one token. A scan that special-cased the + // separated form would step over the action written after it. + expect( + declaresFor({ command: "workflow", args: ["workflow", "--output=start.xmd", "export", "r"] }), + ).toBe(false); + expect( + declaresFor({ + command: "workflow", + args: ["workflow", "--props-name=alice", "start", "flow.md"], + }), + ).toBe(true); + + // And the same reading still finds a real action written after an option. + const executing: readonly (readonly string[])[] = [ + ["workflow", "--plugin", "list", "start", "flow.md"], + ["workflow", "--id", "release-1", "start", "flow.md"], + ["workflow", "--at", "event-4", "fork", "run-1", "flow.md"], + ["workflow", "--verbose", "resume", "run-1"], + ["workflow", "--json", "--props", "list", "start", "flow.md"], + ]; + for (const args of executing) { + expect(`${args.join(" ")}: ${declaresFor({ command: "workflow", args })}`).toBe( + `${args.join(" ")}: true`, + ); + } + }); + + it("finds the command past the Plugin selection, not by searching for it", function* () { + // `--plugin` is answered before every other scanner, so the command is the + // first token left once the selection is gone. + const selected: readonly (readonly [readonly string[], boolean])[] = [ + [["--plugin", "extra", "workflow", "start", "flow.md"], true], + [["--plugin", "extra", "workflow", "list"], false], + // The specifier is a module name, and a module may be named anything — + // including the name of a command. Searching the raw argv for the word + // would find this specifier and read the command itself as the action. + [["--plugin", "workflow", "workflow", "start", "flow.md"], true], + [["--plugin", "workflow", "workflow", "list"], false], + // The assigned spelling is one token and is removed the same way. + [["--plugin=workflow", "workflow", "start", "flow.md"], true], + [["--plugin=workflow", "workflow", "list"], false], + [["--plugin=extra", "--plugin", "workflow", "workflow", "resume", "run-1"], true], + ]; + for (const [args, declares] of selected) { + expect(`${args.join(" ")}: ${declaresFor({ command: "workflow", args })}`).toBe( + `${args.join(" ")}: ${declares}`, + ); + } + + // A command line the host refuses selects nothing and installs nothing, so + // there is no command here to declare for. + expect(declaresFor({ command: "workflow", args: ["--plugin"] })).toBe(false); + expect( + declaresFor({ command: "workflow", args: ["--plugin", "--json", "workflow", "start"] }), + ).toBe(false); + + // And a first token that is not this command is not this command: a token + // naming no command at all is a document reference to `xmd run`. + expect(declaresFor({ command: "workflow", args: ["start", "flow.md"] })).toBe(false); + expect(declaresFor({ command: "workflow", args: ["flow.md", "workflow", "start"] })).toBe( + false, + ); + }); + + it("stops scanning for an action at the end of options", function* () { + // Everything after `--` is positional by definition, so it is not searched + // for an action at all. + expect(declaresFor({ command: "workflow", args: ["workflow", "--", "start"] })).toBe(false); + expect(declaresFor({ command: "workflow", args: ["workflow", "--", "list"] })).toBe(false); + // But a valued option takes the token after it whatever that token is, so + // this `--` is the id and `start` is still the action. + expect(declaresFor({ command: "workflow", args: ["workflow", "--id", "--", "start"] })).toBe( + true, + ); + expect(declaresFor({ command: "workflow", args: ["workflow", "--id", "--", "list"] })).toBe( + false, + ); + expect(declaresFor({ command: "workflow", args: ["workflow", "--"] })).toBe(false); + }); + + it("installs nothing for a command it does not declare for", function* () { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + expect( + yield* install.call(gitPlugin, { command: "workflow", args: ["workflow", "list"] }), + ).toBe(undefined); + const contribution = yield* install.call(gitPlugin, { command: "run", args: ["run"] }); + expect(contribution?.admissions).toHaveLength(2); + }); +}); + +describe("one Plugin value, one execution's retained identities", () => { + it("derives each execution's identities from its own history and no other", function* () { + // One installation, asked of the Plugin once, and used by every execution + // below — which is what a host running several documents under one command + // actually holds. + const installation = yield* installed({ command: "run", args: ["run"] }); + + const historyA = yield* record(SOURCE_A, installation); + const historyB = yield* record(SOURCE_B, installation); + + // Every replay below runs provider-free, so what it consumes is the + // retained record and nothing else, and what it reports is the value its + // own execution's journal read installed. + const first = seen(); + yield* execute({ + stream: new InMemoryStream(historyA), + run: SOURCE_A, + installation, + provider: FORBIDDEN, + observed: first, + }); + expect(first.failures).toEqual([]); + expect(first.gitHost[0]?.map((identity) => identity.runId)).toEqual([SOURCE_A.runId]); + expect(first.gitHost[0]?.every((identity) => !identity.claimed)).toBe(true); + + const second = seen(); + yield* execute({ + stream: new InMemoryStream(historyB), + run: SOURCE_B, + installation, + provider: FORBIDDEN, + observed: second, + }); + expect(second.failures).toEqual([]); + // Nothing of the first execution's history is here: not its run, and not + // an identity it had already consumed. + expect(second.gitHost[0]?.map((identity) => identity.runId)).toEqual([SOURCE_B.runId]); + expect(second.gitHost[0]?.every((identity) => !identity.claimed)).toBe(true); + + // And the first history is still its own after both of those ran. A queue + // shared between executions would be consumed by now. + const again = seen(); + yield* execute({ + stream: new InMemoryStream(historyA), + run: SOURCE_A, + installation, + provider: FORBIDDEN, + observed: again, + }); + expect(again.failures).toEqual([]); + expect(again.gitHost[0]?.map((identity) => identity.runId)).toEqual([SOURCE_A.runId]); + expect(again.gitHost[0]?.every((identity) => !identity.claimed)).toBe(true); + + // The Issue admission installed its own value on the same terms: a list + // this history holds nothing in, rather than the absence that means no + // admission ran. + for (const observed of [first, second, again]) { + expect(observed.issue[0]).toEqual([]); + } + }); +}); diff --git a/packages/git/tests/provider-neutrality.test.ts b/packages/git/tests/provider-neutrality.test.ts new file mode 100644 index 000000000..5ec36be80 --- /dev/null +++ b/packages/git/tests/provider-neutrality.test.ts @@ -0,0 +1,114 @@ +/** + * The half of this package that names no provider. + * + * Repository/Worktree composition and the shared Git-host boundary were + * provider-neutral while they lived in `@executablemd/workflow`, and the move + * changed their addresses rather than that property. The components a document + * writes name no subprocess, no host filesystem and no runtime; the + * external-effect boundary names no Git host, because it exists so that an + * adapter can be written for any of them and the first adapter naming itself + * in a shared contract is how a neutral surface quietly becomes one provider's. + * + * Only `src/deno/**` is allowed to know where it is running, and this proves + * the scan reached past it rather than skipping everything. + */ + +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + COMPUTED, + forbiddenNames, + moduleSpecifiers, + parse, +} from "@executablemd/test-support/host-boundary"; +import { readTextFile } from "@effectionx/fs"; +import { glob } from "@executablemd/runtime"; + +const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); + +describe("the Git package's provider-neutral surface", () => { + it("names no host, no runtime, no storage engine and no Git host", function* () { + // The scan has to be able to fail, or an empty report says nothing. These + // modules explain in their own prose that they name no provider, so a + // search of the whole file would find the explanation rather than a + // crossing. + expect(forbiddenNames(`import { DatabaseSync } from "node:sqlite";`)).toEqual([ + "DatabaseSync", + "sqlite", + "node:sqlite", + ]); + expect(forbiddenNames(`import { run } from "@effectionx/process";`)).toEqual([ + "@effectionx/process", + ]); + expect(forbiddenNames("const home = Deno.cwd();")).toEqual(["Deno"]); + expect(forbiddenNames(`import "../deno/composition/host.ts";`)).toEqual([ + "../deno/composition/host.ts", + ]); + expect(forbiddenNames(`const target = adapter;\nawait import(target);`)).toEqual([COMPUTED]); + + // The sharpest control for this package: the first Git-host adapter is a + // provider, and a shared contract that names it has chosen one. + expect(forbiddenNames(`import { push } from "./GitHubAdapter.ts";`)).toEqual(["GitHub"]); + expect(forbiddenNames(`type Request = { readonly githubRepository: string };`)).toEqual([ + "github", + ]); + // …while the domain noun these modules are actually about is not a + // provider, and neither is prose. + expect(forbiddenNames(`type Request = { readonly repository: string };`)).toEqual([]); + expect(forbiddenNames("// the GitHub adapter implements this boundary")).toEqual([]); + expect(forbiddenNames(`import { reconcile } from "./git-host/effect.ts";`)).toEqual([]); + + const found = (yield* glob({ + root: REPOSITORY, + patterns: ["packages/git/mod.ts", "packages/git/src/**/*.ts"], + // The whole package rather than named modules, so a boundary module + // added later is covered without this list being remembered. + exclude: ["packages/git/src/deno/**"], + })) + .map((entry) => entry.path) + .sort(); + + // A pattern that matched nothing would report a clean boundary, so the + // surface this rule is about is named here. + expect(found).toEqual( + expect.arrayContaining([ + "packages/git/mod.ts", + "packages/git/src/plugin.ts", + "packages/git/src/identities.ts", + "packages/git/src/composition/api.ts", + "packages/git/src/composition/components/Dir.ts", + "packages/git/src/composition/components/Repository.ts", + "packages/git/src/composition/components/Worktree.ts", + "packages/git/src/composition/context.ts", + "packages/git/src/composition/errors.ts", + "packages/git/src/composition/installation.ts", + "packages/git/src/composition/records.ts", + "packages/git/src/git-host/api.ts", + "packages/git/src/git-host/effect.ts", + "packages/git/src/git-host/errors.ts", + "packages/git/src/git-host/records.ts", + ]), + ); + expect(found.some((path) => path.includes("/src/deno/"))).toBe(false); + + const crossings: Record = {}; + const unread: string[] = []; + for (const path of found) { + const source = yield* readTextFile(join(REPOSITORY, path)); + // A parse that failed would report every file as clean. A module whose + // text imports something must yield a specifier, or this scan is reading + // nothing and saying so approvingly. + if (/^import\s/m.test(source) && moduleSpecifiers(parse(source).file).length === 0) { + unread.push(path); + } + const names = forbiddenNames(source); + if (names.length > 0) { + crossings[path] = names; + } + } + expect(unread).toEqual([]); + expect(crossings).toEqual({}); + }); +}); diff --git a/packages/workflow/tests/pull-request-crash.test.ts b/packages/git/tests/pull-request-crash.test.ts similarity index 97% rename from packages/workflow/tests/pull-request-crash.test.ts rename to packages/git/tests/pull-request-crash.test.ts index c15f86bbf..4b368b948 100644 --- a/packages/workflow/tests/pull-request-crash.test.ts +++ b/packages/git/tests/pull-request-crash.test.ts @@ -25,11 +25,16 @@ import { DatabaseSync } from "node:sqlite"; import { fileURLToPath } from "node:url"; import { exec as execProcess } from "@effectionx/process"; import { call, type Operation, race, scoped, spawn, withResolvers } from "effection"; -import { WorkflowRunStorage } from "../mod.ts"; +import { WorkflowRunStorage } from "@executablemd/workflow"; import { GIT_HOST_EFFECT } from "../src/git-host/effect.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; import { PULL_REQUEST } from "../src/composition/pull-request-records.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { remoteRefs, useBareRemote } from "./support/git-remotes.ts"; import { gitHostEvents, gitHostOutcomes, runWorkflowDocument } from "./support/composition.ts"; import { creations, gitHubStore, useGitHubServer } from "./support/github.ts"; diff --git a/packages/workflow/tests/pull-request-durability.test.ts b/packages/git/tests/pull-request-durability.test.ts similarity index 99% rename from packages/workflow/tests/pull-request-durability.test.ts rename to packages/git/tests/pull-request-durability.test.ts index a2f5cf954..0ac4e517a 100644 --- a/packages/workflow/tests/pull-request-durability.test.ts +++ b/packages/git/tests/pull-request-durability.test.ts @@ -25,7 +25,13 @@ import type { GitHubHttpRequest, GitHubHttpResponse, } from "../src/deno/composition/github.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { SOURCE_POSITION_FIELD } from "@executablemd/core"; import { diff --git a/packages/workflow/tests/pull-request-github.test.ts b/packages/git/tests/pull-request-github.test.ts similarity index 100% rename from packages/workflow/tests/pull-request-github.test.ts rename to packages/git/tests/pull-request-github.test.ts diff --git a/packages/workflow/tests/pull-request-read.test.ts b/packages/git/tests/pull-request-read.test.ts similarity index 99% rename from packages/workflow/tests/pull-request-read.test.ts rename to packages/git/tests/pull-request-read.test.ts index d3f39a072..a343aafa6 100644 --- a/packages/workflow/tests/pull-request-read.test.ts +++ b/packages/git/tests/pull-request-read.test.ts @@ -24,9 +24,14 @@ import { recognizesGitHubPullRequestUrl, } from "../src/deno/composition/pull-request-reads.ts"; import { PullRequestReadError } from "../src/composition/errors.ts"; -import { createRun, runPath, useStorageRoot, withStorage } from "./support/storage.ts"; +import { + createRun, + runPath, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import type { DurableEvent, Json } from "@executablemd/durable-streams"; -import type { WorkflowRunDatabase } from "../mod.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { dropRootClose } from "./support/replay.ts"; import { raised, runWorkflowDocument } from "./support/composition.ts"; import { gitHubSource } from "../src/deno/composition/github.ts"; diff --git a/packages/workflow/tests/pull-request-records.test.ts b/packages/git/tests/pull-request-records.test.ts similarity index 100% rename from packages/workflow/tests/pull-request-records.test.ts rename to packages/git/tests/pull-request-records.test.ts diff --git a/packages/workflow/tests/pull-request.test.ts b/packages/git/tests/pull-request.test.ts similarity index 99% rename from packages/workflow/tests/pull-request.test.ts rename to packages/git/tests/pull-request.test.ts index ae4014121..9394678b5 100644 --- a/packages/workflow/tests/pull-request.test.ts +++ b/packages/git/tests/pull-request.test.ts @@ -24,8 +24,8 @@ import { } from "../src/composition/errors.ts"; import { useCompositionComponents } from "../src/composition/installation.ts"; import { parseGitHostReconciliationRecord } from "../src/git-host/records.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { remoteBranch, remoteRefs, useBareRemote } from "./support/git-remotes.ts"; import type { BareRemote } from "./support/git-remotes.ts"; import { diff --git a/packages/workflow/tests/repository-components.test.ts b/packages/git/tests/repository-components.test.ts similarity index 99% rename from packages/workflow/tests/repository-components.test.ts rename to packages/git/tests/repository-components.test.ts index 9b511c8fd..00b2a2044 100644 --- a/packages/workflow/tests/repository-components.test.ts +++ b/packages/git/tests/repository-components.test.ts @@ -26,13 +26,13 @@ import { } from "@executablemd/core"; import type { ComponentInvocation, ComponentRegistration } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; -import type { WorkflowRunDatabase } from "../mod.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { useCompositionComponents } from "../src/composition/installation.ts"; import { RepositoryCompositionProviderError } from "../src/composition/errors.ts"; import { DirInvocationError } from "../src/composition/components/Dir.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, diff --git a/packages/workflow/tests/repository-control-plane.test.ts b/packages/git/tests/repository-control-plane.test.ts similarity index 96% rename from packages/workflow/tests/repository-control-plane.test.ts rename to packages/git/tests/repository-control-plane.test.ts index ed60d6c06..ccfec70c2 100644 --- a/packages/workflow/tests/repository-control-plane.test.ts +++ b/packages/git/tests/repository-control-plane.test.ts @@ -20,14 +20,20 @@ import { RepositoryCompositionError, RepositoryStaleStateError, } from "../src/composition/errors.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { git, useBareRemote } from "./support/git-remotes.ts"; import { causedBy, diff --git a/packages/workflow/tests/repository-materialization.test.ts b/packages/git/tests/repository-materialization.test.ts similarity index 95% rename from packages/workflow/tests/repository-materialization.test.ts rename to packages/git/tests/repository-materialization.test.ts index 1b4385fc7..c837a744b 100644 --- a/packages/workflow/tests/repository-materialization.test.ts +++ b/packages/git/tests/repository-materialization.test.ts @@ -20,14 +20,20 @@ import { RepositoryCompositionError, RepositoryStaleStateError, } from "../src/composition/errors.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { git, useBareRemote } from "./support/git-remotes.ts"; import { causedBy, diff --git a/packages/workflow/tests/repository-replay.test.ts b/packages/git/tests/repository-replay.test.ts similarity index 98% rename from packages/workflow/tests/repository-replay.test.ts rename to packages/git/tests/repository-replay.test.ts index 4bf7bbd28..4ad7b1f5b 100644 --- a/packages/workflow/tests/repository-replay.test.ts +++ b/packages/git/tests/repository-replay.test.ts @@ -21,14 +21,20 @@ import { RepositoryCompositionError, RepositoryStaleStateError, } from "../src/composition/errors.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { git, useBareRemote } from "./support/git-remotes.ts"; import { causedBy, diff --git a/packages/workflow/tests/repository-storage.test.ts b/packages/git/tests/repository-storage.test.ts similarity index 99% rename from packages/workflow/tests/repository-storage.test.ts rename to packages/git/tests/repository-storage.test.ts index ca02b3a7f..c685bdc9c 100644 --- a/packages/workflow/tests/repository-storage.test.ts +++ b/packages/git/tests/repository-storage.test.ts @@ -14,7 +14,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { createRun, useStorageRoot, withStorage } from "./support/storage.ts"; +import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; import { git, moveRemoteBranch, useBareRemote } from "./support/git-remotes.ts"; import { countingHost, diff --git a/packages/workflow/tests/run-composition-ambient.test.ts b/packages/git/tests/run-composition-ambient.test.ts similarity index 100% rename from packages/workflow/tests/run-composition-ambient.test.ts rename to packages/git/tests/run-composition-ambient.test.ts diff --git a/packages/workflow/tests/run-composition-managed.test.ts b/packages/git/tests/run-composition-managed.test.ts similarity index 100% rename from packages/workflow/tests/run-composition-managed.test.ts rename to packages/git/tests/run-composition-managed.test.ts diff --git a/packages/workflow/tests/run-composition-remote.test.ts b/packages/git/tests/run-composition-remote.test.ts similarity index 100% rename from packages/workflow/tests/run-composition-remote.test.ts rename to packages/git/tests/run-composition-remote.test.ts diff --git a/packages/workflow/tests/scenarios/IssueDurability.attempt.stage.md b/packages/git/tests/scenarios/IssueDurability.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueDurability.attempt.stage.md rename to packages/git/tests/scenarios/IssueDurability.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueDurability.test.md b/packages/git/tests/scenarios/IssueDurability.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueDurability.test.md rename to packages/git/tests/scenarios/IssueDurability.test.md diff --git a/packages/workflow/tests/scenarios/IssueHttp.test.md b/packages/git/tests/scenarios/IssueHttp.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueHttp.test.md rename to packages/git/tests/scenarios/IssueHttp.test.md diff --git a/packages/workflow/tests/scenarios/IssueRead.test.md b/packages/git/tests/scenarios/IssueRead.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRead.test.md rename to packages/git/tests/scenarios/IssueRead.test.md diff --git a/packages/workflow/tests/scenarios/IssueReadDurability.attempt.stage.md b/packages/git/tests/scenarios/IssueReadDurability.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueReadDurability.attempt.stage.md rename to packages/git/tests/scenarios/IssueReadDurability.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueReadDurability.test.md b/packages/git/tests/scenarios/IssueReadDurability.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueReadDurability.test.md rename to packages/git/tests/scenarios/IssueReadDurability.test.md diff --git a/packages/workflow/tests/scenarios/IssueReadSubstituted.attempt.stage.md b/packages/git/tests/scenarios/IssueReadSubstituted.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueReadSubstituted.attempt.stage.md rename to packages/git/tests/scenarios/IssueReadSubstituted.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueReadSubstituted.test.md b/packages/git/tests/scenarios/IssueReadSubstituted.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueReadSubstituted.test.md rename to packages/git/tests/scenarios/IssueReadSubstituted.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecovery.attempt.stage.md b/packages/git/tests/scenarios/IssueRecovery.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecovery.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecovery.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecovery.test.md b/packages/git/tests/scenarios/IssueRecovery.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecovery.test.md rename to packages/git/tests/scenarios/IssueRecovery.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryClosed.attempt.stage.md b/packages/git/tests/scenarios/IssueRecoveryClosed.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryClosed.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecoveryClosed.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryClosed.test.md b/packages/git/tests/scenarios/IssueRecoveryClosed.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryClosed.test.md rename to packages/git/tests/scenarios/IssueRecoveryClosed.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryDuplicated.attempt.stage.md b/packages/git/tests/scenarios/IssueRecoveryDuplicated.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryDuplicated.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecoveryDuplicated.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryDuplicated.test.md b/packages/git/tests/scenarios/IssueRecoveryDuplicated.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryDuplicated.test.md rename to packages/git/tests/scenarios/IssueRecoveryDuplicated.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryEdited.attempt.stage.md b/packages/git/tests/scenarios/IssueRecoveryEdited.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryEdited.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecoveryEdited.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryEdited.test.md b/packages/git/tests/scenarios/IssueRecoveryEdited.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryEdited.test.md rename to packages/git/tests/scenarios/IssueRecoveryEdited.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryMoved.attempt.stage.md b/packages/git/tests/scenarios/IssueRecoveryMoved.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryMoved.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecoveryMoved.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryMoved.test.md b/packages/git/tests/scenarios/IssueRecoveryMoved.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryMoved.test.md rename to packages/git/tests/scenarios/IssueRecoveryMoved.test.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryUnavailable.attempt.stage.md b/packages/git/tests/scenarios/IssueRecoveryUnavailable.attempt.stage.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryUnavailable.attempt.stage.md rename to packages/git/tests/scenarios/IssueRecoveryUnavailable.attempt.stage.md diff --git a/packages/workflow/tests/scenarios/IssueRecoveryUnavailable.test.md b/packages/git/tests/scenarios/IssueRecoveryUnavailable.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRecoveryUnavailable.test.md rename to packages/git/tests/scenarios/IssueRecoveryUnavailable.test.md diff --git a/packages/workflow/tests/scenarios/IssueRouting.test.md b/packages/git/tests/scenarios/IssueRouting.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueRouting.test.md rename to packages/git/tests/scenarios/IssueRouting.test.md diff --git a/packages/workflow/tests/scenarios/IssueUpsert.test.md b/packages/git/tests/scenarios/IssueUpsert.test.md similarity index 100% rename from packages/workflow/tests/scenarios/IssueUpsert.test.md rename to packages/git/tests/scenarios/IssueUpsert.test.md diff --git a/packages/workflow/tests/scenarios/sessions/Failing.test.md b/packages/git/tests/scenarios/sessions/Failing.test.md similarity index 100% rename from packages/workflow/tests/scenarios/sessions/Failing.test.md rename to packages/git/tests/scenarios/sessions/Failing.test.md diff --git a/packages/workflow/tests/scenarios/sessions/First.test.md b/packages/git/tests/scenarios/sessions/First.test.md similarity index 100% rename from packages/workflow/tests/scenarios/sessions/First.test.md rename to packages/git/tests/scenarios/sessions/First.test.md diff --git a/packages/workflow/tests/scenarios/sessions/Passing.test.md b/packages/git/tests/scenarios/sessions/Passing.test.md similarity index 100% rename from packages/workflow/tests/scenarios/sessions/Passing.test.md rename to packages/git/tests/scenarios/sessions/Passing.test.md diff --git a/packages/workflow/tests/scenarios/sessions/Second.test.md b/packages/git/tests/scenarios/sessions/Second.test.md similarity index 100% rename from packages/workflow/tests/scenarios/sessions/Second.test.md rename to packages/git/tests/scenarios/sessions/Second.test.md diff --git a/packages/workflow/tests/scenarios/sessions/Untested.md b/packages/git/tests/scenarios/sessions/Untested.md similarity index 100% rename from packages/workflow/tests/scenarios/sessions/Untested.md rename to packages/git/tests/scenarios/sessions/Untested.md diff --git a/packages/workflow/tests/selection-authentication.test.ts b/packages/git/tests/selection-authentication.test.ts similarity index 100% rename from packages/workflow/tests/selection-authentication.test.ts rename to packages/git/tests/selection-authentication.test.ts diff --git a/packages/workflow/tests/support/composition.ts b/packages/git/tests/support/composition.ts similarity index 93% rename from packages/workflow/tests/support/composition.ts rename to packages/git/tests/support/composition.ts index fcfbe9d58..ba5c61863 100644 --- a/packages/workflow/tests/support/composition.ts +++ b/packages/git/tests/support/composition.ts @@ -17,7 +17,8 @@ import { useTempDirectory } from "@executablemd/test-support/temp"; import { GitComposition } from "../../src/composition/git-api.ts"; import { collect, execute, INLINE_SOURCE_PATH, inlineSource } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { retainedWorkflowInstallation } from "../../src/run.ts"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; import { GIT_HOST_EFFECT } from "../../src/git-host/effect.ts"; import type { Json } from "@executablemd/durable-streams"; import type { Yield } from "@executablemd/durable-streams"; @@ -26,9 +27,11 @@ import { type GitHostReconciliationRecord, parseGitHostReconciliationRecord, } from "../../src/git-host/records.ts"; -import type { WorkflowRunDatabase } from "../../mod.ts"; -import { withWorkflowWorkspace } from "../../src/deno/workspace/host.ts"; -import type { WorkflowWorkspaceOptions } from "../../src/deno/workspace/host.ts"; +import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; +import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { gitPlugin } from "../../src/plugin.ts"; +import type { GitWorkspaceOptions } from "../../src/deno/attachment.ts"; import { WORKSPACE_GIT_ADD, WORKSPACE_GIT_COMMIT, @@ -42,18 +45,37 @@ import { denoRepositoryHost, useGitAuthentication } from "../../src/deno/composi import type { HelperAssembly } from "../../src/deno/composition/credential-helper.ts"; import type { GitAuthenticationSession } from "../../src/deno/composition/authentication.ts"; import type { GitInvocation, GitOutcome, RepositoryHost } from "../../src/deno/composition/host.ts"; -import { transactWorkspaceRoots } from "../../src/deno/workspace/private.ts"; +import { transactWorkspaceRoots } from "../../../workflow/src/deno/workspace/private.ts"; import { exportTree, importTree, localizeAdministration, } from "../../src/deno/composition/materialize.ts"; -import { - createWorkspaceMetadata, - type StoredRepository, -} from "../../src/deno/workspace/repositories.ts"; +import { createWorkspaceMetadata, type StoredRepository } from "../../src/deno/repositories.ts"; import type { WorktreeRecord } from "../../src/composition/records.ts"; -import { isGitWorkflowRunRecord, type WorkflowRun, type WorkflowRunRecord } from "../../mod.ts"; +import { + isGitWorkflowRunRecord, + type WorkflowRun, + type WorkflowRunRecord, +} from "@executablemd/workflow"; + +/** + * The admissions the Git Plugin contributes, as a host installing it receives + * them. + * + * Asked of the Plugin value rather than assembled here, so what these cases + * exercise is the same contribution a command gets — one Plugin value, and an + * admission that derives this execution's identities from this execution's own + * retained history. + */ +function* gitPluginAdmissions(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "run", args: [] }); + return { admissions: [...(installed?.admissions ?? [])] }; +} /** What one execution did, at the boundaries a claim can be made about. */ export interface CompositionCounters { @@ -134,7 +156,7 @@ function launcherEnvironment(): Record { return carried; } -export function countingOptions(counting: CountingHost): WorkflowWorkspaceOptions { +export function countingOptions(counting: CountingHost): GitWorkspaceOptions { return { composition: { host: counting.host, @@ -150,7 +172,7 @@ export function countingOptions(counting: CountingHost): WorkflowWorkspaceOption export function runDocument( database: WorkflowRunDatabase, source: string, - options: WorkflowWorkspaceOptions = {}, + options: GitWorkspaceOptions = {}, ): Operation { return scoped(function* () { return yield* withWorkflowWorkspace( @@ -160,7 +182,7 @@ export function runDocument( yield* execute({ ...inlineSource(source), stream: database.journal }), ); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } @@ -181,7 +203,7 @@ export function runDocument( export function runWorkflowDocument( database: WorkflowRunDatabase, source: string, - options: WorkflowWorkspaceOptions = {}, + options: GitWorkspaceOptions = {}, around: (execute: () => Operation) => Operation = (execute) => execute(), ): Operation { return scoped(function* () { @@ -192,11 +214,12 @@ export function runWorkflowDocument( return yield* collect( yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, [ retainedWorkflowInstallation(retainedRunValue(database.record)), + yield* gitPluginAdmissions(), ]), ); }); }), - options, + { attachments: [gitWorkspaceAttachment(options)] }, ); }); } diff --git a/packages/workflow/tests/support/credential-helper-entry.ts b/packages/git/tests/support/credential-helper-entry.ts similarity index 100% rename from packages/workflow/tests/support/credential-helper-entry.ts rename to packages/git/tests/support/credential-helper-entry.ts diff --git a/packages/workflow/tests/support/credential-home.ts b/packages/git/tests/support/credential-home.ts similarity index 100% rename from packages/workflow/tests/support/credential-home.ts rename to packages/git/tests/support/credential-home.ts diff --git a/packages/workflow/tests/support/git-crash-child.ts b/packages/git/tests/support/git-crash-child.ts similarity index 89% rename from packages/workflow/tests/support/git-crash-child.ts rename to packages/git/tests/support/git-crash-child.ts index a50772e8c..f7e528cdb 100644 --- a/packages/workflow/tests/support/git-crash-child.ts +++ b/packages/git/tests/support/git-crash-child.ts @@ -37,29 +37,33 @@ import process from "node:process"; import { collect, execute, inlineSource } from "@executablemd/core"; import { ensure, main, type Operation, scoped, suspend } from "effection"; -import { isGitWorkflowRunRecord, WorkflowRunStorage } from "../../mod.ts"; -import { useWorkflowRunStorage, workflowRunPath } from "../../deno.ts"; -import { createWorkflowRunConnections } from "../../src/deno/connections.ts"; -import { openWorkflowRunDatabase, readRunRow } from "../../src/deno/database.ts"; -import { useJournalRouting } from "../../src/deno/journal-route.ts"; -import { readTransaction } from "../../src/deno/reading.ts"; -import { verifySchema } from "../../src/deno/schema.ts"; +import { isGitWorkflowRunRecord, WorkflowRunStorage } from "@executablemd/workflow"; +import { useWorkflowRunStorage, workflowRunPath } from "@executablemd/workflow/deno"; +import { createWorkflowRunConnections } from "../../../workflow/src/deno/connections.ts"; +import { openWorkflowRunDatabase, readRunRow } from "../../../workflow/src/deno/database.ts"; +import { useJournalRouting } from "../../../workflow/src/deno/journal-route.ts"; +import { readTransaction } from "../../../workflow/src/deno/reading.ts"; +import { verifySchema } from "../../../workflow/src/deno/schema.ts"; import { WORKSPACE_GIT_ADD, WORKSPACE_GIT_COMMIT, WORKSPACE_GIT_SWITCH, } from "../../src/deno/composition/provider.ts"; -import { useWorkspaceEffects } from "../../src/deno/workspace/effect.ts"; -import { withWorkflowWorkspace } from "../../src/deno/workspace/host.ts"; -import { currentWorkspaceRoot } from "../../src/deno/workspace/root.ts"; -import { transactWorkspaceRoots, usePrivateWorkspace } from "../../src/deno/workspace/private.ts"; -import { createWorkspaceMetadata } from "../../src/deno/workspace/repositories.ts"; +import { useWorkspaceEffects } from "../../../workflow/src/deno/workspace/effect.ts"; +import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; +import { currentWorkspaceRoot } from "../../../workflow/src/deno/workspace/root.ts"; +import { + transactWorkspaceRoots, + usePrivateWorkspace, +} from "../../../workflow/src/deno/workspace/private.ts"; +import { createWorkspaceMetadata } from "../../src/deno/repositories.ts"; import { executeInstalled } from "@executablemd/core/host"; -import { retainedWorkflowInstallation } from "../../src/run.ts"; +import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; import { denoRepositoryHost, useGitAuthentication } from "../../src/deno/composition/host.ts"; import { denoGitAuthentication } from "../../src/deno/composition/authentication.ts"; import type { GitInvocation, GitOutcome } from "../../src/deno/composition/host.ts"; -import { count, report } from "./workspace-process.ts"; +import { count, report } from "../../../workflow/tests/support/workspace-process.ts"; import { addDocument, commitDocument, crashDocument, pushDocument } from "./git-crash-process.ts"; import { headCommit, stagedPaths } from "./composition.ts"; @@ -137,6 +141,7 @@ function* crash( }), ); }), + { attachments: [gitWorkspaceAttachment()] }, ); report({ ready: false, reason: "the operation committed" }); } @@ -246,7 +251,7 @@ function* pushCrash( ), ); }), - { composition: { host } }, + { attachments: [gitWorkspaceAttachment({ composition: { host } })] }, ); report({ ready: false, reason: "the push published" }); } diff --git a/packages/workflow/tests/support/git-crash-process.ts b/packages/git/tests/support/git-crash-process.ts similarity index 100% rename from packages/workflow/tests/support/git-crash-process.ts rename to packages/git/tests/support/git-crash-process.ts diff --git a/packages/workflow/tests/support/git-http.ts b/packages/git/tests/support/git-http.ts similarity index 100% rename from packages/workflow/tests/support/git-http.ts rename to packages/git/tests/support/git-http.ts diff --git a/packages/workflow/tests/support/git-remotes.ts b/packages/git/tests/support/git-remotes.ts similarity index 100% rename from packages/workflow/tests/support/git-remotes.ts rename to packages/git/tests/support/git-remotes.ts diff --git a/packages/workflow/tests/support/github.ts b/packages/git/tests/support/github.ts similarity index 100% rename from packages/workflow/tests/support/github.ts rename to packages/git/tests/support/github.ts diff --git a/packages/workflow/tests/support/issue-providers.ts b/packages/git/tests/support/issue-providers.ts similarity index 100% rename from packages/workflow/tests/support/issue-providers.ts rename to packages/git/tests/support/issue-providers.ts diff --git a/packages/workflow/tests/support/issue-scenario.ts b/packages/git/tests/support/issue-scenario.ts similarity index 99% rename from packages/workflow/tests/support/issue-scenario.ts rename to packages/git/tests/support/issue-scenario.ts index ffd5087e3..e0f950314 100644 --- a/packages/workflow/tests/support/issue-scenario.ts +++ b/packages/git/tests/support/issue-scenario.ts @@ -26,8 +26,8 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; import { useTesting } from "@executablemd/testing"; import type { TestResult } from "@executablemd/testing"; -import { retainedWorkflowInstallation } from "../../src/run.ts"; -import type { WorkflowRun } from "../../src/run.ts"; +import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; +import type { WorkflowRun } from "../../../workflow/src/run.ts"; import { useCompositionComponents } from "../../src/composition/installation.ts"; import { ISSUE_EFFECT } from "../../src/issue/effect-type.ts"; import { byCodePoint } from "../../src/issue/records.ts"; diff --git a/packages/workflow/tests/support/issue-tracker-server.ts b/packages/git/tests/support/issue-tracker-server.ts similarity index 100% rename from packages/workflow/tests/support/issue-tracker-server.ts rename to packages/git/tests/support/issue-tracker-server.ts diff --git a/packages/workflow/tests/support/pull-request-crash-child.ts b/packages/git/tests/support/pull-request-crash-child.ts similarity index 84% rename from packages/workflow/tests/support/pull-request-crash-child.ts rename to packages/git/tests/support/pull-request-crash-child.ts index 05bb8325a..6fa4b5d7a 100644 --- a/packages/workflow/tests/support/pull-request-crash-child.ts +++ b/packages/git/tests/support/pull-request-crash-child.ts @@ -18,17 +18,18 @@ import process from "node:process"; import { main, type Operation, scoped, suspend } from "effection"; import { collect, inlineSource } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import { isGitWorkflowRunRecord, WorkflowRunStorage } from "../../mod.ts"; -import { useWorkflowRunStorage } from "../../deno.ts"; -import { retainedWorkflowInstallation } from "../../src/run.ts"; -import { withWorkflowWorkspace } from "../../src/deno/workspace/host.ts"; +import { isGitWorkflowRunRecord, WorkflowRunStorage } from "@executablemd/workflow"; +import { useWorkflowRunStorage } from "@executablemd/workflow/deno"; +import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; +import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; import { denoGitHubAccess } from "../../src/deno/composition/github.ts"; import type { GitHubAccess, GitHubHttpRequest, GitHubHttpResponse, } from "../../src/deno/composition/github.ts"; -import { report } from "./workspace-process.ts"; +import { report } from "../../../workflow/tests/support/workspace-process.ts"; import { published, pullRequest, rewriting } from "./pull-requests.ts"; import { gitHubSource } from "../../src/deno/composition/github.ts"; @@ -89,8 +90,12 @@ function* open(root: string, runId: string, locator: string, endpoint: string): ); }), { - composition: { host: rewriting(locator) }, - gitHubPullRequests: { access: gitHubSource(interrupted(endpoint)) }, + attachments: [ + gitWorkspaceAttachment({ + composition: { host: rewriting(locator) }, + gitHubPullRequests: { access: gitHubSource(interrupted(endpoint)) }, + }), + ], }, ); report({ ready: false, reason: "the pull request was recorded" }); diff --git a/packages/workflow/tests/support/pull-requests.ts b/packages/git/tests/support/pull-requests.ts similarity index 97% rename from packages/workflow/tests/support/pull-requests.ts rename to packages/git/tests/support/pull-requests.ts index e6822f286..ae13ea8aa 100644 --- a/packages/workflow/tests/support/pull-requests.ts +++ b/packages/git/tests/support/pull-requests.ts @@ -13,7 +13,7 @@ import type { Operation } from "effection"; import { denoRepositoryHost } from "../../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome, RepositoryHost } from "../../src/deno/composition/host.ts"; import type { RepositoryRecord } from "../../src/composition/records.ts"; -import type { WorkflowWorkspaceOptions } from "../../src/deno/workspace/host.ts"; +import type { GitWorkspaceOptions } from "../../src/deno/attachment.ts"; import { countingHost, type CountingHost } from "./composition.ts"; import { remoteRefs, type BareRemote } from "./git-remotes.ts"; import { @@ -86,7 +86,7 @@ export function rewriting( export interface Fixture { readonly counting: CountingHost; readonly store: GitHubStore; - readonly options: WorkflowWorkspaceOptions; + readonly options: GitWorkspaceOptions; } /** diff --git a/packages/workflow/tests/support/replay.ts b/packages/git/tests/support/replay.ts similarity index 98% rename from packages/workflow/tests/support/replay.ts rename to packages/git/tests/support/replay.ts index ead13b7dd..ed58ea271 100644 --- a/packages/workflow/tests/support/replay.ts +++ b/packages/git/tests/support/replay.ts @@ -17,9 +17,9 @@ import { mkdir } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; import { RepositoryStaleStateError } from "../../src/composition/errors.ts"; import { WORKSPACE_REPOSITORY, WORKSPACE_WORKTREE } from "../../src/deno/composition/provider.ts"; -import { runPath, tamper } from "./storage.ts"; +import { runPath, tamper } from "../../../workflow/tests/support/storage.ts"; import { compositionEvents } from "./composition.ts"; -import type { WorkflowRunDatabase } from "../../src/storage/api.ts"; +import type { WorkflowRunDatabase } from "../../../workflow/src/storage/api.ts"; export const REMOTE = { commits: [{ message: "first", entries: [{ path: "which.txt", content: "first\n" }] }], diff --git a/packages/workflow/tests/support/run-composition-child.ts b/packages/git/tests/support/run-composition-child.ts similarity index 100% rename from packages/workflow/tests/support/run-composition-child.ts rename to packages/git/tests/support/run-composition-child.ts diff --git a/packages/workflow/tests/support/run-composition-tier.ts b/packages/git/tests/support/run-composition-tier.ts similarity index 100% rename from packages/workflow/tests/support/run-composition-tier.ts rename to packages/git/tests/support/run-composition-tier.ts diff --git a/packages/workflow/tests/support/run-composition.ts b/packages/git/tests/support/run-composition.ts similarity index 100% rename from packages/workflow/tests/support/run-composition.ts rename to packages/git/tests/support/run-composition.ts diff --git a/packages/workflow/tests/worktree-replay.test.ts b/packages/git/tests/worktree-replay.test.ts similarity index 91% rename from packages/workflow/tests/worktree-replay.test.ts rename to packages/git/tests/worktree-replay.test.ts index 0b080c967..ca4b1efd5 100644 --- a/packages/workflow/tests/worktree-replay.test.ts +++ b/packages/git/tests/worktree-replay.test.ts @@ -19,14 +19,20 @@ import { RepositoryCompositionError, RepositoryStaleStateError, } from "../src/composition/errors.ts"; -import type { WorkflowRunDatabase } from "../src/storage/api.ts"; +import type { WorkflowRunDatabase } from "../../workflow/src/storage/api.ts"; import { exportTree, importTree } from "../src/deno/composition/materialize.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import type { GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; -import { transactWorkspaceRoots } from "../src/deno/workspace/private.ts"; -import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; -import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; -import { createRun, runPath, tamper, useStorageRoot, withStorage } from "./support/storage.ts"; +import { transactWorkspaceRoots } from "../../workflow/src/deno/workspace/private.ts"; +import type { DenoWorkspaceFilesystem } from "../../workflow/src/deno/workspace/filesystem.ts"; +import { throwWorkspaceFilesystemFailure } from "../../workflow/src/deno/workspace/errors.ts"; +import { + createRun, + runPath, + tamper, + useStorageRoot, + withStorage, +} from "../../workflow/tests/support/storage.ts"; import { git, useBareRemote } from "./support/git-remotes.ts"; import { causedBy, diff --git a/packages/test-support/host-boundary.ts b/packages/test-support/host-boundary.ts new file mode 100644 index 000000000..fcaf56d1c --- /dev/null +++ b/packages/test-support/host-boundary.ts @@ -0,0 +1,349 @@ +/** + * Whether a module names a host, a runtime, a storage engine or a provider. + * + * A shared contract is neutral because nothing in it names one implementation. + * That is a property of the source text and of what the source loads, so it is + * read here rather than asserted: any package with a provider-neutral half can + * hold its own modules to this rule and name its own surface. + * + * The scan reads code rather than prose. These modules explain in their own + * comments that they name no host, and a substring search of the whole file + * would find the explanation and report the crossing it describes. + * + * `forbiddenNames` is the answer; the rest is exported so a suite can prove the + * scanner can fail before trusting an empty result from it. + */ + +import ts from "typescript"; + +/** + * Storage, adapter and provider names that mean a host reached this surface. + * + * Distinctive enough to be read as text. Runtime globals are not here — `Bun` + * is inside `Bundle` and `Deno` inside `Denominator`, so those are recognized + * as identifiers instead. + * + * A Git host's name is on the list for the same reason a database's is. The + * shared external-effect boundary exists so that any Git host can be adapted to + * it, and the first adapter naming itself in a shared contract is how a neutral + * surface quietly becomes one provider's. + * + * `Forge` is here for a different reason: it is the retired product noun this + * boundary was developed under, and #297's correction leaves no alias, no + * durable `forge_effect`, and no forwarding module behind. The scan reads code + * with comments stripped, so ordinary English — a token that cannot be forged — + * is not this vocabulary and is not what this refuses. + */ +const FORBIDDEN = [ + "GitHub", + "github", + "Forge", + "forge_effect", + "workflow.forge", + "src/forge/", + "DatabaseSync", + "SQLite", + "sqlite", + "Cloudflare", + "DOFS", + "dofs", + "savepoint", + "Savepoint", + "SAVEPOINT", + "RunConnection", + "WorkflowRunConnections", + "WorkflowRunTransactionToken", + "ConnectionGeneration", + "TransactionIdentity", +]; + +/** + * The names this repository gives a runtime-specific entry point. + * + * Code Rule 12 puts host behavior behind runtime-named modules — + * `packages/cli/src/{deno,node,bun,compiled}.ts` are the CLI's — so the name of + * the module is what says a host owns it. `deno` is not the only one, and an + * adapter rule that knows only `deno` is a rule about the adapter someone + * happened to write first. + */ +const RUNTIMES = ["deno", "node", "bun", "compiled", "cloudflare", "workerd"]; + +/** + * Globals only one host provides. + * + * `crypto`, `TextEncoder` and the rest of the cross-runtime Web surface are + * not here: naming a standard is not naming a host. + */ +const HOST_GLOBALS = [ + "process", + "Deno", + "Bun", + "Buffer", + "globalThis", + "navigator", + "__dirname", + "__filename", +]; + +/** + * A module specifier only one host can resolve. + * + * Named by shape rather than one at a time: a list of the host modules anyone + * thought of is a list of the ones that had already been noticed, and the + * import that crosses this boundary next is the one nobody wrote down. + * + * Segments are compared whole. `nodes/`, `bundle.ts` and `vendors/` contain a + * runtime's name without being one, and rejecting them would make the rule + * about spelling rather than about hosts. + */ +function hostModule(specifier: string): boolean { + if (/^(node|bun|deno|cloudflare|workerd):/.test(specifier)) { + return true; + } + if (specifier === "@effectionx/process") { + return true; + } + const segments = specifier.split("/"); + const last = segments[segments.length - 1].replace(/\.[cm]?[jt]sx?$/, ""); + return ( + segments.includes("vendor") || + segments.some((segment) => RUNTIMES.includes(segment)) || + RUNTIMES.includes(last) + ); +} + +/** + * Source with its comments removed. + * + * These modules describe in prose that they name no host, and a search of the + * whole file would find that description rather than a boundary crossing. + */ +export function code(source: string): string { + let output = ""; + let index = 0; + while (index < source.length) { + const character = source[index]; + const following = source[index + 1]; + if (character === "/" && following === "/") { + while (index < source.length && source[index] !== "\n") { + index += 1; + } + continue; + } + if (character === "/" && following === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + index += 1; + } + index += 2; + continue; + } + if (character === '"' || character === "'" || character === "`") { + output += character; + index += 1; + while (index < source.length && source[index] !== character) { + if (source[index] === "\\") { + output += source[index]; + index += 1; + } + output += source[index]; + index += 1; + } + output += character; + index += 1; + continue; + } + output += character; + index += 1; + } + return output; +} + +/** + * What a module this file loads cannot be shown to be. + * + * A specifier the file computes names whatever it is handed, so no inspection + * of this surface can say it is not a host module. It is refused rather than + * skipped: a boundary that admits what it cannot read is not a boundary. + */ +export const COMPUTED = "a computed module specifier"; + +/** + * Every module this source loads, read from the syntax rather than the text. + * + * Parsed, because module loading is not a pattern: a specifier can be a + * template literal, can escape its own characters, can be an expression, and + * the same characters can appear in a string that loads nothing. Each of those + * is a different answer, and only a parse tells them apart. + */ +export function moduleSpecifiers(file: ts.SourceFile): string[] { + const found: string[] = []; + + function record(node: ts.Node | undefined): void { + if ( + node !== undefined && + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ) { + // The parser has already decoded escapes, so `node:crypto` and + // `node:crypto` arrive here as the same specifier. + found.push(node.text); + return; + } + found.push(COMPUTED); + } + + function visit(node: ts.Node): void { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier !== undefined) { + record(node.moduleSpecifier); + } + } else if (ts.isImportEqualsDeclaration(node)) { + if (ts.isExternalModuleReference(node.moduleReference)) { + record(node.moduleReference.expression); + } + } else if (ts.isImportTypeNode(node)) { + record(ts.isLiteralTypeNode(node.argument) ? node.argument.literal : node.argument); + } else if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + callee.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(callee) && callee.text === "require") + ) { + record(node.arguments[0]); + } + } + ts.forEachChild(node, visit); + } + + visit(file); + return found; +} + +/** + * The scanned source, and a checker that knows what its names mean. + * + * `noLib` and `noResolve` are the point rather than an economy: nothing + * outside this file is loaded, so a name resolves only to what the file itself + * declares. Anything left unresolved is ambient — supplied by a host at + * runtime — which is exactly the question being asked. + */ +export function parse(source: string): { file: ts.SourceFile; checker: ts.TypeChecker } { + const path = "/scanned.ts"; + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const program = ts.createProgram({ + rootNames: [path], + options: { noLib: true, noResolve: true, target: ts.ScriptTarget.Latest }, + host: { + getSourceFile: (name) => (name === path ? file : undefined), + getDefaultLibFileName: () => "", + writeFile: () => {}, + getCurrentDirectory: () => "/", + getCanonicalFileName: (name) => name, + useCaseSensitiveFileNames: () => true, + getNewLine: () => "\n", + fileExists: (name) => name === path, + readFile: (name) => (name === path ? source : undefined), + }, + }); + return { file, checker: program.getTypeChecker() }; +} + +/** + * The slots in which the grammar writes a name rather than a reference. + * + * TypeScript spells the distinction structurally: an `IdentifierName` fills a + * `name`, `propertyName` or `label` slot of the node that owns it, and a + * qualified name's `right` is the same thing in type position. Everywhere else + * an identifier is an `IdentifierReference`. + */ +const LABEL_SLOTS = ["name", "propertyName", "label"]; + +/** + * Whether this identifier refers to a binding at all. + * + * Not a scope question — the checker answers those. This asks the grammar + * instead of listing the node kinds someone remembered: the member in + * `x.process`, the label in `break process`, the imported member in + * `{ Deno as portable }`, the key in `{ process: local }`, a named tuple + * element, an import attribute and every declaration's own name all fill a + * name slot, and none of them reads the name it spells. + * + * A shorthand property is the one name slot that is also a read, because + * `{ process }` declares a property and reads a binding with one identifier. + */ +function refers(node: ts.Identifier): boolean { + const parent = node.parent; + if (parent === undefined) { + return true; + } + if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) { + return true; + } + if (ts.isQualifiedName(parent) && parent.right === node) { + return false; + } + return !LABEL_SLOTS.some((slot) => Reflect.get(parent, slot) === node); +} + +/** + * Host globals this source actually reads. + * + * A name is the host's only when nothing in this file declares it, and the + * checker is what knows that. Value scopes and type scopes, `var` hoisting, + * `import =`, `namespace`, mapped-type and `infer` type parameters, accessors + * and shadowing are the language's rules, not a list kept here — every one of + * them was a false positive while this was a list. + */ +function hostGlobals(parsed: { file: ts.SourceFile; checker: ts.TypeChecker }): string[] { + const found: string[] = []; + + /** + * The binding this identifier reads. + * + * `{ process }` writes one name in two roles: the property the literal + * declares and the value it reads. The ordinary symbol is the property — it + * is declared right there, so asking for it would answer that every host + * global is locally declared the moment it is put in an object. The value + * symbol is the one the shorthand refers to. + */ + function binding(node: ts.Identifier): ts.Symbol | undefined { + const parent = node.parent; + if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) { + return parsed.checker.getShorthandAssignmentValueSymbol(parent); + } + return parsed.checker.getSymbolAtLocation(node); + } + + function visit(node: ts.Node): void { + if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && refers(node)) { + const declared = (binding(node)?.declarations ?? []).some( + (declaration) => declaration.getSourceFile() === parsed.file, + ); + if (!declared && !found.includes(node.text)) { + found.push(node.text); + } + } + ts.forEachChild(node, visit); + } + + visit(parsed.file); + return found; +} + +export function forbiddenNames(source: string): string[] { + const parsed = parse(source); + const scanned = code(source); + const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); + for (const global of hostGlobals(parsed)) { + if (!crossings.includes(global)) { + crossings.push(global); + } + } + for (const specifier of moduleSpecifiers(parsed.file)) { + const refused = specifier === COMPUTED || hostModule(specifier); + if (refused && !crossings.includes(specifier)) { + crossings.push(specifier); + } + } + return crossings; +} diff --git a/packages/test-support/package.json b/packages/test-support/package.json index b01910003..09e5dd014 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -6,6 +6,7 @@ "exports": { "./bdd": "./bdd.ts", "./expect": "./expect.ts", + "./host-boundary": "./host-boundary.ts", "./journal": "./journal.ts", "./launch": "./launch.ts", "./temp": "./temp.ts" diff --git a/packages/workflow/deno.json b/packages/workflow/deno.json index f9e877a96..1e01be40b 100644 --- a/packages/workflow/deno.json +++ b/packages/workflow/deno.json @@ -4,8 +4,7 @@ "license": "MIT", "exports": { ".": "./mod.ts", - "./deno": "./deno.ts", - "./credential-helper": "./src/deno/composition/credential-helper.ts" + "./deno": "./deno.ts" }, "publish": { "exclude": ["!vendor/cloudflare-computer-dofs/generated/**/*.d.ts"] diff --git a/packages/workflow/deno.ts b/packages/workflow/deno.ts index 6d527ed7f..0d12bd52b 100644 --- a/packages/workflow/deno.ts +++ b/packages/workflow/deno.ts @@ -75,6 +75,15 @@ export { workflowRunPath, } from "./src/deno/path.ts"; export { APPLICATION_ID, SCHEMA_VERSION } from "./src/deno/schema.ts"; +/** + * The advisory file lock this adapter coordinates run ownership through. + * + * Published because a feature that keeps its own checkouts beside a run needs + * the same mutual exclusion this package uses for executors, and two lock + * implementations over one directory would be two answers to who holds it. + */ +export { useAdvisoryLock } from "./src/deno/advisory-lock.ts"; +export type { AdvisoryLockFile } from "./src/deno/advisory-lock.ts"; // The narrow one, under the name a host already knows. The function beside it // in `workspace/host.ts` accepts the leaf substitutions a suite needs — the Git // subprocess, the temporary directory, the Git-host transport — and a @@ -82,14 +91,6 @@ export { APPLICATION_ID, SCHEMA_VERSION } from "./src/deno/schema.ts"; // that could install one could read the credential this adapter is holding, so // none of that crosses this entrypoint; the suites that need it import from // source, inside the package. -export { - GITHUB as GITHUB_PULL_REQUEST_PROVIDER, - parseGitHubPullRequestUrl, - pullRequestAllowed, - recognizesGitHubPullRequestUrl, - useGitHubPullRequests, -} from "./src/deno/composition/pull-request-reads.ts"; -export type { GitHubPullRequestsOptions } from "./src/deno/composition/pull-request-reads.ts"; export { withWorkflowWorkspace } from "./src/deno/workspace/published.ts"; /** * One durable effect inside this run's Workspace transaction. @@ -155,7 +156,12 @@ export { export { evaluationProfile } from "./src/deno/workspace/evaluate.ts"; export type { GeneratedEvaluationOptions } from "./src/deno/workspace/evaluate.ts"; export type { WorkflowWorkspaceOptions } from "./src/deno/workspace/published.ts"; -export type { WorkflowAgentAttachment, WorkflowAgentInstaller } from "./src/deno/workspace/host.ts"; +export type { + WorkflowAgentAttachment, + WorkflowAgentInstaller, + WorkflowWorkspaceAttachment, + WorkflowWorkspaceInstaller, +} from "./src/deno/workspace/host.ts"; export { providerSessionDirectory, removeProviderSessions, @@ -187,19 +193,6 @@ export type { AgentPromptCheckpoints, AgentPromptCheckpointRecord, } from "./src/deno/workspace/agent-checkpoints.ts"; -export { - WORKSPACE_GIT_ADD, - WORKSPACE_GIT_SWITCH, - WORKSPACE_REPOSITORY, - WORKSPACE_WORKTREE, -} from "./src/deno/composition/provider.ts"; -export { - GITHUB, - parseGitHubIssueTarget, - recognizesGitHubUrl, - useGitHubIssues, -} from "./src/deno/issue/github.ts"; -export type { GitHubIssuesOptions } from "./src/deno/issue/github.ts"; export { WORKSPACE_FILE } from "./src/deno/workspace/files.ts"; export { WORKSPACE_ROOT } from "./src/deno/workspace/logical-path.ts"; export { useWorkflowInputDelivery } from "./src/deno/delivery.ts"; @@ -210,14 +203,3 @@ export type { SuspensionControllerOptions, SuspensionNotice, } from "./src/deno/suspension.ts"; -/** - * The ordinary run's repository provider. - * - * The installer alone, and the options a trusted entrypoint supplies to it. - * What the provider holds — the leases, the credential assembly, the selection - * registry, the live Push evidence and the metadata writer — stays inside it: - * a package that could reach one of those could authorize a publication this - * execution never made. - */ -export { useRunComposition } from "./src/deno/run-composition/provider.ts"; -export type { RunCompositionOptions } from "./src/deno/run-composition/provider.ts"; diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index f3c98fdf1..7a69e685d 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -57,18 +57,7 @@ * one. */ -export { - Git, - gitObjectFormat, - GitObjectError, - GitRepositoryError, - GitRevisionError, - readGitObject, - repositoryRoot, - revParse, -} from "./src/git.ts"; -export type { GitApi, GitObjectFormat } from "./src/git.ts"; -export { getWorkflowRun, retainedWorkflowInstallation, workflowInstallation } from "./src/run.ts"; +export { getWorkflowRun, retainedWorkflowInstallation } from "./src/run.ts"; /** * How a trusted host states what its own run is. * @@ -82,234 +71,18 @@ export type { WorkflowRunPreparation } from "./src/run.ts"; export { workflowBundleInstallation, WorkflowBundleHistoryError } from "./src/bundle.ts"; export type { WorkflowRun } from "./src/run.ts"; export { isGitWorkflowRun, workflowRunValue } from "./src/journal.ts"; +/** + * The version-1 description and the two refusals a Git run is held to. + * + * Published because `@executablemd/git` states what a Git-defined run is, and + * this package still owns what that statement is compared against. The + * description names the exact retained identity released builds wrote; the two + * refusals are the exact words a disagreement travels in. + */ +export { baseMismatch, describeGitWorkflowRun, retainedRunMismatch } from "./src/journal.ts"; export type { GitWorkflowRunV1, SourceBundleWorkflowRunV2 } from "./src/journal.ts"; export { useWorkflowServiceDenial, WorkflowServiceDeniedError } from "./src/service-denial.ts"; -export { RepositoryComposition } from "./src/composition/api.ts"; -export type { RepositoryCompositionApi } from "./src/composition/api.ts"; -export { currentRepository, RepositoryContext } from "./src/composition/context.ts"; -export type { RepositoryContextApi } from "./src/composition/context.ts"; -export { - GitCompositionProviderError, - GitOperationError, - GitOperationProtocolError, - PullRequestAdmissionError, - RepositoryCompositionError, - RepositoryCompositionProtocolError, - RepositoryCompositionProviderError, - RepositoryStaleStateError, - WorktreeCompositionError, -} from "./src/composition/errors.ts"; -export type { - GitFailureReason, - PullRequestAdmissionReason, - RepositoryFailureReason, - WorktreeFailureReason, -} from "./src/composition/errors.ts"; -export { - parseRepositoryRecord, - parseWorktreeRecord, - repositoryRecordJson, - sameRepositoryRecord, - sameWorktreeRecord, - worktreeRecordJson, -} from "./src/composition/records.ts"; -export type { - RepositoryCreationRequest, - RepositoryRecord, - WorktreeCreationRequest, - WorktreeRecord, -} from "./src/composition/records.ts"; -export { - NoPullRequestProvider, - PULL_REQUEST_API, - PullRequestAPI, -} from "./src/composition/pull-request-api.ts"; -export type { - PullRequestApi, - PullRequestInput, - PullRequestReadOptions, - PullRequestUpsertOptions, -} from "./src/composition/pull-request-api.ts"; -export { - canonicalPullRequestUrl, - pullRequestProviderName, -} from "./src/composition/pull-request-target.ts"; -export type { PullRequestTarget } from "./src/composition/pull-request-target.ts"; -export { GitComposition } from "./src/composition/git-api.ts"; -export type { GitCompositionApi } from "./src/composition/git-api.ts"; -export { - gitAddResultJson, - gitCommitResultJson, - gitSwitchResultJson, - parseGitAddResult, - parseGitCheckoutIdentity, - parseGitCheckoutState, - parseGitCommitMessageSource, - parseGitCommitResult, - parseGitSwitchResult, -} from "./src/composition/git-records.ts"; -export type { - GitAddExpectation, - GitAddRequest, - GitAddResult, - GitCheckoutExpectation, - GitCheckoutIdentity, - GitCheckoutState, - GitCommitExpectation, - GitCommitMessageSource, - GitCommitRequest, - GitCommitResult, - GitSwitchExpectation, - GitSwitchRequest, - GitSwitchResult, -} from "./src/composition/git-records.ts"; -export { - destinationRefFor, - GIT_PUSH, - gitPushInputsJson, - gitPushNaturalKeyJson, - gitPushObservationsJson, - gitPushPreStateJson, - gitPushResultJson, - parseGitPushInputs, - parseGitPushNaturalKey, - parseGitPushObservations, - parseGitPushPreState, - parseGitPushRecord, - parseGitPushResult, - PUSH_REMOTE, - pushExpectation, - refspecFor, -} from "./src/composition/git-push-records.ts"; -export type { - GitPushExpectation, - GitPushInputs, - GitPushNaturalKey, - GitPushObservations, - GitPushOutcome, - GitPushPreState, - GitPushRequest, - GitPushResult, -} from "./src/composition/git-push-records.ts"; -export { - OPEN, - parsePullRequestInputs, - parsePullRequestNaturalKey, - parsePullRequestObservations, - parsePullRequestPreState, - parsePullRequestRecord, - parsePullRequestResult, - parsePullRequestSnapshot, - PULL_REQUEST, - pullRequestAgrees, - pullRequestMode, - pullRequestInputsJson, - pullRequestNaturalKey, - pullRequestNaturalKeyJson, - pullRequestNumber, - pullRequestObservationsJson, - pullRequestPreStateJson, - pullRequestResultJson, - pullRequestResultOf, - pullRequestSnapshotJson, - sameNaturalKey, - samePullRequestIdentity, -} from "./src/composition/pull-request-records.ts"; -export type { - PullRequestCreateKey, - PullRequestExpectation, - PullRequestInputs, - PullRequestMode, - PullRequestNaturalKey, - PullRequestObservations, - PullRequestOutcome, - PullRequestPreState, - PullRequestRequest, - PullRequestResult, - PullRequestSnapshot, - PullRequestUpdateKey, -} from "./src/composition/pull-request-records.ts"; -export { admitPushEvidence } from "./src/composition/push-evidence.ts"; -export { - COMPOSITION_REGISTRATIONS, - compositionDocumentation, - useCompositionComponents, -} from "./src/composition/installation.ts"; - -export { ISSUE_API, IssueApi, NoIssueProvider } from "./src/issue/api.ts"; -export type { - IssueDetails, - IssueInput, - IssueOperation, - IssueReadOptions, - IssueReference, - IssueUpsertOptions, -} from "./src/issue/api.ts"; -export { - ISSUE_TRACKER_CONTEXT, - IssueTrackerContext, - currentIssueTracker, -} from "./src/issue/context.ts"; -export { ISSUE_EFFECT } from "./src/issue/effect-type.ts"; -export { - IssueAmbiguousError, - IssueConflictError, - IssueContentError, - IssueProtocolError, - IssueTrackerError, - IssueUnavailableError, -} from "./src/issue/errors.ts"; -export type { IssueTrackerReason } from "./src/issue/errors.ts"; -export { - canonicalIssueTarget, - issueProviderName, - resolveIssueDestination, - withinIssueCeiling, -} from "./src/issue/tracker.ts"; -export type { IssueDestination, IssueTracker } from "./src/issue/tracker.ts"; - -export { GIT_HOST_API, GitHost } from "./src/git-host/api.ts"; -export type { - GitHostApi, - GitHostCall, - GitHostPhase, - GitHostPhaseDetails, - GitHostProvider, - GitHostRoutingRequest, -} from "./src/git-host/api.ts"; -export { - GitHostAmbiguousError, - GitHostConflictError, - GitHostProtocolError, - GitHostProviderError, - GitHostUnavailableError, -} from "./src/git-host/errors.ts"; -export { - completeGitHostEffectRequestJson, - gitHostReconciliationRecordJson, - parseCompleteGitHostEffectRequest, - parseGitHostCompletion, - parseGitHostEffectIdentity, - parseGitHostObservation, - parseGitHostReconciliationRecord, - sameGitHostEffectRequest, -} from "./src/git-host/records.ts"; -export type { - CompleteGitHostEffectRequest, - GitHostCompletion, - GitHostDecision, - GitHostEffectIdentity, - GitHostEffectRequest, - GitHostObservation, - GitHostReconciliationRecord, -} from "./src/git-host/records.ts"; -export { - GIT_HOST_EFFECT, - reconcileGitHostEffect, - withGitHostProvider, -} from "./src/git-host/effect.ts"; - export { WorkspaceCoordination, WorkspaceCoordinationProviderError } from "./src/workspace/api.ts"; export type { WorkspaceCoordinationApi } from "./src/workspace/api.ts"; export { createDurableWorkspaceOperation } from "./src/workspace/effect.ts"; @@ -414,6 +187,7 @@ export type { SourceBundleWorkflowDefinitionV2, } from "./src/storage/source-bundle.ts"; +export { parseJsonValue } from "./src/storage/members.ts"; export { conflictingFields } from "./src/storage/compatibility.ts"; export type { GitWorkflowRunComparisonV1, @@ -479,13 +253,6 @@ export { } from "./src/suspension/api.ts"; export type { WorkflowSuspensionApi, WorkflowSuspensionRequest } from "./src/suspension/api.ts"; export { SUSPENSION_ANSWER } from "./src/suspension/answer.ts"; -export { - filteredRepositoryIdentity, - parseRepositoryIdentity, - repositoryIdentityJson, - sameRepositoryIdentity, -} from "./src/composition/selection.ts"; -export type { RepositoryIdentity } from "./src/composition/selection.ts"; export { WorkflowAnswerDeliveryError, WorkflowInputDelivery, diff --git a/packages/workflow/package.json b/packages/workflow/package.json index c5cbc0ef8..e4518443b 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -5,8 +5,7 @@ "type": "module", "exports": { ".": "./mod.ts", - "./deno": "./deno.ts", - "./credential-helper": "./src/deno/composition/credential-helper.ts" + "./deno": "./deno.ts" }, "dependencies": { "@effectionx/context-api": "0.6.0", diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index 98ba6036d..89495ee3c 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -17,9 +17,9 @@ * * - the read table is core's read-only ``; `` joins it only * where this host also states the exact requests it may perform; - * - the write table is core's paired ``, workflow's own lexical `` - * built from the same definition the ordinary registration owns, and core's - * self-closing ``; + * - the write table is core's paired ``, the directory entry this host + * captured — or the one released builds retained, when it captured none — + * and core's self-closing ``; * - any further read or write comes from the captured host option; and * - the Workspace basis is answered per invocation by the private operation * below, because a run's own progress legitimately advances it: every @@ -71,7 +71,6 @@ import type { GeneratedRequest, } from "@executablemd/core/host"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { COMPOSITION_ORIGIN } from "../../composition/definitions.ts"; import { workflowFilesHandler } from "./files.ts"; import { WORKSPACE_ROOT } from "./logical-path.ts"; import { workspaceRootSelection } from "./effect.ts"; @@ -167,8 +166,9 @@ function workspaceFiles(database: WorkflowRunDatabase): FragmentFileAccess { /** * The directory entry a host that captures none of its own is admitted under. * - * Built from the same definition the ordinary registration owns, so the two - * cannot drift. Versioned in its revision because what the entry authorizes + * The exact entry released builds admitted, stated here rather than derived + * from a registration this package no longer owns. Versioned in its revision + * because what the entry authorizes * changed: the former `Dir` authorized placement that created nothing, and * `` now recursively creates the directory it names. A continuation * granted under the earlier revision must not silently receive the wider @@ -187,8 +187,18 @@ function workspaceFiles(database: WorkflowRunDatabase): FragmentFileAccess { * named the placement-only ``, which created nothing, so answering for it * here would hand a narrower grant the wider one. */ +/** + * The origin released builds retained for this entry. + * + * Written out rather than imported. The component behind `` belongs to + * `@executablemd/git` now, and this string identifies retained history rather + * than current source ownership — every journal a released build wrote holds + * it, so it is this package's own compatibility data. + */ +const RETAINED_DIRECTORY_ORIGIN = "@executablemd/workflow/composition"; + function retainedDirectoryEntry(): FragmentEntry { - return directoryEntry({ origin: COMPOSITION_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ + return directoryEntry({ origin: RETAINED_DIRECTORY_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ "@executablemd/workflow/composition/dir-v2#Dir", ]); } diff --git a/packages/workflow/src/deno/workspace/host.ts b/packages/workflow/src/deno/workspace/host.ts index 069844fb4..8854e3ebe 100644 --- a/packages/workflow/src/deno/workspace/host.ts +++ b/packages/workflow/src/deno/workspace/host.ts @@ -1,19 +1,23 @@ /** * What a host installs around one workflow document execution. * - * Five installations, in one place, because they only make sense together: the + * Three installations, in one place, because they only make sense together: the * run's effect coordinator decides how a Workspace effect commits, the Files - * provider is what turns a document's `` into one of those effects, the - * Repository composition provider is what turns a `` or - * `` into another, the Git composition provider is what turns a - * `` into a third, and the logical working directory is what every - * path any of them resolves is relative to. Installing some without the rest - * would leave a document resolving paths one provider cannot reach. + * provider is what turns a document's `` into one of those effects, and + * the logical working directory is what every path either of them resolves is + * relative to. Installing some without the rest would leave a document + * resolving paths one provider cannot reach. * - * Two more belong here for a different reason: ``, which admits a - * generated fragment under this run's retained roots, and the Agent profile a - * host installs, which needs a run to keep provider sessions for. This is the - * only path that has one. + * Two more belong here for a different reason: ``, and the Agent + * profile a host installs, which needs a run to keep provider sessions for. + * This is the only path that has one. + * + * Everything a *feature* needs is an attachment. A host names the installers it + * wants and they run here, in authored order, between the Files provider and + * `` — which is the position a Repository, a Git operation or a + * service-reaching effect has to be in for the run's own coordinator to be + * beneath it. What those installers do is theirs; this package neither names + * them nor imports what they install. * * They are installed **inside** the execution rather than at the entrypoint, so * they sit beneath the host adapter `xmd run` installs and answer ahead of it. @@ -25,41 +29,19 @@ * there is nothing to give a filesystem to — and attaching one anyway would * open a transaction and capture a root for a run that is not going to perform * an effect. It is also why a completed replay contacts no remote and spawns no - * Git: the provider that could is never installed. + * Git: the attachment that could is never installed. * * `withWorkflowWorkspace()` is therefore the whole of what a host may install. * The pieces are not published separately: the Files provider alone would * resolve a document's paths against whatever working directory the host adapter * answers with, and a host path resolved that way is retained in the durable * effects a run replays from. - * - * Repository, Worktree, Dir and the Git operations are registered here as - * ordinary defaults rather than as reserved names, so a repository-local - * component with one of those names is chosen ahead of them exactly as it would - * be ahead of any other package's default. */ import { scoped, type Operation } from "effection"; import { API } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { useCompositionComponents } from "../../composition/installation.ts"; -import { useGitHubPullRequests } from "../composition/pull-request-reads.ts"; -import { denoRepositoryHost } from "../composition/host.ts"; -import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; import { useWorkflowElicitation } from "../../suspension/elicitation.ts"; -import { - useGitComposition, - useRepositoryComposition, - workflowSelections, - type CompositionProviderOptions, -} from "../composition/provider.ts"; -import { - useRetainedPullRequestOperations, - useRetainedPullRequestReads, -} from "../composition/pull-request-operations.ts"; -import { useRetainedIssueOperations } from "../../issue/effect.ts"; -import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; -import type { HelperAssembly } from "../composition/credential-helper.ts"; import { withWorkspaceEffects } from "./effect.ts"; import { useWorkflowFiles } from "./files.ts"; import { WORKSPACE_ROOT } from "./logical-path.ts"; @@ -93,34 +75,20 @@ function useLogicalWorkspaceCwd(): Operation { * repository arranged on disk cannot make behave deterministically. */ export interface WorkflowWorkspaceOptions { - readonly composition?: CompositionProviderOptions; /** - * What GitHub issue handling this host installs, and what it may reach. + * The further providers this host attaches to the run's Workspace. * - * Separate from `composition` because `` is not Repository - * composition: it reaches a service that need not own a Git repository, so - * its middleware, its ceiling and its credentials are configured on their - * own. Absent installs none, and a document that writes `` then - * reaches `IssueApi`'s own base error. - */ - readonly gitHubIssues?: GitHubIssuesOptions; - /** - * The pull-request destinations this host allows a document to read. - * - * Absent authorizes no URL read, so a document naming one reaches - * `PullRequestAPI`'s own base error rather than a host that quietly read - * somewhere nobody allowed. It does not disable ``, whose - * admission is this run's own Push evidence rather than a configured URL. - */ - readonly gitHubPullRequests?: GitHubPullRequestsOptions; - /** - * How this host writes and starts its own credential helper. + * Where a feature outside this package installs what its own vocabulary + * needs — the Repository and Git providers, the retained lifecycles for a + * service-reaching effect, its component registrations. They are installed + * here, in authored order, after the Files provider and the logical working + * directory and before ``, because that is the position the run's own + * coordinator has to be beneath them in. * - * Supplied by the runtime entrypoint, which is the only place that knows - * whether this is Deno source or a compiled binary and which platform it is - * standing on. + * A completed replay never reaches this path, so nothing installed through it + * runs for a run that is not going to perform an effect. */ - readonly helper?: HelperAssembly; + readonly attachments?: readonly WorkflowWorkspaceInstaller[]; /** * What this host installs so a workflow document may prompt an Agent. * @@ -149,6 +117,22 @@ export interface WorkflowAgentAttachment { export type WorkflowAgentInstaller = (attachment: WorkflowAgentAttachment) => Operation; +/** + * What a Workspace attachment is told about the run it is being installed into. + * + * The run's database and nothing else. Everything a feature does with it goes + * through this package's published boundaries — one durable Workspace effect, + * or one read-only inspection — so an attachment holds no connection, no lease + * and no journal route by virtue of being installed here. + */ +export interface WorkflowWorkspaceAttachment { + readonly database: WorkflowRunDatabase; +} + +export type WorkflowWorkspaceInstaller = ( + attachment: WorkflowWorkspaceAttachment, +) => Operation; + /** Run `operation` with this run's Workspace attached to the document. */ export function withWorkflowWorkspace( database: WorkflowRunDatabase, @@ -160,40 +144,12 @@ export function withWorkflowWorkspace( scoped(function* () { yield* useLogicalWorkspaceCwd(); yield* useWorkflowFiles(database); - // One registry for the whole attachment: `` is handed what - // `` minted, and two registries would be two providers that - // could not recognize each other's selections. - const selections = options.composition?.selections ?? workflowSelections(); - const composition = { - ...options.composition, - ...(options.helper === undefined ? {} : { helper: options.helper }), - selections, - }; - yield* useRepositoryComposition(database, composition); - yield* useGitComposition(database, composition); - if (options.gitHubIssues !== undefined) { - yield* useGitHubIssues(options.gitHubIssues); + // In authored order, so a host that installs two features gets the + // middleware ordering it asked for rather than one this package chose. + for (const attach of options.attachments ?? []) { + yield* attach({ database }); } - // The retained lifecycle for both service-reaching vocabularies, above - // whichever transport middleware this host installed for them. - yield* useRetainedIssueOperations(); - yield* useRetainedPullRequestOperations(); - // Durability for an admitted read, installed beside the transport rather - // than above it: the adapter admits, this retains. - yield* useRetainedPullRequestReads(); - yield* useCompositionComponents(); - // Ordinary middleware, installed the way the Issue adapter is: it owns - // the URLs it recognizes and delegates the rest. - // Installed on every live or partial attachment, configured or not: the - // configuration governs URL reads, and `` must keep working - // on a host that authorizes none. - yield* useGitHubPullRequests( - database, - composition.host ?? denoRepositoryHost(), - options.gitHubPullRequests ?? {}, - selections, - ); - // After the composition components and inside this attachment: a + // After the attachments and inside this attachment: a // completed replay never reaches here, so it registers no second `Elicit` // and installs no provider for work that is not going to happen. yield* useWorkflowElicitation(); diff --git a/packages/workflow/src/deno/workspace/published.ts b/packages/workflow/src/deno/workspace/published.ts index e1fb6d601..c2d998827 100644 --- a/packages/workflow/src/deno/workspace/published.ts +++ b/packages/workflow/src/deno/workspace/published.ts @@ -3,15 +3,13 @@ * * Same name as the one beside it, and deliberately not the same function. The * internal `withWorkflowWorkspace` accepts the leaf substitutions a suite needs - * — the Git subprocess, the temporary directory, the Git-host transport — and - * any one of them is a seam through which a credential this run acquires would - * become visible to whoever supplied it. A `RepositoryHost` sees every - * `GitInvocation`, and an authenticated invocation carries its attachment; a - * package that could install one could read what the adapter is holding. + * — a temporary directory, a subprocess, a transport — and any one of them is a + * seam through which a credential a run acquires would become visible to + * whoever supplied it. * * So what is published is this: a wrapper that names the two things a *host* * owns and projects only those. There is no member on its options for a - * substituted host, and no path through it that would reach one if a caller + * substituted leaf, and no path through it that would reach one if a caller * invented the property anyway — the projection is explicit rather than a spread * of whatever arrived. * @@ -22,42 +20,31 @@ import type { Operation } from "effection"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import type { GitHubIssuesOptions } from "../issue/github.ts"; -import type { GitHubPullRequestsOptions } from "../composition/pull-request-reads.ts"; -import type { HelperAssembly } from "../composition/credential-helper.ts"; import { withWorkflowWorkspace as withBroadWorkspace } from "./host.ts"; -import type { WorkflowAgentInstaller } from "./host.ts"; +import type { WorkflowAgentInstaller, WorkflowWorkspaceInstaller } from "./host.ts"; /** * What a host may configure, and the whole of it. * - * Which issue tracker this host authorizes, and how it assembles its own - * credential helper. Both are facts about the program that is running rather - * than anything a document or a loaded package decides. + * Which features attach to this run, and which Agent profile it installs. Both + * are facts about the program that is running rather than anything a document + * or a loaded package decides. */ export interface WorkflowWorkspaceOptions { - readonly gitHubIssues?: GitHubIssuesOptions; /** - * Which pull requests this host allows a document to read. + * The features this host attaches to the run's Workspace, in authored order. * - * A host fact like the tracker beside it. Absent authorizes no URL read and - * disables nothing else: `` upserts on this run's own Push - * evidence, which no configuration grants or withdraws. - * - * Its `access` member is deliberately *not* projected through here. A - * transport is a seam through which a credential this run acquires would - * become visible to whoever supplied it, and a host outside this package has - * no business installing one — the same reason there is no member for a - * substituted `RepositoryHost`. + * What each one installs belongs to the package that wrote it. This package + * supplies the run, the coordinator and the position in the chain, and reads + * nothing else off the installer it was handed. */ - readonly gitHubPullRequests?: Pick; - readonly helper?: HelperAssembly; + readonly attachments?: readonly WorkflowWorkspaceInstaller[]; /** * The Agent profile this host installs for a live or partial attachment. * - * A host fact like the two beside it: which agent client this program can - * reach, and under what ceiling. This package names no agent client, so the - * profile arrives from the runtime entrypoint that does. + * A host fact like the attachments beside it: which agent client this program + * can reach, and under what ceiling. This package names no agent client, so + * the profile arrives from the runtime entrypoint that does. */ readonly agent?: WorkflowAgentInstaller; } @@ -72,22 +59,7 @@ export function withWorkflowWorkspace( // on the object, and reading an unknown property is how a getter somebody // else wrote gets to run. return withBroadWorkspace(database, operation, { - ...(options.gitHubIssues === undefined ? {} : { gitHubIssues: options.gitHubIssues }), - ...(options.gitHubPullRequests === undefined - ? {} - : { - // Member by member here too, so a caller that put an `access` on the - // object cannot reach the transport seam through a published surface. - gitHubPullRequests: { - ...(options.gitHubPullRequests.allowed === undefined - ? {} - : { allowed: options.gitHubPullRequests.allowed }), - ...(options.gitHubPullRequests.endpoint === undefined - ? {} - : { endpoint: options.gitHubPullRequests.endpoint }), - }, - }), - ...(options.helper === undefined ? {} : { helper: options.helper }), + ...(options.attachments === undefined ? {} : { attachments: [...options.attachments] }), ...(options.agent === undefined ? {} : { agent: options.agent }), }); } diff --git a/packages/workflow/src/lifecycle/forkability.ts b/packages/workflow/src/lifecycle/forkability.ts index 37b9ecd77..0abb95e8e 100644 --- a/packages/workflow/src/lifecycle/forkability.ts +++ b/packages/workflow/src/lifecycle/forkability.ts @@ -38,8 +38,6 @@ */ import type { DurableEvent } from "@executablemd/durable-streams"; -import { GIT_HOST_EFFECT } from "../git-host/effect-type.ts"; -import { parseGitHostReconciliationRecord } from "../git-host/records.ts"; import { WORKFLOW_RUN } from "../journal.ts"; import { SUSPENSION_ANSWER } from "../suspension/answer.ts"; import { SUSPENSION_REQUEST } from "../suspension/suspend.ts"; @@ -106,19 +104,155 @@ const INHERITABLE_EFFECTS: ReadonlySet = new Set([ /** * Whether a retained Git-host event carries a completed reconciliation record. * - * Read through the same total parse the reconciliation itself uses. A failed - * Git-host effect retains no record — its outcome is the durable operation's - * failed result — so there is nothing there a fork could continue from. + * A failed Git-host effect retains no record — its outcome is the durable + * operation's failed result — so there is nothing there a fork could continue + * from. What a completed one carries is a decision, and that member is the + * whole of what this classification needs. + * + * Read as compatibility data rather than through the feature's own parser. The + * effect belongs to `@executablemd/git` now, and this module classifies + * retained history without importing what wrote it. A record that will not + * read as completed is classified `external-state-unavailable`, which is the + * conservative answer and the one the total parse also gave. */ function carriesCompletedGitHostRecord(event: DurableEvent): boolean { try { if (event.type !== "yield" || event.result.status !== "ok") { return false; } - return parseGitHostReconciliationRecord(event.result.value) !== undefined; + const record = exactly(event.result.value, RECORD_MEMBERS); + if (record === undefined) { + return false; + } + const decision = record["decision"]; + if (decision !== "adopted" && decision !== "performed") { + return false; + } + const request = exactly(record["request"], REQUEST_MEMBERS); + if (request === undefined) { + return false; + } + const identity = exactly(request["identity"], IDENTITY_MEMBERS); + if ( + identity === undefined || + !text(identity["runId"]) || + !text(identity["expansionId"]) || + !text(request["kind"]) + ) { + return false; + } + // Every remaining member is a JSON value the record claims to hold, and a + // value this cannot read is a record this build cannot classify. Read in + // full rather than sampled: a sparse array, a cycle, a non-finite number, + // an `undefined` or anything else JSON cannot express is the difference + // between a completed reconciliation and something that resembles one. + return ( + readsAsJson(request["inputs"]) && + readsAsJson(request["naturalKey"]) && + readsAsJson(record["preState"]) && + readsAsJson(record["observations"]) && + readsAsJson(record["result"]) + ); + } catch { + return false; + } +} + +/** The members a completed reconciliation record declares, and no others. */ +const RECORD_MEMBERS: readonly string[] = [ + "request", + "preState", + "observations", + "decision", + "result", +]; + +const REQUEST_MEMBERS: readonly string[] = ["identity", "kind", "inputs", "naturalKey"]; + +const IDENTITY_MEMBERS: readonly string[] = ["runId", "expansionId"]; + +function text(value: unknown): boolean { + return typeof value === "string" && value !== ""; +} + +/** + * One retained object, when it declares exactly these members. + * + * Exactly, because a member the shape does not declare describes something + * else and a fork does not guess at it. A record that is nearly one is still + * not one, and is classified `external-state-unavailable` like any other + * Git-host event this history cannot read a completion from. + * + * Guarded throughout: classification, enumeration and every read are the + * value's to refuse, and a refusal of any of them is the same answer. A getter + * somebody else wrote throws here rather than deciding forkability. + */ +function exactly(value: unknown, members: readonly string[]): Record | undefined { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const keys = Object.keys(value); + if ( + keys.length !== members.length || + !members.every((member) => Object.hasOwn(value, member)) + ) { + return undefined; + } + const read: Record = Object.create(null); + for (const member of members) { + read[member] = Reflect.get(value, member); + } + return read; } catch { + return undefined; + } +} + +/** + * Whether this value is one JSON can express, all the way down. + * + * The same total reading the feature's own parser performs, expressed here + * because this module classifies retained history without importing what wrote + * it. It answers rather than detaching: what forkability needs is whether the + * record can be read, not a copy of it. + */ +function readsAsJson(value: unknown, ancestors: Set = new Set()): boolean { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return true; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (typeof value !== "object") { return false; } + if (ancestors.has(value)) { + return false; + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + // A hole is not a member. `[1, , 3]` reads its middle element as + // `undefined`, which JSON cannot express and this must not invent. + if (!Object.hasOwn(value, index) || !readsAsJson(Reflect.get(value, index), ancestors)) { + return false; + } + } + return true; + } + for (const key of Object.keys(value)) { + if (!readsAsJson(Reflect.get(value, key), ancestors)) { + return false; + } + } + return true; + } catch { + return false; + } finally { + ancestors.delete(value); + } } /** @@ -130,6 +264,17 @@ function carriesCompletedGitHostRecord(event: DurableEvent): boolean { */ const AGENT_EFFECT = "agent_prompt"; +/** + * The effect that reaches a Git host, as the durable record names it. + * + * Written out for the same reason every string in the allowlist above is: what + * a retained row holds is this text, and a build classifying history a + * different build wrote has only the text to go on. The effect belongs to + * `@executablemd/git`; recognizing its retained rows is this module's own + * compatibility obligation and not a dependency on that package. + */ +const GIT_HOST_EFFECT = "git_host_effect"; + /** One retained event, as forkability reads it. */ export interface ForkabilityCandidate { readonly eventId: string; diff --git a/packages/workflow/src/run.ts b/packages/workflow/src/run.ts index 94a7d6b62..6c6a46d31 100644 --- a/packages/workflow/src/run.ts +++ b/packages/workflow/src/run.ts @@ -1,20 +1,25 @@ /** * Associating one document execution with a workflow run. * - * `workflowInstallation({ base })` is a value, not an installation act. It - * creates no workflow run: a run comes into being when a document execution - * reaches its first durable operation, which resolves the base once, records - * one immutable value, and only then lets the root document be imported. + * An installation is a value, not an installation act. It creates no workflow + * run: a run comes into being when a document execution reaches its first + * durable operation, which allocates the run once, records one immutable value, + * and only then lets the root document be imported. + * + * What a run *is* belongs to the host that states it. `@executablemd/git` + * supplies one that resolves a base through Git; a workflow host supplies one + * for a run its storage already created. Both arrive as a + * `WorkflowRunPreparation`, and everything below holds either of them to the + * same journal on the same terms. * * A journal can be in three states, and two of them are held to the run. * - * - **Live** — no record yet. The installation's `prepare` hook allocates the - * run id, resolves `${base}^{commit}` through `Git.revParse()`, and records - * the value — inside the durable root, before any public document policy and - * before the root is imported. + * - **Live** — no record yet. The installation's `prepare` hook asks the + * preparation to allocate, and records the value — inside the durable root, + * before any public document policy and before the root is imported. * - **Truncated** — the record is there but the root never closed. The durable - * operation replays the stored value, so neither the identifier nor Git is - * reached a second time, and the journal cursor still advances past its own + * operation replays the stored value, so the preparation is not asked to + * allocate a second time, and the journal cursor still advances past its own * entry. * - **Completed** — the root `Close` is recorded, and `durableRun` returns the * stored result without entering the durable body at all, so preparation is @@ -64,20 +69,13 @@ import type { Workflow, } from "@executablemd/durable-streams"; import type { ExecutionInstallation, JournalAdmission } from "@executablemd/core/host"; -import { revParse } from "./git.ts"; -import { retainedGitHostIdentities } from "./git-host/identities.ts"; -import { retainedIssueIdentities } from "./issue/identities.ts"; -import type { RetainedIssueIdentity } from "./issue/identities.ts"; -import type { RetainedIdentity } from "./git-host/identities.ts"; import { admitWorkflowRunHistory, - baseMismatch, - describeGitWorkflowRun, - describeWorkflowRun, isGitWorkflowRun, + retainedRunMismatch, + describeWorkflowRun, malformedRecord, readWorkflowRun, - retainedRunMismatch, workflowRunValue, } from "./journal.ts"; import type { WorkflowRun } from "./journal.ts"; @@ -116,35 +114,6 @@ const CurrentWorkflowRun: Context = createContext { - return (yield* CurrentWorkflowRun.get())?.gitHostIdentities; -} - -/** The Issue identities this execution's admitted history holds. */ -export function* retainedIssueIdentitiesHere(): Operation { - return (yield* CurrentWorkflowRun.get())?.issueIdentities; } /** The frozen run of the current document execution; throws outside one. */ @@ -154,7 +123,7 @@ export function* getWorkflowRun(): Operation { if (run === undefined) { throw new Error( "getWorkflowRun() is available only inside a document execution associated with a " + - "workflow run. Pass workflowInstallation({ base }) to executeInstalled().", + "workflow run. Pass a run installation to executeInstalled().", ); } return run; @@ -205,38 +174,6 @@ function* record( }); } -function allocating(base: string): WorkflowRunPreparation { - return { - description: describeGitWorkflowRun(base), - // A base that would not resolve is recorded as a failed effect (§6), and a - // history whose only record is that failure is this run's own. Requiring a - // successful one would retry Git instead of replaying what happened. - required: false, - *allocate(): Operation { - const pinnedCommit = yield* revParse(`${base}^{commit}`); - // Web Crypto rather than `node:crypto`: a run id is allocated in shared - // code, which names no host. - return { runId: crypto.randomUUID(), base, pinnedCommit }; - }, - /** - * The description carries the base for a reader; divergence detection - * compares only type and name, so the base this run supplied is checked - * against the stored *value* rather than against the entry's identity. - */ - agree(recorded: WorkflowRun): WorkflowRun { - // This installation allocates a Git run, so a recorded source bundle is - // not a base disagreement — it is a different kind of run entirely. - if (!isGitWorkflowRun(recorded)) { - throw retainedRunMismatch(["definition version"]); - } - if (recorded.base !== base) { - throw baseMismatch(recorded.base, base); - } - return recorded; - }, - }; -} - function retaining(run: WorkflowRun): WorkflowRunPreparation { return { description: describeWorkflowRun(run), @@ -371,31 +308,10 @@ function admits(preparation: WorkflowRunPreparation): JournalAdmission { if (admitted === undefined) { return; } - // The one place both halves are in hand: the run canonical core just - // admitted, and the snapshot it admitted it from. A Git-host effect at a - // position this history already holds a record at is named by the identity - // that record holds, and this is where that association is established — - // out of reach of every name a document could bind. - yield* publishGitHostIdentities(retained); yield* publish(admitted); }; } -/** - * Publish the identities this run's admitted history holds. - * - * Beside the run, in the same slot, so every physical copy of this package sees - * one answer rather than one per module object. - */ -function* publishGitHostIdentities(retained: readonly DurableEvent[]): Operation { - const slot = yield* CurrentWorkflowRun.get(); - if (slot === undefined) { - return; - } - slot.gitHostIdentities = retainedGitHostIdentities(retained); - slot.issueIdentities = retainedIssueIdentities(retained); -} - /** * Put the run where this execution's readers will find it. * @@ -444,19 +360,6 @@ export function createWorkflowRunInstallation( }; } -/** - * The installation that associates one document execution with a workflow run. - * - * Constructing it creates nothing. Executing a document under it does. - * - * ```ts - * yield* executeInstalled(options, [workflowInstallation({ base: "main" })]); - * ``` - */ -export function workflowInstallation(options: { base: string }): ExecutionInstallation { - return createWorkflowRunInstallation(allocating(options.base)); -} - /** * Associate the document execution this scope owns with a run that already * exists. diff --git a/packages/workflow/tests/public-entrypoint.test.ts b/packages/workflow/tests/public-entrypoint.test.ts index 4561a3621..54d4d2ca5 100644 --- a/packages/workflow/tests/public-entrypoint.test.ts +++ b/packages/workflow/tests/public-entrypoint.test.ts @@ -29,7 +29,7 @@ import { withWorkflowWorkspace } from "@executablemd/workflow/deno"; import type { WorkflowWorkspaceOptions } from "@executablemd/workflow/deno"; import * as published from "@executablemd/workflow/deno"; import * as root from "@executablemd/workflow"; -import { useInvokingHome } from "./support/credential-home.ts"; +import { useInvokingHome } from "../../git/tests/support/credential-home.ts"; import { readdir, readTextFile, stat } from "@effectionx/fs"; import type { Operation } from "effection"; @@ -53,7 +53,7 @@ const LOCATOR = "https://exploit.invalid/octo/one.git"; const PROBE = fileURLToPath(new URL("./support/public-entrypoint-probe.ts", import.meta.url)); const HELPER_MODULE = fileURLToPath( - new URL("./support/credential-helper-entry.ts", import.meta.url), + new URL("../../git/tests/support/credential-helper-entry.ts", import.meta.url), ); describe("workflow published Deno entrypoint", () => { @@ -142,22 +142,31 @@ describe("workflow published Deno entrypoint", () => { // anywhere in the graph is caught here. const reachable = Object.keys(published); expect(reachable).toContain("withWorkflowWorkspace"); - for (const constant of [ + // The Git vocabulary is not this package's to publish any more. Its effect + // types, its providers and its leaf substitution seams are all absent — + // the constants because the feature moved, the seams because a package a + // document loaded could read a credential through one. + for (const moved of [ "WORKSPACE_GIT_ADD", "WORKSPACE_GIT_SWITCH", "WORKSPACE_REPOSITORY", "WORKSPACE_WORKTREE", - ]) { - expect(reachable).toContain(constant); - } - for (const seam of [ "denoRepositoryHost", "useRepositoryComposition", "useGitComposition", "denoGitAuthentication", "denoCredentialBroker", + "useRunComposition", + "useGitHubIssues", + "useGitHubPullRequests", + "Git", + "revParse", + "workflowInstallation", ]) { - expect(reachable).not.toContain(seam); + expect({ name: moved, reachable: reachable.includes(moved) }).toEqual({ + name: moved, + reachable: false, + }); } expect(COMPOSITION_IS_NOT_A_KEY).toBe(false); expect(yield* until(Promise.resolve(true))).toBe(true); @@ -209,44 +218,21 @@ describe("workflow published Deno entrypoint", () => { }); /** - * What the retained path is allowed to depend on. +/** + * Where the Git feature is, and where it is not. * - * A version-1 definition's Markdown lives in a repository, and #443's whole - * point is that the modules which retain, recognize, resume, journal and seal a - * run do not reach one themselves — a trusted host supplies that capability as - * a direct closure instead. So this reads the source tree rather than the - * module graph: an import is a fact about a file, and a test that only exercised - * behaviour would pass right up until something imported Git and never used it. + * #443 held one module — `src/run.ts` — to a single named Git import, because + * everything else that retained, recognized, resumed or sealed a run already + * reached no repository. #822 finishes that: the feature is its own package, so + * the rule is no longer "one exception" but "none at all". * - * One exception, and it is pinned rather than granted. `src/run.ts` holds the - * public `workflowInstallation({ base })` convenience, which resolves a base - * through `Git.revParse()`. What this permits is that one named import and its - * one use inside the allocation path — not the file. A second Git operation - * reaching `run.ts`, or `revParse()` moving out of `allocating()` and into the - * retained path, fails here as surely as an import anywhere else would. - * #822 moves that adapter into the bundled Git Plugin. + * These read the source tree rather than the module graph. An import is a fact + * about a file, and a test that only exercised behaviour would pass right up + * until something imported Git and never used it. */ -describe("workflow retained modules and the Git capability", () => { - /** The directories whose modules retain, recognize, resume or seal a run. */ - const RETAINED = ["src/storage", "src/lifecycle", "src/deno/artifact", "src/deno/workspace"]; - - /** Single files on that same path, beside the directories above. */ - const RETAINED_FILES = ["src/journal.ts", "src/fork.ts", "src/bundle.ts"]; - - /** - * The one module #822 has not moved yet. - * - * Named as a path rather than allowed by pattern: an exception that matched a - * shape would quietly cover the next file that happened to fit it. - */ - const EXCEPTION = "src/run.ts"; - - /** The exact import that exception is, and the one operation it names. */ - const EXCEPTION_IMPORT = "./git.ts"; - const EXCEPTION_OPERATION = "revParse"; - - function packageFile(relative: string): string { - return fileURLToPath(new URL(`../${relative}`, import.meta.url)); +describe("the boundary between workflow and git", () => { + function packageFile(pkg: string, relative: string): string { + return fileURLToPath(new URL(`../../${pkg}/${relative}`, import.meta.url)); } /** @@ -269,23 +255,14 @@ describe("workflow retained modules and the Git capability", () => { return found; } - /** Whether a specifier names the local Git capability or its package. */ - function namesGit(specifier: string): boolean { - return /(?:^|\/)git\.ts$/.test(specifier) || /^@executablemd\/git(?:\/|$)/.test(specifier); - } - - function gitSpecifiers(source: string): string[] { - return specifiers(source).filter(namesGit); - } - - /** Every `.ts` file under one directory of the package, recursively. */ - function* moduleFiles(relative: string): Operation { + /** Every `.ts` file under one directory of one package, recursively. */ + function* moduleFiles(pkg: string, relative: string): Operation { const found: string[] = []; - for (const name of yield* readdir(packageFile(relative))) { + for (const name of yield* readdir(packageFile(pkg, relative))) { const child = `${relative}/${name}`; - const stats = yield* stat(packageFile(child)); + const stats = yield* stat(packageFile(pkg, child)); if (stats.isDirectory()) { - found.push(...(yield* moduleFiles(child))); + found.push(...(yield* moduleFiles(pkg, child))); } else if (name.endsWith(".ts")) { found.push(child); } @@ -293,33 +270,37 @@ describe("workflow retained modules and the Git capability", () => { return found; } - function* retainedModules(): Operation { - const scanned: string[] = [...RETAINED_FILES]; - for (const directory of RETAINED) { - scanned.push(...(yield* moduleFiles(directory))); - } - // The deno adapter's own modules, without the repository-composition - // subsystem: those implement `` and are a capability rather than - // part of what a run retains. - for (const name of yield* readdir(packageFile("src/deno"))) { - if (name.endsWith(".ts")) { - scanned.push(`src/deno/${name}`); - } - } - return scanned; + function* productionModules(pkg: string): Operation { + return [...(yield* moduleFiles(pkg, "src")), "mod.ts", "deno.ts"]; + } + + /** Whether a specifier names the Git package or the local Git capability. */ + function namesGit(specifier: string): boolean { + return /(?:^|\/)git\.ts$/.test(specifier) || /^@executablemd\/git(?:\/|$)/.test(specifier); + } + + /** Whether a specifier names the GitHub package. */ + function namesGitHub(specifier: string): boolean { + return /^@executablemd\/github(?:\/|$)/.test(specifier); + } + + /** Whether a specifier reaches into another package's source rather than its entrypoint. */ + function reachesWorkflowSource(specifier: string): boolean { + return /(?:^|\/)workflow\/(src|tests)\//.test(specifier); } - it("names Git in no retained module, in any import form", function* () { - const scanned = yield* retainedModules(); + it("names Git in no workflow production module, in any import form", function* () { + const scanned = yield* productionModules("workflow"); // The scan has to be looking at something: a glob that matched nothing // would pass this case every time. expect(scanned.length).toBeGreaterThan(40); + expect(scanned).toContain("src/run.ts"); expect(scanned).toContain("src/deno/transitions.ts"); expect(scanned).toContain("src/storage/source-bundle.ts"); - expect(scanned).toContain("src/lifecycle/source.ts"); - expect(scanned).toContain("src/deno/definition-source.ts"); - expect(scanned).not.toContain(EXCEPTION); + expect(scanned).toContain("src/lifecycle/forkability.ts"); + expect(scanned).toContain("mod.ts"); + expect(scanned).toContain("deno.ts"); // And the matcher has to recognize what it is looking for. Each of these is // a way a module could reach Git without writing `from`. @@ -329,51 +310,48 @@ describe("workflow retained modules and the Git capability", () => { 'const git = await import("./git.ts");', 'const git = require("@executablemd/git");', 'export { revParse } from "../../git.ts";', + 'export { gitPlugin } from "@executablemd/git";', ]) { - expect({ form, git: gitSpecifiers(form).length }).toEqual({ form, git: 1 }); + expect({ form, git: specifiers(form).filter(namesGit).length }).toEqual({ form, git: 1 }); } - expect(gitSpecifiers('import { reading } from "./reading.ts";')).toEqual([]); + expect(specifiers('import { reading } from "./reading.ts";').filter(namesGit)).toEqual([]); const importing: string[] = []; for (const relative of scanned) { - const source = yield* readTextFile(packageFile(relative)); - if (gitSpecifiers(source).length > 0) { + const source = yield* readTextFile(packageFile("workflow", relative)); + if (specifiers(source).some(namesGit) || specifiers(source).some(namesGitHub)) { importing.push(relative); } } + // No exception. #443's single `run.ts` allowance is gone with the feature. expect(importing).toEqual([]); }); - it("permits one Git import in run.ts, used once inside the allocation path", function* () { - const source = yield* readTextFile(packageFile(EXCEPTION)); - - // One specifier, and it is the local capability rather than the package. - expect(gitSpecifiers(source)).toEqual([EXCEPTION_IMPORT]); - // Named, so the import states which operation it is the exception for. - expect(source).toContain(`import { ${EXCEPTION_OPERATION} } from "${EXCEPTION_IMPORT}";`); + it("reaches workflow only through its entrypoints, and github not at all", function* () { + const scanned = yield* productionModules("git"); + expect(scanned.length).toBeGreaterThan(40); + expect(scanned).toContain("mod.ts"); + expect(scanned).toContain("deno.ts"); + expect(scanned).toContain("src/plugin.ts"); - // Called once in the whole module. A bare identifier rather than any - // mention of the name: the module's own prose says `Git.revParse()`, and a - // sentence about the exception is not a second use of it. - const calls = [...source.matchAll(/(? { expect(refused[1]?.blockers).toEqual([{ code: "external-state-unavailable", eventId: "e2" }]); }); + it("WFK3c: a record this build cannot read in full is not a completed one", function* () { + // The effect belongs to `@executablemd/git` now, so this module reads its + // retained rows as compatibility data. Reading them *totally* is what makes + // the answer safe: a value JSON cannot express is a record this build + // cannot classify, and classifying it as completed would hand a fork a + // prefix it cannot actually replay. + const complete = { + request: { + identity: { runId: "source-1", expansionId: "x" }, + kind: "git-push", + inputs: { remote: "origin" }, + naturalKey: { destinationRef: "refs/heads/publish/1" }, + }, + preState: { remoteCommit: null }, + observations: { remoteCommit: "abc" }, + decision: "performed", + result: { remoteCommit: "abc" }, + }; + + /** One Git-host event holding exactly this value, however hostile. */ + function holding(value: unknown): DurableEvent { + return { + type: "yield", + coroutineId: "root", + description: { type: "git_host_effect", name: "git-push:1" }, + result: { status: "ok", value: value as Json }, + }; + } + + function blockers(value: unknown) { + return classify([ + { id: "e1", event: RUN_RECORD }, + { id: "e2", event: holding(value) }, + ])[1]?.blockers; + } + + // The positive control: built the same way, read the same way, accepted. + // Without it every refusal below could be the builder rather than the rule. + expect(blockers(structuredClone(complete))).toEqual([]); + + const cyclic: Record = { remoteCommit: "abc" }; + cyclic["self"] = cyclic; + + const sparse: unknown[] = ["a"]; + sparse[2] = "c"; + + const hostile = structuredClone(complete); + Object.defineProperty(hostile, "result", { + enumerable: true, + get(): never { + throw new Error("a record does not get to run code during classification"); + }, + }); + + const refusals: Record = { + // An identity with nothing in it names no run and no position. + "empty runId": { + ...complete, + request: { ...complete.request, identity: { runId: "", expansionId: "x" } }, + }, + "empty expansionId": { + ...complete, + request: { ...complete.request, identity: { runId: "source-1", expansionId: "" } }, + }, + "empty kind": { ...complete, request: { ...complete.request, kind: "" } }, + "absent kind": { ...complete, request: { ...complete.request, kind: undefined } }, + // A decision this build does not know is not one it may act on. + "unknown decision": { ...complete, decision: "abandoned" }, + // Members the shape does not declare, and members it declares missing. + "extra identity member": { + ...complete, + request: { + ...complete.request, + identity: { runId: "source-1", expansionId: "x", host: "github" }, + }, + }, + "missing request member": { + ...complete, + request: { identity: complete.request.identity, kind: "git-push", inputs: {} }, + }, + // Values JSON cannot express, at every depth this reads. + "non-finite number": { ...complete, preState: { drift: Number.POSITIVE_INFINITY } }, + NaN: { ...complete, preState: { drift: Number.NaN } }, + "undefined member": { ...complete, observations: { remoteCommit: undefined } }, + "a sparse array": { ...complete, observations: { refs: sparse } }, + "a cycle": { ...complete, preState: cyclic }, + "a function": { ...complete, result: { settle: () => "now" } }, + "a getter that throws": hostile, + // And the record itself has to be a record. + "an array": [complete], + "a string": JSON.stringify(complete), + null: null, + }; + + for (const [why, value] of Object.entries(refusals)) { + expect([why, blockers(value)]).toEqual([ + why, + [{ code: "external-state-unavailable", eventId: "e2" }], + ]); + } + }); + it("WFK4: selection takes the prefix and leaves the two records a fork writes", function* () { const history = candidates([ { id: "e1", event: RUN_RECORD }, diff --git a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts index 598cc2ce3..118ffa98e 100644 --- a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts +++ b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts @@ -27,8 +27,8 @@ import { exec } from "@effectionx/process"; import { when } from "@effectionx/converge"; import type { Close, DurableEvent, Yield } from "@executablemd/durable-streams"; import { SOURCE_POSITION_FIELD } from "@executablemd/core"; +import { Git } from "../../git/src/git.ts"; import { - Git, isGitWorkflowRunRecord, WorkflowDatabaseFormatError, WorkflowInspectionRecoveryError, diff --git a/packages/workflow/tests/workflow-run-storage.test.ts b/packages/workflow/tests/workflow-run-storage.test.ts index 1ba9546be..0e70e40f6 100644 --- a/packages/workflow/tests/workflow-run-storage.test.ts +++ b/packages/workflow/tests/workflow-run-storage.test.ts @@ -46,7 +46,7 @@ import { createWorkflowRunConnections } from "../src/deno/connections.ts"; import { WorkflowRunRecognition } from "../src/deno/provider.ts"; import { holdRecoveryCoordination } from "../src/deno/recovery-coordination.ts"; import { EXPECTED_SCHEMA, initializeSchema } from "../src/deno/schema.ts"; -import { readRepositories } from "../src/deno/workspace/repositories.ts"; +import { readRepositories } from "../../git/src/deno/repositories.ts"; import { createWorkflowWorkspaceStorage } from "../src/deno/workspace/storage.ts"; import { EMPTY_WORKSPACE_MANIFEST, diff --git a/packages/workflow/tests/workflow-run.test.ts b/packages/workflow/tests/workflow-run.test.ts index 8291623e6..c1647f944 100644 --- a/packages/workflow/tests/workflow-run.test.ts +++ b/packages/workflow/tests/workflow-run.test.ts @@ -31,8 +31,9 @@ import { createApi } from "@effectionx/context-api"; import type { Api } from "@effectionx/context-api"; import { executeInstalled } from "@executablemd/core/host"; import type { ExecutionInstallation } from "@executablemd/core/host"; -import { Git } from "../src/git.ts"; -import { createWorkflowRunInstallation, getWorkflowRun, workflowInstallation } from "../src/run.ts"; +import { Git } from "../../git/src/git.ts"; +import { createWorkflowRunInstallation, getWorkflowRun } from "../src/run.ts"; +import { workflowInstallation } from "../../git/src/installation.ts"; import { describeGitWorkflowRun } from "../src/journal.ts"; import type { WorkflowRun } from "../src/run.ts"; import { type GitWorkflowRunV1, isGitWorkflowRun } from "../mod.ts"; @@ -132,8 +133,8 @@ describe("Tier WR — workflow runs", () => { expect(seen).toHaveLength(1); expect(seen[0]).toEqual({ runId: expect.any(String), base: "main", pinnedCommit: COMMIT }); - expect(before[0]).toContain("workflowInstallation"); - expect(after[0]).toContain("workflowInstallation"); + expect(before[0]).toContain("run installation"); + expect(after[0]).toContain("run installation"); }); it("WR2: every read inside one execution answers with the same frozen value", function* () { @@ -605,7 +606,7 @@ describe("Tier WR — workflow runs", () => { }); expect(failures).toHaveLength(1); - expect(failures[0]).toContain("workflowInstallation"); + expect(failures[0]).toContain("run installation"); }); it("WR11: a journal holding something else under the workflow name is refused", function* () { diff --git a/packages/workflow/tests/workspace-effect-loaded-copy.test.ts b/packages/workflow/tests/workspace-effect-loaded-copy.test.ts index 11d785064..7e45378c6 100644 --- a/packages/workflow/tests/workspace-effect-loaded-copy.test.ts +++ b/packages/workflow/tests/workspace-effect-loaded-copy.test.ts @@ -25,6 +25,7 @@ import { } from "@executablemd/durable-streams"; import { collect, inlineSource, registerComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; +import type { ExecutionInstallation } from "@executablemd/core/host"; import { WorkspaceCoordination, WorkspaceCoordinationProviderError } from "../src/workspace/api.ts"; import { type WorkspaceEffectExecution, @@ -33,31 +34,50 @@ import { } from "../src/workspace/effect.ts"; import { createDurableWorkspaceOperation } from "../mod.ts"; import { retainedWorkflowInstallation } from "../src/run.ts"; +import { gitPlugin } from "../../git/src/plugin.ts"; import type { WorkflowRun } from "../src/run.ts"; import { GIT_HOST_EFFECT, reconcileGitHostEffect, withGitHostProvider, -} from "../src/git-host/effect.ts"; -import { GIT_HOST_API, GitHost } from "../src/git-host/api.ts"; +} from "../../git/src/git-host/effect.ts"; +import { GIT_HOST_API, GitHost } from "../../git/src/git-host/api.ts"; import type { GitHostApi, GitHostCall, GitHostProvider, GitHostRoutingRequest, -} from "../src/git-host/api.ts"; -import { GitHostProviderError } from "../src/git-host/errors.ts"; +} from "../../git/src/git-host/api.ts"; +import { GitHostProviderError } from "../../git/src/git-host/errors.ts"; import { gitHostRequestFingerprint, parseGitHostReconciliationRecord, -} from "../src/git-host/records.ts"; +} from "../../git/src/git-host/records.ts"; import type { CompleteGitHostEffectRequest, GitHostCompletion, GitHostEffectRequest, GitHostObservation, GitHostReconciliationRecord, -} from "../src/git-host/records.ts"; +} from "../../git/src/git-host/records.ts"; + +/** + * The admissions the Git Plugin contributes, as a host installing it receives + * them. + * + * Asked of the Plugin value rather than assembled here, so what these cases + * exercise is the same contribution a command gets — one Plugin value, and an + * admission that derives this execution's identities from this execution's own + * retained history. + */ +function* gitPluginAdmissions(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const installed = yield* install.call(gitPlugin, { command: "run", args: [] }); + return { admissions: [...(installed?.admissions ?? [])] }; +} interface LoadedWorkspaceCopy { createDurableWorkspaceOperation: typeof createDurableWorkspaceOperation; @@ -211,18 +231,22 @@ function loadedGitHostCopy(value: unknown): value is LoadedGitHostCopy { } /** - * A second physical copy of this package's shared source, loaded as its own + * A second physical copy of the Git package's shared source, loaded as its own * module graph. * * The whole shared tree is copied rather than the four Git-host modules, - * because what the copy must reach — the run, the journal and the canonical - * encoding — is exactly what a real second copy reaches. The host adapter is - * left behind: this boundary names no host, so a copy of it needs none. + * because what the copy must reach — the retained identities and the canonical + * encoding beside them — is exactly what a real second copy reaches. The host + * adapter is left behind: this boundary names no host, so a copy of it needs + * none. What the copy does *not* carry is `@executablemd/workflow`: a second + * installation of one package does not duplicate its dependency, so the copy + * resolves the run and the journal through the same published specifiers the + * original does. */ function* physicalGitHostCopy(): Operation { const directory = yield* until(Deno.makeTempDir({ prefix: "xmd-workflow-git-host-copy-" })); yield* ensure(() => rm(directory, { recursive: true, force: true })); - const source = fileURLToPath(new URL("../src/", import.meta.url)); + const source = fileURLToPath(new URL("../../git/src/", import.meta.url)); const entries = yield* glob({ root: source, patterns: ["**/*.ts"], exclude: ["deno/**"] }); expect(entries.length).toBeGreaterThan(4); for (const entry of entries) { @@ -230,6 +254,22 @@ function* physicalGitHostCopy(): Operation { yield* ensureDir(dirname(destination)); yield* writeTextFile(destination, yield* readTextFile(join(source, entry.path))); } + // A manifest, because an installed second copy has one. The copy reaches the + // run, the journal and the canonical encoding through the same published + // specifiers a real one would, rather than through a second physical copy of + // a package it does not own. + yield* writeTextFile( + join(directory, "deno.json"), + JSON.stringify({ + name: "@executablemd/git-loaded-copy", + version: "0.0.0", + exports: { ".": "./git-host/effect.ts" }, + imports: { + "@executablemd/workflow": new URL("../mod.ts", import.meta.url).href, + "@executablemd/workflow/deno": new URL("../deno.ts", import.meta.url).href, + }, + }), + ); const copy = yield* until(import(pathToFileURL(join(directory, "git-host/effect.ts")).href)); if (!loadedGitHostCopy(copy)) { throw new Error("the physical workflow package copy did not export its Git-host surface"); @@ -273,6 +313,7 @@ function* gitHostDocument(stream: InMemoryStream): Operation { return yield* collect( yield* executeInstalled({ ...inlineSource(GIT_HOST_SOURCE), stream }, [ retainedWorkflowInstallation(GIT_HOST_RUN), + yield* gitPluginAdmissions(), ]), ); } diff --git a/packages/workflow/tests/workspace-effect.test.ts b/packages/workflow/tests/workspace-effect.test.ts index 557ddb3ba..27ef572b5 100644 --- a/packages/workflow/tests/workspace-effect.test.ts +++ b/packages/workflow/tests/workspace-effect.test.ts @@ -2,10 +2,16 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { + code, + COMPUTED, + forbiddenNames, + moduleSpecifiers, + parse, +} from "@executablemd/test-support/host-boundary"; import { createApi } from "@effectionx/context-api"; import { readTextFile } from "@effectionx/fs"; import { glob } from "@executablemd/runtime"; -import ts from "typescript"; import { type Operation, scoped } from "effection"; import { establishJournalProvenance, @@ -79,338 +85,6 @@ function successfulProvider(observe?: (execution: WorkspaceEffectExecution) => v const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); -/** - * Storage, adapter and provider names that mean a host reached this surface. - * - * Distinctive enough to be read as text. Runtime globals are not here — `Bun` - * is inside `Bundle` and `Deno` inside `Denominator`, so those are recognized - * as identifiers instead. - * - * A Git host's name is on the list for the same reason a database's is. The - * shared external-effect boundary exists so that any Git host can be adapted to - * it, and the first adapter naming itself in a shared contract is how a neutral - * surface quietly becomes one provider's. - * - * `Forge` is here for a different reason: it is the retired product noun this - * boundary was developed under, and #297's correction leaves no alias, no - * durable `forge_effect`, and no forwarding module behind. The scan reads code - * with comments stripped, so ordinary English — a token that cannot be forged — - * is not this vocabulary and is not what this refuses. - */ -const FORBIDDEN = [ - "GitHub", - "github", - "Forge", - "forge_effect", - "workflow.forge", - "src/forge/", - "DatabaseSync", - "SQLite", - "sqlite", - "Cloudflare", - "DOFS", - "dofs", - "savepoint", - "Savepoint", - "SAVEPOINT", - "RunConnection", - "WorkflowRunConnections", - "WorkflowRunTransactionToken", - "ConnectionGeneration", - "TransactionIdentity", -]; - -/** - * The names this repository gives a runtime-specific entry point. - * - * Code Rule 12 puts host behavior behind runtime-named modules — - * `packages/cli/src/{deno,node,bun,compiled}.ts` are the CLI's — so the name of - * the module is what says a host owns it. `deno` is not the only one, and an - * adapter rule that knows only `deno` is a rule about the adapter someone - * happened to write first. - */ -const RUNTIMES = ["deno", "node", "bun", "compiled", "cloudflare", "workerd"]; - -/** - * Globals only one host provides. - * - * `crypto`, `TextEncoder` and the rest of the cross-runtime Web surface are - * not here: naming a standard is not naming a host. - */ -const HOST_GLOBALS = [ - "process", - "Deno", - "Bun", - "Buffer", - "globalThis", - "navigator", - "__dirname", - "__filename", -]; - -/** - * A module specifier only one host can resolve. - * - * Named by shape rather than one at a time: a list of the host modules anyone - * thought of is a list of the ones that had already been noticed, and the - * import that crosses this boundary next is the one nobody wrote down. - * - * Segments are compared whole. `nodes/`, `bundle.ts` and `vendors/` contain a - * runtime's name without being one, and rejecting them would make the rule - * about spelling rather than about hosts. - */ -function hostModule(specifier: string): boolean { - if (/^(node|bun|deno|cloudflare|workerd):/.test(specifier)) { - return true; - } - if (specifier === "@effectionx/process") { - return true; - } - const segments = specifier.split("/"); - const last = segments[segments.length - 1].replace(/\.[cm]?[jt]sx?$/, ""); - return ( - segments.includes("vendor") || - segments.some((segment) => RUNTIMES.includes(segment)) || - RUNTIMES.includes(last) - ); -} - -/** - * Source with its comments removed. - * - * These modules describe in prose that they name no host, and a search of the - * whole file would find that description rather than a boundary crossing. - */ -function code(source: string): string { - let output = ""; - let index = 0; - while (index < source.length) { - const character = source[index]; - const following = source[index + 1]; - if (character === "/" && following === "/") { - while (index < source.length && source[index] !== "\n") { - index += 1; - } - continue; - } - if (character === "/" && following === "*") { - index += 2; - while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { - index += 1; - } - index += 2; - continue; - } - if (character === '"' || character === "'" || character === "`") { - output += character; - index += 1; - while (index < source.length && source[index] !== character) { - if (source[index] === "\\") { - output += source[index]; - index += 1; - } - output += source[index]; - index += 1; - } - output += character; - index += 1; - continue; - } - output += character; - index += 1; - } - return output; -} - -/** - * What a module this file loads cannot be shown to be. - * - * A specifier the file computes names whatever it is handed, so no inspection - * of this surface can say it is not a host module. It is refused rather than - * skipped: a boundary that admits what it cannot read is not a boundary. - */ -const COMPUTED = "a computed module specifier"; - -/** - * Every module this source loads, read from the syntax rather than the text. - * - * Parsed, because module loading is not a pattern: a specifier can be a - * template literal, can escape its own characters, can be an expression, and - * the same characters can appear in a string that loads nothing. Each of those - * is a different answer, and only a parse tells them apart. - */ -function moduleSpecifiers(file: ts.SourceFile): string[] { - const found: string[] = []; - - function record(node: ts.Node | undefined): void { - if ( - node !== undefined && - (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) - ) { - // The parser has already decoded escapes, so `node:crypto` and - // `node:crypto` arrive here as the same specifier. - found.push(node.text); - return; - } - found.push(COMPUTED); - } - - function visit(node: ts.Node): void { - if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { - if (node.moduleSpecifier !== undefined) { - record(node.moduleSpecifier); - } - } else if (ts.isImportEqualsDeclaration(node)) { - if (ts.isExternalModuleReference(node.moduleReference)) { - record(node.moduleReference.expression); - } - } else if (ts.isImportTypeNode(node)) { - record(ts.isLiteralTypeNode(node.argument) ? node.argument.literal : node.argument); - } else if (ts.isCallExpression(node)) { - const callee = node.expression; - if ( - callee.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(callee) && callee.text === "require") - ) { - record(node.arguments[0]); - } - } - ts.forEachChild(node, visit); - } - - visit(file); - return found; -} - -/** - * The scanned source, and a checker that knows what its names mean. - * - * `noLib` and `noResolve` are the point rather than an economy: nothing - * outside this file is loaded, so a name resolves only to what the file itself - * declares. Anything left unresolved is ambient — supplied by a host at - * runtime — which is exactly the question being asked. - */ -function parse(source: string): { file: ts.SourceFile; checker: ts.TypeChecker } { - const path = "/scanned.ts"; - const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const program = ts.createProgram({ - rootNames: [path], - options: { noLib: true, noResolve: true, target: ts.ScriptTarget.Latest }, - host: { - getSourceFile: (name) => (name === path ? file : undefined), - getDefaultLibFileName: () => "", - writeFile: () => {}, - getCurrentDirectory: () => "/", - getCanonicalFileName: (name) => name, - useCaseSensitiveFileNames: () => true, - getNewLine: () => "\n", - fileExists: (name) => name === path, - readFile: (name) => (name === path ? source : undefined), - }, - }); - return { file, checker: program.getTypeChecker() }; -} - -/** - * The slots in which the grammar writes a name rather than a reference. - * - * TypeScript spells the distinction structurally: an `IdentifierName` fills a - * `name`, `propertyName` or `label` slot of the node that owns it, and a - * qualified name's `right` is the same thing in type position. Everywhere else - * an identifier is an `IdentifierReference`. - */ -const LABEL_SLOTS = ["name", "propertyName", "label"]; - -/** - * Whether this identifier refers to a binding at all. - * - * Not a scope question — the checker answers those. This asks the grammar - * instead of listing the node kinds someone remembered: the member in - * `x.process`, the label in `break process`, the imported member in - * `{ Deno as portable }`, the key in `{ process: local }`, a named tuple - * element, an import attribute and every declaration's own name all fill a - * name slot, and none of them reads the name it spells. - * - * A shorthand property is the one name slot that is also a read, because - * `{ process }` declares a property and reads a binding with one identifier. - */ -function refers(node: ts.Identifier): boolean { - const parent = node.parent; - if (parent === undefined) { - return true; - } - if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) { - return true; - } - if (ts.isQualifiedName(parent) && parent.right === node) { - return false; - } - return !LABEL_SLOTS.some((slot) => Reflect.get(parent, slot) === node); -} - -/** - * Host globals this source actually reads. - * - * A name is the host's only when nothing in this file declares it, and the - * checker is what knows that. Value scopes and type scopes, `var` hoisting, - * `import =`, `namespace`, mapped-type and `infer` type parameters, accessors - * and shadowing are the language's rules, not a list kept here — every one of - * them was a false positive while this was a list. - */ -function hostGlobals(parsed: { file: ts.SourceFile; checker: ts.TypeChecker }): string[] { - const found: string[] = []; - - /** - * The binding this identifier reads. - * - * `{ process }` writes one name in two roles: the property the literal - * declares and the value it reads. The ordinary symbol is the property — it - * is declared right there, so asking for it would answer that every host - * global is locally declared the moment it is put in an object. The value - * symbol is the one the shorthand refers to. - */ - function binding(node: ts.Identifier): ts.Symbol | undefined { - const parent = node.parent; - if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) { - return parsed.checker.getShorthandAssignmentValueSymbol(parent); - } - return parsed.checker.getSymbolAtLocation(node); - } - - function visit(node: ts.Node): void { - if (ts.isIdentifier(node) && HOST_GLOBALS.includes(node.text) && refers(node)) { - const declared = (binding(node)?.declarations ?? []).some( - (declaration) => declaration.getSourceFile() === parsed.file, - ); - if (!declared && !found.includes(node.text)) { - found.push(node.text); - } - } - ts.forEachChild(node, visit); - } - - visit(parsed.file); - return found; -} - -function forbiddenNames(source: string): string[] { - const parsed = parse(source); - const scanned = code(source); - const crossings = FORBIDDEN.filter((name) => scanned.includes(name)); - for (const global of hostGlobals(parsed)) { - if (!crossings.includes(global)) { - crossings.push(global); - } - } - for (const specifier of moduleSpecifiers(parsed.file)) { - const refused = specifier === COMPUTED || hostModule(specifier); - if (refused && !crossings.includes(specifier)) { - crossings.push(specifier); - } - } - return crossings; -} - describe("Tier DLC — Workspace coordination selection", () => { it("DLC10: a missing Workspace provider fails before execution or publication", function* () { const stream = new InMemoryStream(); @@ -704,26 +378,6 @@ describe("Tier DLC — Workspace coordination selection", () => { "packages/durable-streams/live-coordinator.ts", "packages/durable-streams/types.ts", "packages/workflow/mod.ts", - // Repository/Worktree composition is provider-neutral on the same - // terms: the components a document writes name no subprocess, no host - // filesystem and no runtime, and only the Deno adapter beneath them - // does. - "packages/workflow/src/composition/api.ts", - "packages/workflow/src/composition/components/Dir.ts", - "packages/workflow/src/composition/components/Repository.ts", - "packages/workflow/src/composition/components/Worktree.ts", - "packages/workflow/src/composition/context.ts", - "packages/workflow/src/composition/errors.ts", - "packages/workflow/src/composition/installation.ts", - "packages/workflow/src/composition/records.ts", - // The shared external-effect boundary is provider-neutral on the same - // terms and for a sharper reason: it exists so an adapter can be - // written for any Git host, and a host name here would decide which - // one. - "packages/workflow/src/git-host/api.ts", - "packages/workflow/src/git-host/effect.ts", - "packages/workflow/src/git-host/errors.ts", - "packages/workflow/src/git-host/records.ts", "packages/workflow/src/storage/api.ts", "packages/workflow/src/workspace/api.ts", "packages/workflow/src/workspace/effect.ts", diff --git a/packages/workflow/tests/workspace-files.test.ts b/packages/workflow/tests/workspace-files.test.ts index 3f6cc16e5..9baaece5e 100644 --- a/packages/workflow/tests/workspace-files.test.ts +++ b/packages/workflow/tests/workspace-files.test.ts @@ -34,6 +34,7 @@ import { API, FILES_FATAL, parseFilesFatal, useHostFiles } from "@executablemd/r import type { HostFilesEvent } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "../mod.ts"; import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; +import { gitWorkspaceAttachment } from "../../git/src/deno/attachment.ts"; import { WORKSPACE_FILE } from "../src/deno/workspace/files.ts"; import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; @@ -139,6 +140,9 @@ function runDocument(database: WorkflowRunDatabase, source: string): Operation` belongs to `@executablemd/git` now, and these cases drive this + // run's own directory handling through it. + { attachments: [gitWorkspaceAttachment()] }, ); return { output, host }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8444ffc55..60334ec09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@executablemd/durable-streams': specifier: workspace:* version: link:../durable-streams + '@executablemd/git': + specifier: workspace:* + version: link:../git '@executablemd/runtime': specifier: workspace:* version: link:../runtime @@ -314,6 +317,30 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/git: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@executablemd/core': + specifier: workspace:* + version: link:../core + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + '@executablemd/runtime': + specifier: workspace:* + version: link:../runtime + '@executablemd/workflow': + specifier: workspace:* + version: link:../workflow + effection: + specifier: 4.1.0 + version: 4.1.0 + packages/runtime: dependencies: '@effectionx/context-api': diff --git a/scripts/lib/compile.ts b/scripts/lib/compile.ts index 4a415a4ab..78129fa16 100644 --- a/scripts/lib/compile.ts +++ b/scripts/lib/compile.ts @@ -101,7 +101,7 @@ export const PACKAGED_DOCUMENTATION = [ "packages/cli/src/components.md", "packages/testing/src/components.md", "packages/web/src/components.md", - "packages/workflow/src/composition/components.md", + "packages/git/src/composition/components.md", ]; /** Everything a compile embeds, in the order the argv names it. */ diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 33456d99d..9f1918a48 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -198,7 +198,7 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ issue: "https://github.com/taras/executable.md/issues/365", }, { - path: "packages/workflow/tests/ambient-authentication.test.ts", + path: "packages/git/tests/ambient-authentication.test.ts", reason: "drives the Deno host adapter's HTTP credential acquisition against a real node:sqlite WorkflowRun database, a real `git` subprocess and a provider-owned helper the Deno source assembly launches; the adapter, the store and the helper are all the Deno one by design", issue: "https://github.com/taras/executable.md/issues/522", @@ -210,103 +210,103 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ issue: "https://github.com/taras/executable.md/issues/522", }, { - path: "packages/workflow/tests/credential-helper.test.ts", + path: "packages/git/tests/credential-helper.test.ts", reason: "runs the provider-owned credential helper through the Deno source launcher and drives `git credential fill` against isolated fixture homes; the helper mode is dispatched by the Deno entrypoints and has no Node or Bun assembly yet", issue: "https://github.com/taras/executable.md/issues/522", }, { - path: "packages/workflow/tests/repository-components.test.ts", + path: "packages/git/tests/repository-components.test.ts", reason: "drives and against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/repository-storage.test.ts", + path: "packages/git/tests/repository-storage.test.ts", reason: "clones local bare repositories with a real `git` into the Deno DOFS Workspace and reads the retained node:sqlite rows back; the provider is the Deno one by design", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/repository-replay.test.ts", + path: "packages/git/tests/repository-replay.test.ts", reason: "replays a partial node:sqlite WorkflowRun after deleting the remote and every host materialization, and halts a real blocked `git` child; both the store and the subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/repository-materialization.test.ts", + path: "packages/git/tests/repository-materialization.test.ts", reason: "exports retained checkouts to host directories and links their roots at external clones, against a real node:sqlite WorkflowRun database", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/materialization.test.ts", + path: "packages/git/tests/materialization.test.ts", reason: "drives , and against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess; node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/worktree-replay.test.ts", + path: "packages/git/tests/worktree-replay.test.ts", reason: "substitutes a Worktree's retained checkout through the Deno DOFS Workspace and reads it back with a real `git`; node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/293", }, { - path: "packages/workflow/tests/git-add.test.ts", + path: "packages/git/tests/git-add.test.ts", reason: "drives against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess, and imports a physical copy of the Api module through the Deno module loader; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-add-durability.test.ts", + path: "packages/git/tests/git-add-durability.test.ts", reason: "replays and cancels a staging against a real node:sqlite WorkflowRun database and halts a real blocked `git` child; both the store and the subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-add-crash.test.ts", + path: "packages/git/tests/git-add-crash.test.ts", reason: "kills a real Deno child with SIGKILL mid-staging and reads the recovered node:sqlite WorkflowRun database it leaves behind; the child runs under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-commit.test.ts", + path: "packages/git/tests/git-commit.test.ts", reason: "drives against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess that receives its message on standard input; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-commit-durability.test.ts", + path: "packages/git/tests/git-commit-durability.test.ts", reason: "replays and cancels a commit against a real node:sqlite WorkflowRun database and halts a real blocked `git` child; both the store and the subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-commit-crash.test.ts", + path: "packages/git/tests/git-commit-crash.test.ts", reason: "kills a real Deno child with SIGKILL mid-commit and reads the recovered node:sqlite WorkflowRun database it leaves behind; the child runs under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-push.test.ts", + path: "packages/git/tests/git-push.test.ts", reason: "drives against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter, a real local bare remote and a real `git` subprocess that observes and pushes; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/370", }, { - path: "packages/workflow/tests/git-push-durability.test.ts", + path: "packages/git/tests/git-push-durability.test.ts", reason: "replays and cancels a push against a real node:sqlite WorkflowRun database, halts a real blocked `git` child and imports a physical copy of the Api module through the Deno module loader; both the store and the subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/370", }, { - path: "packages/workflow/tests/git-push-crash.test.ts", + path: "packages/git/tests/git-push-crash.test.ts", reason: "kills a real Deno child with SIGKILL after native Git updated the remote and before the result was appended, then reads the recovered node:sqlite WorkflowRun database it leaves behind; the child runs under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/370", }, { - path: "packages/workflow/tests/pull-request.test.ts", + path: "packages/git/tests/pull-request.test.ts", reason: "drives against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter, a real local bare remote and a real `git` subprocess; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/295", @@ -318,43 +318,43 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ issue: "https://github.com/taras/executable.md/issues/576", }, { - path: "packages/workflow/tests/pull-request-read.test.ts", + path: "packages/git/tests/pull-request-read.test.ts", reason: "drives the three evidence reads against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess, and replays one from the retained journal; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/576", }, { - path: "packages/workflow/tests/pull-request-durability.test.ts", + path: "packages/git/tests/pull-request-durability.test.ts", reason: "replays, damages and cancels a pull request against a real node:sqlite WorkflowRun database and the Deno DOFS Workspace adapter; both the store and the Git subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/295", }, { - path: "packages/workflow/tests/pull-request-crash.test.ts", + path: "packages/git/tests/pull-request-crash.test.ts", reason: "kills a real Deno child with SIGKILL after GitHub answered 201 and before the result was appended, then reads the recovered node:sqlite WorkflowRun database it leaves behind; the child runs under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/295", }, { - path: "packages/workflow/tests/git-switch.test.ts", + path: "packages/git/tests/git-switch.test.ts", reason: "drives against a real node:sqlite WorkflowRun database, the Deno DOFS Workspace adapter and a real `git` subprocess, and imports a physical copy of the Api module through the Deno module loader; Bun has no node:sqlite at all and Node 22 keeps it behind --experimental-sqlite", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-switch-durability.test.ts", + path: "packages/git/tests/git-switch-durability.test.ts", reason: "replays and cancels a switch against a real node:sqlite WorkflowRun database and halts a real blocked `git` child; both the store and the subprocess are the Deno adapter's", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/git-switch-crash.test.ts", + path: "packages/git/tests/git-switch-crash.test.ts", reason: "kills a real Deno child with SIGKILL mid-switch and reads the recovered node:sqlite WorkflowRun database it leaves behind; the child runs under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/294", }, { - path: "packages/workflow/tests/repository-control-plane.test.ts", + path: "packages/git/tests/repository-control-plane.test.ts", reason: "writes Git administration a real `git` then reads, through the Deno DOFS Workspace adapter; node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/293", @@ -605,19 +605,19 @@ const COMPILED_BINARY: RuntimeExclusion[] = [ */ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ { - path: "packages/workflow/tests/run-composition-ambient.test.ts", + path: "packages/git/tests/run-composition-ambient.test.ts", reason: "the subject is the ordinary run's repository provider, installed directly by the suite; it discovers an ambient repository and writes to a real checkout through the Deno runtime", issue: DERIVED_SCOPE, }, { - path: "packages/workflow/tests/run-composition-managed.test.ts", + path: "packages/git/tests/run-composition-managed.test.ts", reason: "the same provider, holding managed checkouts under a kernel-released exclusive advisory lock taken through the Deno runtime; Node and Bun expose no equivalent", issue: DERIVED_SCOPE, }, { - path: "packages/workflow/tests/run-composition-remote.test.ts", + path: "packages/git/tests/run-composition-remote.test.ts", reason: "the same provider, publishing and reconciling against a modeled Git host; the transport and its evidence are Deno-only for the same reason the rest of the provider is", issue: DERIVED_SCOPE, diff --git a/scripts/tests/documentation-validation.test.ts b/scripts/tests/documentation-validation.test.ts index 4a411d140..f02eb031a 100644 --- a/scripts/tests/documentation-validation.test.ts +++ b/scripts/tests/documentation-validation.test.ts @@ -32,7 +32,7 @@ function* validate(): Operation<{ ok: boolean; stderr: string }> { } const CORE = "packages/core/src/components/components.md"; -const COMPOSITION = "packages/workflow/src/composition/components.md"; +const COMPOSITION = "packages/git/src/composition/components.md"; describe("Tier SYN — the documentation build gate", () => { it("SYN41: passes on the shipped set", function* () { From 79e5dd2c0181a2d2a50f9aed3330ff2dc53dd549 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 20 Sep 2026 10:52:56 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=90=9B=20Keep=20Git=20declarations=20?= =?UTF-8?q?outside=20Workspace=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Workspace attachment owns this run's providers and durable state. It does not own the names: `` and the twelve components beside it are the Plugin's, installed once per command in the scope that encloses everything the command does. Declaring them again inside the attachment registered the same names a second time, one scope deeper. The test host had the same confusion in a sharper form. It asked the Plugin for its contribution from inside the callback where a case installs a shadowing registration, so the Plugin's declarations landed in the case's own scope and collided with the shadow — which is what made `pull-request.test.ts`'s nested-shadow case fail. The Plugin is now installed where a command installs it, once, enclosing both the attachment and the document. Duplicate-registration refusal and shadowing are unchanged: this removes a declaration that should not have been made, and narrows nothing about what happens when two are. --- packages/git/src/deno/attachment.ts | 15 +++++-- .../git/tests/repository-components.test.ts | 44 +++++++++++++++++++ packages/git/tests/support/composition.ts | 11 ++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/git/src/deno/attachment.ts b/packages/git/src/deno/attachment.ts index 63259cab5..b737d42c9 100644 --- a/packages/git/src/deno/attachment.ts +++ b/packages/git/src/deno/attachment.ts @@ -19,7 +19,6 @@ import type { Operation } from "effection"; import type { WorkflowWorkspaceAttachment } from "@executablemd/workflow/deno"; -import { useCompositionComponents } from "../composition/installation.ts"; import { useRetainedIssueOperations } from "../issue/effect.ts"; import { denoRepositoryHost } from "./composition/host.ts"; import { useGitHubPullRequests } from "./composition/pull-request-reads.ts"; @@ -76,7 +75,18 @@ export interface GitWorkspaceOptions { readonly helper?: HelperAssembly; } -/** The attachment a host names to give a workflow run this vocabulary. */ +/** + * The attachment a host names to give a workflow run this vocabulary's + * providers. + * + * Providers and per-run durable state, and not the declarations: what names + * `` is the Plugin, installed once per command in the scope that + * encloses everything the command does. A Workspace attachment is nested + * inside that scope, so declaring here would register the same names a second + * time, one scope deeper — which is not a collision but a shadow, and a shadow + * of the defaults is exactly the position a document's own registration is + * entitled to. + */ export function gitWorkspaceAttachment( options: GitWorkspaceOptions = {}, ): (attachment: WorkflowWorkspaceAttachment) => Operation { @@ -102,7 +112,6 @@ export function gitWorkspaceAttachment( // Durability for an admitted read, installed beside the transport rather // than above it: the adapter admits, this retains. yield* useRetainedPullRequestReads(); - yield* useCompositionComponents(); // Ordinary middleware, installed the way the Issue adapter is: it owns // the URLs it recognizes and delegates the rest. // Installed on every live or partial attachment, configured or not: the diff --git a/packages/git/tests/repository-components.test.ts b/packages/git/tests/repository-components.test.ts index 00b2a2044..2de9f309d 100644 --- a/packages/git/tests/repository-components.test.ts +++ b/packages/git/tests/repository-components.test.ts @@ -33,6 +33,8 @@ import { DirInvocationError } from "../src/composition/components/Dir.ts"; import { denoRepositoryHost } from "../src/deno/composition/host.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import { createRun, useStorageRoot, withStorage } from "../../workflow/tests/support/storage.ts"; +import { withWorkflowWorkspace } from "../../workflow/src/deno/workspace/host.ts"; +import { gitWorkspaceAttachment } from "../src/deno/attachment.ts"; import { useBareRemote } from "./support/git-remotes.ts"; import { causedBy, @@ -843,3 +845,45 @@ describe("Dir without a Files provider", () => { expect(yield* exists(join(process.cwd(), "made"))).toBe(false); }); }); + +describe("workflow Git declarations belong to the Plugin", () => { + it("are contributed by no Workspace attachment, so a host may name them itself", function* () { + // The attachment owns this run's providers and durable state; the Plugin + // owns the names. So the attachment's own scope holds no `Repository` + // declaration, and a host registering one there meets nothing to collide + // with. + // + // Falsifiable by construction: duplicate registration at one scope is a + // refusal, so restoring `useCompositionComponents()` to the attachment + // turns this registration into a `ComponentRegistrationError`. What the + // nested-shadow case above proves is a different thing — that a *nested* + // registration wins — and it cannot see this one, because a shadow is + // legitimate at any depth. + const root = yield* useStorageRoot(); + + yield* withStorage(root, function* () { + const database = yield* createRun(); + const named: ComponentRegistration = { + name: "Repository", + origin: "test", + props: { type: "object", additionalProperties: true }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "host-named"; + }, + }; + let registered = false; + yield* withWorkflowWorkspace( + database, + (function* (): Operation { + // Deliberately not wrapped in `scoped`: this registration has to land + // in the attachment's own scope for the claim to mean anything. + yield* registerComponents([named]); + registered = true; + })(), + { attachments: [gitWorkspaceAttachment()] }, + ); + expect(registered).toBe(true); + }); + }); +}); diff --git a/packages/git/tests/support/composition.ts b/packages/git/tests/support/composition.ts index ba5c61863..4f2743ebc 100644 --- a/packages/git/tests/support/composition.ts +++ b/packages/git/tests/support/composition.ts @@ -30,6 +30,7 @@ import { import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { useCompositionComponents } from "../../src/composition/installation.ts"; import { gitPlugin } from "../../src/plugin.ts"; import type { GitWorkspaceOptions } from "../../src/deno/attachment.ts"; import { @@ -175,6 +176,9 @@ export function runDocument( options: GitWorkspaceOptions = {}, ): Operation { return scoped(function* () { + // Where a command installs them: outside the Workspace attachment, which + // owns providers and per-run durable state rather than names. + yield* useCompositionComponents(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -207,6 +211,11 @@ export function runWorkflowDocument( around: (execute: () => Operation) => Operation = (execute) => execute(), ): Operation { return scoped(function* () { + // The Plugin, installed where a command installs it: once, in the scope + // that encloses everything below. Its declarations land here rather than + // beside the document, so a registration a case makes inside `around` is + // strictly nested and shadows them. + const git = yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -214,7 +223,7 @@ export function runWorkflowDocument( return yield* collect( yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, [ retainedWorkflowInstallation(retainedRunValue(database.record)), - yield* gitPluginAdmissions(), + git, ]), ); }); From 0e431bce1195bc6c53aede21f9190db935672012 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 20 Sep 2026 11:54:47 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20Complete=20the=20bundled=20GitH?= =?UTF-8?q?ub=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub ships inside `@executablemd/git`, and now it sits behind a boundary rather than merely inside a directory. The two CLI configuration modules move into the package and are read when an invoked GitHub-backed operation needs them, never at startup. Matching a target reads nothing outside the process, so a request bound elsewhere passes this adapter without its configuration being consulted, and an unauthorized destination reaches the surface's own base error exactly as installing no provider used to. `composition/github.ts` keeps the protocol — parsing, headers, pagination, normalization, provider behaviour — and `composition/github-host.ts` takes the environment, the credential-helper subprocess, the temporary directory and the concrete `fetch`. The implementation no longer reaches for the platform: it is handed an inert transport factory, and refuses in its own established vocabulary when it has neither that nor an injected access. `upsertPullRequest` terminates both Workflow reads — the Workspace export and the journal's push evidence — before the GitHub half is handed a locator, admitted inputs and a session. `useGitHubPullRequests` joins that orchestration so no module belongs to two sets. Three regressions hold the arrangement up: a partition scan that classifies every production module by three declared lists and fails on one named in none; an entrypoint scan separating the GitHub contracts a host needs from the package-local seams that must not escape; and an activation scan pinning the order installation → target → configuration → credential → transport, with negative surfaces that throw rather than count. --- packages/cli/src/deno-repositories.ts | 6 - packages/cli/src/deno-workflow.ts | 11 - packages/cli/tests/github-zero-effect.test.ts | 125 ++++++ packages/git/deno.ts | 2 +- packages/git/src/deno/attachment.ts | 14 +- .../git/src/deno/composition/github-host.ts | 157 +++++++ .../deno/composition/github-pull-request.ts | 259 +++++++++++ packages/git/src/deno/composition/github.ts | 131 ------ .../pull-request-configuration.ts} | 20 +- .../deno/composition/pull-request-reads.ts | 184 ++++---- .../git/src/deno/composition/pull-request.ts | 290 ++++--------- .../src/deno/issue/configuration.ts} | 25 +- packages/git/src/deno/issue/github.ts | 103 ++++- .../git/src/deno/run-composition/provider.ts | 40 +- .../git/tests/ambient-authentication.test.ts | 12 +- packages/git/tests/github-activation.test.ts | 360 ++++++++++++++++ ...e-github.test.ts => github-issues.test.ts} | 0 ...b.test.ts => github-pull-requests.test.ts} | 2 +- .../tests/github-workflow-activation.test.ts | 145 +++++++ packages/git/tests/issue-markdown.test.ts | 2 +- packages/git/tests/module-partition.test.ts | 407 ++++++++++++++++++ packages/git/tests/public-entrypoint.test.ts | 84 ++++ packages/git/tests/pull-request-read.test.ts | 27 +- .../git/tests/run-composition-ambient.test.ts | 10 +- .../git/tests/run-composition-remote.test.ts | 12 +- packages/git/tests/support/issue-providers.ts | 7 +- .../tests/support/pull-request-crash-child.ts | 2 +- scripts/runtime-test-exclusions.ts | 18 + 28 files changed, 1943 insertions(+), 512 deletions(-) create mode 100644 packages/cli/tests/github-zero-effect.test.ts create mode 100644 packages/git/src/deno/composition/github-host.ts create mode 100644 packages/git/src/deno/composition/github-pull-request.ts rename packages/{cli/src/github-pull-requests-config.ts => git/src/deno/composition/pull-request-configuration.ts} (86%) rename packages/{cli/src/github-issues-config.ts => git/src/deno/issue/configuration.ts} (78%) create mode 100644 packages/git/tests/github-activation.test.ts rename packages/git/tests/{issue-github.test.ts => github-issues.test.ts} (100%) rename packages/git/tests/{pull-request-github.test.ts => github-pull-requests.test.ts} (99%) create mode 100644 packages/git/tests/github-workflow-activation.test.ts create mode 100644 packages/git/tests/module-partition.test.ts create mode 100644 packages/git/tests/public-entrypoint.test.ts diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index c3e898f00..e2b47fc17 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -18,8 +18,6 @@ import type { Operation } from "effection"; import { cwd } from "@executablemd/runtime"; import { useRunComposition } from "@executablemd/git/deno"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; -import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; -import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; import { DEFAULT_REPOSITORY_ROOT } from "./run-repositories.ts"; import type { RepositoryInstaller } from "./run-repositories.ts"; @@ -35,8 +33,6 @@ export function denoRunRepositories( root: string = DEFAULT_REPOSITORY_ROOT, ): RepositoryInstaller { return function* (): Operation { - const gitHubIssues = yield* gitHubIssuesConfiguration(); - const gitHubPullRequests = yield* gitHubPullRequestsConfiguration(); yield* useRunComposition({ root, // The directory this execution starts in, which is where the ambient @@ -45,8 +41,6 @@ export function denoRunRepositories( // working directory is discovered from that one. cwd: yield* cwd(), helper, - ...(gitHubIssues === undefined ? {} : { gitHubIssues }), - ...(gitHubPullRequests === undefined ? {} : { gitHubPullRequests }), }); }; } diff --git a/packages/cli/src/deno-workflow.ts b/packages/cli/src/deno-workflow.ts index 5ea0120d0..0c3c3c49b 100644 --- a/packages/cli/src/deno-workflow.ts +++ b/packages/cli/src/deno-workflow.ts @@ -35,8 +35,6 @@ import type { WorkflowRunDatabase } from "@executablemd/workflow"; import type { HelperAssembly } from "@executablemd/git/credential-helper"; import { readLegacyDefinitionSource } from "./workflow-source.ts"; import type { WorkflowHost } from "./workflow.ts"; -import { gitHubIssuesConfiguration } from "./github-issues-config.ts"; -import { gitHubPullRequestsConfiguration } from "./github-pull-requests-config.ts"; import { useWorkflowAgentProfile } from "./workflow-agent.ts"; /** Where a run lives when nothing says otherwise. */ @@ -49,13 +47,6 @@ export function* useDenoWorkflowHost(helper: HelperAssembly): Operation { // The same reader the lifecycle installation captures. A version-1 run @@ -82,8 +73,6 @@ export function* useDenoWorkflowHost(helper: HelperAssembly): Operation` cost the bundled GitHub adapter. + * + * Both surfaces *describe* a profile rather than run one: syntax renders the + * catalog a command would install, and Plan validation checks a draft against + * those declarations — including the Git-owned syntax the bundled Plugin + * contributes. Neither is an invoked GitHub operation, so neither may + * read a configuration variable, obtain a credential or open a socket. + * + * Here rather than in `packages/git` because these are the CLI's own surfaces, + * and the CLI is what depends on Git. The adapters' own ordering is proven next + * door, in `packages/git/tests/github-activation.test.ts`. + * + * The boundary installed around each case **throws**. A spy that counted would + * let the read happen and report it afterwards; one that throws refuses it + * where it occurs, so a failure names the crossing. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { API } from "@executablemd/runtime"; +import { installPlugins } from "../src/plugin-host.ts"; +import { syntaxSymbols } from "../src/syntax.ts"; +import { planComponentDescription, structuralValidation } from "../src/plan-component.ts"; +import { gitPlugin } from "@executablemd/git"; +import { useGitHubIssues } from "@executablemd/git/deno"; + +/** A transport factory that refuses to be built at all. */ +function forbidden(): never { + throw new Error("a GitHub transport was built"); +} + +/** + * The bundled adapters, installed as a run profile installs them. + * + * Present but untouched is the whole claim: describing a profile that *has* + * the GitHub adapters in it must still read nothing. Without them installed + * these cases would pass for the trivial reason that there was no adapter to + * read anything. + */ +function* adapters(): Operation { + // The issue adapter is the one a host installs by name through the + // entrypoint; the pull-request reads adapter is installed beneath the run + // provider, which syntax and Plan never assemble. + yield* useGitHubIssues({ host: forbidden }); +} + +/** A configuration boundary that refuses to be read. */ +function* refusing(): Operation { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name.startsWith("XMD_WORKFLOW_GITHUB")) { + throw new Error(`configuration was read: ${name}`); + } + return yield* next(name); + }, + }, + { at: "min" }, + ); +} + +describe("describing the bundled profile costs nothing", () => { + it("renders the syntax catalog without reading GitHub configuration", function* () { + const named = yield* scoped(function* () { + yield* refusing(); + yield* adapters(); + const plugins = yield* installPlugins([gitPlugin], { + command: "syntax", + args: ["syntax"], + }); + const symbols = yield* syntaxSymbols([], plugins); + return symbols.categories.flatMap((category) => category.entries.map((entry) => entry.name)); + }); + + // The catalog really was rendered — an empty one would satisfy the refusal + // above by describing nothing at all. + for (const name of ["Repository", "Worktree", "Dir", "PullRequest", "Issue"]) { + expect(`${name}: ${named.includes(name)}`).toBe(`${name}: true`); + } + }); + + it("validates a Plan draft without reading GitHub configuration", function* () { + // Real structural validation, not merely loading the declaration: a draft + // written in Git-owned syntax, checked through the profile a `plan` command + // assembles. + // + // `` is Git's, but omitting the Plugin from this validation does not + // yet refuse the draft: `useCommandComponents()` still calls + // `useCompositionComponents()` directly (`packages/cli/src/syntax.ts`), so + // the vocabulary reaches syntax and Plan whether or not a Plugin supplied + // it. Slice 4 removes that call and makes the profile the only route, and + // the Plugin-omission probe becomes discriminating then. What this case + // proves now is that validating Git-owned syntax reads no GitHub + // configuration and opens no transport — which the refusing boundaries + // around it enforce, and which the eager-configuration probe fails. + const draft = '# Plan\n\nwork here\n'; + const result = yield* scoped(function* () { + yield* refusing(); + yield* adapters(); + const plugins = yield* installPlugins([gitPlugin], { command: "plan", args: ["plan"] }); + const validate = structuralValidation([], [yield* planComponentDescription()], plugins); + return yield* validate(draft); + }); + + expect(`${result.outcome}: ${result.diagnostics.map((d) => d.code).join(",")}`).toBe("valid: "); + // And it recognized the Git invocation rather than skipping it: an empty + // invocation list is what a validation that knew no components returns. + expect(result.invocations.map((invocation) => invocation.name)).toContain("Dir"); + + // The validation really discriminates. Without this, "valid" could be what + // a validator that accepted anything answers, and the row above would be + // asserting nothing about the vocabulary at all. + const refused = yield* scoped(function* () { + yield* refusing(); + yield* adapters(); + const plugins = yield* installPlugins([gitPlugin], { command: "plan", args: ["plan"] }); + const validate = structuralValidation([], [yield* planComponentDescription()], plugins); + return yield* validate("# Plan\n\n\n"); + }); + expect(refused.outcome).toBe("invalid"); + }); +}); diff --git a/packages/git/deno.ts b/packages/git/deno.ts index 3e70d388e..67b2606ac 100644 --- a/packages/git/deno.ts +++ b/packages/git/deno.ts @@ -19,8 +19,8 @@ export { parseGitHubPullRequestUrl, pullRequestAllowed, recognizesGitHubPullRequestUrl, - useGitHubPullRequests, } from "./src/deno/composition/pull-request-reads.ts"; +export { useGitHubPullRequests } from "./src/deno/composition/pull-request.ts"; export type { GitHubPullRequestsOptions } from "./src/deno/composition/pull-request-reads.ts"; export { WORKSPACE_GIT_ADD, diff --git a/packages/git/src/deno/attachment.ts b/packages/git/src/deno/attachment.ts index b737d42c9..ee9432c9d 100644 --- a/packages/git/src/deno/attachment.ts +++ b/packages/git/src/deno/attachment.ts @@ -21,7 +21,8 @@ import type { Operation } from "effection"; import type { WorkflowWorkspaceAttachment } from "@executablemd/workflow/deno"; import { useRetainedIssueOperations } from "../issue/effect.ts"; import { denoRepositoryHost } from "./composition/host.ts"; -import { useGitHubPullRequests } from "./composition/pull-request-reads.ts"; +import { denoGitHubSource } from "./composition/github-host.ts"; +import { useGitHubPullRequests } from "./composition/pull-request.ts"; import type { GitHubPullRequestsOptions } from "./composition/pull-request-reads.ts"; import type { HelperAssembly } from "./composition/credential-helper.ts"; import { @@ -102,9 +103,12 @@ export function gitWorkspaceAttachment( }; yield* useRepositoryComposition(database, composition); yield* useGitComposition(database, composition); - if (options.gitHubIssues !== undefined) { - yield* useGitHubIssues(options.gitHubIssues); - } + // Installed whether or not this deployment authorized any tracker. What + // absence decides is what the adapter answers, not whether it is present: + // it matches a destination from the request alone, reads configuration only + // once a destination is its own, and passes an unauthorized one on to + // `IssueApi`'s base — the same answer installing nothing used to give. + yield* useGitHubIssues({ host: denoGitHubSource, ...options.gitHubIssues }); // The retained lifecycle for both service-reaching vocabularies, above // whichever transport middleware this host installed for them. yield* useRetainedIssueOperations(); @@ -120,7 +124,7 @@ export function gitWorkspaceAttachment( yield* useGitHubPullRequests( database, composition.host ?? denoRepositoryHost(), - options.gitHubPullRequests ?? {}, + { host: denoGitHubSource, ...options.gitHubPullRequests }, selections, ); }; diff --git a/packages/git/src/deno/composition/github-host.ts b/packages/git/src/deno/composition/github-host.ts new file mode 100644 index 000000000..28dc5a567 --- /dev/null +++ b/packages/git/src/deno/composition/github-host.ts @@ -0,0 +1,157 @@ +/** + * The Deno host's half of the GitHub adapter. + * + * Concrete environment access, the credential-helper invocation, a temporary + * working directory and the platform's own `fetch` — the four things that are + * this runtime's rather than GitHub's. The protocol beside it knows none of + * them: it is handed a `GitHubAccess` and asks it for answers, which is what + * lets the same implementation be driven by a suite's transport with no host + * involved at all. + * + * Nothing here runs at import. A credential is read and a request is made by + * the first invoked operation that needs one, and not before. + */ + +import { call, ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { tmpdir } from "node:os"; +import process from "node:process"; +import { runProcess } from "./subprocess.ts"; +import { + GITHUB_API, + gitHubSource, + type GitHubAccess, + type GitHubHttpRequest, + type GitHubHttpResponse, + type GitHubLogin, + type GitHubSource, +} from "./github.ts"; + +/** The hostname the shipped login is asked about, fixed. */ +const GITHUB_HOST = "github.com"; + +/** + * The GitHub CLI's own stored credential. + * + * The third source, and the one that makes an already authenticated machine + * work without a second setup: `gh auth login` is what a person on this host + * has almost certainly already done, and asking `gh` for the token is asking + * the same broker every other tool on that machine asks. + * + * It is asked about `github.com` outright rather than about whatever endpoint + * an adapter was built with. A substituted endpoint is a test's local server, + * and handing a real host's credential to one would be the accident this whole + * boundary exists to prevent. + * + * A `gh` that is absent, unauthenticated or unreadable is no credential. None + * of those is an error to raise: they are answers the caller already has a word + * for, and what `gh` printed about it travels nowhere. + */ +export function denoGitHubLogin( + ambient: Readonly> = process.env, +): GitHubLogin { + return { + *token(): Operation { + const env: Record = {}; + for (const [name, value] of Object.entries(ambient)) { + if (value !== undefined) { + env[name] = value; + } + } + let outcome: { code: number; stdout: string }; + try { + outcome = yield* runProcess({ + command: "gh", + args: ["auth", "token", "--hostname", GITHUB_HOST], + cwd: tmpdir(), + env, + }); + } catch { + // A `gh` that is not on this machine at all. + return undefined; + } + if (outcome.code !== 0) { + return undefined; + } + const printed = outcome.stdout.trim(); + // One word, or nothing. A token with a space in it is not one this + // adapter puts in a header, and anything `gh` printed around one is not + // something to guess the shape of. + return printed === "" || /\s/.test(printed) ? undefined : printed; + }, + }; +} + +/** + * Where a live invocation gets its access, without holding one. + * + * A source is credential-free and long-lived: an installed middleware or a + * provider module may hold one for as long as it likes, because there is nothing + * in one to retain. A *session* is what has an identity, and one is opened per + * live invocation — after that invocation's ceiling and local admission checks + * — and disposed with it. Two calls are two sessions, so an observation and the + * mutation it decided go out under one identity while two unrelated invocations + * never share one. + */ + +/** The shipped source: the platform's transport and this host's credentials. */ +export function denoGitHubSource( + endpoint: string = GITHUB_API, + options: GitHubAccessOptions = {}, +): GitHubSource { + return gitHubSource(denoGitHubAccess(endpoint, options)); +} + +export interface GitHubAccessOptions { + /** Where the two explicit variables are read from. */ + readonly environment?: Readonly>; + /** The Git-host login consulted when neither variable names a credential. */ + readonly login?: GitHubLogin; +} + +/** + * The platform's own transport and environment. + * + * The request is aborted when the scope around it ends, so a cancelled + * invocation tears its HTTP down rather than leaving it to finish somewhere + * nobody is listening. + */ +export function denoGitHubAccess( + endpoint: string = GITHUB_API, + options: GitHubAccessOptions = {}, +): GitHubAccess { + const environment = options.environment ?? process.env; + const login = options.login ?? denoGitHubLogin(environment); + return { + endpoint, + *token(): Operation { + // Three sources, in this order. The two variables are what a caller says + // outright, and they are answered without consulting anything else — an + // empty one included, because a variable set to nothing is an explicit + // "no credential" rather than an invitation to look elsewhere. Only when + // neither is set at all is the machine's own login asked. + const supplied = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"]; + if (supplied !== undefined) { + return supplied === "" ? undefined : supplied; + } + return yield* login.token(); + }, + *send(request: GitHubHttpRequest): Operation { + return yield* scoped(function* () { + const controller = new AbortController(); + yield* ensure(() => controller.abort()); + const response = yield* until( + fetch(request.url, { + method: request.method, + headers: { ...request.headers }, + body: request.body, + signal: controller.signal, + }), + ); + const body = yield* until(response.text()); + const link = response.headers.get("link"); + return { status: response.status, body, link: link === null ? undefined : link }; + }); + }, + }; +} diff --git a/packages/git/src/deno/composition/github-pull-request.ts b/packages/git/src/deno/composition/github-pull-request.ts new file mode 100644 index 000000000..56c1dd83d --- /dev/null +++ b/packages/git/src/deno/composition/github-pull-request.ts @@ -0,0 +1,259 @@ +/** + * The GitHub adapter for one pull-request reconciliation. + * + * Everything reaching GitHub for `` and nothing else. It is handed + * what the orchestration above it has already established — the authenticated + * locator, the admitted inputs, and an access session — and it never sees where + * any of that came from. No Workspace, no run database, no journal and no + * callback onto one of them crosses this boundary: by the time a provider + * exists, the run's own record of publishing the branch has already admitted + * the request, and what is left is a conversation with a service. + * + * That is what keeps the provider-neutral half neutral. A Git host is selected + * from the locator this invocation carries, so an unsupported one is refused + * here — before a credential is read and before anything is sent. + */ + +import { Err, Ok } from "effection"; +import type { Operation, Result } from "effection"; +import { GitOperationInfrastructureError } from "../../composition/errors.ts"; +import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; +import { + parsePullRequestInputs, + parsePullRequestPreState, + PULL_REQUEST, + pullRequestAgrees, + pullRequestObservationsJson, + pullRequestPreStateJson, + pullRequestResultJson, + pullRequestResultOf, + samePullRequestIdentity, + type PullRequestInputs, + type PullRequestSnapshot, +} from "../../composition/pull-request-records.ts"; +import type { GitHostProvider } from "../../git-host/api.ts"; +import { GitHostProviderError, GitHostUnavailableError } from "../../git-host/errors.ts"; +import type { + CompleteGitHostEffectRequest, + GitHostCompletion, + GitHostObservation, +} from "../../git-host/records.ts"; +import { sameRepositoryIdentity } from "../../composition/selection.ts"; +import { gitHubPullRequests, parseGitHubRepository, type GitHubAccess } from "./github.ts"; + +function unusable(reason: string): never { + throw new GitOperationInfrastructureError(PULL_REQUEST_ELEMENT, reason); +} + +/** + * A pre-state that claims nothing. + * + * The three refusing observations publish no record — the engine journals a + * conflict, an ambiguity and an unavailability as the effect's failed result + * and discards everything the observation carried — so what a refusal saw at + * the Git host has no reason to be described. A pull request somebody else + * opened is their text, and this is the boundary that exists to keep it there. + */ +const NOTHING_PROVEN = pullRequestPreStateJson({ pullRequest: null }); + +/** + * The provider that answers this exact reconciliation, and no other. + * + * Installed around one `reconcileGitHostEffect()` call and reachable only from + * inside it. Its closure holds the parsed repository name, the endpoint and the + * credential source; what it receives from the engine is the frozen request, + * which it parses and holds to the inputs this invocation admitted. A request + * naming another Repository, another branch pair or other content is not this + * invocation's, and answering one would publish a completion for something this + * operation never admitted. + */ +export function pullRequestProvider( + access: GitHubAccess, + locator: string, + admitted: PullRequestInputs, +): GitHostProvider { + function admit(request: CompleteGitHostEffectRequest): PullRequestInputs { + const inputs = parsePullRequestInputs(request.inputs); + if ( + request.kind !== PULL_REQUEST || + inputs === undefined || + !sameRepositoryIdentity(inputs.repository, admitted.repository) || + inputs.title !== admitted.title || + inputs.body !== admitted.body || + inputs.draft !== admitted.draft || + inputs.headBranch !== admitted.headBranch || + inputs.headSha !== admitted.headSha || + inputs.baseBranch !== admitted.baseBranch || + inputs.number !== admitted.number + ) { + unusable( + "the Git host asked this provider about a pull request this invocation did not describe", + ); + } + return inputs; + } + + /** The adapter for this Repository, or a refusal of the whole effect kind. */ + function adapter(): ReturnType | undefined { + const name = parseGitHubRepository(locator); + return name === undefined + ? undefined + : gitHubPullRequests(access, name, admitted.repository.objectFormat); + } + + function completion(pullRequest: PullRequestSnapshot): GitHostCompletion { + return { + observations: pullRequestObservationsJson({ pullRequest }), + result: pullRequestResultJson(pullRequestResultOf(admitted, pullRequest)), + }; + } + + return { + *observe(request): Operation> { + const inputs = admit(request); + const pulls = adapter(); + if (pulls === undefined) { + // Said from observation and before any remote work, which is exactly + // how §10.2 has a host decline a kind it does not implement. The + // locator itself is not repeated: what this run holds for it is a + // fingerprint, and that is what a reader has. + return Err( + new GitHostProviderError( + "this Git host adapter opens pull requests only for repositories on github.com", + ), + ); + } + + const observed = yield* pulls.observe(inputs); + if (observed.state === "unavailable") { + // Not absence. A host that could not answer has proven nothing, and + // offering silence as absence is what would open a second pull request + // or rewrite one this invocation never saw. + return Err(new GitHostUnavailableError()); + } + if (observed.state === "ambiguous") { + return Ok({ state: "ambiguous", preState: NOTHING_PROVEN }); + } + if (observed.state === "conflict") { + return Ok({ state: "conflict", preState: NOTHING_PROVEN }); + } + if (observed.state === "absent") { + // Only an unnumbered request can reach this: a number that named + // nothing provable is unavailable rather than absent, above. + return Ok({ state: "absent", preState: NOTHING_PROVEN }); + } + + const found = observed.pullRequest; + if (pullRequestAgrees(found, inputs)) { + // Everything this invocation asks for is already true. For an + // unnumbered request that is the pull request an interrupted attempt + // created; for a numbered one it is the no-op an unchanged document + // means. Both are the shared adoption, with the pre-state and the + // observations one reading of one pull request. + const adopted = completion(found); + return Ok({ + state: "compatible", + preState: pullRequestPreStateJson({ pullRequest: found }), + observations: adopted.observations, + result: adopted.result, + }); + } + + if (inputs.number === null) { + // One open pull request for this branch pair, saying something else. + // An unnumbered request asks for one to exist, not for whatever is + // there to become this — rewriting it would act on a pull request the + // document never named. + return Ok({ state: "conflict", preState: NOTHING_PROVEN }); + } + + // The document named this pull request and asked for fields it does not + // hold. Absent is the shared machine's word for "the requested + // completion is not there", and the pre-state is what is there instead — + // which is how a performed update can describe what it acted on. + return Ok({ + state: "absent", + preState: pullRequestPreStateJson({ pullRequest: found }), + }); + }, + + *perform(request, observation): Operation> { + const inputs = admit(request); + const pulls = adapter(); + if (pulls === undefined) { + unusable("the Git host that proved absence is not the one being asked to act"); + } + + // Which of the two this is, is decided by the proven absence itself. The + // engine reaches `perform` only from `absent`, and the pre-state it + // carries is this attempt's own observation: nothing there, or the pull + // request the document named as it stood a moment ago. + const before = parsePullRequestPreState(observation.preState, inputs.repository.objectFormat); + if (before === undefined) { + unusable("the proven absence this attempt acts on describes no pre-state"); + } + if (before.pullRequest === null) { + if (inputs.number !== null) { + unusable("a numbered pull request cannot be created"); + } + return yield* created(pulls, inputs); + } + if (inputs.number === null || before.pullRequest.number !== inputs.number) { + unusable("the pull request this attempt would update is not the one it observed"); + } + return yield* updated(pulls, inputs, before.pullRequest); + }, + }; + + /** One creation, and one observation if its outcome is uncertain. */ + function* created( + pulls: ReturnType, + inputs: PullRequestInputs, + ): Operation> { + const attempt = yield* pulls.create(inputs); + if (attempt.state === "settled") { + if (!pullRequestAgrees(attempt.pullRequest, inputs)) { + unusable("the Git host created a pull request other than the one it was asked for"); + } + return Ok(completion(attempt.pullRequest)); + } + if (attempt.state === "unreadable") { + unusable("the Git host answered the creation with something this boundary cannot read"); + } + + // A race, a rejection or a failure with no word for it: what happened is + // decided by observing once, never by a second attempt to create. + const observed = yield* pulls.observe(inputs); + if (observed.state === "found" && pullRequestAgrees(observed.pullRequest, inputs)) { + return Ok(completion(observed.pullRequest)); + } + // Everything else is unknown rather than absent or conflicting, and it is + // published as such. A later explicit attempt starts again at observation, + // where a conflict and an ambiguity have their own words — and nothing here + // creates a second pull request to find out. + return Err(new GitHostUnavailableError()); + } + + /** The required mutations, once each, and the one observation that decides. */ + function* updated( + pulls: ReturnType, + inputs: PullRequestInputs, + before: PullRequestSnapshot, + ): Operation> { + const attempt = yield* pulls.update(inputs, before); + if (attempt.state === "unreadable") { + unusable("the Git host answered the update with something this boundary cannot read"); + } + if (attempt.state === "uncertain" || !pullRequestAgrees(attempt.pullRequest, inputs)) { + // A rejected mutation, a partial multi-call update and a host that could + // not be read afterwards are one answer: this attempt did not reach the + // requested state. Nothing is repeated here — a later explicit attempt + // observes what is now there and finishes only what is left. + return Err(new GitHostUnavailableError()); + } + if (!samePullRequestIdentity(before, attempt.pullRequest)) { + unusable("the Git host answered with a pull request other than the one being updated"); + } + return Ok(completion(attempt.pullRequest)); + } +} diff --git a/packages/git/src/deno/composition/github.ts b/packages/git/src/deno/composition/github.ts index 2664a2543..230217a5c 100644 --- a/packages/git/src/deno/composition/github.ts +++ b/packages/git/src/deno/composition/github.ts @@ -35,9 +35,6 @@ import { ensure, resource, scoped, until } from "effection"; import type { Operation } from "effection"; -import { tmpdir } from "node:os"; -import process from "node:process"; -import { runProcess } from "./subprocess.ts"; import { gitObjectId } from "../../composition/git-push-records.ts"; import type { GitObjectFormat } from "../../composition/records.ts"; import { OPEN, pullRequestNumber } from "../../composition/pull-request-records.ts"; @@ -158,72 +155,6 @@ export interface GitHubLogin { token(): Operation; } -/** The hostname the shipped login is asked about, fixed. */ -const GITHUB_HOST = "github.com"; - -/** - * The GitHub CLI's own stored credential. - * - * The third source, and the one that makes an already authenticated machine - * work without a second setup: `gh auth login` is what a person on this host - * has almost certainly already done, and asking `gh` for the token is asking - * the same broker every other tool on that machine asks. - * - * It is asked about `github.com` outright rather than about whatever endpoint - * an adapter was built with. A substituted endpoint is a test's local server, - * and handing a real host's credential to one would be the accident this whole - * boundary exists to prevent. - * - * A `gh` that is absent, unauthenticated or unreadable is no credential. None - * of those is an error to raise: they are answers the caller already has a word - * for, and what `gh` printed about it travels nowhere. - */ -export function denoGitHubLogin( - ambient: Readonly> = process.env, -): GitHubLogin { - return { - *token(): Operation { - const env: Record = {}; - for (const [name, value] of Object.entries(ambient)) { - if (value !== undefined) { - env[name] = value; - } - } - let outcome: { code: number; stdout: string }; - try { - outcome = yield* runProcess({ - command: "gh", - args: ["auth", "token", "--hostname", GITHUB_HOST], - cwd: tmpdir(), - env, - }); - } catch { - // A `gh` that is not on this machine at all. - return undefined; - } - if (outcome.code !== 0) { - return undefined; - } - const printed = outcome.stdout.trim(); - // One word, or nothing. A token with a space in it is not one this - // adapter puts in a header, and anything `gh` printed around one is not - // something to guess the shape of. - return printed === "" || /\s/.test(printed) ? undefined : printed; - }, - }; -} - -/** - * Where a live invocation gets its access, without holding one. - * - * A source is credential-free and long-lived: an installed middleware or a - * provider module may hold one for as long as it likes, because there is nothing - * in one to retain. A *session* is what has an identity, and one is opened per - * live invocation — after that invocation's ceiling and local admission checks - * — and disposed with it. Two calls are two sessions, so an observation and the - * mutation it decided go out under one identity while two unrelated invocations - * never share one. - */ export interface GitHubSource { readonly endpoint: string; open(): Operation; @@ -270,68 +201,6 @@ export function gitHubSource(access: GitHubAccess): GitHubSource { }; } -/** The shipped source: the platform's transport and this host's credentials. */ -export function denoGitHubSource( - endpoint: string = GITHUB_API, - options: GitHubAccessOptions = {}, -): GitHubSource { - return gitHubSource(denoGitHubAccess(endpoint, options)); -} - -export interface GitHubAccessOptions { - /** Where the two explicit variables are read from. */ - readonly environment?: Readonly>; - /** The Git-host login consulted when neither variable names a credential. */ - readonly login?: GitHubLogin; -} - -/** - * The platform's own transport and environment. - * - * The request is aborted when the scope around it ends, so a cancelled - * invocation tears its HTTP down rather than leaving it to finish somewhere - * nobody is listening. - */ -export function denoGitHubAccess( - endpoint: string = GITHUB_API, - options: GitHubAccessOptions = {}, -): GitHubAccess { - const environment = options.environment ?? process.env; - const login = options.login ?? denoGitHubLogin(environment); - return { - endpoint, - *token(): Operation { - // Three sources, in this order. The two variables are what a caller says - // outright, and they are answered without consulting anything else — an - // empty one included, because a variable set to nothing is an explicit - // "no credential" rather than an invitation to look elsewhere. Only when - // neither is set at all is the machine's own login asked. - const supplied = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"]; - if (supplied !== undefined) { - return supplied === "" ? undefined : supplied; - } - return yield* login.token(); - }, - *send(request: GitHubHttpRequest): Operation { - return yield* scoped(function* () { - const controller = new AbortController(); - yield* ensure(() => controller.abort()); - const response = yield* until( - fetch(request.url, { - method: request.method, - headers: { ...request.headers }, - body: request.body, - signal: controller.signal, - }), - ); - const body = yield* until(response.text()); - const link = response.headers.get("link"); - return { status: response.status, body, link: link === null ? undefined : link }; - }); - }, - }; -} - /** * The headers every authenticated call carries, or nothing when there is no * credential. diff --git a/packages/cli/src/github-pull-requests-config.ts b/packages/git/src/deno/composition/pull-request-configuration.ts similarity index 86% rename from packages/cli/src/github-pull-requests-config.ts rename to packages/git/src/deno/composition/pull-request-configuration.ts index 5563c8fe4..307de9609 100644 --- a/packages/cli/src/github-pull-requests-config.ts +++ b/packages/git/src/deno/composition/pull-request-configuration.ts @@ -1,5 +1,5 @@ /** - * Where the production host learns which pull requests it may read. + * Where this adapter learns which pull requests it may read. * * A URL a document writes is composition data: it says which pull request is * wanted, not that this deployment allows reading it. What allows it is here, @@ -16,10 +16,16 @@ * own matching Push evidence and the Git-host reconciliation behind it. Nothing * here is what admits it, so nothing here can withdraw it. * + * Read when an invoked read turns out to be this adapter's, never at startup. + * Installing the Plugin, describing its syntax, validating a Plan and reading + * a pull request somewhere else read this variable not at all. + * * Malformed configuration is refused rather than narrowed to what parsed. An * operator who wrote a list this host could not read has not authorized the * empty set — they have made a mistake, and running with fewer targets than - * they wrote would hide it until the day it mattered. + * they wrote would hide it until the day it mattered. The refusal reaches the + * operation that needed it rather than the command that started, which is the + * one thing lazy reading changes about it. * * The member is `allowed`, not `ceiling`. "Permission ceiling" is what the * architecture calls the bound; what an operator writes is the list of places @@ -31,8 +37,8 @@ import { env as readEnv } from "@executablemd/runtime"; import type { Operation } from "effection"; -import { canonicalPullRequestUrl } from "@executablemd/git"; -import type { GitHubPullRequestsOptions } from "@executablemd/git/deno"; +import { canonicalPullRequestUrl } from "../../composition/pull-request-target.ts"; +import type { GitHubPullRequestsConfiguration } from "./pull-request-reads.ts"; /** The variable that configures GitHub pull-request reading. */ export const GITHUB_PULL_REQUESTS_ENV = "XMD_WORKFLOW_GITHUB_PULL_REQUESTS"; @@ -87,11 +93,11 @@ function canonicalEndpoint(value: unknown): string | undefined { * * Strict about shape and about the URLs it holds. An entry that is not the * canonical name of a container is refused here rather than at the first - * request, because an operator reading a startup failure can fix it and a - * document author reading a refusal mid-run cannot. + * request, because a list this host cannot read authorizes nothing and saying + * so is the only honest answer to the read that asked. */ export function* gitHubPullRequestsConfiguration(): Operation< - GitHubPullRequestsOptions | undefined + GitHubPullRequestsConfiguration | undefined > { const written = yield* readEnv(GITHUB_PULL_REQUESTS_ENV); if (written === undefined || written === "") { diff --git a/packages/git/src/deno/composition/pull-request-reads.ts b/packages/git/src/deno/composition/pull-request-reads.ts index c08d92dc9..e2d3a475e 100644 --- a/packages/git/src/deno/composition/pull-request-reads.ts +++ b/packages/git/src/deno/composition/pull-request-reads.ts @@ -37,7 +37,6 @@ */ import type { Operation } from "effection"; -import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { GitOperationAdmissionError, PullRequestReadError } from "../../composition/errors.ts"; import type { PullRequestReadKind, @@ -48,10 +47,10 @@ import { PullRequestReadExecution } from "../../composition/pull-request-read-ex import type { PullRequestReadOptions } from "../../composition/pull-request-api.ts"; import type { RepositoryRecord } from "../../composition/records.ts"; import type { SelectionRegistry } from "../selections.ts"; -import { denoGitHubSource } from "./github.ts"; import type { GitHubRepositoryName, GitHubSource } from "./github.ts"; import { readPullRequestEvidence as readEvidence } from "./pull-request-evidence.ts"; import { upsertPullRequest } from "./pull-request.ts"; +import { gitHubPullRequestsConfiguration } from "./pull-request-configuration.ts"; import type { RepositoryHost } from "./host.ts"; import { PULL_REQUEST_ELEMENT } from "../../composition/components/PullRequest.ts"; import type { PullRequestResult } from "../../composition/pull-request-records.ts"; @@ -145,7 +144,7 @@ export function pullRequestAllowed(allowed: readonly string[], url: string): boo return allowed.some((entry) => url === entry || url.startsWith(`${entry}/`)); } -export interface GitHubPullRequestsOptions { +export interface GitHubPullRequestsConfiguration { /** * The canonical containers whose pull requests this host may read. * @@ -157,8 +156,35 @@ export interface GitHubPullRequestsOptions { readonly allowed?: readonly string[]; /** The API base every request is built against, when not the default. */ readonly endpoint?: string; +} + +/** + * What a host installs this adapter with. + * + * Nothing here is an operator's authorization. What is allowed and where the + * API lives are configuration, and configuration is read when an invoked + * operation needs it — so what a host supplies is only the substitutions a + * suite makes for the two things it cannot arrange. + */ +export interface GitHubPullRequestsOptions { /** An injected transport, which outranks any configured endpoint. */ readonly access?: GitHubSource; + /** + * How a source is built when none is injected. + * + * Supplied by the host, never reached for: this implementation knows the + * protocol and not the platform, so the concrete transport and the + * credential behind it arrive from the adapter that owns them. A suite that + * injects `access` needs none of this. + */ + readonly host?: (endpoint?: string) => GitHubSource; + /** + * Configuration stated directly, read instead of the environment. + * + * A suite says what an operator would have written rather than writing it + * into the process it is running in. + */ + readonly configuration?: GitHubPullRequestsConfiguration; } /** @@ -172,11 +198,63 @@ export interface GitHubPullRequestsOptions { * platform's own GitHub. A suite that supplies its own access is not asking for * a different endpoint as well. */ -function sourceOf(options: GitHubPullRequestsOptions): GitHubSource { - return ( - options.access ?? - (options.endpoint === undefined ? denoGitHubSource() : denoGitHubSource(options.endpoint)) - ); +function sourceOf( + options: GitHubPullRequestsOptions, + configuration: GitHubPullRequestsConfiguration, +): GitHubSource { + const source = options.access ?? options.host?.(configuration.endpoint); + if (source === undefined) { + throw new PullRequestReadError( + "unavailable", + PULL_REQUEST_ELEMENT, + "no transport was installed for this Git host, so nothing could be asked of it.", + ); + } + return source; +} + +/** + * What this deployment authorized, read when an invoked request needs it. + * + * Memoized including its absence: an unset variable is an answer, and asking + * the environment again on the next request would be asking a question this + * adapter has already had answered. Matching a URL reads nothing outside the + * process, so a request that is not this adapter's never reaches here. + */ +function transports( + options: GitHubPullRequestsOptions, +): (configuration: GitHubPullRequestsConfiguration) => GitHubSource { + let opened: GitHubSource | undefined; + return (configuration) => (opened ??= sourceOf(options, configuration)); +} + +/** + * The transport this adapter reaches GitHub through, resolved on first use. + * + * What the orchestration above asks for when it has a reconciliation to make. + * Configuration belongs to this set, so it is read here rather than passed in — + * and read only when something actually needs a session. + */ +export function gitHubPullRequestAccess( + options: GitHubPullRequestsOptions = {}, +): () => Operation { + const authorized = resolver(options); + const transport = transports(options); + return function* (): Operation { + return transport((yield* authorized()) ?? {}); + }; +} + +function resolver( + options: GitHubPullRequestsOptions, +): () => Operation { + let settled: { readonly configuration: GitHubPullRequestsConfiguration | undefined } | undefined; + return function* (): Operation { + settled ??= { + configuration: options.configuration ?? (yield* gitHubPullRequestsConfiguration()), + }; + return settled.configuration; + }; } /** @@ -191,31 +269,41 @@ function sourceOf(options: GitHubPullRequestsOptions): GitHubSource { * installing none leaves `PullRequestAPI`'s own base error to report that * nothing handled the request. */ -export function* useGitHubPullRequestReads(options: GitHubPullRequestsOptions): Operation { - const source = sourceOf(options); +export function* useGitHubPullRequestReads( + options: GitHubPullRequestsOptions = {}, +): Operation { + const authorized = resolver(options); + const transport = transports(options); yield* PullRequestAPI.around({ *read([url, read], next): Operation { - // Matched by discriminator, or — with no discriminator — by URL. - // With nothing allowed there is no URL read this host performs, so the - // request passes to whatever else is installed and, finding nothing, - // reaches the surface's own base error. Upsert is untouched by this: it - // is handled below whether or not any URL is allowed. - const configured = options.allowed !== undefined && options.allowed.length > 0; + // Matched by discriminator, or — with no discriminator — by URL. The + // match is decided from the request alone, so a URL that is not this + // adapter's reads no configuration on its way past. const mine = - configured && - (read.provider === undefined + read.provider === undefined ? recognizesGitHubPullRequestUrl(url) - : read.provider === GITHUB); + : read.provider === GITHUB; if (!mine) { return yield* next(url, read); } + // The URL is this adapter's, so now — and only now — what this deployment + // authorized is read. With nothing allowed there is no URL read this host + // performs, so the request passes to whatever else is installed and, + // finding nothing, reaches the surface's own base error. Upsert is + // untouched by this: it is handled below whether or not any URL is + // allowed. + const configuration = yield* authorized(); + if (configuration?.allowed === undefined || configuration.allowed.length === 0) { + return yield* next(url, read); + } + const element = ELEMENT[read.kind]; // From here this middleware owns the answer, and what is allowed is asked // before anything is built: a URL a document wrote is not a place this // host authorized until the configuration says so. - if (!pullRequestAllowed(options.allowed ?? [], url)) { + if (!pullRequestAllowed(configuration.allowed, url)) { throw new PullRequestReadError( "unavailable", element, @@ -246,7 +334,7 @@ export function* useGitHubPullRequestReads(options: GitHubPullRequestsOptions): function* (): Operation { // After the ceiling, never before: a session opened first would be an // identity established for a target this host had not authorized. - const access = yield* source.open(); + const access = yield* transport(configuration).open(); const reading = yield* readEvidence(access, name, name.number, read.kind); if (reading.state === "unavailable") { throw new PullRequestReadError( @@ -272,59 +360,5 @@ export function* useGitHubPullRequestReads(options: GitHubPullRequestsOptions): }); } -/** - * Install the workflow host's reconciled pull-request upsert, and its reads. - * - * The upsert is unchanged in everything but where it is reached from: it still - * proves this run published the branch, still reconciles through the Git-host - * engine, and still refuses a pull request belonging to another Repository. The - * selection it is handed is resolved through the provider's own registry, never - * believed, which is the same rule every Git operation follows. - */ -export function* useGitHubPullRequests( - database: WorkflowRunDatabase, - host: RepositoryHost, - options: GitHubPullRequestsOptions, - selections: SelectionRegistry, -): Operation { - const source = sourceOf(options); - - yield* PullRequestAPI.around({ - *upsert([pullRequest, upsert], next): Operation { - const mine = upsert.provider === undefined || upsert.provider === GITHUB; - if (!mine) { - return yield* next(pullRequest, upsert); - } - const outcome = yield* upsertPullRequest( - database, - host, - { - // The record this provider itself holds for the selection, never the - // selection's own words: a Repository nobody selected is exactly what - // a replaced context would name. - repository: selections.authenticate( - upsert.repository, - () => - new GitOperationAdmissionError( - PULL_REQUEST_ELEMENT, - "the Repository in scope is not one this run selected, so it names no retained " + - "checkout", - ), - ), - workingDirectory: upsert.workingDirectory, - number: pullRequest.number, - title: pullRequest.title, - body: pullRequest.body, - draft: pullRequest.draft, - base: pullRequest.base, - }, - source, - ); - return outcome.result; - }, - }); - yield* useGitHubPullRequestReads(options); -} - /** The options a read carries, re-exported for a host installing this. */ export type { PullRequestReadOptions }; diff --git a/packages/git/src/deno/composition/pull-request.ts b/packages/git/src/deno/composition/pull-request.ts index 1e443f9e8..384df6ad7 100644 --- a/packages/git/src/deno/composition/pull-request.ts +++ b/packages/git/src/deno/composition/pull-request.ts @@ -79,13 +79,25 @@ import { readWorkflowWorkspace } from "@executablemd/workflow/deno"; import { readWorkspaceMetadata } from "../repositories.ts"; import { currentBranch, gitSession, resolveCommit } from "./git.ts"; import { - denoGitHubSource, gitHubPullRequests, parseGitHubRepository, type GitHubAccess, type GitHubSource, } from "./github.ts"; +import { denoGitHubSource } from "./github-host.ts"; import type { RepositoryHost } from "./host.ts"; +import { PullRequestAPI } from "../../composition/pull-request-api.ts"; +import type { PullRequestResult } from "../../composition/pull-request-records.ts"; +import type { SelectionRegistry } from "../selections.ts"; +import type { RepositoryRecord } from "../../composition/records.ts"; +import { GitOperationAdmissionError } from "../../composition/errors.ts"; +import { + gitHubPullRequestAccess, + GITHUB, + useGitHubPullRequestReads, + type GitHubPullRequestsOptions, +} from "./pull-request-reads.ts"; +import { pullRequestProvider } from "./github-pull-request.ts"; import { filteredRepositoryIdentity, sameRepositoryIdentity } from "../../composition/selection.ts"; import { exportCheckoutFamily, @@ -94,221 +106,14 @@ import { type GitCheckout, } from "./operations.ts"; -function unusable(reason: string): never { - throw new GitOperationInfrastructureError(PULL_REQUEST_ELEMENT, reason); -} - -/** - * A pre-state that claims nothing. - * - * The three refusing observations publish no record — the engine journals a - * conflict, an ambiguity and an unavailability as the effect's failed result - * and discards everything the observation carried — so what a refusal saw at - * the Git host has no reason to be described. A pull request somebody else - * opened is their text, and this is the boundary that exists to keep it there. - */ -const NOTHING_PROVEN = pullRequestPreStateJson({ pullRequest: null }); - /** - * The provider that answers this exact reconciliation, and no other. + * A refusal this orchestration makes, for something it could not establish. * - * Installed around one `reconcileGitHostEffect()` call and reachable only from - * inside it. Its closure holds the parsed repository name, the endpoint and the - * credential source; what it receives from the engine is the frozen request, - * which it parses and holds to the inputs this invocation admitted. A request - * naming another Repository, another branch pair or other content is not this - * invocation's, and answering one would publish a completion for something this - * operation never admitted. + * The adapter beside it has its own: each set answers for what it did, and + * neither reaches into the other to say it. */ -function pullRequestProvider( - access: GitHubAccess, - locator: string, - admitted: PullRequestInputs, -): GitHostProvider { - function admit(request: CompleteGitHostEffectRequest): PullRequestInputs { - const inputs = parsePullRequestInputs(request.inputs); - if ( - request.kind !== PULL_REQUEST || - inputs === undefined || - !sameRepositoryIdentity(inputs.repository, admitted.repository) || - inputs.title !== admitted.title || - inputs.body !== admitted.body || - inputs.draft !== admitted.draft || - inputs.headBranch !== admitted.headBranch || - inputs.headSha !== admitted.headSha || - inputs.baseBranch !== admitted.baseBranch || - inputs.number !== admitted.number - ) { - unusable( - "the Git host asked this provider about a pull request this invocation did not describe", - ); - } - return inputs; - } - - /** The adapter for this Repository, or a refusal of the whole effect kind. */ - function adapter(): ReturnType | undefined { - const name = parseGitHubRepository(locator); - return name === undefined - ? undefined - : gitHubPullRequests(access, name, admitted.repository.objectFormat); - } - - function completion(pullRequest: PullRequestSnapshot): GitHostCompletion { - return { - observations: pullRequestObservationsJson({ pullRequest }), - result: pullRequestResultJson(pullRequestResultOf(admitted, pullRequest)), - }; - } - - return { - *observe(request): Operation> { - const inputs = admit(request); - const pulls = adapter(); - if (pulls === undefined) { - // Said from observation and before any remote work, which is exactly - // how §10.2 has a host decline a kind it does not implement. The - // locator itself is not repeated: what this run holds for it is a - // fingerprint, and that is what a reader has. - return Err( - new GitHostProviderError( - "this Git host adapter opens pull requests only for repositories on github.com", - ), - ); - } - - const observed = yield* pulls.observe(inputs); - if (observed.state === "unavailable") { - // Not absence. A host that could not answer has proven nothing, and - // offering silence as absence is what would open a second pull request - // or rewrite one this invocation never saw. - return Err(new GitHostUnavailableError()); - } - if (observed.state === "ambiguous") { - return Ok({ state: "ambiguous", preState: NOTHING_PROVEN }); - } - if (observed.state === "conflict") { - return Ok({ state: "conflict", preState: NOTHING_PROVEN }); - } - if (observed.state === "absent") { - // Only an unnumbered request can reach this: a number that named - // nothing provable is unavailable rather than absent, above. - return Ok({ state: "absent", preState: NOTHING_PROVEN }); - } - - const found = observed.pullRequest; - if (pullRequestAgrees(found, inputs)) { - // Everything this invocation asks for is already true. For an - // unnumbered request that is the pull request an interrupted attempt - // created; for a numbered one it is the no-op an unchanged document - // means. Both are the shared adoption, with the pre-state and the - // observations one reading of one pull request. - const adopted = completion(found); - return Ok({ - state: "compatible", - preState: pullRequestPreStateJson({ pullRequest: found }), - observations: adopted.observations, - result: adopted.result, - }); - } - - if (inputs.number === null) { - // One open pull request for this branch pair, saying something else. - // An unnumbered request asks for one to exist, not for whatever is - // there to become this — rewriting it would act on a pull request the - // document never named. - return Ok({ state: "conflict", preState: NOTHING_PROVEN }); - } - - // The document named this pull request and asked for fields it does not - // hold. Absent is the shared machine's word for "the requested - // completion is not there", and the pre-state is what is there instead — - // which is how a performed update can describe what it acted on. - return Ok({ - state: "absent", - preState: pullRequestPreStateJson({ pullRequest: found }), - }); - }, - - *perform(request, observation): Operation> { - const inputs = admit(request); - const pulls = adapter(); - if (pulls === undefined) { - unusable("the Git host that proved absence is not the one being asked to act"); - } - - // Which of the two this is, is decided by the proven absence itself. The - // engine reaches `perform` only from `absent`, and the pre-state it - // carries is this attempt's own observation: nothing there, or the pull - // request the document named as it stood a moment ago. - const before = parsePullRequestPreState(observation.preState, inputs.repository.objectFormat); - if (before === undefined) { - unusable("the proven absence this attempt acts on describes no pre-state"); - } - if (before.pullRequest === null) { - if (inputs.number !== null) { - unusable("a numbered pull request cannot be created"); - } - return yield* created(pulls, inputs); - } - if (inputs.number === null || before.pullRequest.number !== inputs.number) { - unusable("the pull request this attempt would update is not the one it observed"); - } - return yield* updated(pulls, inputs, before.pullRequest); - }, - }; - - /** One creation, and one observation if its outcome is uncertain. */ - function* created( - pulls: ReturnType, - inputs: PullRequestInputs, - ): Operation> { - const attempt = yield* pulls.create(inputs); - if (attempt.state === "settled") { - if (!pullRequestAgrees(attempt.pullRequest, inputs)) { - unusable("the Git host created a pull request other than the one it was asked for"); - } - return Ok(completion(attempt.pullRequest)); - } - if (attempt.state === "unreadable") { - unusable("the Git host answered the creation with something this boundary cannot read"); - } - - // A race, a rejection or a failure with no word for it: what happened is - // decided by observing once, never by a second attempt to create. - const observed = yield* pulls.observe(inputs); - if (observed.state === "found" && pullRequestAgrees(observed.pullRequest, inputs)) { - return Ok(completion(observed.pullRequest)); - } - // Everything else is unknown rather than absent or conflicting, and it is - // published as such. A later explicit attempt starts again at observation, - // where a conflict and an ambiguity have their own words — and nothing here - // creates a second pull request to find out. - return Err(new GitHostUnavailableError()); - } - - /** The required mutations, once each, and the one observation that decides. */ - function* updated( - pulls: ReturnType, - inputs: PullRequestInputs, - before: PullRequestSnapshot, - ): Operation> { - const attempt = yield* pulls.update(inputs, before); - if (attempt.state === "unreadable") { - unusable("the Git host answered the update with something this boundary cannot read"); - } - if (attempt.state === "uncertain" || !pullRequestAgrees(attempt.pullRequest, inputs)) { - // A rejected mutation, a partial multi-call update and a host that could - // not be read afterwards are one answer: this attempt did not reach the - // requested state. Nothing is repeated here — a later explicit attempt - // observes what is now there and finishes only what is left. - return Err(new GitHostUnavailableError()); - } - if (!samePullRequestIdentity(before, attempt.pullRequest)) { - unusable("the Git host answered with a pull request other than the one being updated"); - } - return Ok(completion(attempt.pullRequest)); - } +function unusable(reason: string): never { + throw new GitOperationInfrastructureError(PULL_REQUEST_ELEMENT, reason); } /** What this invocation asks for: the branch the checkout is on, at its commit. */ @@ -436,3 +241,62 @@ export function* upsertPullRequest( return outcome; }); } + +/** + * Install the workflow host's reconciled pull-request upsert, and its reads. + * + * The upsert is unchanged in everything but where it is reached from: it still + * proves this run published the branch, still reconciles through the Git-host + * engine, and still refuses a pull request belonging to another Repository. The + * selection it is handed is resolved through the provider's own registry, never + * believed, which is the same rule every Git operation follows. + */ +export function* useGitHubPullRequests( + database: WorkflowRunDatabase, + host: RepositoryHost, + options: GitHubPullRequestsOptions, + selections: SelectionRegistry, +): Operation { + const access = gitHubPullRequestAccess(options); + + yield* PullRequestAPI.around({ + *upsert([pullRequest, upsert], next): Operation { + const mine = upsert.provider === undefined || upsert.provider === GITHUB; + if (!mine) { + return yield* next(pullRequest, upsert); + } + const outcome = yield* upsertPullRequest( + database, + host, + { + // The record this provider itself holds for the selection, never the + // selection's own words: a Repository nobody selected is exactly what + // a replaced context would name. + repository: selections.authenticate( + upsert.repository, + () => + new GitOperationAdmissionError( + PULL_REQUEST_ELEMENT, + "the Repository in scope is not one this run selected, so it names no retained " + + "checkout", + ), + ), + workingDirectory: upsert.workingDirectory, + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + draft: pullRequest.draft, + base: pullRequest.base, + }, + // The session, asked of the adapter that owns it. An upsert names a + // branch this run published rather than a URL a document wrote, so + // what is *allowed* does not reach it — but where the API lives is + // still configuration, and the adapter reads that here rather than at + // installation. + yield* access(), + ); + return outcome.result; + }, + }); + yield* useGitHubPullRequestReads(options); +} diff --git a/packages/cli/src/github-issues-config.ts b/packages/git/src/deno/issue/configuration.ts similarity index 78% rename from packages/cli/src/github-issues-config.ts rename to packages/git/src/deno/issue/configuration.ts index 896267076..01a92a140 100644 --- a/packages/cli/src/github-issues-config.ts +++ b/packages/git/src/deno/issue/configuration.ts @@ -1,14 +1,19 @@ /** - * Where the production host learns which issue trackers it may reach. + * Where this adapter learns which issue trackers it may reach. * * A tracker URL a document writes is composition data: it says where an issue * is wanted, not that this deployment allows it. What allows it is here, beside * the credential, and it is the operator's to state — so it is read from the * environment rather than from anything a run can influence. * - * Absence installs nothing, and that is fail-closed by construction: with no - * provider installed, `` reaches `IssueApi`'s own base error and says - * that nothing handles the destination. A deployment that wants issues says so. + * Absence authorizes nothing, and that is fail-closed by construction: with no + * ceiling, this adapter handles no destination and `` reaches + * `IssueApi`'s own base error, which says that nothing handles it. A deployment + * that wants issues says so. + * + * Read when an invoked GitHub-backed operation needs it, never at startup. + * Installing the Plugin, describing its syntax, validating a Plan and running + * unrelated work read this variable not at all. * * Malformed configuration is refused rather than narrowed to what parsed. An * operator who wrote a ceiling this host could not read has not authorized the @@ -18,8 +23,8 @@ import { env as readEnv } from "@executablemd/runtime"; import type { Operation } from "effection"; -import { canonicalIssueTarget } from "@executablemd/git"; -import type { GitHubIssuesOptions } from "@executablemd/git/deno"; +import { canonicalIssueTarget } from "../../issue/tracker.ts"; +import type { GitHubIssuesConfiguration } from "./github.ts"; /** The variable that configures GitHub issue handling. */ export const GITHUB_ISSUES_ENV = "XMD_WORKFLOW_GITHUB_ISSUES"; @@ -32,7 +37,7 @@ export class GitHubIssuesConfigError extends Error { super( `${GITHUB_ISSUES_ENV} is not usable: ${sentence} Expected JSON such as ` + `{"ceiling":["https://github.com/owner/repo"],"endpoint":"https://api.github.com"} — ` + - "ceiling is required, endpoint is optional. Unset it to install no issue provider.", + "ceiling is required, endpoint is optional. Unset it to authorize no tracker at all.", ); } } @@ -42,10 +47,10 @@ export class GitHubIssuesConfigError extends Error { * * Strict about shape and about the URLs it holds. A ceiling entry that is not * the canonical name of a container is refused here rather than at the first - * request, because an operator reading a startup failure can fix it and a - * document author reading a refusal mid-run cannot. + * request, because a ceiling this host cannot read authorizes nothing and + * saying so is the only honest answer to the operation that asked. */ -export function* gitHubIssuesConfiguration(): Operation { +export function* gitHubIssuesConfiguration(): Operation { const written = yield* readEnv(GITHUB_ISSUES_ENV); if (written === undefined || written === "") { return undefined; diff --git a/packages/git/src/deno/issue/github.ts b/packages/git/src/deno/issue/github.ts index dd8330461..e2f4e598a 100644 --- a/packages/git/src/deno/issue/github.ts +++ b/packages/git/src/deno/issue/github.ts @@ -53,7 +53,6 @@ import type { Operation } from "effection"; import { canonicalFingerprint } from "@executablemd/core"; import { authorizedHeaders, - denoGitHubSource, member, nextPage, nonEmpty, @@ -78,6 +77,7 @@ import { IssueUnavailableError, } from "../../issue/errors.ts"; import { withinIssueCeiling } from "../../issue/tracker.ts"; +import { gitHubIssuesConfiguration } from "./configuration.ts"; import { normalizedTags } from "../../issue/records.ts"; /** The discriminator this adapter answers for. */ @@ -297,7 +297,7 @@ export function recognizesGitHubUrl(target: string): boolean { } } -export interface GitHubIssuesOptions { +export interface GitHubIssuesConfiguration { /** * The canonical targets this host authorizes, as containers. * @@ -308,8 +308,36 @@ export interface GitHubIssuesOptions { readonly ceiling: readonly string[]; /** The API base every request is built against, when not the default. */ readonly endpoint?: string; +} + +/** + * What a host installs this adapter with. + * + * Nothing here is an operator's authorization. The ceiling and the endpoint are + * configuration, and configuration is read when an invoked operation needs it — + * so what a host supplies is only the substitutions a suite makes for the two + * things it cannot arrange: the transport, and the configuration a deployment + * would have written. + */ +export interface GitHubIssuesOptions { /** An injected transport, which outranks any configured endpoint. */ readonly access?: GitHubSource; + /** + * How a source is built when none is injected. + * + * Supplied by the host, never reached for: this implementation knows the + * protocol and not the platform, so the concrete transport and the + * credential behind it arrive from the adapter that owns them. A suite that + * injects `access` needs none of this. + */ + readonly host?: (endpoint?: string) => GitHubSource; + /** + * Configuration stated directly, read instead of the environment. + * + * A suite says what an operator would have written rather than writing it + * into the process it is running in. + */ + readonly configuration?: GitHubIssuesConfiguration; } /** @@ -320,17 +348,44 @@ export interface GitHubIssuesOptions { * it needs no coordination between them, and installing none leaves * `IssueApi`'s own base error to report that nothing handled the request. */ -export function* useGitHubIssues(options: GitHubIssuesOptions): Operation { - // A source rather than an access: it is credential-free, so holding one for - // the middleware's whole lifetime retains nothing. A session — which does have - // an identity — is opened per request below, after that request's ceiling. +export function* useGitHubIssues(options: GitHubIssuesOptions = {}): Operation { + // Resolved when an invoked request turns out to be this adapter's, and never + // at installation. Matching a destination reads nothing outside the process, + // so a command that installs this and writes no `` — or writes one + // somewhere else — reads no configuration and obtains no credential. // - // Precedence: an injected transport, then a configured endpoint, then the - // platform's own GitHub. A suite that supplies its own access is not asking - // for a different endpoint as well. - const source = - options.access ?? - (options.endpoint === undefined ? denoGitHubSource() : denoGitHubSource(options.endpoint)); + // Memoized including its absence: an unset variable is an answer, and asking + // the environment again on the next request would be asking a question this + // adapter has already had answered. + let settled: { readonly configuration: GitHubIssuesConfiguration | undefined } | undefined; + let opened: GitHubSource | undefined; + + function* authorized(): Operation { + settled ??= { + configuration: options.configuration ?? (yield* gitHubIssuesConfiguration()), + }; + return settled.configuration; + } + + /** + * The transport for this adapter, built once. + * + * A source rather than an access: it is credential-free, so holding one for + * the middleware's whole lifetime retains nothing. A session — which does have + * an identity — is opened per request below, after that request's ceiling. + * + * Precedence: an injected transport, then the host's own, built against + * whatever endpoint this deployment configured. A suite that supplies its + * own access is not asking for a different endpoint as well. With neither, + * this adapter has no way to reach anything and says so. + */ + function transport(configuration: GitHubIssuesConfiguration): GitHubSource { + opened ??= options.access ?? options.host?.(configuration.endpoint); + if (opened === undefined) { + throw new IssueUnavailableError(); + } + return opened; + } yield* IssueApi.around( { @@ -342,10 +397,18 @@ export function* useGitHubIssues(options: GitHubIssuesOptions): Operation if (!mine) { return yield* next(url, read); } + // The destination is this adapter's, so now — and only now — what this + // deployment authorized is read. Nothing authorized is the same answer + // as no adapter at all: the request is passed on, and `IssueApi`'s base + // says that nothing handles it. + const configuration = yield* authorized(); + if (configuration === undefined) { + return yield* next(url, read); + } // From here this middleware owns the answer, and the ceiling is asked // before anything is built: a URL a document wrote is not a place this // host authorized until the ceiling says so. - if (!withinIssueCeiling(options.ceiling, url)) { + if (!withinIssueCeiling(configuration.ceiling, url)) { throw new IssueUnavailableError(); } if (issue === undefined) { @@ -353,7 +416,7 @@ export function* useGitHubIssues(options: GitHubIssuesOptions): Operation } // After the ceiling, never before: a session opened first would be an // identity established for a target this host had not authorized. - return yield* observed(yield* source.open(), issue, url); + return yield* observed(yield* transport(configuration).open(), issue, url); }, *upsert([issue, upsert], next): Operation { @@ -365,9 +428,17 @@ export function* useGitHubIssues(options: GitHubIssuesOptions): Operation if (!mine) { return yield* next(issue, upsert); } + // The destination is this adapter's, so now — and only now — what this + // deployment authorized is read. Nothing authorized is the same answer + // as no adapter at all: the request is passed on, and `IssueApi`'s base + // says that nothing handles it. + const configuration = yield* authorized(); + if (configuration === undefined) { + return yield* next(issue, upsert); + } // From here this middleware owns the answer. A refusal is the end of // the request rather than a reason to let somebody else try. - if (!withinIssueCeiling(options.ceiling, upsert.url)) { + if (!withinIssueCeiling(configuration.ceiling, upsert.url)) { throw new IssueUnavailableError(); } // Named outright but not a repository issue collection: this adapter @@ -376,7 +447,7 @@ export function* useGitHubIssues(options: GitHubIssuesOptions): Operation if (name === undefined) { throw new IssueUnavailableError(); } - return yield* reconcile(yield* source.open(), name, issue, upsert); + return yield* reconcile(yield* transport(configuration).open(), name, issue, upsert); }, }, { at: "min" }, diff --git a/packages/git/src/deno/run-composition/provider.ts b/packages/git/src/deno/run-composition/provider.ts index 23de36448..b8ceb009e 100644 --- a/packages/git/src/deno/run-composition/provider.ts +++ b/packages/git/src/deno/run-composition/provider.ts @@ -84,8 +84,10 @@ import { currentBranch, gitSession, resolveCommit, type GitSession } from "../co import { denoRepositoryHost, type RepositoryHost } from "../composition/host.ts"; import type { GitAuthentication } from "../composition/authentication.ts"; import type { HelperAssembly } from "../composition/credential-helper.ts"; -import { denoGitHubSource, type GitHubSource } from "../composition/github.ts"; +import type { GitHubSource } from "../composition/github.ts"; +import { denoGitHubSource } from "../composition/github-host.ts"; import { + gitHubPullRequestAccess, useGitHubPullRequestReads, type GitHubPullRequestsOptions, } from "../composition/pull-request-reads.ts"; @@ -368,12 +370,17 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { const refusal = yield* scoped(function* () { yield* useGitHubIssues({ - ceiling: ["https://github.com/octo/authorized"], access: source, + configuration: { ceiling: ["https://github.com/octo/authorized"] }, }); return yield* raised( IssueApi.operations.read("https://github.com/octo/elsewhere/issues/7", { @@ -1370,8 +1371,11 @@ describe("workflow GitHub source sessions", () => { const details = yield* scoped(function* () { yield* useGitHubIssues({ - ceiling: ["https://github.com/octo/project"], - endpoint: server.url, + // The host's own transport, exactly as the Deno adapter supplies it. + // What this case measures is that the base the adapter builds against + // is the configured one, so the factory has to be the real one. + host: denoGitHubSource, + configuration: { ceiling: ["https://github.com/octo/project"], endpoint: server.url }, }); return yield* IssueApi.operations.read("https://github.com/octo/project/issues/7", { provider: GITHUB, diff --git a/packages/git/tests/github-activation.test.ts b/packages/git/tests/github-activation.test.ts new file mode 100644 index 000000000..a791f8ecc --- /dev/null +++ b/packages/git/tests/github-activation.test.ts @@ -0,0 +1,360 @@ +/** + * When the bundled GitHub adapter is allowed to touch anything outside this + * process. + * + * Installing the Plugin makes the vocabulary available. It does not read a + * variable, obtain a credential or open a socket, and neither does describing + * the vocabulary, validating a Plan, or running a document that writes none of + * it. The first invoked GitHub-backed operation is what changes that, and even + * then it happens in one order: + * + * installation → target recognition → configuration → credential → transport + * + * Everything driven through the *real* host assembly — the ordinary-run + * profile and a retained workflow run — lives in + * `github-workflow-activation.test.ts`, because assembling either reaches + * Workflow's storage adapter and so `node:sqlite`. What is here installs the + * adapters directly and is portable across all three runtimes. + * + * Every step is observed here through a boundary the case installs itself, and + * the negative cases install boundaries that *throw*. A spy that merely counts + * would let an unwanted read pass and be discovered by an assertion afterwards; + * one that throws refuses it where it happens, and the case that expected the + * read still gets its answer. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import { API } from "@executablemd/runtime"; +import type { Operation } from "effection"; +import { collect, inlineSource } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { gitPlugin } from "../src/plugin.ts"; +import { IssueApi } from "../src/issue/api.ts"; +import { GITHUB, useGitHubIssues } from "../src/deno/issue/github.ts"; +import { useGitHubPullRequestReads } from "../src/deno/composition/pull-request-reads.ts"; +import { GITHUB_PULL_REQUESTS_ENV } from "../src/deno/composition/pull-request-configuration.ts"; +import { PullRequestAPI } from "../src/composition/pull-request-api.ts"; +import type { + GitHubAccess, + GitHubHttpResponse, + GitHubSource, +} from "../src/deno/composition/github.ts"; +import { GITHUB_ISSUES_ENV } from "../src/deno/issue/configuration.ts"; +import { fakeGitHubAccess, gitHubStore } from "./support/github.ts"; + +/** Where every step this suite watches records itself, in the order it happened. */ +type Step = "configuration" | "source" | "open" | "credential" | "transport"; + +/** A transport factory that refuses to be built at all. */ +function forbidden(): GitHubSource { + throw new Error("a GitHub transport was built"); +} + +/** + * A host whose every outward boundary refuses. + * + * Installed around the negative cases. Each one names what it caught, so a + * failure says which boundary was crossed rather than only that one was. + */ +function* refusing(): Operation { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name.startsWith("XMD_WORKFLOW_GITHUB")) { + throw new Error(`configuration was read: ${name}`); + } + return yield* next(name); + }, + }, + { at: "min" }, + ); +} + +/** + * The established GitHub fake, with each boundary crossing recorded as it + * happens. + * + * Wrapped rather than re-implemented: what this case is about is the *order* + * the boundaries are crossed in, and a hand-written payload would be asserting + * the shape of my own fixture instead. + */ +function recording(steps: Step[]): { host: (endpoint?: string) => GitHubSource } { + const store = gitHubStore({ + issues: [{ nodeId: "I_1", number: 7, state: "open", title: "a title", body: null }], + }); + const answering = fakeGitHubAccess(store); + const access: GitHubAccess = { + endpoint: answering.endpoint, + *token(): Operation { + steps.push("credential"); + return yield* answering.token(); + }, + *send(request): Operation { + steps.push("transport"); + return yield* answering.send(request); + }, + }; + return { + host: () => { + steps.push("source"); + return { + endpoint: access.endpoint, + // deno-lint-ignore require-yield + *open(): Operation { + steps.push("open"); + return access; + }, + }; + }, + }; +} + +/** + * A transport that answers one reviews collection, recording each crossing. + * + * Purpose-built rather than borrowed: the shared GitHub fake answers issue and + * pull-request mutation endpoints and no evidence ones, and what this case + * needs is a complete — if empty — collection so the read succeeds and the + * ordering is the only thing under test. + */ +function reviewing(steps: Step[]): (endpoint?: string) => GitHubSource { + const endpoint = "https://api.github.test"; + const access: GitHubAccess = { + endpoint, + // deno-lint-ignore require-yield + *token(): Operation { + steps.push("credential"); + return "a-token"; + }, + // deno-lint-ignore require-yield + *send(request): Operation { + steps.push("transport"); + const { pathname } = new URL(request.url); + if (pathname !== "/repos/octo/project/pulls/7/reviews") { + throw new Error(`the adapter asked for ${pathname}`); + } + return { status: 200, body: "[]" }; + }, + }; + return () => { + steps.push("source"); + return { + endpoint, + // deno-lint-ignore require-yield + *open(): Operation { + steps.push("open"); + return access; + }, + }; + }; +} + +/** One document, executed with the bundled Plugin installed and nothing else. */ +function* document(source: string): Operation { + const stream = new InMemoryStream(); + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const contribution = yield* install.call(gitPlugin, { command: "run", args: ["run"] }); + return yield* collect( + yield* executeInstalled({ ...inlineSource(source), stream }, [ + { admissions: [...(contribution?.admissions ?? [])] }, + ]), + ); +} + +describe("what installing the bundled GitHub adapter costs", () => { + it("reads no configuration when the Plugin is installed and nothing is invoked", function* () { + // The whole contribution — declarations and admissions — with every + // outward boundary refusing. Installation is the thing under test. + yield* refusing(); + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const contribution = yield* install.call(gitPlugin, { command: "run", args: ["run"] }); + expect(contribution?.admissions).toHaveLength(2); + // And the adapters themselves, installed as the host installs them. + yield* useGitHubIssues({ + host: () => ({ + endpoint: "https://api.github.test", + *open(): Operation { + throw new Error("a credential was obtained"); + }, + }), + }); + }); + + it("reads no configuration for a document that writes no GitHub vocabulary", function* () { + const rendered = yield* scoped(function* () { + yield* refusing(); + yield* useGitHubIssues({ + host: () => ({ + endpoint: "https://api.github.test", + *open(): Operation { + throw new Error("a credential was obtained"); + }, + }), + }); + return yield* document("nothing here reaches a service\n"); + }); + expect(String(rendered)).toContain("nothing here reaches a service"); + }); + + it("reads no configuration for a destination another provider owns", function* () { + // Target recognition comes first, and it reads nothing outside the + // process. A tracker somewhere else therefore passes this adapter without + // its configuration ever being consulted — which is what lets a second + // adapter be installed beside it. + const failure = yield* scoped(function* () { + yield* refusing(); + yield* useGitHubIssues({ + host: () => ({ + endpoint: "https://api.github.test", + *open(): Operation { + throw new Error("a credential was obtained"); + }, + }), + }); + try { + yield* IssueApi.operations.read("https://tracker.example/browse/AB-1", {}); + return undefined; + } catch (error) { + return error; + } + }); + // It reached `IssueApi`'s own base, which is what "nothing handles this" + // means — not a refusal this adapter made after reading something. + expect(String(failure)).toContain("no issue provider"); + }); + + it("crosses configuration, credential and transport in that order, once", function* () { + const steps: Step[] = []; + const { host } = recording(steps); + const details = yield* scoped(function* () { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name !== GITHUB_ISSUES_ENV) { + return yield* next(name); + } + steps.push("configuration"); + return JSON.stringify({ ceiling: ["https://github.com/octo/project"] }); + }, + }, + { at: "min" }, + ); + yield* useGitHubIssues({ host }); + return yield* IssueApi.operations.read("https://github.com/octo/project/issues/7", { + provider: GITHUB, + }); + }); + + expect(steps).toEqual(["configuration", "source", "open", "credential", "transport"]); + expect(details.url).toBe("https://github.com/octo/project/issues/7"); + }); + + it("crosses the same boundaries in order for a pull-request read", function* () { + // The pull-request half has its own lazy configuration and its own + // transport resolution — the `resolver()`/`transports()` pair behind + // `gitHubPullRequestAccess()` — so proving the issue adapter's ordering + // proves nothing about this one. A reviews read is enough: what is under + // test is when each boundary is crossed, not that a pull request changed. + const steps: Step[] = []; + const host = reviewing(steps); + const reviews = yield* scoped(function* () { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name !== GITHUB_PULL_REQUESTS_ENV) { + return yield* next(name); + } + steps.push("configuration"); + return JSON.stringify({ allowed: ["https://github.com/octo/project"] }); + }, + }, + { at: "min" }, + ); + yield* useGitHubPullRequestReads({ host }); + return yield* PullRequestAPI.operations.read("https://github.com/octo/project/pull/7", { + kind: "reviews", + }); + }); + + // Target recognition comes first and records nothing, because it reads + // nothing: the case above that names another provider's destination + // records an empty sequence, which is what proves the order begins there. + expect(steps).toEqual(["configuration", "source", "open", "credential", "transport"]); + // The collection really was read — a refusal would have thrown, and an + // adapter that answered without asking would record no transport. + expect(`${reviews.kind}:${reviews.items.length}`).toBe("reviews:0"); + }); + + it("refuses outside the ceiling without opening a credential", function* () { + const steps: Step[] = []; + const { host } = recording(steps); + const failure = yield* scoped(function* () { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name !== GITHUB_ISSUES_ENV) { + return yield* next(name); + } + steps.push("configuration"); + return JSON.stringify({ ceiling: ["https://github.com/octo/elsewhere"] }); + }, + }, + { at: "min" }, + ); + yield* useGitHubIssues({ host }); + try { + yield* IssueApi.operations.read("https://github.com/octo/project/issues/7", { + provider: GITHUB, + }); + return undefined; + } catch (error) { + return error; + } + }); + + expect(failure).toBeDefined(); + // Configuration was read, because the target was this adapter's. Nothing + // after it was: a refusal at the ceiling is a refusal before an identity + // exists for the target that was refused. + expect(steps).toEqual(["configuration"]); + }); + + it("refuses with no configuration at all before any credential", function* () { + const steps: Step[] = []; + const { host } = recording(steps); + const failure = yield* scoped(function* () { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *env(): Operation { + steps.push("configuration"); + return undefined; + }, + }, + { at: "min" }, + ); + yield* useGitHubIssues({ host }); + try { + yield* IssueApi.operations.read("https://github.com/octo/project/issues/7", { + provider: GITHUB, + }); + return undefined; + } catch (error) { + return error; + } + }); + + // Nothing authorized is the same answer as no adapter at all. + expect(String(failure)).toContain("no issue provider"); + expect(steps).toEqual(["configuration"]); + }); +}); diff --git a/packages/git/tests/issue-github.test.ts b/packages/git/tests/github-issues.test.ts similarity index 100% rename from packages/git/tests/issue-github.test.ts rename to packages/git/tests/github-issues.test.ts diff --git a/packages/git/tests/pull-request-github.test.ts b/packages/git/tests/github-pull-requests.test.ts similarity index 99% rename from packages/git/tests/pull-request-github.test.ts rename to packages/git/tests/github-pull-requests.test.ts index 12fc8254c..385d3aaef 100644 --- a/packages/git/tests/pull-request-github.test.ts +++ b/packages/git/tests/github-pull-requests.test.ts @@ -14,7 +14,6 @@ import process from "node:process"; import type { Operation } from "effection"; import type { RepositoryIdentity } from "../src/composition/selection.ts"; import { - denoGitHubAccess, gitHubPullRequests, openSnapshot, readPullRequest, @@ -25,6 +24,7 @@ import { type GitHubHttpRequest, type GitHubHttpResponse, } from "../src/deno/composition/github.ts"; +import { denoGitHubAccess } from "../src/deno/composition/github-host.ts"; import type { PullRequestInputs, PullRequestSnapshot, diff --git a/packages/git/tests/github-workflow-activation.test.ts b/packages/git/tests/github-workflow-activation.test.ts new file mode 100644 index 000000000..75175b340 --- /dev/null +++ b/packages/git/tests/github-workflow-activation.test.ts @@ -0,0 +1,145 @@ +/** + * The real host assembly, with every outward boundary refusing. + * + * The companion to `github-activation.test.ts`. What is here drives the + * assemblies a command actually installs — the ordinary-run profile, and a + * retained source-bundle run through the Workflow host — rather than the + * adapters on their own. Both reach Workflow's storage adapter and so + * `node:sqlite`, which Bun does not have and Node keeps behind a flag, so this + * file is Deno's alone and the portable ordering cases stay next door. + * + * Installing any of it must read no variable, obtain no credential and open no + * socket. The boundaries here therefore *throw* rather than count: a crossing + * fails where it happens. + * + * Syntax and Plan are the CLI's own surfaces and are exercised for real in + * `packages/cli/tests/github-zero-effect.test.ts`, which renders the catalog + * and describes the Plan component under the same refusing boundary. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { useRunComposition } from "../src/deno/run-composition/provider.ts"; +import { gitPlugin } from "../src/plugin.ts"; +import { collect, inlineSource } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import type { GitHubSource } from "../src/deno/composition/github.ts"; +import { runWorkflowDocument } from "./support/composition.ts"; +import { + BUNDLE_SOURCE, + sourceBundleCreation, + useStorageRoot, + withExecutorRun, + withRunHost, +} from "../../workflow/tests/support/storage.ts"; + +/** A transport factory that refuses to be built at all. */ +function forbidden(): GitHubSource { + throw new Error("a GitHub transport was built"); +} + +/** A host whose configuration boundary refuses to be read. */ +function* refusing(): Operation { + yield* API.Env.around( + { + *env([name], next): Operation { + if (name.startsWith("XMD_WORKFLOW_GITHUB")) { + throw new Error(`configuration was read: ${name}`); + } + return yield* next(name); + }, + }, + { at: "min" }, + ); +} + +/** One document, executed with the bundled Plugin installed and nothing else. */ +function* document(source: string): Operation { + const stream = new InMemoryStream(); + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + const contribution = yield* install.call(gitPlugin, { command: "run", args: ["run"] }); + return yield* collect( + yield* executeInstalled({ ...inlineSource(source), stream }, [ + { admissions: [...(contribution?.admissions ?? [])] }, + ]), + ); +} + +describe("the bundled profile, assembled", () => { + it("installs the whole ordinary-run profile without reading any of it", function* () { + // The real assembly this time, not the adapters alone: `useRunComposition` + // is what `xmd run` installs, and it brings the repository provider, the + // Git capability, both GitHub adapters and the retained lifecycles with it. + // Every outward boundary refuses, so installing any of them wrongly is a + // failure here rather than a count checked afterwards. + const rendered = yield* scoped(function* () { + yield* refusing(); + yield* useRunComposition({ + root: yield* useTempDirectory("xmd-zero-root"), + cwd: yield* useTempDirectory("xmd-zero-cwd"), + gitHubIssues: { host: forbidden }, + gitHubPullRequests: { host: forbidden }, + }); + return yield* document("a plain document that reaches nothing\n"); + }); + expect(String(rendered)).toContain("a plain document that reaches nothing"); + }); + + it("does local Git work without reading any GitHub configuration", function* () { + // `` is Git's own and reaches no service. A profile that read GitHub + // configuration to do local work would be reading it for every run. + const rendered = yield* scoped(function* () { + yield* refusing(); + yield* useHostFiles(); + yield* useRunComposition({ + root: yield* useTempDirectory("xmd-zero-root"), + cwd: yield* useTempDirectory("xmd-zero"), + gitHubIssues: { host: forbidden }, + gitHubPullRequests: { host: forbidden }, + }); + return yield* document('local work only\n'); + }); + expect(String(rendered)).toContain("local work only"); + }); + + it("runs a version-2 source-bundle workflow outside Git without reaching GitHub", function* () { + // The real lifecycle, not a test-shaped run: a source-bundle creation + // begun through the same transitions `xmd workflow start` uses, so what + // executes below is a retained version-2 definition rather than a v1 Git + // run that happens to render. + const root = yield* useStorageRoot(); + yield* withRunHost(root, function* (transitions) { + const creation = yield* sourceBundleCreation(); + return yield* withExecutorRun( + transitions, + { runId: "outside-git", action: "start", creation }, + function* (begun) { + // Pinned so this cannot regress to a v1 run silently. A v1 record + // would render the same and prove nothing about the bundle path. + expect(begun.database.record.definition.version).toBe(2); + + const rendered = yield* scoped(function* () { + yield* refusing(); + return yield* runWorkflowDocument(begun.database, BUNDLE_SOURCE, { + gitHubIssues: { host: forbidden }, + gitHubPullRequests: { host: forbidden }, + }); + }); + + expect(String(rendered)).toContain("this run retains these exact bytes"); + // The run really executed: a document that never ran would render + // nothing and satisfy every refusal above by doing nothing at all. + expect(yield* begun.database.journal.readAll()).not.toEqual([]); + }, + ); + }); + }); +}); diff --git a/packages/git/tests/issue-markdown.test.ts b/packages/git/tests/issue-markdown.test.ts index c578ac506..1a8e17db9 100644 --- a/packages/git/tests/issue-markdown.test.ts +++ b/packages/git/tests/issue-markdown.test.ts @@ -8,7 +8,7 @@ * * What stays in TypeScript is what a document cannot construct: GitHub payload * parsing, pagination, marker reconciliation and serialization live in - * `issue-github.test.ts`, and nothing there duplicates a scenario here. + * `github-issues.test.ts`, and nothing there duplicates a scenario here. */ import { describe, it } from "@executablemd/test-support/bdd"; diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts new file mode 100644 index 000000000..6b49c026b --- /dev/null +++ b/packages/git/tests/module-partition.test.ts @@ -0,0 +1,407 @@ +/** + * Which of this package's three halves each production module belongs to. + * + * `@executablemd/git` ships one Plugin and three kinds of module, and the + * difference between them is a boundary rather than a directory: + * + * - **provider-neutral** — the contracts, records and effects every adapter is + * written against. They name no Git host at all, because a shared contract + * that names one has chosen it. + * - **GitHub implementation** — protocol, parsing, matching, lazy + * configuration, normalization and provider behaviour. It knows GitHub and + * not the platform: the transport and the credential behind it arrive as an + * inert factory the host supplies. + * - **Deno adapter** — environment, credential-helper invocation, subprocess, + * temporary paths, concrete `fetch`, and the orchestration that holds a run + * database. The only set permitted to name a concrete host operation. + * + * The sets are **declared here, not inferred**. Reading ownership off the + * imports a module happens to have would make this scan agree with whatever + * the code does, which is the one thing a boundary test must not do. So every + * module is named below, and a module named nowhere fails: that is what stops + * a new file from quietly joining whichever set its imports resemble. + */ + +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { code, moduleSpecifiers, parse } from "@executablemd/test-support/host-boundary"; +import { readTextFile } from "@effectionx/fs"; +import { glob } from "@executablemd/runtime"; +import type { Operation } from "effection"; + +const REPOSITORY = fileURLToPath(new URL("../../..", import.meta.url)); +const PACKAGE = "packages/git"; + +/** + * The provider-neutral half, named one module at a time. + * + * The contracts, records and effects every adapter is written against. Named + * rather than described as "everything outside `src/deno/`", because a set + * computed from where a file sits can never have an unclassified member: a new + * module would join it by existing, which is the one thing this scan is for. + */ +const PROVIDER_NEUTRAL: readonly string[] = [ + "mod.ts", + "src/composition/api.ts", + "src/composition/components/Dir.ts", + "src/composition/components/GitAdd.ts", + "src/composition/components/GitCommit.ts", + "src/composition/components/GitPush.ts", + "src/composition/components/GitSwitch.ts", + "src/composition/components/Issue.ts", + "src/composition/components/IssueTracker.ts", + "src/composition/components/PullRequest.ts", + "src/composition/components/PullRequestReads.ts", + "src/composition/components/Repository.ts", + "src/composition/components/Worktree.ts", + "src/composition/context.ts", + "src/composition/definitions.ts", + "src/composition/errors.ts", + "src/composition/git-api.ts", + "src/composition/git-push-records.ts", + "src/composition/git-records.ts", + "src/composition/installation.ts", + "src/composition/parse.ts", + "src/composition/pull-request-api.ts", + "src/composition/pull-request-operations.ts", + "src/composition/pull-request-read-execution.ts", + "src/composition/pull-request-read-records.ts", + "src/composition/pull-request-records.ts", + "src/composition/pull-request-target.ts", + "src/composition/push-evidence.ts", + "src/composition/records.ts", + "src/composition/selection.ts", + "src/git-host/api.ts", + "src/git-host/effect-type.ts", + "src/git-host/effect.ts", + "src/git-host/errors.ts", + "src/git-host/identities.ts", + "src/git-host/records.ts", + "src/git.ts", + "src/identities.ts", + "src/installation.ts", + "src/issue/api.ts", + "src/issue/context.ts", + "src/issue/effect-type.ts", + "src/issue/effect.ts", + "src/issue/errors.ts", + "src/issue/identities.ts", + "src/issue/operations.ts", + "src/issue/records.ts", + "src/issue/tracker.ts", + "src/plugin.ts", +]; + +/** + * The GitHub implementation, named one module at a time. + * + * Every one of these is GitHub and nothing else. None of them may reach the + * platform: what they are handed is a `GitHubAccess` or a factory for one, and + * what they answer with is a normalized value the provider-neutral contracts + * above them already describe. + */ +const INTERNAL: readonly string[] = [ + "gitHubPullRequestAccess", + "denoGitHubAccess", + "denoGitHubLogin", + "pullRequestProvider", + "gitHubIssuesConfiguration", + "gitHubPullRequestsConfiguration", +]; + +const GITHUB_IMPLEMENTATION: readonly string[] = [ + "src/deno/composition/github.ts", + "src/deno/composition/github-pull-request.ts", + "src/deno/composition/pull-request-configuration.ts", + "src/deno/composition/pull-request-evidence.ts", + "src/deno/composition/pull-request-reads.ts", + "src/deno/issue/configuration.ts", + "src/deno/issue/github.ts", +]; + +/** + * The Deno adapter, named one module at a time. + * + * Concrete host operations, and the orchestration that terminates the Workflow + * dependency before a GitHub module is handed anything. This is the only set a + * `node:` import, a host global or a subprocess may appear in. + */ +const DENO_ADAPTER: readonly string[] = [ + "deno.ts", + "credential-helper.ts", + "src/deno/attachment.ts", + "src/deno/repositories.ts", + "src/deno/selections.ts", + "src/deno/composition/add.ts", + "src/deno/composition/authentication.ts", + "src/deno/composition/commit.ts", + "src/deno/composition/credential-helper.ts", + "src/deno/composition/effects.ts", + "src/deno/composition/git.ts", + "src/deno/composition/github-host.ts", + "src/deno/composition/host.ts", + "src/deno/composition/identity.ts", + "src/deno/composition/locator.ts", + "src/deno/composition/materialize.ts", + "src/deno/composition/object-source.ts", + "src/deno/composition/operations.ts", + "src/deno/composition/placement.ts", + "src/deno/composition/provider.ts", + "src/deno/composition/pull-request-operations.ts", + "src/deno/composition/pull-request.ts", + "src/deno/composition/push.ts", + "src/deno/composition/refusals.ts", + "src/deno/composition/repository.ts", + "src/deno/composition/subprocess.ts", + "src/deno/composition/switch.ts", + "src/deno/composition/worktree.ts", + "src/deno/run-composition/ambient.ts", + "src/deno/run-composition/checkouts.ts", + "src/deno/run-composition/errors.ts", + "src/deno/run-composition/identity.ts", + "src/deno/run-composition/leases.ts", + "src/deno/run-composition/metadata.ts", + "src/deno/run-composition/operations.ts", + "src/deno/run-composition/placement.ts", + "src/deno/run-composition/provider.ts", + "src/deno/run-composition/pull-request.ts", +]; + +/** + * How many of the declared lists name each module. + * + * Counted across all three at once. Comparing them pairwise would miss a + * module named twice inside one list, and a count is what lets "exactly one" + * mean it: zero is unclassified, two is ambiguous, and neither is a partition. + */ +function memberships(lists: readonly (readonly string[])[]): Map { + const counted = new Map(); + for (const list of lists) { + for (const entry of list) { + counted.set(entry, (counted.get(entry) ?? 0) + 1); + } + } + return counted; +} + +/** The modules a set of lists does not classify exactly once. */ +function misclassified(counted: Map): [string, number][] { + return [...counted].filter(([, count]) => count !== 1); +} + +/** + * The subpaths a manifest's `exports` declares, or nothing when it declares no + * readable map. + * + * Narrowed by asking the value what it is rather than telling the compiler. + * The distinction matters here: this case's claim is about what the export map + * admits, and a manifest this could not read would otherwise answer "no + * subpaths" and satisfy the claim by accident. + */ +function subpaths(declared: unknown): string[] | undefined { + if (typeof declared !== "object" || declared === null) { + return undefined; + } + const exports: unknown = Reflect.get(declared, "exports"); + if (typeof exports !== "object" || exports === null) { + return undefined; + } + return Object.keys(exports); +} + +/** Where a module the scan finds is resolved from. */ +function path(relative: string): string { + return `${PACKAGE}/${relative}`; +} + +/** Every production module this package ships, as the filesystem holds them. */ +function* production(): Operation { + const found = yield* glob({ + root: REPOSITORY, + patterns: [ + `${PACKAGE}/mod.ts`, + `${PACKAGE}/deno.ts`, + `${PACKAGE}/credential-helper.ts`, + `${PACKAGE}/src/**/*.ts`, + ], + }); + return found.map((entry) => entry.path).sort(); +} + +/** The specifiers a module loads, resolved to repository paths where local. */ +function* loaded(relative: string): Operation { + const source = yield* readTextFile(join(REPOSITORY, path(relative))); + const parsed = parse(source); + // A parse that failed would report every module as importing nothing, and a + // boundary that admits what it cannot read is not a boundary. + if (/^import\s/m.test(source)) { + expect(`${relative}: ${moduleSpecifiers(parsed.file).length > 0}`).toBe(`${relative}: true`); + } + return moduleSpecifiers(parsed.file); +} + +describe("the three halves of @executablemd/git", () => { + it("assign every production module to exactly one declared set", function* () { + const modules = yield* production(); + const neutral = PROVIDER_NEUTRAL.map(path); + const github = GITHUB_IMPLEMENTATION.map(path); + const adapter = DENO_ADAPTER.map(path); + + // Non-empty, each of them. A set that matched nothing would satisfy every + // rule below without constraining a single module. + expect(neutral.length > 0).toBe(true); + expect(github.length > 0).toBe(true); + expect(adapter.length > 0).toBe(true); + + // The counting has to be able to fail, or an empty report means nothing. + // A module named by two lists, and one named twice by a single list, are + // both ambiguous ownership and both are caught. + expect(misclassified(memberships([["a.ts"], ["a.ts"]]))).toEqual([["a.ts", 2]]); + expect(misclassified(memberships([["a.ts", "a.ts"], []]))).toEqual([["a.ts", 2]]); + expect(misclassified(memberships([["a.ts"], ["b.ts"]]))).toEqual([]); + + // Exactly one membership each, across all three lists at once. + const counted = memberships([neutral, github, adapter]); + expect(misclassified(counted)).toEqual([]); + + // Complete. A module named in no set is the failure this exists for: it is + // how a new file joins whichever half its imports resemble. + expect(modules.filter((entry) => !counted.has(entry))).toEqual([]); + // And every declared name is a module that exists, so a rename cannot + // silently empty a set. + const present = new Set(modules); + expect([...neutral, ...github, ...adapter].filter((entry) => !present.has(entry))).toEqual([]); + }); + + it("keep the GitHub implementation free of the platform and of Workflow", function* () { + const crossings: Record = {}; + for (const relative of GITHUB_IMPLEMENTATION) { + const source = yield* readTextFile(join(REPOSITORY, path(relative))); + const scanned = code(source); + const specifiers = yield* loaded(relative); + const named: string[] = []; + + for (const specifier of specifiers) { + // Workflow and the CLI are above this set, not beside it. The host half + // is beside it and must arrive as a capability rather than an import. + if (/^@executablemd\/(workflow|cli)(\/|$)/.test(specifier)) { + named.push(specifier); + } + if (/^node:/.test(specifier)) { + named.push(specifier); + } + if (specifier === "@effectionx/process") { + named.push(specifier); + } + if (/(^|\/)github-host\.ts$/.test(specifier)) { + named.push(specifier); + } + if (/(^|\/)subprocess\.ts$/.test(specifier)) { + named.push(specifier); + } + if (/\/workflow\/src\//.test(specifier)) { + named.push(specifier); + } + } + // Host globals and the platform's own transport, read from code with its + // prose removed — these modules explain in comments that they reach none + // of them. + for (const global of ["Deno.", "process.", "Bun.", "__dirname"]) { + if (scanned.includes(global)) { + named.push(global); + } + } + if (/(^|[^.\w])fetch\s*\(/.test(scanned)) { + named.push("fetch("); + } + if (named.length > 0) { + crossings[relative] = named; + } + } + expect(crossings).toEqual({}); + }); + + it("keep GitHub out of the provider-neutral half", function* () { + const implementation = new Set(GITHUB_IMPLEMENTATION.map((entry) => entry.split("/").pop())); + const reaching: Record = {}; + for (const relative of PROVIDER_NEUTRAL) { + const specifiers = yield* loaded(relative); + const named = specifiers.filter((specifier) => { + const last = specifier.split("/").pop(); + return ( + /(^|\/)deno(\/|$)/.test(specifier) || (last !== undefined && implementation.has(last)) + ); + }); + if (named.length > 0) { + reaching[relative] = named; + } + } + expect(reaching).toEqual({}); + }); + + it("leave concrete host operations to the Deno adapter alone", function* () { + // The set is not merely non-empty: it is where the host actually is. If no + // adapter named a concrete host operation, the rule above would be holding + // the GitHub half to a standard nothing else in the package meets. + const naming: string[] = []; + for (const relative of DENO_ADAPTER) { + const scanned = code(yield* readTextFile(join(REPOSITORY, path(relative)))); + if ( + scanned.includes("process.") || + scanned.includes("Deno.") || + /(^|[^.\w])fetch\s*\(/.test(scanned) || + scanned.includes("node:") + ) { + naming.push(relative); + } + } + expect(naming.length > 0).toBe(true); + expect(naming).toContain("src/deno/composition/github-host.ts"); + }); + + it("names no seam in either entrypoint's own source", function* () { + // The export map is one route and a re-export is another: a name absent + // from the runtime namespace but written into `deno.ts` is a line waiting + // to be uncommented, so the source is read too. + for (const entry of ["mod.ts", "deno.ts"]) { + const source = yield* readTextFile(join(REPOSITORY, PACKAGE, entry)); + const exported = source + .split("\n") + .filter((line) => line.startsWith("export")) + .join("\n"); + expect(`${entry}: ${exported.length > 0}`).toBe(`${entry}: true`); + for (const name of INTERNAL) { + expect(`${entry} exports ${name}: ${exported.includes(name)}`).toBe( + `${entry} exports ${name}: false`, + ); + } + } + }); + + it("admits exactly the two entrypoints the manifests declare", function* () { + // A third subpath would be a third contract. `./credential-helper` is the + // one beside them, and it is Git's own rather than GitHub's. + for (const manifest of ["deno.json", "package.json"]) { + const declared: unknown = JSON.parse( + yield* readTextFile(join(REPOSITORY, PACKAGE, manifest)), + ); + // Narrowed rather than asserted: a manifest whose `exports` was missing, + // or was not an object, would otherwise satisfy both checks below by + // having no keys at all. `subpaths()` answers `undefined` for anything it + // cannot read, and `undefined` is what the next line refuses. + const declaredSubpaths = subpaths(declared); + expect(`${manifest}: ${declaredSubpaths !== undefined}`).toBe(`${manifest}: true`); + const names = declaredSubpaths ?? []; + expect(`${manifest}: ${names.toSorted().join(" ")}`).toBe( + `${manifest}: . ./credential-helper ./deno`, + ); + // And no subpath names GitHub: the implementation ships inside this + // package rather than beside it. + expect(`${manifest}: ${names.filter((key) => /github/i.test(key)).length}`).toBe( + `${manifest}: 0`, + ); + } + }); +}); diff --git a/packages/git/tests/public-entrypoint.test.ts b/packages/git/tests/public-entrypoint.test.ts new file mode 100644 index 000000000..8395d1a04 --- /dev/null +++ b/packages/git/tests/public-entrypoint.test.ts @@ -0,0 +1,84 @@ +/** + * What `@executablemd/git` publishes, and what it deliberately does not. + * + * The package ships GitHub inside itself, so "GitHub is internal" cannot mean + * "no GitHub name is exported" — several are, and they are contracts a host + * genuinely needs: the discriminators a document writes as `provider=`, the + * recognizers a host asks before configuring a ceiling, and the two installers + * a trusted entrypoint calls. + * + * What must not leak is the *seam* — the internal arrangement by which the + * implementation is handed a transport. `gitHubPullRequestAccess()` is one of + * those: it exists so the orchestration half can ask the GitHub half for a + * session, and publishing it would turn a package-local capability into + * something a consumer could hold and hand somebody else. + * + * Read through the bare specifiers rather than by relative path, because what + * a consumer can reach is what the export map admits, not what the source tree + * contains — which is also why this suite is Deno-only: importing + * `@executablemd/git/deno` reaches Workflow's storage adapter and so + * `node:sqlite`. The two readings that need no import — the entrypoints as + * source, and the manifest export maps — live in `module-partition.test.ts` + * and run on every runtime. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { until } from "effection"; + +/** + * Seams that are this package's own business. + * + * Each one is reachable from inside `packages/git` and from nowhere else. A + * name arriving here is a decision to widen the package's contract, which is a + * thing to do on purpose rather than by re-exporting a module wholesale. + */ +const INTERNAL: readonly string[] = [ + "gitHubPullRequestAccess", + "denoGitHubAccess", + "denoGitHubLogin", + "pullRequestProvider", + "gitHubIssuesConfiguration", + "gitHubPullRequestsConfiguration", +]; + +/** + * GitHub names this package publishes on purpose. + * + * Listed so that losing one is a failure rather than a silent narrowing: a + * host configuring a ceiling needs the recognizer, and a document naming a + * provider needs the discriminator. + */ +const PUBLISHED: readonly string[] = [ + "GITHUB", + "GITHUB_PULL_REQUEST_PROVIDER", + "parseGitHubIssueTarget", + "parseGitHubPullRequestUrl", + "pullRequestAllowed", + "recognizesGitHubPullRequestUrl", + "recognizesGitHubUrl", + "useGitHubIssues", + "useGitHubPullRequests", +]; + +describe("what @executablemd/git publishes", () => { + it("publishes the GitHub contracts a host needs", function* () { + const published = yield* until(import("@executablemd/git/deno")); + const names = Object.keys(published); + // A positive control first: an empty or failed import would satisfy every + // absence below while proving nothing at all. + expect(names.length > 0).toBe(true); + expect(PUBLISHED.filter((name) => !names.includes(name))).toEqual([]); + }); + + it("publishes no package-local seam from either entrypoint", function* () { + for (const specifier of ["@executablemd/git", "@executablemd/git/deno"]) { + const published = yield* until(import(specifier)); + const names = Object.keys(published); + expect(`${specifier}: ${names.length > 0}`).toBe(`${specifier}: true`); + expect(`${specifier}: ${INTERNAL.filter((name) => names.includes(name)).join(",")}`).toBe( + `${specifier}: `, + ); + } + }); +}); diff --git a/packages/git/tests/pull-request-read.test.ts b/packages/git/tests/pull-request-read.test.ts index a343aafa6..e0cf97df6 100644 --- a/packages/git/tests/pull-request-read.test.ts +++ b/packages/git/tests/pull-request-read.test.ts @@ -130,7 +130,10 @@ function ahead(handler: () => Operation, body: () => Operation): Ope function reading(host: Server, allowed: readonly string[] = [SUBJECT_REPO]) { return { composition: {}, - gitHubPullRequests: { allowed, access: gitHubSource(host.access) }, + gitHubPullRequests: { + access: gitHubSource(host.access), + configuration: { allowed }, + }, }; } @@ -985,7 +988,10 @@ describe("Tier PRR — pull-request evidence", () => { { composition: {}, // Authorizes a different repository entirely. - gitHubPullRequests: { allowed: ["https://github.com/octo/other"], access: counted }, + gitHubPullRequests: { + access: counted, + configuration: { allowed: ["https://github.com/octo/other"] }, + }, }, ), ); @@ -1023,8 +1029,8 @@ describe("Tier PRR — pull-request evidence", () => { // No storage, no run, no attachment — just the adapter and the components. const answers = yield* scoped(function* () { yield* useGitHubPullRequestReads({ - allowed: [SUBJECT_REPO], access: gitHubSource(host.access), + configuration: { allowed: [SUBJECT_REPO] }, }); const first = yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); const second = yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); @@ -1062,8 +1068,8 @@ describe("Tier PRR — pull-request evidence", () => { const failure = yield* raised( scoped(function* () { yield* useGitHubPullRequestReads({ - allowed: ["https://github.com/octo/other"], access: counted, + configuration: { allowed: ["https://github.com/octo/other"] }, }); return yield* PullRequestAPI.operations.read(SUBJECT_URL, { kind: "reviews" }); }), @@ -1111,7 +1117,7 @@ describe("Tier PRR — pull-request evidence", () => { composition: {}, // The ceiling would admit the target; the discriminator is what // this adapter does not answer to. - gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted }, + gitHubPullRequests: { access: counted, configuration: { allowed: [SUBJECT_REPO] } }, }, ), ); @@ -1157,7 +1163,7 @@ describe("Tier PRR — pull-request evidence", () => { `\n`, { composition: {}, - gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted }, + gitHubPullRequests: { access: counted, configuration: { allowed: [SUBJECT_REPO] } }, }, ), ); @@ -1315,7 +1321,7 @@ describe("Tier PRR — pull-request evidence", () => { }; const options = { composition: {}, - gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted }, + gitHubPullRequests: { access: counted, configuration: { allowed: [SUBJECT_REPO] } }, }; const document = `\n\n{reviews.length} reviews\n`; @@ -1379,7 +1385,10 @@ describe("Tier PRR — pull-request evidence", () => { yield* runWorkflowDocument( database, `\n`, - { composition: {}, gitHubPullRequests: { allowed: [SUBJECT_REPO], access: counted } }, + { + composition: {}, + gitHubPullRequests: { access: counted, configuration: { allowed: [SUBJECT_REPO] } }, + }, ); }); @@ -1424,7 +1433,7 @@ describe("Tier PRR — pull-request evidence", () => { yield* raised( runWorkflowDocument(database, document, { composition: {}, - gitHubPullRequests: { allowed: [SUBJECT_REPO], access: hanging }, + gitHubPullRequests: { access: hanging, configuration: { allowed: [SUBJECT_REPO] } }, }), ); }), diff --git a/packages/git/tests/run-composition-ambient.test.ts b/packages/git/tests/run-composition-ambient.test.ts index 9e1961f7e..f54f15970 100644 --- a/packages/git/tests/run-composition-ambient.test.ts +++ b/packages/git/tests/run-composition-ambient.test.ts @@ -173,7 +173,10 @@ describe("ORC5 — origin is not local admission", () => { cwd: solo.root, host: counting.host, gitHubPullRequests: { access: gitHubSource(github.access) }, - gitHubIssues: { ceiling: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + gitHubIssues: { + access: gitHubSource(github.access), + configuration: { ceiling: [GITHUB_LOCATOR] }, + }, }), ); expect(`${source} ${String(failure)}`).toContain("no usable origin"); @@ -220,7 +223,10 @@ describe("ORC5 — origin is not local admission", () => { { root, cwd: checkout.root, - gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access: gitHubSource(github.access) }, + gitHubPullRequests: { + access: gitHubSource(github.access), + configuration: { allowed: [GITHUB_LOCATOR] }, + }, }, ); diff --git a/packages/git/tests/run-composition-remote.test.ts b/packages/git/tests/run-composition-remote.test.ts index e5bdaf32d..5c66363d8 100644 --- a/packages/git/tests/run-composition-remote.test.ts +++ b/packages/git/tests/run-composition-remote.test.ts @@ -496,8 +496,8 @@ describe("ORC16 — live Issues", () => { root, cwd: checkout.root, gitHubIssues: { - ceiling: [GITHUB_LOCATOR], access: gitHubSource(fakeGitHubAccess(store)), + configuration: { ceiling: [GITHUB_LOCATOR] }, }, }; @@ -544,8 +544,8 @@ describe("ORC16 — live Issues", () => { root, cwd: checkout.root, gitHubIssues: { - ceiling: [GITHUB_LOCATOR], access: gitHubSource(fakeGitHubAccess(store)), + configuration: { ceiling: [GITHUB_LOCATOR] }, }, }), ); @@ -628,7 +628,7 @@ describe("ORC17 — live PullRequests", () => { { root, cwd: checkout.root, - gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access }, + gitHubPullRequests: { access, configuration: { allowed: [GITHUB_LOCATOR] } }, }, ); @@ -663,7 +663,11 @@ describe("ORC17 — live PullRequests", () => { const failure = yield* raised( runOrdinaryDocument( ``, - { root, cwd: checkout.root, gitHubPullRequests: { allowed: [GITHUB_LOCATOR], access } }, + { + root, + cwd: checkout.root, + gitHubPullRequests: { access, configuration: { allowed: [GITHUB_LOCATOR] } }, + }, ), ); expect(String(failure)).toContain("has not authorized"); diff --git a/packages/git/tests/support/issue-providers.ts b/packages/git/tests/support/issue-providers.ts index e51efb03c..be2ba51df 100644 --- a/packages/git/tests/support/issue-providers.ts +++ b/packages/git/tests/support/issue-providers.ts @@ -16,7 +16,7 @@ import type { IssueDetails, IssueInput, IssueReference } from "../../src/issue/a import { IssueUnavailableError } from "../../src/issue/errors.ts"; import { withinIssueCeiling } from "../../src/issue/tracker.ts"; import { useGitHubIssues } from "../../src/deno/issue/github.ts"; -import { denoGitHubAccess } from "../../src/deno/composition/github.ts"; +import { denoGitHubAccess } from "../../src/deno/composition/github-host.ts"; import type { GitHubAccess, GitHubHttpResponse } from "../../src/deno/composition/github.ts"; import { credentialFor } from "./issue-tracker-server.ts"; import type { CredentialCondition } from "./issue-tracker-server.ts"; @@ -159,7 +159,10 @@ export function useProviderComponents(log: ProviderLog): Operation { failsTransport: props.failsTransport === true, interruptsAfterCreate: props.interruptsAfterCreate === true, }); - yield* useGitHubIssues({ ceiling, access: gitHubSource(access) }); + yield* useGitHubIssues({ + access: gitHubSource(access), + configuration: { ceiling }, + }); return yield* content(); }, }, diff --git a/packages/git/tests/support/pull-request-crash-child.ts b/packages/git/tests/support/pull-request-crash-child.ts index 6fa4b5d7a..728e056eb 100644 --- a/packages/git/tests/support/pull-request-crash-child.ts +++ b/packages/git/tests/support/pull-request-crash-child.ts @@ -23,7 +23,7 @@ import { useWorkflowRunStorage } from "@executablemd/workflow/deno"; import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; -import { denoGitHubAccess } from "../../src/deno/composition/github.ts"; +import { denoGitHubAccess } from "../../src/deno/composition/github-host.ts"; import type { GitHubAccess, GitHubHttpRequest, diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 9f1918a48..5f55b281f 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -197,6 +197,24 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "kills real Deno child processes with SIGKILL and reads the recovered node:sqlite WorkflowRun database they leave behind; the children run under the Deno executable and node:sqlite remains behind --experimental-sqlite on Node 22", issue: "https://github.com/taras/executable.md/issues/365", }, + { + path: "packages/cli/tests/github-zero-effect.test.ts", + reason: + "renders the syntax catalog and validates a Plan draft through the CLI's own surfaces, which reach `@executablemd/git/deno` and so Workflow's storage adapter and `node:sqlite` — absent from Bun and behind --experimental-sqlite on Node 22. The adapters' own activation ordering, and every refusal before configuration, are proven portably in packages/git/tests/github-activation.test.ts", + issue: DERIVED_SCOPE, + }, + { + path: "packages/git/tests/github-workflow-activation.test.ts", + reason: + "assembles the real ordinary-run profile and executes a retained source-bundle run through the real Workflow host; both reach Workflow's storage adapter and so `node:sqlite` — absent from Bun and behind --experimental-sqlite on Node 22. What is lost is only the assembled-profile reading: the adapters' own activation ordering, and every refusal before configuration, are proven portably in github-activation.test.ts, which runs on all three runtimes", + issue: DERIVED_SCOPE, + }, + { + path: "packages/git/tests/public-entrypoint.test.ts", + reason: + "both of its cases import `@executablemd/git` and `@executablemd/git/deno` to read what the entrypoint actually publishes, and that entrypoint reaches Workflow's Deno storage adapter and so `node:sqlite` — absent from Bun and behind --experimental-sqlite on Node 22. What is lost is only the runtime-namespace reading: the same two entrypoints are read as source by module-partition.test.ts, which also reads both manifest export maps and runs on all three runtimes", + issue: DERIVED_SCOPE, + }, { path: "packages/git/tests/ambient-authentication.test.ts", reason: From 95ba19aec4a8de7f35b4e250af814aaa1b1b3a2a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 20 Sep 2026 19:25:55 -0400 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20Bundle=20the=20Git=20run=20prof?= =?UTF-8?q?ile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMD ships one Plugin and activates it for every command that executes or describes a document. `assembleRunProfile()` prefixes the statically imported `@executablemd/git`, resolves the reserved `git` selector to that value without loading a module, and appends authored specifiers in order. `installPlugins()` and `NO_PLUGINS` are untouched: which Plugins a command runs with is the caller's business, and an empty list still installs nothing. The profile is now the only route. `cli.ts` and `syntax.ts` no longer bootstrap the repository vocabulary, and the workflow command no longer builds Git's admissions a second time — the profile carries them, so what used to be a duplicate installation is one. `xmd test` is not a run profile and loses the vocabulary; a nested `host="run"` child assembles it for itself, prefixing the bundled value by *identity* so an impostor claiming the name still collides. Installing the repository provider now costs nothing. Every capability — the Git session, the managed root, the leases, the invocation identity, the commit identity and ambient discovery — is acquired by the first operation that needs it, single-flight, owned by the installation scope and held by suspension. A run that touches no repository creates no managed root. ``'s write-table entry moves to the package that owns the component. Workflow states none: a host that supplies no directory capability grants none, and the XMD workflow profile supplies Git's at the released position between the file write and the file delete, under the identity retained history holds. --- packages/cli/src/cli.ts | 29 ++- packages/cli/src/git-plugin-installation.ts | 38 ---- packages/cli/src/plugin-host.ts | 10 +- packages/cli/src/run-profile.ts | 112 +++++++++++ packages/cli/src/syntax.ts | 12 +- packages/cli/src/testing-host.ts | 16 +- packages/cli/src/workflow-fork.ts | 24 +-- packages/cli/src/workflow.ts | 28 +-- packages/cli/tests/evaluate-workflow.test.ts | 53 +++++- packages/cli/tests/github-zero-effect.test.ts | 42 +++-- packages/cli/tests/plugin-cli.test.ts | 29 ++- packages/cli/tests/plugin-host.test.ts | 10 +- .../cli/tests/run-composition-nested.test.ts | 11 +- packages/cli/tests/run-composition.test.ts | 38 +++- packages/cli/tests/run-profile.test.ts | 165 ++++++++++++++++ .../cli/tests/support/run-markdown-tier.ts | 3 +- packages/cli/tests/syntax-cli.test.ts | 10 +- packages/cli/tests/test-root-profile.test.ts | 117 ++++++++++++ packages/cli/tests/workflow-agent.test.ts | 8 +- packages/cli/tests/workflow-host.test.ts | 8 +- .../cli/tests/workflow-installation.test.ts | 22 ++- packages/git/mod.ts | 23 +++ packages/git/src/composition/definitions.ts | 26 +++ .../git/src/deno/run-composition/ambient.ts | 10 +- .../git/src/deno/run-composition/identity.ts | 10 +- .../git/src/deno/run-composition/provider.ts | 176 ++++++++++++++---- packages/git/tests/git-add-durability.test.ts | 3 + packages/git/tests/git-add.test.ts | 5 + .../git/tests/git-commit-durability.test.ts | 3 + packages/git/tests/git-commit.test.ts | 5 + .../git/tests/git-switch-durability.test.ts | 2 + packages/git/tests/git-switch.test.ts | 6 + packages/git/tests/public-entrypoint.test.ts | 17 ++ .../git/tests/run-composition-lazy.test.ts | 158 ++++++++++++++++ packages/git/tests/support/composition.ts | 11 +- packages/git/tests/support/git-crash-child.ts | 7 + .../tests/support/pull-request-crash-child.ts | 4 + .../workflow/src/deno/workspace/evaluate.ts | 63 ++----- .../tests/generated-agent-component.test.ts | 38 +++- .../workflow/tests/workspace-files.test.ts | 24 ++- scripts/tests/cli-npm-bin.test.ts | 6 +- scripts/tests/plugin-compiled.test.ts | 141 +++++++++++++- scripts/validate-documentation.ts | 19 +- 43 files changed, 1269 insertions(+), 273 deletions(-) delete mode 100644 packages/cli/src/git-plugin-installation.ts create mode 100644 packages/cli/src/run-profile.ts create mode 100644 packages/cli/tests/run-profile.test.ts create mode 100644 packages/cli/tests/test-root-profile.test.ts create mode 100644 packages/git/tests/run-composition-lazy.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 469a22de4..8c2f3b2e8 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -134,7 +134,7 @@ import { renderSyntaxMarkdown, syntaxSymbols, } from "./syntax.ts"; -import { loadPlugins } from "./plugin-loader.ts"; +import { assembleRunProfile } from "./run-profile.ts"; import type { PluginModuleLoader } from "./plugin-loader.ts"; import { installPlugins, NO_PLUGINS } from "./plugin-host.ts"; import type { CommandPlugins } from "./plugin-host.ts"; @@ -161,7 +161,6 @@ import { runWorkflowManagement } from "./workflow-management.ts"; import { establishDefinition } from "./workflow-definition.ts"; import type { EstablishedDefinition } from "./workflow-definition.ts"; import { useWorkflowServiceDenial } from "@executablemd/workflow"; -import { useCompositionComponents } from "@executablemd/git"; import denoJson from "../deno.json" with { type: "json" }; const SECRET_DETECTION_OPTION = "--secret-detection"; @@ -955,12 +954,11 @@ export function* installDocumentComponents(mode: DocumentMode, verbose: boolean) // `` is registered at all. yield* Config.around({ verbose: () => verbose }, { at: "min" }); - // The repository-composition vocabulary, as ordinary shadowable defaults, - // with the documentation that describes it. Bootstrapping it installs no - // provider, discovers no repository, acquires no lock and reaches no network: - // what a name *does* is decided by whichever provider the command installed, - // and a runtime that installs none still resolves every one of these. - yield* useCompositionComponents(); + // The repository-composition vocabulary is not bootstrapped here. It belongs + // to the bundled Git Plugin, which the run profile installs in the command + // scope this assembly runs inside — so `xmd run` and a nested + // `host="run"` child resolve every one of those names, and the `xmd test` + // root, whose profile carries no Plugin, resolves none of them. // Compose testing around the single core execution entrypoint: both // commands register the components (assertions work in regular documents, @@ -2981,10 +2979,12 @@ export function* runXmd( * document. `undefined` is an invocation that installs none — help, the * version, and the internal worker mode. * - * What a command line selected is the complete list, in the order it was - * written. XMD bundles none and defaults to none: a command that named no - * `--plugin` installs nothing, and a package that happens to be installed stays - * inert until it is named. + * What a command runs with is the profile `assembleRunProfile()` builds: the + * one Plugin XMD bundles, for the commands that execute or describe a + * document, and then whatever the operator selected in the order they wrote + * it. A package that happens to be installed stays inert until it is named, + * and a command outside that profile — `xmd test`, `upgrade`, a workflow + * management action — carries no Plugin it did not ask for. */ function* withPlugins( selection: PluginSelection | undefined, @@ -2997,10 +2997,7 @@ function* withPlugins( } let plugins: CommandPlugins; try { - // Captured before the first module is loaded, so a relative path and a - // package specifier both resolve where the caller is standing. - const directory = yield* cwd(); - const loaded = yield* loadPlugins(selection.specifiers, directory, loadPluginModule); + const loaded = yield* assembleRunProfile(selection, loadPluginModule); plugins = yield* installPlugins(loaded, { command: selection.command, args: selection.args, diff --git a/packages/cli/src/git-plugin-installation.ts b/packages/cli/src/git-plugin-installation.ts deleted file mode 100644 index b944f8970..000000000 --- a/packages/cli/src/git-plugin-installation.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * What the bundled Git Plugin contributes to one workflow execution. - * - * Asked of the Plugin value rather than assembled here. Its admissions are what - * let a replay recognize the Git-host and Issue records a run retained or - * inherited, and each one derives an execution's identities from that - * execution's own snapshot — so one Plugin value serves every execution the - * command runs without any of them reading another's history. - * - * The workflow command reaches the Plugin directly because it assembles its - * execution itself. The ordinary run profile does not need this: a Plugin the - * command selected is installed by `plugin-host.ts`, and what it returns - * already travels on `CommandPlugins.installations`. - */ - -import type { Operation } from "effection"; -import type { ExecutionInstallation } from "@executablemd/core/host"; -import { gitPlugin } from "@executablemd/git"; - -/** - * The Git Plugin's contribution for one workflow action, as installations. - * - * The action is stated as the argv that names it rather than on its own, so - * what the Plugin answers here is what it answers for the real command line. - */ -export function* gitPluginInstallation(action: string): Operation { - const install = gitPlugin.install; - if (install === undefined) { - return {}; - } - // The command token and the action, as the Plugin's own predicate reads an - // argv: it locates `workflow` and then finds the first positional after it. - const installed = yield* install.call(gitPlugin, { - command: "workflow", - args: ["workflow", action], - }); - return { admissions: [...(installed?.admissions ?? [])] }; -} diff --git a/packages/cli/src/plugin-host.ts b/packages/cli/src/plugin-host.ts index 1edb8ec17..7b4e9b66a 100644 --- a/packages/cli/src/plugin-host.ts +++ b/packages/cli/src/plugin-host.ts @@ -1,10 +1,12 @@ /** * Installing the Plugins one command runs with. * - * The modules the operator selected, in the order they wrote them, and nothing - * else: XMD ships no Plugin and installs none by default, so a command that - * named none installs none. That order fixes how middleware composes — the - * first Plugin installed is the outermost wrapper — and decides nothing else: + * The list it is handed, in the order it is handed them, and nothing else. + * This is the generic installer: which Plugins a command runs with is the + * caller's business — `assembleRunProfile()` is where XMD's own bundled prefix + * is decided — and an empty list installs nothing at all. That order fixes how + * middleware composes — the first Plugin installed is the outermost wrapper — + * and decides nothing else: * two Plugins claiming one name, one component name or one structural construct * are refused rather than settled by position. * diff --git a/packages/cli/src/run-profile.ts b/packages/cli/src/run-profile.ts new file mode 100644 index 000000000..e7d6b2114 --- /dev/null +++ b/packages/cli/src/run-profile.ts @@ -0,0 +1,112 @@ +/** + * The profile a command runs with: one bundled Plugin, then whatever the + * operator named. + * + * XMD ships exactly one Plugin — `@executablemd/git` — and activates it for + * every command that executes or describes a document. It is statically + * imported rather than loaded, so the distribution carries it whole and no + * resolution can substitute it. Explicit `--plugin` selections follow it in the + * order they were written, which fixes how middleware composes: the bundled + * value is the outermost wrapper. + * + * ## The reserved selector + * + * `--plugin git` names the value this profile already carries. It is resolved + * here, without loading a module, and writing it once or five times leaves the + * profile exactly as it was: Git appears once, first. That is a property of the + * *host's own selector for its own bundled value*, and of nothing else — a + * module that merely claims the same Plugin name is a duplicate like any other, + * and `admitPlugins()` refuses it before anything installs. + * + * ## What this is not + * + * It is not a change to the installer. `installPlugins()` and `NO_PLUGINS` + * remain what they were: a generic primitive that installs the list it is + * handed, and an empty assembly. Which Plugins a *command* runs with is this + * module's business, and assembling them is all it does. + */ + +import type { Operation } from "effection"; +import { cwd } from "@executablemd/runtime"; +import { gitPlugin, gitPluginDeclaresFor } from "@executablemd/git"; +import type { Plugin } from "@executablemd/core/api"; +import { loadPlugins, type PluginModuleLoader } from "./plugin-loader.ts"; +import type { PluginSelection } from "./plugin-selection.ts"; + +/** + * The selector that names the bundled Plugin. + * + * Short, and reserved: an operator writing it is referring to what the profile + * already has rather than asking for a module to be found. Nothing resolves it + * on a filesystem or a registry, so no package by this name can take its place. + */ +export const BUNDLED_SELECTOR = "git"; + +/** The Plugin every run-profile command begins with. */ +export const BUNDLED_PLUGIN: Plugin = gitPlugin; + +/** + * Whether this command line runs with the bundled profile. + * + * Asked of the Plugin rather than listed here, because the answer is the + * Plugin's: `run`, `plan` and `syntax` execute or describe a document outright, + * and `workflow` reaches one only through `start`, `resume` or `fork`. A + * management action reads or lists runs and executes nothing, so the value does + * not belong in its profile at all — carrying it where it declares nothing + * would still put it in `ActivePlugins`, and being in that list is what having + * the default prefix means. + * + * `xmd test` answers false for the same reason: the test root is a different + * profile, and a nested `` child assembles this one for + * itself rather than inheriting it. + */ +export function carriesBundledPlugin(selection: PluginSelection): boolean { + return gitPluginDeclaresFor({ command: selection.command, args: selection.args }); +} + +/** + * The Plugin values one command runs with, in installation order. + * + * The bundled value first when this command's profile carries it, then the + * operator's own in the order they were written, with every occurrence of the + * reserved selector resolved to the value already present rather than loaded. + */ +export function* assembleRunProfile( + selection: PluginSelection, + load: PluginModuleLoader, +): Operation { + const bundled = carriesBundledPlugin(selection); + // Resolved before anything is loaded: the reserved selector names a value + // this profile holds, so it is never handed to a module loader, and a command + // whose profile does not carry the bundled Plugin cannot conjure one by + // writing the selector either. + const named = selection.specifiers.filter((specifier) => specifier !== BUNDLED_SELECTOR); + if (named.length === 0) { + return bundled ? Object.freeze([BUNDLED_PLUGIN]) : Object.freeze([]); + } + // Captured before the first module is loaded, so a relative path and a + // package specifier both resolve where the caller is standing. + const directory = yield* cwd(); + const loaded = yield* loadPlugins(named, directory, load); + return Object.freeze(bundled ? [BUNDLED_PLUGIN, ...loaded] : [...loaded]); +} + +/** + * The profile a nested `` child runs with. + * + * The bundled value first, then whatever the outer command had selected. The + * prefix is added here rather than inherited, because the root may not have had + * it: `xmd test` is not a run profile, and a child does not inherit what its + * parent declined. + * + * The bundled value is dropped from the outer list **by identity**. What may be + * dropped is the one object this host statically bundled — an operator who + * wrote the reserved selector is holding exactly that value, and prefixing it + * twice would be the host duplicating itself. A *different* Plugin that merely + * claims the same name is not this value, is not dropped, and meets + * `admitPlugins()`'s duplicate-name refusal like any other collision. Comparing + * names here would have let an impostor disappear instead of collide. + */ +export function nestedRunProfile(outer: readonly Plugin[]): readonly Plugin[] { + return Object.freeze([BUNDLED_PLUGIN, ...outer.filter((plugin) => plugin !== BUNDLED_PLUGIN)]); +} diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 565ce336d..f75da1b90 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -44,7 +44,6 @@ import type { SyntaxSymbols } from "@executablemd/core"; import { useTestingComponents } from "@executablemd/testing"; import { useWebComponents } from "@executablemd/web"; import { useVerboseComponent } from "./verbose-component.ts"; -import { useCompositionComponents } from "@executablemd/git"; import type { ExecutionDeclaration } from "@executablemd/core/host"; import { NO_PLUGINS } from "./plugin-host.ts"; import type { CommandPlugins } from "./plugin-host.ts"; @@ -146,12 +145,11 @@ export function* useCommandComponents(): Operation { yield* useAgentComponents(); yield* useTestingComponents(); yield* useWebComponents(); - // The repository-composition vocabulary. - yield* useCompositionComponents(); - // No package-specific vocabulary is bootstrapped here. What a Plugin - // registers — the review graph's six reserved registrations among them — it - // registers in the command scope this one is entered inside, so a command - // that installed no Plugin describes exactly the engine's own language. + // No package-specific vocabulary is bootstrapped here — not the review + // graph's six reserved registrations, and not the repository-composition + // vocabulary either. What a Plugin registers it registers in the command + // scope this one is entered inside, so what a command describes is exactly + // the engine's own language plus whatever its profile installed. } /** diff --git a/packages/cli/src/testing-host.ts b/packages/cli/src/testing-host.ts index 8af8d16a0..8a771ce8c 100644 --- a/packages/cli/src/testing-host.ts +++ b/packages/cli/src/testing-host.ts @@ -59,6 +59,7 @@ import type { } from "@executablemd/testing"; import { installDocumentComponents } from "./cli.ts"; import { installPlugins } from "./plugin-host.ts"; +import { nestedRunProfile } from "./run-profile.ts"; import type { Plugin } from "@executablemd/core/api"; import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; import type { HostServiceInstaller } from "./cli.ts"; @@ -292,11 +293,16 @@ function* runProfileChild( const { testAgent, answers } = selectConfiguration(request); yield* installDocumentComponents({ testing: false }, false); - // The run profile's Plugins, installed in this child's own scope and told - // they are installing for a run. A child of `xmd test` therefore gains the - // vocabulary an ordinary run has, while the test root that launched it — - // a different profile — still has none of it. - const childPlugins = yield* installPlugins(settings.plugins, { + // The run profile, assembled in this child's own scope: the bundled Plugin + // first, then whatever the operator selected, and all of them told they are + // installing for a run. A child of `xmd test` therefore gains the vocabulary + // an ordinary run has — including the repository components — while the test + // root that launched it, a different profile, still has none of it. + // + // The bundled value is prefixed here rather than carried down from the root, + // because the root never had it: `xmd test` is not a run profile, and a child + // does not inherit what its parent declined. + const childPlugins = yield* installPlugins(nestedRunProfile(settings.plugins), { command: "run", args: settings.pluginArgs, }); diff --git a/packages/cli/src/workflow-fork.ts b/packages/cli/src/workflow-fork.ts index eb1686356..e139944bc 100644 --- a/packages/cli/src/workflow-fork.ts +++ b/packages/cli/src/workflow-fork.ts @@ -134,7 +134,6 @@ export function* preflightFork( request: ForkRequest, host: ForkPreflightHost, execute: (execution: WorkflowExecution) => Operation>, - git: ExecutionInstallation, ): Operation> { const history = yield* WorkflowLifecycle.operations.history(request.sourceRunId); if (!history.ok) { @@ -156,7 +155,7 @@ export function* preflightFork( ? {} : { targetPath: request.creation.definition.targetPath }), }; - const imported = yield* captureRootImport(request, run, execute, git); + const imported = yield* captureRootImport(request, run, execute); if (!imported.ok) { return imported; } @@ -182,14 +181,8 @@ export function* preflightFork( return staged; } const database = staged.value; - return yield* replayPrefix( - request, - run, - journal, - identities, - execute, - (operation) => host.attach(database, operation), - git, + return yield* replayPrefix(request, run, journal, identities, execute, (operation) => + host.attach(database, operation), ); }); if (!checked.ok) { @@ -216,7 +209,6 @@ function* captureRootImport( request: ForkRequest, run: WorkflowRun, execute: (execution: WorkflowExecution) => Operation>, - git: ExecutionInstallation, ): Operation> { const record = forkRunRecordEvent(run); let captured: DurableEvent | undefined; @@ -238,7 +230,7 @@ function* captureRootImport( }, }; - const attempted = yield* execute(execution(request, run, stream, passThrough, git)); + const attempted = yield* execute(execution(request, run, stream, passThrough)); if (captured !== undefined) { return Ok(captured); } @@ -263,7 +255,6 @@ function* replayPrefix( identities: readonly string[], execute: (execution: WorkflowExecution) => Operation>, attach: (operation: Operation) => Operation, - git: ExecutionInstallation, ): Operation> { const total = journal.filter((event) => event.type === "yield").length; const progress = { consumed: 0 }; @@ -300,7 +291,7 @@ function* replayPrefix( ); } - const attempted = yield* execute(execution(request, run, stream, boundary, git)); + const attempted = yield* execute(execution(request, run, stream, boundary)); if (attempted.ok) { return Err( new Error( @@ -334,7 +325,6 @@ function execution( run: WorkflowRun, stream: DurableStream, around: (operation: Operation) => Operation, - git: ExecutionInstallation, ): WorkflowExecution { return { root: retainedSource(request.creation.definition.entrypoint, request.established.source, { @@ -346,10 +336,6 @@ function execution( stream, installations: [ retainedWorkflowInstallation(run), - // What the Git Plugin admits of the inherited history. Without its - // admissions an inherited effect is named by a live identity and the - // candidate diverges from its own history. - git, ...(request.established.components.length === 0 ? [] : [workflowBundleInstallation(request.established.components)]), diff --git a/packages/cli/src/workflow.ts b/packages/cli/src/workflow.ts index 602371ee4..dc4545ff2 100644 --- a/packages/cli/src/workflow.ts +++ b/packages/cli/src/workflow.ts @@ -70,6 +70,7 @@ import { field, object, cli } from "configliere"; import { z } from "zod"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { retainedSource, validateProps } from "@executablemd/core"; +import { gitDirectoryEntry } from "@executablemd/git"; import type { PropsSchema } from "@executablemd/core"; import type { RootDocumentSource } from "@executablemd/core"; import { @@ -79,7 +80,6 @@ import { WORKFLOW_RUN_STATUSES, WorkflowLifecycle, } from "@executablemd/workflow"; -import { gitPluginInstallation } from "./git-plugin-installation.ts"; import type { ExecutionInstallation } from "@executablemd/core/host"; import type { ExecutorLock, @@ -923,13 +923,6 @@ export function runWorkflow( return { exitCode: 1 }; } - // Asked of the Plugin once, here, because a Plugin installs once per - // command. Its declarations are the command's and belong in this scope; - // asking again lower down would register the same names in the same scope - // a second time. Both the fork preflight and the run's own execution are - // handed this one value. - const git = yield* gitPluginInstallation(request.action); - // Before the executor lock, and before a destination exists: a fork that // cannot reproduce the prefix it asked to inherit is a request being // refused, not a run that failed. @@ -941,7 +934,6 @@ export function runWorkflow( host, transitions, execute, - git, ); if (!inheritance.ok) { report(inheritance.error.message); @@ -1073,12 +1065,6 @@ export function runWorkflow( // real adapter. installations: [ retainedWorkflowInstallation(installedRun(record)), - // What the Git Plugin admits of this run's retained history, taken from - // the Plugin value itself. Its admissions are what let a replay - // recognize the Git-host and Issue records it inherited, and each one - // derives this execution's identities from this execution's own - // snapshot. - git, // The bundle this run is a run of, when it is a run of one. Both start // and resume install it, and a completed replay installs it too: the // retained history is held to the same components before its recorded @@ -1091,7 +1077,15 @@ export function runWorkflow( // about the component. Stated where the Workspace is attached — a // completed replay restores its retained output and expands nothing, so // it needs no ceiling of its own. - ...(completed || replay ? [] : [{ evaluation: yield* evaluationProfile(database) }]), + // ``'s entry comes from the package that owns the component, so + // the write table names one identity rather than two copies of it. + ...(completed || replay + ? [] + : [ + { + evaluation: yield* evaluationProfile(database, { directory: gitDirectoryEntry() }), + }, + ]), ], around(operation: Operation): Operation { // A completed run replays its retained output and result. Attaching a @@ -1234,7 +1228,6 @@ function* forkInheritance( host: WorkflowHost, transitions: WorkflowExecutionTransitions, execute: (execution: WorkflowExecution) => Operation>, - git: ExecutionInstallation, ): Operation> { if (request.action !== "fork") { return Ok(undefined); @@ -1256,7 +1249,6 @@ function* forkInheritance( }, { transitions, attach: (database, operation) => host.attach(database, operation) }, execute, - git, ); if (!checked.ok) { return checked; diff --git a/packages/cli/tests/evaluate-workflow.test.ts b/packages/cli/tests/evaluate-workflow.test.ts index 3c0d5fa37..b18ca4dc8 100644 --- a/packages/cli/tests/evaluate-workflow.test.ts +++ b/packages/cli/tests/evaluate-workflow.test.ts @@ -29,6 +29,7 @@ import type { Json } from "@executablemd/durable-streams"; import { API, useHostFiles } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; import { evaluationProfile, withWorkflowWorkspace } from "@executablemd/workflow/deno"; +import { gitDirectoryEntry } from "@executablemd/git"; import { createRun, useStorageRoot, withStorage } from "./support/workflow-run.ts"; /** @@ -60,7 +61,12 @@ function runDocument( scoped(function* () { return yield* collect( yield* executeInstalled({ ...inlineSource(source), stream: database.journal }, [ - { evaluation: yield* evaluationProfile(database) }, + // The bundled XMD workflow profile: Git supplies ``. + { + evaluation: yield* evaluationProfile(database, { + directory: gitDirectoryEntry(), + }), + }, ]), ); }), @@ -75,6 +81,51 @@ function runDocument( const NOTE = "the retained note"; describe("Tier FE — the workflow host's profile", () => { + it("FE22: the directory capability is the host's, and Git is what supplies it", function* () { + // `` belongs to `@executablemd/git`. Workflow states no entry for it + // at all now, so a generic Workflow host composing this profile has no + // directory capability — and the XMD workflow profile has one because Git + // hands it over, not because Workflow kept a copy. + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + + const generic = yield* evaluationProfile(database); + const names = (generic.write ?? []).map((entry) => entry.name); + // Non-empty, so "no Dir" is an absence among entries rather than an + // empty table that would satisfy the claim by containing nothing. + expect(names.length > 0).toBe(true); + expect(names).not.toContain("Dir"); + + // And the position is the contract: a retained continuation compares + // this table position by position, so the supplied entry sits exactly + // between the file write and the file delete. + const bundled = yield* evaluationProfile(database, { directory: gitDirectoryEntry() }); + expect((bundled.write ?? []).map((entry) => entry.name)).toEqual([ + "File", + "Dir", + "File.Delete", + ]); + }); + }); + + it("FE23: Git's directory entry is the exact identity retained history holds", function* () { + // Written out here rather than compared against the implementation, so a + // change to either side is a change to this row. Every released journal + // holds these strings: the origin a released build recorded, the revision + // it recorded, and the version-1 alias whose grant this entry answers for. + const entry = gitDirectoryEntry(); + expect(entry.name).toBe("Dir"); + expect(entry.identity).toEqual({ + origin: "@executablemd/workflow/composition", + key: "Dir", + revision: "3", + }); + expect(Reflect.get(entry, "legacy")).toEqual(["@executablemd/workflow/composition/dir-v2#Dir"]); + // It is the directory capability, not some other entry wearing the name. + expect(Reflect.get(entry, "capability")).toBe("directory:ensure"); + }); + it("FE19: `source` and `text` behave identically here, and neither warns", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { diff --git a/packages/cli/tests/github-zero-effect.test.ts b/packages/cli/tests/github-zero-effect.test.ts index 9900cbccf..7191e904b 100644 --- a/packages/cli/tests/github-zero-effect.test.ts +++ b/packages/cli/tests/github-zero-effect.test.ts @@ -85,17 +85,9 @@ describe("describing the bundled profile costs nothing", () => { it("validates a Plan draft without reading GitHub configuration", function* () { // Real structural validation, not merely loading the declaration: a draft // written in Git-owned syntax, checked through the profile a `plan` command - // assembles. - // - // `` is Git's, but omitting the Plugin from this validation does not - // yet refuse the draft: `useCommandComponents()` still calls - // `useCompositionComponents()` directly (`packages/cli/src/syntax.ts`), so - // the vocabulary reaches syntax and Plan whether or not a Plugin supplied - // it. Slice 4 removes that call and makes the profile the only route, and - // the Plugin-omission probe becomes discriminating then. What this case - // proves now is that validating Git-owned syntax reads no GitHub - // configuration and opens no transport — which the refusing boundaries - // around it enforce, and which the eager-configuration probe fails. + // assembles. `` is Git's, and the profile is now the only route to it + // — nothing bootstraps the vocabulary any more — so the omission case + // below refuses this very draft. const draft = '# Plan\n\nwork here\n'; const result = yield* scoped(function* () { yield* refusing(); @@ -122,4 +114,32 @@ describe("describing the bundled profile costs nothing", () => { }); expect(refused.outcome).toBe("invalid"); }); + + it("refuses the same draft when the Git Plugin is not in the profile", function* () { + // The omission case. `` reaches a Plan only because the profile + // carries the Plugin that declares it: nothing bootstraps that vocabulary, + // so a validation assembled without the Plugin does not merely lose a + // provider — it does not know the name. + // + // This is what makes the positive case above a claim about the *profile* + // rather than about whatever the engine happens to declare. + const draft = '# Plan\n\nwork here\n'; + const result = yield* scoped(function* () { + yield* refusing(); + yield* adapters(); + // Deliberately empty: a `plan` command that assembled no Plugin. + const plugins = yield* installPlugins([], { command: "plan", args: ["plan"] }); + const validate = structuralValidation([], [yield* planComponentDescription()], plugins); + return yield* validate(draft); + }); + + expect(result.outcome).toBe("invalid"); + const unresolved = result.diagnostics.filter( + (diagnostic) => diagnostic.code === "component-unresolved", + ); + // Named, not merely refused: a draft rejected for some other reason would + // satisfy `invalid` while proving nothing about the vocabulary. + expect(`unresolved: ${unresolved.length > 0}`).toBe("unresolved: true"); + expect(unresolved.map((diagnostic) => diagnostic.component)).toContain("Dir"); + }); }); diff --git a/packages/cli/tests/plugin-cli.test.ts b/packages/cli/tests/plugin-cli.test.ts index 67e55d015..d3931f52b 100644 --- a/packages/cli/tests/plugin-cli.test.ts +++ b/packages/cli/tests/plugin-cli.test.ts @@ -53,6 +53,9 @@ function* useWorkspace( const DOCUMENT = "document body\n"; +/** The Plugin every run profile begins with. */ +const GIT = "@executablemd/git"; + describe("PC1 — selected Plugins compose around the document, in the order written", () => { it("nests two wrappers with the first one written outermost", function* () { yield* useWorkspace({ "doc.md": DOCUMENT }, function* (dir) { @@ -109,9 +112,10 @@ describe("PC1 — selected Plugins compose around the document, in the order wri ], { cwd: dir }, ).expect(); - // The written order is the whole order: XMD bundles nothing, so there is - // no prefix in front of what the caller selected. - expect(run.stdout).toContain("active: active, wrapper-one"); + // The bundled Plugin first, then the written order. XMD ships one + // Plugin and activates it for every run, so what a caller selects + // follows it rather than beginning the list. + expect(run.stdout).toContain(`active: ${GIT}, active, wrapper-one`); }); }); @@ -241,13 +245,16 @@ describe("PC3 — a selection that cannot be honored costs nothing", () => { }); describe("PC4 — nothing is discovered, and nothing unselected runs", () => { - it("loads no Plugin at all when none was selected", function* () { + it("loads no external module when none was selected", function* () { yield* useWorkspace({ "doc.md": DOCUMENT }, function* (dir) { const run = yield* runCli(["run", "doc.md"], { cwd: dir }).expect(); expect(run.stdout).toContain("document body"); expect(run.stdout).not.toContain("one open"); // The fixture directory holds a module that announces itself the moment - // anything loads it, and nothing did. + // anything loads it, and nothing did. The bundled Plugin is active here + // — every run carries it — but it is a statically imported value rather + // than something discovered, so nothing on this directory was read to + // find it. expect(run.stderr).not.toContain("inert-fixture"); }); }); @@ -361,10 +368,16 @@ describe("PC6 — every surface of one command sees one vocabulary", () => { }); }); - it("describes none of it where no Plugin was selected", function* () { + it("describes the bundled vocabulary, and no unselected Plugin's, by default", function* () { yield* useWorkspace({}, function* (dir) { const run = yield* runCli(["syntax"], { cwd: dir }).expect(); + // Nothing an operator did not select. expect(run.stdout).not.toContain("Greeting"); + // And everything the one bundled Plugin brings, because every syntax + // catalog is built from the profile the command runs with. + for (const name of ["Repository", "Worktree", "Dir", "PullRequest", "Issue"]) { + expect(`${name}: ${run.stdout.includes(name)}`).toBe(`${name}: true`); + } }); }); @@ -432,7 +445,7 @@ describe("PC9 — the review graph arrives only when it is selected", () => { ["run", `--plugin=${PACKAGE}`, `--plugin=${fixture("active.mjs")}`, "doc.md"], { cwd: dir }, ).expect(); - expect(run.stdout).toContain(`active: ${REVIEW}, active`); + expect(run.stdout).toContain(`active: ${GIT}, ${REVIEW}, active`); }); }); }); @@ -501,7 +514,7 @@ describe("PC7 — a package is selected by name, from where the command runs", ( expect(run.stderr).toContain("selected-package: installed for run"); // A Plugin's name is its own. The package it came from is how an // operator found it, and nothing resolves one to the other. - expect(run.stdout).toContain("active: renamed-by-its-author, active"); + expect(run.stdout).toContain(`active: ${GIT}, renamed-by-its-author, active`); // And the package installed beside it did nothing at all: presence is // not selection, and nothing here scans `node_modules`. expect(run.stderr).not.toContain("unselected-package"); diff --git a/packages/cli/tests/plugin-host.test.ts b/packages/cli/tests/plugin-host.test.ts index b4bc2682c..be6e4dc19 100644 --- a/packages/cli/tests/plugin-host.test.ts +++ b/packages/cli/tests/plugin-host.test.ts @@ -480,9 +480,13 @@ describe("PH6 — the selected review Plugin claims the commands that run a revi } }); - it("contributes nothing at all when it is not selected", function* () { - // XMD bundles no Plugin. A command that named none installs none, whichever - // command it is — the graph arrives because an operator asked for it. + it("contributes nothing at all when it is not in the list", function* () { + // A claim about this primitive, not about the product. `installPlugins()` + // installs the list it is handed and nothing else, so an empty list + // contributes nothing whichever command it is for. Which Plugins a command + // actually runs with — XMD's bundled prefix among them — is assembled by + // the caller, in `assembleRunProfile()`, and is not this function's to + // decide. for (const command of ["run", "syntax", "plan", "workflow"]) { const assembly = yield* scoped(function* () { return yield* installPlugins([], { command, args: [command] }); diff --git a/packages/cli/tests/run-composition-nested.test.ts b/packages/cli/tests/run-composition-nested.test.ts index 68784321d..f43bede6b 100644 --- a/packages/cli/tests/run-composition-nested.test.ts +++ b/packages/cli/tests/run-composition-nested.test.ts @@ -542,10 +542,11 @@ describe("ORC19 — a nested run profile", () => { fixture, ); - // Nothing was checked out for it. The managed root itself exists — every - // run creates one before a document expands — so what says the child did no - // work is that it never reached a repository to make a slot under. - expect(yield* exists(fixture.managed)).toBe(true); - expect(yield* exists(join(fixture.managed, "worktrees"))).toBe(false); + // Nothing was made for it at all — not a slot, and not the managed root + // itself. Installing the provider acquires nothing: the root is created by + // the first operation that needs somewhere to put a checkout, and this + // child refused before it had one. A run that touches no repository + // therefore leaves the filesystem exactly as it found it. + expect(yield* exists(fixture.managed)).toBe(false); }); }); diff --git a/packages/cli/tests/run-composition.test.ts b/packages/cli/tests/run-composition.test.ts index d23a72285..43e157db1 100644 --- a/packages/cli/tests/run-composition.test.ts +++ b/packages/cli/tests/run-composition.test.ts @@ -27,7 +27,10 @@ import { exists, readdir, readTextFile, writeTextFile } from "@effectionx/fs"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { join } from "node:path"; import { COMPOSITION_REGISTRATIONS } from "@executablemd/git"; -import { syntaxSymbols, useCommandComponents } from "../src/syntax.ts"; +import { syntaxSymbols } from "../src/syntax.ts"; +import { installPlugins } from "../src/plugin-host.ts"; +import type { CommandPlugins } from "../src/plugin-host.ts"; +import { BUNDLED_PLUGIN } from "../src/run-profile.ts"; import { DEFAULT_REPOSITORY_ROOT, unsupportedRepositories } from "../src/run-repositories.ts"; /** Every element an author can write that needs a repository provider. */ @@ -98,6 +101,19 @@ function ordinaryWithoutProvider(source: string, cwd: string): Operation { + return yield* installPlugins([BUNDLED_PLUGIN], { command, args: [command] }); +} + describe("ORC1 — describing the vocabulary reaches nothing", () => { it("builds the catalog without a subprocess, a service, a request or a lock", function* () { const managed = yield* useTempDirectory("xmd-orc1-managed-"); @@ -142,7 +158,7 @@ describe("ORC1 — describing the vocabulary reaches nothing", () => { }, { at: "min" }, ); - return yield* syntaxSymbols([]); + return yield* syntaxSymbols([], yield* gitProfile("syntax")); }); // The whole vocabulary is described. @@ -162,7 +178,10 @@ describe("ORC1 — describing the vocabulary reaches nothing", () => { // Registering the declarations installs no provider: the Apis still answer // with their own defaults, which is what a catalog is allowed to leave // behind. - yield* useCommandComponents(); + // Assembled rather than bootstrapped: the vocabulary belongs to the + // bundled Plugin, and installing it is what a run profile does. Installing + // it still installs no provider, which is the claim. + yield* gitProfile("run"); const failure = yield* raisedValue( collect(yield* execute({ ...inlineSource(``), stream: new InMemoryStream() })), ); @@ -174,16 +193,19 @@ describe("ORC1 — describing the vocabulary reaches nothing", () => { describe("ORC2 — one language, described everywhere and operated somewhere", () => { it("registers the same thirteen declarations the syntax catalog describes", function* () { - // The array itself, rather than a second list: `useCommandComponents()`, - // `installDocumentComponents()` and `useCompositionComponents()` all - // consume this one, so there is nothing for a runtime to disagree about. + // The array itself, rather than a second list: the Plugin's own + // registration and the catalog both consume this one, so there is nothing + // for a runtime to disagree about. expect(COMPOSITION_REGISTRATIONS).toHaveLength(13); expect([...COMPOSITION_REGISTRATIONS].map((registration) => registration.name).sort()).toEqual( [...COMPOSITION_NAMES].sort(), ); - // And the catalog every runtime builds describes each of them completely. - const catalog = yield* scoped(() => syntaxSymbols([])); + // And the catalog every runtime builds describes each of them completely, + // once the profile that owns them is assembled. + const catalog = yield* scoped(function* () { + return yield* syntaxSymbols([], yield* gitProfile("syntax")); + }); const builtIn = catalog.categories[1].entries; for (const name of COMPOSITION_NAMES) { const entry = builtIn.find((candidate) => candidate.name === name); diff --git a/packages/cli/tests/run-profile.test.ts b/packages/cli/tests/run-profile.test.ts new file mode 100644 index 000000000..4b7d06ffa --- /dev/null +++ b/packages/cli/tests/run-profile.test.ts @@ -0,0 +1,165 @@ +/** + * Which commands run with the bundled Plugin, and where it sits. + * + * Being in `ActivePlugins` *is* having the default prefix, so a command that + * executes no document must not carry the value at all — not even where the + * Plugin would decline to declare anything for it. `xmd workflow` therefore + * cannot be classified as a whole: `start`, `resume` and `fork` reach a + * document, and `list`, `status` and the rest read runs and execute nothing. + * + * The assembler is exercised with a loader that **throws**. A bundled value is + * carried, never loaded, and the reserved selector names that value rather than + * a module — so any module load at all is the failure this suite exists to + * catch, and nothing here needs a fixture on disk. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; +import { + assembleRunProfile, + BUNDLED_PLUGIN, + BUNDLED_SELECTOR, + nestedRunProfile, +} from "../src/run-profile.ts"; +import { installPlugins } from "../src/plugin-host.ts"; +import { Plugin } from "@executablemd/core/api"; +import { scoped } from "effection"; +import { selectPlugins } from "../src/plugin-selection.ts"; + +/** A loader that refuses: this suite never legitimately reaches a module. */ +// deno-lint-ignore require-yield +function* refusing(specifier: string): Operation { + throw new Error(`a module was loaded: ${specifier}`); +} + +/** The Plugin names one command line assembles, in installation order. */ +function* profile(args: readonly string[]): Operation { + const assembled = yield* assembleRunProfile(selectPlugins(args), refusing); + return assembled.map((plugin) => plugin.name); +} + +const GIT = "@executablemd/git"; + +describe("the bundled run profile", () => { + it("carries the bundled Plugin for the commands that reach a document", function* () { + for (const args of [["run", "doc.md"], ["plan", "doc.md"], ["syntax"], ["doc.md"]]) { + expect(`${args.join(" ")}: ${(yield* profile(args)).join(",")}`).toBe( + `${args.join(" ")}: ${GIT}`, + ); + } + }); + + it("carries it for the workflow actions that execute a document", function* () { + for (const action of ["start", "resume", "fork"]) { + const args = ["workflow", action, "flow.md"]; + expect(`${action}: ${(yield* profile(args)).join(",")}`).toBe(`${action}: ${GIT}`); + } + }); + + it("carries it for no workflow action that only reads or manages runs", function* () { + for (const action of ["list", "status", "history", "export", "answer", "cancel", "delete"]) { + const args = ["workflow", action]; + expect(`${action}: ${(yield* profile(args)).join(",")}`).toBe(`${action}: `); + } + }); + + it("carries it for neither the test root nor a command that runs nothing", function* () { + for (const args of [["test"], ["test", "suite.md"], ["upgrade"], ["workflow"]]) { + expect(`${args.join(" ")}: ${(yield* profile(args)).join(",")}`).toBe(`${args.join(" ")}: `); + } + }); + + it("resolves the reserved selector without loading anything", function* () { + // Once, five times, and beside an ineligible command: the selector names + // the value this profile already holds, so it is consumed rather than + // resolved. The refusing loader is what proves "consumed". + expect(yield* profile(["--plugin", BUNDLED_SELECTOR, "run", "doc.md"])).toEqual([GIT]); + expect( + yield* profile([ + "--plugin", + BUNDLED_SELECTOR, + "--plugin", + BUNDLED_SELECTOR, + "--plugin=git", + "run", + "doc.md", + ]), + ).toEqual([GIT]); + + // And it cannot turn a command that executes nothing into one that does. + expect(yield* profile(["--plugin", BUNDLED_SELECTOR, "workflow", "list"])).toEqual([]); + expect(yield* profile(["--plugin", BUNDLED_SELECTOR, "test"])).toEqual([]); + }); + + it("gives a nested run child the bundled prefix without duplicating it", function* () { + // The outer command held the bundled value itself — an operator who wrote + // the reserved selector holds exactly this object. Prefixing it again would + // be the host duplicating itself, so it is dropped by identity. + expect(nestedRunProfile([BUNDLED_PLUGIN]).map((plugin) => plugin.name)).toEqual([GIT]); + expect(nestedRunProfile([]).map((plugin) => plugin.name)).toEqual([GIT]); + + // And an unrelated Plugin keeps its place behind the prefix. + const other = Plugin({ + name: "other", + // deno-lint-ignore require-yield + *install(): Operation { + return undefined; + }, + }); + expect(nestedRunProfile([other]).map((plugin) => plugin.name)).toEqual([GIT, "other"]); + }); + + it("refuses an impostor claiming the bundled name rather than dropping it", function* () { + // A distinct object that merely *says* it is `@executablemd/git`. Comparing + // names would make it vanish — silently replaced by the trusted value, + // which is the outcome an impostor would want. Comparing identity keeps it + // in the list, where a name collision has always been refused. + const impostor = Plugin({ + name: GIT, + // deno-lint-ignore require-yield + *install(): Operation { + return undefined; + }, + }); + expect(impostor).not.toBe(BUNDLED_PLUGIN); + + const assembled = nestedRunProfile([impostor]); + expect(assembled).toHaveLength(2); + expect(assembled[1]).toBe(impostor); + + const failure = yield* raised(installPlugins(assembled, { command: "run", args: ["run"] })); + expect(String(failure)).toContain(`two selected Plugins are named ${GIT}`); + }); + + it("leaves the selector's own spelling to the host, not a registry", function* () { + // The reserved word is `git`. A module *named* `@executablemd/git` is not + // the selector and is loaded like any other specifier — where it would then + // meet `admitPlugins()`'s duplicate-name refusal, which is the whole point + // of keeping idempotence to the host's own selector. + const failure = yield* reached(["--plugin", GIT, "run", "doc.md"]); + expect(String(failure)).toContain(`a module was loaded: ${GIT}`); + }); +}); + +/** What a command line raised, when reaching a module is the expected outcome. */ +function* reached(args: readonly string[]): Operation { + try { + yield* profile(args); + return undefined; + } catch (error) { + return error; + } +} + +/** What an operation raised, when a refusal is the expected outcome. */ +function* raised(operation: Operation): Operation { + try { + yield* scoped(function* () { + return yield* operation; + }); + return undefined; + } catch (error) { + return error; + } +} diff --git a/packages/cli/tests/support/run-markdown-tier.ts b/packages/cli/tests/support/run-markdown-tier.ts index 79ab06835..26e12a968 100644 --- a/packages/cli/tests/support/run-markdown-tier.ts +++ b/packages/cli/tests/support/run-markdown-tier.ts @@ -81,7 +81,8 @@ export function runMarkdownTier(document: string): Operation { includes: ["components", "."], // The Plugin a run would have selected, because a `host="run"` child gets // what `xmd run` gets: the child installs it again in its own scope, with - // command `run`. XMD bundles none, so this harness names it exactly as an + // command `run`. XMD bundles only Git, so this harness names the rest + // exactly as an // operator does. plugins: [reviewPlugin], pluginArgs: [], diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index c943c1ec4..957363ea3 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -29,6 +29,8 @@ import { API } from "@executablemd/runtime"; import { CORE_COMPONENT_NAMES } from "@executablemd/core"; import type { PropsSchema, SyntaxSymbols } from "@executablemd/core"; import { renderSyntaxJson, renderSyntaxMarkdown, syntaxSymbols } from "../src/syntax.ts"; +import { installPlugins } from "../src/plugin-host.ts"; +import { BUNDLED_PLUGIN } from "../src/run-profile.ts"; function* useWorkspace( files: Record, @@ -211,7 +213,13 @@ describe("Tier SX — the run profile the command describes", () => { }); it("ORC1: names all thirteen repository-composition components, with contracts", function* () { - const catalog = yield* syntaxSymbols([]); + // Described because the profile carries the Plugin that declares them. + // `xmd syntax` renders the run profile's vocabulary, and the repository + // components are the bundled Plugin's rather than the engine's own. + const catalog = yield* syntaxSymbols( + [], + yield* installPlugins([BUNDLED_PLUGIN], { command: "syntax", args: ["syntax"] }), + ); const entries = catalog.categories[1].entries; const builtIn = names(entries); diff --git a/packages/cli/tests/test-root-profile.test.ts b/packages/cli/tests/test-root-profile.test.ts new file mode 100644 index 000000000..6ce422f38 --- /dev/null +++ b/packages/cli/tests/test-root-profile.test.ts @@ -0,0 +1,117 @@ +/** + * The `xmd test` root is not a run profile, and its children are. + * + * XMD bundles one Plugin and activates it for the commands that execute or + * describe a document. `xmd test` is not one of them: the root is a harness + * that decides what its children run, and a harness that quietly gained the + * repository vocabulary would be claiming names its children are entitled to + * shadow. + * + * So the same element resolves in two places and not in a third, and that + * asymmetry is the whole subject here: `` inside a nested + * `` child is Git's component, doing Git's work, while + * `` written at the test root resolves to nothing at all. + * + * Driven through the real binary rather than an in-process assembly, because + * what is under test is which profile each *command* runs with — and a command + * is the one thing an in-process harness has to invent. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { runCli } from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { writeTextFile } from "@effectionx/fs"; +import { join } from "node:path"; +import type { Operation } from "effection"; + +/** One document, run by one command, from a directory naming no repository. */ +function* xmd( + args: readonly string[], + source: string, + name = "subject.md", +): Operation<{ code: number | undefined; output: string }> { + const directory = yield* useTempDirectory("xmd-test-profile"); + const home = yield* useTempDirectory("xmd-test-profile-home"); + const document = join(directory, name); + yield* writeTextFile(document, source); + const run = yield* runCli([...args, document], { + cwd: directory, + env: { HOME: home }, + timeout: 180_000, + }).join(); + return { code: run.code, output: `${run.stdout}\n${run.stderr}` }; +} + +describe("the profile each command runs with", () => { + it("resolves Git's vocabulary for an ordinary run", function* () { + // The positive control for both cases below: the element, the spelling and + // the directory are the same everywhere, so what differs is only which + // profile the command carries. + const { code, output } = yield* xmd(["run"], 'a run resolves this\n'); + expect(`${code}: ${output.includes("a run resolves this")}`).toBe("0: true"); + }); + + it("resolves none of it at the test root", function* () { + // Not a failure to *perform* the work — a failure to know the name at all. + // The root profile carries no Plugin, so `` is not a component here. + const { code, output } = yield* xmd( + ["test"], + [ + "", + "", + '', + "", + 'the root must not resolve this', + "", + "", + "", + "", + "", + ].join("\n"), + "root.md", + ); + expect(code).not.toBe(0); + // The intended failure, named. Any non-zero exit would otherwise satisfy + // this — including one from a CLI fault that has nothing to do with which + // profile the command carries. + expect(output).toContain("Cannot resolve component: Dir"); + expect(output).not.toContain("the root must not resolve this"); + }); + + it("resolves it inside a nested run child", function* () { + // The same element, one scope down, under a child that assembles the run + // profile for itself. The root still has none of it; the child has all of + // it — which is what "a child does not inherit what its parent declined" + // looks like from the outside. + const { code, output } = yield* xmd( + ["test"], + [ + "", + "", + '', + "", + 'the child resolves this"} as="child">', + '', + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + "nested.md", + ); + // The proof is inside the document. `` binds the child's + // output rather than printing it, so what discriminates here is the pair of + // assertions the child ran: that it succeeded at all, and that what it + // rendered is what `` renders. A child that resolved no component + // fails the first; one that resolved a different one fails the second. + expect(code).toBe(0); + // And it resolved the component rather than reporting it missing, which is + // exactly what the root case above sees. + expect(output).not.toContain("Cannot resolve component: Dir"); + }); +}); diff --git a/packages/cli/tests/workflow-agent.test.ts b/packages/cli/tests/workflow-agent.test.ts index c66e6b063..171ef1feb 100644 --- a/packages/cli/tests/workflow-agent.test.ts +++ b/packages/cli/tests/workflow-agent.test.ts @@ -13,6 +13,7 @@ * rather than restated here: a fixture a test rewrites is a fixture nobody runs. */ +import { gitDirectoryEntry } from "@executablemd/git"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn } from "effection"; @@ -262,7 +263,12 @@ function runFixture( [ { components: [...agentIdentityComponents()], - evaluation: yield* evaluationProfile(database, options.evaluation ?? {}), + evaluation: yield* evaluationProfile(database, { + // This harness stands in for the bundled XMD workflow + // profile, which supplies Git's directory entry. + directory: gitDirectoryEntry(), + ...(options.evaluation ?? {}), + }), }, ], ), diff --git a/packages/cli/tests/workflow-host.test.ts b/packages/cli/tests/workflow-host.test.ts index 0d53edd6a..9075dcb02 100644 --- a/packages/cli/tests/workflow-host.test.ts +++ b/packages/cli/tests/workflow-host.test.ts @@ -28,12 +28,12 @@ import { GITHUB_ISSUES_ENV, GitHubIssuesConfigError, gitHubIssuesConfiguration, -} from "../src/github-issues-config.ts"; +} from "../../git/src/deno/issue/configuration.ts"; import { GITHUB_PULL_REQUESTS_ENV, GitHubPullRequestsConfigError, gitHubPullRequestsConfiguration, -} from "../src/github-pull-requests-config.ts"; +} from "../../git/src/deno/composition/pull-request-configuration.ts"; import { HELPER_MODE, helperCommand, @@ -159,7 +159,7 @@ describe("Tier WFH — workflow host boundary", () => { }); }); -describe("Tier WFH — GitHub issue handling is host configuration", () => { +describe("Tier WFH — GitHub issue handling is adapter configuration", () => { /** * H1. Which trackers a run may reach is the operator's to state, so it is * read from the environment and from nowhere a document can influence. @@ -233,7 +233,7 @@ describe("Tier WFH — GitHub issue handling is host configuration", () => { }); }); -describe("Tier WFH — GitHub pull-request reading is host configuration", () => { +describe("Tier WFH — GitHub pull-request reading is adapter configuration", () => { /** * H2. Which pull requests a run may *read* is the operator's to state, and it * is stated as `allowed` — the list of places this host permits — rather than diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts index 408410301..35925684c 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -291,12 +291,11 @@ describe("Tier WFI — what a run hands to canonical core", () => { ); expect(preparing.length).toEqual(1); expect(preparing[0]?.admissions?.length).toEqual(1); - // Every installation this run was given is one of four things, and none of + // Every installation this run was given is one of three things, and none of // them is a second execution: one `executeInstalled()`, not one per phase. // A run-contract installation carries its admission; a bundle carries its - // own admission and no preparation; the bundled Git Plugin carries the two - // journal admissions that let a replay recognize the Git-host and Issue - // records this history holds; and the fragment-evaluation profile carries a + // own admission and no preparation; and the fragment-evaluation profile + // carries a // ceiling and no admission at all, because stating what a generated // fragment may do is not a claim about this run's history. const profiles = (execution?.installations ?? []).filter( @@ -310,16 +309,21 @@ describe("Tier WFI — what a run hands to canonical core", () => { } } // The exact contribution, rather than a rule each one satisfies: this run - // installs no bundle, so what reaches core is the run contract's one - // admission and the Git Plugin's two. A contribution that went missing, an - // installation that arrived twice, or an admission that was dropped on the - // way all change this list. + // installs no bundle, so what the *workflow command* hands core is the run + // contract's one admission and nothing else. + // + // The bundled Git Plugin's two admissions are not here, and their absence + // is the point. They arrive with the command's profile — assembled once by + // `assembleRunProfile()` and threaded into every execution — rather than + // being built a second time by the workflow command, which is what it used + // to do. This harness drives `runWorkflow()` directly, with no profile, so + // what it sees is the run's own contribution alone. expect( (execution?.installations ?? []) .filter((candidate) => candidate.evaluation === undefined) .map((candidate) => candidate.admissions?.length ?? 0) .toSorted((left, right) => left - right), - ).toEqual([1, 2]); + ).toEqual([1]); // The ceiling is stated exactly once, and it is a real one: a run that // installed no profile, or an empty one, would leave `` with diff --git a/packages/git/mod.ts b/packages/git/mod.ts index 30eae3391..4d8882bd0 100644 --- a/packages/git/mod.ts +++ b/packages/git/mod.ts @@ -55,6 +55,29 @@ export { default } from "./src/plugin.ts"; export { gitPlugin } from "./src/plugin.ts"; +/** + * ``'s entry in a workflow run's generated-XMD write table. + * + * Published because the component is this package's and the table is + * Workflow's: a host assembling the generated-evaluation profile asks the + * owner for the entry rather than either package writing the identity twice. + */ +export { gitDirectoryEntry } from "./src/composition/definitions.ts"; +/** + * Whether *this* Plugin declares anything for a command. + * + * Published because a host that bundles it has to decide, before it assembles + * a profile, whether the command it is about to run is one this vocabulary + * belongs to. Carrying the value where it declares nothing would still put it + * in the active list, and being in the active list *is* having the default + * prefix — so the host asks rather than guesses, and asks the same predicate + * `install()` itself uses. + * + * Named for the Plugin it classifies. A bare `declaresFor` at a package root + * reads like a general question about Plugins, and this answers only for the + * one this package ships. + */ +export { declaresFor as gitPluginDeclaresFor } from "./src/plugin.ts"; export { workflowInstallation } from "./src/installation.ts"; export { Git, diff --git a/packages/git/src/composition/definitions.ts b/packages/git/src/composition/definitions.ts index 5ff733312..9da2459fd 100644 --- a/packages/git/src/composition/definitions.ts +++ b/packages/git/src/composition/definitions.ts @@ -12,6 +12,8 @@ */ import { formDispatcher } from "@executablemd/core"; +import { directoryEntry } from "@executablemd/core/host"; +import type { FragmentEntry } from "@executablemd/core/host"; import type { FunctionComponentDefinition } from "@executablemd/core"; import { form as dirForm, props as dirProps } from "./components/Dir.ts"; @@ -31,3 +33,27 @@ const fn = formDispatcher(dirForm); export function dirDefinition(): FunctionComponentDefinition { return { kind: "function", name: "Dir", props: dirProps, fn }; } + +/** + * The origin released builds retained for ``'s generated-XMD entry. + * + * Written out rather than derived from this package's own name. It identifies + * *retained history*: every journal a released build wrote holds this string, + * and a continuation compares the write table position by position. The + * component moved packages; what a run already recorded did not. + */ +export const RETAINED_DIRECTORY_ORIGIN = "@executablemd/workflow/composition"; + +/** + * ``'s entry in a workflow run's generated-XMD write table. + * + * Stated here, beside the definition the ordinary registration uses, because + * both describe the same component and a second copy of either would drift. + * A host assembling the generated-evaluation profile asks for this rather than + * writing the identity out again. + */ +export function gitDirectoryEntry(): FragmentEntry { + return directoryEntry({ origin: RETAINED_DIRECTORY_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ + "@executablemd/workflow/composition/dir-v2#Dir", + ]); +} diff --git a/packages/git/src/deno/run-composition/ambient.ts b/packages/git/src/deno/run-composition/ambient.ts index 489246dd3..d46917bb9 100644 --- a/packages/git/src/deno/run-composition/ambient.ts +++ b/packages/git/src/deno/run-composition/ambient.ts @@ -24,10 +24,12 @@ * * ## Discovery is not an operation the document asked for * - * It happens once, before root expansion, from the invocation's starting - * directory. Being outside a repository is not a startup failure: a document - * that never asks for a Repository-dependent operation runs exactly as it would - * anywhere else, and only an element that needs one refuses. + * It happens once, on the first ambient request, from the invocation's + * starting directory — never at installation. Being outside a repository is not + * a startup failure: a document that never asks for a Repository-dependent + * operation runs exactly as it would anywhere else and never looks at all, and + * only an element that needs one refuses. The answer, including "there is + * none", is kept for the rest of the execution. * * The `origin` is read the same way, and its absence is likewise not a failure. * A repository with no origin is a perfectly good Repository for a Worktree, a diff --git a/packages/git/src/deno/run-composition/identity.ts b/packages/git/src/deno/run-composition/identity.ts index a295a972d..c5d45ad4f 100644 --- a/packages/git/src/deno/run-composition/identity.ts +++ b/packages/git/src/deno/run-composition/identity.ts @@ -8,8 +8,9 @@ * attributing it to `Executable.md workflow` would put a name in their history * that nobody there recognizes. * - * So the invoking user's effective identity is captured once, before the - * document expands, and used for `` alone. + * So the invoking user's effective identity is captured once, by the first + * `` that needs it, and used for that alone. A run that commits + * nothing asks the host nothing about who it is. * * ## Captured from the trusted host, and nowhere else * @@ -17,8 +18,9 @@ * Git would use: the `GIT_*_NAME`/`GIT_*_EMAIL` variables, then `user.name` and * `user.email` from the configuration Git itself resolves, then whatever the * host can auto-detect. Reading it takes the caller's own environment and the - * directory the command was run in, which is why it happens here — at the - * trusted entrypoint's provider construction, before any document code exists. + * directory the command was run in, which is why the reader is the trusted + * entrypoint's — constructed at provider installation, where no document code + * exists, and consulted later from the commit that needs an answer. * * It is not a prop, a Context value, a component result or a middleware answer, * and no document can read it, replace it or ask for a different one. diff --git a/packages/git/src/deno/run-composition/provider.ts b/packages/git/src/deno/run-composition/provider.ts index b8ceb009e..44f86631d 100644 --- a/packages/git/src/deno/run-composition/provider.ts +++ b/packages/git/src/deno/run-composition/provider.ts @@ -29,13 +29,19 @@ * * ## Discovery costs nothing until something asks * - * The ambient repository is discovered once, before root expansion, from the - * directory the command was run in. Being outside a repository is not a startup - * failure: it is remembered as "there is none", and only an element that needs - * one refuses. + * Installing this provider acquires nothing. The ambient repository is + * discovered when the first element asks for one, from the directory the + * command was run in, and the answer is kept for the rest of the execution. + * Being outside a repository is not a startup failure: it is remembered as + * "there is none", and only an element that needs one refuses. A document that + * asks for no repository never looks — and a managed root, a lease, a Git + * session and a commit identity are acquired on the same terms, each by the + * first operation that needs it. */ -import type { Operation } from "effection"; +import { race, suspend, useScope, withResolvers } from "effection"; +import type { Operation, Scope } from "effection"; + import { randomUUID } from "node:crypto"; import { getExpansion } from "@executablemd/core"; import { RepositoryComposition } from "../../composition/api.ts"; @@ -94,7 +100,9 @@ import { import { useGitHubIssues, type GitHubIssuesOptions } from "../issue/github.ts"; import { selectionRegistry } from "../selections.ts"; import { discoverAmbientRepository, type AmbientRepository } from "./ambient.ts"; +import type { Leases } from "./leases.ts"; import { captureCommitIdentity, denoIdentityReader, type IdentityReader } from "./identity.ts"; +import type { GitCommitIdentity } from "./identity.ts"; import { selectManagedRepository, selectManagedWorktree } from "./checkouts.ts"; import { NoAmbientRepositoryError } from "./errors.ts"; import { useLeases } from "./leases.ts"; @@ -115,6 +123,9 @@ import { realpath } from "node:fs/promises"; import { ensureDir } from "@effectionx/fs"; import { until } from "effection"; +/** What `withResolvers()` hands back, named so a capability can hold a pair. */ +type Resolvers = ReturnType>; + /** The canonical directory this path resolves to, or the path as written. */ function* canonicalPath(path: string): Operation { try { @@ -155,6 +166,57 @@ interface SelectedRepository { readonly commonDirectory: string; } +/** + * One capability, acquired at most once, owned by the installation scope. + * + * Installing this provider must reach nothing: no directory, no lease, no + * `git`, no identity read. What each capability costs is paid by the first + * operation that actually needs it, and by nothing else — a document that + * writes no repository component pays none of it. + * + * Four properties, and each one is why this is not a plain memoized function: + * + * - **Single-flight.** The first ask starts the acquisition; an ask that + * arrives while it is still running awaits the same task rather than starting + * a second. Two concurrent `` elements share one managed root. + * - **Owned by the installation scope.** The work runs in `owner`, not in + * whichever element happened to ask first, and then *suspends* — because a + * task that returns releases what it acquired. A temporary directory, a + * session and a lease therefore last as long as the execution does, rather + * than as long as the element that first needed them. + * - **Settled answers are answers.** An absent ambient repository and a host + * that cannot state a commit identity are values, not failures, and are + * remembered as such: the second ask does not rediscover them. + * - **Nothing half-made is observable.** A caller cancelled while waiting + * abandons its own wait and not the acquisition; a failed acquisition is + * remembered as failed, and every later ask is told the same thing rather + * than retrying into partially made state. + */ +function lazily(owner: Scope, acquire: () => Operation): () => Operation { + let settled: { readonly ready: Resolvers; readonly failed: Resolvers } | undefined; + return function* (): Operation { + if (settled === undefined) { + const ready = withResolvers(); + const failed = withResolvers(); + settled = { ready, failed }; + owner.run(function* () { + let value: T; + try { + value = yield* acquire(); + } catch (error) { + failed.reject(error instanceof Error ? error : new Error(String(error))); + return; + } + ready.resolve(value); + // Held for the scope's life. Returning here would end the task, and + // ending a task releases everything it acquired. + yield* suspend(); + }); + } + return yield* race([settled.ready.operation, settled.failed.operation]); + }; +} + /** * Install the ordinary repository vocabulary for the current scope and below. * @@ -169,47 +231,74 @@ export function* useRunComposition(options: RunCompositionOptions): Operation(); + const registered: RegisteredCheckout[] = []; + const evidence: PushEvidence[] = []; + // The Git session's root is also `HOME`, so Git reads no configuration // belonging to whoever is running the command — the same isolation a workflow // run gets, applied to a repository the caller owns. - const home = yield* host.useDirectory(); - const git: GitSession = gitSession(host, home); + const useGit = lazily(owner, function* (): Operation { + return gitSession(host, yield* host.useDirectory()); + }); // Created before it is canonicalized, and canonicalized once. A path that // does not exist yet resolves to itself, so a root canonicalized before it // was made would be one spelling on the execution that created it and another // on every execution afterwards — two spellings, two digests, two slots for // one Repository, and no lock between them. - yield* ensureDir(options.root); - const root = yield* canonicalPath(options.root); - const leases = yield* useLeases(root); - const selections = selectionRegistry(); - const registered: RegisteredCheckout[] = []; - const evidence: PushEvidence[] = []; + const useRoot = lazily(owner, function* (): Operation { + yield* ensureDir(options.root); + return yield* canonicalPath(options.root); + }); + const useSlots = lazily(owner, function* (): Operation { + return yield* useLeases(yield* useRoot()); + }); + // Fresh, opaque and never derived from anything a document wrote. It names // this execution to a service; it is not addressable, reusable or observable. - const invocation = randomUUID(); + // Minted once, on the first operation that needs to name this execution. + const useInvocation = lazily(owner, function* (): Operation { + return randomUUID(); + }); - // Once, before root expansion, from the trusted host: the four strings a - // commit records about who made it. A host that cannot say is remembered as - // not saying, and only `` refuses. - const identity = yield* captureCommitIdentity( - options.identity ?? denoIdentityReader(options.cwd), - ); + // From the trusted host: the four strings a commit records about who made it. + // A host that cannot say is remembered as not saying, and only `` + // refuses — so the read happens when a commit needs it, not before. + const useIdentity = lazily(owner, function* (): Operation { + return yield* captureCommitIdentity(options.identity ?? denoIdentityReader(options.cwd)); + }); - // Once, before root expansion. A repository this command was not run inside - // is remembered as absent rather than refused, so a document that never asks - // for one runs exactly as it would anywhere else. - const ambient = yield* discoverAmbientRepository(git, options.cwd); - const ambientSelection = - ambient === undefined ? undefined : registerAmbient(ambient, selections, registered); + // A repository this command was not run inside is remembered as absent + // rather than refused, so a document that never asks for one runs exactly as + // it would anywhere else — and never looks. + const useAmbient = lazily(owner, function* (): Operation<{ + readonly ambient: AmbientRepository | undefined; + readonly selection: RepositorySelection | undefined; + }> { + const ambient = yield* discoverAmbientRepository(yield* useGit(), options.cwd); + return { + ambient, + selection: + ambient === undefined ? undefined : registerAmbient(ambient, selections, registered), + }; + }); yield* RepositoryComposition.around( { *selectRepository([request]: [RepositoryRequest]): Operation { - const slot = repositorySlot(root, request.locator, request.name); - yield* leases.hold("repository", slot, `repository ${JSON.stringify(request.name)}`); - const managed = yield* selectManagedRepository(git, host, slot, request); + const slot = repositorySlot(yield* useRoot(), request.locator, request.name); + yield* (yield* useSlots()).hold( + "repository", + slot, + `repository ${JSON.stringify(request.name)}`, + ); + const managed = yield* selectManagedRepository(yield* useGit(), host, slot, request); const identity: RepositoryIdentity = Object.freeze({ name: request.name, locatorFingerprint: managed.metadata.locatorFingerprint, @@ -241,9 +330,13 @@ export function* useRunComposition(options: RunCompositionOptions): Operation new RepositorySelectionError(""), ); - const slot = worktreeSlot(root, owner.commonDirectory, request.name); - yield* leases.hold("worktree", slot, `worktree ${JSON.stringify(request.name)}`); - const managed = yield* selectManagedWorktree(git, slot, { + const slot = worktreeSlot(yield* useRoot(), owner.commonDirectory, request.name); + yield* (yield* useSlots()).hold( + "worktree", + slot, + `worktree ${JSON.stringify(request.name)}`, + ); + const managed = yield* selectManagedWorktree(yield* useGit(), slot, { name: request.name, branch: request.branch, base: request.base, @@ -270,6 +363,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { + const { selection: ambientSelection } = yield* useAmbient(); if (ambientSelection === undefined) { // This profile *has* ambient repositories; this invocation is not in // one. The refusal says how to run inside one rather than reporting @@ -317,7 +411,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation", ); return yield* liveSwitch( - liveCheckout(git, checkout, invocation_.workingDirectory), + liveCheckout(yield* useGit(), checkout, invocation_.workingDirectory), invocation_.branch, invocation_.base, ); @@ -333,7 +427,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation", ); return yield* liveCommit( - liveCheckout(git, checkout, invocation_.workingDirectory), + liveCheckout(yield* useGit(), checkout, invocation_.workingDirectory), admitCommitMessage(invocation_.message), invocation_.messageSource, - identity, + // Read here, because this is the operation that needs it: a run that + // commits nothing asks the host nothing about who it is. + yield* useIdentity(), ); }, @@ -358,7 +454,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation", ); - const published = yield* livePush(host, git, checkout); + const published = yield* livePush(host, yield* useGit(), checkout); // Only after the provider has verified a performed or adopted // publication. A refused or unreadable one leaves no entry, so nothing // it did authorizes a pull request. @@ -407,7 +503,7 @@ export function* useRunComposition(options: RunCompositionOptions): Operation { yield* withStorage(root, function* () { const database = yield* createRun(); yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -542,6 +544,7 @@ describe("workflow Git.Add composition routing", () => { const forged = yield* createRun({ runId: "loaded-copy-forged" }); const failure = yield* raised( scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( forged, scoped(function* () { diff --git a/packages/git/tests/git-add.test.ts b/packages/git/tests/git-add.test.ts index 1927bbbbd..aed35512c 100644 --- a/packages/git/tests/git-add.test.ts +++ b/packages/git/tests/git-add.test.ts @@ -59,6 +59,7 @@ import { stagedPaths, subcommands, } from "./support/composition.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; /** * A tracked file at the root, one in a subdirectory, and one more of each. @@ -142,6 +143,7 @@ function runForged( options: GitWorkspaceOptions, ): Operation { return scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -618,6 +620,7 @@ describe("workflow Git.Add selection", () => { const database = yield* createRun(); const counting = countingHost(); const output = yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -844,6 +847,7 @@ describe("workflow Git.Add pathspec text", () => { let refused: unknown; const output = yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -948,6 +952,7 @@ describe("workflow Git.Add request ownership", () => { }); yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/git/tests/git-commit-durability.test.ts b/packages/git/tests/git-commit-durability.test.ts index c48632b1c..7163f74c9 100644 --- a/packages/git/tests/git-commit-durability.test.ts +++ b/packages/git/tests/git-commit-durability.test.ts @@ -44,6 +44,7 @@ import { withStorage, } from "../../workflow/tests/support/storage.ts"; import { useBareRemote } from "./support/git-remotes.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; import { causedBy, countingHost, @@ -519,6 +520,7 @@ describe("workflow Git.Commit composition routing", () => { yield* withStorage(root, function* () { const database = yield* createRun(); yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -539,6 +541,7 @@ describe("workflow Git.Commit composition routing", () => { const forged = yield* createRun({ runId: "loaded-copy-forged" }); const failure = yield* raised( scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( forged, scoped(function* () { diff --git a/packages/git/tests/git-commit.test.ts b/packages/git/tests/git-commit.test.ts index ad8574fb6..9110386ff 100644 --- a/packages/git/tests/git-commit.test.ts +++ b/packages/git/tests/git-commit.test.ts @@ -72,6 +72,7 @@ import { workspaceText, writeCheckoutFile, } from "./support/composition.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; import { dropRootClose } from "./support/replay.ts"; import type { RepositorySelection } from "../src/composition/selection.ts"; @@ -176,6 +177,7 @@ function runForged( options: GitWorkspaceOptions, ): Operation { return scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -628,6 +630,7 @@ describe("workflow Git.Commit leading content", () => { /** Run one document with `` registered for it. */ function withBody(database: WorkflowRunDatabase, text: string, source: string): Operation { return scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -964,6 +967,7 @@ describe("workflow Git.Commit selection", () => { const database = yield* createRun(); const counting = countingHost(); const output = yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -1144,6 +1148,7 @@ describe("workflow Git.Commit request ownership", () => { }); yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/git/tests/git-switch-durability.test.ts b/packages/git/tests/git-switch-durability.test.ts index dc4ae4379..6aae9d475 100644 --- a/packages/git/tests/git-switch-durability.test.ts +++ b/packages/git/tests/git-switch-durability.test.ts @@ -64,6 +64,7 @@ import { survivingRoots, workspaceText, } from "./support/composition.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; const REMOTE = { @@ -222,6 +223,7 @@ function runObserved( options: GitWorkspaceOptions, ): Operation { return scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/git/tests/git-switch.test.ts b/packages/git/tests/git-switch.test.ts index 38bef31e8..9821d30dc 100644 --- a/packages/git/tests/git-switch.test.ts +++ b/packages/git/tests/git-switch.test.ts @@ -68,6 +68,7 @@ import { survivingRoots, workspaceText, } from "./support/composition.ts"; +import { gitPluginAdmissions } from "./support/composition.ts"; import type { LoadedGitApi } from "./support/composition.ts"; import { committedRoot, dropRootClose, latestRoot, publishedRoots } from "./support/replay.ts"; @@ -148,6 +149,7 @@ function runForged( options: GitWorkspaceOptions, ): Operation { return scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -737,6 +739,7 @@ describe("workflow Git.Switch selection", () => { const database = yield* createRun(); const counting = countingHost(); const output = yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -936,6 +939,7 @@ describe("workflow Git composition routing", () => { yield* withStorage(root, function* () { const database = yield* createRun(); const output = yield* scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { @@ -971,6 +975,7 @@ describe("workflow Git composition routing", () => { const forgedRun = yield* createRun({ runId: "loaded-copy-forged" }); const failure = yield* raised( scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( forgedRun, scoped(function* () { @@ -1087,6 +1092,7 @@ describe("workflow Git.Switch request ownership", () => { const database = yield* createRun(); const perform = (options: GitWorkspaceOptions): Operation => scoped(function* () { + yield* gitPluginAdmissions(); return yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/git/tests/public-entrypoint.test.ts b/packages/git/tests/public-entrypoint.test.ts index 8395d1a04..d9f44eeb5 100644 --- a/packages/git/tests/public-entrypoint.test.ts +++ b/packages/git/tests/public-entrypoint.test.ts @@ -42,6 +42,16 @@ const INTERNAL: readonly string[] = [ "gitHubPullRequestsConfiguration", ]; +/** + * What the root entrypoint publishes for a host that bundles this Plugin. + * + * `gitPlugin` is the value a distribution carries; `gitPluginDeclaresFor` is + * how the host asks, before assembling a profile, whether this command's + * profile carries it at all. Both are host seams rather than document + * vocabulary, and both are named for the Plugin they belong to. + */ +const ROOT_PUBLISHED: readonly string[] = ["gitPlugin", "gitPluginDeclaresFor"]; + /** * GitHub names this package publishes on purpose. * @@ -71,6 +81,13 @@ describe("what @executablemd/git publishes", () => { expect(PUBLISHED.filter((name) => !names.includes(name))).toEqual([]); }); + it("publishes the host seams a bundled profile needs", function* () { + const published = yield* until(import("@executablemd/git")); + const names = Object.keys(published); + expect(names.length > 0).toBe(true); + expect(ROOT_PUBLISHED.filter((name) => !names.includes(name))).toEqual([]); + }); + it("publishes no package-local seam from either entrypoint", function* () { for (const specifier of ["@executablemd/git", "@executablemd/git/deno"]) { const published = yield* until(import(specifier)); diff --git a/packages/git/tests/run-composition-lazy.test.ts b/packages/git/tests/run-composition-lazy.test.ts new file mode 100644 index 000000000..1b10ae747 --- /dev/null +++ b/packages/git/tests/run-composition-lazy.test.ts @@ -0,0 +1,158 @@ +/** + * What installing the ordinary repository provider costs, capability by + * capability. + * + * Installing it must cost nothing at all, and a document that writes no + * repository component must keep costing nothing. Proving that by looking for + * an absent managed root proves only one of six things — so each capability is + * counted separately here, and the claim is that *every* count is zero. + * + * Counted rather than forbidden, because these are not all refusable: a Git + * session is a temporary directory, an invocation identity is a random string, + * and neither has a boundary that can throw. What each one has is a moment it + * is acquired, and counting those moments says exactly when. + * + * The same counters then prove the other half: an operation that needs a + * capability acquires it *once*, however many times it is asked for. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { all, scoped, spawn } from "effection"; +import type { Operation } from "effection"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { collect, execute, inlineSource } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { useHostFiles } from "@executablemd/runtime"; +import { useRunComposition } from "../src/deno/run-composition/provider.ts"; +import { useCompositionComponents } from "../src/composition/installation.ts"; +import { RepositoryComposition } from "../src/composition/api.ts"; +import type { RepositoryHost, GitInvocation, GitOutcome } from "../src/deno/composition/host.ts"; + +/** Every acquisition this provider can make, counted where it happens. */ +interface Acquisitions { + /** Temporary directories the host was asked for: the Git session's `HOME`. */ + directories: number; + /** `git` invocations, which is what ambient discovery costs. */ + commands: string[][]; + /** Commit-identity reads. */ + identities: number; +} + +function counters(): Acquisitions { + return { directories: 0, commands: [], identities: 0 }; +} + +/** + * A host that records what it was asked for and answers plausibly. + * + * Substituted at the leaf — the subprocess and the temporary directory — which + * is the boundary a repository arranged on disk cannot make deterministic. + * Everything above it is the real provider. + */ +function counting(seen: Acquisitions, directory: string): RepositoryHost { + return { + *useDirectory(): Operation { + seen.directories += 1; + return directory; + }, + *git(invocation: GitInvocation): Operation { + seen.commands.push([...invocation.args]); + // Whatever it asked, the answer is "this is not a checkout": that is the + // cheapest true answer outside a repository, and it is what makes the + // ambient case below a discovery that found nothing rather than one that + // never ran. + return { code: 128, stdout: "", stderr: "not a git repository" }; + }, + }; +} + +/** One document, run with the provider installed over a counting host. */ +function* run(source: string, seen: Acquisitions): Operation { + const directory = yield* useTempDirectory("xmd-lazy"); + const root = yield* useTempDirectory("xmd-lazy-root"); + return String( + yield* scoped(function* () { + yield* useHostFiles(); + yield* useCompositionComponents(); + yield* useRunComposition({ + root, + cwd: directory, + host: counting(seen, directory), + // Stated, so a commit identity read is this counter rather than a + // subprocess: what is under test is *when* it is read. + // deno-lint-ignore require-yield + identity: function* (): Operation { + seen.identities += 1; + return undefined; + }, + }); + return yield* collect( + yield* execute({ ...inlineSource(source), stream: new InMemoryStream() }), + ); + }), + ); +} + +describe("what the ordinary repository provider acquires, and when", () => { + it("acquires nothing at installation", function* () { + const seen = counters(); + const directory = yield* useTempDirectory("xmd-lazy"); + const root = yield* useTempDirectory("xmd-lazy-root"); + yield* scoped(function* () { + yield* useRunComposition({ root, cwd: directory, host: counting(seen, directory) }); + }); + expect(seen).toEqual({ directories: 0, commands: [], identities: 0 }); + }); + + it("acquires nothing for a document that writes no repository component", function* () { + const seen = counters(); + const rendered = yield* run("a plain document\n", seen); + // The document really ran — an empty render would satisfy every count. + expect(rendered).toContain("a plain document"); + expect(seen).toEqual({ directories: 0, commands: [], identities: 0 }); + }); + + it("acquires no repository capability for a bare ", function* () { + const seen = counters(); + const rendered = yield* run('local work only\n', seen); + expect(rendered).toContain("local work only"); + // `` makes a directory. It selects no repository, runs no `git`, opens + // no session and reads no identity. + expect(seen).toEqual({ directories: 0, commands: [], identities: 0 }); + }); + + it("discovers the ambient repository once, when something first asks", function* () { + const seen = counters(); + yield* scoped(function* () { + const directory = yield* useTempDirectory("xmd-lazy"); + const root = yield* useTempDirectory("xmd-lazy-root"); + yield* useRunComposition({ root, cwd: directory, host: counting(seen, directory) }); + expect(seen.commands).toEqual([]); + + // Two concurrent asks. Single-flight means one discovery, not two — and + // the second must not start a fresh one merely because the first had not + // finished. + const answers = yield* all([yield* spawn(() => ambient()), yield* spawn(() => ambient())]); + expect(answers).toEqual([false, false]); + }); + + // One session for the discovery, and one discovery however many asked. + expect(seen.directories).toBe(1); + expect(seen.commands.length > 0).toBe(true); + const discoveries = seen.commands.filter((args) => args.includes("rev-parse")); + expect(`rev-parse invocations: ${discoveries.length}`).toBe("rev-parse invocations: 1"); + // And nothing asked about a commit identity. + expect(seen.identities).toBe(0); + }); +}); + +/** Whether an ambient repository was found, asked through the public Api. */ +function* ambient(): Operation { + try { + yield* RepositoryComposition.operations.ambientRepository(); + return true; + } catch { + return false; + } +} diff --git a/packages/git/tests/support/composition.ts b/packages/git/tests/support/composition.ts index 4f2743ebc..f450fc517 100644 --- a/packages/git/tests/support/composition.ts +++ b/packages/git/tests/support/composition.ts @@ -61,15 +61,20 @@ import { } from "@executablemd/workflow"; /** - * The admissions the Git Plugin contributes, as a host installing it receives - * them. + * The bundled Git Plugin, installed where a command installs it. + * + * Every host in this package that executes a document needs this: `` + * and the twelve components beside it are the Plugin's, and nothing bootstraps + * them any more. Call it once, in the scope that encloses the document — above + * any Workspace attachment, which owns providers and durable state rather than + * names — and use the installation it returns for the run's own admissions. * * Asked of the Plugin value rather than assembled here, so what these cases * exercise is the same contribution a command gets — one Plugin value, and an * admission that derives this execution's identities from this execution's own * retained history. */ -function* gitPluginAdmissions(): Operation { +export function* gitPluginAdmissions(): Operation { const install = gitPlugin.install; if (install === undefined) { throw new Error("the Git Plugin installed nothing"); diff --git a/packages/git/tests/support/git-crash-child.ts b/packages/git/tests/support/git-crash-child.ts index f7e528cdb..e36cfa69a 100644 --- a/packages/git/tests/support/git-crash-child.ts +++ b/packages/git/tests/support/git-crash-child.ts @@ -51,6 +51,7 @@ import { } from "../../src/deno/composition/provider.ts"; import { useWorkspaceEffects } from "../../../workflow/src/deno/workspace/effect.ts"; import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { gitPluginAdmissions } from "./composition.ts"; import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; import { currentWorkspaceRoot } from "../../../workflow/src/deno/workspace/root.ts"; import { @@ -131,6 +132,9 @@ function* crash( yield* useWorkspaceEffects(connections); const database = yield* openWorkflowRunDatabase({ connection, connections, record }); + // The bundled Plugin, installed where a command installs it: above the + // attachment, which owns this run's providers rather than its names. + yield* gitPluginAdmissions(); yield* withWorkflowWorkspace( database, scoped(function* () { @@ -235,6 +239,9 @@ function* pushCrash( throw new Error(`expected a Git run record, got ${record.definition.kind}`); } + // The bundled Plugin, installed where a command installs it: above the + // attachment, which owns this run's providers rather than its names. + yield* gitPluginAdmissions(); yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/git/tests/support/pull-request-crash-child.ts b/packages/git/tests/support/pull-request-crash-child.ts index 728e056eb..127a5bb32 100644 --- a/packages/git/tests/support/pull-request-crash-child.ts +++ b/packages/git/tests/support/pull-request-crash-child.ts @@ -22,6 +22,7 @@ import { isGitWorkflowRunRecord, WorkflowRunStorage } from "@executablemd/workfl import { useWorkflowRunStorage } from "@executablemd/workflow/deno"; import { retainedWorkflowInstallation } from "../../../workflow/src/run.ts"; import { gitWorkspaceAttachment } from "../../src/deno/attachment.ts"; +import { gitPluginAdmissions } from "./composition.ts"; import { withWorkflowWorkspace } from "../../../workflow/src/deno/workspace/host.ts"; import { denoGitHubAccess } from "../../src/deno/composition/github-host.ts"; import type { @@ -70,6 +71,9 @@ function* open(root: string, runId: string, locator: string, endpoint: string): throw new Error(`expected a Git run record, got ${record.definition.kind}`); } + // The bundled Plugin, installed where a command installs it: above the + // attachment, which owns this run's providers rather than its names. + yield* gitPluginAdmissions(); yield* withWorkflowWorkspace( database, scoped(function* () { diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index 89495ee3c..ded2097fe 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -55,7 +55,6 @@ import type { Operation } from "effection"; import { detachHeaders, detachStatus, - directoryEntry, fetchEntry, fileDeleteEntry, fileReadEntry, @@ -100,9 +99,14 @@ export interface GeneratedEvaluationOptions { * * Its own member rather than one of the additive entries above, because it is * not an addition: it occupies the position the standard profile has always - * had a directory entry in, and a continuation compares that table position by - * position. A host that captures none is admitted under the entry released - * builds retained, which this package still states. + * had a directory entry in, and a continuation compares that table position + * by position. + * + * Optional, and absence grants nothing. `` belongs to + * `@executablemd/git`, and this package states no entry for it: a host that + * supplies none is a Workflow host without a directory capability, which is + * a thing a generic host is entitled to be. The XMD workflow profile supplies + * Git's. */ readonly directory?: FragmentEntry; } @@ -163,46 +167,6 @@ function workspaceFiles(database: WorkflowRunDatabase): FragmentFileAccess { }; } -/** - * The directory entry a host that captures none of its own is admitted under. - * - * The exact entry released builds admitted, stated here rather than derived - * from a registration this package no longer owns. Versioned in its revision - * because what the entry authorizes - * changed: the former `Dir` authorized placement that created nothing, and - * `` now recursively creates the directory it names. A continuation - * granted under the earlier revision must not silently receive the wider - * permission, and the retained comparison refuses it before generated - * execution. - * - * Revision 3: the grant is the workflow's, so the identity names this package. - * What changed from revision 2 is the operation behind it — the body is now - * closed over the `ensureDirectory` this profile handed over rather than - * resolving a Files provider when it runs — so a continuation granted under the - * older, composable one is refused rather than re-granted. - * - * The version-1 alias is the exact string released builds retained for this - * entry, written out rather than assembled: that is what those journals hold, - * and nothing derives it. The pre-`dir-v2` spelling is deliberately absent — it - * named the placement-only ``, which created nothing, so answering for it - * here would hand a narrower grant the wider one. - */ -/** - * The origin released builds retained for this entry. - * - * Written out rather than imported. The component behind `` belongs to - * `@executablemd/git` now, and this string identifies retained history rather - * than current source ownership — every journal a released build wrote holds - * it, so it is this package's own compatibility data. - */ -const RETAINED_DIRECTORY_ORIGIN = "@executablemd/workflow/composition"; - -function retainedDirectoryEntry(): FragmentEntry { - return directoryEntry({ origin: RETAINED_DIRECTORY_ORIGIN, key: "Dir", revision: "3" }, "Dir", [ - "@executablemd/workflow/composition/dir-v2#Dir", - ]); -} - /** * The ceiling a workflow run's generated fragments are admitted under. * @@ -230,7 +194,16 @@ export function* evaluationProfile( ], write: [ fileWriteEntry(), - options.directory ?? retainedDirectoryEntry(), + // The directory capability is the host's to supply, and a host that + // supplies none grants none: `` belongs to `@executablemd/git`, and + // a generic Workflow host composing this profile without it is not + // withholding a capability so much as never having had one. + // + // Its position is the contract. A released journal holds this table + // position by position, so the entry a host does supply occupies the + // slot between the file write and the file delete, exactly where every + // retained continuation expects to find it. + ...(options.directory === undefined ? [] : [options.directory]), fileDeleteEntry(), ...(options.writes ?? []), ], diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 8946d0e8a..e56311c78 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -15,6 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { directoryEntry, executeInstalled } from "@executablemd/core/host"; +import type { FragmentEntry } from "@executablemd/core/host"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; @@ -126,7 +127,19 @@ function runDocument( // `` is canonical core's. What this run states is the // ceiling, captured by canonical execution before any document // code exists. - [{ evaluation: yield* evaluationProfile(database, evaluation) }], + [ + { + evaluation: yield* evaluationProfile(database, { + // A directory capability, stated by this host. This package + // states none of its own — `` belongs to + // `@executablemd/git`, and a Workflow host that wants the + // capability supplies an entry for it, exactly as WGAC16 + // shows a host doing explicitly. + directory: HOST_DIRECTORY, + ...evaluation, + }), + }, + ], ), ); }), @@ -901,6 +914,25 @@ describe("Tier WGAC — the registered Evaluate component", () => { }); }); +/** + * The directory entry this test host states. + * + * The identity released builds retained, written out here as this suite's own + * compatibility data — several rows below assert the table a run admits, and + * what they assert is exactly these strings. + * + * This package states no entry of its own any more: `` belongs to + * `@executablemd/git`, and the XMD workflow profile supplies Git's. A test + * host standing in for that profile therefore states the same identity rather + * than importing it, which is what keeps this suite free of the package under + * it. + */ +const HOST_DIRECTORY: FragmentEntry = directoryEntry( + { origin: "@executablemd/workflow/composition", key: "Dir", revision: "3" }, + "Dir", + ["@executablemd/workflow/composition/dir-v2#Dir"], +); + /** * Tier WGAC — the effect classes `` selects between * (specs/workflow-workspace-spec.md §8.4). @@ -1048,8 +1080,8 @@ describe("Tier WGAC — the standard write table", () => { const database = yield* createRun(); // A host states the component behind ``; this package states the - // rest of the ceiling. The entry above is the one released builds - // retained and is what a host that captures none is admitted under. + // rest of the ceiling. A host that states none is admitted under no + // directory entry at all, which is why every case here supplies one. const attempt = yield* runDocument(database, evaluates(WRITES, ["write"]), { directory: directoryEntry({ origin: "tier-wgac", key: "Dir", revision: "1" }, "Dir"), }); diff --git a/packages/workflow/tests/workspace-files.test.ts b/packages/workflow/tests/workspace-files.test.ts index 9baaece5e..2200b2c7c 100644 --- a/packages/workflow/tests/workspace-files.test.ts +++ b/packages/workflow/tests/workspace-files.test.ts @@ -35,6 +35,22 @@ import type { HostFilesEvent } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "../mod.ts"; import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; import { gitWorkspaceAttachment } from "../../git/src/deno/attachment.ts"; +import { gitPlugin } from "../../git/src/plugin.ts"; + +/** + * The bundled Git Plugin, installed once for this scope. + * + * Reached through Git's source the same way the attachment is — this suite + * already depends on that package's tree, and nothing in Workflow's own + * production code does. + */ +function* installGitPlugin(): Operation { + const install = gitPlugin.install; + if (install === undefined) { + throw new Error("the Git Plugin installed nothing"); + } + yield* install.call(gitPlugin, { command: "run", args: ["run"] }); +} import { WORKSPACE_FILE } from "../src/deno/workspace/files.ts"; import { throwWorkspaceFilesystemFailure } from "../src/deno/workspace/errors.ts"; import type { DenoWorkspaceFilesystem } from "../src/deno/workspace/filesystem.ts"; @@ -133,6 +149,12 @@ interface Run { function runDocument(database: WorkflowRunDatabase, source: string): Operation { return scoped(function* () { const host = yield* useHostSpy(); + // `` belongs to `@executablemd/git` now, and these cases drive this + // run's own directory handling through it. The Plugin is installed here, + // at the profile scope, because that is where a command installs one: the + // attachment below owns this run's providers and durable state, not the + // names. Installing it declares and admits and does nothing else. + yield* installGitPlugin(); const output = yield* withWorkflowWorkspace( database, scoped(function* () { @@ -140,8 +162,6 @@ function runDocument(database: WorkflowRunDatabase, source: string): Operation` belongs to `@executablemd/git` now, and these cases drive this - // run's own directory handling through it. { attachments: [gitWorkspaceAttachment()] }, ); return { output, host }; diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 1816fa284..89cabe386 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -171,8 +171,10 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () * * The Plugin itself imports nothing, because that is the portable contract — * what this proves is that a Node installation with no checkout loads a - * module an operator named and runs it. It is also the only way this package - * reaches a Plugin at all now: it bundles none and depends on none. + * module an operator named and runs it. It is not the only Plugin this + * package reaches: `@executablemd/git` is bundled and depended on, and is + * active for every run without being named. What is proven here is the other + * route — the one an operator drives. */ it("publishes the /api subpath and loads a Plugin named on the command line", function* () { yield* ensure(removeNpmOutput); diff --git a/scripts/tests/plugin-compiled.test.ts b/scripts/tests/plugin-compiled.test.ts index 6dc9f5044..2c8857278 100644 --- a/scripts/tests/plugin-compiled.test.ts +++ b/scripts/tests/plugin-compiled.test.ts @@ -82,14 +82,16 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => }); }); - it("describes only the engine's own language when nothing was selected", function* () { + it("describes the engine's language and the bundled Plugin's, and nothing else", function* () { if (!(yield* exists(BINARY))) { throw new Error(`${BINARY} is missing — run \`deno task build\` before this case`); } - // The binary bundles no Plugin and embeds no Plugin's assets. What it - // describes with no `--plugin` is the language `xmd` itself is — which is - // also what makes the row below a claim about selection rather than about - // what happened to be compiled in. + // The binary bundles exactly one Plugin — `@executablemd/git` — and embeds + // its assets. What it describes with no `--plugin` is therefore the + // language `xmd` itself is *plus* that one vocabulary, and nothing an + // operator did not select. Both halves matter: the first proves the + // distribution really carries the Plugin rather than depending on one, and + // the second is what makes the row below a claim about selection. yield* useElsewhere(function* (dir) { const run = yield* runBinary(["syntax", "--json", "--include", dir], dir); if (run.code !== 0) { @@ -97,6 +99,11 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => } const names = describedNames(run.stdout); expect(names).toContain("Syntax"); + // The bundled vocabulary, compiled in and active by default. + for (const name of ["Repository", "Worktree", "Dir", "PullRequest", "Issue"]) { + expect(`${name}: ${names.includes(name)}`).toBe(`${name}: true`); + } + // And nothing from a Plugin nobody named. expect(names).not.toContain("Finding"); expect(names).not.toContain("ReviewContext"); }); @@ -233,3 +240,127 @@ describe( }); }, ); + +describe("the bundled Git Plugin", { sanitizeOps: false, sanitizeResources: false }, () => { + it("is compiled in once, with its documentation and released origins", function* () { + if (!(yield* exists(BINARY))) { + throw new Error(`${BINARY} is missing — run \`deno task build\` before this case`); + } + yield* useElsewhere(function* (dir) { + const run = yield* runBinary(["syntax", "--json", "--include", dir], dir); + if (run.code !== 0) { + throw new Error(`the compiled binary exited ${run.code}\n${run.stderr}`); + } + const catalog = JSON.parse(run.stdout); + const entries: Record[] = catalog.categories.flatMap( + (category: { entries: Record[] }) => category.entries, + ); + + // Once each. A Plugin bundled *and* resolved would describe its + // vocabulary twice, which is the shape a second copy takes. + for (const name of BUNDLED_COMPONENTS) { + const found = entries.filter((entry) => entry.name === name); + expect(`${name}: ${found.length}`).toBe(`${name}: 1`); + // Complete, not a bare name: the assets travel with the binary. + expect(`${name} described: ${typeof found[0]?.description === "string"}`).toBe( + `${name} described: true`, + ); + } + + // The GitHub half ships with it: ``'s evidence components + // are the adapter's subject, and a distribution carrying the Plugin + // without them would be carrying half of it. + for (const name of ["PullRequest.Reviews", "PullRequest.Comments", "PullRequest.Checks"]) { + expect(`${name}: ${entries.some((entry) => entry.name === name)}`).toBe(`${name}: true`); + } + }); + }); + + it("is idempotent under the reserved selector, however many times it is written", function* () { + if (!(yield* exists(BINARY))) { + throw new Error(`${BINARY} is missing — run \`deno task build\` before this case`); + } + yield* useElsewhere(function* (dir) { + const bare = yield* runBinary(["syntax", "--json", "--include", dir], dir); + const selected = yield* runBinary( + ["syntax", "--json", "--include", dir, "--plugin", "git", "--plugin", "git"], + dir, + ); + if (selected.code !== 0) { + throw new Error(`the compiled binary exited ${selected.code}\n${selected.stderr}`); + } + // The same catalog, byte for byte: writing the selector names the value + // the profile already carries, so it adds nothing and reorders nothing. + expect(selected.stdout).toBe(bare.stdout); + }); + }); + + it("refuses a module that claims the bundled Plugin's name", function* () { + if (!(yield* exists(BINARY))) { + throw new Error(`${BINARY} is missing — run \`deno task build\` before this case`); + } + yield* useElsewhere(function* (dir) { + // An impostor: a real module, exporting a real Plugin, named + // `@executablemd/git`. The reserved selector is idempotent for the + // host's own value; a module claiming that name is a duplicate. + const impostor = path.join(dir, "impostor.mjs"); + yield* writeTextFile( + impostor, + [ + "export default {", + ' name: "@executablemd/git",', + " *install() {", + " return undefined;", + " },", + "};", + "", + ].join("\n"), + ); + const run = yield* runBinary(["run", `--plugin=${impostor}`, "doc.md"], dir); + expect(run.code).not.toBe(0); + expect(`${run.stdout}\n${run.stderr}`).toContain( + "two selected Plugins are named @executablemd/git", + ); + }); + }); + + it("performs no GitHub configuration, credential or transport work when loaded", function* () { + if (!(yield* exists(BINARY))) { + throw new Error(`${BINARY} is missing — run \`deno task build\` before this case`); + } + yield* useElsewhere(function* (dir) { + // Deliberately unusable configuration. If loading the bundled Plugin + // read either variable, this run would refuse before the document ran; + // the variables are read by an invoked GitHub-backed operation, and this + // document invokes none. + const run = yield* timebox(TIMEOUT, function* () { + return yield* exec(BINARY, { + arguments: ["run", "doc.md"], + cwd: dir, + env: { + XMD_WORKFLOW_GITHUB_ISSUES: "{not json at all", + XMD_WORKFLOW_GITHUB_PULL_REQUESTS: "{also not json", + }, + }).join(); + }); + if (run.timeout) { + throw new Error("the compiled binary timed out"); + } + expect(`${run.value.code}: ${run.value.stdout.includes("document body")}`).toBe("0: true"); + }); + }); +}); + +/** The vocabulary the bundled Plugin brings, as the catalog names it. */ +const BUNDLED_COMPONENTS: readonly string[] = [ + "Repository", + "Worktree", + "Dir", + "Git.Switch", + "Git.Add", + "Git.Commit", + "Git.Push", + "PullRequest", + "IssueTracker", + "Issue", +]; diff --git a/scripts/validate-documentation.ts b/scripts/validate-documentation.ts index a4c42c798..bf2e32c8f 100644 --- a/scripts/validate-documentation.ts +++ b/scripts/validate-documentation.ts @@ -13,9 +13,12 @@ * unknown heading and a component documented twice each fail the build for * exactly the reason they would fail a run. * - * XMD bundles no Plugin, so the selection is explicit here too. This script is - * a build tool rather than the product, which is why it may name the package - * that ships the graph; nothing in the CLI does. + * XMD bundles one Plugin — `@executablemd/git` — so this assembles the run + * profile: the bundled value, then the review graph named explicitly. A + * distribution carries the first, which makes its documentation this gate's + * business; the second is an operator's selection, and this script is a build + * tool rather than the product, which is why it may name the package that + * ships it. Nothing in the CLI does. */ import { main, scoped } from "effection"; @@ -23,6 +26,7 @@ import type { Operation } from "effection"; import { capturedDocumentation, documentationIndexFor } from "@executablemd/core"; import type { ComponentOrigin, DocumentationIndex } from "@executablemd/core"; import { useCommandComponents } from "../packages/cli/src/syntax.ts"; +import { BUNDLED_PLUGIN } from "../packages/cli/src/run-profile.ts"; import { installPlugins } from "../packages/cli/src/plugin-host.ts"; import reviewPlugin from "../packages/code-review-agent/mod.ts"; @@ -32,10 +36,13 @@ export function* validateDocumentation(): Operation { // that installed it: collecting outside would find core's terminal alone and // pass every rule vacuously. const index = yield* scoped(function* () { - // The first-party Plugin, installed exactly as `--plugin` installs one: a + // The run profile as a command assembles it: the bundled Git Plugin first, + // then the first-party review Plugin exactly as `--plugin` installs one. A // Plugin's registrations and the documentation describing them arrive - // together, so a build that shipped one without the other fails here. - yield* installPlugins([reviewPlugin], { command: "run", args: [] }); + // together, so a build that shipped one without the other fails here — and + // the bundled one is in this list because a distribution carries it, which + // makes its documentation this gate's business too. + yield* installPlugins([BUNDLED_PLUGIN, reviewPlugin], { command: "run", args: ["run"] }); yield* useCommandComponents(); return documentationIndexFor(yield* capturedDocumentation()); }); From 9b13ce158aeafb573f46a7a684ec54b926f862a7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 09:22:24 -0400 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=9D=20Specify=20the=20bundled=20Gi?= =?UTF-8?q?t=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation said XMD ships no Plugin. It ships one. `architecture.md` and the specs now state the profile as it is: one bundled `@executablemd/git`, statically imported, first in the active list, active for the commands that execute or describe a document and absent from `xmd test`, `upgrade` and a workflow management action. `git` is described as a reserved host selector — it names the value the profile already holds, loads nothing, and repeating it changes nothing — while a module claiming the same Plugin name is an ordinary duplicate. Tier PL gains three cases for the selector, the name-versus-selector distinction, and test isolation. Workflow is no longer credited with owning Git. Its spec's §7 says where the capability went, `@executablemd/workflow`'s module doc drops the Git-host section describing functions it no longer exports, and the repository vocabulary is attributed to the Plugin that declares it rather than to a Workspace attachment, which owns a run's providers and durable state and declares nothing. Eager acquisition is described as what it became: ambient discovery on the first ambient request, a commit identity on the first commit, GitHub configuration when an invoked GitHub-backed operation needs it, and installation costing nothing at all. `deno-repositories.ts`'s own comment said the opposite of the code above it. Released durable origin strings are untouched: ``'s entry is named as the Plugin's while it still states `@executablemd/workflow/composition`, because that string identifies retained history rather than current source ownership. Documentation only. Both TypeScript diffs are comment-only, and no executable behavior, type, export, manifest, fixture, generated file or measured weight changes. --- README.md | 7 ++ architecture.md | 125 +++++++++++++++----------- packages/cli/src/cli.ts | 4 +- packages/cli/src/deno-repositories.ts | 9 +- packages/cli/src/run-profile.ts | 3 +- packages/workflow/mod.ts | 46 +++++----- specs/code-review-agent-spec.md | 8 +- specs/executable-mdx-spec.md | 66 +++++++++----- specs/plan-command-spec.md | 8 +- specs/release-process-spec.md | 9 +- specs/testing-spec.md | 10 ++- specs/workflow-spec.md | 55 ++++++++---- specs/workflow-workspace-spec.md | 21 +++-- 13 files changed, 233 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 2bc65722f..086c29400 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ Locate the package that owns the behavior you are about to change: config — that every other package reaches the host through. - `packages/durable-streams/` — the journal protocol, replay and divergence. - `packages/workflow/` — workflow runs, run storage, and the Workspace. +- `packages/git/` — repository collaboration: ``, ``, + ``, the `Git.*` operations, pull requests and issues, and the GitHub + adapter behind them. It is the one Plugin `xmd` bundles, active by default for + the run-profile commands — `run`, `plan`, `syntax`, and `workflow start`, + `resume` and `fork`. The `xmd test` root is deliberately not one of them, so a + test document may shadow these names; a nested `` child + assembles the profile for itself. - `packages/testing/` and `packages/test-agent/` — `` and the deterministic agent; `packages/test-support/` is the one BDD surface all three runtimes share. diff --git a/architecture.md b/architecture.md index d13f1a4b3..05c2846b0 100644 --- a/architecture.md +++ b/architecture.md @@ -47,9 +47,9 @@ and categorization rather than clarify them, so both stay exactly as written. | legacy workflow source reader | the direct, non-contextual capability a trusted host supplies so the retained lifecycle can obtain a version-1 definition's Markdown. It is captured before document code runs and reachable through no Context, Api, component, Plugin installation result or authored value; Workflow validates everything it returns against the descriptor it asked about | | Repository base | the optional Git revision from which one named Repository initializes its primary checkout | | Repository selection | plain structural composition data naming the repository one component invocation acts on: an opaque provider-minted selection identifier, the display name, the credential-free repository identity, and the selected checkout path. It carries no credential, provider handle, lock, database, run ID or permission — the installed provider authenticates every selection against private state before it touches Git or a service, so a copied, replaced or rebuilt one can misname a target and be refused but can never reach one | -| ambient Repository | the repository an ordinary `xmd run` was started inside, discovered once before root expansion from the invocation's starting directory. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so starting in a linked worktree names the same repository as starting in the primary checkout while Git operations still act on the worktree. A workflow run has none | +| ambient Repository | the repository an ordinary `xmd run` was started inside, discovered when the first element asks for one — never at installation — from the invocation's starting directory, and remembered for the rest of the execution including when there is none. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so starting in a linked worktree names the same repository as starting in the primary checkout while Git operations still act on the worktree. A workflow run has none | | managed checkout | a Repository or Worktree an ordinary `xmd run` created under the host root `~/.xmd/repositories`, addressed by a digest of its whole identity, described by a closed version 1 sidecar written beside it, and held for one document execution by an exclusive non-blocking advisory lock. It survives every execution: nothing deletes, resets, cleans, fetches or repairs one | -| ordinary Git identity | the invoking user's effective Git author and committer name and email, captured once from the trusted host's own environment and configuration before a document expands and kept in the provider's closure. It is used for an ordinary `` and nothing else, because that commit lands in the caller's own checkout; a workflow run keeps its one fixed identity, whose whole purpose is that retained Git state does not depend on whose machine made it. It is not a prop, a Context value, a component result or a middleware answer, and nothing else about the environment is borrowed with it — hooks, file-system monitors, signing programs and repository-supplied credential helpers stay disabled by the same fixed command-line configuration. A host where Git can name no identity refuses `` with an actionable sentence rather than substituting the workflow one; every other component is unaffected | +| ordinary Git identity | the invoking user's effective Git author and committer name and email, read from the trusted host's own environment and configuration by the first `` that needs one — never at installation — and kept in the provider's closure for the rest of the execution, including when the host can name none. A run that commits nothing never asks. It is used for an ordinary `` and nothing else, because that commit lands in the caller's own checkout; a workflow run keeps its one fixed identity, whose whole purpose is that retained Git state does not depend on whose machine made it. It is not a prop, a Context value, a component result or a middleware answer, and nothing else about the environment is borrowed with it — hooks, file-system monitors, signing programs and repository-supplied credential helpers stay disabled by the same fixed command-line configuration. A host where Git can name no identity refuses `` with an actionable sentence rather than substituting the workflow one; every other component is unaffected | | ordinary invocation identity | a fresh opaque random value an ordinary document execution's repository provider mints for itself and keeps in its own closure. It is not a prop, a Context value, a component result, a middleware answer, a lifecycle ID or a retained record, and it is neither addressable nor reusable; live Issue and pull-request idempotency and reconciliation keys are derived from it together with the engine's own expansion identity | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | | document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | @@ -63,7 +63,7 @@ and categorization rather than clarify them, so both stay exactly as written. | release identity | the invocation-local opaque identifier `` mints for each release it admits. Holding one is what authorizes downloading that release, and nothing outside that one invocation's private admission map can read, extend or forge it. A download mints an upgrade candidate identity in the same way, and that candidate advances `downloaded → verified → committed` exactly once | | expansion | one logical evaluation of an authored executable element within a document execution | | expansion ID | a deterministic identifier for one logical expansion; restoring or retrying that expansion preserves the ID, while a distinct evaluation requested by the document receives another | -| Git capability | the contextual interface through which workflow infrastructure queries the Git repository associated with the current working directory | +| Git capability | the contextual interface through which `@executablemd/git` queries the Git repository associated with the current working directory | | Git host | an external service that owns remote Git repositories and associated collaboration objects such as branches and pull requests. GitHub is one Git-host adapter. A Git host is distinct from the local Git capability and the trusted workflow host, and from an Issue provider | | Issue provider | an external service that owns a collection of issues. GitHub and Atlassian Cloud are Issue-provider adapters. It is a separate boundary from a Git host because an Issue provider need not own a Git repository, and one that does not cannot truthfully persist as a Git-host effect | | Issue tracker | what a lexical `` names: a credential-free URL for the container new issues are created in, together with an optional explicit provider discriminator. An upsert requires one and takes its discriminator only from it; a read needs none, because the URL it was given is the identity. It is replaceable composition data; it requests a destination and grants nothing | @@ -139,9 +139,9 @@ and categorization rather than clarify them, so both stay exactly as written. | execution environment | the private bundle of services and records one execution supplies while its document expands: what resolves a name, what routes it to a retained implementation, what the execution declared, and the records it keeps about its own identities, forms, vocabulary and source output. The execution builds it from what it captured and admitted, owns it, and passes it by value through canonical expansion — never through a context, because a context resolves by name and a name is not a secret. An expansion driven directly is handed none at all, which is what "nothing is installed here" means; there is no partial one. Its shape is an architecture and product boundary | | syntax reference | the engine-owned lexical answer to "what may a document write here", carried by value on canonical core's execution environment beside what resolves an import. The execution builds one at its root from the selection inputs it captured before any installation, middleware or document code ran, or from the one set of symbols a trusted host stated for its profile; a trusted canonical evaluation boundary replaces it for the subtree it evaluates. It answers with text and decides nothing: a component the symbols name is not a component anything may run | | run profile declarations | the component registrations a first-party package makes, held as plain values apart from the middleware, providers, activation and launchers its installer also arranges. The installer registers exactly those values and inspection reads exactly those values, so what a run installs and what the symbols report cannot drift | -| Plugin | trusted code an operator selects with `--plugin`, installed once before an execution imports a root document. XMD ships none and defaults to none, so a command that selects no Plugin installs none and a package that happens to be installed stays inert until it is named. A plain structural value carrying a name and one optional `install`; what it contributes is the existing `ExecutionInstallation` members, and what it composes through are the three stable contextual APIs below. Selecting one runs its module, and selected Plugin code can execute whatever the surrounding runtime permits: it is trusted, and it is not sandboxed | +| Plugin | trusted code installed once before an execution imports a root document. XMD bundles exactly one — `@executablemd/git` — and activates it for the run-profile commands: `run`, `plan`, `syntax`, and `workflow start`, `resume` and `fork`. The `xmd test` root is not one of them, because a harness that quietly gained the repository vocabulary would be claiming names its children are entitled to shadow; a nested `` child assembles the profile for itself rather than inheriting what its parent declined. Everything else an operator selects with `--plugin`, and a package that happens to be installed stays inert until it is named. A plain structural value carrying a name and one optional `install`; what it contributes is the existing `ExecutionInstallation` members, and what it composes through are the three stable contextual APIs below. Selecting one runs its module, and selected Plugin code can execute whatever the surrounding runtime permits: it is trusted, and it is not sandboxed | | document envelope | the Markdown the `Document` Api composes for one run: the canonical `` placeholder wrapped by each Plugin's middleware, first Plugin outermost. Canonical core scans it once and splices the root's already parsed, already target-selected segments in at each placeholder, at their authored positions — so an unwrapped run is the run it always was, and an authored `` is an ordinary element that projects nothing | -| active Plugins | the frozen ordered list of Plugin values installed for one command, published before the first `install()` runs so that every Plugin and every later consumer read the same list. It is a description of what is installed and grants nothing | +| active Plugins | the frozen ordered list of Plugin values installed for one command — the bundled Git Plugin first where the command's profile carries it, then the operator's own in the order they wrote them — published before the first `install()` runs so that every Plugin and every later consumer read the same list. It is a description of what is installed and grants nothing | | command declarations | everything one command declares, in one order and under one discriminant: the exact Markdown components *and* the structural syntax the selected Plugins declared, then whatever the calling surface declares for itself. Both arms, because both are what a name means here — a catalog holding only the Markdown half would let `xmd syntax` and ``'s validation describe a language a run does not have. Built once per command so that an ordinary run, a nested `host="run"` child, inspection and ``'s validation cannot describe four different vocabularies | | origin-only | the inspectability of a component whose contract could only be learned by loading it: a repository TypeScript module, whose schemas live on its exports and whose top level would run. Such an entry carries name, category, origin and source kind, and no contract field at all — an absent contract is stated, never rendered as an empty one | | definition-owned return state | which value body a `` selects for: one ephemeral state per execution of one value root or Markdown value component. Structural directives keep the ambient one, a component invocation hides it from the invoked body, a nested value body installs its own, and caller-projected content restores the caller's. It travels down the expansion call stack as a local rather than through a context, and no exported function accepts another body's, so nothing a document can read, replace, or import acts on a live one. The first claim on it is atomic, so a second executed return fails the body rather than replacing its value, and it appends no durable event | @@ -214,19 +214,23 @@ interface GitApi { directory. Its default provider invokes the Git CLI; another provider may replace it lexically. `workflowInstallation({ base })` calls it with `${base}^{commit}`, which verifies that the result is a commit and returns its -full object ID, and it is the only caller of that capability left in the -workflow package. Starting such a run fails before root expansion when Git -cannot be invoked, the working directory is not a Git repository, or the base -does not resolve to a commit. Ordinary `execute()` remains Git-independent, and -so does starting, resuming, forking or exporting a source-bundle run. - -**Retained workflow identity and retained source retrieval require no Git.** The -CLI establishes a version-2 candidate from filesystem bytes, and the retained -lifecycle re-derives a version-2 source from the run's own store. Version-1 -source crosses the host-supplied legacy reader, which is a direct dependency -rather than a package-level import. The one remaining exception is -`workflowInstallation({ base })` itself, which issue #822 moves into the bundled -Git Plugin; nothing else in the retained path names Git. +full object ID. It lives in `@executablemd/git` with the rest of the +capability; the workflow package calls none of it. Starting such a run fails +before root expansion when Git cannot be invoked, the working directory is not +a Git repository, or the base does not resolve to a commit. Ordinary +`execute()` remains Git-independent, and so does starting, resuming, forking or +exporting a source-bundle run. + +**The workflow package imports no Git feature — which is not the same as saying +a retained run reaches no repository.** The CLI establishes a version-2 +candidate from filesystem bytes, and the retained lifecycle re-derives a +version-2 source from the run's own store, so a source-bundle run genuinely +needs none. A version-1 definition retains no Markdown, so its source crosses +the host-supplied legacy reader, which may read a repository: that reader is a +direct dependency rather than a package-level import, and Workflow +authenticates the closure it returns. `workflowInstallation({ base })` moved +into the bundled Git Plugin, so no workflow production module names Git in an +import, in any form. Replay restores the recorded `WorkflowRun` without allocating another run ID or invoking Git. The supplied base must equal the recorded base. Git is not @@ -251,12 +255,16 @@ exposes no journal, Git, workspace or continuation capability. Replay preserves the field values, not JavaScript object identity. The `@executablemd/workflow` package owns `WorkflowRun`, -`workflowInstallation()`, `retainedWorkflowInstallation()`, `getWorkflowRun()` -and the Git capability. It depends on `@executablemd/core`, -`@executablemd/durable-streams` and `@executablemd/runtime`, whose contextual -`exec()` and `cwd()` the Git provider invokes; core never imports workflow or -Git. `xmd workflow start` and `xmd workflow resume` are the CLI lifecycle, and -they resume through the run storage below. Ordinary `xmd run` remains unchanged. +`retainedWorkflowInstallation()`, `getWorkflowRun()` and the generic run, +storage, replay and Workspace boundaries. It owns no Git: repository +composition, the Git capability, issue and pull-request contracts and the +Git-host engine are `@executablemd/git`'s, and that package imports Workflow's +published extension boundaries rather than the other way round. It depends on +`@executablemd/core`, `@executablemd/durable-streams` and +`@executablemd/runtime`; core never imports workflow or Git. `xmd workflow +start` and `xmd workflow resume` are the CLI lifecycle, and they resume through +the run storage below. Ordinary `xmd run` carries the bundled Git Plugin, so +the repository vocabulary is available there without an operator naming it. ## Workflow run storage @@ -2136,7 +2144,7 @@ contextual environment, because the contextual environment is exactly what a document can answer. That narrowing changed what these identities authorize, so core's entries state -revision 2 and the workflow's `` states revision 3. **A run suspended +revision 2 and the bundled Git Plugin's `` states revision 3. **A run suspended under the earlier revisions refuses to resume rather than silently receiving the narrower grant**, which is what a revision is for: a continuation resumes only under the exact identities it was admitted with. @@ -2297,11 +2305,14 @@ an admitted `File:read` does not become a write and an admitted `File:write` does not become a read, however many times something is asked. The standard Deno workflow profile's write table is three entries, in the order -it states them: core's paired `File:write`, the workflow package's paired `Dir` -under the versioned origin +it states them: core's paired `File:write`, the bundled Git Plugin's paired +`Dir` under the versioned origin `@executablemd/workflow/composition/dir-v2#Dir` — built from the same implementation and schema the ordinary registration owns so the two cannot -drift — and core's self-closing `@executablemd/core#File.Delete`. A host's own +drift — and core's self-closing `@executablemd/core#File.Delete`. That origin +names where released history recorded the entry, not what owns it today: +`` is the Git Plugin's component, and the string is retained unchanged so +runs already holding it still replay. A host's own extensions come after them. Selecting `write` intentionally authorizes Dir's persistent recursive directory creation as well as the mutations File and File.Delete perform. Each is the ordinary component and crosses `API.Files` @@ -3842,7 +3853,7 @@ selected this dispatcher for that import; otherwise the component's own refusal is raised, before its body, `Env.cwd`, its content, or a provider. Core's `` is dual-form, core's `` is self-closing only, and -the workflow package's `` is paired only. Each is irreversible in one +the bundled Git Plugin's `` is paired only. Each is irreversible in one direction: a `` reported as content-bearing would render an empty string over the file the document wrote a read for, and a paired `` reported as self-closing would remove the path its children were written beside. @@ -4229,12 +4240,23 @@ durable is written. ## The Plugin boundary A **Plugin** is trusted code installed before an execution imports a root -document. An operator selects them with a repeatable `--plugin `, and -that selection is the complete list: XMD bundles none, defaults to none, and -installs none for a command that named none. It is the one extension boundary -XMD has: there is no package with a reserved name, no manifest, no Plugin -version, no capability bag and no central runner context a Plugin reaches -through. +document. XMD bundles exactly one, `@executablemd/git`, and an operator adds more with a +repeatable `--plugin `. The bundled value is statically imported +rather than resolved, so a distribution carries it whole and nothing can +substitute it; it is activated for `run`, `plan`, `syntax` and the workflow +actions that execute a document — `start`, `resume` and `fork` — and for nothing +else. `xmd test`, `upgrade` and a workflow management action carry no Plugin +they did not ask for. + +`git` is a reserved *host selector*, not a package name: writing `--plugin git` +names the value the profile already holds, resolves without loading a module, +and is a no-op however many times it appears. A module that merely claims the +same Plugin name is not that value — it loads like any other specifier and is +refused as a duplicate. + +Beyond that one prefix this is still the only extension boundary XMD has: no +manifest, no Plugin version, no capability bag and no central runner context a +Plugin reaches through. **A Plugin is a value, not a registration.** It carries a name and, optionally, one `install(request)` operation: @@ -4303,8 +4325,9 @@ install, an npm install and a compiled binary agree about what one name means. V1 performs no ambient or document-declared discovery — that is a decision about what V1 does, not a claim that discovery can never be added. -**Order composes and never arbitrates.** The selections install in occurrence -order, and the first Plugin installed is the outermost middleware wrapper. +**Order composes and never arbitrates.** The bundled Plugin installs first +where the profile carries it, then the selections in occurrence order, and the +first Plugin installed is the outermost middleware wrapper. Position decides nothing else: two Plugins claiming one Plugin name, one component name, one structural construct or one documentation contribution are refused at admission, exactly as two trusted @@ -4346,7 +4369,8 @@ expansion identity are what they would have been with no Plugin at all; splicing rather than nesting is also what keeps the root's own top-level `` a top-level child and its `` in the value body's flow. With no middleware the envelope is exactly the placeholder and the execution runs the definition it -imported, so there is nothing for a no-Plugin run to observe. A middleware may +imported, so there is nothing for a run whose Plugins compose no middleware to +observe — which the bundled Git Plugin does not. A middleware may delegate more than once, and nothing caches or counts: each placeholder projects the document again. @@ -4830,10 +4854,10 @@ no index and would otherwise run to completion on an assembly nobody validated. exact documentation text and the component-name set — and equality covers all four, by value rather than by object identity, with a name set carrying no order. A repetition of that same value adds nothing and succeeds: one package's -declarative vocabulary is deliberately entered at more than one layer, the -repository-composition set by an ordinary run's bootstrap and again inside a -workflow attachment because either may be the only one, and a nested run or -evaluation host the same way. The inner scope descends from the outer, so both +declarative vocabulary is deliberately entered at more than one layer — a +nested run installs the same bundled Plugin again in its own scope, and an +evaluation host the same way, because the inner scope may be the only one a +document sees. The inner scope descends from the outer, so both wrappers sit in one chain, and collection folds them to one — keeping every layer's registrations and one documentation value. Comparing the asset alone would instead coalesce two bootstraps that genuinely disagree and silently keep @@ -5167,12 +5191,13 @@ Status is measured against main. | targeted `xmd run` | reads a file argument as a document reference and executes the one exact target its selector resolved to, replacing the selector before execution rereads the file | built on the #412 stack | | targeted workflow definition | a workflow definition of either version optionally carries the exact canonical document target, which takes part in definition identity and in compatible reuse but not in the source-bundle hash | built on the #412 stack; `xmd workflow start` takes a document reference and resolves any selector through core against the bytes it is about to retain | | source-bundle workflow definition | version 2 of the workflow definition is the exact bytes themselves: an entrypoint, a canonically ordered manifest of logical paths with each source's domain-separated SHA-256 and byte length, a bundle hash over that manifest and mapping, and the optional exact target and component mapping. The bytes are retained with the run in live schema version 2, so starting, resuming, replaying, forking and exporting need no repository and survive the original being edited, moved or deleted | built on the #443 stack; version 1 is unchanged, nothing migrates between them, and the artifact-backed fork remains unbuilt | -| legacy workflow source reader | a version-1 run's Markdown is obtained through a direct capability a trusted host captures before document code runs, reachable through no Context, Api, component, Plugin result or authored value. Workflow validates the returned closure against the descriptor it asked about, recomputing every blob identity from the bytes that came back | built on the #443 stack; the Deno and compiled hosts supply the Git-object adapter, and #822 moves it into the bundled Git Plugin | +| legacy workflow source reader | a version-1 run's Markdown is obtained through a direct capability a trusted host captures before document code runs, reachable through no Context, Api, component, Plugin result or authored value. Workflow validates the returned closure against the descriptor it asked about, recomputing every blob identity from the bytes that came back | built on the #443 stack; the Deno and compiled hosts supply the Git-object adapter, which lives in the bundled Git Plugin | | installed structural syntax | a trusted host declares a structural construct and the regions written directly inside it, as the second arm of the same `declarations` list: name, origin, accepted forms, props schema, syntax examples, description, what the content means, and `parent` — `null` for the construct, the construct's name for one of its regions. The accepted regions are derived from the regions that named the construct rather than restated by it. The installation that declares them supplies the one `expand` handler, read once and bound at capture, and held on the admitted catalog entry rather than in any registry, context or second dispatch protocol. Admission refuses an invalid name, an empty origin, absent or non-canonical forms, an uncompilable schema, missing syntax examples or description, a context that is neither prose nor `null`, a name the engine's structural table or the canonical protected tier owns, a name a reserved registration claims, a duplicate across either arm or across installations, a collision with a private closure name, an orphan region, a region of a region, a pair split across two installations, a construct with no region, declarations with no handler, and a handler with no declarations — all of it before the root document is read. Resolution places a declared construct in the host tier beside declared Markdown; `inspectComponent()`, `inspectSyntax()` and document validation describe and check it from the same catalog, with the structural entries sorted by code point beside the engine's own and the symbols unchanged at version 2. Expansion asks the catalog after every engine branch and before component import, settles placement, forms, `as`/`slot` and every construct and region prop, and then calls the captured handler once inside its own scope with a frozen request. Each region is an operation whose subscription owns one demand-driven producer: a chunk is delivered only to a waiting read, authored work waits for the read that permits it, output never reaches `DocumentOutput` or the journal, and leaving the handler's scope halts and joins every producer it entered | built on the #806 stack | | declared Markdown component | a trusted host declares exact first-party Markdown to one execution as immutable data on an `ExecutionInstallation`, built with `Markdown({…})`: the host states the name, origin, source, its SHA-256, the accepted forms, an optional statement of the props and return that must agree with the parsed source, and an optional private component closure, and the constructor returns a fresh declaration carrying the required `kind: "component"` written after that description. It validates, hashes, copies and freezes nothing. Admission reads the kind before any other member and refuses a missing, unknown or superseded one — including the `"markdown"` earlier versions stated — rather than reading the value as Markdown, then parses the bytes and refuses a mismatched digest or schema, a non-canonical form, a name that is not a component name or is structural, a duplicate, a reserved-registration collision and a private name a registration also claims. Resolution places it in the protected tier with reserved registrations, above the workflow component bundle, repository files and every registered default. Live import and retained history are held to the declared origin, digest and bytes, private names resolve only while canonical core expands the declaring bytes' own body — by the authored occurrence rather than by the name, so an answer kept from a legitimate private import authorizes no later site, no alias, no copy of the definition, no invocation that is over and no later execution — including one that declares no Markdown at all — while a private name written anywhere else resolves to nothing before the bundle, the repository or a registration can answer for it — and `xmd syntax` and document validation describe the declared contract from the same declaration without describing the closure. Closure is per name: only the declared component and its private closure become canonical imports, and every other name in the execution stays the ordinary open import middleware may still answer | built on the #660 stack; no public component uses it yet (#660 PR 2) | | workflow component bundle | a workflow root declares a closed set of authored Markdown components; the workflow definition optionally carries them as one canonically ordered array, and an absent member identifies a run closed over no components — so a definition retained before the member existed reads unchanged. A version-1 entry holds the name, its canonical repository-relative path inside the pinned commit and that blob's object ID; a version-2 entry maps the name onto a logical path the same source bundle retains. `start` reads every component from beside the root and retains it, and `resume` executes the closure the lifecycle authenticated; the array takes part in definition identity and is compared as part of the same descriptor in compatible reuse; and canonical core resolves those names and holds both live import and retained history to that exact bundle | built on the #301 stack; the full adversarial implementation loop and its scheduling remain unbuilt (#300), and generated XMD admits no bundled Markdown component (#369) | -| `workflowInstallation()` / `getWorkflowRun()` | associates one document execution with a workflow run, through an `ExecutionInstallation` the trusted host passes to `executeInstalled()` | built on the #366 stack | -| `retainedWorkflowInstallation()` | associates one document execution with a run storage already created, requiring exact journal agreement | built on the #366 stack | +| `workflowInstallation()` | creates a version-1 run and associates one document execution with it, through an `ExecutionInstallation` the trusted host passes to `executeInstalled()`. It resolves the supplied base, so it belongs to `@executablemd/git` rather than to the workflow package | built on the #366 stack | +| `getWorkflowRun()` | reads the run the current execution is associated with | built on the #366 stack | +| `retainedWorkflowInstallation()` | associates one document execution with a run storage already created, requiring exact journal agreement. The association itself resolves no revision and names no Git feature, so this half stays in the workflow package; it is not a claim that the run needs no repository, because a version-1 definition retains no Markdown and obtaining its source is a later step through the host-supplied legacy reader | built on the #366 stack | | `Git.revParse()` | verifies and resolves one Git revision expression contextually | 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 | 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 | built on main | @@ -5202,14 +5227,14 @@ Status is measured against main. | transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) under a workflow run | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation admits anything — the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of admission, of retained state or of an unrecognized native condition fails the run instead of publishing a result | built on the #294 stack, Deno provider only | | `Git.Push` under a workflow run | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator. A destination proven absent is published to once and one already naming this exact commit is adopted; one naming a distinct commit that same authenticated source proves is in this commit's ancestry is a performable pre-state, published over by the same exact non-force refspec and retained as the predecessor with the attested relation, while a divergent commit and one the source cannot read are both conflicts and nothing is fetched to decide either; a completed Push is reconstructed from the Workspace root its own journal event was appended against, read without publishing it or moving the run's frontier, so a branch published more than once resumes | built on the #370 stack, Deno provider only | | `` under a workflow run | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` — the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit — proven by a scan of the whole successful history that requires each relevant record's natural key, inputs and result to describe one publication; a branch is published repeatedly, so the whole history is read in order and the run's last publication of that branch decides — an earlier one behind it is history rather than disagreement, while a last one naming another commit is the branch having moved on; that is conflicting, no relevant record at all is missing, and a relevant record that cannot be read whole is unreadable, each failing locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN`, then `GITHUB_TOKEN`, then the machine's own `gh` login, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only | -| repository composition vocabulary | one array of thirteen ordinary, shadowable registrations — `Repository`, `Worktree`, `Dir`, the four `Git.*` operations, `PullRequest` and its three evidence reads, `IssueTracker` and `Issue` — consumed by the workflow attachment, by `xmd syntax` and `xmd plan`'s validation and generation, and by an ordinary document execution, so one vocabulary is described and resolved everywhere. Registering it installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential; what a name does is the installed provider's. A repository-local Markdown or TypeScript component of the same name is chosen ahead of any of them | built on the #643 stack | +| repository composition vocabulary | one array of thirteen ordinary, shadowable registrations — `Repository`, `Worktree`, `Dir`, the four `Git.*` operations, `PullRequest` and its three evidence reads, `IssueTracker` and `Issue` — declared by the bundled Git Plugin and consumed wherever its profile is assembled: an ordinary document execution, `xmd syntax`, `xmd plan`'s validation and generation, and a workflow action that executes a document, so one vocabulary is described and resolved everywhere. A Workspace attachment installs this run's providers and durable state and declares none of it. Registering it installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential; what a name does is the installed provider's. A repository-local Markdown or TypeScript component of the same name is chosen ahead of any of them | built on the #643 stack | | `` / `` composition under an ordinary run | selects a managed checkout under `~/.xmd/repositories`, addressed by a digest of its whole identity and described by a closed version 1 sidecar written by exclusive temporary sibling plus atomic rename only after the checkout is complete and verified. The slot is entered under an exclusive non-blocking advisory lock held for the whole document execution, so a second process is refused rather than made to wait and a self-closing Worktree captured with `as` stays protected while a later sibling `` and an interactive Session use it. Reuse compares creation identity alone — the immutable request, the recorded creation facts, the canonical checkout and common directory, the object format, the admitted `origin` and the creation commit still being present — and never HEAD, the current branch, the index or the working tree, which are the mutable work the checkout exists to preserve; a conflict refuses and leaves every byte where it was, and nothing resets, switches, cleans, fetches, moves, replaces, repairs or deletes. A metadata-free slot is adopted only after the stricter pre-exposure state is proved — exact owner and locator, the branch and base this request resolves to, the creation commit still being HEAD, the object format, linked-worktree registration where applicable, and nothing in the slot but the checkout — and refuses otherwise. Written outside a lexical ``, a Worktree belongs to the ambient Repository; outside a Git checkout it refuses locally and names how to run inside one | built on the #643 stack, Deno and compiled only | -| local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | perform the same authored transitions the workflow performers perform — named branches only, explicit Add pathspecs, index-only Commit, no implicit stage or push, and the same fixed provider Git configuration that disables hooks, signing, file-system monitors and repository-supplied helper programs — directly against the authenticated selected checkout. A commit records the invoking user's own effective Git identity, captured once from the trusted host before the document expands and read back off the written object; a host that can name no identity refuses `` and names the commands that fix it rather than writing the workflow identity into somebody's repository, and every other component stays usable. They enlist in no transaction, roll back nothing and replay nothing, and a failure claims neither. Which checkout one runs in is decided by the Repository selection in scope and the contextual working directory, resolved through the provider's own invocation-owned checkout registry rather than through anything the selection says about itself | built on the #643 stack, Deno and compiled only | +| local Git operations (`Git.Switch` / `Git.Add` / `Git.Commit`) under an ordinary run | perform the same authored transitions the workflow performers perform — named branches only, explicit Add pathspecs, index-only Commit, no implicit stage or push, and the same fixed provider Git configuration that disables hooks, signing, file-system monitors and repository-supplied helper programs — directly against the authenticated selected checkout. A commit records the invoking user's own effective Git identity, captured from the trusted host by the first `` that needs it and read back off the written object — a run that commits nothing asks the host nothing about who it is; a host that can name no identity refuses `` and names the commands that fix it rather than writing the workflow identity into somebody's repository, and every other component stays usable. They enlist in no transaction, roll back nothing and replay nothing, and a failure claims neither. Which checkout one runs in is decided by the Repository selection in scope and the contextual working directory, resolved through the provider's own invocation-owned checkout registry rather than through anything the selection says about itself | built on the #643 stack, Deno and compiled only | | `Git.Push` under an ordinary run | keeps the same observe/adopt/fast-forward/refuse rules and the same isolated transport aimed at the checkout's admitted `origin`: a destination proven absent is published once, one already naming this exact commit is adopted, one holding a proven ancestor is published over by the same exact non-force refspec, and a divergent or unreadable one is a conflict, with an unreachable host never read as absence. It reconciles no Git-host effect and retains nothing. After a verified performed or adopted publication it stores one private evidence entry — the authenticated Repository identity, canonical checkout root, origin, named branch, destination ref and exact commit — in the provider instance's own closure. A checkout with no admitted `origin` refuses before a credential, a session or a transport exists | built on the #643 stack, Deno and compiled only | | `` and its evidence reads under an ordinary run | share the URL matching, host ceiling, response normalization and low-level GitHub reconciliation, and differ in lifecycle and admission. A read is performed afresh every execution and retained nowhere. An upsert authenticates the Repository selection and the contextual checkout, reads the current named branch and commit, and requires the exact matching entry this provider instance already holds — a Push for another checkout, Repository, origin, destination, branch or commit is irrelevant, a later Push of the same destination supersedes the earlier entry, and missing or conflicting evidence is a local refusal before a credential is opened. Nothing crosses executions: a new run and a new `--journal` run each start with a new invocation identity and empty evidence, and copying a Context value, a component result or a previous trace file grants nothing. Within one invocation the attempt happens at most once; across a process interruption there is no exactly-once claim | built on the #643 stack, Deno and compiled only | | `` under an ordinary run | reaches the same configured transport under the same host ceiling, with no durable envelope: identity is this execution's own opaque invocation identity together with the engine's expansion identity, so an upsert presents an idempotency key a provider can carry and a second run is a new request rather than a resumption. Absent or out-of-ceiling configuration installs no matching provider and sends no credential and no request | built on the #643 stack, Deno and compiled only | -| ordinary repository provider assembly | the Deno source entrypoint and the compiled binary install the live provider for `xmd run`, parameterized by the same credential-helper assembly the workflow host uses and by the two existing host configurations, `XMD_WORKFLOW_GITHUB_ISSUES` and `XMD_WORKFLOW_GITHUB_PULL_REQUESTS`, both read and validated before a document runs. A nested `` child receives a fresh instance — its own invocation identity, leases and Push evidence — so nothing it publishes authorizes its parent or a sibling. The outer `xmd test` command and a workflow execution install none. Node and Bun register the vocabulary and install no operational provider, so every repository operation reports an absent provider before a lock, a credential, a subprocess or a request exists | built on the #643 stack | -| `` under a workflow run | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not permission: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The Deno workflow host installs configured GitHub middleware and installs none otherwise, so absence of configuration is fail-closed | built on the #296 stack; GitHub middleware, Deno host | +| ordinary repository provider assembly | the Deno source entrypoint and the compiled binary install the live provider for `xmd run`, parameterized by the same credential-helper assembly the workflow host uses. It reads no configuration: `XMD_WORKFLOW_GITHUB_ISSUES` and `XMD_WORKFLOW_GITHUB_PULL_REQUESTS` belong to the GitHub adapter inside `@executablemd/git`, which reads and validates one when an invoked GitHub-backed operation needs it, so a run that opens no pull request and files no issue reads neither. Installing the provider acquires nothing at all — no managed root, no lease, no Git session, no identity, no ambient discovery; each of those is acquired by the first operation that needs it and kept for the rest of the execution. A nested `` child receives a fresh instance — its own invocation identity, leases and Push evidence — so nothing it publishes authorizes its parent or a sibling. The outer `xmd test` command installs none — it is not a run profile, and a nested `` child assembles one for itself rather than inheriting what its parent declined. Node and Bun register the vocabulary and install no operational provider, so every repository operation reports an absent provider before a lock, a credential, a subprocess or a request exists | built on the #643 stack | +| `` under a workflow run | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not permission: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The bundled Git Plugin installs the GitHub adapter itself rather than a decision about it: installing reads no variable, obtains no credential and opens no socket, and configuration is read only when a matching invoked operation recognizes a GitHub target. Absence is therefore not an uninstalled provider but an adapter that delegates, and the request reaches the boundary's own `NoIssueProvider` refusal unchanged — fail-closed either way, and without making startup depend on what a run turns out to do | built on the #296 stack; GitHub middleware, Deno host | | workflow lifecycle inspection and control | reads status/list/history without advancing a run, recovering a private copy when a crashed source needs rollback; enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | direct read-only inspection and control built on the #367 stack; coordinated recovered inspection built on the #513 stack, Deno provider only | | XMD artifact export, inspection and fork source | seals one run's committed retained state, Workspace roots and workflow definition source closure into one immutable `.xmd` evidence file; opens that file read-only for status/history and admits continuation only by creating a new history fork whose lineage names the artifact identity. The physical container stays at schema version 1 while the semantic format follows the run: format 1 carries a Git closure, format 2 carries a source bundle as one entry/content pair per logical path under its own manifest version and `xmd-artifact\0v2\0` identity domain, and the reader selects the closed inventory and verifier from the header rather than reading either as the other | specified by `specs/xmd-artifact-spec.md`; the sealed container, its total read-only verifier, both semantic formats, `xmd workflow export` and artifact `status`/`history` are built, Deno provider only — the artifact-source fork remains unbuilt. Inspection is two sibling lifecycle operations, `inspectArtifact()` and `historyArtifact()`, taking a path rather than a run id: a run id names live lifecycle ownership and a path names immutable evidence, so neither is a mode of the other. They reach no run store, lock, Workspace, definition reader or external provider, and the artifact path never enters the structural answer | | Agent session portability evidence in an XMD artifact | classifies every logical Agent session that contributed a retained Prompt as portable — with ordered provider checkpoint tokens and an opaque Agent session bundle — or as explicitly unavailable, as two content kinds inside the existing version-1 manifest and identity | specified by `specs/xmd-artifact-spec.md` §2.5; the closed union, both content kinds and the complete post-identity profile verifier are built on the #621 stack, Deno provider only. Provider bundle capture, Agent-aware export, intrinsic Agent-aware inspection and artifact-backed fork are unbuilt | @@ -5217,7 +5242,7 @@ Status is measured against main. | history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props | built on the #368 stack, Deno provider only | | workflow Agent session | a workflow document's `` runs under a profile the host attaches only for a live or partial run: an empty host-owned working directory instead of any Workspace, checkout or caller path, no MCP servers, an empty requested native tool set, and `deny-all` with a permission path that denies every native request and fails the turn that asked without reaching the public permission chain. Within a run a session is identified by the Agent/Session expansion identity the engine derived — the authored name is descriptive, so two sibling `` elements are two sessions — routed inside a placement bound to its element and good for one use, so a kept placement cannot be substituted for the next. The conversation is retained as a row in the run's own database with the provider, resolved agent command and policy fingerprint beside it as compatibility attributes. The order is placement, the backend's acceptance of the session's first turn, the provider's canonical tagged assertion, then the mapping commit — and only then is anything that turn produced exposed. A placement is inert: it creates no provider session and writes no row. Occupancy of a provider key is not an assertion, and a record held for a first turn nobody accepted asserts nothing at all; the pre-commit window reconciles only from exactly one canonical assertion, and a missing, conflicting, replaced or ambiguous assertion is one explicit refusal that starts no replacement. Deleting a run removes the row with the run and the provider-session directory beside it, and reports the categories. The profile selects ACP-only capability explicitly — no native-launch advertisement and no client-native attachment advertisement — rather than inheriting the provider package's ordinary-run sets by omission, and it supplies no machine session coordinator, construction-route store or executable observer: a workflow session belongs to a run, and the machine-wide account describes a different thing entirely | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling | | generated-XMD admission | admits one Agent-generated fragment through the trusted-host seam: host policy is a `read` table and a `write` table of exact pinned identities, each carrying the authored forms it is admitted for, and an authored `allow` selects a canonical non-empty subset of the closed classes — omitted means `read`. The complete source is preflighted inside one `generated_xmd` durable effect before its first generated effect; only the pinned identity the selected classes hold for that name **and** that form executes; and the admitted source, class selection, selected root, every selected entry with its forms, the identity and form of each element named, and the normalized request policy are retained in that effect's own result — so a continuation restores the decision without reading the current candidate and expands only the retained source. The roots are an as-of-admission retained basis checked by membership — the run's own later root publications and an advanced retained current root pass, while a lost admission root or lost selected root refuses — and every non-root term is checked exactly, refusing a run whose classes, identities, forms or requests have moved. The admission and every nested generated effect are offered inline by the owning expansion in authored order, so a partial continuation restores each completed one without another live execution. Each admitted effect is retained by its own ordinary record, and every component keeps its ordinary binding and output behavior | built on the #369 stack, continuation basis amended by #589; core owns the mechanics and the workflow policy wrapper is internal | -| `` | canonical core's own protected component, written where program text the document did not author should run. Core claims the name ahead of every host and author tier, so no registration, repository file, bundle member, declared Markdown component, import handler answer or second loaded copy replaces it; a handler may observe or refuse the import, and only canonical execution answers one. Protection settles which implementation runs and grants nothing: a host supplies the *ceiling* as one `ExecutionInstallation.evaluation` profile, captured by value before any installation runs, and an execution accepts one and refuses two. Its schema is closed on `text`, the workflow-only `source` alias, and an optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read`. The two input forms are disjoint: `text` states the program, paired content renders it, and an element stating both is refused rather than resolved by precedence. A paired producer renders under the narrowed syntax reference through an execution-owned one-shot projection that bypasses the public `content()`/`tryContent()` chain, and keeps its own operational permission while doing so. Evaluate renders the fragment's ordinary output and invents no observation or result envelope. Fragment-local `as` bindings suppress their component's output normally, and language constructs plus pure components such as `` remain available regardless of effect selection; the fragment explicitly renders any bound values it wants to expose. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage per invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's self-closing `` read — joined in the ordinary run profile by core's self-closing `` and canonical protected `` — the write table of core's paired ``, the workflow's `` and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. An entry states what is behind a name in one of exactly two ways, and neither carries a function. A *capability* names an operation core supplies the body for: canonical capture reads the host's own operation off once, binds it behind a revocation the execution owns, and closes core's own body over it, so an admitted element reaches those operations and never `API.Files`, `API.Fetch` or `API.Env`. A capability states what it binds wherever the ordinary component does, so an admitted `` is the value component the ordinary one is — sharing its props, return contract, source rules and sanitized failure sentence, refusing a missing `as` and an unusable pattern before the search, and reaching the captured search operation and captured working directory rather than a provider a document arranged. A name admitted for two spellings that would bind different results refuses at capture. A *component answer* names an implementation the ordinary import chain resolves, and states only the identity a provider must have claimed for it; canonical execution resolves that name once — before the root import and before any document code, through the complete ordinary `Component.importComponent` chain and a private terminal that writes no record — and asks the identity owner about the exact final answer in one call. That call answers with the claim *and* core's own copy of what was claimed, taken when the claim was recorded: the check and the thing kept are one result, so nothing downstream reads the chain's object a second time and an answer whose members read differently on each read cannot pass a check with one reading and be sealed with another. The identity is then compared whole, and the copy — not the answer — is sealed behind the same revocation. One name states one identity, however many forms and tables hold it: two entries under one name are the two spellings of one component and share one lookup and one sealed implementation, while a second identity for that name, or a name held as both a capability and a component answer, refuses at capture rather than letting assembly order decide. Provider installation and the right to answer one occurrence are separate: installation receives a registrar that installs import middleware, and every invocation of that middleware receives a fresh request fixed to the exact name, position, provider installation, origin and resolution-window object it was asked under. Only that request may claim, and it states the answer plus key and revision without restating the name. Canonical execution closes the request synchronously when its handler returns, fails or is cancelled; an outer request remains live while it delegates and may claim a replacement after the inner handler returns. Enclosing resolution still closes in its own `finally` on success, fallback, failure or cancellation. A claim therefore requires the execution, request and exact current window to remain active, the request's fixed name to match that window, and that provider installation not to have stated a different answer in the window. Identification takes the expected window explicitly and accepts only a claim recorded for that exact object and name, so a stale request cannot answer a later same-name resolution or retag itself while another name is live. The installation is reusable: one provider may answer several admitted names and repeated resolutions through distinct requests. All of this state is held in ordinary private closures — no Context, shared symbol, public brand or module-global registry — and an unidentified middleware replacement remains a valid ordinary import answer outside fragment evaluation. A capability entry also states the exact version-1 identity strings it succeeds, which is the only thing a released untagged record reconciles against; a component answer states none. A capability-only profile performs no such lookup at all. Resolution happens at capture and never again: a fragment runs the sealed snapshot, a continuation resolves once more in its own capture and reconciles before any effect, and a provider still answering when a fragment resolves its admitted name is answering a generated import, which only canonical execution answers. A trusted layering control may enter one package bootstrap through an inherited layer and an execution-local one — documentation, registrations and the provider together — and the repeat is additive: the outer entry observes and delegates rather than claiming a second identity for an implementation that already states what it is, and a non-identical documentation overlap on one owner and component still refuses at the child collection boundary, before the root import and therefore before the provider is asked at all. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated effects use occurrence-owned identities in the existing ordinary durable sequence: their records persist and replay normally, with no staging or rollback. The generated projection and its structured teardown complete before Evaluate returns and later parent work begins. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 | +| `` | canonical core's own protected component, written where program text the document did not author should run. Core claims the name ahead of every host and author tier, so no registration, repository file, bundle member, declared Markdown component, import handler answer or second loaded copy replaces it; a handler may observe or refuse the import, and only canonical execution answers one. Protection settles which implementation runs and grants nothing: a host supplies the *ceiling* as one `ExecutionInstallation.evaluation` profile, captured by value before any installation runs, and an execution accepts one and refuses two. Its schema is closed on `text`, the workflow-only `source` alias, and an optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read`. The two input forms are disjoint: `text` states the program, paired content renders it, and an element stating both is refused rather than resolved by precedence. A paired producer renders under the narrowed syntax reference through an execution-owned one-shot projection that bypasses the public `content()`/`tryContent()` chain, and keeps its own operational permission while doing so. Evaluate renders the fragment's ordinary output and invents no observation or result envelope. Fragment-local `as` bindings suppress their component's output normally, and language constructs plus pure components such as `` remain available regardless of effect selection; the fragment explicitly renders any bound values it wants to expose. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage per invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's self-closing `` read — joined in the ordinary run profile by core's self-closing `` and canonical protected `` — the write table of core's paired ``, the bundled Git Plugin's `` — supplied by the host that assembles the profile, since this package states no directory entry of its own — and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. An entry states what is behind a name in one of exactly two ways, and neither carries a function. A *capability* names an operation core supplies the body for: canonical capture reads the host's own operation off once, binds it behind a revocation the execution owns, and closes core's own body over it, so an admitted element reaches those operations and never `API.Files`, `API.Fetch` or `API.Env`. A capability states what it binds wherever the ordinary component does, so an admitted `` is the value component the ordinary one is — sharing its props, return contract, source rules and sanitized failure sentence, refusing a missing `as` and an unusable pattern before the search, and reaching the captured search operation and captured working directory rather than a provider a document arranged. A name admitted for two spellings that would bind different results refuses at capture. A *component answer* names an implementation the ordinary import chain resolves, and states only the identity a provider must have claimed for it; canonical execution resolves that name once — before the root import and before any document code, through the complete ordinary `Component.importComponent` chain and a private terminal that writes no record — and asks the identity owner about the exact final answer in one call. That call answers with the claim *and* core's own copy of what was claimed, taken when the claim was recorded: the check and the thing kept are one result, so nothing downstream reads the chain's object a second time and an answer whose members read differently on each read cannot pass a check with one reading and be sealed with another. The identity is then compared whole, and the copy — not the answer — is sealed behind the same revocation. One name states one identity, however many forms and tables hold it: two entries under one name are the two spellings of one component and share one lookup and one sealed implementation, while a second identity for that name, or a name held as both a capability and a component answer, refuses at capture rather than letting assembly order decide. Provider installation and the right to answer one occurrence are separate: installation receives a registrar that installs import middleware, and every invocation of that middleware receives a fresh request fixed to the exact name, position, provider installation, origin and resolution-window object it was asked under. Only that request may claim, and it states the answer plus key and revision without restating the name. Canonical execution closes the request synchronously when its handler returns, fails or is cancelled; an outer request remains live while it delegates and may claim a replacement after the inner handler returns. Enclosing resolution still closes in its own `finally` on success, fallback, failure or cancellation. A claim therefore requires the execution, request and exact current window to remain active, the request's fixed name to match that window, and that provider installation not to have stated a different answer in the window. Identification takes the expected window explicitly and accepts only a claim recorded for that exact object and name, so a stale request cannot answer a later same-name resolution or retag itself while another name is live. The installation is reusable: one provider may answer several admitted names and repeated resolutions through distinct requests. All of this state is held in ordinary private closures — no Context, shared symbol, public brand or module-global registry — and an unidentified middleware replacement remains a valid ordinary import answer outside fragment evaluation. A capability entry also states the exact version-1 identity strings it succeeds, which is the only thing a released untagged record reconciles against; a component answer states none. A capability-only profile performs no such lookup at all. Resolution happens at capture and never again: a fragment runs the sealed snapshot, a continuation resolves once more in its own capture and reconciles before any effect, and a provider still answering when a fragment resolves its admitted name is answering a generated import, which only canonical execution answers. A trusted layering control may enter one package bootstrap through an inherited layer and an execution-local one — documentation, registrations and the provider together — and the repeat is additive: the outer entry observes and delegates rather than claiming a second identity for an implementation that already states what it is, and a non-identical documentation overlap on one owner and component still refuses at the child collection boundary, before the root import and therefore before the provider is asked at all. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated effects use occurrence-owned identities in the existing ordinary durable sequence: their records persist and replay normally, with no staging or rollback. The generated projection and its structured teardown complete before Evaluate returns and later parent work begins. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 | | generated mutation proposals | lets an Agent propose constrained executable changes that a separate admission then performs against the run's own Workspace | built on the #369 and #567 stacks, with directory creation added by #643: the standard Deno profile's write table is core's paired `File:write`, the paired `@executablemd/workflow/composition/dir-v2#Dir` and core's self-closing `File.Delete`, in that retained order and followed by any host extension. `allow={["write"]}` intentionally authorizes Dir's persistent recursive directory creation; its versioned identity makes every continuation retained under the former non-mutating Dir identity refuse before generated execution. Admitted mutations run as the ordinary components they are through the run's effect transactions, and the evaluator adds no mutation API or receipt. Approval is authored control flow before the write-enabled element. Local Git, Git-host, issue, process, execution, credential and external-write effects are outside the class | | Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path, captures arbitrary canonical retained roots, privately restores them, and atomically coordinates one Workspace mutation with its filtered Yield | built on the #365 stack; public document filesystem effects and the CLI lifecycle route to it on the #366 stack | | scoped Worker Shell | executes `just-bash` through the Workspace adapter inside a Deno Worker | containment and effect-transaction POCs complete (#351, #357); production integration unbuilt | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8c2f3b2e8..7c49a3d5b 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2980,8 +2980,8 @@ export function* runXmd( * version, and the internal worker mode. * * What a command runs with is the profile `assembleRunProfile()` builds: the - * one Plugin XMD bundles, for the commands that execute or describe a - * document, and then whatever the operator selected in the order they wrote + * one Plugin XMD bundles, for the run-profile commands the Plugin itself + * declares for, and then whatever the operator selected in the order they wrote * it. A package that happens to be installed stays inert until it is named, * and a command outside that profile — `xmd test`, `upgrade`, a workflow * management action — carries no Plugin it did not ask for. diff --git a/packages/cli/src/deno-repositories.ts b/packages/cli/src/deno-repositories.ts index e2b47fc17..07e02e72a 100644 --- a/packages/cli/src/deno-repositories.ts +++ b/packages/cli/src/deno-repositories.ts @@ -24,9 +24,12 @@ import type { RepositoryInstaller } from "./run-repositories.ts"; /** * The live provider Deno and the compiled binary install. * - * The two GitHub configurations are read once, when the installer runs, so an - * operator who wrote something this host cannot use learns it before a document - * expands rather than in the middle of one. + * It reads no configuration. The two GitHub variables belong to the adapter + * inside `@executablemd/git`, which reads them when an invoked GitHub-backed + * operation needs them — so a run that opens no pull request and files no issue + * reads neither, and an operator who wrote something unusable learns it from + * the operation that needed it rather than from a command that was only + * starting up. */ export function denoRunRepositories( helper: HelperAssembly, diff --git a/packages/cli/src/run-profile.ts b/packages/cli/src/run-profile.ts index e7d6b2114..f4963f750 100644 --- a/packages/cli/src/run-profile.ts +++ b/packages/cli/src/run-profile.ts @@ -3,7 +3,8 @@ * operator named. * * XMD ships exactly one Plugin — `@executablemd/git` — and activates it for - * every command that executes or describes a document. It is statically + * the run-profile commands, which are not simply the ones that execute a + * document: `xmd test` executes one and carries none. It is statically * imported rather than loaded, so the distribution carries it whole and no * resolution can substitute it. Explicit `--plugin` selections follow it in the * order they were written, which fixes how middleware composes: the bundled diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index 7a69e685d..c5ea3ffb7 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -18,13 +18,27 @@ * outside Git, an untracked file, and a file edited since its last commit each * be one immutable definition the moment it is retained. * + * Starting a version-1 run means resolving a base, which is a Git capability + * this package does not own: `workflowInstallation({ base })` is exported by + * the `@executablemd/git` package instead. No module here names it in an + * import, which is the boundary rather than an accident of layout. + * + * What stays here is the retained half. `retainedWorkflowInstallation()` + * resolves no base and names no Git feature — but that is a statement about + * this package's imports, not about what a run needs. A version-1 definition + * retains no Markdown, so resuming, forking or exporting one still obtains its + * bytes through the host-supplied legacy source reader, which may well read a + * repository. Workflow authenticates the closure that reader returns against + * the retained descriptor, recomputing every blob identity from the bytes + * themselves. Only a source-bundle run needs no repository at any point. + * * ```ts - * import { workflowInstallation } from "@executablemd/workflow"; + * import { retainedWorkflowInstallation } from "@executablemd/workflow"; * import { executeInstalled } from "@executablemd/core/host"; * * const execution = yield* executeInstalled( * { path: "./workflow.md", stream }, - * [workflowInstallation({ base: "main" })], + * [retainedWorkflowInstallation(run)], * ); * ``` * @@ -33,28 +47,14 @@ * `@executablemd/workflow/deno`; nothing here imports it, and nothing here * imports SQLite, Deno or any other host. * - * ## Git-host effects - * - * A **Git host** is an external service that owns remote Git repositories and - * associated collaboration objects such as branches, pull requests and issues. - * GitHub is one Git-host adapter; a Git host is not the local Git capability - * and not the trusted workflow host. - * - * A Git host owns state no local transaction can enclose, so pushing, opening a - * pull request and filing an issue all face the same question after an - * interruption: did the previous attempt already succeed? - * `reconcileGitHostEffect()` answers it once, for all three. A live attempt - * observes under an identity derived from the run and the expansion, then - * adopts a proven compatible completion, performs a proven absence exactly - * once, or refuses. Prompt is not one of these effects and keeps its Agent - * provider contract. + * ## What this package no longer owns * - * `withGitHostProvider()` installs the provider that answers those phases. A - * provider need not implement every kind: a plain Git server may support - * `git-push` and refuse pull requests and issues. Routing is one contextual - * operation that settles no completion — middleware may inspect, - * narrow or refuse a request, and nothing it can hold or combine can answer - * one. + * Repository composition, the Git capability, issue and pull-request contracts + * and the Git-host reconciliation engine belong to `@executablemd/git`, which + * imports this package's public extension boundaries rather than the other way + * round. A run's history still holds their retained records, and this package + * still reads enough of one to decide whether a checkpoint can be forked — as + * compatibility data, named by the strings a released build wrote. */ export { getWorkflowRun, retainedWorkflowInstallation } from "./src/run.ts"; diff --git a/specs/code-review-agent-spec.md b/specs/code-review-agent-spec.md index 1f158b807..ded41ce23 100644 --- a/specs/code-review-agent-spec.md +++ b/specs/code-review-agent-spec.md @@ -236,8 +236,8 @@ test root does not. The workflow action is found by name among the tokens after `workflow`, so an option written before it — `--plugin` included — is never read as one. -Nothing in the CLI names this package. XMD ships no Plugin, so the graph -arrives only when an operator selects it — `--plugin @executablemd/code-review-agent` +Nothing in the CLI names this package. XMD bundles only `@executablemd/git`, +so this graph arrives only when an operator selects it — `--plugin @executablemd/code-review-agent` where the package is installed, or `--plugin ./packages/code-review-agent/mod.ts` by path — and a command that names none has none of the forty-one names. @@ -1028,8 +1028,8 @@ normalization live in typed function components or package modules. The review workflow checks out the requested revision, installs the pinned Deno toolchain, runs `deno task setup`, and executes that checkout's `./dist/xmd` binary with `--plugin ./packages/code-review-agent/mod.ts`. The -binary embeds none of this package: it ships no Plugin, so the graph arrives -because the workflow selected it, and the package reads its own assets from the +binary embeds none of this package: the only Plugin it ships is +`@executablemd/git`, so this graph arrives because the workflow selected it, and the package reads its own assets from the checkout it was named in. It still passes no component include, which is the anti-shadowing claim and is diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index b99b705db..a7f6a1401 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1955,7 +1955,8 @@ run but are absent from the diagnostic trace. | `packages/workflow/src/deno/workspace/host.ts` | `withWorkflowWorkspace()` — the run's effect coordinator, logical cwd `/`, and Files provider installed together inside one execution | | `packages/workflow/src/generated-observations.ts` | `evaluateGeneratedFragment()` — the workflow policy adapter over `evaluateGeneratedXmd()`, returning its rendered text directly as `Operation`; `GeneratedObservation` remains the read-table entry type, not an output envelope | | `packages/workflow/src/journal.ts` | the `workflow_run` record, canonical-record recognition, and the refusals that name differing fields without their values | -| `packages/workflow/src/run.ts` | `workflowInstallation()` / `retainedWorkflowInstallation()` — the `ExecutionInstallation` values a trusted host passes to `executeInstalled()`, each contributing a mandatory run-identity admission and the `prepare` hook that creates or restores the run inside the durable root | +| `packages/workflow/src/run.ts` | `retainedWorkflowInstallation()` and `getWorkflowRun()` — associating one execution with a run already created, as the `ExecutionInstallation` a trusted host passes to `executeInstalled()`, contributing a mandatory run-identity admission and the `prepare` hook that reopens the run inside the durable root. The association resolves no revision and imports no Git feature, which is why it stays here; obtaining a version-1 run's Markdown afterwards is a separate step that reaches the host-supplied legacy reader | +| `packages/git/src/installation.ts` | `workflowInstallation()` — the matching `ExecutionInstallation` that *creates* a version-1 run, resolving the supplied base to a pinned commit. Creating one is the single lifecycle step that needs a Git capability, so it is stated by the package that owns one rather than by Workflow | | `packages/workflow/src/bundle.ts` | `workflowBundleInstallation()` — the `ExecutionInstallation` that closes one execution over a workflow's component bundle, carrying the pinned execution view and the admission that holds every retained component import to it | | `packages/cli/src/file-stream.ts` | `FileStream` — JSONL-backed `DurableStream` implementation | @@ -2766,10 +2767,10 @@ matter, so two bootstraps that build fresh objects and list the same names in different orders have contributed the same value. **A repetition of the same value adds nothing and succeeds.** One package's -declarative vocabulary is deliberately entered at more than one layer — the -repository-composition set by an ordinary run's bootstrap and again inside a -workflow attachment, because either may be the only one, and a nested run or -evaluation host the same way — and the inner scope descends from the outer, so +declarative vocabulary is deliberately entered at more than one layer — a +nested run installs the same bundled Plugin again in its own scope, and an +evaluation host the same way, because the inner scope may be the only one a +document sees — and the inner scope descends from the outer, so both wrappers sit in one chain. One bootstrap and two identical bootstraps produce exactly the same captured documentation, and the repeated bootstrap keeps both its registrations and one documentation value. @@ -2895,8 +2896,11 @@ ordinary registered defaults in tier 5. A repository-local Markdown or TypeScript component of any of those names is chosen ahead of them, exactly as it is ahead of any other package's default, and for its own scope alone. -They are one array with several consumers: an ordinary document execution, a -workflow attachment, `xmd syntax`, and `xmd plan`'s validation and generation. +They are one array declared by the bundled Git Plugin and read wherever its +profile is assembled: an ordinary document execution, `xmd syntax`, `xmd plan`'s +validation and generation, and a workflow action that executes a document. A +Workspace attachment installs that run's providers and durable state and +declares none of them. Registering them installs no provider, discovers no repository, acquires no lock, spawns no Git and reads no credential — describing an environment mints nothing. What each name *does* is decided by whichever repository provider the @@ -5259,7 +5263,7 @@ Neither this operation nor the method on the invocation selects an effect. A component whose forms do different things declares them, and canonical invocation-form dispatch (§5.6) enters the one the scan recorded — `` (§6.13) is dual-form, `` (§6.13.1) is self-closing only, and the -workflow package's `` is paired only. A component that reads this +bundled Git Plugin's `` is paired only. A component that reads this contextual operation has no authored-form guarantee at all: what it receives is whatever the chain answered for that call. @@ -9443,18 +9447,25 @@ imported. There are two, and they differ in lifetime and permission rather than in what an author writes. The **ordinary provider** is what the Deno source entrypoint and the compiled -binary install for `xmd run`. Constructing -it mints a fresh opaque invocation identity and empty state, both private to -that one execution: - -- **Ambient discovery** happens once, before root expansion, from the +binary install for `xmd run`. Constructing it acquires nothing at all: no +managed root, no lease, no Git session, no invocation identity, no commit +identity and no ambient discovery. Each of those is obtained by the first +invoked operation that needs it, once — a second ask, including one arriving +while the first is still in flight, shares that acquisition rather than starting +another — and is kept for the rest of the execution, held by the scope the +provider was installed in rather than by whichever element happened to ask +first. A document that writes no repository component performs none of it and +leaves no managed root behind: + +- **Ambient discovery** happens on the first ambient request, from the invocation's starting directory: the canonical checkout root, the canonical common Git directory, the object format, the current HEAD and branch, the locally recorded admitted `origin` when there is one, and the recorded default branch. Being outside a Git checkout is not a startup failure — only an element that needs a repository refuses, and it names how to run inside one. - **The Git identity** an ordinary commit records is the invoking user's own, - read once from the trusted host's environment and configuration. It is used + read from the trusted host's environment and configuration by the first + `` that needs it — a run that commits nothing asks nothing. It is used for `` alone and is not otherwise observable; a host that can name no identity refuses that one component and leaves every other one usable. Nothing else crosses from the caller's environment: hooks, file-system @@ -9736,10 +9747,18 @@ and no later policy replaces it. ### Plugins A **Plugin** is trusted code an operator selects, and it is installed before an -execution imports a root document. The CLI accepts a repeatable -`--plugin ` or `--plugin=`, and those occurrences in the -order they were written are the complete list: XMD ships no Plugin and installs -none for a command that named none. +execution imports a root document. XMD bundles exactly one — +`@executablemd/git` — and activates it for the run-profile commands: `run`, +`plan`, `syntax`, and the `workflow` actions that execute a document, namely +`start`, `resume` and `fork`. Every other command carries none, including +`xmd test`, which executes a document but is a harness rather than a run +profile: its root must not claim names its children are entitled to shadow, and +a nested `` child assembles the profile for itself. The +CLI accepts a repeatable `--plugin ` or +`--plugin=`, and those occurrences in the order they were written +follow the bundled value. `git` is a reserved host selector naming that value +rather than a module: it resolves without loading anything and repeating it +changes nothing. A Plugin is a plain structural value: @@ -13254,12 +13273,12 @@ Plugin is the run it always was. | PL2a | Admission returns the value | What comes back is the admitted object itself: members outside the contract survive, `install` is the same function the module exported, and calling it through admission gives it the receiver its own module gave it | | PL3 | The flag grammar | Both spellings are read in occurrence order, only those tokens are removed, the scan stops at `--`, the original argv is retained frozen, and a missing or option-shaped value refuses before anything loads | | PL4 | The command a Plugin is told | Each public command reports its own name and the shorthand document form reports `run`; help, `--version` and the internal worker mode install no Plugin and load no module | -| PL5 | Order composes | The repeated `--plugin` occurrences in written order are the complete order, with no prefix in front of them; the first selected Plugin is the outermost `Document` wrapper, and reversing the selection reverses the composition | +| PL5 | Order composes | The bundled Plugin comes first where the command's profile carries it, then the repeated `--plugin` occurrences in written order; the first Plugin installed is the outermost `Document` wrapper, and reversing the selection reverses the composition behind the prefix | | PL6 | One name, one Plugin | Two selections claiming one Plugin name refuse before the first `install()` runs, whichever modules they came from | -| PL7 | The active list | Every Plugin, including the first, reads the complete frozen list, and a snapshot is not the installed array | +| PL7 | The active list | Every Plugin, including the first, reads the complete frozen list — bundled value first where the profile carries it — and a snapshot is not the installed array | | PL8 | Declarative contribution | `components`, `structural` and `admissions` cross as one `ExecutionInstallation`; returning `undefined` contributes none; two Plugins declaring one component name refuse at admission | | PL8a | The pair is the Plugin's | A Plugin that declares structural syntax and supplies no `expand`, or supplies one and declares none, is refused where the assembly is built — before `xmd syntax` describes the construct, before Plan validation accepts it and before a run expands it; the Plugins installed before it are unwound and the one after it never installs | -| PL9 | Nothing is discovered | A module beside a selected one, and an installed package nobody named, are never loaded; a command that selected none loads none | +| PL9 | Nothing is discovered | A module beside a selected one, and an installed package nobody named, are never loaded; a command that selected none loads no module at all — the bundled value is statically imported rather than resolved | | PL10 | Explicit paths and no remote | An explicit path inside the current repository loads; a remote specifier refuses without fetching | | PL10a | A path is not a scheme | A Windows drive path classifies as a filesystem path on every host, not as a remote URL; relative paths, POSIX absolute paths, UNC shares, `file:` URLs, bare packages and real remote schemes each keep the answer they had | | PL11 | Lifetime | A Plugin that fails to install unwinds the Plugins before it, reads no root, starts no document, and leaves nothing a document would have written | @@ -13268,7 +13287,10 @@ Plugin is the run it always was. | PL14 | The key, not the name | An Api built under the bare names `Document`, `RootMetadata` or `ActivePlugins` composes nothing and observes nothing; one built under the published key composes, including from a second loaded copy inside the compiled binary | | PL15 | Selection by package | A bare specifier resolves in the invocation directory's package environment, and the Plugin's own name is what identifies it — not the package or module it came from | | PL16 | Cancellation | A command halted while a Plugin is still installing releases what the Plugins before it acquired, installs nothing after it, and stays a cancellation; a scope whose body and teardown both fail reports exactly what it reported before Plugins existed | -| PL17 | Nothing by default | A command that names no `--plugin` installs none, under every command; the review graph is absent from the symbols and from a run until it is selected, and present in both once it is | +| PL17 | One bundled Plugin, and nothing else by default | A command that names no `--plugin` installs exactly the bundled `@executablemd/git` where its profile carries it — `run`, `plan`, `syntax` and workflow `start`/`resume`/`fork` — and nothing at all for `xmd test`, `upgrade` and a workflow management action; an unselected Plugin such as the review graph is absent from the symbols and from a run until it is named, and present in both once it is | +| PL19 | The reserved selector | `--plugin git` names the bundled value rather than a module: it loads nothing, and writing it once or repeatedly leaves one `@executablemd/git` first in the active list. It cannot give a command a profile it does not have — `--plugin git` before a workflow management action or `xmd test` still installs none | +| PL20 | The selector is not the name | `--plugin @executablemd/git` is an ordinary specifier: it loads, and a module claiming the bundled Plugin's name is refused as a duplicate like any other collision. Identity-based idempotence belongs to the host's own selector for its own value, and a nested run child prefixing the bundled value drops it by identity so an impostor still collides | +| PL21 | Test isolation | The outer `xmd test` root carries no bundled Plugin and resolves none of its names; a nested `` child assembles the run profile in its own scope and resolves all of them, and does not inherit what its parent declined | | PL18 | Both arms, every consumer | A Plugin declaring structural syntax and no Markdown component has its construct and region described by `xmd syntax` with the pair reported, accepted by Plan structural validation, and expanded by a run under the same installation; the same candidate is refused where nothing declared it | ### Tier RC — Root composition (§5.4, §7 Plugins) diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 897a7d60b..759b14316 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -354,10 +354,10 @@ Plan is a program a later `xmd run` executes, and this authorship execution searches no repository and refuses almost every capability, so symbols derived from it would describe a vocabulary the approved program would not have. -That vocabulary is whatever the Plugins this invocation explicitly selected -declare, and nothing else: XMD ships no Plugin, so a `xmd plan` that named none -describes the engine's own language and a Plan is held to exactly that. There is -no review prefix in front of the selection. The command installs what was named +That vocabulary is what this invocation's profile declares: the bundled Git +Plugin, which `xmd plan` carries by default, and whatever else the operator +selected. A Plan is held to exactly that. There is no review prefix in front of +the selection — the one prefix is Git's, and it is the host's own. The command installs what was named once and retains one assembly: the symbols the writer is shown, every draft check, the admission inside `` and the gate the command keeps after that document has torn down all read it. Reinstalling per check would diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 52ac507f0..49a90be12 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -680,9 +680,12 @@ the flags, and every embedded asset, in three lists that differ in how they are maintained: - `EMBEDDED_PACKAGES` — a whole package the binary executes Markdown out of. - **Empty**, because XMD ships no Plugin: the binary contains the program `xmd` - is, and a Plugin is not part of it. An entry here is for a package the binary - *imports*; naming one nothing imports would embed bytes no code can reach. + **Empty**, and not because no Plugin ships. `@executablemd/git` is bundled, + but it is *statically imported*, so its module graph is already part of the + program the binary contains and its documentation asset travels through + `PACKAGED_DOCUMENTATION` below. An entry here is for a package the binary + executes Markdown out of without importing; naming one nothing imports would + embed bytes no code can reach. - `UNEMBEDDED_PACKAGES` — the packages whose assets the binary deliberately does not carry, so the discovery sweep below does not demand them. The code-review package is the one: it is selected with `--plugin` and reads its own assets diff --git a/specs/testing-spec.md b/specs/testing-spec.md index 214851e34..b16460907 100644 --- a/specs/testing-spec.md +++ b/specs/testing-spec.md @@ -318,10 +318,12 @@ Plugins the invocation selected. It runs in an isolated scope and inherits no middleware, no registration and no installation-scoped resource, so the host installs the same Plugin **values** again inside that scope, with command `run` — never the parent's live middleware and never a second reading of the command -line. What it reinstalls is what the invocation selected: a child of an -invocation that named no `--plugin` installs none, and one that selected the -code-review Plugin receives the review graph, which claims nothing at the test -root and claims all forty-one of its names in the child. +line. What it reinstalls is the run profile: the bundled Git Plugin, which the test +root itself does not carry, plus whatever the invocation selected. A child of an +invocation that named no `--plugin` therefore receives Git's thirteen names and +nothing else, and one that selected the code-review Plugin receives the review +graph too — which claims nothing at the test root and claims all forty-one of +its names in the child. `host="workflow"`, and the `` scope it requires, are specified in issue #454 and are not built: a host that provides no workflow profile refuses diff --git a/specs/workflow-spec.md b/specs/workflow-spec.md index 66e40bb5f..ddeb4292b 100644 --- a/specs/workflow-spec.md +++ b/specs/workflow-spec.md @@ -31,7 +31,8 @@ direct dependency (§7.1). ```ts import { executeInstalled } from "@executablemd/core/host"; -import { workflowInstallation, getWorkflowRun } from "@executablemd/workflow"; +import { getWorkflowRun } from "@executablemd/workflow"; +import { workflowInstallation } from "@executablemd/git"; const execution = yield* executeInstalled( { path: "./workflow.md", stream }, @@ -39,18 +40,35 @@ const execution = yield* executeInstalled( ); ``` -The package owns `WorkflowRun`, `workflowInstallation()`, `getWorkflowRun()`, -the source-bundle identity primitives and the Git capability. It depends on -`@executablemd/core`, `@executablemd/durable-streams` and -`@executablemd/runtime`, whose contextual `exec()` and `cwd()` the Git provider -invokes. Core never imports workflow or Git, so ordinary `execute()` and -`xmd run` stay Git-independent. +`workflowInstallation()` is imported from `@executablemd/git` because resolving +a base is a Git capability: creating a version-1 run is the lifecycle path that +reaches Git through the contextual `Git.revParse` capability, so it is stated by +the package that owns one. It is not the only path that needs repository +bytes — a version-1 run retains no Markdown, so resuming, forking and exporting +one obtain their source through the legacy reader instead (§7.1). -`workflowInstallation({ base })` is the one place this package still reaches Git -to establish a run. Every retained path — recognition, resume, fork, journal and -export — reaches none. Issue #822 moves that adapter into the bundled Git Plugin -and removes the final package-level dependency; until it does, the exception is -exactly that one entrypoint. +The package owns `WorkflowRun`, `retainedWorkflowInstallation()`, +`getWorkflowRun()` and the source-bundle identity primitives. It depends on +`@executablemd/core`, `@executablemd/durable-streams` and +`@executablemd/runtime`. Core never imports workflow, and workflow never imports +Git. + +**It owns no Git capability.** `GitApi`, `revParse` and +`workflowInstallation({ base })` live in `@executablemd/git`, which imports the +extension boundaries this package publishes rather than the other way round. No +module here names a Git feature in an import, in any form. + +That is a boundary about imports, not about repositories. Recognition, the +journal and every source-bundle path reach repository bytes not at all, but +version-1 retained execution — resume, fork and export — obtains its Markdown +through the direct legacy reader of §7.1. The host supplies that capability and +may back it with Git; this package neither imports it nor chooses it, and +authenticates whatever it returns. + +Ordinary `execute()` stays Git-independent. `xmd run` is a different matter: the +CLI bundles the Git Plugin and activates it by default, so a document run there +has the repository vocabulary available without an operator naming it. That is +the host's profile, not this package's dependency. ## 2. What a run is @@ -271,7 +289,7 @@ The journal decides which middleware does the work. | State | What runs | What happens | | --- | --- | --- | -| **live** — no record | the admission, then `prepare` | the admission finds nothing to hold the run to; preparation allocates the run id, resolves the base through `Git.revParse()`, records one immutable value, and only then is the root imported | +| **live** — no record | the admission, then `prepare` | the admission finds nothing to hold the run to; preparation allocates the run id, resolves the base through the Git Plugin's `Git.revParse()`, records one immutable value, and only then is the root imported | | **truncated** — record present, root not closed | the admission, then `prepare` | the admission restores the recorded value; preparation re-enters and its durable operation restores what it already recorded, so neither the identifier nor Git is reached again and the journal cursor still advances past its own entry | | **completed** — root `Close` recorded | the admission only | canonical core returns the recorded result without entering the durable body, so preparation never runs and the admission is the only place the run is restored — or a disagreeing one refused | @@ -316,7 +334,13 @@ not re-enter preparation, does not run document policy, imports no root, expands nothing, and appends nothing. The workflow installation raises no objection to it (§3.2). -## 7. The Git capability +## 7. The Git capability — moved + +This section described `GitApi` and `revParse` while they were this package's. +They are `@executablemd/git`'s now, together with the version-1 establishment +adapter that calls them, and are specified in +[the Workspace spec](./workflow-workspace-spec.md). What remains here is the +shape a reader of an older journal may still meet: ```ts interface GitApi { @@ -346,7 +370,8 @@ a nested replacement wins rather than being shadowed by an outer handler. `workflowInstallation({ base })` calls it with `${base}^{commit}`, which is what makes "does not resolve to a commit" an error rather than a tag object id. That -entrypoint is the only caller in this package (§1). +entrypoint is the only caller in the `@executablemd/git` package, which is where +both it and `revParse` live (§1). ### 7.1 The legacy source reader diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index baa4585e2..0d52c7f9a 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -65,7 +65,8 @@ compiled binary. The ordinary provider gives a document the same thirteen components a workflow run has, over the caller's own filesystem: - The **ambient Repository** is the Git checkout the command was run in, - discovered once before root expansion. Its identity is the canonical common + discovered when the first element asks for one — never at installation — and + remembered for the rest of the execution, including when there is none. Its identity is the canonical common Git directory and its selected checkout is the canonical checkout root, so a command started in a linked worktree names the same repository as one started in the primary checkout while Git operations act on the worktree. A document @@ -80,8 +81,9 @@ components a workflow run has, over the caller's own filesystem: document execution by an exclusive non-blocking advisory lock. - Local Git operations happen directly against the selected checkout. There is no transaction, no rollback and no replay, and none is claimed. A commit is - made under the invoking user's own effective Git identity, captured once from - the trusted host before the document expands; a host where Git can name no + made under the invoking user's own effective Git identity, captured from the + trusted host by the first commit that needs it — a run that commits nothing + asks the host nothing about who it is; a host where Git can name no identity refuses `` and names the two commands that fix it, and every other component stays usable. Nothing else is borrowed from the caller's environment: hooks, file-system monitors, signing programs and @@ -2996,8 +2998,11 @@ discriminator stays in the normalized record, and the adopted-or-performed decision stays journal evidence. Provider state and later edits are separate reads. -**The host.** The Deno workflow host installs GitHub Issue middleware when it is -configured to, and installs none otherwise. Configuration names the ceiling and +**The adapter.** The GitHub Issue middleware lives in `@executablemd/git` and is +installed with the rest of the Plugin. It reads its configuration when an +invoked issue operation turns out to be its own; a deployment that authorized +no tracker handles no destination, and `` reaches `IssueApi`'s own base +error exactly as it did when absence installed no middleware at all. Configuration names the ceiling and optionally an endpoint; it is refused rather than narrowed when it cannot be used. With no configuration there is no Issue provider, so every request reaches `NoIssueProvider` — absence of configuration is fail-closed, never an open @@ -3274,7 +3279,9 @@ checkouts live under `~/.xmd/repositories`, in `repositories//` and outside the slot they protect. Every authored string — a name, a locator — is present only as a digest, so no name a document writes decides a path. It uses the same host authentication and the same `XMD_WORKFLOW_GITHUB_ISSUES` and -`XMD_WORKFLOW_GITHUB_PULL_REQUESTS` configurations this host already reads. +`XMD_WORKFLOW_GITHUB_PULL_REQUESTS` configurations, which belong to the GitHub +adapter inside `@executablemd/git` and are read when an invoked GitHub-backed +operation needs one. The local lifecycle adapter owns a non-blocking exclusive advisory lock on one deterministic sidecar per run. The open file belongs to the workflow executor's @@ -3526,7 +3533,7 @@ fetch operation requires its own language and durability contract. | provider-backed retained Workspace | document filesystem built by #366 and repository composition by #293; document deletion (§10.1) built by #567 for both providers; mandatory directory ensure is specified by #643 for both providers; process capabilities unbuilt (#218) | | `xmd workflow start` / `resume` | built by #366, Deno entrypoints only; both acquire #367's executor lock | | ``, `` and `` composition under a workflow run | Repository and Worktree built by #293, Deno provider only; ``'s mandatory `API.Files.ensureDirectory` is specified by #643 as one transactional `workspace_file` mutation followed by lexical cwd installation | -| the same thirteen declarations under every runtime | built by #643: one shadowable array consumed by the workflow attachment, `xmd syntax`, `xmd plan` and an ordinary document execution | +| the same thirteen declarations under every runtime | built by #643: one shadowable array declared by the bundled Git Plugin and consumed wherever its profile is assembled — `xmd syntax`, `xmd plan`, an ordinary document execution and a workflow action that executes a document | | ``, `` and the ambient Repository under an ordinary run | built by #643, Deno and compiled only: managed checkouts under `~/.xmd/repositories` with version 1 sidecars and execution-owned non-blocking locks, and the checkout the command was run in as the default Repository. Node and Bun install no operational provider | | local Git operations and `Git.Push` evidence under an ordinary run | built by #643, Deno and compiled only: the same authored transitions with no transaction and no replay, commits recorded under the invoking user's own captured Git identity with an actionable refusal when the host can name none, and a private per-execution Push evidence entry that authorizes `` and crosses no run | | `` and pull-request reads under an ordinary run | built by #643, Deno and compiled only: the same transports and ceilings with no durable envelope, keyed by this execution's own invocation identity | From 69e19a8be8e4e68a4c6222c1a8fbc908a5b99038 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 12:53:11 -0400 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20Typecheck=20the=20Git=20pack?= =?UTF-8?q?age=20under=20Node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/git` was the only workspace member missing from `tsconfig.node.json`'s `include`, so the package this stack extracted was invisible to the Node typecheck — the check whose whole job is catching the places where Node's lib trails Deno's. Adding it surfaced the second `toSorted` immediately. Both are replaced with `sort`, which ES2022 has: `map` already returned a fresh array in the CLI case, and the partition case copies first because it reads the array again. Node execution was never affected — discovery walks the workspace, not the tsconfig — so the gap was typecheck-only and silent. --- packages/cli/tests/workflow-installation.test.ts | 5 ++++- packages/git/tests/module-partition.test.ts | 4 +++- tsconfig.node.json | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/workflow-installation.test.ts b/packages/cli/tests/workflow-installation.test.ts index 35925684c..99bdb346f 100644 --- a/packages/cli/tests/workflow-installation.test.ts +++ b/packages/cli/tests/workflow-installation.test.ts @@ -322,7 +322,10 @@ describe("Tier WFI — what a run hands to canonical core", () => { (execution?.installations ?? []) .filter((candidate) => candidate.evaluation === undefined) .map((candidate) => candidate.admissions?.length ?? 0) - .toSorted((left, right) => left - right), + // `sort`, not `toSorted`: the Node typecheck's lib is ES2022 and + // `toSorted` is ES2023. `map` already returned a fresh array, so + // sorting it in place mutates nothing shared. + .sort((left, right) => left - right), ).toEqual([1]); // The ceiling is stated exactly once, and it is a real one: a run that diff --git a/packages/git/tests/module-partition.test.ts b/packages/git/tests/module-partition.test.ts index 6b49c026b..9d62471a2 100644 --- a/packages/git/tests/module-partition.test.ts +++ b/packages/git/tests/module-partition.test.ts @@ -394,7 +394,9 @@ describe("the three halves of @executablemd/git", () => { const declaredSubpaths = subpaths(declared); expect(`${manifest}: ${declaredSubpaths !== undefined}`).toBe(`${manifest}: true`); const names = declaredSubpaths ?? []; - expect(`${manifest}: ${names.toSorted().join(" ")}`).toBe( + // Copied before sorting: `toSorted` is ES2023 and the Node typecheck's + // lib is ES2022, and `names` is read again below. + expect(`${manifest}: ${[...names].sort().join(" ")}`).toBe( `${manifest}: . ./credential-helper ./deno`, ); // And no subpath names GitHub: the implementation ships inside this diff --git a/tsconfig.node.json b/tsconfig.node.json index 1a7c96438..ddaf8742b 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -75,6 +75,7 @@ "packages/code-review-agent/**/*.ts", "packages/core/**/*.ts", "packages/durable-streams/**/*.ts", + "packages/git/**/*.ts", "packages/runtime/**/*.ts", "packages/test-agent/**/*.ts", "packages/test-support/**/*.ts", From 6c63567308731a1d5c74a5c4354b6a7ed164643b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 13:35:45 -0400 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=85=20Hold=20the=20Node=20typecheck?= =?UTF-8?q?=20to=20every=20workspace=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit put `packages/git` back in `tsconfig.node.json`. Nothing kept it there: once the two ES2023 calls were gone, dropping the member again would have typechecked clean and run green, because shard discovery walks the workspace rather than the tsconfig — an unchecked package still executes. So the list is asserted against the filesystem instead of against itself. Every `packages/*` that ships a `deno.json` must appear in `include`, and adding a package is what fails here rather than remembering to add it. Read through TypeScript's own config reader, since the file carries comments that `JSON.parse` refuses. --- scripts/tests/ci-workflow.test.ts | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/scripts/tests/ci-workflow.test.ts b/scripts/tests/ci-workflow.test.ts index 67d5796a9..76ba835dc 100644 --- a/scripts/tests/ci-workflow.test.ts +++ b/scripts/tests/ci-workflow.test.ts @@ -4,6 +4,9 @@ import { expect } from "@executablemd/test-support/expect"; import type { Operation } from "effection"; import { readTextFile } from "@effectionx/fs"; import { exec } from "@effectionx/process"; +import { glob } from "@executablemd/runtime"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; import { applicableTestFiles } from "../lib/test-files.ts"; import { partitionTests } from "../lib/test-shards.ts"; @@ -422,6 +425,59 @@ describe("the sharded runtime jobs", () => { expect(source).not.toContain("continue-on-error"); }); + + /** + * The Node typecheck's scope, which is the only place `lib: ES2022` is + * enforced. + * + * Deno's lib is newer than the one `tsconfig.node.json` pins, so a call like + * `toSorted` compiles under Deno and fails under Node. `pnpm exec tsc` is + * what catches that — but only for the files its `include` reaches, and a + * package missing from that list is checked by nothing at all. + * + * Nothing else notices the omission: shard discovery walks the workspace + * rather than the tsconfig, so an unchecked package still *executes* under + * Node and its shards stay green. That is how `packages/git` was extracted + * out of `packages/workflow` — which is included — into a member that was + * not, and stayed invisible until an ES2023 call happened to be written in + * it. + * + * So the list is asserted against the filesystem rather than against itself: + * adding a package is what must fail here, not remembering to add it. + */ + it("typechecks every workspace package under Node", function* () { + const text = yield* readTextFile(new URL("tsconfig.node.json", ROOT)); + // Through TypeScript's own reader: the file carries `//` comments, so + // `JSON.parse` would refuse it and a hand-rolled stripper would be one + // more thing that can quietly be wrong. + const parsed = ts.parseConfigFileTextToJson("tsconfig.node.json", text); + expect(parsed.error).toBeUndefined(); + const include: unknown = (parsed.config as { include?: unknown } | undefined)?.include; + expect(Array.isArray(include)).toBe(true); + const globs = include as string[]; + + // Every workspace package on disk, which is the thing that grows. + const manifests = yield* glob({ + root: fileURLToPath(ROOT), + patterns: ["packages/*/deno.json"], + }); + const members = manifests + .map((entry) => /packages[/\\]([^/\\]+)[/\\]deno\.json$/.exec(entry.path)?.[1]) + .filter((name): name is string => name !== undefined) + .sort(); + + // The scan has to be looking at something. An empty members list, or an + // empty include, would satisfy the comparison below every time. + expect(globs.length).toBeGreaterThan(5); + expect(members.length).toBeGreaterThan(5); + // And it has to be looking at the right thing: these two are the packages + // the extraction split, and the pair the omission was found between. + expect(members).toContain("git"); + expect(members).toContain("workflow"); + + const missing = members.filter((name) => !globs.includes(`packages/${name}/**/*.ts`)); + expect(missing).toEqual([]); + }); }); describe("the actual partition the workflow installs", () => { From ecd1b458b342d4bce1c1e37d4c43f5453bcbdf9e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 21 Sep 2026 13:50:03 -0400 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=90=9B=20Discover=20workspace=20packa?= =?UTF-8?q?ges=20by=20either=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression looked for `packages/*/deno.json`, which is not what a workspace member is: `test-support` is internal and ships only `package.json`. It was therefore never examined — an existing member left permanently unchecked by the case whose whole subject is members left unchecked. Both manifests now, deduplicated, with `test-support` asserted beside `git` and `workflow` so the union is covered rather than assumed. The two type assertions go with it. The suite already parses unknown JSON through `object()` and `strings()`, which name the member that was wrong instead of asserting it was right. --- scripts/tests/ci-workflow.test.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/scripts/tests/ci-workflow.test.ts b/scripts/tests/ci-workflow.test.ts index 76ba835dc..8837cf447 100644 --- a/scripts/tests/ci-workflow.test.ts +++ b/scripts/tests/ci-workflow.test.ts @@ -452,28 +452,37 @@ describe("the sharded runtime jobs", () => { // more thing that can quietly be wrong. const parsed = ts.parseConfigFileTextToJson("tsconfig.node.json", text); expect(parsed.error).toBeUndefined(); - const include: unknown = (parsed.config as { include?: unknown } | undefined)?.include; - expect(Array.isArray(include)).toBe(true); - const globs = include as string[]; + const config = object(parsed.config, "tsconfig.node.json"); + const globs = strings(config.include, "tsconfig.node.json include"); // Every workspace package on disk, which is the thing that grows. + // + // Both manifests, because a member declares whichever it needs: + // `test-support` is internal and ships only `package.json`, while the + // published packages carry `deno.json` too. Looking for one of them would + // leave the other kind of member permanently unexamined — the same shape + // of blind spot this case exists to close. const manifests = yield* glob({ root: fileURLToPath(ROOT), - patterns: ["packages/*/deno.json"], + patterns: ["packages/*/deno.json", "packages/*/package.json"], }); - const members = manifests - .map((entry) => /packages[/\\]([^/\\]+)[/\\]deno\.json$/.exec(entry.path)?.[1]) - .filter((name): name is string => name !== undefined) - .sort(); + const members = [ + ...new Set( + manifests + .map((entry) => /packages[/\\]([^/\\]+)[/\\][^/\\]+$/.exec(entry.path)?.[1]) + .filter((name): name is string => name !== undefined), + ), + ].sort(); // The scan has to be looking at something. An empty members list, or an // empty include, would satisfy the comparison below every time. expect(globs.length).toBeGreaterThan(5); expect(members.length).toBeGreaterThan(5); - // And it has to be looking at the right thing: these two are the packages - // the extraction split, and the pair the omission was found between. + // And it has to be looking at the right things: the two packages the + // extraction split, and the member that only `package.json` finds. expect(members).toContain("git"); expect(members).toContain("workflow"); + expect(members).toContain("test-support"); const missing = members.filter((name) => !globs.includes(`packages/${name}/**/*.ts`)); expect(missing).toEqual([]);