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

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 (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. 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) => {
@@ -159,7 +174,7 @@ export class StageLedger {
159
174
  }
160
175
 
161
176
  /**
162
- * `workflow.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
177
+ * `wf.stop`, called in `stage` or between stages: what to throw. The first stop is kept,
163
178
  * and any later stop throws it again; once it was found caught, that instead. After the
164
179
  * workflow's body ended, a stop ends nothing.
165
180
  */
@@ -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,23 +3,42 @@ 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 help from "./help/test.txt" with { type: "text" };
8
+ import type { Out } from "./run-commands";
6
9
 
7
10
  const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
8
11
 
9
- export const testUsage = [
10
- "usage: awf test [paths...] [-t <pattern>] [--watch] [--timeout <duration>]",
11
- "",
12
- "Runs a workflow's tests (*.test.ts and *.spec.ts files) under the current directory, or under",
13
- "each file or directory given, with agentswf/workflow, agentswf/testing and typebox served as",
14
- "awf run serves them: nothing is installed. -t runs the tests whose names match the pattern;",
15
- "--watch reruns them on a change; --timeout bounds each test, 5s by default.",
16
- "",
17
- "Exits 0 when every test passed, 1 when one failed or none was found, 2 on a usage error. What",
18
- "it prints is for reading, not parsing.",
19
- ].join("\n");
12
+ export const testUsage = help.trimEnd();
20
13
 
21
14
  export type TestCommand = { paths: string[]; bunArgs: string[] };
22
15
 
16
+ /**
17
+ * `awf test`: the workflow tests under the given paths, with Bun's test runner. Their output goes
18
+ * to `io.output` when given, else straight to this process's.
19
+ */
20
+ export async function testCommand(
21
+ argv: readonly string[],
22
+ cwd: string,
23
+ io: Out & { output?: Out; signal?: AbortSignal },
24
+ ): Promise<number> {
25
+ let tests: TestCommand | "help";
26
+ try {
27
+ tests = parseTestCommand(argv, cwd);
28
+ } catch (error) {
29
+ io.stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
30
+ return 2;
31
+ }
32
+ if (tests === "help") {
33
+ io.stdout(testUsage);
34
+ return 0;
35
+ }
36
+ return runWorkflowTests(tests, cwd, {
37
+ ...(io.signal ? { signal: io.signal } : {}),
38
+ ...(io.output ? { output: io.output } : {}),
39
+ });
40
+ }
41
+
23
42
  /**
24
43
  * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
25
44
  * and a later runner can take its place. A path must exist; Bun would take it as a filter.
@@ -91,6 +110,9 @@ export async function runWorkflowTests(
91
110
  );
92
111
  if (signal?.aborted) stop();
93
112
  signal?.addEventListener("abort", stop, { once: true });
113
+ // awf exiting at a second signal does not wait for the tests, which would outlive it.
114
+ const orphaned = () => child.kill("SIGKILL");
115
+ process.on("exit", orphaned);
94
116
  try {
95
117
  if (output) {
96
118
  const [out, err] = await Promise.all([
@@ -106,5 +128,6 @@ export async function runWorkflowTests(
106
128
  return ended ? 128 + constants.signals[ended] : code;
107
129
  } finally {
108
130
  signal?.removeEventListener("abort", stop);
131
+ process.off("exit", orphaned);
109
132
  }
110
133
  }
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,13 +39,72 @@ 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 } {
36
104
  return { unixMilliseconds: Date.now() + milliseconds };
37
105
  }
38
106
 
39
- /** Answers a call the way an agent's `wf` does: one framed request over its own socket. */
107
+ /** Answers a call the way an agent's `wfa` does: one framed request over its own socket. */
40
108
  export async function submit(
41
109
  binding: { endpoint: string; operationId: string },
42
110
  value: unknown,
@@ -0,0 +1,76 @@
1
+ import type { TickResultJson } from "@agentswf/contract/records";
2
+ import { messageOf } from "./errors";
3
+ import { exists } from "./files";
4
+ import help from "./help/tick.txt" with { type: "text" };
5
+ import { listProjects, projectRunRoot } from "./projects";
6
+ import type { Out } from "./run-commands";
7
+ import { parsePlace } from "./run-commands";
8
+ import { runLockFile, withRunLock } from "./run-lock";
9
+ import { listRuns } from "./run-state";
10
+ import { type Launch, wake } from "./wake";
11
+
12
+ export const tickUsage = help.trimEnd();
13
+
14
+ export async function tickCommand(
15
+ argv: readonly string[],
16
+ shellCwd: string,
17
+ out: Out,
18
+ context: {
19
+ home: string;
20
+ environment: Readonly<Record<string, string | undefined>>;
21
+ launch?: Launch;
22
+ now?: () => number;
23
+ },
24
+ ): Promise<number> {
25
+ if (argv.includes("-h") || argv.includes("--help")) {
26
+ out.stdout(tickUsage);
27
+ return 0;
28
+ }
29
+ let parsed: ReturnType<typeof parsePlace>;
30
+ try {
31
+ parsed = parsePlace(argv, shellCwd);
32
+ const unknown = parsed.rest.find((word) => word !== "--all");
33
+ if (unknown !== undefined) throw new Error(`tick takes no ${unknown}`);
34
+ if (parsed.rest.includes("--all") && parsed.place.repeat.length > 0)
35
+ throw new Error("--all wakes every project's runs; it takes no --cwd or --run-root");
36
+ } catch (error) {
37
+ out.stderr(`awf: ${messageOf(error)}\n\n${tickUsage}`);
38
+ return 2;
39
+ }
40
+ const { place, json } = parsed;
41
+ const woke: TickResultJson["woke"] = [];
42
+ let failed = false;
43
+ const roots: string[] = [];
44
+ if (!parsed.rest.includes("--all")) roots.push(place.runRoot);
45
+ else
46
+ for (const project of await listProjects(context.home)) {
47
+ if (await exists(project)) roots.push(projectRunRoot(project));
48
+ else {
49
+ failed = true;
50
+ out.stderr(`awf: ${project}, in ~/.awf/projects, is gone; its runs are not woken`);
51
+ }
52
+ }
53
+ for (const root of roots) {
54
+ for (const run of await listRuns(root)) {
55
+ if (!run.record.events) continue;
56
+ const ref = `${run.record.workflow}/${run.record.id}`;
57
+ try {
58
+ const { workflow, id } = run.record;
59
+ const why = await withRunLock(runLockFile(root, workflow, id), async () => {
60
+ const woken = await wake(run, { ...context, nudgeLive: false });
61
+ if (woken.why === "launch-failed") throw new Error(woken.error ?? "launch failed");
62
+ if ((woken.did !== "started" && woken.did !== "continued") || !woken.cause)
63
+ return undefined;
64
+ return { did: woken.did, why: woken.cause };
65
+ });
66
+ if (why) woke.push({ ref, ...why });
67
+ } catch (error) {
68
+ failed = true;
69
+ out.stderr(`awf: ${ref}: ${messageOf(error)}`);
70
+ }
71
+ }
72
+ }
73
+ if (json) out.stdout(JSON.stringify({ v: 1, woke } satisfies TickResultJson));
74
+ else for (const each of woke) out.stdout(`${each.did} ${each.ref}: ${each.why}`);
75
+ return failed ? 1 : 0;
76
+ }