@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,14 +1,11 @@
1
- import {
2
- STAGE_RECORD_VERSION,
3
- type StageNeed,
4
- type StageRecord,
5
- type TurnRecord,
6
- } from "@agentswf/contract/records";
1
+ import type { RunView, StageLine, StageNeed } from "@agentswf/contract/records";
7
2
  import type { JsonSchema } from "@agentswf/contract/schema";
8
3
  import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
9
4
  import type { RunEventBody } from "./run-event-log";
10
- import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
11
- import { planStage, planStageByContent } from "./stage-plan";
5
+ import type { RunLines } from "./run-lines";
6
+ import { planStage, planStageByContent } from "./run-policy/stage-plan";
7
+ import { recordedStages, StageReuse, upcomingStages } from "./run-policy/stages";
8
+ import { stageNameProblem } from "./runs";
12
9
  import { placeholderOf } from "./stage-schema";
13
10
  import { primaryFailure, WorkflowStopped } from "./stopped";
14
11
 
@@ -28,11 +25,11 @@ export type StageProgress = Omit<StageSummary, "value" | "outcome" | "spanMs"> &
28
25
  outcome?: StageOutcome;
29
26
  startedAt: number;
30
27
  endedAt?: number;
31
- /** When a reused stage's record ended, which the view ages. */
28
+ /** When a reused stage's line was written, which the view ages. */
32
29
  recordedAt?: number;
33
30
  };
34
31
 
