@agentswf/engine 0.1.0-beta.4 → 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 +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- 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 +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- 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 +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- 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 +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- 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 +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- 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 +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- 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 +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- 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 +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- 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 +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/run-commands.ts
CHANGED
|
@@ -1,98 +1,34 @@
|
|
|
1
|
+
// What the commands that read runs say of one: its state, span and cost, and the commands that go
|
|
2
|
+
// on from it.
|
|
1
3
|
import { resolve } from "node:path";
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import { continueCommand
|
|
4
|
+
import type { RunLogLine } from "@agentswf/contract/records";
|
|
5
|
+
import { runEstimate, unknownOps } from "./accounting/spent";
|
|
6
|
+
import { continueCommand } from "./attempt-view";
|
|
7
|
+
import { command, type Out, placeFlags, type ReadPlace, shellLine } from "./command-line";
|
|
8
|
+
import { NO_JSON, readFailureCode, refusalLine } from "./refusals";
|
|
5
9
|
import type { RunCommand } from "./run-command";
|
|
6
10
|
import { type Cursor, encodeCursor } from "./run-read";
|
|
7
11
|
import { RunNotFound, type RunStatus } from "./run-state";
|
|
12
|
+
import { clockTime } from "./run-text";
|
|
8
13
|
import { runRootOf } from "./runs";
|
|
14
|
+
import { readSpent } from "./spend";
|
|
9
15
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
export type Out = { stdout(text: string): void; stderr(text: string): void };
|
|
16
|
-
|
|
17
|
-
/** `--cwd`, `--run-root` and `--json`, taken out of `argv`; the rest is left for the command. */
|
|
18
|
-
export function parsePlace(
|
|
19
|
-
argv: readonly string[],
|
|
20
|
-
shellCwd: string,
|
|
21
|
-
): { place: ReadPlace; json: boolean; rest: string[] } {
|
|
22
|
-
let cwd = shellCwd;
|
|
23
|
-
let runRoot: string | undefined;
|
|
24
|
-
let json = false;
|
|
25
|
-
const repeat: string[] = [];
|
|
26
|
-
const rest: string[] = [];
|
|
27
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
28
|
-
const arg = argv[index]!;
|
|
29
|
-
// What follows is the command's own, a message's text included.
|
|
30
|
-
if (arg === "--") {
|
|
31
|
-
rest.push(...argv.slice(index));
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
if (arg === "--json") json = true;
|
|
35
|
-
else if (arg === "--cwd" || arg === "--run-root") {
|
|
36
|
-
const value = argv[index + 1];
|
|
37
|
-
if (!value || value.startsWith("--")) throw new Error(`${arg} needs a directory`);
|
|
38
|
-
index += 1;
|
|
39
|
-
if (arg === "--cwd") cwd = resolve(shellCwd, value);
|
|
40
|
-
else runRoot = resolve(shellCwd, value);
|
|
41
|
-
repeat.push(arg, value);
|
|
42
|
-
} else rest.push(arg);
|
|
43
|
-
}
|
|
44
|
-
return { place: { shellCwd, runRoot: runRoot ?? runRootOf(cwd), repeat }, json, rest };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** A suggested command, with the place repeated. */
|
|
48
|
-
export function command(place: ReadPlace, ...words: string[]): string[] {
|
|
49
|
-
const [first, second, ...rest] = words;
|
|
50
|
-
return [first!, second!, ...rest.slice(0, 1), ...place.repeat, ...rest.slice(1)];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** A field whose name says it holds a credential, whatever its value looks like. */
|
|
54
|
-
const SECRET_NAME =
|
|
55
|
-
/password|passwd|passphrase|passcode|secret|token|credential|cookie|bearer|authorization|(?:api|access|private|session|ssh|signing|encryption)[_-]?key|^(?:pwd|pass|pin|auth)$/i;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Every string in `value` masked, and everything under a credential's name hidden: a run's text,
|
|
59
|
-
* errors and fields are its workflow's, its agents' and its tools'. Past `limit`, a string is cut.
|
|
60
|
-
*/
|
|
61
|
-
export function masked(value: unknown, limit = Number.POSITIVE_INFINITY, hidden = false): unknown {
|
|
62
|
-
if (typeof value === "string") {
|
|
63
|
-
if (hidden) return "***";
|
|
64
|
-
const shown = maskCredentials(value);
|
|
65
|
-
return shown.length > limit ? `${shown.slice(0, limit - 1)}…` : shown;
|
|
66
|
-
}
|
|
67
|
-
if (hidden && value !== null && typeof value !== "object") return "***";
|
|
68
|
-
if (Array.isArray(value)) return value.map((each) => masked(each, limit, hidden));
|
|
69
|
-
if (value && typeof value === "object") {
|
|
70
|
-
return Object.fromEntries(
|
|
71
|
-
Object.entries(value).map(([key, each]) => [
|
|
72
|
-
key,
|
|
73
|
-
masked(each, limit, hidden || SECRET_NAME.test(key)),
|
|
74
|
-
]),
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Text a run wrote, as one masked line of about `limit` characters, so it can never pass for a
|
|
82
|
-
* line of its own or fill a page. `@{handle} --full` prints it whole.
|
|
83
|
-
*/
|
|
84
|
-
export function said(text: string, limit = 120): string {
|
|
85
|
-
const line = maskCredentials(text).replace(/\s*[\r\n]+\s*/g, " ⏎ ");
|
|
86
|
-
return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** A suggested command as text to paste into a shell. */
|
|
90
|
-
export function shellLine(words: readonly string[]): string {
|
|
91
|
-
return words.map(shellWord).join(" ");
|
|
92
|
-
}
|
|
16
|
+
/** The longest `--wait` the commands that read runs take. */
|
|
17
|
+
export const MAX_WAIT_SECONDS = 300;
|
|
18
|
+
/** How often they read a run while they wait. */
|
|
19
|
+
export const POLL_MS = 1_000;
|
|
93
20
|
|
|
94
21
|
/** Says a run that is not there, and exits 1; anything else is the run's files, exit 3. */
|
|
95
|
-
export function failed(
|
|
22
|
+
export function failed(
|
|
23
|
+
error: unknown,
|
|
24
|
+
place: ReadPlace,
|
|
25
|
+
out: Out,
|
|
26
|
+
message: string,
|
|
27
|
+
json: { json: boolean; v: number } = NO_JSON,
|
|
28
|
+
): number {
|
|
29
|
+
// The error's own code; else a file that couldn't be read, exit 3's, or a failure of awf's.
|
|
30
|
+
const code = readFailureCode(error);
|
|
31
|
+
if (json.json) out.stdout(refusalLine(json.v, code, message));
|
|
96
32
|
if (error instanceof RunNotFound) {
|
|
97
33
|
out.stderr(`${error.message}; runs here: ${shellLine(command(place, "awf", "runs"))}`);
|
|
98
34
|
return 1;
|
|
@@ -114,16 +50,6 @@ export function startedLine(
|
|
|
114
50
|
return `awf: ${ref} started · watch: ${shellLine(["awf", "logs", ref, ...placeFlags(run)])}`;
|
|
115
51
|
}
|
|
116
52
|
|
|
117
|
-
/** The flags that find this run again from another command: the folder or run root given. */
|
|
118
|
-
export function placeFlags(
|
|
119
|
-
run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
|
|
120
|
-
): string[] {
|
|
121
|
-
return [
|
|
122
|
-
...(run.cwdGiven ? ["--cwd", run.cwd] : []),
|
|
123
|
-
...(run.runRootGiven ? ["--run-root", run.runRoot] : []),
|
|
124
|
-
];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
53
|
/** `awf logs` from `cursor`: with `--wait` unless the page stopped short of the newest. */
|
|
128
54
|
export function nextLogs(
|
|
129
55
|
status: RunStatus,
|
|
@@ -137,7 +63,7 @@ export function nextLogs(
|
|
|
137
63
|
...filters,
|
|
138
64
|
"--after",
|
|
139
65
|
encodeCursor(cursor),
|
|
140
|
-
...(wait ? ["--wait", "
|
|
66
|
+
...(wait ? ["--wait", "90s"] : []),
|
|
141
67
|
];
|
|
142
68
|
}
|
|
143
69
|
|
|
@@ -205,22 +131,24 @@ export function span(
|
|
|
205
131
|
return { started, ...(ended === undefined ? {} : { ended }), ms: (ended ?? now) - started };
|
|
206
132
|
}
|
|
207
133
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
134
|
+
/**
|
|
135
|
+
* At list prices, today's, over the run's agent operations that recorded their spend, never what
|
|
136
|
+
* was billed; and how many operations' usage isn't known, which makes it a floor.
|
|
137
|
+
*/
|
|
138
|
+
export async function estimateOf(
|
|
139
|
+
status: RunStatus,
|
|
140
|
+
): Promise<{ estimate?: number; unknown: number }> {
|
|
141
|
+
const spent = await readSpent(status.run.dir, status.attempts, { decisions: false }).catch(
|
|
142
|
+
() => undefined,
|
|
212
143
|
);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const date = new Date(at);
|
|
224
|
-
const two = (n: number) => String(n).padStart(2, "0");
|
|
225
|
-
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
|
144
|
+
if (!spent) return { unknown: 0 };
|
|
145
|
+
const estimate = runEstimate(spent);
|
|
146
|
+
// A running attempt's operations are read as it ends: not yet unknown.
|
|
147
|
+
const live = status.state === "running" ? status.attempts.at(-1)?.attempt : undefined;
|
|
148
|
+
return {
|
|
149
|
+
...(estimate === undefined ? {} : { estimate }),
|
|
150
|
+
unknown: unknownOps(
|
|
151
|
+
spent.map((each) => (each.attempt === live ? { ...each, unsettled: 0 } : each)),
|
|
152
|
+
),
|
|
153
|
+
};
|
|
226
154
|
}
|
package/src/run-event-log.ts
CHANGED
|
@@ -41,7 +41,7 @@ export class RunEventLog {
|
|
|
41
41
|
if (this.#closed) return;
|
|
42
42
|
this.#writes.push(
|
|
43
43
|
appendEvent(this.options.runDir, record).catch((error) =>
|
|
44
|
-
this.options.failed(`awf: a turn was not recorded in
|
|
44
|
+
this.options.failed(`awf: a turn was not recorded in log.jsonl: ${messageOf(error)}`),
|
|
45
45
|
),
|
|
46
46
|
);
|
|
47
47
|
}
|
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.
|
|
@@ -1,27 +1,32 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StageLine, StageNeed } from "@agentswf/contract/records";
|
|
2
2
|
import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
|
|
3
3
|
import type { JsonValue } from "@agentswf/contract/workflow";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What a continue does with a stage it enters: until its start point, each is reused from its
|
|
7
|
-
*
|
|
8
|
-
* point is `--from-stage`, or else the first stage entered with no succeeded
|
|
9
|
-
* `--from-stage`, a stage whose
|
|
10
|
-
* returns nothing is passed, and one with nothing recorded needs a value. Pure: the
|
|
11
|
-
* in, a decision goes out.
|
|
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
12
|
*/
|
|
13
13
|
type StagePlanDecision =
|
|
14
|
-
| { kind: "reuse";
|
|
14
|
+
| { kind: "reuse"; line: StageLine; value: JsonValue | undefined }
|
|
15
15
|
/** A value given, checked by the stage's result; undefined for a stage that returns nothing. */
|
|
16
16
|
| { kind: "provide"; value: JsonValue | undefined }
|
|
17
|
-
/** `start` on the stage that is the start point
|
|
17
|
+
/** `start` on the stage that is the start point. */
|
|
18
18
|
| { kind: "run"; start: boolean }
|
|
19
19
|
| { kind: "stop"; reason: string }
|
|
20
20
|
/** Nothing recorded, and no value given that fits: `--values` lets the attempt go on. */
|
|
21
21
|
| { kind: "need"; reason: string; need: StageNeed };
|
|
22
22
|
|
|
23
23
|
export type StagePlanState = {
|
|
24
|
-
|
|
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[];
|
|
25
30
|
fromStage?: string;
|
|
26
31
|
/** `--values`: each stage's value, to start at `fromStage`. */
|
|
27
32
|
values?: ReadonlyMap<string, JsonValue>;
|
|
@@ -44,20 +49,20 @@ export function planStage(
|
|
|
44
49
|
): StagePlanDecision {
|
|
45
50
|
if (state.started) return { kind: "run", start: false };
|
|
46
51
|
if (stage === state.fromStage) return { kind: "run", start: true };
|
|
47
|
-
const
|
|
48
|
-
const succeeded =
|
|
52
|
+
const { line } = state;
|
|
53
|
+
const succeeded = line?.outcome === "succeeded" ? line : undefined;
|
|
49
54
|
const stale = succeeded && unreusable(state, succeeded, result);
|
|
50
55
|
if (succeeded && stale === undefined) {
|
|
51
|
-
return { kind: "reuse",
|
|
56
|
+
return { kind: "reuse", line: succeeded, value: succeeded.value };
|
|
52
57
|
}
|
|
53
58
|
if (state.fromStage === undefined) {
|
|
54
59
|
return stale === undefined ? { kind: "run", start: true } : { kind: "stop", reason: stale };
|
|
55
60
|
}
|
|
56
|
-
// A
|
|
57
|
-
//
|
|
58
|
-
const redo =
|
|
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 && {
|
|
59
64
|
kind: "stop" as const,
|
|
60
|
-
reason: stale ?? `${stage} did not succeed in attempt ${
|
|
65
|
+
reason: stale ?? `${stage} did not succeed in attempt ${line.attempt}`,
|
|
61
66
|
};
|
|
62
67
|
if (result === undefined) return redo ?? { kind: "provide", value: undefined };
|
|
63
68
|
const need = { stage, schema: result };
|
|
@@ -78,38 +83,37 @@ export function planStage(
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
* Why a succeeded
|
|
82
|
-
*
|
|
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`.
|
|
83
88
|
*/
|
|
84
89
|
function unreusable(
|
|
85
90
|
state: StagePlanState,
|
|
86
|
-
|
|
91
|
+
line: StageLine,
|
|
87
92
|
result: JsonSchema | undefined,
|
|
88
93
|
): string | undefined {
|
|
89
|
-
const
|
|
90
|
-
const recorded = majorOf(record.workflowVersion);
|
|
94
|
+
const recorded = majorOf(state.lineVersion);
|
|
91
95
|
const now = majorOf(state.workflowVersion);
|
|
92
96
|
if (recorded !== undefined && now !== undefined && recorded !== now) {
|
|
93
|
-
return `${
|
|
97
|
+
return `${line.name} was recorded by ${state.lineVersion}; this is ${state.workflowVersion}`;
|
|
94
98
|
}
|
|
95
|
-
return misfitOf(
|
|
99
|
+
return misfitOf(line, result);
|
|
96
100
|
}
|
|
97
101
|
|
|
98
|
-
/** Why a succeeded
|
|
99
|
-
function misfitOf(
|
|
100
|
-
const
|
|
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;
|
|
101
105
|
if (result === undefined) {
|
|
102
|
-
return
|
|
106
|
+
return line.value === undefined
|
|
103
107
|
? undefined
|
|
104
|
-
: `${stage}'s
|
|
108
|
+
: `${stage}'s line holds a value, and the stage no longer has a result`;
|
|
105
109
|
}
|
|
106
|
-
if (
|
|
107
|
-
return `${stage}'s
|
|
110
|
+
if (line.value === undefined) {
|
|
111
|
+
return `${stage}'s line holds no value, and the stage's result now expects one`;
|
|
108
112
|
}
|
|
109
|
-
const errors = validate(result,
|
|
113
|
+
const errors = validate(result, line.value);
|
|
110
114
|
return errors.length === 0
|
|
111
115
|
? undefined
|
|
112
|
-
: problems(`${stage}'s
|
|
116
|
+
: problems(`${stage}'s line no longer fits its result schema:`, errors);
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/** A value's problems under what they are problems of, a line each. */
|
|
@@ -119,13 +123,13 @@ function problems(heading: string, errors: readonly SchemaError[]): string {
|
|
|
119
123
|
|
|
120
124
|
/** Recorded stages this attempt hasn't reached, which a rename in the code would leave behind. */
|
|
121
125
|
function unreached(state: StagePlanState, stage: string): string {
|
|
122
|
-
const others =
|
|
126
|
+
const others = state.recorded.filter(
|
|
123
127
|
(name) => name !== stage && name !== state.fromStage && !state.entered.includes(name),
|
|
124
128
|
);
|
|
125
129
|
return others.length > 0 ? ` (recorded and not reached: ${others.join(", ")})` : "";
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
/** What must match for a
|
|
132
|
+
/** What must match for a line to be reused: the major, or `0.{minor}`, as semver has it. */
|
|
129
133
|
function majorOf(version: string | undefined): string | undefined {
|
|
130
134
|
if (version === undefined) return undefined;
|
|
131
135
|
const [major, minor] = version.split(".");
|
|
@@ -133,18 +137,18 @@ function majorOf(version: string | undefined): string | undefined {
|
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
/**
|
|
136
|
-
* In a run of a workflow with `events`, a stage runs once ever: a succeeded
|
|
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.
|
|
140
144
|
*/
|
|
141
145
|
export function planStageByContent(
|
|
142
|
-
|
|
146
|
+
line: StageLine | undefined,
|
|
143
147
|
result: JsonSchema | undefined,
|
|
144
148
|
): Extract<StagePlanDecision, { kind: "reuse" | "stop" }> | { kind: "run" } {
|
|
145
|
-
if (
|
|
146
|
-
const misfit = misfitOf(
|
|
149
|
+
if (line?.outcome !== "succeeded") return { kind: "run" };
|
|
150
|
+
const misfit = misfitOf(line, result);
|
|
147
151
|
return misfit === undefined
|
|
148
|
-
? { kind: "reuse",
|
|
152
|
+
? { kind: "reuse", line, value: line.value }
|
|
149
153
|
: { kind: "stop", reason: misfit };
|
|
150
154
|
}
|