@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/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,130 @@
|
|
|
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 type { Out } from "./run-commands";
|
|
6
|
+
import type { Launch } from "./wake";
|
|
7
|
+
import { loadWorkflowFile } from "./workflow-loader";
|
|
8
|
+
|
|
9
|
+
export const emitUsage = [
|
|
10
|
+
"usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--json]",
|
|
11
|
+
"",
|
|
12
|
+
"Sends an event to the run of a workflow with events that --id names. That run starts if it has",
|
|
13
|
+
"none and the event is one that starts one, and is woken if it has one: nudged while it runs, continued when it is",
|
|
14
|
+
"parked or crashed. The run lives in the project of <file>, the nearest directory above it",
|
|
15
|
+
"holding .awf/, whoever sends. --event-id names the occurrence: the same id again is a duplicate.",
|
|
16
|
+
"",
|
|
17
|
+
"Prints what became of the event, and what was done to the run: accepted, duplicate or refused.",
|
|
18
|
+
"--json prints one document. Exits 0 for any of the three, 1 when the run's attempt could not be",
|
|
19
|
+
"started (the event is kept, and the next event or awf tick tries again), 2 when the event is",
|
|
20
|
+
"wrong for its target: its name, its data, or its id.",
|
|
21
|
+
].join("\n");
|
|
22
|
+
|
|
23
|
+
export async function emitCommand(
|
|
24
|
+
argv: readonly string[],
|
|
25
|
+
shellCwd: string,
|
|
26
|
+
out: Out,
|
|
27
|
+
context: {
|
|
28
|
+
home: string;
|
|
29
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
launch?: Launch;
|
|
31
|
+
},
|
|
32
|
+
): Promise<number> {
|
|
33
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
34
|
+
out.stdout(emitUsage);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
let parsed: {
|
|
38
|
+
target: string;
|
|
39
|
+
run: string;
|
|
40
|
+
name: string;
|
|
41
|
+
data: JsonValue;
|
|
42
|
+
id?: string;
|
|
43
|
+
json: boolean;
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
parsed = parse(argv);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
let result: EmitResultJson;
|
|
52
|
+
try {
|
|
53
|
+
const loaded = await loadWorkflowFile(parsed.target, shellCwd);
|
|
54
|
+
if (!isEventWorkflow(loaded.workflow)) {
|
|
55
|
+
out.stderr(`awf: ${loaded.file} declares no events; awf run starts it`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
result = await emitEvent(
|
|
59
|
+
loaded.workflow,
|
|
60
|
+
{
|
|
61
|
+
run: parsed.run,
|
|
62
|
+
name: parsed.name,
|
|
63
|
+
data: parsed.data,
|
|
64
|
+
...(parsed.id === undefined ? {} : { id: parsed.id }),
|
|
65
|
+
},
|
|
66
|
+
{ from: "operator", ...context },
|
|
67
|
+
);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
out.stderr(`awf: ${messageOf(error)}`);
|
|
70
|
+
return error instanceof EmitError ? 2 : 1;
|
|
71
|
+
}
|
|
72
|
+
return printEmit(result, parsed.json, out);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Prints an emit's result, as `awf emit` and `awf send` do: 1 when the run's attempt didn't start. */
|
|
76
|
+
export function printEmit(result: EmitResultJson, json: boolean, out: Out): number {
|
|
77
|
+
out.stdout(json ? JSON.stringify(result) : emitLine(result));
|
|
78
|
+
if (result.why !== "launch-failed") return 0;
|
|
79
|
+
out.stderr(`awf: the run's attempt did not start: ${result.error}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `accepted dd-case/kombo: started`. */
|
|
84
|
+
function emitLine(result: EmitResultJson): string {
|
|
85
|
+
const did = result.did === "none" ? result.why : result.did;
|
|
86
|
+
return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parse(argv: readonly string[]): {
|
|
90
|
+
target: string;
|
|
91
|
+
run: string;
|
|
92
|
+
name: string;
|
|
93
|
+
data: JsonValue;
|
|
94
|
+
id?: string;
|
|
95
|
+
json: boolean;
|
|
96
|
+
} {
|
|
97
|
+
let target: string | undefined;
|
|
98
|
+
let run: string | undefined;
|
|
99
|
+
let id: string | undefined;
|
|
100
|
+
let json = false;
|
|
101
|
+
const words: string[] = [];
|
|
102
|
+
for (let i = 0; i < argv.length; i++) {
|
|
103
|
+
const word = argv[i]!;
|
|
104
|
+
const value = () => {
|
|
105
|
+
const next = argv[++i];
|
|
106
|
+
if (next === undefined) throw new Error(`${word} needs a value`);
|
|
107
|
+
return next;
|
|
108
|
+
};
|
|
109
|
+
if (word === "--target") target = value();
|
|
110
|
+
else if (word === "--id") run = value();
|
|
111
|
+
else if (word === "--event-id") id = value();
|
|
112
|
+
else if (word === "--json") json = true;
|
|
113
|
+
else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
|
|
114
|
+
else words.push(word);
|
|
115
|
+
}
|
|
116
|
+
if (target === undefined) throw new Error("--target names the workflow file the event is for");
|
|
117
|
+
if (run === undefined) throw new Error("--id names the run the event is for, by its id");
|
|
118
|
+
const [name, text, ...more] = words;
|
|
119
|
+
if (name === undefined) throw new Error("the event's name is missing");
|
|
120
|
+
if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
|
|
121
|
+
let data: JsonValue = null;
|
|
122
|
+
if (text !== undefined) {
|
|
123
|
+
try {
|
|
124
|
+
data = JSON.parse(text) as JsonValue;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { target, run, name, data, ...(id === undefined ? {} : { id }), json };
|
|
130
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { type EmitResultJson, MAILBOX_RECORD_VERSION } from "@agentswf/contract/records";
|
|
4
|
+
import { parseJsonSchema, validate } from "@agentswf/contract/schema";
|
|
5
|
+
import {
|
|
6
|
+
AWF_ID_PREFIX,
|
|
7
|
+
eventWorkflowProblem,
|
|
8
|
+
type JsonValue,
|
|
9
|
+
nameProblem,
|
|
10
|
+
} from "@agentswf/contract/workflow";
|
|
11
|
+
import { messageOf } from "./errors";
|
|
12
|
+
import { exists } from "./files";
|
|
13
|
+
import { appendMailbox, foldMailbox, readMailbox } from "./mailbox";
|
|
14
|
+
import { projectOf, projectRunRoot, recordProject } from "./projects";
|
|
15
|
+
import { runLockFile, withRunLock } from "./run-lock";
|
|
16
|
+
import { prepareArgs } from "./run-prepare";
|
|
17
|
+
import { createRun, idProblem, openRun, type Run, readAttempts } from "./runs";
|
|
18
|
+
import { type WakeContext, type Woken, wake } from "./wake";
|
|
19
|
+
import type { EventWorkflow } from "./workflow-loader";
|
|
20
|
+
|
|
21
|
+
/** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
|
|
22
|
+
export class EmitError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "EmitError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type EmitContext = Omit<WakeContext, "file"> & {
|
|
30
|
+
/** As `WorkflowEvent.from`: the sending run's ref, or `operator`. */
|
|
31
|
+
from: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A sender's way in: checks the event against its target and delivers it to the run it names. A wrong name,
|
|
36
|
+
* shape or id throws an `EmitError`; the run's state is the outcome.
|
|
37
|
+
*/
|
|
38
|
+
export async function emitEvent(
|
|
39
|
+
target: EventWorkflow,
|
|
40
|
+
event: { run: string; name: string; data: JsonValue; id?: string },
|
|
41
|
+
context: EmitContext,
|
|
42
|
+
): Promise<EmitResultJson> {
|
|
43
|
+
const workflow = target.meta.name;
|
|
44
|
+
const { name, data } = event;
|
|
45
|
+
checkEvent(target, event);
|
|
46
|
+
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
47
|
+
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
48
|
+
const runId = event.run;
|
|
49
|
+
const problem = idProblem(runId);
|
|
50
|
+
if (problem) throw new EmitError(`the run's id: ${problem}`);
|
|
51
|
+
const project = await projectOf(target.file, context.home).catch((error: unknown) => {
|
|
52
|
+
throw new EmitError(messageOf(error));
|
|
53
|
+
});
|
|
54
|
+
const starts = target.starts?.includes(name) ?? true;
|
|
55
|
+
return deliver(
|
|
56
|
+
{
|
|
57
|
+
runRoot: projectRunRoot(project),
|
|
58
|
+
workflow,
|
|
59
|
+
runId,
|
|
60
|
+
event: { id, name, data, from: context.from, at },
|
|
61
|
+
deduplicate: event.id !== undefined,
|
|
62
|
+
file: target.file,
|
|
63
|
+
...(starts ? { start: { project, target } } : {}),
|
|
64
|
+
},
|
|
65
|
+
context,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An event for a run of a workflow with events, as `deliver` takes it. */
|
|
70
|
+
export type Delivery = {
|
|
71
|
+
runRoot: string;
|
|
72
|
+
workflow: string;
|
|
73
|
+
runId: string;
|
|
74
|
+
event: { id: string; name: string; data: JsonValue; from: string; at: string };
|
|
75
|
+
/** False for an id awf made at random, which nothing repeats. */
|
|
76
|
+
deduplicate: boolean;
|
|
77
|
+
/** Given when the event may start the run: what a new run records, and the workflow it prepares. */
|
|
78
|
+
start?: { project: string; target: EventWorkflow };
|
|
79
|
+
/** The file a sender named, which a start or continue runs; absent, the run's last. */
|
|
80
|
+
file?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The one way in, for every event a run of a workflow with events gets: a sender's, a participant's reply, or awf's
|
|
85
|
+
* own. Under the run's lock it finds a duplicate, refuses the event, or creates the run, appends
|
|
86
|
+
* the event and wakes the run.
|
|
87
|
+
*/
|
|
88
|
+
export async function deliver(
|
|
89
|
+
delivery: Delivery,
|
|
90
|
+
context: Omit<WakeContext, "file">,
|
|
91
|
+
): Promise<EmitResultJson> {
|
|
92
|
+
const { runRoot, workflow, runId, event, start, file } = delivery;
|
|
93
|
+
const woken = { ...context, ...(file ? { file } : {}) };
|
|
94
|
+
const ref = `${workflow}/${runId}`;
|
|
95
|
+
return withRunLock(runLockFile(runRoot, workflow, runId), async () => {
|
|
96
|
+
let run: Run | undefined = (await exists(join(runRoot, workflow, runId, "run.json")))
|
|
97
|
+
? await openRun(runRoot, workflow, runId)
|
|
98
|
+
: undefined;
|
|
99
|
+
// What was due is `awf tick`'s to say; an emit's caller knows it sent an event.
|
|
100
|
+
const result = (outcome: EmitResultJson["outcome"], { cause: _, ...woken }: Woken) =>
|
|
101
|
+
({ v: 1, outcome, ref, eventId: event.id, ...woken }) satisfies EmitResultJson;
|
|
102
|
+
if (run) {
|
|
103
|
+
// Not appended again, but it still wakes the run: a sender retrying after a launch that failed
|
|
104
|
+
// sends the same id.
|
|
105
|
+
if (delivery.deduplicate && foldMailbox(await readMailbox(run.dir)).events.has(event.id))
|
|
106
|
+
return result("duplicate", await wake(run, { ...woken, nudgeLive: false }));
|
|
107
|
+
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
108
|
+
return result("refused", { did: "none", why: "returned" });
|
|
109
|
+
}
|
|
110
|
+
const starts = !run;
|
|
111
|
+
if (!run) {
|
|
112
|
+
if (!start) return result("refused", { did: "none", why: "not-a-start" });
|
|
113
|
+
checkStartArgs(start.target, start.project);
|
|
114
|
+
run = await createEventRun(runRoot, workflow, runId, start.project, file, context);
|
|
115
|
+
}
|
|
116
|
+
await appendMailbox(run.dir, {
|
|
117
|
+
version: MAILBOX_RECORD_VERSION,
|
|
118
|
+
kind: "event",
|
|
119
|
+
...event,
|
|
120
|
+
...(starts ? { starts: true as const } : {}),
|
|
121
|
+
});
|
|
122
|
+
return result("accepted", await wake(run, woken));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A run an event starts has no argv: its args are what `prepare` makes of none, checked before the
|
|
128
|
+
* run is made, so a workflow that needs args refuses the sender and leaves no run.
|
|
129
|
+
*/
|
|
130
|
+
function checkStartArgs(target: EventWorkflow, project: string): void {
|
|
131
|
+
try {
|
|
132
|
+
prepareArgs(target, [], project);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new EmitError(
|
|
135
|
+
`${target.meta.name}: a run an event starts has no arguments, which its prepare refuses: ${messageOf(error)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A new run of a workflow with events, in `project`, recorded so `awf tick` finds it. */
|
|
141
|
+
async function createEventRun(
|
|
142
|
+
runRoot: string,
|
|
143
|
+
workflow: string,
|
|
144
|
+
id: string,
|
|
145
|
+
project: string,
|
|
146
|
+
file: string | undefined,
|
|
147
|
+
context: Pick<WakeContext, "home" | "environment">,
|
|
148
|
+
argv: readonly string[] = [],
|
|
149
|
+
): Promise<Run> {
|
|
150
|
+
const run = await createRun(runRoot, {
|
|
151
|
+
id,
|
|
152
|
+
workflow,
|
|
153
|
+
argv: [...argv],
|
|
154
|
+
cwd: project,
|
|
155
|
+
sandbox: null,
|
|
156
|
+
events: true,
|
|
157
|
+
env: { PATH: context.environment.PATH ?? "" },
|
|
158
|
+
...(file ? { file } : {}),
|
|
159
|
+
});
|
|
160
|
+
await recordProject(context.home, project);
|
|
161
|
+
return run;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** What `awf run --id` did to a run of a workflow with events: started it, or found it there. */
|
|
165
|
+
export type Opened = { run: string; created: boolean } & Woken;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `awf run --id` for a workflow with events: creates the run with no event, under its lock, and
|
|
169
|
+
* starts it, so it runs to its first wait and parks there. One already there is only woken, as
|
|
170
|
+
* `awf tick` would; one that returned is refused, as its events are.
|
|
171
|
+
*/
|
|
172
|
+
export async function openEventRun(
|
|
173
|
+
target: EventWorkflow,
|
|
174
|
+
id: string,
|
|
175
|
+
argv: readonly string[],
|
|
176
|
+
context: Omit<WakeContext, "file">,
|
|
177
|
+
): Promise<Opened> {
|
|
178
|
+
const workflow = target.meta.name;
|
|
179
|
+
const problem = idProblem(id);
|
|
180
|
+
if (problem) throw new EmitError(problem);
|
|
181
|
+
const project = await projectOf(target.file, context.home).catch((error: unknown) => {
|
|
182
|
+
throw new EmitError(messageOf(error));
|
|
183
|
+
});
|
|
184
|
+
const runRoot = projectRunRoot(project);
|
|
185
|
+
const ref = `${workflow}/${id}`;
|
|
186
|
+
return withRunLock(runLockFile(runRoot, workflow, id), async () => {
|
|
187
|
+
const woken = { ...context, file: target.file };
|
|
188
|
+
if (await exists(join(runRoot, workflow, id, "run.json"))) {
|
|
189
|
+
const run = await openRun(runRoot, workflow, id);
|
|
190
|
+
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
191
|
+
return { run: ref, created: false, did: "none", why: "returned" };
|
|
192
|
+
if (argv.length > 0 && JSON.stringify(argv) !== JSON.stringify(run.record.argv))
|
|
193
|
+
throw new EmitError(
|
|
194
|
+
`${ref} is there already, started with ${run.record.argv.length > 0 ? "arguments of its own" : "none"}; awf run --id ${id} with none wakes it`,
|
|
195
|
+
);
|
|
196
|
+
return { run: ref, created: false, ...(await wake(run, woken)) };
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
prepareArgs(target, argv, project);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new EmitError(`prepare: ${messageOf(error)}`);
|
|
202
|
+
}
|
|
203
|
+
const run = await createEventRun(runRoot, workflow, id, project, target.file, context, argv);
|
|
204
|
+
return { run: ref, created: true, ...(await wake(run, woken)) };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit. */
|
|
209
|
+
export function checkEvent(
|
|
210
|
+
target: EventWorkflow,
|
|
211
|
+
event: { name: string; data: JsonValue; id?: string },
|
|
212
|
+
): void {
|
|
213
|
+
const workflow = target.meta.name;
|
|
214
|
+
const problem = eventWorkflowProblem(target);
|
|
215
|
+
if (problem) throw new EmitError(`${workflow}: ${problem}`);
|
|
216
|
+
if (!isAbsolute(target.file))
|
|
217
|
+
throw new EmitError(`${workflow}: its file, ${target.file}, is import.meta.path, absolute`);
|
|
218
|
+
if (event.name.includes(":"))
|
|
219
|
+
throw new EmitError(`${event.name}: a name with a colon is awf's own, and not sent`);
|
|
220
|
+
const schema = target.events[event.name];
|
|
221
|
+
if (!schema)
|
|
222
|
+
throw new EmitError(
|
|
223
|
+
`${workflow} declares no event ${event.name}; its events are ${Object.keys(target.events).join(", ")}`,
|
|
224
|
+
);
|
|
225
|
+
let parsed: ReturnType<typeof parseJsonSchema>;
|
|
226
|
+
try {
|
|
227
|
+
parsed = parseJsonSchema(schema);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new EmitError(`${workflow}'s schema for ${event.name}: ${String(error)}`);
|
|
230
|
+
}
|
|
231
|
+
const errors = validate(parsed, event.data);
|
|
232
|
+
if (errors.length > 0)
|
|
233
|
+
throw new EmitError(
|
|
234
|
+
`${event.name}'s data does not fit its schema: ${errors.map((e) => `${e.path || "/"}: ${e.message}`).join("; ")}`,
|
|
235
|
+
);
|
|
236
|
+
const badId = event.id === undefined ? undefined : eventIdProblem(event.id);
|
|
237
|
+
if (badId) throw new EmitError(`the id ${JSON.stringify(event.id)}: ${badId}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** What is wrong with an id a sender gives an event, if anything: ids starting `awf.` are awf's. */
|
|
241
|
+
export function eventIdProblem(id: string): string | undefined {
|
|
242
|
+
return (
|
|
243
|
+
nameProblem(id) ??
|
|
244
|
+
(id.startsWith(AWF_ID_PREFIX) ? `ids starting ${AWF_ID_PREFIX} are awf's own` : undefined)
|
|
245
|
+
);
|
|
246
|
+
}
|
package/src/files.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { link, lstat, open, rename, unlink } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** `path` with its symlinks resolved, or only made absolute when it doesn't exist. */
|
|
7
|
+
export function realPath(path: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(resolve(path));
|
|
10
|
+
} catch {
|
|
11
|
+
return resolve(path);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
4
14
|
|
|
5
15
|
/**
|
|
6
16
|
* Writes `value` to `path` whole: a temp file beside it, synced, then renamed into place. A crash
|
package/src/here.ts
CHANGED
|
@@ -53,6 +53,12 @@ export async function startHere(
|
|
|
53
53
|
stderr(`awf: ${ready.refused}`);
|
|
54
54
|
return 2;
|
|
55
55
|
}
|
|
56
|
+
if ("opens" in ready) {
|
|
57
|
+
stderr(
|
|
58
|
+
"awf: a workflow with events runs detached, in its project; awf run --id starts it, without --here",
|
|
59
|
+
);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
56
62
|
for (const line of ready.prepared.continued?.warnings ?? []) notice(line);
|
|
57
63
|
const env = environment.environment ?? process.env;
|
|
58
64
|
const run = environment.herdr ?? runProcess;
|
|
@@ -99,7 +105,7 @@ export async function startHere(
|
|
|
99
105
|
// Under codex this can be another pane's workspace (E8); the run's tab still works from it.
|
|
100
106
|
workspace,
|
|
101
107
|
cwd: command.shellCwd,
|
|
102
|
-
label: tabLabel(ready.loaded.
|
|
108
|
+
label: tabLabel(ready.loaded.workflow.meta.name, id),
|
|
103
109
|
argv: [
|
|
104
110
|
...self,
|
|
105
111
|
"run",
|
|
@@ -125,7 +131,7 @@ export async function startHere(
|
|
|
125
131
|
"Run the workflow from a shell with awf run instead.",
|
|
126
132
|
);
|
|
127
133
|
}
|
|
128
|
-
notice(startedLine(ready.loaded.
|
|
134
|
+
notice(startedLine(ready.loaded.workflow.meta.name, id, command));
|
|
129
135
|
stdout(
|
|
130
136
|
[
|
|
131
137
|
`awf: ${basename(ready.loaded.file)} is starting in Herdr tab ${started.tabId}. Once this turn ends it takes this session over: each of its steps arrives here as a prompt, and a last message hands the session back.`,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Holds a run's lock from another process for `emit.test.ts`: `bun hold-run-lock.fixture.ts {file} {ms}`.
|
|
2
|
+
import { withRunLock } from "./run-lock";
|
|
3
|
+
|
|
4
|
+
const [file, ms] = process.argv.slice(2);
|
|
5
|
+
await withRunLock(file!, async () => {
|
|
6
|
+
console.log("held");
|
|
7
|
+
await Bun.sleep(Number(ms));
|
|
8
|
+
});
|