@agentswf/engine 0.1.0-beta.4 → 0.1.0-beta.5

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
package/src/run-view.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { join } from "node:path";
2
2
  import type {
3
3
  Activity,
4
+ AgentStateJson,
4
5
  AttemptRecord,
5
6
  OperationLivenessRecord,
6
7
  RunLogLine,
8
+ StageStateJson,
7
9
  } from "@agentswf/contract/records";
8
- import type { AgentKey, HarnessKind, StageOutcome } from "@agentswf/contract/workflow";
10
+ import type { AgentKey, HarnessKind } from "@agentswf/contract/workflow";
9
11
  import { readLines } from "./jsonl";
10
12
  import { currentStage, estimateOf } from "./run-commands";
11
13
  import { type Cursor, type FileCache, readRun, timeOf } from "./run-read";
@@ -14,7 +16,7 @@ import type { RunStatus } from "./run-state";
14
16
  /** A stage of the attempt shown: running, reused or given from an earlier one, or how it ended. */
15
17
  export type StageView = {
16
18
  stage: string;
17
- state: "running" | "interrupted" | "reused" | "provided" | StageOutcome;
19
+ state: StageStateJson;
18
20
  startedAt?: number;
19
21
  endedAt?: number;
20
22
  };
@@ -29,7 +31,7 @@ export type AgentView = {
29
31
  caller: boolean;
30
32
  /** The stage its last operation ran in. */
31
33
  stage?: string;
32
- state: string;
34
+ state: AgentStateJson;
33
35
  turns: number;
34
36
  /** When its current operation, or its last, started. */
35
37
  since?: number;
@@ -137,7 +139,7 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
137
139
  const stage = currentStage(status, read.log.lines);
138
140
  // A run whose process is gone left what it was doing undone.
139
141
  if (status.state === "interrupted") {
140
- for (const each of agents.values()) if (each.state === "working") each.state = "was working";
142
+ for (const each of agents.values()) if (each.state === "working") each.state = "interrupted";
141
143
  for (const each of stages) if (each.state === "running") each.state = "interrupted";
142
144
  }
143
145
  if (status.state === "running") {
@@ -153,6 +155,9 @@ export async function viewRun(status: RunStatus): Promise<RunView> {
153
155
  } else if (phase?.kind === "awaiting-reply" && phase.peer !== undefined) {
154
156
  agent(key).live = { phase: "awaiting-reply", peer: phase.peer, ...until };
155
157
  }
158
+ // One word with `wfa peers`: the phase is the state; `live` keeps its detail.
159
+ const { live } = agent(key);
160
+ if (live && agent(key).state === "working") agent(key).state = live.phase;
156
161
  }
157
162
  }
158
163
  const since = attempt ? Date.parse(attempt.started) : Number.NEGATIVE_INFINITY;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { RunJson, RunsJson } from "@agentswf/contract/records";
3
- import { duration } from "./duration";
3
+ import { formatDuration } from "./duration";
4
4
  import { messageOf } from "./errors";
5
5
  import help from "./help/runs.txt" with { type: "text" };
