@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
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import type { ParticipantResultJson } from "@agentswf/contract/records";
5
+ import { decodeHolderRequest, type NudgeWireResult } from "@agentswf/contract/wire";
6
+ import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
7
+ import { realPath } from "./files";
8
+ import type { MessagingEventBody } from "./messaging/messaging";
9
+ import type { RunStatus } from "./run-state";
10
+
11
+ /** A request is a line of text and its JSON; well past a message's own 16 KB limit. */
12
+ const MAX_REQUEST_BYTES = 256 * 1024;
13
+ const CONNECTION_LIFETIME_MS = 30_000;
14
+
15
+ type Connection = {
16
+ chunks: Buffer[];
17
+ bytes: number;
18
+ /** Its request was taken, answered or refused: nothing more is read. */
19
+ taken: boolean;
20
+ timer?: ReturnType<typeof setTimeout>;
21
+ };
22
+
23
+ /**
24
+ * A run's coordinator socket: named by a hash of its run directory, so any depth of directory fits
25
+ * `sun_path`'s 104 bytes, in a folder only the operator can open.
26
+ */
27
+ export function coordinatorSocketPath(dir: string, runDir: string): string {
28
+ // Its real path: a holder reaching it through a symlink, or `/tmp` for `/private/tmp`, names it alike.
29
+ const name = createHash("sha256").update(realPath(runDir)).digest("hex").slice(0, 32);
30
+ return join(dir, `${name}.sock`);
31
+ }
32
+
33
+ /**
34
+ * Refuses a coordinator folder inside the workflow's directory, where its agents work, through
35
+ * symlinks too, and one given as a relative path, which each command would read from where it ran.
36
+ * Then makes it, the operator's alone, before any sandbox is resolved against it.
37
+ */
38
+ export async function prepareCoordinatorDir(
39
+ dir: string,
40
+ workflowDir: string,
41
+ /** It was moved with `AWF_COORDINATOR_DIR`; `~/.awf`'s own is kept from sandboxes as `~/.awf` is. */
42
+ moved = true,
43
+ ): Promise<void> {
44
+ if (!isAbsolute(dir)) throw new Error(`AWF_COORDINATOR_DIR ${dir} is not an absolute path`);
45
+ const real = async (path: string): Promise<string> => {
46
+ try {
47
+ return await realpath(path);
48
+ } catch {
49
+ // Not there yet: its parent's real path, and its own name.
50
+ const parent = dirname(path);
51
+ return parent === path ? path : join(await real(parent), basename(path));
52
+ }
53
+ };
54
+ const inside = relative(await real(workflowDir), await real(dir));
55
+ const within =
56
+ inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
57
+ if (moved && within)
58
+ throw new Error(
59
+ `AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
60
+ );
61
+ await mkdir(dir, { recursive: true, mode: 0o700 });
62
+ await chmod(dir, 0o700);
63
+ }
64
+
65
+ /**
66
+ * The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
67
+ * connects an outside participant and goes when the run ends; each connection is one request
68
+ * line and one `ParticipantResultJson` line back.
69
+ */
70
+ export function coordinatorSocket(options: {
71
+ dir: string;
72
+ runDir: string;
73
+ attempt: number;
74
+ /** Why the socket did not open: the run goes on, and no holder's command reaches it. */
75
+ failed(reason: string): void;
76
+ /** A nudge: the run's mailbox has a new event. Given, the socket opens at once, for awf's nudge. */
77
+ nudged?(): void;
78
+ }): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
79
+ const path = coordinatorSocketPath(options.dir, options.runDir);
80
+ let holder: CoordinatorHolder | undefined;
81
+ let listener: Bun.UnixSocketListener<{ chunks: Buffer[]; bytes: number }> | undefined;
82
+ let opening: Promise<void> | undefined;
83
+ let closed = false;
84
+ const id = (n: number) => `${options.attempt}.${n}`;
85
+
86
+ /** Undefined for a request no `awf` command makes: the connection closes unanswered. */
87
+ const answer = async (
88
+ frame: string,
89
+ ): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
90
+ let parsed: unknown;
91
+ try {
92
+ parsed = JSON.parse(frame);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ const decoded = decodeHolderRequest(parsed);
97
+ if (!decoded.ok) return undefined;
98
+ const request = decoded.value;
99
+ if (request.command === "nudge") {
100
+ if (!options.nudged) return undefined;
101
+ options.nudged();
102
+ return { v: 1, code: "nudged" };
103
+ }
104
+ if (!holder) return refused("no-participant", "the run has no participant connected");
105
+ // Another attempt's question is never open in this one: a number no message has.
106
+ const to =
107
+ request.to === undefined
108
+ ? undefined
109
+ : request.to.split(".")[0] === String(options.attempt)
110
+ ? Number(request.to.split(".")[1])
111
+ : 0;
112
+ const asked = {
113
+ ...(request.participant === undefined ? {} : { coordinator: request.participant }),
114
+ agent: request.agent,
115
+ ...(to === undefined ? {} : { to }),
116
+ text: request.text,
117
+ via: "command" as const,
118
+ };
119
+ const result =
120
+ request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
121
+ return result.code === "delivered"
122
+ ? { v: 1, code: "delivered", id: id(result.message) }
123
+ : {
124
+ v: 1,
125
+ code: result.code,
126
+ error: result.error,
127
+ ...(result.choices ? { choices: result.choices } : {}),
128
+ };
129
+ };
130
+
131
+ const open = async () => {
132
+ // A socket a killed run left here is no one's: this run's directory names it.
133
+ await rm(path, { force: true });
134
+ if (closed) return;
135
+ listener = Bun.listen<Connection>({
136
+ unix: path,
137
+ allowHalfOpen: true,
138
+ data: { chunks: [], bytes: 0, taken: false },
139
+ socket: {
140
+ open(socket) {
141
+ socket.data = {
142
+ chunks: [],
143
+ bytes: 0,
144
+ taken: false,
145
+ // Only while the request is read: an answer being made is never cut off.
146
+ timer: setTimeout(() => socket.terminate(), CONNECTION_LIFETIME_MS),
147
+ };
148
+ },
149
+ data(socket, data) {
150
+ if (socket.data.taken) return;
151
+ socket.data.bytes += data.byteLength;
152
+ if (socket.data.bytes > MAX_REQUEST_BYTES) {
153
+ take(socket);
154
+ socket.end(`${JSON.stringify(refused("too-large", "the request is too large"))}\n`);
155
+ return;
156
+ }
157
+ socket.data.chunks.push(Buffer.from(data));
158
+ // A request is one line: it is answered at its end, whether or not the client half-closes.
159
+ const text = Buffer.concat(socket.data.chunks).toString("utf8");
160
+ const newline = text.indexOf("\n");
161
+ if (newline >= 0) respond(socket, text.slice(0, newline));
162
+ },
163
+ end(socket) {
164
+ if (!socket.data.taken)
165
+ respond(socket, Buffer.concat(socket.data.chunks).toString("utf8"));
166
+ },
167
+ close(socket) {
168
+ clearTimeout(socket.data.timer);
169
+ },
170
+ error(socket) {
171
+ socket.terminate();
172
+ },
173
+ },
174
+ });
175
+ await chmod(path, 0o600);
176
+ };
177
+ const take = (socket: Bun.Socket<Connection>) => {
178
+ socket.data.taken = true;
179
+ clearTimeout(socket.data.timer);
180
+ };
181
+ const respond = (socket: Bun.Socket<Connection>, frame: string) => {
182
+ take(socket);
183
+ void answer(frame)
184
+ .catch(() => undefined)
185
+ .then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
186
+ };
187
+
188
+ if (options.nudged)
189
+ opening = open().catch((error: unknown) =>
190
+ options.failed(error instanceof Error ? error.message : String(error)),
191
+ );
192
+ return {
193
+ path,
194
+ attach(given) {
195
+ holder = given;
196
+ },
197
+ recorded(event: MessagingEventBody) {
198
+ if (event.kind === "participant-connected" && !opening) {
199
+ opening = open().catch((error: unknown) =>
200
+ options.failed(error instanceof Error ? error.message : String(error)),
201
+ );
202
+ }
203
+ },
204
+ async close() {
205
+ closed = true;
206
+ await opening;
207
+ listener?.stop(true);
208
+ if (listener) await rm(path, { force: true });
209
+ },
210
+ };
211
+ }
212
+
213
+ function refused(
214
+ code: Exclude<ParticipantResultJson["code"], "delivered">,
215
+ error: string,
216
+ ): ParticipantResultJson {
217
+ return { v: 1, code, error };
218
+ }
219
+
220
+ /**
221
+ * The socket of a run still live, or undefined: `run-state` decides, so a socket a killed run left
222
+ * behind is not trusted.
223
+ */
224
+ export async function liveCoordinatorSocket(
225
+ dir: string,
226
+ status: Pick<RunStatus, "state" | "run">,
227
+ ): Promise<string | undefined> {
228
+ if (status.state !== "running") return undefined;
229
+ const path = coordinatorSocketPath(dir, status.run.dir);
230
+ return (await stat(path).then(
231
+ () => true,
232
+ () => false,
233
+ ))
234
+ ? path
235
+ : undefined;
236
+ }
package/src/deadlines.ts CHANGED
@@ -7,23 +7,29 @@ export class WorkflowCancelledError extends Error {
7
7
  }
8
8
  }
9
9
 
10
- /** Races `execute` against `signal` and `deadline`; losing the race does not stop `execute`. */
10
+ /**
11
+ * Races `execute` against `signal` and `deadline`, if there is one; losing the race does not stop
12
+ * `execute`.
13
+ */
11
14
  export async function runUntilStopped<T>(
12
15
  execute: () => Promise<T>,
13
16
  signal: AbortSignal | undefined,
14
- deadline: AbsoluteDeadline,
17
+ deadline: AbsoluteDeadline | undefined,
15
18
  ): Promise<T> {
16
19
  if (signal?.aborted) throw new WorkflowCancelledError(signal.reason);
17
- if (Date.now() >= deadline.unixMilliseconds) throw new DeadlineExceededError(deadline);
20
+ if (deadline && Date.now() >= deadline.unixMilliseconds)
21
+ throw new DeadlineExceededError(deadline);
18
22
  let rejectStopped!: (error: Error) => void;
19
23
  const stopped = new Promise<never>((_resolve, reject) => {
20
24
  rejectStopped = reject;
21
25
  });
22
26
  const abort = () => rejectStopped(new WorkflowCancelledError(signal?.reason));
23
27
  signal?.addEventListener("abort", abort, { once: true });
24
- const cancelDeadline = scheduleAt(deadline, () => {
25
- rejectStopped(new DeadlineExceededError(deadline));
26
- });
28
+ const cancelDeadline = deadline
29
+ ? scheduleAt(deadline, () => {
30
+ rejectStopped(new DeadlineExceededError(deadline));
31
+ })
32
+ : () => undefined;
27
33
  try {
28
34
  return await Promise.race([execute(), stopped]);
29
35
  } finally {
@@ -32,7 +38,11 @@ export async function runUntilStopped<T>(
32
38
  }
33
39
  }
34
40
 
35
- export function waitForDeadline<T>(promise: Promise<T>, deadline: AbsoluteDeadline): Promise<T> {
41
+ export function waitForDeadline<T>(
42
+ promise: Promise<T>,
43
+ deadline: AbsoluteDeadline | undefined,
44
+ ): Promise<T> {
45
+ if (!deadline) return promise;
36
46
  let cancelTimer: (() => void) | undefined;
37
47
  const expired = new Promise<never>((_resolve, reject) => {
38
48
  cancelTimer = scheduleAt(deadline, () => reject(new DeadlineExceededError(deadline)));
@@ -60,7 +70,20 @@ export function scheduleAt(deadline: AbsoluteDeadline, action: () => void): () =
60
70
  };
61
71
  }
62
72
 
63
- export function earlierDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
73
+ /** The earlier of two deadlines, an absent one being none. */
74
+ export function earlierDeadline(
75
+ left: AbsoluteDeadline,
76
+ right: AbsoluteDeadline | undefined,
77
+ ): AbsoluteDeadline;
78
+ export function earlierDeadline(
79
+ left: AbsoluteDeadline | undefined,
80
+ right: AbsoluteDeadline | undefined,
81
+ ): AbsoluteDeadline | undefined;
82
+ export function earlierDeadline(
83
+ left: AbsoluteDeadline | undefined,
84
+ right: AbsoluteDeadline | undefined,
85
+ ): AbsoluteDeadline | undefined {
86
+ if (!left || !right) return left ?? right;
64
87
  return left.unixMilliseconds <= right.unixMilliseconds ? left : right;
65
88
  }
66
89
 
@@ -68,14 +91,18 @@ export function laterDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline):
68
91
  return left.unixMilliseconds >= right.unixMilliseconds ? left : right;
69
92
  }
70
93
 
71
- export function deadlineWithin(milliseconds: number, ceiling: AbsoluteDeadline): AbsoluteDeadline {
94
+ export function deadlineWithin(
95
+ milliseconds: number,
96
+ ceiling: AbsoluteDeadline | undefined,
97
+ ): AbsoluteDeadline {
72
98
  if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
73
99
  throw new Error("timeoutMs must be a positive safe integer");
74
100
  }
75
101
  return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
76
102
  }
77
103
 
78
- export function assertDeadline(deadline: AbsoluteDeadline): void {
104
+ export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
105
+ if (!deadline) return;
79
106
  assertDeadlineValue(deadline);
80
107
  if (Date.now() >= deadline.unixMilliseconds) {
81
108
  throw new DeadlineExceededError(deadline);
@@ -88,6 +115,16 @@ export function assertDeadlineValue(deadline: AbsoluteDeadline): void {
88
115
  }
89
116
  }
90
117
 
118
+ /**
119
+ * What a harness bounds a run, an agent or a turn by when nothing else does. Workflow code sees no
120
+ * deadline then; a harness needs one, and a timer cannot wait past about 24.8 days.
121
+ */
122
+ const HARNESS_BOUND_MS = 7 * 24 * 60 * 60_000;
123
+
124
+ export function harnessDeadline(deadline: AbsoluteDeadline | undefined): AbsoluteDeadline {
125
+ return deadline ?? { unixMilliseconds: Date.now() + HARNESS_BOUND_MS };
126
+ }
127
+
91
128
  /** Waits `milliseconds`, or until `signal` aborts, whichever is first. */
92
129
  export function abortableSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
93
130
  return new Promise((resolve) => {
@@ -34,9 +34,9 @@ import {
34
34
  const MAX_ATTEMPTS = 3;
35
35
  const FIRST_BACKOFF_MILLISECONDS = 500;
36
36
 
37
- /** Where a call runs: its scope's deadline, and the scope's way of cancelling what it owns. */
37
+ /** Where a call runs: its scope's deadline, if any, and the scope's way of cancelling what it owns. */
38
38
  export type DecisionScope = {
39
- deadline: AbsoluteDeadline;
39
+ deadline: AbsoluteDeadline | undefined;
40
40
  /** Returns the removal. */
41
41
  add?(cancel: (reason: string) => Promise<unknown>): () => void;
42
42
  /** The workflow stage it is asked in. */
@@ -48,7 +48,7 @@ type PreparedCall = {
48
48
  ids: string[];
49
49
  resolved: { provider: string; model: string };
50
50
  provider: DecisionProvider;
51
- deadline: AbsoluteDeadline;
51
+ deadline: AbsoluteDeadline | undefined;
52
52
  slot: number;
53
53
  };
54
54
 
@@ -165,7 +165,9 @@ export class RunDecisions {
165
165
  controller.abort(next.outcome);
166
166
  };
167
167
  const cancel = (reason: string) => end({ outcome: "cancelled", reason });
168
- const cancelTimer = scheduleAt(deadline, () => end({ outcome: "timed-out", deadline }));
168
+ const cancelTimer = deadline
169
+ ? scheduleAt(deadline, () => end({ outcome: "timed-out", deadline }))
170
+ : () => undefined;
169
171
  this.#cancellers.add(cancel);
170
172
  const removeFromScope = scope.add?.(async (reason) => cancel(reason));
171
173
 
@@ -217,7 +219,7 @@ export class RunDecisions {
217
219
  thrown instanceof DecisionProviderError &&
218
220
  thrown.retryable &&
219
221
  attempts < MAX_ATTEMPTS &&
220
- Date.now() + backoff < deadline.unixMilliseconds;
222
+ (!deadline || Date.now() + backoff < deadline.unixMilliseconds);
221
223
  if (!retry) break;
222
224
  await (this.options.sleep ?? abortableSleep)(backoff, controller.signal);
223
225
  if (ending) break;
@@ -291,7 +293,7 @@ export class RunDecisions {
291
293
  const { charged: _charged, ...returned } = settled;
292
294
 
293
295
  if (returned.outcome === "answered") return { answers: answers!, record: returned };
294
- if (returned.outcome === "timed-out") throw new DeadlineExceededError(deadline);
296
+ if (returned.outcome === "timed-out") throw new DeadlineExceededError(deadline!);
295
297
  throw new DecisionError(`decision ${spec.key}: ${returned.error}`, returned);
296
298
  }
297
299
  }
package/src/duration.ts CHANGED
@@ -10,3 +10,32 @@ export function parseDuration(value: string): number {
10
10
  if (!Number.isSafeInteger(milliseconds)) throw new Error(`duration is too large: ${value}`);
11
11
  return milliseconds;
12
12
  }
13
+
14
+ /** `45s`, `6m40s`, `1h02m`. */
15
+ export function duration(milliseconds: number): string {
16
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
17
+ if (seconds < 60) return `${seconds}s`;
18
+ const minutes = Math.floor(seconds / 60);
19
+ if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
20
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
21
+ }
22
+
23
+ /**
24
+ * When a timer given `when` is due, epoch ms, from `now`, and `when` as its record keeps it: a
25
+ * duration of days, hours or minutes as given, or a `Date` as an ISO time. A workflow's grammar,
26
+ * apart from the CLI's `parseDuration`: a wait is days long, and `1.5h` reads as written.
27
+ */
28
+ export function parseWhen(when: unknown, now: number): { due: number; when: string } {
29
+ if (when instanceof Date) {
30
+ const due = when.getTime();
31
+ if (!Number.isFinite(due)) throw new Error("a timer's time is a valid Date");
32
+ return { due, when: when.toISOString() };
33
+ }
34
+ const matched = typeof when === "string" ? /^(\d+(?:\.\d+)?)(d|h|m)$/.exec(when) : null;
35
+ if (!matched)
36
+ throw new Error(
37
+ `a timer is due in days, hours or minutes, "14d", "4h", "1.5h", or at a Date; not ${String(when)}`,
38
+ );
39
+ const unit = matched[2] === "d" ? 86_400_000 : matched[2] === "h" ? 3_600_000 : 60_000;
40
+ return { due: now + Math.round(Number(matched[1]) * unit), when: when as string };
41
+ }
@@ -0,0 +1,119 @@
1
+ import type { EmitResultJson } from "@agentswf/contract/records";
2
+ import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
3
+ import { EmitError, emitEvent } from "./emit";
4
+ import { messageOf } from "./errors";
5
+ import help from "./help/emit.txt" with { type: "text" };
6
+ import type { Out } from "./run-commands";
7
+ import type { Launch } from "./wake";
8
+ import { loadWorkflowFile } from "./workflow-loader";
9
+
10
+ export const emitUsage = help.trimEnd();
11
+
12
+ export async function emitCommand(
13
+ argv: readonly string[],
14
+ shellCwd: string,
15
+ out: Out,
16
+ context: {
17
+ home: string;
18
+ environment: Readonly<Record<string, string | undefined>>;
19
+ launch?: Launch;
20
+ },
21
+ ): Promise<number> {
22
+ if (argv.includes("-h") || argv.includes("--help")) {
23
+ out.stdout(emitUsage);
24
+ return 0;
25
+ }
26
+ let parsed: {
27
+ target: string;
28
+ run: string;
29
+ name: string;
30
+ data: JsonValue;
31
+ id?: string;
32
+ json: boolean;
33
+ };
34
+ try {
35
+ parsed = parse(argv);
36
+ } catch (error) {
37
+ out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
38
+ return 2;
39
+ }
40
+ let result: EmitResultJson;
41
+ try {
42
+ const loaded = await loadWorkflowFile(parsed.target, shellCwd);
43
+ if (!isEventWorkflow(loaded.workflow)) {
44
+ out.stderr(`awf: ${loaded.file} declares no events; awf run starts it`);
45
+ return 2;
46
+ }
47
+ result = await emitEvent(
48
+ loaded.workflow,
49
+ {
50
+ run: parsed.run,
51
+ name: parsed.name,
52
+ data: parsed.data,
53
+ ...(parsed.id === undefined ? {} : { id: parsed.id }),
54
+ },
55
+ { from: "operator", ...context },
56
+ );
57
+ } catch (error) {
58
+ out.stderr(`awf: ${messageOf(error)}`);
59
+ return error instanceof EmitError ? 2 : 1;
60
+ }
61
+ return printEmit(result, parsed.json, out);
62
+ }
63
+
64
+ /** Prints an emit's result, as `awf emit` and `awf send` do: 1 when the run's attempt didn't start. */
65
+ export function printEmit(result: EmitResultJson, json: boolean, out: Out): number {
66
+ out.stdout(json ? JSON.stringify(result) : emitLine(result));
67
+ if (result.why !== "launch-failed") return 0;
68
+ out.stderr(`awf: the run's attempt did not start: ${result.error}`);
69
+ return 1;
70
+ }
71
+
72
+ /** `accepted dd-case/kombo: started`. */
73
+ function emitLine(result: EmitResultJson): string {
74
+ const did = result.did === "none" ? result.why : result.did;
75
+ return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
76
+ }
77
+
78
+ function parse(argv: readonly string[]): {
79
+ target: string;
80
+ run: string;
81
+ name: string;
82
+ data: JsonValue;
83
+ id?: string;
84
+ json: boolean;
85
+ } {
86
+ let target: string | undefined;
87
+ let run: string | undefined;
88
+ let id: string | undefined;
89
+ let json = false;
90
+ const words: string[] = [];
91
+ for (let i = 0; i < argv.length; i++) {
92
+ const word = argv[i]!;
93
+ const value = () => {
94
+ const next = argv[++i];
95
+ if (next === undefined) throw new Error(`${word} needs a value`);
96
+ return next;
97
+ };
98
+ if (word === "--target") target = value();
99
+ else if (word === "--id") run = value();
100
+ else if (word === "--event-id") id = value();
101
+ else if (word === "--json") json = true;
102
+ else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
103
+ else words.push(word);
104
+ }
105
+ if (target === undefined) throw new Error("--target names the workflow file the event is for");
106
+ if (run === undefined) throw new Error("--id names the run the event is for, by its id");
107
+ const [name, text, ...more] = words;
108
+ if (name === undefined) throw new Error("the event's name is missing");
109
+ if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
110
+ let data: JsonValue = null;
111
+ if (text !== undefined) {
112
+ try {
113
+ data = JSON.parse(text) as JsonValue;
114
+ } catch (error) {
115
+ throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
116
+ }
117
+ }
118
+ return { target, run, name, data, ...(id === undefined ? {} : { id }), json };
119
+ }