@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.
- package/package.json +5 -6
- package/src/agent-cli/cli.ts +571 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +31 -21
- package/src/attempt-ending.ts +23 -5
- package/src/attempt-output.ts +48 -18
- package/src/attempt-view.ts +25 -15
- package/src/commands.ts +176 -0
- package/src/control-plane.ts +73 -22
- package/src/coordinator-port.ts +29 -0
- package/src/coordinator-prompt.ts +237 -0
- package/src/coordinator-socket.ts +236 -0
- package/src/deadlines.ts +47 -10
- package/src/decisions/directory.ts +8 -6
- package/src/duration.ts +29 -0
- package/src/emit-command.ts +119 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +13 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +13 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +44 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/here.ts +9 -3
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +279 -0
- package/src/inbox-command.ts +715 -0
- package/src/init-command.ts +29 -11
- package/src/init-template/AGENTS.md.template +4 -4
- package/src/init-template/hello/workflow.test.ts.template +2 -2
- package/src/init-template/hello/workflow.ts.template +16 -20
- package/src/keep-alive.ts +40 -0
- package/src/logs-command.ts +106 -35
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +990 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/limits.ts +1 -1
- package/src/messaging/messaging.ts +478 -85
- package/src/messaging/routes.ts +11 -4
- package/src/messaging/text.ts +25 -22
- package/src/operation-liveness.ts +55 -9
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +8 -8
- package/src/progress-view.ts +49 -7
- package/src/projects.ts +57 -0
- package/src/result-slots.ts +43 -19
- package/src/result-validation.ts +18 -27
- package/src/run-command.ts +29 -66
- package/src/run-commands.ts +13 -2
- package/src/run-lock.ts +123 -0
- package/src/run-prepare.ts +94 -22
- package/src/run-state.ts +125 -6
- package/src/run-usage.ts +2 -2
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +43 -25
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +11 -4
- package/src/simple-input.ts +268 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +89 -14
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +34 -11
- package/src/testing.ts +72 -4
- package/src/tick-command.ts +76 -0
- package/src/tools.ts +645 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1277 -97
- package/src/workflow-testing/coordinator.typecheck.ts +54 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +67 -7
- package/src/workflow-testing/index.ts +488 -34
- package/src/workflow-testing/script.ts +74 -4
- 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
|
-
/**
|
|
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)
|
|
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 =
|
|
25
|
-
|
|
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>(
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
+
}
|