6
6
  import {
@@ -133,7 +133,7 @@ async function list(place: ReadPlace, limit: number, json: boolean, out: Out, no
133
133
  status.ref.padEnd(width),
134
134
  states[index]!.padEnd(stateWidth),
135
135
  dayTime(started).padEnd(13),
136
- duration(ms).padEnd(8),
136
+ formatDuration(ms).padEnd(8),
137
137
  money(estimate)
138
138
  .join("")
139
139
  .concat(waiting ? " " : ""),
@@ -162,13 +162,15 @@ function dayTime(at: number): string {
162
162
  return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${clockTime(at).slice(0, 5)}`;
163
163
  }
164
164
 
165
- const busy = (agent: AgentView) => agent.state === "working" || agent.state === "was working";
165
+ const inTurn = (agent: AgentView) =>
166
+ agent.state === "working" || agent.state === "waiting" || agent.state === "awaiting-reply";
167
+ const busy = (agent: AgentView) => inTurn(agent) || agent.state === "interrupted";
166
168
 
167
169
  function agentLine(agent: AgentView, indent: string, now: number, width: number): string {
168
170
  const { live } = agent;
169
171
  const parts = [
170
172
  said(agent.key, 60).padEnd(width),
171
- (live?.phase === "waiting" ? "waiting" : live ? "awaiting" : agent.state).padEnd(11),
173
+ agent.state.padEnd(14),
172
174
  busy(agent) ? `turn ${agent.turns + 1}` : `${agent.turns} turn${agent.turns === 1 ? "" : "s"}`,
173
175
  ...(live?.phase === "waiting"
174
176
  ? [
@@ -181,10 +183,10 @@ function agentLine(agent: AgentView, indent: string, now: number, width: number)
181
183
  ]
182
184
  : live
183
185
  ? [
184
- `${said(live.peer, 60)}'s reply${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
186
+ `from ${said(live.peer, 60)}${live.until ? ` (until ${clockTime(live.until).slice(0, 5)})` : ""}`,
185
187
  ]
186
188
  : []),
187
- ...(agent.since && agent.state === "working" ? [duration(now - agent.since)] : []),
189
+ ...(agent.since && inTurn(agent) ? [formatDuration(now - agent.since)] : []),
188
190
  ...(agent.last
189
191
  ? [`last: ${describeActivity(agent.last.activity)} (${clockTime(agent.last.at)})`]
190
192
  : []),
@@ -236,13 +238,13 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
236
238
  ...(view.stage ? [`stage ${view.stage}`] : []),
237
239
  status.state === "interrupted" && view.lastEventAt
238
240
  ? `last event ${clockTime(view.lastEventAt)}`
239
- : duration(span(status, now).ms),
241
+ : formatDuration(span(status, now).ms),
240
242
  ...money(view.estimate),
241
243
  ].join(" "),
242
244
  ];
