@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.3

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 (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -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 } from "./stage-plan";
11
+ import { planStage, planStageByContent } from "./stage-plan";
12
12
  import { placeholderOf } from "./stage-schema";
13
13
  import { primaryFailure, WorkflowStopped } from "./stopped";
14
14
 
@@ -86,6 +86,8 @@ export class StageLedger {
86
86
  */
87
87
  readonly #looked = new Set<string>();
88
88
  #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>;
89
91
 
90
92
  constructor(
91
93
  private readonly options: {
@@ -100,11 +102,23 @@ export class StageLedger {
100
102
  fromStage?: string;
101
103
  /** `--values`: the values of stages before `fromStage` that have no record to reuse. */
102
104
  values?: ReadonlyMap<string, JsonValue>;
105
+ /**
106
+ * 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`.
108
+ */
109
+ byContent?: boolean;
103
110
  /** Writes the run's event log. */
104
111
  event?(body: RunEventBody, at?: number): void;
105
112
  now?: () => Date;
106
113
  },
107
- ) {}
114
+ ) {
115
+ this.#records = new Map(options.records);
116
+ }
117
+
118
+ /** The stage open now, if one is. */
119
+ get openStage(): string | undefined {
120
+ return this.#open?.name;
121
+ }
108
122
 
109
123
  /** A `--from-stage` the attempt never reached: a typo, or a branch not taken. */
110
124
  get fromStageUnreached(): string | undefined {
@@ -144,9 +158,10 @@ export class StageLedger {
144
158
  * entered yet, in the order they first started.
145
159
  */
146
160
  progress(): { stages: StageProgress[]; upcoming: string[] } {
147
- const upcoming = this.recorded
148
- .map((record) => record.stage)
149
- .filter((stage) => !this.#stages.has(stage));
161
+ // By content, an earlier event's stage may never be entered again: none is upcoming.
162
+ const upcoming = this.options.byContent
163
+ ? []
164
+ : this.recorded.map((record) => record.stage).filter((stage) => !this.#stages.has(stage));
150
165
  const recorded = (stage: string) => this.options.records?.get(stage)?.ended;
151
166
  return {
152
167
  stages: [...this.#stages.values()].map((entered) => {
@@ -196,6 +211,7 @@ export class StageLedger {
196
211
  if (caught) throw caught;
197
212
  this.#check(name);
198
213
  const at = this.#now().getTime();
214
+ if (this.options.byContent) return this.#enterByContent(name, entry, at);
199
215
  const decision = planStage(
200
216
  {
201
217
  records: this.options.records ?? new Map(),
@@ -295,6 +311,42 @@ export class StageLedger {
295
311
  return { kind: "run", stage };
296
312
  }
297
313
 
314
+ /**
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
+ */
318
+ async #enterByContent(name: string, entry: StageEntry, at: number): Promise<EnteredStage> {
319
+ const decision = planStageByContent(this.#records.get(name), entry.result);
320
+ if (decision.kind === "stop") {
321
+ this.#stopped = new WorkflowStopped(decision.reason, name, true);
322
+ this.#stoppedAt(name, at);
323
+ throw this.#stopped;
324
+ }
325
+ if (decision.kind === "reuse") {
326
+ const held = this.#hold(name, decision.value);
327
+ this.#reused.add(name);
328
+ // Entered again in the attempt that ran or reused it: nothing more to show or log.
329
+ if (this.#stages.get(name)?.outcome === "succeeded") return held;
330
+ const { attempt, summary } = decision.record;
331
+ this.options.event?.(
332
+ { kind: "stage-ended", stage: name, outcome: "succeeded", source: "reused" },
333
+ at,
334
+ );
335
+ this.#stages.set(name, {
336
+ stage: name,
337
+ source: "reused",
338
+ outcome: "succeeded",
339
+ attempt,
340
+ startedAt: at,
341
+ endedAt: at,
342
+ ...(summary === undefined ? {} : { summary }),
343
+ ...(decision.value === undefined ? {} : { value: decision.value }),
344
+ });
345
+ return held;
346
+ }
347
+ return { kind: "run", stage: this.#opened(name, at) };
348
+ }
349
+
298
350
  /** A stage the plan stopped as it was entered: shown stopped, though it ran nothing. */
299
351
  #stoppedAt(name: string, at: number): void {
300
352
  this.options.event?.({ kind: "stage-ended", stage: name, outcome: "stopped" }, at);
@@ -411,7 +463,7 @@ export class StageLedger {
411
463
  `stage ${name} was entered while stage ${this.#open.name} is open; one stage runs at a time, and parallel work goes inside one`,
412
464
  );
413
465
  }
414
- if (this.#stages.has(name) || this.#looked.has(name)) {
466
+ if (!this.options.byContent && (this.#stages.has(name) || this.#looked.has(name))) {
415
467
  throw new Error(`stage ${name} was entered twice; a loop goes inside one stage`);
416
468
  }
417
469
  }
@@ -452,13 +504,26 @@ export class StageLedger {
452
504
  ...(record.summary === undefined ? {} : { summary: record.summary }),
453
505
  ...(record.value === undefined ? {} : { value: record.value }),
454
506
  });
455
- ended = writeStageRecord(this.options.runDir, record).then(
456
- () => undefined,
457
- (error: unknown) => {
458
- entered.outcome = "failed";
459
- throw error;
460
- },
461
- );
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
+ );
462
527
  // Its outcome once the record is written: one that is not fails the stage.
463
528
  const logEnd = () =>
464
529
  this.options.event?.(
@@ -484,6 +549,16 @@ export class StageLedger {
484
549
  return stage;
485
550
  }
486
551
 
552
+ /**
553
+ * Leaves the open stage `name` unended and unrecorded, as the run parked at a wait in it: the
554
+ * next attempt runs it again.
555
+ */
556
+ abandon(name: string): void {
557
+ if (this.#open?.name !== name) return;
558
+ this.#open = undefined;
559
+ this.#stages.delete(name);
560
+ }
561
+
487
562
  /** Once the workflow's body has ended, why: no stage is entered after it. */
488
563
  seal(reason: string): void {
489
564
  if (this.#sealed !== undefined) return;
package/src/stage-plan.ts CHANGED
@@ -92,6 +92,12 @@ function unreusable(
92
92
  if (recorded !== undefined && now !== undefined && recorded !== now) {
93
93
  return `${stage} was recorded by ${record.workflowVersion}; this is ${state.workflowVersion}`;
94
94
  }
95
+ return misfitOf(record, result);
96
+ }
97
+
98
+ /** Why a succeeded record's value no longer fits the stage's `result`; undefined when it does. */
99
+ function misfitOf(record: StageRecord, result: JsonSchema | undefined): string | undefined {
100
+ const { stage } = record;
95
101
  if (result === undefined) {
96
102
  return record.value === undefined
97
103
  ? undefined
@@ -125,3 +131,20 @@ function majorOf(version: string | undefined): string | undefined {
125
131
  const [major, minor] = version.split(".");
126
132
  return major === "0" ? `0.${minor}` : major;
127
133
  }
134
+
135
+ /**
136
+ * In a run of a workflow with `events`, a stage runs once ever: a succeeded record is reused
137
+ * wherever and however often its name is entered, whatever version of the code recorded it, as
138
+ * long as its value still fits the stage's `result`. One that doesn't fit stops the attempt; a
139
+ * failed record, or none, runs.
140
+ */
141
+ export function planStageByContent(
142
+ record: StageRecord | undefined,
143
+ result: JsonSchema | undefined,
144
+ ): Extract<StagePlanDecision, { kind: "reuse" | "stop" }> | { kind: "run" } {
145
+ if (record?.outcome !== "succeeded") return { kind: "run" };
146
+ const misfit = misfitOf(record, result);
147
+ return misfit === undefined
148
+ ? { kind: "reuse", record, value: record.value }
149
+ : { kind: "stop", reason: misfit };
150
+ }
@@ -0,0 +1,39 @@
1
+ import type { AttemptRecord } from "@agentswf/contract/records";
2
+ import { runProcess } from "@agentswf/harness";
3
+ import { ensureRunSession, runSessionName } from "./herdr-run-session";
4
+ import { readMarks } from "./herdr-workspace-marks";
5
+ import { probeProcess } from "./runs";
6
+
7
+ /**
8
+ * Stops what a crashed attempt left working before a continue writes its turns' failed ends (Q17):
9
+ * what is left of its process group, which a detached `awf run` leads, and its panes, by sweeping
10
+ * its Herdr session, which closes the workspaces of runs whose process is gone. A run whose attempt
11
+ * opened no pane starts no Herdr. A harness's own process runs in a session of its own, so one the
12
+ * attempt orphaned isn't reached here; it is not stopped yet.
13
+ */
14
+ export async function stopCrashed(
15
+ previous: AttemptRecord,
16
+ label: string,
17
+ context: { home: string; environment: Readonly<Record<string, string | undefined>> },
18
+ ): Promise<void> {
19
+ // Only once its leader is gone for good: a live group's id is never handed out again, so a group
20
+ // still there is the attempt's leftovers, never another process that took its pid since. One an
21
+ // `awf run` typed in a terminal led is its pipeline's, which goes with it.
22
+ if (probeProcess(previous.pid) === "gone") {
23
+ try {
24
+ process.kill(-previous.pid, "SIGTERM");
25
+ await Bun.sleep(500);
26
+ process.kill(-previous.pid, "SIGKILL");
27
+ } catch {
28
+ // No such group, or it ended at the first signal.
29
+ }
30
+ }
31
+ const name = runSessionName(context.environment);
32
+ const marks = await readMarks(context.home, name);
33
+ if (!marks.some((mark) => mark.label === label)) return;
34
+ await ensureRunSession(name, {
35
+ run: runProcess,
36
+ environment: context.environment,
37
+ home: context.home,
38
+ });
39
+ }
@@ -3,6 +3,8 @@ import { constants } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseDuration } from "./duration";
6
+ import { messageOf } from "./errors";
7
+ import type { Out } from "./run-commands";
6
8
 
7
9
  const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
8
10
 
@@ -20,6 +22,32 @@ export const testUsage = [
20
22
 
21
23
  export type TestCommand = { paths: string[]; bunArgs: string[] };
22
24
 
25
+ /**
26
+ * `awf test`: the workflow tests under the given paths, with Bun's test runner. Their output goes
27
+ * to `io.output` when given, else straight to this process's.
28
+ */
29
+ export async function testCommand(
30
+ argv: readonly string[],
31
+ cwd: string,
32
+ io: Out & { output?: Out; signal?: AbortSignal },
33
+ ): Promise<number> {
34
+ let tests: TestCommand | "help";
35
+ try {
36
+ tests = parseTestCommand(argv, cwd);
37
+ } catch (error) {
38
+ io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
39
+ return 2;
40
+ }
41
+ if (tests === "help") {
42
+ io.stdout(testUsage);
43
+ return 0;
44
+ }
45
+ return runWorkflowTests(tests, cwd, {
46
+ ...(io.signal ? { signal: io.signal } : {}),
47
+ ...(io.output ? { output: io.output } : {}),
48
+ });
49
+ }
50
+
23
51
  /**
24
52
  * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
25
53
  * and a later runner can take its place. A path must exist; Bun would take it as a filter.
@@ -91,6 +119,9 @@ export async function runWorkflowTests(
91
119
  );
92
120
  if (signal?.aborted) stop();
93
121
  signal?.addEventListener("abort", stop, { once: true });
122
+ // awf exiting at a second signal does not wait for the tests, which would outlive it.
123
+ const orphaned = () => child.kill("SIGKILL");
124
+ process.on("exit", orphaned);
94
125
  try {
95
126
  if (output) {
96
127
  const [out, err] = await Promise.all([
@@ -106,5 +137,6 @@ export async function runWorkflowTests(
106
137
  return ended ? 128 + constants.signals[ended] : code;
107
138
  } finally {
108
139
  signal?.removeEventListener("abort", stop);
140
+ process.off("exit", orphaned);
109
141
  }
110
142
  }
package/src/testing.ts CHANGED
@@ -1,15 +1,24 @@
1
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { createConnection } from "node:net";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { AttemptRecord } from "@agentswf/contract/records";
5
6
  import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
6
- import type { JsonValue, WorkflowContext, WorkflowDefinition } from "@agentswf/contract/workflow";
7
+ import {
8
+ defineWorkflow,
9
+ type EventWorkflowContext,
10
+ type JsonValue,
11
+ type OutputSchema,
12
+ type WorkflowContext,
13
+ type WorkflowDefinition,
14
+ } from "@agentswf/contract/workflow";
7
15
  import { createSingleSessionHostFactory } from "@agentswf/harness";
8
16
  import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
9
17
  import { createFakeAdapter } from "@agentswf/harness/testing";
10
18
  import Type from "typebox";
19
+ import { writeJson } from "./files";
11
20
  import { OPERATOR_ALIASES } from "./operator-aliases";
12
- import { createRun, openRun } from "./runs";
21
+ import { createRun, openRun, type Run, readAttempts } from "./runs";
13
22
  import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
14
23
 
15
24
  export function createTempRunDirs(): {
@@ -30,6 +39,65 @@ export function createTempRunDirs(): {
30
39
  };
31
40
  }
32
41
 
42
+ /** A project, `.awf/` at its root, with `source` at `flows/{name}`, and a home of its own. */
43
+ export function eventProject(
44
+ dirs: ReturnType<typeof createTempRunDirs>,
45
+ name: string,
46
+ source: string,
47
+ ): { root: string; file: string; home: string } {
48
+ const root = dirs.tempRunDir();
49
+ mkdirSync(join(root, ".awf"));
50
+ mkdirSync(join(root, "flows"));
51
+ const file = join(root, "flows", name);
52
+ writeFileSync(file, source);
53
+ return { root, file, home: dirs.tempRunDir() };
54
+ }
55
+
56
+ /** Polls `check` until it holds; throws after `ms`. */
57
+ export async function until(check: () => Promise<boolean>, ms = 20_000): Promise<void> {
58
+ const end = Date.now() + ms;
59
+ while (!(await check())) {
60
+ if (Date.now() > end) throw new Error("timed out");
61
+ await Bun.sleep(50);
62
+ }
63
+ }
64
+
65
+ /** Writes the run's next attempt record, as `awf run` claims one, with `fields` over defaults. */
66
+ export async function writeAttempt(run: Run, fields: Partial<AttemptRecord>): Promise<void> {
67
+ const n = (await readAttempts(run)).length + 1;
68
+ mkdirSync(join(run.dir, "attempts"), { recursive: true });
69
+ await writeJson(join(run.dir, "attempts", `${n}.json`), {
70
+ version: 1,
71
+ attempt: n,
72
+ file: "/flows/case.ts",
73
+ flags: {},
74
+ started: new Date().toISOString(),
75
+ ...fields,
76
+ });
77
+ }
78
+
79
+ const Changes: OutputSchema<{ case: string }> = {
80
+ type: "object",
81
+ properties: { case: { type: "string" } },
82
+ required: ["case"],
83
+ };
84
+ const caseEvents = { "dd.changes": Changes };
85
+
86
+ /** A workflow with one event, `dd.changes`, keyed by its case, whose body is `run`. */
87
+ export const caseRun = (
88
+ run: (workflow: EventWorkflowContext<typeof caseEvents>) => Promise<JsonValue>,
89
+ ) =>
90
+ defineWorkflow({
91
+ meta: { name: "dd-case", description: "A case." },
92
+ file: import.meta.path,
93
+ events: caseEvents,
94
+ starts: ["dd.changes"],
95
+ run,
96
+ });
97
+
98
+ /** The options that start a `caseRun` under the key `k`. */
99
+ export const startCase = { id: "k", event: { name: "dd.changes" as const, data: { case: "k" } } };
100
+
33
101
  export { COUNT_SCHEMA } from "@agentswf/contract/testing";
34
102
 
35
103
  export function future(milliseconds = 60_000): { unixMilliseconds: number } {
@@ -0,0 +1,87 @@
1
+ import type { TickResultJson } from "@agentswf/contract/records";
2
+ import { messageOf } from "./errors";
3
+ import { exists } from "./files";
4
+ import { listProjects, projectRunRoot } from "./projects";
5
+ import type { Out } from "./run-commands";
6
+ import { parsePlace } from "./run-commands";
7
+ import { runLockFile, withRunLock } from "./run-lock";
8
+ import { listRuns } from "./run-state";
9
+ import { type Launch, wake } from "./wake";
10
+
11
+ export const tickUsage = [
12
+ "usage: awf tick [--cwd <directory>] [--run-root <directory>] [--json]",
13
+ " awf tick --all [--json]",
14
+ "",
15
+ "Wakes every run of a workflow with events under --cwd's .awf/runs, or --run-root, the runs awf",
16
+ "runs lists there, as an event would: continues a parked run whose wait's timer is due, and a",
17
+ "crashed or failed one until it has failed three times in a row. Runs of other workflows are left",
18
+ "as they are. --all wakes them in every project awf has started one in, as ~/.awf/projects lists",
19
+ "them. Run it on any schedule; a run is continued once however many wakers come at once.",
20
+ "",
21
+ "Prints a line for each run it woke, and nothing when it woke none. --json prints one document.",
22
+ "Exits 0, or 1 when a project is gone or a run could not be woken; the others are still woken.",
23
+ ].join("\n");
24
+
25
+ export async function tickCommand(
26
+ argv: readonly string[],
27
+ shellCwd: string,
28
+ out: Out,
29
+ context: {
30
+ home: string;
31
+ environment: Readonly<Record<string, string | undefined>>;
32
+ launch?: Launch;
33
+ now?: () => number;
34
+ },
35
+ ): Promise<number> {
36
+ if (argv.includes("-h") || argv.includes("--help")) {
37
+ out.stdout(tickUsage);
38
+ return 0;
39
+ }
40
+ let parsed: ReturnType<typeof parsePlace>;
41
+ try {
42
+ parsed = parsePlace(argv, shellCwd);
43
+ const unknown = parsed.rest.find((word) => word !== "--all");
44
+ if (unknown !== undefined) throw new Error(`tick takes no ${unknown}`);
45
+ if (parsed.rest.includes("--all") && parsed.place.repeat.length > 0)
46
+ throw new Error("--all wakes every project's runs; it takes no --cwd or --run-root");
47
+ } catch (error) {
48
+ out.stderr(`awf: ${messageOf(error)}\n\n${tickUsage}`);
49
+ return 2;
50
+ }
51
+ const { place, json } = parsed;
52
+ const woke: TickResultJson["woke"] = [];
53
+ let failed = false;
54
+ const roots: string[] = [];
55
+ if (!parsed.rest.includes("--all")) roots.push(place.runRoot);
56
+ else
57
+ for (const project of await listProjects(context.home)) {
58
+ if (await exists(project)) roots.push(projectRunRoot(project));
59
+ else {
60
+ failed = true;
61
+ out.stderr(`awf: ${project}, in ~/.awf/projects, is gone; its runs are not woken`);
62
+ }
63
+ }
64
+ for (const root of roots) {
65
+ for (const run of await listRuns(root)) {
66
+ if (!run.record.events) continue;
67
+ const ref = `${run.record.workflow}/${run.record.id}`;
68
+ try {
69
+ const { workflow, id } = run.record;
70
+ const why = await withRunLock(runLockFile(root, workflow, id), async () => {
71
+ const woken = await wake(run, { ...context, nudgeLive: false });
72
+ if (woken.why === "launch-failed") throw new Error(woken.error ?? "launch failed");
73
+ if ((woken.did !== "started" && woken.did !== "continued") || !woken.cause)
74
+ return undefined;
75
+ return { did: woken.did, why: woken.cause };
76
+ });
77
+ if (why) woke.push({ ref, ...why });
78
+ } catch (error) {
79
+ failed = true;
80
+ out.stderr(`awf: ${ref}: ${messageOf(error)}`);
81
+ }
82
+ }
83
+ }
84
+ if (json) out.stdout(JSON.stringify({ v: 1, woke } satisfies TickResultJson));
85
+ else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
86
+ return failed ? 1 : 0;
87
+ }