@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,115 @@
|
|
|
1
|
+
import { createConnection, type Socket } from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
type ControlRequest,
|
|
4
|
+
decodeResponseFor,
|
|
5
|
+
type ResponseFor,
|
|
6
|
+
type ResultSubmitRequest,
|
|
7
|
+
type ResultSubmitResponse,
|
|
8
|
+
type WaitingRequest,
|
|
9
|
+
type WaitingResponse,
|
|
10
|
+
} from "@agentswf/contract/wire";
|
|
11
|
+
|
|
12
|
+
const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
13
|
+
|
|
14
|
+
/** The connection never opened: nothing was written, so nothing reached the engine. */
|
|
15
|
+
export class NotSentError extends Error {}
|
|
16
|
+
|
|
17
|
+
export function submitResult(
|
|
18
|
+
endpoint: string,
|
|
19
|
+
request: ResultSubmitRequest,
|
|
20
|
+
timeoutSeconds = 30,
|
|
21
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
22
|
+
): Promise<ResultSubmitResponse> {
|
|
23
|
+
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function submitWaiting(
|
|
27
|
+
endpoint: string,
|
|
28
|
+
request: WaitingRequest,
|
|
29
|
+
timeoutSeconds = 30,
|
|
30
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
31
|
+
): Promise<WaitingResponse> {
|
|
32
|
+
return submitControl(endpoint, request, timeoutSeconds, connect);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sends one request and resolves with a response that answers its command. */
|
|
36
|
+
export function submitControl<R extends ControlRequest>(
|
|
37
|
+
endpoint: string,
|
|
38
|
+
request: R,
|
|
39
|
+
/** Null waits as long as the engine takes, as a tool call does: it answers once the call settles. */
|
|
40
|
+
timeoutSeconds: number | null = 30,
|
|
41
|
+
connect: (endpoint: string) => Socket = createConnection,
|
|
42
|
+
): Promise<ResponseFor[R["command"]]> {
|
|
43
|
+
type Response = ResponseFor[R["command"]];
|
|
44
|
+
if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
|
|
45
|
+
return Promise.reject(new Error("control-plane timeout must be positive"));
|
|
46
|
+
}
|
|
47
|
+
const outgoing = Buffer.from(`${JSON.stringify(request)}\n`);
|
|
48
|
+
|
|
49
|
+
return new Promise<Response>((resolve, reject) => {
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
let bytes = 0;
|
|
52
|
+
let settled = false;
|
|
53
|
+
let connected = false;
|
|
54
|
+
const socket = connect(endpoint);
|
|
55
|
+
const lifetime =
|
|
56
|
+
timeoutSeconds === null
|
|
57
|
+
? undefined
|
|
58
|
+
: setTimeout(
|
|
59
|
+
() => fail(new Error("control plane response timed out")),
|
|
60
|
+
timeoutSeconds * 1000,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const fail = (error: Error): void => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(lifetime);
|
|
67
|
+
socket.destroy();
|
|
68
|
+
reject(error);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
socket.once("connect", () => {
|
|
72
|
+
connected = true;
|
|
73
|
+
socket.end(outgoing);
|
|
74
|
+
});
|
|
75
|
+
socket.on("data", (data) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
const chunk = typeof data === "string" ? Buffer.from(data) : data;
|
|
78
|
+
bytes += chunk.byteLength;
|
|
79
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
80
|
+
fail(new Error("control-plane response exceeds the size limit"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
chunks.push(chunk);
|
|
84
|
+
});
|
|
85
|
+
socket.once("end", () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
const frame = Buffer.concat(chunks).toString("utf8");
|
|
88
|
+
if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
|
|
89
|
+
fail(new Error("control plane returned an invalid response frame"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(frame.slice(0, -1));
|
|
95
|
+
} catch {
|
|
96
|
+
fail(new Error("control plane returned invalid JSON"));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const decoded = decodeResponseFor<R["command"]>(request.command, parsed);
|
|
100
|
+
if (!decoded.ok) {
|
|
101
|
+
fail(new Error(decoded.error));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(lifetime);
|
|
106
|
+
resolve(decoded.value);
|
|
107
|
+
});
|
|
108
|
+
socket.once("error", (error) =>
|
|
109
|
+
fail(connected ? error : new NotSentError(error.message, { cause: error })),
|
|
110
|
+
);
|
|
111
|
+
socket.once("close", () => {
|
|
112
|
+
if (!settled) fail(new Error("control plane closed without a response"));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/agent-launcher.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { chmod, mkdir,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { type AgentDoor, shellQuote } from "@agentswf/sandbox";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const AGENT_CLI = fileURLToPath(new URL("./agent-cli/cli.ts", import.meta.url));
|
|
7
|
+
|
|
8
|
+
/** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wfa`'s. */
|
|
7
9
|
const NO_ENV = "--no-env-file";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -27,19 +29,18 @@ export async function installAgentLauncher(
|
|
|
27
29
|
*/
|
|
28
30
|
sessionEnv?: string,
|
|
29
31
|
): Promise<string> {
|
|
30
|
-
|
|
31
|
-
return writeLauncher(directory, [process.execPath, NO_ENV, command], endpoint, sessionEnv);
|
|
32
|
+
return writeLauncher(directory, [process.execPath, NO_ENV, AGENT_CLI], endpoint, sessionEnv);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
* A sandboxed agent's door: the same launcher, running the bundled `
|
|
36
|
+
* A sandboxed agent's door: the same launcher, running the bundled `wfa` with the host's bun by
|
|
36
37
|
* its real path, since a sandbox reads neither the workspace's sources nor bun's links under `~`.
|
|
37
38
|
* A box copies `boxScript` to the launcher's path, which runs the bundle with the box's own bun.
|
|
38
39
|
*/
|
|
39
40
|
export async function installSandboxedDoor(
|
|
40
41
|
directory: string,
|
|
41
42
|
endpoint: string,
|
|
42
|
-
/** The bundled `
|
|
43
|
+
/** The bundled `wfa`'s source, written beside the launcher. */
|
|
43
44
|
source: string,
|
|
44
45
|
sessionEnv?: string,
|
|
45
46
|
): Promise<AgentDoor> {
|
|
@@ -50,7 +51,7 @@ export async function installSandboxedDoor(
|
|
|
50
51
|
const socket = join(real, basename(endpoint));
|
|
51
52
|
// In the agent's own directory: bun will not load a module from the control plane's, which is
|
|
52
53
|
// not listable so that no agent can find another's socket.
|
|
53
|
-
const bundle = join(real, "
|
|
54
|
+
const bundle = join(real, "wfa.js");
|
|
54
55
|
await writeFile(bundle, source, { mode: 0o500 });
|
|
55
56
|
const launcher = await writeLauncher(real, [bun, NO_ENV, bundle], socket, sessionEnv);
|
|
56
57
|
return {
|
|
@@ -64,12 +65,12 @@ export async function installSandboxedDoor(
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
|
-
* `
|
|
68
|
+
* `wfa` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
|
|
68
69
|
* a `bun build` of its own: in process, the bundler resolved packages against the test runner's
|
|
69
70
|
* state and missed the workspace's links.
|
|
70
71
|
*/
|
|
71
72
|
export async function buildAgentBundle(): Promise<string> {
|
|
72
|
-
const entry =
|
|
73
|
+
const entry = AGENT_CLI;
|
|
73
74
|
const built = Bun.spawn({
|
|
74
75
|
cmd: [process.execPath, "build", entry, "--target=bun"],
|
|
75
76
|
cwd: dirname(entry),
|
|
@@ -81,7 +82,7 @@ export async function buildAgentBundle(): Promise<string> {
|
|
|
81
82
|
new Response(built.stdout).text(),
|
|
82
83
|
new Response(built.stderr).text(),
|
|
83
84
|
]);
|
|
84
|
-
if (code !== 0) throw new Error(`could not bundle
|
|
85
|
+
if (code !== 0) throw new Error(`could not bundle wfa: ${stderr.trim().slice(0, 400)}`);
|
|
85
86
|
return bundle;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -93,7 +94,7 @@ async function writeLauncher(
|
|
|
93
94
|
): Promise<string> {
|
|
94
95
|
await mkdir(directory, { recursive: true });
|
|
95
96
|
await chmod(directory, 0o700);
|
|
96
|
-
const path = join(directory, "
|
|
97
|
+
const path = join(directory, "wfa");
|
|
97
98
|
// Not writable, so an agent that redirects into its own launcher fails rather than replacing its
|
|
98
99
|
// only way to answer. A `chmod` still gets past it; a sandbox denies the write outright.
|
|
99
100
|
await writeFile(path, launcherScript(command, endpoint, sessionEnv, path), { mode: 0o500 });
|
|
@@ -105,7 +106,7 @@ function launcherScript(
|
|
|
105
106
|
command: readonly string[],
|
|
106
107
|
endpoint: string,
|
|
107
108
|
sessionEnv: string | undefined,
|
|
108
|
-
/** Its own path, which `
|
|
109
|
+
/** Its own path, which `wfa` names in what it prints, as an agent must type it. */
|
|
109
110
|
path: string,
|
|
110
111
|
): string {
|
|
111
112
|
if (sessionEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sessionEnv)) {
|
|
@@ -114,14 +115,3 @@ function launcherScript(
|
|
|
114
115
|
const session = sessionEnv ? ` --session "\${${sessionEnv}:-}"` : "";
|
|
115
116
|
return `#!/bin/sh\nexec ${command.map(shellQuote).join(" ")} --at ${shellQuote(endpoint)} --as ${shellQuote(path)}${session} "$@"\n`;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
async function resolveAgentCommand(): Promise<string> {
|
|
119
|
-
const require = createRequire(import.meta.url);
|
|
120
|
-
const manifestPath = require.resolve("@agentswf/wf/package.json");
|
|
121
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
|
|
122
|
-
bin?: string | Record<string, string>;
|
|
123
|
-
};
|
|
124
|
-
const relative = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.wf;
|
|
125
|
-
if (!relative) throw new Error("@agentswf/wf does not publish the wf command");
|
|
126
|
-
return resolve(dirname(manifestPath), relative);
|
|
127
|
-
}
|
package/src/allowance-command.ts
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
type HarnessAllowance,
|
|
1
|
+
import type {
|
|
2
|
+
AllowanceReport,
|
|
3
|
+
AllowanceWindow,
|
|
4
|
+
HarnessAllowance,
|
|
6
5
|
} from "@agentswf/contract/records";
|
|
7
6
|
import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
|
|
8
|
-
import {
|
|
7
|
+
import { planPrice } from "./accounting/plans";
|
|
8
|
+
import { messageOf } from "./errors";
|
|
9
|
+
import help from "./help/allowance.txt" with { type: "text" };
|
|
10
|
+
import type { Out } from "./run-commands";
|
|
9
11
|
|
|
10
|
-
export const allowanceUsage =
|
|
11
|
-
"usage: awf allowance [harness...] [--json]",
|
|
12
|
-
"",
|
|
13
|
-
"What is left of each harness's plan, as the harness itself shows it: claude's /usage, codex's",
|
|
14
|
-
"/status, and cursor's /usage, read in a Herdr pane. Nothing asks a model. Every harness when",
|
|
15
|
-
`none is named: ${HARNESS_NAMES.join(", ")}.`,
|
|
16
|
-
"",
|
|
17
|
-
"A plan's monthly price is the vendor's list price as of the table's date, not what was",
|
|
18
|
-
`charged: ${PLAN_BASIS}.`,
|
|
19
|
-
"",
|
|
20
|
-
`--json prints an ${ALLOWANCE_VERSION} record, the interface for scripts, with the plan as the`,
|
|
21
|
-
"harness names it and no price; what it prints otherwise is for reading. A harness that could",
|
|
22
|
-
"not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
|
|
23
|
-
].join("\n");
|
|
12
|
+
export const allowanceUsage = help.trimEnd();
|
|
24
13
|
|
|
25
14
|
export type AllowanceCommand = { harnesses: Harness[]; json: boolean } | "help";
|
|
26
15
|
|
|
@@ -37,6 +26,27 @@ export function parseAllowanceCommand(argv: readonly string[]): AllowanceCommand
|
|
|
37
26
|
return { harnesses: harnesses.length > 0 ? harnesses : [...HARNESS_NAMES], json };
|
|
38
27
|
}
|
|
39
28
|
|
|
29
|
+
/** `awf allowance`: what is left of each harness's plan, read with `read`. */
|
|
30
|
+
export async function allowanceCommand(
|
|
31
|
+
argv: readonly string[],
|
|
32
|
+
io: Out & { read: (harness: Harness) => Promise<HarnessAllowance>; now: () => number },
|
|
33
|
+
): Promise<number> {
|
|
34
|
+
let command: AllowanceCommand;
|
|
35
|
+
try {
|
|
36
|
+
command = parseAllowanceCommand(argv);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
io.stderr(`awf: ${messageOf(error)}\n\n${allowanceUsage}`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
if (command === "help") {
|
|
42
|
+
io.stdout(allowanceUsage);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
const report = await readReport(command.harnesses, io.read, io.now());
|
|
46
|
+
io.stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
/** Every harness asked at once: cursor's pane takes seconds the others need not wait for. */
|
|
41
51
|
export async function readReport(
|
|
42
52
|
harnesses: readonly Harness[],
|
|
@@ -44,7 +54,7 @@ export async function readReport(
|
|
|
44
54
|
now: number,
|
|
45
55
|
): Promise<AllowanceReport> {
|
|
46
56
|
return {
|
|
47
|
-
|
|
57
|
+
v: 1,
|
|
48
58
|
readAt: new Date(now).toISOString(),
|
|
49
59
|
harnesses: await Promise.all(harnesses.map(read)),
|
|
50
60
|
};
|
package/src/attempt-ending.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { PUBLISHED_PRICES } from "./accounting/prices";
|
|
13
13
|
import { summarizeRun } from "./accounting/summary";
|
|
14
14
|
import { messageOf } from "./errors";
|
|
15
|
+
import { RunParked } from "./mailbox";
|
|
15
16
|
import type { Paint } from "./progress-view";
|
|
16
17
|
import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
|
|
17
18
|
import {
|
|
@@ -53,6 +54,7 @@ export const OUTCOMES: Record<
|
|
|
53
54
|
{ word: string; told: string; mark: (paint: Paint) => string; exitCode?: number }
|
|
54
55
|
> = {
|
|
55
56
|
completed: { word: "completed", told: "completed", mark: (p) => p.ok("✓"), exitCode: 0 },
|
|
57
|
+
parked: { word: "parked", told: "parked", mark: (p) => p.ok("◇"), exitCode: 0 },
|
|
56
58
|
stopped: { word: "stopped", told: "stopped", mark: (p) => p.busy("■"), exitCode: 3 },
|
|
57
59
|
cancelled: { word: "cancelled", told: "was cancelled", mark: (p) => p.busy("■") },
|
|
58
60
|
"timed-out": { word: "timed out", told: "timed out", mark: (p) => p.bad("✗"), exitCode: 1 },
|
|
@@ -63,7 +65,11 @@ export const OUTCOMES: Record<
|
|
|
63
65
|
export type GoOn = (stop: WorkflowStopped | undefined, entered: readonly StageSummary[]) => string;
|
|
64
66
|
|
|
65
67
|
/** The ending of a run that returned or threw. */
|
|
66
|
-
export function decideEnding(
|
|
68
|
+
export function decideEnding(
|
|
69
|
+
run: Finished,
|
|
70
|
+
deadline: AbsoluteDeadline | undefined,
|
|
71
|
+
goOn: GoOn,
|
|
72
|
+
): AttemptEnd {
|
|
67
73
|
if ("result" in run) {
|
|
68
74
|
const { result } = run;
|
|
69
75
|
return {
|
|
@@ -74,6 +80,18 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
|
|
|
74
80
|
}
|
|
75
81
|
const { error } = run;
|
|
76
82
|
const settled = error instanceof WorkflowRunError ? error : undefined;
|
|
83
|
+
const parked = primaryFailure(unwrapped(error));
|
|
84
|
+
if (parked instanceof RunParked) {
|
|
85
|
+
return {
|
|
86
|
+
ending: {
|
|
87
|
+
kind: "parked",
|
|
88
|
+
stages: settled?.stages ?? [],
|
|
89
|
+
...parked.wait,
|
|
90
|
+
},
|
|
91
|
+
...(settled ? { settled } : {}),
|
|
92
|
+
exitCode: OUTCOMES.parked.exitCode!,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
77
95
|
const { kind, stop, cancellation } = runOutcome(error, deadline);
|
|
78
96
|
const cause = unwrapped(error);
|
|
79
97
|
const beside =
|
|
@@ -104,10 +122,10 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
|
|
|
104
122
|
*/
|
|
105
123
|
export function endedBeforeStart(
|
|
106
124
|
error: unknown,
|
|
107
|
-
deadline: AbsoluteDeadline,
|
|
125
|
+
deadline: AbsoluteDeadline | undefined,
|
|
108
126
|
run: { runId: string; startedAt: number; endedAt: number },
|
|
109
127
|
goOn: GoOn,
|
|
110
|
-
): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" }> } {
|
|
128
|
+
): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" | "parked" }> } {
|
|
111
129
|
const { kind, cancellation } = runOutcome(error, deadline);
|
|
112
130
|
const times = {
|
|
113
131
|
startedAt: new Date(run.startedAt).toISOString(),
|
|
@@ -156,14 +174,14 @@ export function cleanupFailed(end: AttemptEnd, error: unknown, goOn: string): At
|
|
|
156
174
|
*/
|
|
157
175
|
export function runOutcome(
|
|
158
176
|
error: unknown,
|
|
159
|
-
deadline: AbsoluteDeadline,
|
|
177
|
+
deadline: AbsoluteDeadline | undefined,
|
|
160
178
|
): { kind: UnfinishedOutcome; stop?: WorkflowStopped; cancellation?: WorkflowCancelledError } {
|
|
161
179
|
const cancellation = findCancellation(error);
|
|
162
180
|
const failure = primaryFailure(unwrapped(error));
|
|
163
181
|
const stop = failure instanceof WorkflowStopped ? failure : undefined;
|
|
164
182
|
const timedOut =
|
|
165
183
|
failure instanceof DeadlineExceededError &&
|
|
166
|
-
failure.deadline.unixMilliseconds === deadline
|
|
184
|
+
failure.deadline.unixMilliseconds === deadline?.unixMilliseconds;
|
|
167
185
|
const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
|
|
168
186
|
return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
|
|
169
187
|
}
|
package/src/attempt-output.ts
CHANGED
|
@@ -4,13 +4,15 @@ import {
|
|
|
4
4
|
type OutputRecord,
|
|
5
5
|
RUN_EVENT_VERSIONS,
|
|
6
6
|
} from "@agentswf/contract/records";
|
|
7
|
-
import type { Ending,
|
|
7
|
+
import type { Ending, JsonValue } from "@agentswf/contract/workflow";
|
|
8
8
|
import { attemptAccounting } from "./accounting/summary";
|
|
9
9
|
import type { AttemptEnd } from "./attempt-ending";
|
|
10
10
|
import { messageOf } from "./errors";
|
|
11
11
|
import { writeJson, writeWhole } from "./files";
|
|
12
|
+
import { foldMailbox, readMailbox } from "./mailbox";
|
|
12
13
|
import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
|
|
13
14
|
import { withoutValue } from "./stage-ledger";
|
|
15
|
+
import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
|
|
14
16
|
|
|
15
17
|
/** Where an attempt's records went, and its output record as `--json` prints it. */
|
|
16
18
|
export type Kept = {
|
|
@@ -30,27 +32,34 @@ export async function keepRecords(
|
|
|
30
32
|
end: AttemptEnd,
|
|
31
33
|
context: {
|
|
32
34
|
attempt: Attempt;
|
|
33
|
-
|
|
34
|
-
workflow: { name: string; file: string };
|
|
35
|
+
loaded: LoadedWorkflow;
|
|
35
36
|
stderr: (text: string) => void;
|
|
37
|
+
/** An event run's: runs `work` holding its run's lock. */
|
|
38
|
+
locked?: (work: () => Promise<void>) => Promise<void>;
|
|
36
39
|
},
|
|
37
40
|
): Promise<Kept> {
|
|
38
41
|
const { attempt, stderr } = context;
|
|
39
42
|
const { ending, settled } = end;
|
|
40
43
|
const dir = attempt.run.dir;
|
|
41
|
-
const report = await writeReport(context.
|
|
44
|
+
const report = await writeReport(context.loaded.workflow, ending, dir, stderr);
|
|
42
45
|
const stages = settled?.stages?.map(withoutValue);
|
|
43
|
-
const
|
|
44
|
-
|
|
46
|
+
const unfinished = ending.kind !== "completed" && ending.kind !== "parked" ? ending : undefined;
|
|
47
|
+
const at = unfinished?.stage !== undefined ? { stage: unfinished.stage } : {};
|
|
45
48
|
const ended =
|
|
46
49
|
ending.kind === "completed"
|
|
47
50
|
? { outcome: "completed" as const, value: ending.value }
|
|
48
|
-
:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
: ending.kind === "parked"
|
|
52
|
+
? {
|
|
53
|
+
outcome: "parked" as const,
|
|
54
|
+
...(ending.label === undefined ? {} : { label: ending.label }),
|
|
55
|
+
...(ending.until === undefined ? {} : { until: ending.until }),
|
|
56
|
+
}
|
|
57
|
+
: {
|
|
58
|
+
outcome: ending.kind,
|
|
59
|
+
reason: ending.reason,
|
|
60
|
+
...at,
|
|
61
|
+
...(end.needs ? { needs: [...end.needs] } : {}),
|
|
62
|
+
};
|
|
54
63
|
const kept: Kept = report ? { report } : {};
|
|
55
64
|
const output = outputFile(dir);
|
|
56
65
|
if (!settled) {
|
|
@@ -63,7 +72,7 @@ export async function keepRecords(
|
|
|
63
72
|
version: OUTPUT_RECORD_VERSION,
|
|
64
73
|
runId: settled.runId,
|
|
65
74
|
attempt: attempt.record.attempt,
|
|
66
|
-
workflow: context.workflow,
|
|
75
|
+
workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
|
|
67
76
|
accounting: settled.accounting,
|
|
68
77
|
usage: settled.usage,
|
|
69
78
|
artifacts: dir,
|
|
@@ -91,14 +100,35 @@ export async function keepRecords(
|
|
|
91
100
|
kind: "attempt-ended",
|
|
92
101
|
outcome: ending.kind,
|
|
93
102
|
}).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
|
|
94
|
-
|
|
103
|
+
// An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
|
|
104
|
+
// the run returned and is refused, so what no wait took is counted whole. Its last batch was
|
|
105
|
+
// handled, as the code returned after it.
|
|
106
|
+
const writeEnding = async () => {
|
|
107
|
+
const unacknowledged =
|
|
108
|
+
ending.kind === "completed" && attempt.run.record.events
|
|
109
|
+
? await readMailbox(dir).then(
|
|
110
|
+
// The event that started the run is `wf.started`'s, taken or not.
|
|
111
|
+
(records) => {
|
|
112
|
+
const state = foldMailbox(records);
|
|
113
|
+
return state.unacknowledged.filter((id) => id !== state.started?.id).length;
|
|
114
|
+
},
|
|
115
|
+
(error: unknown) => {
|
|
116
|
+
stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
|
|
117
|
+
return undefined;
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
: undefined;
|
|
95
121
|
await endAttempt(attempt, {
|
|
96
122
|
outcome: ending.kind,
|
|
97
|
-
...(
|
|
123
|
+
...(unacknowledged === undefined ? {} : { unacknowledged }),
|
|
124
|
+
...(unfinished ? { reason: unfinished.reason } : {}),
|
|
98
125
|
...at,
|
|
99
126
|
...(stages ? { stages } : {}),
|
|
100
127
|
...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
|
|
101
128
|
});
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
await (context.locked ?? ((work: () => Promise<void>) => work()))(writeEnding);
|
|
102
132
|
} catch (error) {
|
|
103
133
|
stderr(`awf: the attempt's ending was not written to ${attempt.file}: ${messageOf(error)}`);
|
|
104
134
|
}
|
|
@@ -106,7 +136,7 @@ export async function keepRecords(
|
|
|
106
136
|
}
|
|
107
137
|
|
|
108
138
|
async function writeReport(
|
|
109
|
-
|
|
139
|
+
workflow: AnyWorkflow,
|
|
110
140
|
ending: Ending<JsonValue>,
|
|
111
141
|
dir: string,
|
|
112
142
|
stderr: (text: string) => void,
|
|
@@ -114,11 +144,11 @@ async function writeReport(
|
|
|
114
144
|
const file = reportFile(dir);
|
|
115
145
|
// An earlier attempt's report is gone with it: report.md is the last ended attempt's.
|
|
116
146
|
const none = () => rm(file, { force: true }).then(() => undefined);
|
|
117
|
-
if (!
|
|
147
|
+
if (!workflow.report) return none();
|
|
118
148
|
// The result is still in output.json; a report that cannot be rendered or saved should not fail
|
|
119
149
|
// the run, nor cost it its record.
|
|
120
150
|
try {
|
|
121
|
-
const markdown =
|
|
151
|
+
const markdown = workflow.report(
|
|
122
152
|
ending.kind === "completed" ? ending.value : undefined,
|
|
123
153
|
ending,
|
|
124
154
|
);
|
package/src/attempt-view.ts
CHANGED
|
@@ -5,12 +5,7 @@ import type {
|
|
|
5
5
|
StageNeed,
|
|
6
6
|
StageRecord,
|
|
7
7
|
} from "@agentswf/contract/records";
|
|
8
|
-
import type {
|
|
9
|
-
Ending,
|
|
10
|
-
ExecutableWorkflow,
|
|
11
|
-
JsonValue,
|
|
12
|
-
StageSummary,
|
|
13
|
-
} from "@agentswf/contract/workflow";
|
|
8
|
+
import type { Ending, JsonValue, StageSummary } from "@agentswf/contract/workflow";
|
|
14
9
|
import { ago, describeAccounting, describeAttempts } from "./accounting/format";
|
|
15
10
|
import { sumAttempts } from "./accounting/summary";
|
|
16
11
|
import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
|
|
@@ -22,6 +17,7 @@ import type { RunCommand } from "./run-command";
|
|
|
22
17
|
import type { AttemptRefusal } from "./runs";
|
|
23
18
|
import { schemaLines } from "./stage-schema";
|
|
24
19
|
import { FromStageUnreached, type WorkflowStopped } from "./stopped";
|
|
20
|
+
import type { AnyWorkflow } from "./workflow-loader";
|
|
25
21
|
|
|
26
22
|
/**
|
|
27
23
|
* Prints how the attempt ended, and returns its exit code. A completed one's result goes to stdout;
|
|
@@ -32,7 +28,7 @@ export function printEnding(
|
|
|
32
28
|
end: AttemptEnd,
|
|
33
29
|
kept: Kept,
|
|
34
30
|
context: {
|
|
35
|
-
|
|
31
|
+
workflow: AnyWorkflow;
|
|
36
32
|
/** The run's id and folder. */
|
|
37
33
|
run: { id: string; dir: string };
|
|
38
34
|
n: number;
|
|
@@ -56,7 +52,7 @@ export function printEnding(
|
|
|
56
52
|
const { ending, settled } = end;
|
|
57
53
|
const presented =
|
|
58
54
|
ending.kind === "completed" && !context.json
|
|
59
|
-
? present(context.
|
|
55
|
+
? present(context.workflow, ending, stderr)
|
|
60
56
|
: undefined;
|
|
61
57
|
if (end.alsoFailed) stderr(`awf: also failed: ${end.alsoFailed}`);
|
|
62
58
|
const again =
|
|
@@ -67,7 +63,7 @@ export function printEnding(
|
|
|
67
63
|
if (context.cleanupAlso) stderr(`awf: runtime cleanup also failed: ${context.cleanupAlso}`);
|
|
68
64
|
// Beside the result rather than in it: stdout stays the workflow's report or the JSON.
|
|
69
65
|
const lines = describeEnding(ending, {
|
|
70
|
-
named: `${context.
|
|
66
|
+
named: `${context.workflow.meta.name} ${context.run.id}`,
|
|
71
67
|
n: context.n,
|
|
72
68
|
...(settled ? { accounting: settled.accounting } : {}),
|
|
73
69
|
earlier: context.earlier,
|
|
@@ -78,7 +74,7 @@ export function printEnding(
|
|
|
78
74
|
...(end.needs ? { needs: end.needs } : {}),
|
|
79
75
|
...(settled?.panes ? { panes: settled.panes } : {}),
|
|
80
76
|
// As the run's Herdr workspace is labelled.
|
|
81
|
-
runWorkspace: `awf ${context.
|
|
77
|
+
runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
|
|
82
78
|
paint: context.terminal?.color ? ANSI : PLAIN,
|
|
83
79
|
});
|
|
84
80
|
for (const line of ["", ...lines]) stderr(line);
|
|
@@ -91,7 +87,9 @@ export function printEnding(
|
|
|
91
87
|
export function toldOf(end: AttemptEnd, kept: Kept): string {
|
|
92
88
|
const { ending } = end;
|
|
93
89
|
const told = `${OUTCOMES[ending.kind].told}${kept.output ? `; its record is ${kept.output}` : ""}`;
|
|
94
|
-
|
|
90
|
+
if (ending.kind === "completed") return told;
|
|
91
|
+
if (ending.kind === "parked") return ending.label ? `${told}: ${ending.label}` : told;
|
|
92
|
+
return `${told}: ${ending.reason}`;
|
|
95
93
|
}
|
|
96
94
|
|
|
97
95
|
/**
|
|
@@ -135,6 +133,17 @@ function describeEnding(
|
|
|
135
133
|
if (ending.kind === "completed") {
|
|
136
134
|
return [`${mark} ${word} · ${run}`, ...figures, ...rows([], context)];
|
|
137
135
|
}
|
|
136
|
+
if (ending.kind === "parked") {
|
|
137
|
+
const waits = [ending.label, ending.until && `until ${ending.until}`]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(" · ");
|
|
140
|
+
return [
|
|
141
|
+
`${mark} ${word}${waits ? `: ${waits}` : ""}`,
|
|
142
|
+
` ${run}`,
|
|
143
|
+
...figures,
|
|
144
|
+
...rows([], context),
|
|
145
|
+
];
|
|
146
|
+
}
|
|
138
147
|
// A reason's later lines, such as a schema's problems, sit under it, apart from the rows.
|
|
139
148
|
const [first, ...more] = ending.reason.split("\n");
|
|
140
149
|
const goOn = context.choose
|
|
@@ -233,13 +242,13 @@ function runTotal(n: number, current: RunAccounting, earlier: readonly AttemptRe
|
|
|
233
242
|
|
|
234
243
|
/** A completed attempt's value as the workflow presents it; undefined to print the JSON. */
|
|
235
244
|
function present(
|
|
236
|
-
|
|
245
|
+
workflow: AnyWorkflow,
|
|
237
246
|
ending: Extract<Ending<JsonValue>, { kind: "completed" }>,
|
|
238
247
|
stderr: (text: string) => void,
|
|
239
248
|
): string | undefined {
|
|
240
|
-
if (!
|
|
249
|
+
if (!workflow.present) return undefined;
|
|
241
250
|
try {
|
|
242
|
-
return
|
|
251
|
+
return workflow.present(ending.value, ending)?.trimEnd();
|
|
243
252
|
} catch (error) {
|
|
244
253
|
stderr(`awf: present: ${messageOf(error)}; printing the full result instead`);
|
|
245
254
|
return undefined;
|
|
@@ -343,8 +352,9 @@ export function continueCommand(
|
|
|
343
352
|
...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
|
|
344
353
|
...(command.cwdGiven ? ["--cwd", command.cwd] : []),
|
|
345
354
|
pathFrom(command.shellCwd, command.workflowFile),
|
|
346
|
-
"--
|
|
355
|
+
"--id",
|
|
347
356
|
id,
|
|
357
|
+
"--continue",
|
|
348
358
|
...(redo === undefined ? [] : ["--from-stage", redo]),
|
|
349
359
|
...(values === undefined ? [] : ["--values", values]),
|
|
350
360
|
]
|