243
245
  for (const earlier of status.attempts.slice(0, -1)) {
244
246
  const took = earlier.ended
245
- ? ` ${duration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
247
+ ? ` ${formatDuration(Date.parse(earlier.ended) - Date.parse(earlier.started))}`
246
248
  : "";
247
249
  const how = `${earlier.outcome ?? "interrupted"}${earlier.stage ? ` in ${earlier.stage}` : ""}`;
248
250
  lines.push(` attempt ${earlier.attempt} ${how}${took}`);
@@ -254,7 +256,7 @@ function describe(view: RunView, place: ReadPlace, now: number): string {
254
256
  const took =
255
257
  stage.state === "reused" || stage.state === "provided" || stage.startedAt === undefined
256
258
  ? ""
257
- : duration((stage.endedAt ?? now) - stage.startedAt);
259
+ : formatDuration((stage.endedAt ?? now) - stage.startedAt);
258
260
  lines.push(
259
261
  `${indent} stage ${stage.stage.padEnd(8)} ${stage.state.padEnd(10)} ${took}`.trimEnd(),
260
262
  );
@@ -309,7 +311,7 @@ function runJson(view: RunView, place: ReadPlace, now: number): RunJson {
309
311
  ? undefined
310
312
  : continueOf(status, view.lines, place);
311
313
  return {
312
- v: 1,
314
+ v: 2,
313
315
  run: {
314
316
  ref: status.ref,
315
317
  state: status.state,
package/src/runs.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, open, readdir, readFile, rename, rm, stat, unlink } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ mkdir,
5
+ open,
6
+ readdir,
7
+ readFile,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ unlink,
12
+ } from "node:fs/promises";
3
13
  import { join } from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
4
15
  import {
5
16
  ATTEMPT_RECORD_VERSION,
6
17
  type AttemptRecord,
@@ -8,6 +19,7 @@ import {
8
19
  type Candidate,
9
20
  RUN_RECORD_VERSION,
10
21
  type RunLogLine,
22
+ type RunPanes,
11
23
  type RunRecord,
12
24
  runLogLine,
13
25
  STAGE_RECORD_VERSION,
@@ -190,13 +202,15 @@ export async function openRun(root: string, workflow: string, id: string): Promi
190
202
 
191
203
  /**
192
204
  * What a continue may be given: nothing that changes the run. argv is the run's, and its working
193
- * directory and sandbox are those it was started with.
205
+ * directory, sandbox and panes are those it was started with.
194
206
  */
195
207
  export function checkContinue(
196
208
  run: Run,
197
- given: { argv: readonly string[]; cwd?: string; sandbox?: unknown },
209
+ given: { argv: readonly string[]; cwd?: string; sandbox?: unknown; panes?: RunPanes },
198
210
  ): void {
199
211
  const { id, cwd, sandbox } = run.record;
212
+ const panes = panesChanged(run.record, given.panes);
213
+ if (panes) throw new RunRefused(panes);
200
214
  if (given.argv.length > 0) {
201
215
  throw new RunRefused(
202
216
  `${id} keeps the arguments it was started with; a run with others is a new run (--id)`,
@@ -210,11 +224,32 @@ export function checkContinue(
210
224
  }
211
225
  }
212
226
 
227
+ /**
228
+ * Why `given` would change the panes `record`'s run was started with; a flag given again with its
229
+ * value, or with its default where the run was started without it, changes nothing.
230
+ */
231
+ export function panesChanged(record: RunRecord, given: RunPanes | undefined): string | undefined {
232
+ const flags = [
233
+ ["--workspace", "workspace", "run"],
234
+ ["--keep-panes", "keep", "never"],
235
+ ] as const;
236
+ for (const [flag, field, byDefault] of flags) {
237
+ const value = given?.[field];
238
+ const recorded = record.panes?.[field];
239
+ if (value === undefined || isDeepStrictEqual(value, recorded ?? byDefault)) continue;
240
+ // A run an event or an older awf started has none, and keeps the defaults.
241
+ return recorded === undefined
242
+ ? `${record.id} was started without ${flag}, so with ${flag} ${byDefault}; leave it out`
243
+ : `${record.id} keeps the ${flag} it was started with; leave ${flag} out`;
244
+ }
245
+ return undefined;
246
+ }
247
+
213
248
  /**
214
249
  * The run's attempts, in order. One that doesn't parse, or is newer than this awf, refuses; one
215
250
  * gone since the folder was listed was a refused attempt removing its own claim.
216
251
  */
217
- export async function readAttempts(run: Run): Promise<AttemptRecord[]> {
252
+ export async function readAttempts(run: Pick<Run, "dir">): Promise<AttemptRecord[]> {
218
253
  const dir = attemptsDir(run.dir);
219
254
  const numbered = await attemptNames(run);
220
255
  const records = await Promise.all(
@@ -405,9 +440,10 @@ export async function replaceStale(
405
440
  /**
406
441
  * Moves a stage's record, unchanged, to `replaced/{stage}.{attempt}.json`, or, for a stage by
407
442
  * content replaced again in the same attempt, `{stage}.{attempt}+{n}.json` from 2: no name has a
408
- * `+`, so neither reads as another stage's.
443
+ * `+`, so neither reads as another stage's. `copy` leaves it in `stages/` for the new record to
444
+ * overwrite, so an attempt that dies between the two leaves the old one, not none.
409
445
  */
410
- export async function replaceRecord(runDir: string, stage: string): Promise<void> {
446
+ export async function replaceRecord(runDir: string, stage: string, copy = false): Promise<void> {
411
447
  const file = join(stagesDir(runDir), `${stage}.json`);
412
448
  const record = await readRecord<StageRecord>(file, STAGE_RECORD_VERSION);
413
449
  const replaced = join(runDir, "replaced");
@@ -416,7 +452,7 @@ export async function replaceRecord(runDir: string, stage: string): Promise<void
416
452
  for (let n = 2; await exists(target); n++) {
417
453
  target = join(replaced, `${stage}.${record.attempt}+${n}.json`);
418
454
  }
419
- await rename(file, target);
455
+ await (copy ? copyFile(file, target) : rename(file, target));
420
456
  }
421
457
 
422
458
  /** Appends a line to the run's event log, `turns.jsonl`, which only the live attempt writes. */
@@ -501,7 +537,7 @@ export async function logSize(runDir: string): Promise<number> {
501
537
  return (await stat(turnsFile(runDir)).catch(() => undefined))?.size ?? 0;
502
538
  }
503
539
 
504
- async function attemptNames(run: Run): Promise<string[]> {
540
+ async function attemptNames(run: Pick<Run, "dir">): Promise<string[]> {
505
541
  return (await entries(attemptsDir(run.dir))).filter((name) => /^[1-9]\d*\.json$/.test(name));
506
542
  }
507
543
 
package/src/sources.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { WaitTake } from "@agentswf/contract/records";
2
- import type { JsonValue, WorkflowEvent } from "@agentswf/contract/workflow";
2
+ import { awfEventOf, type JsonValue, type WorkflowEvent } from "@agentswf/contract/workflow";
3
3
 
4
4
  /** What each source the run issued takes: only awf makes a source, so only these are. */
5
5
  const takes = new WeakMap<object, WaitTake>();
6
6
 
7
+ /** Signals: a branch on one hands back the whole event, not its data. */
8
+ const signals = new WeakSet<object>();
9
+
7
10
  /** Marks `value` as a source that takes `take`, and returns it. */
8
11
  export function asSource<T extends object>(value: T, take: WaitTake): T {
9
12
  takes.set(value, take);
@@ -18,8 +21,40 @@ export function source<T extends object>(
18
21
  return asSource(Object.freeze({ ...fields, is: (event: Looked) => matches(event, take) }), take);
19
22
  }
20
23
 
24
+ /** As `source`, for a signal. */
25
+ export function signalOf<T extends object>(fields: T, take: WaitTake): T {
26
+ const made = source(fields, take);
27
+ signals.add(made);
28
+ return made;
29
+ }
30
+
31
+ /** Whether `value` is a signal, whose branch hands back the whole event. */
32
+ export function isSignal(value: unknown): boolean {
33
+ return typeof value === "object" && value !== null && signals.has(value);
34
+ }
35
+
21
36
  type Looked = { name: string; from: string; data?: unknown };
22
37
 
38
+ /**
39
+ * The name the mailbox records an event under: awf's keep `agent:ended` and `participant:reply`,
40
+ * from their key, whatever name the workflow declares and is handed, `{key}:ended` or `{key}:reply`.
41
+ */
42
+ export function recordName(event: { name: string; from: string }): string {
43
+ const awf = awfEventOf(event.name);
44
+ if (!awf || awf.key !== event.from) return event.name;
45
+ return awf.kind === "ended" ? "agent:ended" : "participant:reply";
46
+ }
47
+
48
+ /** `event` under the name the workflow declares for it, where it declares one. */
49
+ export function presented<E extends WorkflowEvent<string, JsonValue>>(
50
+ event: E,
51
+ declared: ReadonlySet<string>,
52
+ ): E {
53
+ const kind = { "agent:ended": "ended", "participant:reply": "reply" }[event.name as string];
54
+ if (kind === undefined || !declared.has(`${event.from}:${kind}`)) return event;
55
+ return Object.freeze({ ...event, name: `${event.from}:${kind}` });
56
+ }
57
+
23
58
  /** The first of `branches` that takes `event`. */
24
59
  export function branchOf(
25
60
  branches: readonly (readonly [string, WaitTake])[],
@@ -33,12 +68,12 @@ export function takeOf(value: unknown): WaitTake | undefined {
33
68
  return typeof value === "object" && value !== null ? takes.get(value) : undefined;
34
69
  }
35
70
 
36
- /** Whether a wait that takes `take` takes `event`. */
71
+ /** Whether a wait that takes `take` takes `event`, under its recorded name or its declared one. */
37
72
  export function matches(
38
73
  event: { name: string; from: string; data?: unknown },
39
74
  take: WaitTake,
40
75
  ): boolean {
41
- if (event.name !== take.name) return false;
76
+ if (recordName(event) !== take.name) return false;
42
77
  if (take.from !== undefined && event.from !== take.from) return false;
43
78
  if (take.turn === undefined) return true;
44
79
  const data = event.data as { turn?: unknown } | null | undefined;
@@ -55,10 +90,11 @@ export function wouldTake(
55
90
 
56
91
  /**
57
92
  * What a branch hands back of the event it took: awf's own events as their data with the event's id
58
- * and arrival, and a workflow's own event whole.
93
+ * and arrival, and a workflow's own event, or any a signal takes, whole.
59
94
  */
60
- export function branchValue(event: WorkflowEvent<string, JsonValue>): unknown {
61
- switch (event.name) {
95
+ export function branchValue(event: WorkflowEvent<string, JsonValue>, whole = false): unknown {
96
+ if (whole) return event;
97
+ switch (recordName(event)) {
62
98
  case "timer:due":
63
99
  case "at:due":
64
100
  case "participant:reply":
@@ -8,7 +8,7 @@ import type { JsonSchema } from "@agentswf/contract/schema";
8
8
  import type { JsonValue, StageOutcome, StageSummary } from "@agentswf/contract/workflow";
9
9
  import type { RunEventBody } from "./run-event-log";
10
10
  import { replaceRecord, replaceStale, stageNameProblem, writeStageRecord } from "./runs";
11
- import { planStage, planStageByContent } from "./stage-plan";
11
+ import { planStage, planStageByContent, type StageRedo } from "./stage-plan";
12
12
  import { placeholderOf } from "./stage-schema";
13
13
  import { primaryFailure, WorkflowStopped } from "./stopped";
14
14
 
@@ -89,6 +89,11 @@ export class StageLedger {
89
89
  /** The run's stage records as they stand, this attempt's included: what a stage by content reuses. */
90
90
  readonly #records: Map<string, StageRecord>;
91
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;
96
+
92
97
  constructor(
93
98
  private readonly options: {
94
99
  runDir: string;
@@ -102,9 +107,11 @@ export class StageLedger {
102
107
  fromStage?: string;
103
108
  /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
104
109
  values?: ReadonlyMap<string, JsonValue>;
110
+ /** A run of a workflow with `events`: the last `--from-stage` an attempt redid. */
111
+ redo?: StageRedo;
105
112
  /**
106
113
  * 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`.
114
+ * as `planStageByContent` says, and no `values`. Its start point is `fromStage` alone.
108
115
  */
109
116
  byContent?: boolean;
110
117
  /** Writes the run's event log. */
@@ -113,6 +120,7 @@ export class StageLedger {
113
120
  },
114
121
  ) {
115
122
  this.#records = new Map(options.records);
123
+ this.#redo = options.redo;
116
124
  }
