@agentswf/engine 0.0.0-stage → 0.1.0-beta.1

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 (82) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,425 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import type { Activity, RunLogLine } from "@agentswf/contract/records";
4
+ import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
5
+ import { findHarness, harnessState, readActivity } from "@agentswf/harness";
6
+ import { readNumberedEvents } from "./runs";
7
+
8
+ /**
9
+ * A native session the run's log names, numbered by the line that first named it, so a session
10
+ * named later never renumbers the others. Its files are found under its agent's home and working
11
+ * directory: a sandbox's, for an agent in one.
12
+ */
13
+ export type NativeSession = {
14
+ n: number;
15
+ harness: HarnessKind;
16
+ id: string;
17
+ agent?: AgentKey;
18
+ };
19
+
20
+ /** A span of time, open while it has no end. */
21
+ type Span = { from: number; to?: number };
22
+
23
+ /** What the log says of an agent: where its files are, and the spans its activity is the run's. */
24
+ export type RunAgent = {
25
+ key: AgentKey;
26
+ cwd?: string;
27
+ home?: string;
28
+ caller: boolean;
29
+ operations: (Span & { id: string })[];
30
+ /**
31
+ * Each time the run held it: from its opening, its fork, or, in a log an earlier awf wrote, its
32
+ * first delivery, to its stop or its attempt's last line.
33
+ */
34
+ windows: Span[];
35
+ };
36
+
37
+ export type RunLog = {
38
+ lines: { number: number; line: RunLogLine }[];
39
+ sessions: NativeSession[];
40
+ agents: Map<AgentKey, RunAgent>;
41
+ };
42
+
43
+ /** `ended`: no attempt is running, so a span still open on the calling session ended with it. */
44
+ export async function readRunLog(
45
+ runDir: string,
46
+ options: { ended?: boolean } = {},
47
+ ): Promise<RunLog> {
48
+ const lines = await readNumberedEvents(runDir);
49
+ const sessions: NativeSession[] = [];
50
+ const agents = new Map<AgentKey, RunAgent>();
51
+ const agentOf = new Map<string, AgentKey>();
52
+ const agent = (key: AgentKey) => {
53
+ const found = agents.get(key) ?? { key, caller: false, operations: [], windows: [] };
54
+ agents.set(key, found);
55
+ return found;
56
+ };
57
+ const open = (held: RunAgent, from: number) => {
58
+ if (held.windows.at(-1)?.to === undefined && held.windows.length > 0) return;
59
+ held.windows.push({ from });
60
+ };
61
+ const name = (harness: HarnessKind, id: string, key?: AgentKey) => {
62
+ const known = sessions.find((each) => each.harness === harness && each.id === id);
63
+ if (known) {
64
+ if (key && !known.agent) known.agent = key;
65
+ return;
66
+ }
67
+ sessions.push({ n: sessions.length + 1, harness, id, ...(key ? { agent: key } : {}) });
68
+ };
69
+ // An attempt that died left its spans open. An agent's end where the next attempt begins; the
70
+ // calling session's at the dead attempt's last line, past which its file is the operator's.
71
+ let attempt: number | undefined;
72
+ let lastAt: number | undefined;
73
+ const closeAll = (at: number, callerAt = at) => {
74
+ for (const held of agents.values()) {
75
+ const end = held.caller ? callerAt : at;
76
+ for (const span of [...held.windows, ...held.operations]) span.to ??= end;
77
+ }
78
+ };
79
+ for (const { line } of lines) {
80
+ const at = Date.parse(timeOf(line));
81
+ if (attempt !== undefined && line.attempt !== attempt && lastAt !== undefined) {
82
+ closeAll(at, lastAt);
83
+ }
84
+ attempt = line.attempt;
85
+ if (!Number.isNaN(at)) lastAt = at;
86
+ switch (line.kind) {
87
+ case "agent-opened": {
88
+ const opened = agent(line.agent);
89
+ opened.cwd = line.cwd;
90
+ if (line.home !== undefined) opened.home = line.home;
91
+ if (line.caller) opened.caller = true;
92
+ else open(opened, at);
93
+ break;
94
+ }
95
+ case "agent-forked":
96
+ open(agent(line.agent), at);
97
+ break;
98
+ case "agent-stopped": {
99
+ const window = agent(line.agent).windows.at(-1);
100
+ if (window && window.to === undefined) window.to = at;
101
+ break;
102
+ }
103
+ case "operation-started": {
104
+ const started = agent(line.agent);
105
+ // Its own operations say so too, should its opening line be lost.
106
+ if (line.execution.caller) started.caller = true;
107
+ const from = Date.parse(line.deliveredAt ?? line.at);
108
+ if (!started.caller) open(started, from);
109
+ started.operations.push({ id: line.operationId, from });
110
+ agentOf.set(line.operationId, line.agent);
111
+ for (const session of line.sessions) name(session.harness, session.id, line.agent);
112
+ break;
113
+ }
114
+ case "session-known": {
115
+ const key = line.agent ?? (line.operationId ? agentOf.get(line.operationId) : undefined);
116
+ name(line.session.harness, line.session.id, key);
117
+ break;
118
+ }
119
+ case "turn":
120
+ case "compact": {
121
+ const settled = agent(line.agent);
122
+ if (line.execution.caller) settled.caller = true;
123
+ const to = line.settledAt ? Date.parse(line.settledAt) : undefined;
124
+ const started = settled.operations.find((each) => each.id === line.operationId);
125
+ // A line an earlier awf wrote has no start before it: its span is its own times.
126
+ if (started) started.to = to ?? at;
127
+ else if (line.deliveredAt) {
128
+ const from = Date.parse(line.deliveredAt);
129
+ if (!settled.caller) open(settled, from);
130
+ settled.operations.push({ id: line.operationId, from, to: to ?? at });
131
+ }
132
+ for (const session of line.sessions) name(session.harness, session.id, line.agent);
133
+ break;
134
+ }
135
+ case "operation-failed": {
136
+ const failed = agents
137
+ .get(line.agent)
138
+ ?.operations.find((each) => each.id === line.operationId);
139
+ if (failed) failed.to = at;
140
+ break;
141
+ }
142
+ case "attempt-ended":
143
+ closeAll(at);
144
+ break;
145
+ }
146
+ }
147
+ if (options.ended && lastAt !== undefined) closeAll(Number.POSITIVE_INFINITY, lastAt);
148
+ return { lines, sessions, agents };
149
+ }
150
+
151
+ const within = (span: Span, at: number) =>
152
+ span.from <= at && (span.to === undefined || at <= span.to);
153
+
154
+ /** The operation an agent's activity at `at` belongs to: null between them, undefined outside the run. */
155
+ function operationAt(agent: RunAgent, at: number): string | null | undefined {
156
+ const inside = agent.operations.find((each) => within(each, at));
157
+ if (inside) return inside.id;
158
+ // The calling session's file is the operator's own conversation outside the run's operations.
159
+ if (agent.caller) return undefined;
160
+ return agent.windows.some((each) => within(each, at)) ? null : undefined;
161
+ }
162
+
163
+ /**
164
+ * A session's files as found, kept between the reads of one command: finding them can list a
165
+ * harness's whole session store. Found again soon when none were, as a session's file can come
166
+ * after its name, and now and then after, for the files a session adds as it runs.
167
+ */
168
+ export type FileCache = Map<number, { files: SessionFile[]; at: number }>;
169
+
170
+ const REFIND_MS = 30_000;
171
+ const REFIND_NONE_MS = 5_000;
172
+
173
+ /** Without a cursor, a native file is read from this far before its end: a read shows its last events. */
174
+ const TAIL_BYTES = 4 * 1024 * 1024;
175
+
176
+ /** With a cursor, how far one read goes on in a native file: a page needs little of it. */
177
+ const READ_BYTES = 4 * 1024 * 1024;
178
+
179
+ /** One native file of a session, by a key its name gives, so a late file renumbers nothing. */
180
+ export type SessionFile = { session: NativeSession; key: string; path: string };
181
+
182
+ /** A session's files, the first its own and keyed `""`; none where they cannot be found. */
183
+ async function sessionFiles(
184
+ session: NativeSession,
185
+ agent: RunAgent | undefined,
186
+ runCwd: string,
187
+ ): Promise<SessionFile[]> {
188
+ const spec = findHarness(session.harness);
189
+ if (!spec?.sessionFiles || !spec.activityRow) return [];
190
+ const home =
191
+ agent?.home ?? harnessState()[session.harness as keyof ReturnType<typeof harnessState>];
192
+ if (!home) return [];
193
+ const found = await spec
194
+ .sessionFiles(home, session.id, agent?.cwd ?? runCwd)
195
+ .catch(() => undefined);
196
+ // A codex fork's files name the sessions it was forked from after its own, whose rows are
197
+ // theirs; a session's own files, a claude subagent's too, carry its id in their path.
198
+ const own = (found ?? []).filter(
199
+ (relative, index) => index === 0 || relative.includes(session.id),
200
+ );
201
+ return own.map((relative, index) => ({
202
+ session,
203
+ key: index === 0 ? "" : createHash("sha256").update(relative).digest("hex").slice(0, 12),
204
+ path: join(home, relative),
205
+ }));
206
+ }
207
+
208
+ /**
209
+ * Where a read stopped: the event log's last line number, and each native file's byte offset.
210
+ * Opaque to its holder, who copies it back; versioned, so a later awf can refuse an old one.
211
+ */
212
+ export type Cursor = { log: number; files: Record<string, number> };
213
+
214
+ export function encodeCursor(cursor: Cursor): string {
215
+ return `c1.${Buffer.from(JSON.stringify([cursor.log, cursor.files])).toString("base64url")}`;
216
+ }
217
+
218
+ export function decodeCursor(text: string): Cursor | undefined {
219
+ if (!text.startsWith("c1.")) return undefined;
220
+ try {
221
+ const [log, files] = JSON.parse(Buffer.from(text.slice(3), "base64url").toString("utf8"));
222
+ if (!Number.isSafeInteger(log) || log < 0 || typeof files !== "object" || files === null) {
223
+ return undefined;
224
+ }
225
+ const offsets = Object.entries(files as Record<string, unknown>);
226
+ if (!offsets.every(([, offset]) => Number.isSafeInteger(offset) && (offset as number) >= 0)) {
227
+ return undefined;
228
+ }
229
+ return { log, files: Object.fromEntries(offsets) as Record<string, number> };
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ }
234
+
235
+ /** A file's name in cursors and handles: its session's number, and its key when not the first. */
236
+ function fileName(file: Pick<SessionFile, "session" | "key">): string {
237
+ return `a${file.session.n}${file.key ? `-${file.key}` : ""}`;
238
+ }
239
+
240
+ /** An event's handle: its line in the log, or its file and its place in that file. */
241
+ export type Handle =
242
+ | { kind: "event"; line: number }
243
+ | { kind: "activity"; file: string; offset: number; index: number };
244
+
245
+ export function encodeHandle(handle: Handle): string {
246
+ if (handle.kind === "event") return `e${handle.line}`;
247
+ const index = handle.index > 0 ? `.${handle.index}` : "";
248
+ return `${handle.file}.${handle.offset.toString(16)}${index}`;
249
+ }
250
+
251
+ export function decodeHandle(text: string): Handle | undefined {
252
+ const plain = text.startsWith("@") ? text.slice(1) : text;
253
+ const event = /^e([1-9]\d*)$/.exec(plain);
254
+ if (event) return { kind: "event", line: Number(event[1]) };
255
+ const activity = /^(a[1-9]\d*(?:-[0-9a-f]{12})?)\.([0-9a-f]+)(?:\.([1-9]\d*))?$/.exec(plain);
256
+ if (!activity) return undefined;
257
+ return {
258
+ kind: "activity",
259
+ file: activity[1]!,
260
+ offset: Number.parseInt(activity[2]!, 16),
261
+ index: activity[3] ? Number(activity[3]) : 0,
262
+ };
263
+ }
264
+
265
+ /** One event of a read: a line of the log, or an agent's activity, with its handle. */
266
+ export type ReadEvent = {
267
+ handle: string;
268
+ at: string;
269
+ /** Sorts by time, then by where it was read. */
270
+ time: number;
271
+ } & (
272
+ | { source: "log"; line: RunLogLine }
273
+ | {
274
+ source: "activity";
275
+ agent?: AgentKey;
276
+ operation: string | null;
277
+ activity: Activity;
278
+ text?: string;
279
+ }
280
+ );
281
+
282
+ /**
283
+ * Everything after `cursor`: the log's lines and each session's activity that is the run's, merged
284
+ * by time. The cursor returned is where this read stopped, past nothing torn.
285
+ */
286
+ export async function readRun(
287
+ runDir: string,
288
+ runCwd: string,
289
+ cursor: Cursor = { log: 0, files: {} },
290
+ options: {
291
+ full?: boolean;
292
+ ended?: boolean;
293
+ files?: FileCache;
294
+ /** Reads only this native file's first line from the cursor, by its name in handles; `""` reads none. */
295
+ only?: string;
296
+ /** A read of the newest: each file not in the cursor from its tail, `cut` where it had more. */
297
+ tail?: boolean;
298
+ /** The command's clock, for when to look for files again. */
299
+ now?: number;
300
+ /** With a cursor, at most this many lines of each native file: a page needs few. */
301
+ lines?: number;
302
+ } = {},
303
+ ): Promise<{ events: ReadEvent[]; cursor: Cursor; log: RunLog; cut: boolean; more: boolean }> {
304
+ const log = await readRunLog(runDir, options);
305
+ const cache = options.files ?? new Map();
306
+ const now = options.now ?? Date.now();
307
+ let cut = false;
308
+ // Where the files that stopped short stopped: what comes after it is left for the next read.
309
+ let horizon = Number.POSITIVE_INFINITY;
310
+ const events: ReadEvent[] = [];
311
+ let last = cursor.log;
312
+ // The log's lines keep their order among the activity: a turn line's time is its settling,
313
+ // which can come before the line written ahead of it.
314
+ let after = Number.NEGATIVE_INFINITY;
315
+ for (const { number, line } of log.lines) {
316
+ if (number <= cursor.log) continue;
317
+ last = number;
318
+ const at = timeOf(line);
319
+ after = Math.max(after, Date.parse(at) || after);
320
+ events.push({
321
+ handle: encodeHandle({ kind: "event", line: number }),
322
+ at,
323
+ time: after,
324
+ source: "log",
325
+ line,
326
+ });
327
+ }
328
+ const files = { ...cursor.files };
329
+ // A pane's pi names its session twice, by id and by path: one file, read once.
330
+ const seen = new Set<string>();
331
+ for (const session of log.sessions) {
332
+ const agent = session.agent ? log.agents.get(session.agent) : undefined;
333
+ const only = options.only;
334
+ if (only !== undefined && only !== `a${session.n}` && !only.startsWith(`a${session.n}-`)) {
335
+ continue;
336
+ }
337
+ let found = cache.get(session.n);
338
+ const stale = found && now - found.at >= (found.files.length ? REFIND_MS : REFIND_NONE_MS);
339
+ if (!found || stale) {
340
+ found = { files: await sessionFiles(session, agent, runCwd), at: now };
341
+ cache.set(session.n, found);
342
+ }
343
+ for (const file of found.files) {
344
+ const name = fileName(file);
345
+ if (options.only !== undefined && name !== options.only) continue;
346
+ if (seen.has(file.path)) continue;
347
+ seen.add(file.path);
348
+ const bounded = options.only === undefined && !(options.tail && files[name] === undefined);
349
+ const readFrom = (from: number) =>
350
+ readActivity(session.harness, file.path, from, {
351
+ ...(options.full ? { full: true } : {}),
352
+ ...(options.tail && files[name] === undefined ? { tail: TAIL_BYTES } : {}),
353
+ // One file read for one handle: its line is the first.
354
+ ...(options.only !== undefined ? { lines: 1 } : {}),
355
+ ...(bounded
356
+ ? { bytes: READ_BYTES, ...(options.lines ? { lines: options.lines } : {}) }
357
+ : {}),
358
+ });
359
+ let read = await readFrom(files[name] ?? 0);
360
+ // A stretch with no row of the run's, untimed or outside its windows, as the operator's own
361
+ // conversation in a calling session's file, shows nothing: read on past it.
362
+ const ours = (row: { at: string }) => {
363
+ const time = Date.parse(row.at);
364
+ return !Number.isNaN(time) && agent !== undefined && operationAt(agent, time) !== undefined;
365
+ };
366
+ while (bounded && read.more && !read.rows.some(ours)) read = await readFrom(read.offset);
367
+ if (read.cut) cut = true;
368
+ if (bounded && read.more) {
369
+ let reached = Number.NEGATIVE_INFINITY;
370
+ for (const row of read.rows) reached = Math.max(reached, Date.parse(row.at) || reached);
371
+ horizon = Math.min(horizon, reached);
372
+ }
373
+ files[name] = read.offset;
374
+ for (const row of read.rows) {
375
+ const time = Date.parse(row.at);
376
+ const operation = agent ? operationAt(agent, time) : undefined;
377
+ if (operation === undefined) continue;
378
+ events.push({
379
+ handle: encodeHandle({
380
+ kind: "activity",
381
+ file: name,
382
+ offset: row.offset,
383
+ index: row.index,
384
+ }),
385
+ at: row.at,
386
+ time,
387
+ source: "activity",
388
+ ...(session.agent ? { agent: session.agent } : {}),
389
+ operation,
390
+ activity: row.activity,
391
+ ...(row.text === undefined ? {} : { text: row.text }),
392
+ });
393
+ }
394
+ }
395
+ }
396
+ events.sort((a, b) => a.time - b.time);
397
+ const more = horizon < Number.POSITIVE_INFINITY;
398
+ const beyond = events.findIndex((event) => event.time > horizon);
399
+ if (beyond < 0) return { events, cursor: { log: last, files }, log, cut, more };
400
+ const next = cutCursor(events, beyond, { log: last, files });
401
+ return { events: events.slice(0, beyond), cursor: next, log, cut, more };
402
+ }
403
+
404
+ /**
405
+ * Where a page cut before `cut` leaves off: before every event it did not show. Events are in
406
+ * time order and the log's lines in line order among them, so nothing shown is read again.
407
+ */
408
+ export function cutCursor(all: readonly ReadEvent[], cut: number, end: Cursor): Cursor {
409
+ const next: Cursor = { log: end.log, files: { ...end.files } };
410
+ for (const event of all.slice(cut)) {
411
+ const handle = decodeHandle(event.handle)!;
412
+ if (handle.kind === "event") next.log = Math.min(next.log, handle.line - 1);
413
+ else
414
+ next.files[handle.file] = Math.min(next.files[handle.file] ?? handle.offset, handle.offset);
415
+ }
416
+ return next;
417
+ }
418
+
419
+ /** When a line happened: an event's time, or a turn's settling. */
420
+ export function timeOf(line: RunLogLine): string {
421
+ if ("at" in line && typeof line.at === "string") return line.at;
422
+ return (
423
+ ("settledAt" in line && line.settledAt) || ("deliveredAt" in line && line.deliveredAt) || ""
424
+ );
425
+ }
@@ -0,0 +1,120 @@
1
+ import { join } from "node:path";
2
+ import type { AttemptRecord } from "@agentswf/contract/records";
3
+ import type { AttemptOutcome } from "@agentswf/contract/workflow";
4
+ import { exists } from "./files";
5
+ import {
6
+ entries,
7
+ idProblem,
8
+ openRun,
9
+ probeProcess,
10
+ type Run,
11
+ readAttempts,
12
+ sameStart,
13
+ } from "./runs";
14
+
15
+ /** What `ps` says of a process: its start, `gone`, or `unknown`. */
16
+ export type ProcessStatus = (pid: number) => string | "gone" | "unknown";
17
+
18
+ /**
19
+ * A run as `awf runs` and `awf logs` see it: its last attempt still open and its process there,
20
+ * `running`; open and its process gone, `interrupted`; or how its last attempt ended. Read from
21
+ * `run.json` and the attempt files, never the event log, so a run an older awf wrote reads too.
22
+ */
23
+ export type RunState = "running" | "interrupted" | AttemptOutcome;
24
+
25
+ export type RunStatus = {
26
+ run: Run;
27
+ /** `{workflow}/{id}`. */
28
+ ref: string;
29
+ attempts: AttemptRecord[];
30
+ state: RunState;
31
+ /** False for a `running` whose process could not be asked about. */
32
+ checked: boolean;
33
+ };
34
+
35
+ /** How long a run folder with no attempt yet may be an `awf run` still starting. */
36
+ const STARTING_MS = 60_000;
37
+
38
+ export async function runStatus(
39
+ run: Run,
40
+ probe: ProcessStatus = probeProcess,
41
+ at = Date.now(),
42
+ ): Promise<RunStatus> {
43
+ const attempts = await readAttempts(run);
44
+ const last = attempts.at(-1);
45
+ const ref = `${run.record.workflow}/${run.record.id}`;
46
+ // Between its folder and its first attempt's claim: an `awf run` starting, or, past a minute,
47
+ // one refused or killed before it claimed one.
48
+ if (!last) {
49
+ const starting = at - Date.parse(run.record.created) < STARTING_MS;
50
+ return { run, ref, attempts, state: starting ? "running" : "interrupted", checked: !starting };
51
+ }
52
+ if (last.outcome !== undefined) return { run, ref, attempts, state: last.outcome, checked: true };
53
+ const now = probe(last.pid);
54
+ if (now === "unknown") return { run, ref, attempts, state: "running", checked: false };
55
+ const same = now !== "gone" && sameStart(now, last.processStart);
56
+ return { run, ref, attempts, state: same ? "running" : "interrupted", checked: true };
57
+ }
58
+
59
+ const ALSO_RUNNING_CHECKED = 20;
60
+
61
+ /** A reference the commands take, or why there is none to read. */
62
+ export class RunNotFound extends Error {}
63
+
64
+ /**
65
+ * Every run under `runRoot`, newest `created` first. A folder whose `run.json` cannot be read is
66
+ * not a run this awf can show, and is passed over.
67
+ */
68
+ export async function listRuns(runRoot: string): Promise<Run[]> {
69
+ const runs: Run[] = [];
70
+ for (const workflow of await entries(runRoot).catch(() => [])) {
71
+ for (const id of await entries(join(runRoot, workflow)).catch(() => [])) {
72
+ const run = await openRun(runRoot, workflow, id).catch(() => undefined);
73
+ if (run) runs.push(run);
74
+ }
75
+ }
76
+ return runs.sort((a, b) => b.record.created.localeCompare(a.record.created));
77
+ }
78
+
79
+ /**
80
+ * The run `ref` names, `{workflow}/{id}` or an `{id}` only one workflow here has; with none, the
81
+ * newest here, and whichever others are still running beside it.
82
+ */
83
+ export async function findRun(
84
+ runRoot: string,
85
+ ref: string | undefined,
86
+ probe: ProcessStatus = probeProcess,
87
+ ): Promise<{ status: RunStatus; alsoRunning: string[] }> {
88
+ if (ref === undefined) {
89
+ const [newest, ...older] = await listRuns(runRoot);
90
+ if (!newest) throw new RunNotFound(`no runs under ${runRoot}`);
91
+ const alsoRunning: string[] = [];
92
+ // The newest few: each open attempt is a `ps`, and a run root is never pruned.
93
+ for (const run of older.slice(0, ALSO_RUNNING_CHECKED)) {
94
+ // One that cannot be read is no reason to fail a read of another.
95
+ const status = await runStatus(run, probe).catch(() => undefined);
96
+ if (status?.state === "running") alsoRunning.push(status.ref);
97
+ }
98
+ return { status: await runStatus(newest, probe), alsoRunning };
99
+ }
100
+ const slash = ref.indexOf("/");
101
+ if (slash >= 0) {
102
+ const [workflow, id] = [ref.slice(0, slash), ref.slice(slash + 1)];
103
+ const problem = idProblem(workflow) ?? idProblem(id);
104
+ if (problem) throw new RunNotFound(`no run ${ref}: ${problem}`);
105
+ // A record that is there but cannot be read is not a missing run, and says why itself.
106
+ if (!(await exists(join(runRoot, workflow, id, "run.json")))) {
107
+ const missing = await openRun(runRoot, workflow, id).catch((error: unknown) => error);
108
+ throw new RunNotFound(missing instanceof Error ? missing.message : `no run ${ref}`);
109
+ }
110
+ const run = await openRun(runRoot, workflow, id);
111
+ return { status: await runStatus(run, probe), alsoRunning: [] };
112
+ }
113
+ const found = (await listRuns(runRoot)).filter((run) => run.record.id === ref);
114
+ if (found.length === 0) throw new RunNotFound(`no run ${ref} under ${runRoot}`);
115
+ if (found.length > 1) {
116
+ const refs = found.map((run) => `${run.record.workflow}/${ref}`).join(", ");
117
+ throw new RunNotFound(`${ref} names a run of more than one workflow: ${refs}`);
118
+ }
119
+ return { status: await runStatus(found[0]!, probe), alsoRunning: [] };
120
+ }