Skip to content

Commit bd4e28f

Browse files
committed
🍴 Continue one run's history under a changed definition
`xmd workflow fork <source> --at=<event> <definition>` creates a new immutable run whose journal begins as somebody else's. It writes two records for itself — its own `workflow_run`, and its own root import, which is what makes it a run of the definition the caller named — and inherits everything after them with their public event ids, filtered results, authored positions, Workspace roots and source provenance. Compatibility is proved before any storage the host would recognize as the new run exists. The candidate replays the inherited prefix against a staged copy of the fork, so a document resolves its paths through the Workspace the fork will actually have, and a guard ends the replay on the last inherited event — before the first effect after the checkpoint is entered, and before anything is appended. A divergent candidate leaves no fork. `history --forkable` reports which checkpoints a fork may select, under four stable blocker codes that name the earliest event introducing each and nothing a retained description held. Version 1 gains two tables in place: the fork's lineage, and the source run and event behind each inherited row.
1 parent c21e598 commit bd4e28f

25 files changed

Lines changed: 3372 additions & 153 deletions

‎architecture.md‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,7 @@ the journal and the document receive only the fingerprint.
854854

855855
The command selects the environment; the document describes the procedure.
856856
`xmd run` uses the caller's current environment and makes no restoration
857-
promise. The specified future `xmd workflow` command creates a workflow run with
858-
one implicit root Workspace. The same declarative components use contextual
857+
promise. `xmd workflow` creates a workflow run with one implicit root Workspace. The same declarative components use contextual
859858
capabilities in both modes; the workflow host supplies the stronger durability
860859
and authority boundary.
861860

@@ -1079,10 +1078,29 @@ because a live operation that appended nothing would leave the next one to
10791078
append at its position.
10801079

10811080
Every committed journal event references the current logical Workspace root.
1082-
Only committed event boundaries are checkpoints. A history fork cheaply retains
1083-
or clones the selected root, replays the inherited prefix under a compatible
1084-
modified definition and appends new history under a new run ID. It never rewinds
1085-
external branches, pushes, pull requests or provider state.
1081+
Only committed event boundaries are checkpoints. A history fork copies the
1082+
selected root and the roots the inherited prefix names into the new run, replays
1083+
the inherited prefix under a compatible modified definition and appends new
1084+
history under a new run ID. It never rewinds external branches, pushes, pull
1085+
requests or provider state.
1086+
1087+
A fork writes two records for itself and inherits the rest. Its own
1088+
`workflow_run` record is what its journal is held to, and its own root import is
1089+
what makes the run a run of the definition the caller named — that record holds
1090+
the document's text, so inheriting the source's would run the source's document
1091+
whatever was asked for. Everything after them travels unchanged, keeping its
1092+
public event ID, its filtered result, its authored position and its Workspace
1093+
root, and carrying the source run and source event it came from. Retention moves
1094+
with the fork: once the fork commits, deleting the source leaves its prefix and
1095+
its selected root exactly as readable as they were.
1096+
1097+
Compatibility is decided before any storage the host would recognize as the new
1098+
run exists. The candidate replays the inherited prefix against a staged copy of
1099+
the fork — the whole run, assembled where nothing discovers it — and the replay
1100+
ends at the checkpoint, before the first live effect, rather than after it. A
1101+
divergent candidate leaves no fork. After it succeeds, one transaction commits
1102+
the run, its lineage, the inherited prefix, the copied roots, the selected
1103+
current root and the first document execution together.
10861104

10871105
## Agent authority and generated XMD
10881106

@@ -1150,7 +1168,8 @@ after SQLite has restored the prior frontier.
11501168