117
125
 
118
126
  /** The stage open now, if one is. */
@@ -313,10 +321,23 @@ export class StageLedger {
313
321
 
314
322
  /**
315
323
  * 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.
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
326
  */
318
327
  async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
319
- const decision = planStageByContent(this.#records.get(name), entry.result);
328
+ 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);
320
341
  if (decision.kind === "stop") {
321
342
  this.#stopped = new WorkflowStopped(decision.reason, name, true);
322
343
  this.#stoppedAt(name, at);
@@ -347,6 +368,20 @@ export class StageLedger {
347
368
  return { kind: "run", stage: this.#opened(name, at) };
348
369
  }
349
370
 
371
+ /**
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.
376
+ */
377
+ #startByContent(name: string, at: number): EnteredStage {
378
+ const stage = this.#opened(name, at);
379
+ this.#started = true;
380
+ this.#redo = { stage: name, attempt: this.options.attempt };
381
+ this.#afterRedo = true;
382
+ return { kind: "run", stage };
383
+ }
384
+
350
385
  /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
351
386
  #stoppedAt(name: string, at: number): void {
352
387
  this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
@@ -506,14 +541,9 @@ export class StageLedger {
506
541
  });
507
542
  // By content, a failed record this one replaces is kept, as a redone stage's is.
508
543
  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
- )
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())
517
547
  .then(() => writeStageRecord(this.options.runDir, record))
