@agentswf/engine 0.0.0-stage → 0.1.0-beta.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 (77) 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/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
@@ -0,0 +1,535 @@
1
+ import {
2
+ STAGE_RECORD_VERSION,
3
+ type StageNeed,
4
+ type StageRecord,
5
+ type TurnRecord,
6
+ } from "@agentswf/contract/records";
7
+ import type { JsonSchema } from "@agentswf/contract/schema";
8
+ import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
9
+ import type { RunEventBody } from "./run-event-log";
10
+ import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
11
+ import { planStage } from "./stage-plan";
12
+ import { placeholderOf } from "./stage-schema";
13
+ import { primaryFailure, WorkflowStopped } from "./stopped";
14
+
15
+ /** A stage entered and not yet ended; it ends once, whichever end comes first. */
16
+ export type OpenStage = {
17
+ readonly name: string;
18
+ succeed(value: JsonValue | undefined, summary?: string): Promise<void>;
19
+ stop(reason: string): Promise<void>;
20
+ fail(reason: string): Promise<void>;
21
+ };
22
+
23
+ /**
24
+ * A stage as this attempt entered it, for the view: its summary less its value, its outcome once
25
+ * it has one, and when it began and ended here. A reused stage begins and ends as it is entered.
26
+ */
27
+ export type StageProgress = Omit<StageSummary, "value" | "outcome" | "spanMs"> & {
28
+ outcome?: StageOutcome;
29
+ startedAt: number;
30
+ endedAt?: number;
31
+ /** When a reused stage's record ended, which the view ages. */
32
+ recordedAt?: number;
33
+ };
34
+
35
+ /** A stage without its value, which its stage record keeps. */
36
+ export function withoutValue<Stage extends { value?: JsonValue }>(
37
+ stage: Stage,
38
+ ): Omit<Stage, "value"> {
39
+ const { value: _value, ...rest } = stage;
40
+ return rest;
41
+ }
42
+
43
+ /** What the ledger needs of a stage's options to enter it. */
44
+ export type StageEntry = {
45
+ /** Its `result` as JSON Schema; absent for a stage that returns nothing. */
46
+ result?: JsonSchema;
47
+ /** Its one line for a value given, as its `summary` gives it. */
48
+ summary(value: JsonValue): string | undefined;
49
+ };
50
+
51
+ /** A stage entered: reused from its record or a value given without running, or run. */
52
+ type EnteredStage =
53
+ /** `release` once the value is handed back: until then the stage counts as open. */
54
+ | { kind: "reuse"; value: JsonValue | undefined; release(): void }
55
+ | { kind: "run"; stage: OpenStage };
56
+
57
+ /**
58
+ * One attempt's stages: which are entered, which one is open, and each one's record, written into
59
+ * the run's `stages/` when it ends. A stage is a name entered at most once per attempt, one at a
60
+ * time. Until the attempt's start point each is reused or stops it, as the stage plan says; the
61
+ * rest is the runner's.
62
+ */
63
+ export class StageLedger {
64
+ /** Each stage entered, in order, as it stands. */
65
+ readonly #stages = new Map<string, StageProgress & { value?: JsonValue }>();
66
+ readonly #reused = new Set<string>();
67
+ /** Whether the attempt has reached its start point, after which every stage runs. */
68
+ #started = false;
69
+ readonly #writes = new Set<Promise<void>>();
70
+ #open: OpenStage | undefined;
71
+ /** Set once the workflow's body has ended: what it left running enters no stage. */
72
+ #sealed: string | undefined;
73
+ /** The stop that ended the attempt; a later stop throws it again. */
74
+ #stopped: WorkflowStopped | undefined;
75
+ /** Why the attempt fails once its stop was found caught. */
76
+ #caught: Error | undefined;
77
+ /** Each stage's failure as its work threw it. */
78
+ readonly #failures = new Map<unknown, string>();
79
+ /** The stage still open when the attempt ended. */
80
+ #closedOpen: string | undefined;
81
+ /** The values found missing, in order: once there is one, the attempt only looks on. */
82
+ readonly #missing: { need: StageNeed; reason: string }[] = [];
83
+ /**
84
+ * Stages entered while looking on, apart from `#stages`: the view shows only the first missing,
85
+ * as where the attempt stopped. Kept so one isn't entered twice.
86
+ */
87
+ readonly #looked = new Set<string>();
88
+ #needed: WorkflowStopped | undefined;
89
+
90
+ constructor(
91
+ private readonly options: {
92
+ runDir: string;
93
+ attempt: number;
94
+ workflowVersion?: string;
95
+ /** The attempt's turns as they settled, which name the stage each ran in. */
96
+ turns: () => readonly TurnRecord[];
97
+ /** The run's stage records as the attempt began: what a continue may reuse. */
98
+ records?: ReadonlyMap<string, StageRecord>;
99
+ /** `--from-stage`: the stage the attempt starts at. */
100
+ fromStage?: string;
101
+ /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
102
+ values?: ReadonlyMap<string, JsonValue>;
103
+ /** Writes the run's event log. */
104
+ event?(body: RunEventBody, at?: number): void;
105
+ now?: () => Date;
106
+ },
107
+ ) {}
108
+
109
+ /** A `--from-stage` the attempt never reached: a typo, or a branch not taken. */
110
+ get fromStageUnreached(): string | undefined {
111
+ return this.#started ? undefined : this.options.fromStage;
112
+ }
113
+
114
+ /**
115
+ * The run's stage records as the attempt began, in the order they started. Before the start
116
+ * point nothing runs, so for an attempt that never reached it they are still the run's.
117
+ */
118
+ get recorded(): StageRecord[] {
119
+ return [...(this.options.records?.values() ?? [])].sort((a, b) =>
120
+ a.started.localeCompare(b.started),
121
+ );
122
+ }
123
+
124
+ /** The stop that ended the attempt, even if the workflow caught it. */
125
+ get stopped(): WorkflowStopped | undefined {
126
+ return this.#stopped;
127
+ }
128
+
129
+ /**
130
+ * Each stage entered that has ended, in order: reused, run, or stopped by the plan as it was
131
+ * entered, which ran nothing and has no record.
132
+ */
133
+ get summaries(): StageSummary[] {
134
+ return [...this.#stages.values()].flatMap(
135
+ ({ outcome, startedAt, endedAt, ...stage }): StageSummary[] =>
136
+ outcome === undefined || endedAt === undefined
137
+ ? []
138
+ : [{ ...stage, outcome, spanMs: endedAt - startedAt }],
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Each stage entered, as the view shows it, and those the run recorded that this attempt hasn't
144
+ * entered yet, in the order they first started.
145
+ */
146
+ progress(): { stages: StageProgress[]; upcoming: string[] } {
147
+ const upcoming = this.recorded
148
+ .map((record) => record.stage)
149
+ .filter((stage) => !this.#stages.has(stage));
150
+ const recorded = (stage: string) => this.options.records?.get(stage)?.ended;
151
+ return {
152
+ stages: [...this.#stages.values()].map((entered) => {
153
+ const ended = entered.source === "reused" ? recorded(entered.stage) : undefined;
154
+ const stage = withoutValue(entered);
155
+ return ended === undefined ? stage : { ...stage, recordedAt: Date.parse(ended) };
156
+ }),
157
+ upcoming,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * `workflow.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
163
+ * and any later stop throws it again; once it was found caught, that instead. After the
164
+ * workflow's body ended, a stop ends nothing.
165
+ */
166
+ stop(reason: string, stage: string | undefined): Error {
167
+ if (this.#caught) return this.#caught;
168
+ if (this.#sealed !== undefined) return new WorkflowStopped(reason, stage);
169
+ // Looking on past stand-ins, the workflow's own checks see values no one gave.
170
+ const needed = this.#stopForNeeded();
171
+ if (needed) return needed;
172
+ this.#stopped ??= new WorkflowStopped(reason, stage);
173
+ return this.#stopped;
174
+ }
175
+
176
+ /** The stop was caught and the workflow went on: from now on, that is what fails the attempt. */
177
+ caught(): Error | undefined {
178
+ if (this.#stopped) {
179
+ this.#caught ??= new Error(`stop was caught: ${this.#stopped.reason}`, {
180
+ cause: this.#stopped,
181
+ });
182
+ }
183
+ return this.#caught;
184
+ }
185
+
186
+ /**
187
+ * Enters `name`: reused or provided when the plan says so, or opened to run. A value provided is
188
+ * recorded before it is handed back. Throws why it can't be: a bad name, a second entry, another
189
+ * one open, or a record that stops the attempt. Entered after a stop, the stop was caught, and
190
+ * that fails the attempt. Reaching the start point, it moves the records it outdates to
191
+ * `replaced/`.
192
+ */
193
+ async enter(name: string, entry: StageEntry): Promise<EnteredStage> {
194
+ // Looking on, a stop caught is the one for the values missing, which ends the attempt anyway.
195
+ const caught = this.#missing.length > 0 ? undefined : this.caught();
196
+ if (caught) throw caught;
197
+ this.#check(name);
198
+ const at = this.#now().getTime();
199
+ const decision = planStage(
200
+ {
201
+ records: this.options.records ?? new Map(),
202
+ ...(this.options.fromStage === undefined ? {} : { fromStage: this.options.fromStage }),
203
+ ...(this.options.values === undefined ? {} : { values: this.options.values }),
204
+ ...(this.options.workflowVersion === undefined
205
+ ? {}
206
+ : { workflowVersion: this.options.workflowVersion }),
207
+ started: this.#started,
208
+ entered: [...this.#stages.keys()],
209
+ },
210
+ name,
211
+ entry.result,
212
+ );
213
+ if (this.#missing.length > 0) {
214
+ // Looking on for the other values the start point needs: nothing runs or is recorded on
215
+ // disk, and anything else ends the attempt with what was found.
216
+ this.#looked.add(name);
217
+ if (decision.kind === "need") {
218
+ this.#missing.push({ need: decision.need, reason: decision.reason });
219
+ return this.#hold(name, placeholderOf(decision.need.schema));
220
+ }
221
+ if (decision.kind === "reuse" || decision.kind === "provide") {
222
+ return this.#hold(name, decision.value);
223
+ }
224
+ throw this.#stopForNeeded();
225
+ }
226
+ if (decision.kind === "need") {
227
+ // The attempt stops for it once it has looked on, past a stand-in its schema accepts, for
228
+ // the other values the start point needs.
229
+ this.#missing.push({ need: decision.need, reason: decision.reason });
230
+ this.#stoppedAt(name, at);
231
+ return this.#hold(name, placeholderOf(decision.need.schema));
232
+ }
233
+ if (decision.kind === "stop") {
234
+ this.#stopped = new WorkflowStopped(decision.reason, name, true);
235
+ this.#stoppedAt(name, at);
236
+ throw this.#stopped;
237
+ }
238
+ if (decision.kind === "reuse" || decision.kind === "provide") {
239
+ const held = this.#hold(name, decision.value);
240
+ let record: StageRecord;
241
+ if (decision.kind === "reuse") record = decision.record;
242
+ else {
243
+ const written = this.#provide(name, decision.value, entry, at);
244
+ // Its failure is the stage's, thrown below.
245
+ this.#writes.add(
246
+ written.then(
247
+ () => undefined,
248
+ () => undefined,
249
+ ),
250
+ );
251
+ try {
252
+ record = await written;
253
+ } catch (error) {
254
+ held.release();
255
+ throw error;
256
+ }
257
+ }
258
+ this.#reused.add(name);
259
+ this.options.event?.(
260
+ {
261
+ kind: "stage-ended",
262
+ stage: name,
263
+ outcome: "succeeded",
264
+ source: decision.kind === "reuse" ? "reused" : "provided",
265
+ },
266
+ at,
267
+ );
268
+ const { attempt, summary, provided } = record;
269
+ this.#stages.set(name, {
270
+ stage: name,
271
+ source: "reused",
272
+ outcome: "succeeded",
273
+ attempt,
274
+ startedAt: at,
275
+ endedAt: at,
276
+ ...(summary === undefined ? {} : { summary }),
277
+ ...(decision.value === undefined ? {} : { value: decision.value }),
278
+ ...(provided ? { provided } : {}),
279
+ });
280
+ return held;
281
+ }
282
+ // Open while the records it outdates are moved, so no other stage enters beside it.
283
+ const stage = this.#opened(name, at);
284
+ if (decision.start) {
285
+ try {
286
+ await replaceStale(this.options.runDir, name, this.#reused);
287
+ } catch (error) {
288
+ // Not entered after all: nothing ran, so nothing is recorded, and no stage is left open.
289
+ this.#stages.delete(name);
290
+ this.#open = undefined;
291
+ throw error;
292
+ }
293
+ this.#started = true;
294
+ }
295
+ return { kind: "run", stage };
296
+ }
297
+
298
+ /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
299
+ #stoppedAt(name: string, at: number): void {
300
+ this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
301
+ this.#stages.set(name, {
302
+ stage: name,
303
+ source: "ran",
304
+ outcome: "stopped",
305
+ attempt: this.options.attempt,
306
+ startedAt: at,
307
+ endedAt: at,
308
+ });
309
+ }
310
+
311
+ #now(): Date {
312
+ return this.options.now?.() ?? new Date();
313
+ }
314
+
315
+ /** A stage handed back without running, open until it is, so none is entered beside it. */
316
+ #hold(name: string, value: JsonValue | undefined): Extract<EnteredStage, { kind: "reuse" }> {
317
+ const held: OpenStage = {
318
+ name,
319
+ succeed: async () => undefined,
320
+ stop: async () => undefined,
321
+ fail: async () => undefined,
322
+ };
323
+ this.#open = held;
324
+ return {
325
+ kind: "reuse",
326
+ value,
327
+ release: () => {
328
+ if (this.#open === held) this.#open = undefined;
329
+ },
330
+ };
331
+ }
332
+
333
+ /**
334
+ * The stop for every value the attempt found missing, at the first stage's, in the order
335
+ * reached, which is from now on the attempt's stop; undefined when none was. Its reason is the
336
+ * first's, and any value given that didn't fit.
337
+ */
338
+ #stopForNeeded(): WorkflowStopped | undefined {
339
+ const [first, ...more] = this.#missing;
340
+ if (!first) return undefined;
341
+ // Built again as the look finds more: a stop thrown and caught earlier named fewer.
342
+ if (this.#needed?.needs?.length !== this.#missing.length) {
343
+ const given = more.filter(({ need }) => this.options.values?.has(need.stage));
344
+ this.#needed = new WorkflowStopped(
345
+ [first.reason, ...given.map(({ reason }) => reason)].join("\n"),
346
+ first.need.stage,
347
+ false,
348
+ this.#missing.map(({ need }) => need),
349
+ );
350
+ this.#stopped = this.#needed;
351
+ }
352
+ return this.#needed;
353
+ }
354
+
355
+ /**
356
+ * Before a turn, compaction, decision or sandbox starts: while the attempt looks on for missing
357
+ * values, past stand-ins, none may, and the attempt stops with what it found.
358
+ */
359
+ checkOperation(): void {
360
+ const needed = this.#stopForNeeded();
361
+ if (needed) throw needed;
362
+ }
363
+
364
+ /**
365
+ * What the attempt ends with, given how its body did: past stand-ins, what it went on to do,
366
+ * return or throw, isn't its own, and it stops for the values it found missing, unless it was
367
+ * `interrupted`, cancelled or out of time. Undefined for a body that returned on its own.
368
+ */
369
+ settle(failure: unknown, interrupted: boolean): unknown {
370
+ return (interrupted ? undefined : this.#stopForNeeded()) ?? failure;
371
+ }
372
+
373
+ /** Records a value given for a stage, or none for one that returns nothing: no turn ran it. */
374
+ async #provide(
375
+ name: string,
376
+ value: JsonValue | undefined,
377
+ entry: StageEntry,
378
+ at: number,
379
+ ): Promise<StageRecord> {
380
+ const time = new Date(at).toISOString();
381
+ const summary = value === undefined ? undefined : entry.summary(value);
382
+ const record: StageRecord = {
383
+ version: STAGE_RECORD_VERSION,
384
+ stage: name,
385
+ attempt: this.options.attempt,
386
+ outcome: "succeeded",
387
+ started: time,
388
+ ended: time,
389
+ ...(this.options.workflowVersion === undefined
390
+ ? {}
391
+ : { workflowVersion: this.options.workflowVersion }),
392
+ sessions: [],
393
+ ...(summary === undefined ? {} : { summary }),
394
+ ...(value === undefined ? {} : { value }),
395
+ provided: true,
396
+ };
397
+ // A record it replaces, one that failed or went stale, is kept as a redone stage's is.
398
+ if (this.options.records?.has(name)) await replaceRecord(this.options.runDir, name);
399
+ await writeStageRecord(this.options.runDir, record);
400
+ return record;
401
+ }
402
+
403
+ #check(name: string): void {
404
+ const problem = stageNameProblem(name);
405
+ if (problem) throw new Error(`stage ${problem}`);
406
+ if (this.#sealed !== undefined) {
407
+ throw new Error(`stage ${name} was entered after the workflow ended: ${this.#sealed}`);
408
+ }
409
+ if (this.#open) {
410
+ throw new Error(
411
+ `stage ${name} was entered while stage ${this.#open.name} is open; one stage runs at a time, and parallel work goes inside one`,
412
+ );
413
+ }
414
+ if (this.#stages.has(name) || this.#looked.has(name)) {
415
+ throw new Error(`stage ${name} was entered twice; a loop goes inside one stage`);
416
+ }
417
+ }
418
+
419
+ #opened(name: string, startedAt: number): OpenStage {
420
+ const entered: StageProgress & { value?: JsonValue } = {
421
+ stage: name,
422
+ source: "ran",
423
+ attempt: this.options.attempt,
424
+ startedAt,
425
+ };
426
+ this.#stages.set(name, entered);
427
+ this.options.event?.({ kind: "stage-started", stage: name }, startedAt);
428
+ let ended: Promise<void> | undefined;
429
+ // A record that fails to be written fails the stage with that error, whatever its work did.
430
+ const end = (fields: Pick<StageRecord, "outcome" | "reason" | "summary" | "value">) => {
431
+ if (ended) return ended;
432
+ if (this.#open === stage) this.#open = undefined;
433
+ const at = this.#now();
434
+ const record: StageRecord = {
435
+ version: STAGE_RECORD_VERSION,
436
+ stage: name,
437
+ attempt: this.options.attempt,
438
+ outcome: fields.outcome,
439
+ ...(fields.reason === undefined ? {} : { reason: fields.reason }),
440
+ started: new Date(startedAt).toISOString(),
441
+ ended: at.toISOString(),
442
+ ...(this.options.workflowVersion === undefined
443
+ ? {}
444
+ : { workflowVersion: this.options.workflowVersion }),
445
+ sessions: sessionsOf(this.options.turns(), name),
446
+ ...(fields.summary === undefined ? {} : { summary: fields.summary }),
447
+ ...(fields.value === undefined ? {} : { value: fields.value }),
448
+ };
449
+ Object.assign(entered, {
450
+ outcome: record.outcome,
451
+ endedAt: at.getTime(),
452
+ ...(record.summary === undefined ? {} : { summary: record.summary }),
453
+ ...(record.value === undefined ? {} : { value: record.value }),
454
+ });
455
+ ended = writeStageRecord(this.options.runDir, record).then(
456
+ () => undefined,
457
+ (error: unknown) => {
458
+ entered.outcome = "failed";
459
+ throw error;
460
+ },
461
+ );
462
+ // Its outcome once the record is written: one that is not fails the stage.
463
+ const logEnd = () =>
464
+ this.options.event?.(
465
+ { kind: "stage-ended", stage: name, outcome: entered.outcome ?? record.outcome },
466
+ at.getTime(),
467
+ );
468
+ ended.then(logEnd, logEnd);
469
+ this.#writes.add(ended);
470
+ return ended;
471
+ };
472
+ const stage: OpenStage = {
473
+ name,
474
+ succeed: (value, summary) =>
475
+ end({
476
+ outcome: "succeeded",
477
+ ...(summary === undefined ? {} : { summary }),
478
+ ...(value === undefined ? {} : { value }),
479
+ }),
480
+ stop: (reason) => end({ outcome: "stopped", reason }),
481
+ fail: (reason) => end({ outcome: "failed", reason }),
482
+ };
483
+ this.#open = stage;
484
+ return stage;
485
+ }
486
+
487
+ /** Once the workflow's body has ended, why: no stage is entered after it. */
488
+ seal(reason: string): void {
489
+ if (this.#sealed !== undefined) return;
490
+ this.#sealed = reason;
491
+ // Cancellation settles the stage's own work after the body ended: the stage open now is where.
492
+ if (this.#open && !this.#reused.has(this.#open.name)) this.#closedOpen = this.#open.name;
493
+ }
494
+
495
+ /**
496
+ * Fails the stage still open, once what ran in it has settled, and waits for every record: none
497
+ * is written after the attempt ends.
498
+ */
499
+ async close(): Promise<void> {
500
+ this.seal("the attempt ended");
501
+ const failing = this.#open?.fail(this.#sealed ?? "the attempt ended");
502
+ // Every other write's failure was its stage's, and reported there.
503
+ await Promise.allSettled(this.#writes);
504
+ await failing;
505
+ }
506
+
507
+ /** A stage's failure, as its work threw it: the attempt ends in that stage if it escapes. */
508
+ failedIn(stage: string, error: unknown): void {
509
+ this.#failures.set(error, stage);
510
+ }
511
+
512
+ /**
513
+ * The stage an attempt that didn't complete ended in: the one its failure escaped from, or the
514
+ * one still open when it ended. Undefined when it ended between stages.
515
+ */
516
+ endedIn(failure: unknown): string | undefined {
517
+ const first = primaryFailure(failure);
518
+ if (first instanceof WorkflowStopped) return first.stage;
519
+ return this.#failures.get(first) ?? this.#closedOpen;
520
+ }
521
+ }
522
+
523
+ /** The session each of the stage's turns ran on, once per agent and session. */
524
+ function sessionsOf(turns: readonly TurnRecord[], stage: string): StageRecord["sessions"] {
525
+ const seen = new Map<string, StageRecord["sessions"][number]>();
526
+ for (const turn of turns) {
527
+ const session = turn.sessions.at(-1);
528
+ if (turn.stage !== stage || !session) continue;
529
+ const key = `${turn.agent}\0${session.harness}\0${session.id}`;
530
+ if (!seen.has(key)) {
531
+ seen.set(key, { agent: turn.agent, harness: session.harness, session: session.id });
532
+ }
533
+ }
534
+ return [...seen.values()];
535
+ }
@@ -0,0 +1,127 @@
1
+ import type { StageNeed, StageRecord } from "@agentswf/contract/records";
2
+ import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
3
+ import type { JsonValue } from "@agentswf/contract/workflow";
4
+
5
+ /**
6
+ * What a continue does with a stage it enters: until its start point, each is reused from its
7
+ * 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.
12
+ */
13
+ type StagePlanDecision =
14
+ | { kind: "reuse"; record: StageRecord; value: JsonValue | undefined }
15
+ /** A value given, checked by the stage's result; undefined for a stage that returns nothing. */
16
+ | { kind: "provide"; value: JsonValue | undefined }
17
+ /** `start` on the stage that is the start point, when the records it outdates are moved. */
18
+ | { kind: "run"; start: boolean }
19
+ | { kind: "stop"; reason: string }
20
+ /** Nothing recorded, and no value given that fits: `--values` lets the attempt go on. */
21
+ | { kind: "need"; reason: string; need: StageNeed };
22
+
23
+ export type StagePlanState = {
24
+ records: ReadonlyMap<string, StageRecord>;
25
+ fromStage?: string;
26
+ /** `--values`: each stage's value, to start at `fromStage`. */
27
+ values?: ReadonlyMap<string, JsonValue>;
28
+ /** The workflow's `meta.version` now. */
29
+ workflowVersion?: string;
30
+ /** Whether this attempt has reached its start point. */
31
+ started: boolean;
32
+ /** The stages this attempt has entered so far. */
33
+ entered: readonly string[];
34
+ };
35
+
36
+ /**
37
+ * Decides a stage entered, given its `result` as JSON Schema, absent for a stage that returns
38
+ * nothing. A stop's reason says why; the command that goes on names the stage to redo.
39
+ */
40
+ export function planStage(
41
+ state: StagePlanState,
42
+ stage: string,
43
+ result: JsonSchema | undefined,
44
+ ): StagePlanDecision {
45
+ if (state.started) return { kind: "run", start: false };
46
+ if (stage === state.fromStage) return { kind: "run", start: true };
47
+ const record = state.records.get(stage);
48
+ const succeeded = record?.outcome === "succeeded" ? record : undefined;
49
+ const stale = succeeded && unreusable(state, succeeded, result);
50
+ if (succeeded && stale === undefined) {
51
+ return { kind: "reuse", record: succeeded, value: succeeded.value };
52
+ }
53
+ if (state.fromStage === undefined) {
54
+ return stale === undefined ? { kind: "run", start: true } : { kind: "stop", reason: stale };
55
+ }
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 && {
59
+ kind: "stop" as const,
60
+ reason: stale ?? `${stage} did not succeed in attempt ${record.attempt}`,
61
+ };
62
+ if (result === undefined) return redo ?? { kind: "provide", value: undefined };
63
+ const need = { stage, schema: result };
64
+ const given = state.values?.get(stage);
65
+ if (given !== undefined) {
66
+ const errors = validate(result, given);
67
+ if (errors.length === 0) return { kind: "provide", value: given };
68
+ const misfit = problems(`${stage}'s value in --values does not fit its result:`, errors);
69
+ return redo ? { kind: "stop", reason: misfit } : { kind: "need", reason: misfit, need };
70
+ }
71
+ return (
72
+ redo ?? {
73
+ kind: "need",
74
+ reason: `nothing recorded for ${stage}${unreached(state, stage)}`,
75
+ need,
76
+ }
77
+ );
78
+ }
79
+
80
+ /**
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`.
83
+ */
84
+ function unreusable(
85
+ state: StagePlanState,
86
+ record: StageRecord,
87
+ result: JsonSchema | undefined,
88
+ ): string | undefined {
89
+ const { stage } = record;
90
+ const recorded = majorOf(record.workflowVersion);
91
+ const now = majorOf(state.workflowVersion);
92
+ if (recorded !== undefined && now !== undefined && recorded !== now) {
93
+ return `${stage} was recorded by ${record.workflowVersion}; this is ${state.workflowVersion}`;
94
+ }
95
+ if (result === undefined) {
96
+ return record.value === undefined
97
+ ? undefined
98
+ : `${stage}'s record holds a value, and the stage no longer has a result`;
99
+ }
100
+ if (record.value === undefined) {
101
+ return `${stage}'s record holds no value, and the stage's result now expects one`;
102
+ }
103
+ const errors = validate(result, record.value);
104
+ return errors.length === 0
105
+ ? undefined
106
+ : problems(`${stage}'s record no longer fits its result schema:`, errors);
107
+ }
108
+
109
+ /** A value's problems under what they are problems of, a line each. */
110
+ function problems(heading: string, errors: readonly SchemaError[]): string {
111
+ return [heading, ...errors.map((error) => ` ${error.path}: ${error.message}`)].join("\n");
112
+ }
113
+
114
+ /** Recorded stages this attempt hasn't reached, which a rename in the code would leave behind. */
115
+ function unreached(state: StagePlanState, stage: string): string {
116
+ const others = [...state.records.keys()].filter(
117
+ (name) => name !== stage && name !== state.fromStage && !state.entered.includes(name),
118
+ );
119
+ return others.length > 0 ? ` (recorded and not reached: ${others.join(", ")})` : "";
120
+ }
121
+
122
+ /** What must match for a record to be reused: the major, or `0.{minor}`, as semver has it. */
123
+ function majorOf(version: string | undefined): string | undefined {
124
+ if (version === undefined) return undefined;
125
+ const [major, minor] = version.split(".");
126
+ return major === "0" ? `0.${minor}` : major;
127
+ }