35
- /** A stage without its value, which its stage record keeps. */
32
+ /** A stage without its value, which its stage line keeps. */
36
33
  export function withoutValue<Stage extends { value?: JsonValue }>(
37
34
  stage: Stage,
38
35
  ): Omit<Stage, "value"> {
@@ -48,22 +45,24 @@ export type StageEntry = {
48
45
  summary(value: JsonValue): string | undefined;
49
46
  };
50
47
 
51
- /** A stage entered: reused from its record or a value given without running, or run. */
48
+ /** A stage entered: reused from its line or a value given without running, or run. */
52
49
  type EnteredStage =
53
50
  /** `release` once the value is handed back: until then the stage counts as open. */
54
51
  | { kind: "reuse"; value: JsonValue | undefined; release(): void }
55
52
  | { kind: "run"; stage: OpenStage };
56
53
 
57
54
  /**
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.
55
+ * One attempt's stages: which are entered, which one is open, and each one's end, a `stage` line of
56
+ * `run.jsonl`. A stage is a name entered at most once per attempt, one at a time. Until the
57
+ * attempt's start point each is reused or stops it, as the stage plan says; the rest is the
58
+ * runner's.
62
59
  */
63
60
  export class StageLedger {
64
61
  /** Each stage entered, in order, as it stands. */
65
62
  readonly #stages = new Map<string, StageProgress & { value?: JsonValue }>();
66
63
  readonly #reused = new Set<string>();
64
+ /** The line each stage reused from an earlier attempt was reused from. */
65
+ readonly #reusedLines = new Map<string, StageLine>();
67
66
  /** Whether the attempt has reached its start point, after which every stage runs. */
68
67
  #started = false;
69
68
  readonly #writes = new Set<Promise<void>>();
@@ -86,25 +85,30 @@ export class StageLedger {
86
85
  */
87
86
  readonly #looked = new Set<string>();
88
87
  #needed: WorkflowStopped | undefined;
89
- /** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
90
- readonly #records: Map<string, StageRecord>;
88
+ /** What each stage entered may reuse, this attempt's own lines included. */
89
+ readonly #reuse: StageReuse;
90
+ /** The run's stages as the attempt began, each by its latest line, in the order they started. */
91
+ readonly #recorded: StageLine[];
92
+ /** Of those, the ones a continue may still come to: none the last redo left behind. */
93
+ readonly #upcoming: string[];
94
+ readonly #attempt: number;
91
95
 
92
96
  constructor(
93
97
  private readonly options: {
94
- runDir: string;
95
- attempt: number;
98
+ /** The attempt's writer on `run.jsonl`, which each stage's end is a line of. */
99
+ lines: Pick<RunLines, "attempt" | "append">;
100
+ /** `run.jsonl` as the attempt began: what a continue may reuse, and the last redo. */
101
+ run?: Pick<RunView, "stages" | "redo">;
102
+ /** Each earlier attempt's `meta.version`, which a line it wrote was written under. */
103
+ versions?: ReadonlyMap<number, string>;
96
104
  workflowVersion?: string;
97
- /** The attempt's turns as they settled, which name the stage each ran in. */
98
- turns: () => readonly TurnRecord[];
99
- /** The run's stage records as the attempt began: what a continue may reuse. */
100
- records?: ReadonlyMap<string, StageRecord>;
101
105
  /** `--from-stage`: the stage the attempt starts at. */
102
106
  fromStage?: string;
103
- /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
107
+ /** `--values`: the values of stages before `fromStage` that have no line to reuse. */
104
108
  values?: ReadonlyMap<string, JsonValue>;
105
109
  /**
106
110
  * A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
107
- * as `planStageByContent` says; no start point, and no `fromStage` or `values`.
111
+ * as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
108
112
  */
109
113
  byContent?: boolean;
110
114
  /** Writes the run's event log. */
@@ -112,7 +116,11 @@ export class StageLedger {
112
116
  now?: () => Date;
113
117
  },
114
118
  ) {
115
- this.#records = new Map(options.records);
119
+ const run = options.run ?? { stages: {} };
120
+ this.#attempt = options.lines.attempt;
121
+ this.#reuse = new StageReuse(run, this.#attempt);
122
+ this.#recorded = recordedStages(run);
123
+ this.#upcoming = upcomingStages(run).map((line) => line.name);
116
124
  }
117
125
 
118
126
  /** The stage open now, if one is. */
@@ -126,13 +134,12 @@ export class StageLedger {
126
134
  }
127
135
 
128
136
  /**
129
- * The run's stage records as the attempt began, in the order they started. Before the start
130
- * point nothing runs, so for an attempt that never reached it they are still the run's.
137
+ * The run's stages as the attempt began, each by its latest line, in the order they started.
138
+ * Before the start point nothing runs, so for an attempt that never reached it they are still the
139
+ * run's.
131
140
  */
132
- get recorded(): StageRecord[] {
133
- return [...(this.options.records?.values() ?? [])].sort((a, b) =>
134
- a.started.localeCompare(b.started),
135
- );
141
+ get recorded(): StageLine[] {
142
+ return this.#recorded;
136
143
  }
137
144
 
138
145
  /** The stop that ended the attempt, even if the workflow caught it. */
@@ -142,7 +149,7 @@ export class StageLedger {
142
149
 
143
150
  /**
144
151
  * Each stage entered that has ended, in order: reused, run, or stopped by the plan as it was
145
- * entered, which ran nothing and has no record.
152
+ * entered, which ran nothing and wrote no line.
146
153
  */
147
154
  get summaries(): StageSummary[] {
148
155
  return [...this.#stages.values()].flatMap(
@@ -161,11 +168,11 @@ export class StageLedger {
161
168
  // By content, an earlier event's stage may never be entered again: none is upcoming.
162
169
  const upcoming = this.options.byContent
163
170
  ? []
164
- : this.recorded.map((record) => record.stage).filter((stage) => !this.#stages.has(stage));
165
- const recorded = (stage: string) => this.options.records?.get(stage)?.ended;
171
+ : this.#upcoming.filter((stage) => !this.#stages.has(stage));
166
172
  return {
167
173
  stages: [...this.#stages.values()].map((entered) => {
168
- const ended = entered.source === "reused" ? recorded(entered.stage) : undefined;
174
+ const ended =
175
+ entered.source === "reused" ? this.#reusedLines.get(entered.stage)?.at : undefined;
169
176
  const stage = withoutValue(entered);
170
177
  return ended === undefined ? stage : { ...stage, recordedAt: Date.parse(ended) };
171
178
  }),
@@ -200,10 +207,9 @@ export class StageLedger {
200
207
 
201
208
  /**
202
209
  * Enters `name`: reused or provided when the plan says so, or opened to run. A value provided is
203
- * recorded before it is handed back. Throws why it can't be: a bad name, a second entry, another
204
- * one open, or a record that stops the attempt. Entered after a stop, the stop was caught, and
205
- * that fails the attempt. Reaching the start point, it moves the records it outdates to
206
- * `replaced/`.
210
+ * written as its line before it is handed back. Throws why it can't be: a bad name, a second
211
+ * entry, another one open, or a line that stops the attempt. Entered after a stop, the stop was
212
+ * caught, and that fails the attempt.
207
213
  */
208
214
  async enter(name: string, entry: StageEntry): Promise<EnteredStage> {
209
215
  // Looking on, a stop caught is the one for the values missing, which ends the attempt anyway.
@@ -212,9 +218,13 @@ export class StageLedger {
212
218
  this.#check(name);
213
219
  const at = this.#now().getTime();
214
220
  if (this.options.byContent) return this.#enterByContent(name, entry, at);
221
+ const line = this.#reuse.enter(name);
222
+ const lineVersion = line && this.options.versions?.get(line.attempt);
215
223
  const decision = planStage(
216
224
  {
217
- records: this.options.records ?? new Map(),
225
+ ...(line === undefined ? {} : { line }),
226
+ ...(lineVersion === undefined ? {} : { lineVersion }),
227
+ recorded: this.#recorded.map((each) => each.name),
218
228
  ...(this.options.fromStage === undefined ? {} : { fromStage: this.options.fromStage }),
219
229
  ...(this.options.values === undefined ? {} : { values: this.options.values }),
220
230
  ...(this.options.workflowVersion === undefined
@@ -253,8 +263,8 @@ export class StageLedger {
253
263
  }
254
264
  if (decision.kind === "reuse" || decision.kind === "provide") {
255
265
  const held = this.#hold(name, decision.value);
256
- let record: StageRecord;
257
- if (decision.kind === "reuse") record = decision.record;
266
+ let line: StageLine;
267
+ if (decision.kind === "reuse") line = decision.line;
258
268
  else {
259
269
  const written = this.#provide(name, decision.value, entry, at);
260
270
  // Its failure is the stage's, thrown below.
@@ -265,13 +275,14 @@ export class StageLedger {
265
275
  ),
266
276
  );
267
277
  try {
268
- record = await written;
278
+ line = await written;
269
279
  } catch (error) {
270
280
  held.release();
271
281
  throw error;
272
282
  }
273
283
  }
274
284
  this.#reused.add(name);
285
+ if (decision.kind === "reuse") this.#reusedLines.set(name, line);
275
286
  this.options.event?.(
276
287
  {
277
288
  kind: "stage-ended",
@@ -281,7 +292,7 @@ export class StageLedger {
281
292
  },
282
293
  at,
283
294
  );
284
- const { attempt, summary, provided } = record;
295
+ const { attempt, summary, provided } = line;
285
296
  this.#stages.set(name, {
286
297
  stage: name,
287
298
  source: "reused",
@@ -295,28 +306,21 @@ export class StageLedger {
295
306
  });
296
307
  return held;
297
308
  }
298
- // Open while the records it outdates are moved, so no other stage enters beside it.
299
- const stage = this.#opened(name, at);
300
309
  if (decision.start) {
301
- try {
302
- await replaceStale(this.options.runDir, name, this.#reused);
303
- } catch (error) {
304
- // Not entered after all: nothing ran, so nothing is recorded, and no stage is left open.
305
- this.#stages.delete(name);
306
- this.#open = undefined;
307
- throw error;
308
- }
309
310
  this.#started = true;
311
+ this.#reuse.redoFrom(name);
310
312
  }
311
- return { kind: "run", stage };
313
+ return { kind: "run", stage: this.#opened(name, at, decision.start) };
312
314
  }
313
315
 
314
316
  /**
315
- * Enters a stage by content: reused from its succeeded record, this attempt's included, run when
316
- * it has none or a failed one, which its own record then replaces.
317
+ * Enters a stage by content: reused from its succeeded line, this attempt's included, run when it
318
+ * has none or a failed one. After the redo's stage in this attempt, a line older than the redo is
319
+ * outdated and runs again: see `StageRedo`.
317
320
  */
318
321
  async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
319
- const decision = planStageByContent(this.#records.get(name), entry.result);
322
+ if (!this.#started && name === this.options.fromStage) return this.#startByContent(name, at);
323
+ const decision = planStageByContent(this.#reuse.enter(name), entry.result);
320
324
  if (decision.kind === "stop") {
321
325
  this.#stopped = new WorkflowStopped(decision.reason, name, true);
322
326
  this.#stoppedAt(name, at);
@@ -327,7 +331,8 @@ export class StageLedger {
327
331
  this.#reused.add(name);
328
332
  // Entered again in the attempt that ran or reused it: nothing more to show or log.
329
333
  if (this.#stages.get(name)?.outcome === "succeeded") return held;
330
- const { attempt, summary } = decision.record;
334
+ this.#reusedLines.set(name, decision.line);
335
+ const { attempt, summary } = decision.line;
331
336
  this.options.event?.(
332
337
  { kind: "stage-ended", stage: name, outcome: "succeeded", source: "reused" },
333
338
  at,
@@ -347,6 +352,19 @@ export class StageLedger {
347
352
  return { kind: "run", stage: this.#opened(name, at) };
348
353
  }
349
354
 
355
+ /**
356
+ * `--from-stage` reached by content: it runs, and this attempt is the redo, which its line says.
357
+ * An attempt that dies while it runs writes no line, so the old one stands and the redo, which
358
+ * nothing then shows, is issued again. The stages after it rerun as they are entered, not all at
359
+ * once: one never entered keeps its line.
360
+ */
361
+ #startByContent(name: string, at: number): EnteredStage {
362
+ this.#reuse.enter(name);
363
+ this.#reuse.redoFrom(name);
364
+ this.#started = true;
365
+ return { kind: "run", stage: this.#opened(name, at, true) };
366
+ }
367
+
350
368
  /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
351
369
  #stoppedAt(name: string, at: number): void {
352
370
  this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
@@ -354,7 +372,7 @@ export class StageLedger {
354
372
  stage: name,
355
373
  source: "ran",
356
374
  outcome: "stopped",
357
- attempt: this.options.attempt,
375
+ attempt: this.#attempt,
358
376
  startedAt: at,
359
377
  endedAt: at,
360
378
  });
@@ -422,34 +440,29 @@ export class StageLedger {
422
440
  return (interrupted ? undefined : this.#stopForNeeded()) ?? failure;
423
441
  }
424
442
 
425
- /** Records a value given for a stage, or none for one that returns nothing: no turn ran it. */
443
+ /** Writes a value given for a stage, or none for one that returns nothing: no turn ran it. */
426
444
  async #provide(
427
445
  name: string,
428
446
  value: JsonValue | undefined,
429
447
  entry: StageEntry,
430
448
  at: number,
431
- ): Promise<StageRecord> {
449
+ ): Promise<StageLine> {
432
450
  const time = new Date(at).toISOString();
433
451
  const summary = value === undefined ? undefined : entry.summary(value);
434
- const record: StageRecord = {
435
- version: STAGE_RECORD_VERSION,
436
- stage: name,
437
- attempt: this.options.attempt,
452
+ const line: StageLine = {
453
+ kind: "stage",
454
+ attempt: this.#attempt,
455
+ at: time,
456
+ name,
438
457
  outcome: "succeeded",
439
458
  started: time,
440
- ended: time,
441
- ...(this.options.workflowVersion === undefined
442
- ? {}
443
- : { workflowVersion: this.options.workflowVersion }),
444
- sessions: [],
445
459
  ...(summary === undefined ? {} : { summary }),
446
460
  ...(value === undefined ? {} : { value }),
447
461
  provided: true,
448
462
  };
449
- // A record it replaces, one that failed or went stale, is kept as a redone stage's is.
450
- if (this.options.records?.has(name)) await replaceRecord(this.options.runDir, name);
451
- await writeStageRecord(this.options.runDir, record);
452
- return record;
463
+ await this.options.lines.append(line);
464
+ this.#reuse.wrote(line);
465
+ return line;
453
466
  }
454
467
 
455
468
  #check(name: string): void {
@@ -468,66 +481,55 @@ export class StageLedger {
468
481
  }
469
482
  }
470
483
 
471
- #opened(name: string, startedAt: number): OpenStage {
484
+ /**
485
+ * Opens `name` to run, from `startedAt`, the one clock read its line's `started` and the log's
486
+ * `stage-started` both carry, which `awf runs` pairs them by. `redo` on the stage the attempt
487
+ * started at.
488
+ */
489
+ #opened(name: string, startedAt: number, redo = false): OpenStage {
472
490
  const entered: StageProgress & { value?: JsonValue } = {
473
491
  stage: name,
474
492
  source: "ran",
475
- attempt: this.options.attempt,
493
+ attempt: this.#attempt,
476
494
  startedAt,
477
495
  };
478
496
  this.#stages.set(name, entered);
479
497
  this.options.event?.({ kind: "stage-started", stage: name }, startedAt);
480
498
  let ended: Promise<void> | undefined;
481
- // A record that fails to be written fails the stage with that error, whatever its work did.
482
- const end = (fields: Pick<StageRecord, "outcome" | "reason" | "summary" | "value">) => {
499
+ // A line that fails to be written fails the stage with that error, whatever its work did.
500
+ const end = (fields: Pick<StageLine, "outcome" | "reason" | "summary" | "value">) => {
483
501
  if (ended) return ended;
484
502
  if (this.#open === stage) this.#open = undefined;
485
503
  const at = this.#now();
486
- const record: StageRecord = {
487
- version: STAGE_RECORD_VERSION,
488
- stage: name,
489
- attempt: this.options.attempt,
504
+ const line: StageLine = {
505
+ kind: "stage",
506
+ attempt: this.#attempt,
507
+ at: at.toISOString(),
508
+ name,
490
509
  outcome: fields.outcome,
491
510
  ...(fields.reason === undefined ? {} : { reason: fields.reason }),
492
511
  started: new Date(startedAt).toISOString(),
493
- ended: at.toISOString(),
494
- ...(this.options.workflowVersion === undefined
495
- ? {}
496
- : { workflowVersion: this.options.workflowVersion }),
497
- sessions: sessionsOf(this.options.turns(), name),
498
512
  ...(fields.summary === undefined ? {} : { summary: fields.summary }),
499
513
  ...(fields.value === undefined ? {} : { value: fields.value }),
514
+ ...(redo ? { redo: true as const } : {}),
500
515
  };
501
516
  Object.assign(entered, {
502
- outcome: record.outcome,
517
+ outcome: line.outcome,
503
518
  endedAt: at.getTime(),
504
- ...(record.summary === undefined ? {} : { summary: record.summary }),
505
- ...(record.value === undefined ? {} : { value: record.value }),
519
+ ...(line.summary === undefined ? {} : { summary: line.summary }),
520
+ ...(line.value === undefined ? {} : { value: line.value }),
506
521
  });
507
- // By content, a failed record this one replaces is kept, as a redone stage's is.
508
- const replacing = this.options.byContent && this.#records.has(name);
509
- ended = (
510
- replacing
511
- ? replaceRecord(this.options.runDir, name).then(() => {
512
- // Moved: a write that fails now leaves nothing in `stages/` to move again.
513
- this.#records.delete(name);
514
- })
515
- : Promise.resolve()
516
- )
517
- .then(() => writeStageRecord(this.options.runDir, record))
518
- .then(
519
- () => {
520
- this.#records.set(name, record);
521
- },
522
- (error: unknown) => {
523
- entered.outcome = "failed";
524
- throw error;
525
- },
526
- );
527
- // Its outcome once the record is written: one that is not fails the stage.
522
+ ended = this.options.lines.append(line).then(
523
+ () => this.#reuse.wrote(line),
524
+ (error: unknown) => {
525
+ entered.outcome = "failed";
526
+ throw error;
527
+ },
528
+ );
529
+ // Its outcome once the line is written: one that is not fails the stage.
528
530
  const logEnd = () =>
529
531
  this.options.event?.(
530
- { kind: "stage-ended", stage: name, outcome: entered.outcome ?? record.outcome },
532
+ { kind: "stage-ended", stage: name, outcome: entered.outcome ?? line.outcome },
531
533
  at.getTime(),
532
534
  );
533
535
  ended.then(logEnd, logEnd);
@@ -550,7 +552,7 @@ export class StageLedger {
550
552
  }
551
553
 
552
554
  /**
553
- * Leaves the open stage `name` unended and unrecorded, as the run parked at a wait in it: the
555
+ * Leaves the open stage `name` unended and without a line, as the run parked at a wait in it: the
554
556
  * next attempt runs it again.
555
557
  */
556
558
  abandon(name: string): void {
@@ -568,8 +570,8 @@ export class StageLedger {
568
570
  }
569
571
 
570
572
  /**
571
- * Fails the stage still open, once what ran in it has settled, and waits for every record: none
572
- * is written after the attempt ends.
573
+ * Fails the stage still open, once what ran in it has settled, and waits for every line: none is
574
+ * written after the attempt ends.
573
575
  */
574
576
  async close(): Promise<void> {
575
577
  this.seal("the attempt ended");
@@ -594,17 +596,3 @@ export class StageLedger {
594
596
  return this.#failures.get(first) ?? this.#closedOpen;
595
597
  }
596
598
  }
597
-
598
- /** The session each of the stage's turns ran on, once per agent and session. */
599
- function sessionsOf(turns: readonly TurnRecord[], stage: string): StageRecord["sessions"] {
600
- const seen = new Map<string, StageRecord["sessions"][number]>();
601
- for (const turn of turns) {
602
- const session = turn.sessions.at(-1);
603
- if (turn.stage !== stage || !session) continue;
604
- const key = `${turn.agent}\0${session.harness}\0${session.id}`;
605
- if (!seen.has(key)) {
606
- seen.set(key, { agent: turn.agent, harness: session.harness, session: session.id });
607
- }
608
- }
609
- return [...seen.values()];
610
- }
@@ -1,4 +1,4 @@
1
- import type { AttemptRecord } from "@agentswf/contract/records";
1
+ import type { ClaimedAttempt } from "@agentswf/contract/records";
2
2
  import { runProcess } from "@agentswf/harness";
3
3
  import { ensureRunSession, runSessionName } from "./herdr-run-session";
4
4
  import { readMarks } from "./herdr-workspace-marks";
@@ -12,18 +12,19 @@ import { probeProcess } from "./runs";
12
12
  * attempt orphaned isn't reached here; it is not stopped yet.
13
13
  */
14
14
  export async function stopCrashed(
15
- previous: AttemptRecord,
15
+ previous: ClaimedAttempt,
16
16
  label: string,
17
17
  context: { home: string; environment: Readonly<Record<string, string | undefined>> },
18
18
  ): Promise<void> {
19
19
  // Only once its leader is gone for good: a live group's id is never handed out again, so a group
20
20
  // still there is the attempt's leftovers, never another process that took its pid since. One an
21
21
  // `awf run` typed in a terminal led is its pipeline's, which goes with it.
22
- if (probeProcess(previous.pid) === "gone") {
22
+ const { pid } = previous;
23
+ if (pid !== undefined && probeProcess(pid) === "gone") {
23
24
  try {
24
- process.kill(-previous.pid, "SIGTERM");
25
+ process.kill(-pid, "SIGTERM");
25
26
  await Bun.sleep(500);
26
- process.kill(-previous.pid, "SIGKILL");
27
+ process.kill(-pid, "SIGKILL");
27
28
  } catch {
28
29
  // No such group, or it ended at the first signal.
29
30
  }
package/src/stopped.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { StageNeed, StageRecord } from "@agentswf/contract/records";
1
+ import type { StageLine, StageNeed } from "@agentswf/contract/records";
2
2
 
3
3
  /**
4
4
  * Ends an attempt `stopped`, apart from `failed`: the workflow's `stop`, or a continue that can't
@@ -9,7 +9,7 @@ export class WorkflowStopped extends Error {
9
9
  readonly reason: string,
10
10
  /** The stage it stopped in; absent between stages. */
11
11
  readonly stage?: string,
12
- /** Whether going on takes `--from-stage {stage}`: a record a continue can't reuse. */
12
+ /** Whether going on takes `--from-stage {stage}`: a line a continue can't reuse. */
13
13
  readonly redo = false,
14
14
  /** The stages whose values, given with `--values`, let the attempt start at a later one. */
15
15
  readonly needs?: readonly StageNeed[],
@@ -23,8 +23,8 @@ export class WorkflowStopped extends Error {
23
23
  export class FromStageUnreached extends WorkflowStopped {
24
24
  constructor(
25
25
  readonly fromStage: string,
26
- /** The run's stage records, in the order they started: the stages to choose from. */
27
- readonly recorded: readonly StageRecord[],
26
+ /** Each of the run's stages by its latest line, in the order they started: those to choose from. */
27
+ readonly recorded: readonly StageLine[],
28
28
  ) {
29
29
  super(`never reached ${fromStage}`);
30
30
  }
@@ -1,17 +1,19 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
2
  import { constants } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import type { Out } from "./command-line";
5
6
  import { parseDuration } from "./duration";
6
7
  import { messageOf } from "./errors";
7
8
  import help from "./help/test.txt" with { type: "text" };
8
- import type { Out } from "./run-commands";
9
+ import { checkProjectEngine } from "./project-engine";
10
+ import { NO_JSON, printRefusal } from "./refusals";
9
11
 
10
12
  const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
11
13
 
12
14
  export const testUsage = help.trimEnd();
13
15
 
14
- export type TestCommand = { paths: string[]; bunArgs: string[] };
16
+ type TestCommand = { paths: string[]; bunArgs: string[] };
15
17
 
16
18
  /**
17
19
  * `awf test`: the workflow tests under the given paths, with Bun's test runner. Their output goes
@@ -22,16 +24,26 @@ export async function testCommand(
22
24
  cwd: string,
23
25
  io: Out & { output?: Out; signal?: AbortSignal },
24
26
  ): Promise<number> {
25
- let tests: TestCommand | "help";
27
+ let tests: TestCommand;
26
28
  try {
27
29
  tests = parseTestCommand(argv, cwd);
28
30
  } catch (error) {
29
- io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
31
+ printRefusal(io, NO_JSON, error, "usage", testUsage);
30
32
  return 2;
31
33
  }
32
- if (tests === "help") {
33
- io.stdout(testUsage);
34
- return 0;
34
+ try {
35
+ // Each test's workflow gets this engine's author surface: its project, and each path's, must
36
+ // run on it (ADR 0015).
37
+ // The paths given, or, with none, everything under here: bun test loads no other.
38
+ for (const place of tests.paths.length > 0 ? tests.paths : [cwd]) {
39
+ const isDir = statSync(resolve(cwd, place), { throwIfNoEntry: false })?.isDirectory();
40
+ const dir = resolve(cwd, isDir ? place : dirname(place));
41
+ // In a workspace whose packages each install agentswf, the tests' files name the engine.
42
+ checkProjectEngine(dir, { files: isDir ? testFiles(dir) : [resolve(cwd, place)] });
43
+ }
44
+ } catch (error) {
45
+ io.stderr(`awf: ${messageOf(error)}`);
46
+ return 2;
35
47
  }
36
48
  return runWorkflowTests(tests, cwd, {
37
49
  ...(io.signal ? { signal: io.signal } : {}),
@@ -43,13 +55,12 @@ export async function testCommand(
43
55
  * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
44
56
  * and a later runner can take its place. A path must exist; Bun would take it as a filter.
45
57
  */
46
- export function parseTestCommand(argv: readonly string[], cwd: string): TestCommand | "help" {
58
+ function parseTestCommand(argv: readonly string[], cwd: string): TestCommand {
47
59
  const paths: string[] = [];
48
60
  const bunArgs: string[] = [];
49
61
  for (let index = 0; index < argv.length; index += 1) {
50
62
  const option = argv[index]!;
51
63
  const value = argv[index + 1];
52
- if (option === "-h" || option === "--help") return "help";
53
64
  if (option === "--watch") {
54
65
  bunArgs.push("--watch");
55
66
  } else if (option === "-t") {
@@ -58,7 +69,7 @@ export function parseTestCommand(argv: readonly string[], cwd: string): TestComm
58
69
  index += 1;
59
70
  } else if (option === "--timeout") {
60
71
  if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
61
- bunArgs.push("--timeout", String(parseDuration(value)));
72
+ bunArgs.push("--timeout", String(parseDuration(value, "--timeout")));
62
73
  index += 1;
63
74
  } else if (option.startsWith("-")) {
64
75
  throw new Error(`unknown option: ${option}`);
@@ -75,12 +86,12 @@ export function parseTestCommand(argv: readonly string[], cwd: string): TestComm
75
86
  * Runs `bun test` in `cwd` with the author surface preloaded, and resolves to its exit code. When
76
87
  * `signal` aborts, as on Ctrl-C to awf alone, the tests are stopped with the same signal.
77
88
  */
78
- export async function runWorkflowTests(
89
+ async function runWorkflowTests(
79
90
  command: TestCommand,
80
91
  cwd: string,
81
92
  options: {
82
93
  signal?: AbortSignal;
83
- output?: { stdout(text: string): void; stderr(text: string): void };
94
+ output?: Out;
84
95
  } = {},
85
96
  ): Promise<number> {
86
97
  const { signal, output } = options;
@@ -131,3 +142,14 @@ export async function runWorkflowTests(
131
142
  process.off("exit", orphaned);
132
143
  }
133
144
  }
145
+
146
+ /** A test file `bun test` would find under a folder, outside `node_modules`, for each folder. */
147
+ function testFiles(dir: string): string[] {
148
+ const byFolder = new Map<string, string>();
149
+ for (const path of new Bun.Glob("**/*.{test,spec}.{ts,tsx,js,jsx,mts,mjs}").scanSync({
150
+ cwd: dir,
151
+ }))
152
+ if (!path.split("/").includes("node_modules") && !byFolder.has(dirname(path)))
153
+ byFolder.set(dirname(path), join(dir, path));
154
+ return [...byFolder.values()];
155
+ }