@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -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 { AttemptRecord, RunLogLine } from "@agentswf/contract/records";
3
- import { maskCredentials } from "@agentswf/harness";
4
- import { continueCommand, shellWord } from "./attempt-view";
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
- /** What `awf runs` and `awf logs` share: where they look, and how they say a run. */
11
-
12
- /** Where the commands that read runs look, and the options their suggested commands repeat. */
13
- export type ReadPlace = { shellCwd: string; runRoot: string; repeat: string[] };
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(error: unknown, place: ReadPlace, out: Out, message: string): number {
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", "90"] : []),
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
- /** At list prices, over the attempts that kept one: never what was billed. */
209
- export function estimateOf(attempts: readonly AttemptRecord[]): number | undefined {
210
- const known = attempts.flatMap((each) =>
211
- each.accounting?.totals.estimate === undefined ? [] : [each.accounting.totals.estimate],
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
- return known.length > 0 ? known.reduce((total, each) => total + each, 0) : undefined;
214
- }
215
-
216
- /** `$6.10`; `<$0.01` for less, and nothing for none. */
217
- export function money(estimate: number | undefined): string[] {
218
- if (!estimate) return [];
219
- return [estimate < 0.005 ? "<$0.01" : `$${estimate.toFixed(2)}`];
220
- }
221
-
222
- export function clockTime(at: number | string): string {
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
  }
@@ -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 turns.jsonl: ${messageOf(error)}`),
44
+ this.options.failed(`awf: a turn was not recorded in log.jsonl: ${messageOf(error)}`),
45
45
  ),
46
46
  );
47
47
  }
@@ -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 the run's mailbox, and each decision made from it, holds it.
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 { StageNeed, StageRecord } from "@agentswf/contract/records";
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
- * record, 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 record. Before a
9
- * `--from-stage`, a stage whose record 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 records, the values, the result and the version come
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"; record: StageRecord; value: JsonValue | undefined }
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, when the records it outdates are moved. */
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
- records: ReadonlyMap<string, StageRecord>;
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 record = state.records.get(stage);
48
- const succeeded = record?.outcome === "succeeded" ? record : undefined;
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", record: succeeded, value: succeeded.value };
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 record there that failed or went stale is redone from its stage, as on any continue, unless
57
- // a value given replaces it.
58
- const redo = record && {
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 ${record.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 record can't be reused, as a stop says it, or undefined when it can: another
82
- * major version recorded it, or its value no longer fits the stage's `result`.
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
- record: StageRecord,
91
+ line: StageLine,
87
92
  result: JsonSchema | undefined,
88
93
  ): string | undefined {
89
- const { stage } = record;
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 `${stage} was recorded by ${record.workflowVersion}; this is ${state.workflowVersion}`;
97
+ return `${line.name} was recorded by ${state.lineVersion}; this is ${state.workflowVersion}`;
94
98
  }
95
- return misfitOf(record, result);
99
+ return misfitOf(line, result);
96
100
  }
97
101
 
98
- /** Why a succeeded record's value no longer fits the stage's `result`; undefined when it does. */
99
- function misfitOf(record: StageRecord, result: JsonSchema | undefined): string | undefined {
100
- const { stage } = record;
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 record.value === undefined
106
+ return line.value === undefined
103
107
  ? undefined
104
- : `${stage}'s record holds a value, and the stage no longer has a result`;
108
+ : `${stage}'s line holds a value, and the stage no longer has a result`;
105
109
  }
106
- if (record.value === undefined) {
107
- return `${stage}'s record holds no value, and the stage's result now expects one`;
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, record.value);
113
+ const errors = validate(result, line.value);
110
114
  return errors.length === 0
111
115
  ? undefined
112
- : problems(`${stage}'s record no longer fits its result schema:`, errors);
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 = [...state.records.keys()].filter(
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 record to be reused: the major, or `0.{minor}`, as semver has it. */
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 record is reused
137
- * wherever and however often its name is entered, whatever version of the code recorded it, as
138
- * long as its value still fits the stage's `result`. One that doesn't fit stops the attempt; a
139
- * failed record, or none, runs.
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
- record: StageRecord | undefined,
146
+ line: StageLine | undefined,
143
147
  result: JsonSchema | undefined,
144
148
  ): Extract<StagePlanDecision, { kind: "reuse" | "stop" }> | { kind: "run" } {
145
- if (record?.outcome !== "succeeded") return { kind: "run" };
146
- const misfit = misfitOf(record, result);
149
+ if (line?.outcome !== "succeeded") return { kind: "run" };
150
+ const misfit = misfitOf(line, result);
147
151
  return misfit === undefined
148
- ? { kind: "reuse", record, value: record.value }
152
+ ? { kind: "reuse", line, value: line.value }
149
153
  : { kind: "stop", reason: misfit };
150
154
  }