@agentswf/engine 0.1.0-beta.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
package/src/run-lines.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { open, readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { type RunLine, type RunView, readRun } from "@agentswf/contract/records";
|
|
4
|
+
import { messageOf } from "./errors";
|
|
5
|
+
import { isCode, syncDir } from "./files";
|
|
6
|
+
import { queued } from "./jsonl";
|
|
7
|
+
import { unendedTurns } from "./run-policy/waits";
|
|
8
|
+
import { RunUnreachable } from "./run-unreachable";
|
|
9
|
+
import { crashedEnd } from "./turn-ends";
|
|
10
|
+
|
|
11
|
+
/** `run.jsonl`, what the run settled; resolved, as it keys the append queue. */
|
|
12
|
+
export function runLinesFile(runDir: string): string {
|
|
13
|
+
return resolve(runDir, "run.jsonl");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Appends one attempt's lines to `run.jsonl`, in call order, each whole and synced. */
|
|
17
|
+
export type RunLines = {
|
|
18
|
+
readonly attempt: number;
|
|
19
|
+
append(line: RunLine): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Attempt `attempt`'s writer: made only by its claim, so nothing appends before the fence. */
|
|
23
|
+
function runLines(runDir: string, attempt: number): RunLines {
|
|
24
|
+
const path = runLinesFile(runDir);
|
|
25
|
+
return {
|
|
26
|
+
attempt,
|
|
27
|
+
append(line) {
|
|
28
|
+
if (line.attempt !== attempt) {
|
|
29
|
+
return Promise.reject(
|
|
30
|
+
new Error(`attempt ${attempt} cannot append a line of attempt ${line.attempt}`),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return queued(path, () => appendSynced(path, `${JSON.stringify(line)}\n`));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The claim on `run.jsonl`: cuts a line a crash left torn, so it never merges with the next, then
|
|
40
|
+
* appends `claimed`, the fence after which a line of an earlier attempt is ignored, then ends each
|
|
41
|
+
* turn an earlier attempt began and never ended, failed, before any wait can look. Called once the
|
|
42
|
+
* previous attempt's process is gone.
|
|
43
|
+
*/
|
|
44
|
+
export async function claimRunLines(
|
|
45
|
+
runDir: string,
|
|
46
|
+
attempt: number,
|
|
47
|
+
at: string,
|
|
48
|
+
): Promise<RunLines> {
|
|
49
|
+
const path = runLinesFile(runDir);
|
|
50
|
+
await queued(path, () => cutTorn(path));
|
|
51
|
+
// `run.jsonl` may be new here: its entry in the folder is made durable with the claim.
|
|
52
|
+
await syncDir(runDir);
|
|
53
|
+
const lines = runLines(runDir, attempt);
|
|
54
|
+
await lines.append({ kind: "claimed", attempt, at });
|
|
55
|
+
for (const turn of unendedTurns((await readRunLines(runDir)).lines))
|
|
56
|
+
await lines.append(crashedEnd(turn, attempt, at));
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `run.jsonl`'s complete lines, checked, and their reading. Text after the last newline is a line
|
|
62
|
+
* still being written or one a crash tore, which the next claim cuts: it is not read.
|
|
63
|
+
*/
|
|
64
|
+
export async function readRunLines(runDir: string): Promise<{ lines: RunLine[]; view: RunView }> {
|
|
65
|
+
const path = runLinesFile(runDir);
|
|
66
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
67
|
+
if (isCode(error, "ENOENT")) return "";
|
|
68
|
+
throw error;
|
|
69
|
+
});
|
|
70
|
+
const complete = text.slice(0, text.lastIndexOf("\n") + 1).split("\n");
|
|
71
|
+
complete.pop();
|
|
72
|
+
const parsed = complete.map((line, index) => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(line) as unknown;
|
|
75
|
+
} catch {
|
|
76
|
+
throw new RunUnreachable(`run.jsonl line ${index + 1}: not JSON`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
let view: RunView;
|
|
80
|
+
try {
|
|
81
|
+
view = readRun(parsed);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A line of no kind this awf knows: the run's record can't be read, as a line not JSON.
|
|
84
|
+
throw new RunUnreachable(messageOf(error));
|
|
85
|
+
}
|
|
86
|
+
return { lines: parsed as RunLine[], view };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One `O_APPEND` write, then fsync; Bun's fsync on macOS is not `F_FULLFSYNC` (story 041, Q5). A
|
|
91
|
+
* write that fails part way is cut, so the next line isn't glued to its half.
|
|
92
|
+
*/
|
|
93
|
+
async function appendSynced(path: string, text: string): Promise<void> {
|
|
94
|
+
const handle = await open(path, "a", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
const { size } = await handle.stat();
|
|
97
|
+
const bytes = new TextEncoder().encode(text);
|
|
98
|
+
let written = 0;
|
|
99
|
+
try {
|
|
100
|
+
while (written < bytes.byteLength) {
|
|
101
|
+
const { bytesWritten } = await handle.write(bytes, written, bytes.byteLength - written);
|
|
102
|
+
if (bytesWritten === 0) throw new Error("append made no progress");
|
|
103
|
+
written += bytesWritten;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Only when the file ends with this write's bytes: a stale writer never cuts another's line.
|
|
107
|
+
const now = await handle.stat().catch(() => undefined);
|
|
108
|
+
if (written > 0 && now?.size === size + written) {
|
|
109
|
+
await handle.truncate(size).catch(() => undefined);
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
await handle.sync();
|
|
114
|
+
} finally {
|
|
115
|
+
await handle.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Truncates `path` to its last newline, creating it when absent. */
|
|
120
|
+
async function cutTorn(path: string): Promise<void> {
|
|
121
|
+
const handle = await open(path, "a+", 0o600);
|
|
122
|
+
try {
|
|
123
|
+
const { size } = await handle.stat();
|
|
124
|
+
const end = await afterLastNewline(handle, size);
|
|
125
|
+
if (end < size) {
|
|
126
|
+
await handle.truncate(end);
|
|
127
|
+
await handle.sync();
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
await handle.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type Reader = {
|
|
135
|
+
read(
|
|
136
|
+
buffer: Uint8Array,
|
|
137
|
+
offset: number,
|
|
138
|
+
length: number,
|
|
139
|
+
position: number,
|
|
140
|
+
): Promise<{
|
|
141
|
+
bytesRead: number;
|
|
142
|
+
}>;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The offset just past the last newline before `size`, read backwards; 0 when there is none. */
|
|
146
|
+
async function afterLastNewline(handle: Reader, size: number): Promise<number> {
|
|
147
|
+
const chunk = new Uint8Array(64 * 1024);
|
|
148
|
+
let end = size;
|
|
149
|
+
while (end > 0) {
|
|
150
|
+
const start = Math.max(0, end - chunk.byteLength);
|
|
151
|
+
const length = end - start;
|
|
152
|
+
const { bytesRead } = await handle.read(chunk, 0, length, start);
|
|
153
|
+
if (bytesRead !== length) throw new Error("run.jsonl changed while it was claimed");
|
|
154
|
+
const newline = chunk.subarray(0, length).lastIndexOf(10);
|
|
155
|
+
if (newline !== -1) return start + newline + 1;
|
|
156
|
+
end = start;
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
package/src/run-lock.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { probeProcess, processStart, sameStart } from "./runs";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `{root}/{workflow}/{run id}.lock`: the lock on a run, beside its run's folder so it exists before
|
|
8
|
-
* the run does. Every append to
|
|
8
|
+
* the run does. Every sender's append to `in.jsonl` holds it, and so does a wait's last look before
|
|
9
|
+
* it parks.
|
|
9
10
|
*/
|
|
10
11
|
export function runLockFile(runRoot: string, workflow: string, runId: string): string {
|
|
11
12
|
return join(runRoot, workflow, `${runId}.lock`);
|
|
@@ -79,6 +80,61 @@ async function claim(file: string): Promise<Holder> {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* `{root}/{workflow}/{run id}.open`: held by `awf open --no-fork` for as long as the session it
|
|
85
|
+
* opened is open, so no attempt starts and no second one opens it (story 042). Claimed as the
|
|
86
|
+
* run's lock is, but never waited for.
|
|
87
|
+
*/
|
|
88
|
+
export function runOpenFile(runRoot: string, workflow: string, runId: string): string {
|
|
89
|
+
return join(runRoot, workflow, `${runId}.open`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Who holds a claim: its process, by pid and start. */
|
|
93
|
+
export type ClaimHolder = Holder;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Claims `file` once, without waiting: this process's holding, or the live process that holds it.
|
|
97
|
+
* A holder whose process is gone is taken over.
|
|
98
|
+
*/
|
|
99
|
+
export async function claimNow(file: string): Promise<{ held: Holder } | { heldBy: Holder }> {
|
|
100
|
+
const started = ownStart();
|
|
101
|
+
if (started === undefined) throw new Error(`no start time for this process (${process.pid})`);
|
|
102
|
+
const mine: Holder = { pid: process.pid, processStart: started, at: new Date().toISOString() };
|
|
103
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
104
|
+
for (let tries = 0; tries < 5; tries += 1) {
|
|
105
|
+
if (await linkNew(file, mine)) return { held: mine };
|
|
106
|
+
const holder = await readHolder(file);
|
|
107
|
+
if (holder === "gone") continue;
|
|
108
|
+
if (holder && !gone(holder)) return { heldBy: holder };
|
|
109
|
+
await takeOver(file, holder);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`${file} changed hands as it was claimed; try again`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Lets go of `file`, if `held` still holds it. */
|
|
115
|
+
export async function releaseClaim(file: string, held: Holder): Promise<void> {
|
|
116
|
+
if (JSON.stringify(await readHolder(file)) === JSON.stringify(held))
|
|
117
|
+
await unlink(file).catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The live process holding `file`, read without taking it; none for a holder that is gone. A
|
|
122
|
+
* holder `ps` can't be asked about counts as live, as in `gone`: what it holds back waits for a
|
|
123
|
+
* later look, never runs beside it.
|
|
124
|
+
*/
|
|
125
|
+
export async function liveHolder(file: string): Promise<Holder | undefined> {
|
|
126
|
+
const holder = await readHolder(file);
|
|
127
|
+
return holder !== "gone" && holder !== undefined && !gone(holder) ? holder : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether a live process holds the lock, read without taking it. A holder whose process is gone,
|
|
132
|
+
* a sender killed mid-launch, holds nothing; nor does a torn file, which `claim` takes over.
|
|
133
|
+
*/
|
|
134
|
+
export async function lockHeld(file: string): Promise<boolean> {
|
|
135
|
+
return (await liveHolder(file)) !== undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
82
138
|
/**
|
|
83
139
|
* Whether a holder's process is gone. Where `ps` can't be asked, it is taken as alive: taking a
|
|
84
140
|
* live holder's lock over would let two writers in.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { StageLine, StageNeed } from "@agentswf/contract/records";
|
|
2
|
+
import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
|
|
3
|
+
import type { JsonValue } from "@agentswf/contract/workflow";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a continue does with a stage it enters: until its start point, each is reused from its
|
|
7
|
+
* line, provided, or stops the attempt; from the start point on, every stage runs. The start
|
|
8
|
+
* point is `--from-stage`, or else the first stage entered with no succeeded line. Before a
|
|
9
|
+
* `--from-stage`, a stage whose line can't be reused takes its value from `--values`, one that
|
|
10
|
+
* returns nothing is passed, and one with nothing recorded needs a value. Pure: the line the run's
|
|
11
|
+
* reuse gives, the values, the result and the version come in, a decision goes out.
|
|
12
|
+
*/
|
|
13
|
+
type StagePlanDecision =
|
|
14
|
+
| { kind: "reuse"; line: StageLine; value: JsonValue | undefined }
|
|
15
|
+
/** A value given, checked by the stage's result; undefined for a stage that returns nothing. */
|
|
16
|
+
| { kind: "provide"; value: JsonValue | undefined }
|
|
17
|
+
/** `start` on the stage that is the start point. */
|
|
18
|
+
| { kind: "run"; start: boolean }
|
|
19
|
+
| { kind: "stop"; reason: string }
|
|
20
|
+
/** Nothing recorded, and no value given that fits: `--values` lets the attempt go on. */
|
|
21
|
+
| { kind: "need"; reason: string; need: StageNeed };
|
|
22
|
+
|
|
23
|
+
export type StagePlanState = {
|
|
24
|
+
/** The stage's line as `StageReuse` gives it: its latest, unless a redo outdated it. */
|
|
25
|
+
line?: StageLine;
|
|
26
|
+
/** The `meta.version` of the attempt that wrote `line`. */
|
|
27
|
+
lineVersion?: string;
|
|
28
|
+
/** The stages the run has a line for. */
|
|
29
|
+
recorded: readonly string[];
|
|
30
|
+
fromStage?: string;
|
|
31
|
+
/** `--values`: each stage's value, to start at `fromStage`. */
|
|
32
|
+
values?: ReadonlyMap<string, JsonValue>;
|
|
33
|
+
/** The workflow's `meta.version` now. */
|
|
34
|
+
workflowVersion?: string;
|
|
35
|
+
/** Whether this attempt has reached its start point. */
|
|
36
|
+
started: boolean;
|
|
37
|
+
/** The stages this attempt has entered so far. */
|
|
38
|
+
entered: readonly string[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decides a stage entered, given its `result` as JSON Schema, absent for a stage that returns
|
|
43
|
+
* nothing. A stop's reason says why; the command that goes on names the stage to redo.
|
|
44
|
+
*/
|
|
45
|
+
export function planStage(
|
|
46
|
+
state: StagePlanState,
|
|
47
|
+
stage: string,
|
|
48
|
+
result: JsonSchema | undefined,
|
|
49
|
+
): StagePlanDecision {
|
|
50
|
+
if (state.started) return { kind: "run", start: false };
|
|
51
|
+
if (stage === state.fromStage) return { kind: "run", start: true };
|
|
52
|
+
const { line } = state;
|
|
53
|
+
const succeeded = line?.outcome === "succeeded" ? line : undefined;
|
|
54
|
+
const stale = succeeded && unreusable(state, succeeded, result);
|
|
55
|
+
if (succeeded && stale === undefined) {
|
|
56
|
+
return { kind: "reuse", line: succeeded, value: succeeded.value };
|
|
57
|
+
}
|
|
58
|
+
if (state.fromStage === undefined) {
|
|
59
|
+
return stale === undefined ? { kind: "run", start: true } : { kind: "stop", reason: stale };
|
|
60
|
+
}
|
|
61
|
+
// A line there that failed or went stale is redone from its stage, as on any continue, unless a
|
|
62
|
+
// value given replaces it.
|
|
63
|
+
const redo = line && {
|
|
64
|
+
kind: "stop" as const,
|
|
65
|
+
reason: stale ?? `${stage} did not succeed in attempt ${line.attempt}`,
|
|
66
|
+
};
|
|
67
|
+
if (result === undefined) return redo ?? { kind: "provide", value: undefined };
|
|
68
|
+
const need = { stage, schema: result };
|
|
69
|
+
const given = state.values?.get(stage);
|
|
70
|
+
if (given !== undefined) {
|
|
71
|
+
const errors = validate(result, given);
|
|
72
|
+
if (errors.length === 0) return { kind: "provide", value: given };
|
|
73
|
+
const misfit = problems(`${stage}'s value in --values does not fit its result:`, errors);
|
|
74
|
+
return redo ? { kind: "stop", reason: misfit } : { kind: "need", reason: misfit, need };
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
redo ?? {
|
|
78
|
+
kind: "need",
|
|
79
|
+
reason: `nothing recorded for ${stage}${unreached(state, stage)}`,
|
|
80
|
+
need,
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Why a succeeded line can't be reused, as a stop says it, or undefined when it can: another major
|
|
87
|
+
* version wrote it, or its value no longer fits the stage's `result`.
|
|
88
|
+
*/
|
|
89
|
+
function unreusable(
|
|
90
|
+
state: StagePlanState,
|
|
91
|
+
line: StageLine,
|
|
92
|
+
result: JsonSchema | undefined,
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const recorded = majorOf(state.lineVersion);
|
|
95
|
+
const now = majorOf(state.workflowVersion);
|
|
96
|
+
if (recorded !== undefined && now !== undefined && recorded !== now) {
|
|
97
|
+
return `${line.name} was recorded by ${state.lineVersion}; this is ${state.workflowVersion}`;
|
|
98
|
+
}
|
|
99
|
+
return misfitOf(line, result);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Why a succeeded line's value no longer fits the stage's `result`; undefined when it does. */
|
|
103
|
+
function misfitOf(line: StageLine, result: JsonSchema | undefined): string | undefined {
|
|
104
|
+
const stage = line.name;
|
|
105
|
+
if (result === undefined) {
|
|
106
|
+
return line.value === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: `${stage}'s line holds a value, and the stage no longer has a result`;
|
|
109
|
+
}
|
|
110
|
+
if (line.value === undefined) {
|
|
111
|
+
return `${stage}'s line holds no value, and the stage's result now expects one`;
|
|
112
|
+
}
|
|
113
|
+
const errors = validate(result, line.value);
|
|
114
|
+
return errors.length === 0
|
|
115
|
+
? undefined
|
|
116
|
+
: problems(`${stage}'s line no longer fits its result schema:`, errors);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A value's problems under what they are problems of, a line each. */
|
|
120
|
+
function problems(heading: string, errors: readonly SchemaError[]): string {
|
|
121
|
+
return [heading, ...errors.map((error) => ` ${error.path}: ${error.message}`)].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Recorded stages this attempt hasn't reached, which a rename in the code would leave behind. */
|
|
125
|
+
function unreached(state: StagePlanState, stage: string): string {
|
|
126
|
+
const others = state.recorded.filter(
|
|
127
|
+
(name) => name !== stage && name !== state.fromStage && !state.entered.includes(name),
|
|
128
|
+
);
|
|
129
|
+
return others.length > 0 ? ` (recorded and not reached: ${others.join(", ")})` : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What must match for a line to be reused: the major, or `0.{minor}`, as semver has it. */
|
|
133
|
+
function majorOf(version: string | undefined): string | undefined {
|
|
134
|
+
if (version === undefined) return undefined;
|
|
135
|
+
const [major, minor] = version.split(".");
|
|
136
|
+
return major === "0" ? `0.${minor}` : major;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* In a run of a workflow with `events`, a stage runs once ever: a succeeded line is reused wherever
|
|
141
|
+
* and however often its name is entered, whatever version of the code wrote it, as long as its
|
|
142
|
+
* value still fits the stage's `result`. One that doesn't fit stops the attempt; a failed line, or
|
|
143
|
+
* none, runs.
|
|
144
|
+
*/
|
|
145
|
+
export function planStageByContent(
|
|
146
|
+
line: StageLine | undefined,
|
|
147
|
+
result: JsonSchema | undefined,
|
|
148
|
+
): Extract<StagePlanDecision, { kind: "reuse" | "stop" }> | { kind: "run" } {
|
|
149
|
+
if (line?.outcome !== "succeeded") return { kind: "run" };
|
|
150
|
+
const misfit = misfitOf(line, result);
|
|
151
|
+
return misfit === undefined
|
|
152
|
+
? { kind: "reuse", line, value: line.value }
|
|
153
|
+
: { kind: "stop", reason: misfit };
|
|
154
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ClaimedAttempt, RunView, StageLine } from "@agentswf/contract/records";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The stage attempt `attempt` started at, which its line says with `redo`: the one `--from-stage`
|
|
5
|
+
* named, or a plain run's first stage not reused. In that attempt and every later one, a stage
|
|
6
|
+
* entered after `stage` whose line an earlier attempt than `attempt` wrote runs again: it was made
|
|
7
|
+
* against the stage before. One never entered after the stage keeps its reuse.
|
|
8
|
+
*/
|
|
9
|
+
export type StageRedo = { stage: string; attempt: number };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an attempt may reuse of the run's stages, as it enters them: the engine's rule over
|
|
13
|
+
* `readRun`'s view. A stage stands for its latest line, unless the redo outdated it; the plan
|
|
14
|
+
* decides from that line whether it is reused, which it is only when that line succeeded: a
|
|
15
|
+
* continue reuses `settled` only when it is also `last`. One per attempt, told of each line it
|
|
16
|
+
* writes.
|
|
17
|
+
*/
|
|
18
|
+
export class StageReuse {
|
|
19
|
+
/** Each stage's latest line, this attempt's included. */
|
|
20
|
+
readonly #last: Map<string, StageLine>;
|
|
21
|
+
#redo: StageRedo | undefined;
|
|
22
|
+
/** Whether this attempt has entered the redo's stage, after which an older line is outdated. */
|
|
23
|
+
#afterRedo = false;
|
|
24
|
+
readonly #entered = new Set<string>();
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
view: Pick<RunView, "stages" | "redo">,
|
|
28
|
+
private readonly attempt: number,
|
|
29
|
+
) {
|
|
30
|
+
this.#last = new Map(Object.entries(view.stages).map(([name, { last }]) => [name, last]));
|
|
31
|
+
this.#redo = view.redo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Enters `name`: the line it stands for now, of any outcome, or undefined when it has none or the
|
|
36
|
+
* redo outdated it. A stage entered already in this attempt keeps the line it had: one stage, one
|
|
37
|
+
* value.
|
|
38
|
+
*/
|
|
39
|
+
enter(name: string): StageLine | undefined {
|
|
40
|
+
const line = this.#last.get(name);
|
|
41
|
+
const redo = this.#redo;
|
|
42
|
+
const outdated =
|
|
43
|
+
redo !== undefined &&
|
|
44
|
+
this.#afterRedo &&
|
|
45
|
+
line !== undefined &&
|
|
46
|
+
line.attempt < redo.attempt &&
|
|
47
|
+
!this.#entered.has(name);
|
|
48
|
+
this.#entered.add(name);
|
|
49
|
+
if (name === redo?.stage) this.#afterRedo = true;
|
|
50
|
+
return outdated ? undefined : line;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** This attempt starts at `name`, entered now: a later stage with an older line runs again. */
|
|
54
|
+
redoFrom(name: string): void {
|
|
55
|
+
this.#redo = { stage: name, attempt: this.attempt };
|
|
56
|
+
this.#afterRedo = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A line this attempt wrote, which the stages it enters from now on see. */
|
|
60
|
+
wrote(line: StageLine): void {
|
|
61
|
+
this.#last.set(line.name, line);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Each stage's latest line, in the order they started, attempt then file order breaking a tie:
|
|
67
|
+
* what `--from-stage` chooses from, and what a stage not reached names.
|
|
68
|
+
*/
|
|
69
|
+
export function recordedStages(view: Pick<RunView, "stages">): StageLine[] {
|
|
70
|
+
return Object.values(view.stages)
|
|
71
|
+
.map(({ last }) => last)
|
|
72
|
+
.sort((a, b) => a.started.localeCompare(b.started) || a.attempt - b.attempt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The stages a continue may still come to, in the order they started: each recorded stage but
|
|
77
|
+
* those the last redo left behind, such as a stage the code no longer has. The redo's attempt ran
|
|
78
|
+
* every stage it entered after its start point again, so a stage whose latest line is older than
|
|
79
|
+
* that attempt, and which started after the redo's stage in the attempt that wrote it, was never
|
|
80
|
+
* entered there. Where that attempt reused the redo's stage, and so wrote it no line, the redo
|
|
81
|
+
* stage's latest line from before it stands in: what that attempt ran came after the stage it
|
|
82
|
+
* reused. A stage before the redo's stage was reused, and is kept; so is one whose order can't be
|
|
83
|
+
* told, and a removed stage that came before it. A stage first run before the redo's stage, in an
|
|
84
|
+
* attempt that reused the redo's stage, is taken for one after it, and left out.
|
|
85
|
+
*/
|
|
86
|
+
export function upcomingStages(view: Pick<RunView, "stages" | "redo">): StageLine[] {
|
|
87
|
+
const { redo } = view;
|
|
88
|
+
return recordedStages(view).filter((line) => {
|
|
89
|
+
if (redo === undefined || line.attempt >= redo.attempt) return true;
|
|
90
|
+
const history = view.stages[redo.stage]?.history ?? [];
|
|
91
|
+
const start =
|
|
92
|
+
history.find((earlier) => earlier.attempt === line.attempt) ??
|
|
93
|
+
history.findLast((earlier) => earlier.attempt < line.attempt);
|
|
94
|
+
return start === undefined || Date.parse(line.started) <= Date.parse(start.started);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The `--from-stage` a continue goes on with when the attempt before it was one that crashed inside
|
|
100
|
+
* its start stage: it has no ending, named a `--from-stage`, and wrote no line for that stage. That
|
|
101
|
+
* redo isn't in the lines, so without it the old lines would stand. Undefined otherwise. The caller
|
|
102
|
+
* has already found the attempt's process gone.
|
|
103
|
+
*/
|
|
104
|
+
export function crashedRedo(
|
|
105
|
+
attempts: readonly Pick<ClaimedAttempt, "attempt" | "ended" | "flags">[],
|
|
106
|
+
view: Pick<RunView, "stages">,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
const last = attempts.at(-1);
|
|
109
|
+
const stage = last?.flags.fromStage;
|
|
110
|
+
if (!last || last.ended !== undefined || stage === undefined) return undefined;
|
|
111
|
+
const wrote = view.stages[stage]?.history.some((line) => line.attempt === last.attempt);
|
|
112
|
+
return wrote ? undefined : stage;
|
|
113
|
+
}
|