diff --git a/.gitignore b/.gitignore index f43331fe..9ae85558 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ npm/ # deno task build:web output — built into the package at release time, # never a repository source file (specs/release-process-spec.md) packages/web/generated/ + +# Vite writes a transient config shim beside site/vite.config.ts while it loads +# it. The concurrent verifier runs site:build and site:check together, so a lint +# that walked one would report on a file nobody wrote. +site/*.timestamp-*.mjs diff --git a/architecture.md b/architecture.md index 38c9cd71..abedeb5e 100644 --- a/architecture.md +++ b/architecture.md @@ -646,6 +646,13 @@ selector while inspecting the document, then asks execution for the exact target that resolved — so a file replaced between the two reads fails on the target the run chose, rather than silently running whatever the glob would name now. +The workflow definition is the second. It optionally carries that exact target, +compares it with the rest of the descriptor, and validates it through core's own +canonical-target predicate rather than a rule the workflow package restates — +identity two packages define separately is identity they can disagree about. A +run of one section, a run of another, and a run of the whole document are three +different runs. + A resumed run re-resolves the current selector against the *recorded* content and refuses to continue unless the outcome is the one recorded. A failed selection is an outcome too, and is recorded and compared as one — otherwise a @@ -1088,7 +1095,8 @@ Status is measured against main. | `Expansion` / `getExpansion()` | describes the current logical element expansion | built on main | | document targets | catalogs a root document's addressable static headings, resolves one selector to one exact target, and projects the document to it before expansion | built on the #412 stack | | `xmd targets` | prints one document's catalog as full document references, by inspection alone | built on the #412 stack | -| 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; the targeted workflow definition is unbuilt | +| 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 | the V1 workflow definition optionally carries the exact canonical document target, which takes part in definition identity and in compatible reuse | built on the #412 stack; the workflow CLI does not supply one yet | | `useWorkflow()` / `getWorkflowRun()` | associates one document execution with a workflow run | built on main | | `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; public workflow execution is unbuilt | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 45af4b8a..e85b1dbb 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -146,6 +146,11 @@ export { asDocumentTargetError, DocumentTargetError, isDocumentTargetError, + // The one authority on what an exact target looks like. Exported under the + // fuller name because a consumer outside this package — a stored workflow + // definition validating the target it retained — reads it beside its own + // vocabulary, where "target" alone would not say target of what. + isCanonicalTarget as isCanonicalDocumentTarget, parseDocumentTargetFailure, } from "./src/document-targets.ts"; export type { DocumentTargetErrorKind, DocumentTargetFailure } from "./src/document-targets.ts"; diff --git a/packages/workflow/src/storage/compatibility.ts b/packages/workflow/src/storage/compatibility.ts index 27fff71b..5794f6fd 100644 --- a/packages/workflow/src/storage/compatibility.ts +++ b/packages/workflow/src/storage/compatibility.ts @@ -54,6 +54,13 @@ export function conflictingFields( * nothing a canonical spelling would reconcile — and comparing the members * keeps a later variant from being admitted because it happened to serialize * the same way. + * + * The exact target is one of those members. A run of one section and a run of + * the whole document are different runs, and so are runs of two different + * sections: they execute different content, so reusing one run id for the other + * would let a resumed run continue something it never started. Absent compares + * equal only to absent, which is what makes whole-document and targeted + * definitions incompatible rather than merely unequal. */ function sameDefinition(stored: WorkflowDefinition, requested: WorkflowDefinition): boolean { return ( @@ -61,6 +68,7 @@ function sameDefinition(stored: WorkflowDefinition, requested: WorkflowDefinitio stored.kind === requested.kind && stored.objectFormat === requested.objectFormat && stored.objectId === requested.objectId && - stored.rootDocumentPath === requested.rootDocumentPath + stored.rootDocumentPath === requested.rootDocumentPath && + stored.targetPath === requested.targetPath ); } diff --git a/packages/workflow/src/storage/definition.ts b/packages/workflow/src/storage/definition.ts index eecbb0bc..9049ac56 100644 --- a/packages/workflow/src/storage/definition.ts +++ b/packages/workflow/src/storage/definition.ts @@ -16,17 +16,35 @@ */ import { Err, Ok, type Result } from "effection"; +import { isCanonicalDocumentTarget } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; import { WorkflowDefinitionError } from "./errors.ts"; -import { describe, parseMembers, parseStringMember, requireMemberNames } from "./members.ts"; +import { + describe, + type Members, + parseMembers, + parseStringMember, + requireMemberNames, +} from "./members.ts"; -/** A document at a path inside one immutable Git object. */ +/** + * A document at a path inside one immutable Git object, optionally projected to + * one of its sections. + * + * `targetPath` is the *resolved exact* canonical document target, never the + * selector a caller wrote: two callers may spell one request differently, and a + * glob re-resolved against a different checkout would name a different section. + * Absent, it identifies the whole document — which is what a whole-document + * workflow is, not a legacy spelling of a targeted one. + */ export interface GitWorkflowDefinitionV1 { readonly version: 1; readonly kind: "git"; readonly objectFormat: "sha1" | "sha256"; readonly objectId: string; readonly rootDocumentPath: string; + /** One exact canonical document target, without a leading `#`. */ + readonly targetPath?: string; } /** Every descriptor this build understands. */ @@ -38,7 +56,14 @@ const OBJECT_ID_LENGTHS: Readonly { ).toEqual([]); }); }); + +/** + * Every target form this suite exercises, and whether a descriptor may carry it. + * + * Canonical encoding escapes everything outside RFC 3986's unreserved set, so + * a heading holding `/`, `*`, `#`, `%`, or a space is retained as an escape and + * cannot be read back as hierarchy or operator syntax. + */ +const CANONICAL_TARGETS = [ + "Release", + "Release/Publish", + "Release/Publish/Notes", + "Release%2FNotes", + "star%2A", + "hash%23tag", + "pct%25value", + "two%20words", + "%C3%9Cn%C3%AFc%C3%B8d%C3%A9", +]; + +const REFUSED_TARGETS = [ + "", + "#Release", + "Release/*", + "**", + "Rel*ease", + "Release/**/Notes", + "%zz", + "%2f", + "Release/", + "/Release", + "Release//Notes", + "Release ", + " Release", + "Two words", + "éclair", +]; + +describe("Tier WD — a definition's exact document target", () => { + it("WD18: an untargeted descriptor writes no target member at all", function* () { + const untargeted = parsed(); + + expect("targetPath" in untargeted).toBe(false); + expect(Object.keys(definitionToJson(untargeted) as Record)).toEqual([ + "version", + "kind", + "objectFormat", + "objectId", + "rootDocumentPath", + ]); + }); + + it("WD19: a targeted descriptor round-trips its exact target unchanged", function* () { + const targeted = parsed({ targetPath: "Release/Publish" }); + + expect(targeted.targetPath).toBe("Release/Publish"); + + const json = definitionToJson(targeted) as Record; + expect(json["targetPath"]).toBe("Release/Publish"); + + const again = parseWorkflowDefinition(json); + expect(again.ok && again.value).toEqual(targeted); + }); + + it("WD20: every canonical target survives byte for byte", function* () { + for (const targetPath of CANONICAL_TARGETS) { + const stored = parsed({ targetPath }); + expect({ targetPath, stored: stored.targetPath }).toEqual({ targetPath, stored: targetPath }); + + const again = parseWorkflowDefinition(definitionToJson(stored)); + expect({ targetPath, ok: again.ok }).toEqual({ targetPath, ok: true }); + expect(again.ok && again.value.targetPath).toBe(targetPath); + } + }); + + it("WD21: a target that is not exactly canonical is refused at its own path", function* () { + for (const targetPath of REFUSED_TARGETS) { + const error = refusal(definition({ targetPath })); + expect({ targetPath, path: error.path }).toEqual({ targetPath, path: "$.targetPath" }); + expect(error.message).toContain("expected one exact canonical document target"); + // A canonical target encodes heading text, so the diagnostic says nothing + // about the one it read. The empty target is skipped because every string + // contains it. + if (targetPath !== "") { + expect(error.message).not.toContain(targetPath); + } + } + }); + + it("WD22: a present target that is not a string is refused, absence excepted", function* () { + for (const value of [undefined, null, 1, true, ["Release"], { path: "Release" }]) { + const error = refusal(definition({ targetPath: value })); + expect({ value, path: error.path }).toEqual({ value, path: "$.targetPath" }); + expect(error.message).toContain("expected a string"); + } + }); + + it("WD23: the public core predicate answers exactly as definition parsing does", function* () { + for (const targetPath of CANONICAL_TARGETS) { + expect({ targetPath, canonical: isCanonicalDocumentTarget(targetPath) }).toEqual({ + targetPath, + canonical: true, + }); + } + for (const targetPath of REFUSED_TARGETS) { + expect({ targetPath, canonical: isCanonicalDocumentTarget(targetPath) }).toEqual({ + targetPath, + canonical: false, + }); + } + }); + + it("WD24: a run of one section is not a run of the whole document", function* () { + const whole = record(); + const section = record({ definition: parsed({ targetPath: "Release/Publish" }) }); + const other = record({ definition: parsed({ targetPath: "Release/Announce" }) }); + + const asking = (stored: WorkflowRunRecord, definition: GitWorkflowDefinitionV1) => + conflictingFields(stored, { + runId: stored.runId, + definition, + base: stored.base, + props: stored.props, + }); + + // The same exact target is the same run. + expect(asking(section, section.definition)).toEqual([]); + expect(asking(whole, whole.definition)).toEqual([]); + + // Whole-document and targeted are different runs, in both directions. + expect(asking(whole, section.definition)).toEqual(["definition"]); + expect(asking(section, whole.definition)).toEqual(["definition"]); + + // So are two different sections of one document. + expect(asking(section, other.definition)).toEqual(["definition"]); + }); +}); diff --git a/packages/workflow/tests/workflow-run-storage.test.ts b/packages/workflow/tests/workflow-run-storage.test.ts index ac68c8af..1beb67b6 100644 --- a/packages/workflow/tests/workflow-run-storage.test.ts +++ b/packages/workflow/tests/workflow-run-storage.test.ts @@ -1460,3 +1460,113 @@ describe("Tier WS — refusing what is not this run's database", () => { expect(readFileSync(path).length).toBeGreaterThan(4096); }); }); + +describe("Tier WS — a run of one section", () => { + it("WS28: a stored exact target survives the process unchanged", function* () { + const root = yield* useStorageRoot(); + const targeted = definition({ targetPath: "Release/Publish%2FNotes" }); + + const written = yield* withStorage(root, function* () { + const database = yield* createRun({ definition: targeted }); + return database.record; + }); + + expect(written.definition.targetPath).toBe("Release/Publish%2FNotes"); + + // A second scope reads it back out of SQLite and parses it again, so the + // column, the serializer, and the parser all agree about one target. + const restored = yield* withStorage(root, function* () { + const found = yield* lookup("release-1.4"); + if (!found.ok) { + throw found.error; + } + return found.value.record; + }); + + expect(restored.definition).toEqual(written.definition); + expect(restored.definition.targetPath).toBe("Release/Publish%2FNotes"); + }); + + it("WS29: an untargeted run reopens with no target member at all", function* () { + const root = yield* useStorageRoot(); + + yield* withStorage(root, function* () { + yield* createRun(); + }); + + const restored = yield* withStorage(root, function* () { + const found = yield* lookup("release-1.4"); + if (!found.ok) { + throw found.error; + } + return found.value.record; + }); + + expect("targetPath" in restored.definition).toBe(false); + }); + + it("WS30: one run id cannot be reused for a different section, or for the whole document", function* () { + const root = yield* useStorageRoot(); + + const conflicts = yield* withStorage(root, function* () { + yield* createRun({ definition: definition({ targetPath: "Release/Publish" }) }); + + const attempts = [ + { asked: "another section", definition: definition({ targetPath: "Release/Announce" }) }, + { asked: "the whole document", definition: definition() }, + ]; + + const errors: { asked: string; result: Result }[] = []; + for (const attempt of attempts) { + errors.push({ + asked: attempt.asked, + result: yield* create(request({ definition: attempt.definition })), + }); + } + return errors; + }); + + for (const { asked, result } of conflicts) { + expect({ asked, ok: result.ok }).toEqual({ asked, ok: false }); + if (result.ok) { + continue; + } + expect(result.error).toBeInstanceOf(WorkflowRunConflictError); + expect(result.error.message).toContain("definition"); + // The section that was asked for is document content, not a field name. + expect(result.error.message).not.toContain("Announce"); + expect(result.error.message).not.toContain("Publish"); + } + }); + + it("WS31: a targeted run is found again by the request that created it", function* () { + const root = yield* useStorageRoot(); + const targeted = definition({ targetPath: "Release/Publish" }); + + const same = yield* withStorage(root, function* () { + const first = yield* createRun({ definition: targeted }); + const again = yield* create(request({ definition: targeted })); + if (!again.ok) { + throw again.error; + } + return first.record.runId === again.value.record.runId; + }); + + expect(same).toBe(true); + }); + + it("WS32: a whole-document run refuses to be reused as a targeted one", function* () { + const root = yield* useStorageRoot(); + + const result = yield* withStorage(root, function* () { + yield* createRun(); + return yield* create(request({ definition: definition({ targetPath: "Release/Publish" }) })); + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(WorkflowRunConflictError); + expect(result.error.message).toContain("definition"); + } + }); +}); diff --git a/site/deno.json b/site/deno.json index a7859ba3..58fe90a0 100644 --- a/site/deno.json +++ b/site/deno.json @@ -19,7 +19,8 @@ } }, "exclude": [ - "**/_fresh/*" + "**/_fresh/*", + "**/*.timestamp-*.mjs" ], "imports": { "@/": "./", diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e23c2f42..ff3c1817 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2908,8 +2908,17 @@ class DocumentTargetError extends Error { function isDocumentTargetError(error: unknown): boolean; function asDocumentTargetError(error: unknown): DocumentTargetError | undefined; function parseDocumentTargetFailure(value: unknown): DocumentTargetFailure | undefined; +function isCanonicalDocumentTarget(target: string): boolean; ``` +`isCanonicalDocumentTarget()` answers whether a fragment is an **exact** +canonical target by the round-trip rule above: it is the one authority on that +question, so a consumer outside this package validates a target it holds by +asking rather than by restating the rule. A stored workflow definition uses it +on the exact target it retained, because identity two packages define separately +is identity they can disagree about. It takes no leading `#` — that delimiter +belongs to a document reference, not to a target. + `selector` is the fragment as it arrived. `matches` is the ambiguity list and is empty for every other kind; `available` is the whole catalog. The message is derived from the data, quotes the selector as JSON, and lists canonical encoded diff --git a/specs/workflow-spec.md b/specs/workflow-spec.md index 39b2e61c..50f6f9b3 100644 --- a/specs/workflow-spec.md +++ b/specs/workflow-spec.md @@ -200,6 +200,7 @@ interface GitWorkflowDefinitionV1 { objectFormat: "sha1" | "sha256"; objectId: string; rootDocumentPath: string; + targetPath?: string; } ``` @@ -210,6 +211,41 @@ backslashes, NULs, empty paths, empty segments and `.` or `..` segments are refused rather than normalized, because two spellings of one path would otherwise be two identities. +#### The document target a run is a run of + +`targetPath` names one **document target** (executable-MDX §5.4) inside the root +document. Absent, the definition identifies the complete root document. Present, +it identifies exactly one section of it, and carries no leading `#`. + +It is the **resolved exact canonical target**, never the selector a caller +wrote. A glob describes what somebody asked for; two callers may spell one +request differently, and re-resolving a glob against a different checkout can +name a different section. Only the resolved answer is stable enough to be +identity, which is the same routing-is-not-authority split the document layer +makes. + +What counts as canonical is not restated here. A stored target has to satisfy +the same round trip an exact document target does — `isCanonicalDocumentTarget()` +from `@executablemd/core` is the one authority — so an empty target, an empty +hierarchy level, a leading `#`, a raw `#`, `*` or `**` anywhere in it, a +malformed or lowercase escape, a byte sequence that is not UTF-8, an NFD +spelling, and leading, trailing or uncollapsed whitespace are all refused. +Canonical escapes such as `%2A`, `%2F`, `%23` and `%25` are ordinary characters +inside a label and remain valid. + +The member is closed like every other. Writing `targetPath` at all is what makes +it present, so an explicit `undefined` or `null` is a descriptor that asked for a +target and failed to name one — refused, rather than read as the whole document. +A failure is reported at `$.targetPath` in fixed wording that never echoes the +target it read, because a canonical target encodes heading text and heading text +is document content. Serialization writes the member only when there is one, and +stored identity is never normalized, decoded, repaired or re-encoded on the way +through. + +`version` stays `1`. The five-member untargeted shape is the current +representation of a whole-document workflow rather than a legacy format being +preserved, so there is no second version, no version union, and no migration. + Where that object can be fetched from is deliberately not identity. A locator and a local checkout path are **retrieval metadata** — replaceable, excluded from the comparison, never containing credentials, and reauthorized by the host @@ -228,6 +264,13 @@ stop reason, retrieval metadata, timestamps, document executions and journal records all change while the run stays the run it was. A conflict names the fields that differ and never the values behind them. +The exact target is compared with the rest of the descriptor. A run of one +section and a run of the whole document execute different content, and so do +runs of two different sections, so reusing one run id for the other reports a +`definition` conflict rather than finding the stored run. Absent compares equal +only to absent. The same exact target under the same id is the same run and is +found, which is what lets a targeted run be resumed. + `lookup()` finds by id and creates nothing. ### 9.3 Finding a run without a registry diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index a089e5ba..6b40503e 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -89,6 +89,14 @@ implicit Repository inside the Workspace. Repository components may therefore open two or more unrelated repositories without changing definition or run identity. +When the definition names one **document target**, that exact canonical target +is part of it and therefore part of definition identity. A run of one section +and a run of the whole document are different runs, as are runs of two different +sections, so a resume is a resume only when it names the same exact target. The +selector a caller wrote never occupies that field: identity is the resolved +answer, so resuming re-enters the section the run actually executed rather than +whatever the same glob would name against a later checkout. + ### 3.1 Start and resume stream in the foreground Starting the same definition twice creates two runs: