@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.
- package/package.json +5 -5
- package/src/allowance-command.ts +29 -7
- 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 +72 -21
- 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 +130 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/here.ts +8 -2
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +297 -0
- package/src/inbox-command.ts +727 -0
- package/src/init-command.ts +27 -0
- package/src/init-template/AGENTS.md.template +1 -1
- 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 +104 -20
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +977 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/messaging.ts +458 -65
- package/src/messaging/routes.ts +10 -3
- package/src/messaging/text.ts +8 -5
- package/src/operation-liveness.ts +54 -8
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +5 -2
- 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 +36 -32
- 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 +1 -1
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +46 -17
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +8 -2
- package/src/simple-input.ts +258 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +88 -13
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +32 -0
- package/src/testing.ts +71 -3
- package/src/tick-command.ts +87 -0
- package/src/tools.ts +636 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1210 -77
- package/src/workflow-testing/coordinator.typecheck.ts +55 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +61 -1
- package/src/workflow-testing/index.ts +488 -33
- package/src/workflow-testing/script.ts +72 -2
- package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
package/src/wake.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { closeSync, openSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
MAILBOX_RECORD_VERSION,
|
|
5
|
+
type MailboxRecord,
|
|
6
|
+
type WakeDid,
|
|
7
|
+
type WakeWhy,
|
|
8
|
+
} from "@agentswf/contract/records";
|
|
9
|
+
import { WIRE_VERSION } from "@agentswf/contract/wire";
|
|
10
|
+
import { coordinatorSocketPath, liveCoordinatorSocket } from "./coordinator-socket";
|
|
11
|
+
import { messageOf } from "./errors";
|
|
12
|
+
import { exists } from "./files";
|
|
13
|
+
import { coordinatorDir } from "./machine";
|
|
14
|
+
import { appendMailbox, foldMailbox, hasWork, readMailbox, readyFor } from "./mailbox";
|
|
15
|
+
import { statusOf, stuckOf } from "./run-state";
|
|
16
|
+
import { type Run, readAttempts, sameProcess } from "./runs";
|
|
17
|
+
import { exchange } from "./socket-exchange";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What `wake` did to a run: why it did nothing when it did nothing, and what was due when it
|
|
21
|
+
* started or continued it.
|
|
22
|
+
*/
|
|
23
|
+
export type Woken = {
|
|
24
|
+
did: WakeDid;
|
|
25
|
+
why?: WakeWhy;
|
|
26
|
+
error?: string;
|
|
27
|
+
cause?: "event" | "timer" | "crashed" | "failed";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Starts or continues a run's attempt, returning once that attempt has claimed its number, so
|
|
32
|
+
* its pid is recorded while the run's lock is held.
|
|
33
|
+
*/
|
|
34
|
+
export type Launch = (run: Run, file: string) => Promise<void>;
|
|
35
|
+
|
|
36
|
+
export type WakeContext = {
|
|
37
|
+
home: string;
|
|
38
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
39
|
+
/** The file a sender named; absent, the run's last attempt's. */
|
|
40
|
+
file?: string;
|
|
41
|
+
launch?: Launch;
|
|
42
|
+
/** Asks a live run's wait to read its mailbox again; false when it couldn't be reached. */
|
|
43
|
+
nudge?: (run: Run) => Promise<boolean>;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
/** False for a duplicate: a live run has the event already, and a nudge would tell it nothing. */
|
|
46
|
+
nudgeLive?: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The one policy for what an accepted event, or `awf tick`, does to a run: start it, nudge it,
|
|
51
|
+
* continue it, or nothing. The caller holds the run's lock. A limit on live runs goes here.
|
|
52
|
+
*/
|
|
53
|
+
export async function wake(run: Run, context: WakeContext): Promise<Woken> {
|
|
54
|
+
const attempts = await readAttempts(run);
|
|
55
|
+
let read: MailboxRecord[] | undefined;
|
|
56
|
+
const mailbox = async () => {
|
|
57
|
+
read ??= await readMailbox(run.dir);
|
|
58
|
+
return read;
|
|
59
|
+
};
|
|
60
|
+
const launch = async (
|
|
61
|
+
did: "started" | "continued",
|
|
62
|
+
cause: NonNullable<Woken["cause"]>,
|
|
63
|
+
): Promise<Woken> => {
|
|
64
|
+
const file = context.file ?? attempts.at(-1)?.file ?? run.record.file;
|
|
65
|
+
try {
|
|
66
|
+
if (file === undefined) throw new Error(`${run.record.id} has no file to run`);
|
|
67
|
+
await (context.launch ?? detachedLaunch(context))(run, file);
|
|
68
|
+
return { did, cause };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// The event is in the mailbox; the next wake tries again, until Q13's limit.
|
|
71
|
+
const reason = messageOf(error);
|
|
72
|
+
read = undefined;
|
|
73
|
+
await appendMailbox(run.dir, {
|
|
74
|
+
version: MAILBOX_RECORD_VERSION,
|
|
75
|
+
at: new Date(context.now?.() ?? Date.now()).toISOString(),
|
|
76
|
+
kind: "launch-failed",
|
|
77
|
+
reason,
|
|
78
|
+
});
|
|
79
|
+
return { did: "none", why: "launch-failed", error: reason };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const stuck = async () => stuckOf(attempts, await mailbox());
|
|
83
|
+
if (attempts.length === 0)
|
|
84
|
+
return (await stuck()) ? { did: "none", why: "stuck" } : launch("started", "event");
|
|
85
|
+
const { state } = statusOf(run, attempts);
|
|
86
|
+
switch (state) {
|
|
87
|
+
case "running":
|
|
88
|
+
if (context.nudgeLive === false) return { did: "none" };
|
|
89
|
+
return (await (context.nudge ?? nudgeOver(context))(run))
|
|
90
|
+
? { did: "nudged" }
|
|
91
|
+
: { did: "none", why: "nudge-failed" };
|
|
92
|
+
case "parked": {
|
|
93
|
+
if (await stuck()) return { did: "none", why: "stuck" };
|
|
94
|
+
// Only for what the wait it parked at would take: an event it names, or its timer due.
|
|
95
|
+
const state = foldMailbox(await mailbox());
|
|
96
|
+
const takes = state.lastWait?.takes;
|
|
97
|
+
if (!hasWork(state, takes, context.now?.() ?? Date.now()))
|
|
98
|
+
return { did: "none", why: "nothing-due" };
|
|
99
|
+
return launch("continued", readyFor(state, takes).length > 0 ? "event" : "timer");
|
|
100
|
+
}
|
|
101
|
+
case "interrupted":
|
|
102
|
+
case "failed":
|
|
103
|
+
case "timed-out":
|
|
104
|
+
return (await stuck())
|
|
105
|
+
? { did: "none", why: "stuck" }
|
|
106
|
+
: launch("continued", state === "interrupted" ? "crashed" : "failed");
|
|
107
|
+
case "stopped":
|
|
108
|
+
case "cancelled":
|
|
109
|
+
return { did: "none", why: state };
|
|
110
|
+
case "stuck":
|
|
111
|
+
return { did: "none", why: "stuck" };
|
|
112
|
+
case "completed":
|
|
113
|
+
return { did: "none", why: "returned" };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What a run's attempt is given of the environment of whoever woke it: who and where the engineer
|
|
119
|
+
* is, the machine's plumbing (the ssh agent, proxies, XDG directories) and awf's own settings, and
|
|
120
|
+
* the run's recorded `PATH`. Tokens, keys and harness settings come from the project's `.env`,
|
|
121
|
+
* which Bun reads at every start and which a variable already set would override, so a run
|
|
122
|
+
* behaves the same whoever sends, and a rotated token reaches it (Q15).
|
|
123
|
+
*/
|
|
124
|
+
export function attemptEnvironment(
|
|
125
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
126
|
+
run: Run,
|
|
127
|
+
): Record<string, string> {
|
|
128
|
+
const kept: Record<string, string> = {};
|
|
129
|
+
for (const [name, value] of Object.entries(environment)) {
|
|
130
|
+
if (value !== undefined && (KEPT.has(name) || KEPT_PREFIXES.some((p) => name.startsWith(p))))
|
|
131
|
+
kept[name] = value;
|
|
132
|
+
}
|
|
133
|
+
return { ...kept, PATH: run.record.env?.PATH ?? environment.PATH ?? "" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const KEPT = new Set([
|
|
137
|
+
"HOME",
|
|
138
|
+
"USER",
|
|
139
|
+
"LOGNAME",
|
|
140
|
+
"SHELL",
|
|
141
|
+
"TMPDIR",
|
|
142
|
+
"LANG",
|
|
143
|
+
"TERM",
|
|
144
|
+
"SSH_AUTH_SOCK",
|
|
145
|
+
"HTTP_PROXY",
|
|
146
|
+
"HTTPS_PROXY",
|
|
147
|
+
"NO_PROXY",
|
|
148
|
+
"http_proxy",
|
|
149
|
+
"https_proxy",
|
|
150
|
+
"no_proxy",
|
|
151
|
+
// awf's own: where holders reach the run, and which Herdr session its panes open in.
|
|
152
|
+
"AWF_COORDINATOR_DIR",
|
|
153
|
+
"AWF_HERDR_SESSION",
|
|
154
|
+
]);
|
|
155
|
+
const KEPT_PREFIXES = ["LC_", "XDG_"];
|
|
156
|
+
|
|
157
|
+
/** A nudge over the run's socket, which a run with `events` opens at its start. */
|
|
158
|
+
function nudgeOver(context: WakeContext): (run: Run) => Promise<boolean> {
|
|
159
|
+
return async (run) => {
|
|
160
|
+
const dir = coordinatorDir(context.home, context.environment);
|
|
161
|
+
const socket = await liveCoordinatorSocket(dir, { state: "running", run });
|
|
162
|
+
if (!socket) return false;
|
|
163
|
+
try {
|
|
164
|
+
const answer = await exchange(
|
|
165
|
+
socket,
|
|
166
|
+
`${JSON.stringify({ version: WIRE_VERSION, command: "nudge" })}\n`,
|
|
167
|
+
2_000,
|
|
168
|
+
);
|
|
169
|
+
return (JSON.parse(answer) as { code?: unknown }).code === "nudged";
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The awf command this engine ships, as a process runs it. */
|
|
177
|
+
const CLI = join(import.meta.dir, "operator-cli.ts");
|
|
178
|
+
|
|
179
|
+
/** How long a claimed attempt is given to open its socket before the launch returns anyway. */
|
|
180
|
+
const SOCKET_MILLISECONDS = 5_000;
|
|
181
|
+
|
|
182
|
+
/** How long a launched attempt has to claim its number before the launch gives up. */
|
|
183
|
+
const CLAIM_MILLISECONDS = 60_000;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Runs `awf run {file} --id {id} --continue` in a session of its own (Q4), so it outlives the sender's
|
|
187
|
+
* terminal, in the run's project with its recorded `PATH`, its output in the run's `process.log`.
|
|
188
|
+
* Bun reads the project's `.env` there, as at every start, so a rotated token reaches it.
|
|
189
|
+
*/
|
|
190
|
+
function detachedLaunch(context: WakeContext): Launch {
|
|
191
|
+
return async (run, file) => {
|
|
192
|
+
const before = (await readAttempts(run)).length;
|
|
193
|
+
// Only what this launch writes is quoted if it fails: the log holds every attempt's output.
|
|
194
|
+
const from = Bun.file(join(run.dir, "process.log")).size;
|
|
195
|
+
const log = openSync(join(run.dir, "process.log"), "a", 0o600);
|
|
196
|
+
const env = attemptEnvironment(context.environment, run);
|
|
197
|
+
let child: ReturnType<typeof Bun.spawn>;
|
|
198
|
+
try {
|
|
199
|
+
child = Bun.spawn({
|
|
200
|
+
cmd: [
|
|
201
|
+
process.execPath,
|
|
202
|
+
CLI,
|
|
203
|
+
"run",
|
|
204
|
+
file,
|
|
205
|
+
"--id",
|
|
206
|
+
run.record.id,
|
|
207
|
+
"--continue",
|
|
208
|
+
"--run-root",
|
|
209
|
+
run.root,
|
|
210
|
+
],
|
|
211
|
+
cwd: run.record.cwd,
|
|
212
|
+
env,
|
|
213
|
+
stdio: ["ignore", log, log],
|
|
214
|
+
detached: true,
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
closeSync(log);
|
|
218
|
+
}
|
|
219
|
+
child.unref();
|
|
220
|
+
const giveUp = Date.now() + CLAIM_MILLISECONDS;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const claimed = await readAttempts(run);
|
|
223
|
+
if (claimed.length > before) {
|
|
224
|
+
// The new attempt opens its socket only once the one before it is gone: no sender waits
|
|
225
|
+
// on that.
|
|
226
|
+
const previous = claimed.at(-2);
|
|
227
|
+
if (previous && sameProcess(previous.pid, previous.processStart)) return;
|
|
228
|
+
// Best effort: an event sent next nudges the run rather than waiting for its poll.
|
|
229
|
+
const socket = coordinatorSocketPath(coordinatorDir(context.home, env), run.dir);
|
|
230
|
+
const opened = Date.now() + SOCKET_MILLISECONDS;
|
|
231
|
+
while (
|
|
232
|
+
Date.now() < opened &&
|
|
233
|
+
child.exitCode === null &&
|
|
234
|
+
child.signalCode === null &&
|
|
235
|
+
!(await exists(socket))
|
|
236
|
+
)
|
|
237
|
+
await Bun.sleep(25);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
241
|
+
const tail = await Bun.file(join(run.dir, "process.log"))
|
|
242
|
+
.slice(from)
|
|
243
|
+
.text()
|
|
244
|
+
.then((text) => text.trim().split("\n").slice(-3).join(" / "))
|
|
245
|
+
.catch((error: unknown) => messageOf(error));
|
|
246
|
+
throw new Error(`the attempt of ${run.record.id} exited before it started: ${tail}`);
|
|
247
|
+
}
|
|
248
|
+
if (Date.now() > giveUp) {
|
|
249
|
+
// One that claimed meanwhile goes on. Left running, one that did not could still claim the
|
|
250
|
+
// run later and race the next launch.
|
|
251
|
+
if ((await readAttempts(run)).length > before) continue;
|
|
252
|
+
try {
|
|
253
|
+
process.kill(-child.pid, "SIGTERM");
|
|
254
|
+
} catch {
|
|
255
|
+
// It ended meanwhile.
|
|
256
|
+
}
|
|
257
|
+
throw new Error(
|
|
258
|
+
`the attempt of ${run.record.id} did not start within ${CLAIM_MILLISECONDS / 1000} s`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
await Bun.sleep(25);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/workflow-loader.ts
CHANGED
|
@@ -3,14 +3,17 @@ import { extname, resolve } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import * as workflowSurface from "@agentswf/contract/workflow";
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
type EventSchemas,
|
|
7
|
+
eventWorkflowProblem,
|
|
8
8
|
isJsonValue,
|
|
9
9
|
type JsonValue,
|
|
10
|
+
WORKFLOW_KIND,
|
|
11
|
+
type Workflow,
|
|
10
12
|
} from "@agentswf/contract/workflow";
|
|
11
13
|
import { plugin } from "bun";
|
|
12
14
|
import * as typebox from "typebox";
|
|
13
15
|
import * as typeboxValue from "typebox/value";
|
|
16
|
+
import { realPath } from "./files";
|
|
14
17
|
import { idProblem } from "./runs";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -42,10 +45,11 @@ export function serveAuthorSurface(): void {
|
|
|
42
45
|
|
|
43
46
|
const WORKFLOW_EXTENSIONS = new Set([".ts", ".mts", ".js", ".mjs"]);
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
/** A workflow as the engine takes one from a file, with `events` or without. */
|
|
49
|
+
export type AnyWorkflow = Workflow<JsonValue, JsonValue> | EventWorkflow;
|
|
50
|
+
export type EventWorkflow = Workflow<JsonValue, JsonValue, EventSchemas>;
|
|
51
|
+
|
|
52
|
+
export type LoadedWorkflow = { file: string; workflow: AnyWorkflow };
|
|
49
53
|
|
|
50
54
|
export async function loadWorkflowFile(file: string, cwd: string): Promise<LoadedWorkflow> {
|
|
51
55
|
if (file.includes("://") || file.startsWith("file:")) {
|
|
@@ -65,11 +69,9 @@ export async function loadWorkflowFile(file: string, cwd: string): Promise<Loade
|
|
|
65
69
|
|
|
66
70
|
serveAuthorSurface();
|
|
67
71
|
const namespace: unknown = await import(pathToFileURL(absolute).href);
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
const { name, version } = exported.definition.meta;
|
|
72
|
+
const given = record(namespace)?.default;
|
|
73
|
+
if (!isWorkflow(given)) throw new Error(`default export must be an ${WORKFLOW_KIND} workflow`);
|
|
74
|
+
const { name, version } = given.meta;
|
|
73
75
|
// The name is a folder its runs are kept in, and the version's major decides which records a
|
|
74
76
|
// continue may reuse.
|
|
75
77
|
const problem = idProblem(name);
|
|
@@ -79,7 +81,16 @@ export async function loadWorkflowFile(file: string, cwd: string): Promise<Loade
|
|
|
79
81
|
`meta.version: ${JSON.stringify(version)} is not a semver version such as 1.2.0`,
|
|
80
82
|
);
|
|
81
83
|
}
|
|
82
|
-
|
|
84
|
+
if (given.events !== undefined) {
|
|
85
|
+
// As `tsc` would have checked them, for a file it never saw.
|
|
86
|
+
const eventProblem = eventWorkflowProblem(given);
|
|
87
|
+
if (eventProblem) throw new Error(eventProblem);
|
|
88
|
+
if (realPath(given.file as string) !== realPath(absolute))
|
|
89
|
+
throw new Error(
|
|
90
|
+
`file is ${JSON.stringify(given.file)}; a workflow with events gives file: import.meta.path, ${absolute}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return { file: absolute, workflow: given };
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
@@ -88,17 +99,16 @@ export function assertJsonValue(value: unknown, label: string): asserts value is
|
|
|
88
99
|
if (!isJsonValue(value)) throw new Error(`${label} must contain only JSON values`);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const meta = record(definition?.meta);
|
|
102
|
+
function isWorkflow(value: unknown): value is AnyWorkflow {
|
|
103
|
+
const workflow = record(value);
|
|
104
|
+
const meta = record(workflow?.meta);
|
|
95
105
|
return (
|
|
96
|
-
|
|
97
|
-
typeof
|
|
98
|
-
(
|
|
99
|
-
(
|
|
100
|
-
(
|
|
101
|
-
typeof
|
|
106
|
+
workflow?.kind === WORKFLOW_KIND &&
|
|
107
|
+
typeof workflow.prepare === "function" &&
|
|
108
|
+
(workflow.present === undefined || typeof workflow.present === "function") &&
|
|
109
|
+
(workflow.report === undefined || typeof workflow.report === "function") &&
|
|
110
|
+
(workflow.id === undefined || typeof workflow.id === "function") &&
|
|
111
|
+
typeof workflow.run === "function" &&
|
|
102
112
|
typeof meta?.name === "string" &&
|
|
103
113
|
meta.name.trim() !== "" &&
|
|
104
114
|
typeof meta.description === "string" &&
|