Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions packages/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 9 additions & 1 deletion packages/workflow/src/storage/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,21 @@ 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 (
stored.version === requested.version &&
stored.kind === requested.kind &&
stored.objectFormat === requested.objectFormat &&
stored.objectId === requested.objectId &&
stored.rootDocumentPath === requested.rootDocumentPath
stored.rootDocumentPath === requested.rootDocumentPath &&
stored.targetPath === requested.targetPath
);
}
66 changes: 63 additions & 3 deletions packages/workflow/src/storage/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -38,7 +56,14 @@ const OBJECT_ID_LENGTHS: Readonly<Record<GitWorkflowDefinitionV1["objectFormat"]
sha256: 64,
};

const MEMBER_NAMES = ["version", "kind", "objectFormat", "objectId", "rootDocumentPath"];
const MEMBER_NAMES = [
"version",
"kind",
"objectFormat",
"objectId",
"rootDocumentPath",
"targetPath",
];

function fail(reason: string, path: string): Error {
return new WorkflowDefinitionError(reason, path);
Expand Down Expand Up @@ -77,6 +102,7 @@ function parseDefinition(value: unknown): WorkflowDefinition {
}

const objectFormat = parseObjectFormat(members.get("objectFormat"));
const targetPath = parseTargetPath(members);

return {
version: 1,
Expand All @@ -86,9 +112,39 @@ function parseDefinition(value: unknown): WorkflowDefinition {
rootDocumentPath: parseRootDocumentPath(
parseStringMember(members, "rootDocumentPath", "$", fail),
),
...(targetPath === undefined ? {} : { targetPath }),
};
}

/**
* The exact target this descriptor names, if it names one.
*
* Presence is the member being written at all, not its value: a descriptor that
* wrote `targetPath` and gave it `undefined` or `null` asked for a target and
* failed to say which, which is not the same as asking for the whole document.
*
* What counts as canonical is core's own predicate, not a rule restated here.
* Identity that two packages define separately is identity they can disagree
* about, and this member is compared against targets the document layer
* produced.
*/
function parseTargetPath(members: Members): string | undefined {
if (!members.has("targetPath")) {
return undefined;
}
const path = "$.targetPath";
const value = members.get("targetPath");
if (typeof value !== "string") {
throw fail(`expected a string, found ${describe(value)}`, path);
}
// Deliberately says nothing about the target it read: a canonical target
// encodes heading text, and heading text is document content.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// encodes heading text, and heading text is document content.

if (!isCanonicalDocumentTarget(value)) {
throw fail("expected one exact canonical document target", path);
}
return value;
}

/**
* The descriptor as a plain JSON value.
*
Expand All @@ -103,6 +159,10 @@ export function definitionToJson(definition: WorkflowDefinition): Json {
objectFormat: definition.objectFormat,
objectId: definition.objectId,
rootDocumentPath: definition.rootDocumentPath,
// Written only when there is one. An untargeted definition that stored an
// explicit absence would parse back as a descriptor that asked for a target
// and failed to name it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and failed to name it.

...(definition.targetPath === undefined ? {} : { targetPath: definition.targetPath }),
};
}

Expand Down
138 changes: 138 additions & 0 deletions packages/workflow/tests/workflow-definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { isCanonicalDocumentTarget } from "@executablemd/core";
import {
canonicalJson,
conflictingFields,
Expand Down Expand Up @@ -312,3 +313,140 @@ describe("Tier WD — compatible reuse", () => {
).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<string, unknown>)).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<string, unknown>;
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"]);
});
});
Loading
Loading