518
548
  .then(
519
549
  () => {
package/src/stage-plan.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { StageNeed, StageRecord } from "@agentswf/contract/records";
1
+ import type {
2
+ AttemptRecord,
3
+ AttemptStage,
4
+ StageNeed,
5
+ StageRecord,
6
+ } from "@agentswf/contract/records";
2
7
  import { type JsonSchema, type SchemaError, validate } from "@agentswf/contract/schema";
3
8
  import type { JsonValue } from "@agentswf/contract/workflow";
4
9
 
@@ -148,3 +153,34 @@ export function planStageByContent(
148
153
  ? { kind: "reuse", record, value: record.value }
149
154
  : { kind: "stop", reason: misfit };
150
155
  }
156
+
157
+ /**
158
+ * A `--from-stage {stage}` that attempt `attempt` redid, in a run of a workflow with `events`. In
159
+ * that attempt and every later one, a stage first entered after `stage` whose record an earlier
160
+ * attempt than `attempt` wrote runs again, its record replaced: it was made against the stage
161
+ * before the redo. A record never entered after the stage keeps its reuse.
162
+ */
163
+ export type StageRedo = { stage: string; attempt: number };
164
+
165
+ /**
166
+ * The last `--from-stage` an earlier attempt of a run with `events` redid: the latest attempt with
167
+ * one that ran its stage. Its attempt record says so in `stages`, which a stop for a stage never
168
+ * reached, a refusal and a crash leave without it; a crash after the stage's new record was written
169
+ * leaves that record, the attempt's own. A redo whose stage failed and ran again on a wake is still
170
+ * the redo. Only attempt and stage records are read, which a continue may read.
171
+ */
172
+ export function lastRedo(
173
+ attempts: readonly (Pick<AttemptRecord, "attempt" | "flags"> & {
174
+ stages?: readonly Pick<AttemptStage, "stage" | "source">[];
175
+ })[],
176
+ records: ReadonlyMap<string, StageRecord>,
177
+ before: number,
178
+ ): StageRedo | undefined {
179
+ for (const { attempt, flags, stages } of [...attempts].sort((a, b) => b.attempt - a.attempt)) {
180
+ const stage = flags.fromStage;
181
+ if (attempt >= before || stage === undefined) continue;
182
+ const ran = stages?.some((each) => each.stage === stage && each.source === "ran") ?? false;
183
+ if (ran || records.get(stage)?.attempt === attempt) return { stage, attempt };
184
+ }
185
+ return undefined;
186
+ }
@@ -24,7 +24,7 @@ export async function testCommand(
24
24
  ): Promise<number> {
25
25
  let tests: TestCommand | "help";
26
26
  try {
27
- tests = parseTestCommand(argv, cwd);
27
+ tests = parseTestCommand(argv, cwd, (line) => io.stderr(line));
28
28
  } catch (error) {
29
29
  io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
30
30
  return 2;
@@ -43,7 +43,11 @@ export async function testCommand(
43
43
  * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
44
44
  * and a later runner can take its place. A path must exist; Bun would take it as a filter.
45
45
  */
46
- export function parseTestCommand(argv: readonly string[], cwd: string): TestCommand | "help" {
46
+ export function parseTestCommand(
47
+ argv: readonly string[],
48
+ cwd: string,
49
+ deprecated: (line: string) => void = () => {},
50
+ ): TestCommand | "help" {
47
51
  const paths: string[] = [];
48
52
  const bunArgs: string[] = [];
49
53
  for (let index = 0; index < argv.length; index += 1) {
@@ -58,7 +62,7 @@ export function parseTestCommand(argv: readonly string[], cwd: string): TestComm
58
62
  index += 1;
59
63
  } else if (option === "--timeout") {
60
64
  if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
61
- bunArgs.push("--timeout", String(parseDuration(value)));
65
+ bunArgs.push("--timeout", String(parseDuration(value, "--timeout", deprecated)));
62
66
  index += 1;
63
67
  } else if (option.startsWith("-")) {
64
68
  throw new Error(`unknown option: ${option}`);
package/src/testing.ts CHANGED
@@ -6,11 +6,9 @@ import type { AttemptRecord } from "@agentswf/contract/records";
6
6
  import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
7
7
  import {
8
8
  defineWorkflow,
9
- type EventWorkflowContext,
10
9
  type JsonValue,
11
10
  type OutputSchema,
12
11
  type WorkflowContext,
13
- type WorkflowDefinition,
14
12
  } from "@agentswf/contract/workflow";
15
13
  import { createSingleSessionHostFactory } from "@agentswf/harness";
16
14
  import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
@@ -19,6 +17,7 @@ import Type from "typebox";
19
17
  import { writeJson } from "./files";
20
18
  import { OPERATOR_ALIASES } from "./operator-aliases";
21
19
  import { createRun, openRun, type Run, readAttempts } from "./runs";
20
+ import type { RunnableWorkflow } from "./workflow-runner";
22
21
  import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
23
22
 
24
23
  export function createTempRunDirs(): {
@@ -81,11 +80,11 @@ const Changes: OutputSchema<{ case: string }> = {
81
80
  properties: { case: { type: "string" } },
82
81
  required: ["case"],
83
82
  };
84
- const caseEvents = { "dd.changes": Changes };
83
+ export const caseEvents = { "dd.changes": Changes };
85
84
 
86
85
  /** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
87
86
  export const caseRun = (
88
- run: (workflow: EventWorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
87
+ run: (workflow: WorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
89
88
  ) =>
90
89
  defineWorkflow({
91
90
  meta: { name: "dd-case", description: "A case." },
@@ -140,7 +139,7 @@ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties:
140
139
  export function workflowOf<Result extends JsonValue>(
141
140
  run: (workflow: WorkflowContext) => Promise<Result>,
142
141
  meta: { name?: string; version?: string } = {},
143
- ): WorkflowDefinition<null, Result> {
142
+ ): RunnableWorkflow<null, Result> {
144
143
  const { name = "staged", version } = meta;
145
144
  return {
146
145
  meta: { name, description: name, ...(version === undefined ? {} : { version }) },
@@ -168,7 +167,7 @@ export function answering(): AgentSessionAdapter {
168
167
  * `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
169
168
  */
170
169
  export async function runAttempt<Result extends JsonValue>(
171
- workflow: WorkflowDefinition<null, Result>,
170
+ workflow: RunnableWorkflow<null, Result>,
172
171
  options: {
173
172
  runRoot: string;
174
173
  attempt?: number;
@@ -206,7 +205,7 @@ export async function runAttempt<Result extends JsonValue>(
206
205
 
207
206
  /** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
208
207
  export async function startNew<Args extends JsonValue, Result extends JsonValue>(
209
- definition: WorkflowDefinition<Args, Result>,
208
+ definition: RunnableWorkflow<Args, Result>,
210
209
  args: Args,
211
210
  options: Omit<RunWorkflowOptions, "run">,
212
211
  ) {
@@ -224,7 +223,7 @@ export async function startNew<Args extends JsonValue, Result extends JsonValue>
224
223
 
225
224
  /** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
226
225
  export async function runNew<Args extends JsonValue, Result extends JsonValue>(
227
- definition: WorkflowDefinition<Args, Result>,
226
+ definition: RunnableWorkflow<Args, Result>,
228
227
  args: Args,
229
228
  options: Omit<RunWorkflowOptions, "run">,
230
229
  ) {