@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -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, type StageRedo } 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,29 +85,27 @@ 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>;
91
-
92
- /** The redo stale records are judged by: the options', then this attempt's own. */
93
- #redo: StageRedo | undefined;
94
- /** Whether this attempt has entered the redo's stage, after which a stale record reruns. */
95
- #afterRedo = false;
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;
96
95
 
97
96
  constructor(
98
97
  private readonly options: {
99
- runDir: string;
100
- 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>;
101
104
  workflowVersion?: string;
102
- /** The attempt's turns as they settled, which name the stage each ran in. */
103
- turns: () => readonly TurnRecord[];
104
- /** The run's stage records as the attempt began: what a continue may reuse. */
105
- records?: ReadonlyMap<string, StageRecord>;
106
105
  /** `--from-stage`: the stage the attempt starts at. */
107
106
  fromStage?: string;
108
- /** `--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. */
109
108
  values?: ReadonlyMap<string, JsonValue>;
110
- /** A run of a workflow with `events`: the last `--from-stage` an attempt redid. */
111
- redo?: StageRedo;
112
109
  /**
113
110
  * A run of a workflow with `events`: a stage runs once ever, reused wherever it is entered,
114
111
  * as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
@@ -119,8 +116,11 @@ export class StageLedger {
119
116
  now?: () => Date;
120
117
  },
121
118
  ) {
122
- this.#records = new Map(options.records);
123
- this.#redo = options.redo;
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);
124
124
  }
125
125
 
126
126
  /** The stage open now, if one is. */
@@ -134,13 +134,12 @@ export class StageLedger {
134
134
  }
135
135
 
136
136
  /**
137
- * The run's stage records as the attempt began, in the order they started. Before the start
138
- * 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.
139
140
  */
140
- get recorded(): StageRecord[] {
141
- return [...(this.options.records?.values() ?? [])].sort((a, b) =>
142
- a.started.localeCompare(b.started),
143
- );
141
+ get recorded(): StageLine[] {
142
+ return this.#recorded;
144
143
  }
145
144
 
146
145
  /** The stop that ended the attempt, even if the workflow caught it. */
@@ -150,7 +149,7 @@ export class StageLedger {
150
149
 
151
150
  /**
152
151
  * Each stage entered that has ended, in order: reused, run, or stopped by the plan as it was
153
- * entered, which ran nothing and has no record.
152
+ * entered, which ran nothing and wrote no line.
154
153
  */
155
154
  get summaries(): StageSummary[] {
156
155
  return [...this.#stages.values()].flatMap(
@@ -169,11 +168,11 @@ export class StageLedger {
169
168
  // By content, an earlier event's stage may never be entered again: none is upcoming.
170
169
  const upcoming = this.options.byContent
171
170
  ? []
172
- : this.recorded.map((record) => record.stage).filter((stage) => !this.#stages.has(stage));
173
- const recorded = (stage: string) => this.options.records?.get(stage)?.ended;
171
+ : this.#upcoming.filter((stage) => !this.#stages.has(stage));
174
172
  return {
175
173
  stages: [...this.#stages.values()].map((entered) => {
176
- const ended = entered.source === "reused" ? recorded(entered.stage) : undefined;
174
+ const ended =
175
+ entered.source === "reused" ? this.#reusedLines.get(entered.stage)?.at : undefined;
177
176
  const stage = withoutValue(entered);
178
177
  return ended === undefined ? stage : { ...stage, recordedAt: Date.parse(ended) };
179
178
  }),
@@ -208,10 +207,9 @@ export class StageLedger {
208
207
 
209
208
  /**
210
209
  * Enters `name`: reused or provided when the plan says so, or opened to run. A value provided is
211
- * recorded before it is handed back. Throws why it can't be: a bad name, a second entry, another
212
- * one open, or a record that stops the attempt. Entered after a stop, the stop was caught, and
213
- * that fails the attempt. Reaching the start point, it moves the records it outdates to
214
- * `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.
215
213
  */
216
214
  async enter(name: string, entry: StageEntry): Promise<EnteredStage> {
217
215
  // Looking on, a stop caught is the one for the values missing, which ends the attempt anyway.
@@ -220,9 +218,13 @@ export class StageLedger {
220
218
  this.#check(name);
221
219
  const at = this.#now().getTime();
222
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);
223
223
  const decision = planStage(
224
224
  {
225
- records: this.options.records ?? new Map(),
225
+ ...(line === undefined ? {} : { line }),
226
+ ...(lineVersion === undefined ? {} : { lineVersion }),
227
+ recorded: this.#recorded.map((each) => each.name),
226
228
  ...(this.options.fromStage === undefined ? {} : { fromStage: this.options.fromStage }),
227
229
  ...(this.options.values === undefined ? {} : { values: this.options.values }),
228
230
  ...(this.options.workflowVersion === undefined
@@ -261,8 +263,8 @@ export class StageLedger {
261
263
  }
262
264
  if (decision.kind === "reuse" || decision.kind === "provide") {
263
265
  const held = this.#hold(name, decision.value);
264
- let record: StageRecord;
265
- if (decision.kind === "reuse") record = decision.record;
266
+ let line: StageLine;
267
+ if (decision.kind === "reuse") line = decision.line;
266
268
  else {
267
269
  const written = this.#provide(name, decision.value, entry, at);
268
270
  // Its failure is the stage's, thrown below.
@@ -273,13 +275,14 @@ export class StageLedger {
273
275
  ),
274
276
  );
275
277
  try {
276
- record = await written;
278
+ line = await written;
277
279
  } catch (error) {
278
280
  held.release();
279
281
  throw error;
280
282
  }
281
283
  }
282
284
  this.#reused.add(name);
285
+ if (decision.kind === "reuse") this.#reusedLines.set(name, line);
283
286
  this.options.event?.(
284
287
  {
285
288
  kind: "stage-ended",
@@ -289,7 +292,7 @@ export class StageLedger {
289
292
  },
290
293
  at,
291
294
  );
292
- const { attempt, summary, provided } = record;
295
+ const { attempt, summary, provided } = line;
293
296
  this.#stages.set(name, {
294
297
  stage: name,
295
298
  source: "reused",
@@ -303,41 +306,21 @@ export class StageLedger {
303
306
  });
304
307
  return held;
305
308
  }
306
- // Open while the records it outdates are moved, so no other stage enters beside it.
307
- const stage = this.#opened(name, at);
308
309
  if (decision.start) {
309
- try {
310
- await replaceStale(this.options.runDir, name, this.#reused);
311
- } catch (error) {
312
- // Not entered after all: nothing ran, so nothing is recorded, and no stage is left open.
313
- this.#stages.delete(name);
314
- this.#open = undefined;
315
- throw error;
316
- }
317
310
  this.#started = true;
311
+ this.#reuse.redoFrom(name);
318
312
  }
319
- return { kind: "run", stage };
313
+ return { kind: "run", stage: this.#opened(name, at, decision.start) };
320
314
  }
321
315
 
322
316
  /**
323
- * Enters a stage by content: reused from its succeeded record, this attempt's included, run when
324
- * it has none or a failed one, which its own record then replaces. After the redo's stage in this
325
- * attempt, a record older than the redo is stale and runs again: see `StageRedo`.
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`.
326
320
  */
327
321
  async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
328
322
  if (!this.#started && name === this.options.fromStage) return this.#startByContent(name, at);
329
- const record = this.#records.get(name);
330
- const redo = this.#redo;
331
- // Entered already in this attempt, it has its value here: one stage, one value.
332
- const stale =
333
- redo !== undefined &&
334
- this.#afterRedo &&
335
- record !== undefined &&
336
- record.attempt < redo.attempt &&
337
- !this.#stages.has(name);
338
- if (name === redo?.stage) this.#afterRedo = true;
339
- if (stale) return { kind: "run", stage: this.#opened(name, at) };
340
- const decision = planStageByContent(record, entry.result);
323
+ const decision = planStageByContent(this.#reuse.enter(name), entry.result);
341
324
  if (decision.kind === "stop") {
342
325
  this.#stopped = new WorkflowStopped(decision.reason, name, true);
343
326
  this.#stoppedAt(name, at);
@@ -348,7 +331,8 @@ export class StageLedger {
348
331
  this.#reused.add(name);
349
332
  // Entered again in the attempt that ran or reused it: nothing more to show or log.
350
333
  if (this.#stages.get(name)?.outcome === "succeeded") return held;
351
- const { attempt, summary } = decision.record;
334
+ this.#reusedLines.set(name, decision.line);
335
+ const { attempt, summary } = decision.line;
352
336
  this.options.event?.(
353
337
  { kind: "stage-ended", stage: name, outcome: "succeeded", source: "reused" },
354
338
  at,
@@ -369,17 +353,16 @@ export class StageLedger {
369
353
  }
370
354
 
371
355
  /**
372
- * `--from-stage` reached by content: it runs, and this attempt is the redo. Its old record moves to
373
- * `replaced/` only as its new one is written, as a failed record's does, so an attempt that dies
374
- * while it runs leaves the old one, and the redo, which nothing then shows, is issued again. The
375
- * stages after it rerun as they are entered, not all at once: one never entered keeps its record.
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.
376
360
  */
377
361
  #startByContent(name: string, at: number): EnteredStage {
378
- const stage = this.#opened(name, at);
362
+ this.#reuse.enter(name);
363
+ this.#reuse.redoFrom(name);
379
364
  this.#started = true;
380
- this.#redo = { stage: name, attempt: this.options.attempt };
381
- this.#afterRedo = true;
382
- return { kind: "run", stage };
365
+ return { kind: "run", stage: this.#opened(name, at, true) };
383
366
  }
384
367
 
385
368
  /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
@@ -389,7 +372,7 @@ export class StageLedger {
389
372
  stage: name,
390
373
  source: "ran",
391
374
  outcome: "stopped",
392
- attempt: this.options.attempt,
375
+ attempt: this.#attempt,
393
376
  startedAt: at,
394
377
  endedAt: at,
395
378
  });
@@ -457,34 +440,29 @@ export class StageLedger {
457
440
  return (interrupted ? undefined : this.#stopForNeeded()) ?? failure;
458
441
  }
459
442
 
460
- /** 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. */
461
444
  async #provide(
462
445
  name: string,
463
446
  value: JsonValue | undefined,
464
447
  entry: StageEntry,
465
448
  at: number,
466
- ): Promise<StageRecord> {
449
+ ): Promise<StageLine> {
467
450
  const time = new Date(at).toISOString();
468
451
  const summary = value === undefined ? undefined : entry.summary(value);
469
- const record: StageRecord = {
470
- version: STAGE_RECORD_VERSION,
471
- stage: name,
472
- attempt: this.options.attempt,
452
+ const line: StageLine = {
453
+ kind: "stage",
454
+ attempt: this.#attempt,
455
+ at: time,
456
+ name,
473
457
  outcome: "succeeded",
474
458
  started: time,
475
- ended: time,
476
- ...(this.options.workflowVersion === undefined
477
- ? {}
478
- : { workflowVersion: this.options.workflowVersion }),
479
- sessions: [],
480
459
  ...(summary === undefined ? {} : { summary }),
481
460
  ...(value === undefined ? {} : { value }),
482
461
  provided: true,
483
462
  };
484
- // A record it replaces, one that failed or went stale, is kept as a redone stage's is.
485
- if (this.options.records?.has(name)) await replaceRecord(this.options.runDir, name);
486
- await writeStageRecord(this.options.runDir, record);
487
- return record;
463
+ await this.options.lines.append(line);
464
+ this.#reuse.wrote(line);
465
+ return line;
488
466
  }
489
467
 
490
468
  #check(name: string): void {
@@ -503,61 +481,55 @@ export class StageLedger {
503
481
  }
504
482
  }
505
483
 
506
- #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 {
507
490
  const entered: StageProgress & { value?: JsonValue } = {
508
491
  stage: name,
509
492
  source: "ran",
510
- attempt: this.options.attempt,
493
+ attempt: this.#attempt,
511
494
  startedAt,
512
495
  };
513
496
  this.#stages.set(name, entered);
514
497
  this.options.event?.({ kind: "stage-started", stage: name }, startedAt);
515
498
  let ended: Promise<void> | undefined;
516
- // A record that fails to be written fails the stage with that error, whatever its work did.
517
- 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">) => {
518
501
  if (ended) return ended;
519
502
  if (this.#open === stage) this.#open = undefined;
520
503
  const at = this.#now();
521
- const record: StageRecord = {
522
- version: STAGE_RECORD_VERSION,
523
- stage: name,
524
- attempt: this.options.attempt,
504
+ const line: StageLine = {
505
+ kind: "stage",
506
+ attempt: this.#attempt,
507
+ at: at.toISOString(),
508
+ name,
525
509
  outcome: fields.outcome,
526
510
  ...(fields.reason === undefined ? {} : { reason: fields.reason }),
527
511
  started: new Date(startedAt).toISOString(),
528
- ended: at.toISOString(),
529
- ...(this.options.workflowVersion === undefined
530
- ? {}
531
- : { workflowVersion: this.options.workflowVersion }),
532
- sessions: sessionsOf(this.options.turns(), name),
533
512
  ...(fields.summary === undefined ? {} : { summary: fields.summary }),
534
513
  ...(fields.value === undefined ? {} : { value: fields.value }),
514
+ ...(redo ? { redo: true as const } : {}),
535
515
  };
536
516
  Object.assign(entered, {
537
- outcome: record.outcome,
517
+ outcome: line.outcome,
538
518
  endedAt: at.getTime(),
539
- ...(record.summary === undefined ? {} : { summary: record.summary }),
540
- ...(record.value === undefined ? {} : { value: record.value }),
519
+ ...(line.summary === undefined ? {} : { summary: line.summary }),
520
+ ...(line.value === undefined ? {} : { value: line.value }),
541
521
  });
542
- // By content, a failed record this one replaces is kept, as a redone stage's is.
543
- const replacing = this.options.byContent && this.#records.has(name);
544
- // Copied, then overwritten: dying between the two leaves the old record, as dying while the
545
- // stage runs does, never a redo's stage without one and stale records after it.
546
- ended = (replacing ? replaceRecord(this.options.runDir, name, true) : Promise.resolve())
547
- .then(() => writeStageRecord(this.options.runDir, record))
548
- .then(
549
- () => {
550
- this.#records.set(name, record);
551
- },
552
- (error: unknown) => {
553
- entered.outcome = "failed";
554
- throw error;
555
- },
556
- );
557
- // 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.
558
530
  const logEnd = () =>
559
531
  this.options.event?.(
560
- { kind: "stage-ended", stage: name, outcome: entered.outcome ?? record.outcome },
532
+ { kind: "stage-ended", stage: name, outcome: entered.outcome ?? line.outcome },
561
533
  at.getTime(),
562
534
  );
563
535
  ended.then(logEnd, logEnd);
@@ -580,7 +552,7 @@ export class StageLedger {
580
552
  }
581
553
 
582
554
  /**
583
- * 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
584
556
  * next attempt runs it again.
585
557
  */
586
558
  abandon(name: string): void {
@@ -598,8 +570,8 @@ export class StageLedger {
598
570
  }
599
571
 
600
572
  /**
601
- * Fails the stage still open, once what ran in it has settled, and waits for every record: none
602
- * 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.
603
575
  */
604
576
  async close(): Promise<void> {
605
577
  this.seal("the attempt ended");
@@ -624,17 +596,3 @@ export class StageLedger {
624
596
  return this.#failures.get(first) ?? this.#closedOpen;
625
597
  }
626
598
  }
627
-
628
- /** The session each of the stage's turns ran on, once per agent and session. */
629
- function sessionsOf(turns: readonly TurnRecord[], stage: string): StageRecord["sessions"] {
630
- const seen = new Map<string, StageRecord["sessions"][number]>();
631
- for (const turn of turns) {
632
- const session = turn.sessions.at(-1);
633
- if (turn.stage !== stage || !session) continue;
634
- const key = `${turn.agent}\0${session.harness}\0${session.id}`;
635
- if (!seen.has(key)) {
636
- seen.set(key, { agent: turn.agent, harness: session.harness, session: session.id });
637
- }
638
- }
639
- return [...seen.values()];
640
- }
@@ -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
- tests = parseTestCommand(argv, cwd, (line) => io.stderr(line));
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,17 +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(
47
- argv: readonly string[],
48
- cwd: string,
49
- deprecated: (line: string) => void = () => {},
50
- ): TestCommand | "help" {
58
+ function parseTestCommand(argv: readonly string[], cwd: string): TestCommand {
51
59
  const paths: string[] = [];
52
60
  const bunArgs: string[] = [];
53
61
  for (let index = 0; index < argv.length; index += 1) {
54
62
  const option = argv[index]!;
55
63
  const value = argv[index + 1];
56
- if (option === "-h" || option === "--help") return "help";
57
64
  if (option === "--watch") {
58
65
  bunArgs.push("--watch");
59
66
  } else if (option === "-t") {
@@ -62,7 +69,7 @@ export function parseTestCommand(
62
69
  index += 1;
63
70
  } else if (option === "--timeout") {
64
71
  if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
65
- bunArgs.push("--timeout", String(parseDuration(value, "--timeout", deprecated)));
72
+ bunArgs.push("--timeout", String(parseDuration(value, "--timeout")));
66
73
  index += 1;
67
74
  } else if (option.startsWith("-")) {
68
75
  throw new Error(`unknown option: ${option}`);
@@ -79,12 +86,12 @@ export function parseTestCommand(
79
86
  * Runs `bun test` in `cwd` with the author surface preloaded, and resolves to its exit code. When
80
87
  * `signal` aborts, as on Ctrl-C to awf alone, the tests are stopped with the same signal.
81
88
  */
82
- export async function runWorkflowTests(
89
+ async function runWorkflowTests(
83
90
  command: TestCommand,
84
91
  cwd: string,
85
92
  options: {
86
93
  signal?: AbortSignal;
87
- output?: { stdout(text: string): void; stderr(text: string): void };
94
+ output?: Out;
88
95
  } = {},
89
96
  ): Promise<number> {
90
97
  const { signal, output } = options;
@@ -135,3 +142,14 @@ export async function runWorkflowTests(
135
142
  process.off("exit", orphaned);
136
143
  }
137
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
+ }