11511169
Retained roots, manifests and blobs remain indefinitely. Cloudflare garbage
11521170
collection is not in the production closure and is never invoked. The provider
1153-
exposes no public history selection or fork operation at this layer. Its
1171+
exposes no public history selection at this layer; a fork's root copying is a
1172+
lifecycle transaction above it. Its
11541173
coordinator combines one mutation, immutable-root publication and one filtered
11551174
journal result atomically, and the transaction-bound Files provider is what
11561175
routes a document's `<File>` and `<Glob>` to it. `xmd workflow start` and
@@ -2283,7 +2302,7 @@ Status is measured against main.
22832302
| `Git.Push` | 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 | built on the #370 stack, Deno provider only |
22842303
| workflow lifecycle inspection and control | reads status/list/history without advancing a run, enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | built on the #367 stack |
22852304
| historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack |
2286-
| history fork | creates a new run from one compatible checkpoint and retained Workspace root | defined in `specs/workflow-workspace-spec.md`, unbuilt (#368) |
2305+
| 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 |
22872306
| read-only workflow Agent / generated XMD | lets an Agent inspect a derived view and propose constrained executable changes | defined in `specs/workflow-workspace-spec.md`, unbuilt |
22882307
| 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 |
22892308
| 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 |

‎packages/cli/src/cli.ts‎

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,14 @@ interface DocumentConfig {
511511
* quietly empty the process results a resumed workflow reads back.
512512
*/
513513
retainProcessOutput: boolean;
514+
/**
515+
* Whether what this execution renders is kept from the reader.
516+
*
517+
* A fork's compatibility replay re-renders history that already happened in
518+
* another run, and the fork's own execution renders it again a moment later.
519+
* The default is that a reader sees what a document produced.
520+
*/
521+
discardOutput?: boolean;
514522
}
515523

516524
interface DocumentMode {
@@ -676,9 +684,10 @@ function* runDocument(
676684
// observability, shown on stderr under --verbose and dropped otherwise.
677685
// Interactive TTY: write each chunk as it arrives.
678686
// Piped: collect and write the full output at the end.
687+
const discarded = config.discardOutput === true;
679688
const fullOutput = yield* forEach(function* (chunk: string) {
680-
if (valueRoot) {
681-
if (verbose) {
689+
if (valueRoot || discarded) {
690+
if (verbose && !discarded) {
682691
process.stderr.write(chunk);
683692
}
684693
return;
@@ -689,7 +698,7 @@ function* runDocument(
689698
}, execution.output);
690699

691700
// When piped (not TTY), write the full output at the end.
692-
if (!valueRoot && !process.stdout.isTTY) {
701+
if (!valueRoot && !discarded && !process.stdout.isTTY) {
693702
process.stdout.write(fullOutput);
694703
}
695704

@@ -1006,7 +1015,7 @@ interface PropsPhase {
10061015
* positional written after `--` is not in it, and asking the parser again
10071016
* would lose exactly the token the separator was there to protect.
10081017
*/
1009-
workflow?: { action?: string; target?: string; suspension?: string; answer?: string };
1018+
workflow?: { action?: string; target?: string; argument?: string; value?: string };
10101019
root?: RootDocumentSource;
10111020
bindings: Binding[];
10121021
extraction?: Extraction;
@@ -1161,21 +1170,23 @@ function exactRoot(root: RootDocumentSource, target: string | undefined): RootDo
11611170
* The props phase of a `workflow` invocation.
11621171
*
11631172
* `start` reads what the pinned definition declares, so its generated
1164-
* `--props-*` arguments are exactly `xmd run`'s for that document. `resume`
1173+
* `--props-*` arguments are exactly `xmd run`'s for that document. A `fork`
1174+
* reads the same way, from the definition it names as its third argument: the
1175+
* fork is a run of that document, so its props are that document's. `resume`
11651176
* declares nothing at all: its props are the ones the run retained, and any
11661177
* spelling that would supply new ones is refused rather than ignored.
11671178
*/
11681179
function* prepareWorkflowProps(
11691180
rawArgs: string[],
11701181
args: string[],
1171-
config: { action?: string; target?: string; suspension?: string; answer?: string },
1182+
config: { action?: string; target?: string; argument?: string; value?: string },
11721183
inlineDocument: string | undefined,
11731184
): Operation<PropsPhase> {
11741185
const workflow = {
11751186
action: config.action,
11761187
target: config.target,
1177-
suspension: config.suspension,
1178-
answer: config.answer,
1188+
argument: config.argument,
1189+
value: config.value,
11791190
};
11801191
if (inlineDocument !== undefined) {
11811192
return {
@@ -1202,7 +1213,11 @@ function* prepareWorkflowProps(
12021213
}
12031214

12041215
const stray = findPropsFlag(args);
1205-
if (config.action !== "start") {
1216+
// Only the two actions that name a definition declare properties. A fork
1217+
// names its own, as its third argument: it is a run of that document, so the
1218+
// generated arguments are that document's rather than the source run's.
1219+
const definitionPath = workflowDefinitionPath(config);
1220+
if (definitionPath === undefined) {
12061221
if (stray) {
12071222
const action = config.action ?? "resume";
12081223
return {
@@ -1217,11 +1232,11 @@ function* prepareWorkflowProps(
12171232
return { args, bindings: [], workflow };
12181233
}
12191234

1220-
if (config.target === undefined || config.target === "") {
1235+
if (definitionPath === "") {
12211236
return { args, bindings: [], workflow };
12221237
}
12231238

1224-
const established = yield* establishDefinition(config.target);
1239+
const established = yield* establishDefinition(definitionPath);
12251240
if (!established.ok) {
12261241
return { args, bindings: [], workflow, error: established.error.message };
12271242
}
@@ -1249,6 +1264,28 @@ function* prepareWorkflowProps(
12491264
}
12501265
}
12511266

1267+
/**
1268+
* The document one `workflow` invocation runs, when it names one.
1269+
*
1270+
* `start` names it as its only argument and `fork` as its second; every other
1271+
* action names a run and no definition at all. The empty string is a definition
1272+
* the caller has not written yet, which the props phase reports later as a
1273+
* missing argument rather than as an establishment failure.
1274+
*/
1275+
function workflowDefinitionPath(config: {
1276+
action?: string;
1277+
target?: string;
1278+
argument?: string;
1279+
}): string | undefined {
1280+
if (config.action === "start") {
1281+
return config.target ?? "";
1282+
}
1283+
if (config.action === "fork") {
1284+
return config.argument ?? "";
1285+
}
1286+
return undefined;
1287+
}
1288+
12521289
/**
12531290
* Whether these arguments select the `workflow` command.
12541291
*
@@ -1269,19 +1306,20 @@ function namesWorkflow(args: string[]): boolean {
12691306
* exists to prevent, since a document never selects a run.
12701307
*
12711308
* Read from the argv the props phase already stripped, so a generated property
1272-
* value is not mistaken for an argument. `--id` is the one option that takes a
1273-
* separated value, and `--` ends option parsing: every token after it is
1309+
* value is not mistaken for an argument. `--id` and `--at` are the options that
1310+
* take a separated value, and `--` ends option parsing: every token after it is
12741311
* positional, including one that begins with `-`.
12751312
*/
12761313
function extraWorkflowArgument(args: string[], action?: string): string | undefined {
12771314
const start = args.indexOf("workflow");
12781315
if (start === -1) {
12791316
return undefined;
12801317
}
1281-
// The action is itself the first positional. Every action but `answer` takes
1282-
// one more; `answer` takes three, because a delivery names the run, the wait
1283-
// inside it and the value in that order.
1284-
const allowed = action === "answer" ? 4 : 2;
1318+
// The action is itself the first positional. Most actions take one more;
1319+
// `fork` takes two, because it names the run it continues and the document it
1320+
// continues with; `answer` takes three, because a delivery names the run, the
1321+
// wait inside it and the value in that order.
1322+
const allowed = action === "answer" ? 4 : action === "fork" ? 3 : 2;
12851323
let positionals = 0;
12861324
let skip = false;
12871325
let parsingOptions = true;
@@ -1299,7 +1337,7 @@ function extraWorkflowArgument(args: string[], action?: string): string | undefi
12991337
continue;
13001338
}
13011339
if (parsingOptions && arg.startsWith("-")) {
1302-
skip = arg === "--id";
1340+
skip = arg === "--id" || arg === "--at";
13031341
continue;
13041342
}
13051343
positionals += 1;
@@ -1340,14 +1378,14 @@ function separateArgs(args: string[]): SeparatedArgs {
13401378
* separator it is positional because of where it is.
13411379
*/
13421380
function workflowPositionals(
1343-
config: { action?: string; target?: string; suspension?: string; answer?: string },
1381+
config: { action?: string; target?: string; argument?: string; value?: string },
13441382
tail: string[],
1345-
): { action?: string; target?: string; suspension?: string; answer?: string } {
1346-
const named = [config.action, config.target, config.suspension, config.answer].filter(
1347-
(value) => value !== undefined,
1383+
): { action?: string; target?: string; argument?: string; value?: string } {
1384+
const named = [config.action, config.target, config.argument, config.value].filter(
1385+
(written) => written !== undefined,
13481386
);
1349-
const [action, target, suspension, answer] = [...named, ...tail];
1350-
return { action, target, suspension, answer };
1387+
const [action, target, argument, value] = [...named, ...tail];
1388+
return { action, target, argument, value };
13511389
}
13521390

13531391
const COMMAND_NAMES = ["run", "test", "test-agent", "workflow"];
@@ -1681,6 +1719,9 @@ function* dispatch(
16811719
// It names no `--journal`, which is exactly why this is stated
16821720
// here and never derived from that pathname.
16831721
retainProcessOutput: true,
1722+
// A fork's compatibility replay renders history another run
1723+
// already produced; the fork's own execution renders it again.
1724+
...(execution.discardOutput === true ? { discardOutput: true } : {}),
16841725
},
16851726
{ testing: false, props: execution.props, installations: execution.installations },
16861727
// The workflow authority boundary sits exactly where a host

0 commit comments

Comments
 (0)