@agentswf/engine 0.1.0-beta.3 → 0.1.0-beta.5
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 +611 -0
- package/src/agent-cli/client.ts +115 -0
- package/src/agent-launcher.ts +15 -25
- package/src/allowance-command.ts +3 -15
- package/src/attempt-ending.ts +2 -4
- package/src/attempt-output.ts +3 -2
- package/src/attempt-view.ts +1 -1
- package/src/control-plane.ts +38 -6
- package/src/coordinator-socket.ts +15 -6
- package/src/deadlines.ts +69 -2
- package/src/decisions/directory.ts +9 -11
- package/src/deprecations.ts +177 -0
- package/src/duration.ts +50 -20
- package/src/emit-command.ts +57 -54
- package/src/emit.ts +124 -15
- package/src/help/allowance.txt +13 -0
- package/src/help/emit.txt +21 -0
- package/src/help/inbox.txt +12 -0
- package/src/help/init.txt +12 -0
- package/src/help/logs.txt +14 -0
- package/src/help/reply.txt +18 -0
- package/src/help/run.txt +58 -0
- package/src/help/runs.txt +11 -0
- package/src/help/test.txt +9 -0
- package/src/help/tick.txt +12 -0
- package/src/herdr-run-session.ts +2 -2
- package/src/here.ts +1 -1
- package/src/holder-command.ts +38 -51
- package/src/inbox-command.ts +9 -24
- package/src/init-command.ts +2 -11
- package/src/init-template/AGENTS.md.template +3 -3
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +6 -6
- package/src/keep-alive.ts +28 -13
- package/src/logs-command.ts +14 -27
- package/src/mailbox.ts +16 -21
- package/src/messaging/limits.ts +5 -5
- package/src/messaging/messaging.ts +48 -43
- package/src/messaging/routes.ts +2 -2
- package/src/messaging/text.ts +24 -22
- package/src/operation-liveness.ts +1 -1
- package/src/operator-cli.ts +23 -9
- package/src/operator-runtime.ts +9 -12
- package/src/pane-layout.ts +125 -32
- package/src/progress-view.ts +8 -8
- package/src/result-validation.ts +1 -1
- package/src/run-command.ts +27 -47
- package/src/run-commands.ts +1 -1
- package/src/run-prepare.ts +46 -8
- package/src/run-usage.ts +2 -2
- package/src/run-view.ts +9 -4
- package/src/runs-command.ts +14 -23
- package/src/runs.ts +44 -8
- package/src/sandboxes.ts +3 -2
- package/src/simple-input.ts +13 -3
- package/src/sources.ts +42 -6
- package/src/stage-ledger.ts +43 -13
- package/src/stage-plan.ts +37 -1
- package/src/test-command.ts +9 -14
- package/src/testing.ts +8 -9
- package/src/tick-command.ts +2 -13
- package/src/tools.ts +62 -25
- package/src/workflow-loader.ts +5 -2
- package/src/workflow-runner.ts +636 -271
- package/src/workflow-testing/coordinator.typecheck.ts +12 -13
- package/src/workflow-testing/coordinators.ts +2 -2
- package/src/workflow-testing/host.ts +115 -46
- package/src/workflow-testing/index.ts +45 -837
- package/src/workflow-testing/run-test.ts +1213 -0
- package/src/workflow-testing/script.ts +106 -45
- package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every name deprecated before 0.1.0, with what replaces it: each is logged once per attempt it is
|
|
3
|
+
* used in, so a continued or woken run logs it again, and removed one beta after the beta that
|
|
4
|
+
* deprecated it (story 039).
|
|
5
|
+
*/
|
|
6
|
+
export const DEPRECATIONS = {
|
|
7
|
+
labels: {
|
|
8
|
+
old: "an agent's labels option",
|
|
9
|
+
instead: "drop them: nothing reads an agent's labels",
|
|
10
|
+
},
|
|
11
|
+
whenToUse: { old: "meta.whenToUse", instead: "drop it: nothing reads it" },
|
|
12
|
+
"stage-receive": {
|
|
13
|
+
old: "a stage around wf.receive()",
|
|
14
|
+
instead: "wait with wf.race(name, …), which is recorded under its name, outside a stage",
|
|
15
|
+
},
|
|
16
|
+
isAnswered: { old: "isAnswered(outcome)", instead: "check outcome.answered" },
|
|
17
|
+
defineExecutableWorkflow: {
|
|
18
|
+
old: "defineExecutableWorkflow",
|
|
19
|
+
instead: "use defineWorkflow, with meta and run beside prepare",
|
|
20
|
+
},
|
|
21
|
+
"call-id": {
|
|
22
|
+
old: "the id of one run, start, compact or set",
|
|
23
|
+
instead:
|
|
24
|
+
"drop it: a later attempt doesn't know it, and a stage keeps a call from running twice",
|
|
25
|
+
},
|
|
26
|
+
"turn-source": {
|
|
27
|
+
old: "a started turn as a wait's branch",
|
|
28
|
+
instead: "name the agent, { done: agent }, which has at most one started turn",
|
|
29
|
+
},
|
|
30
|
+
"turn-is": { old: "turn.is(e)", instead: "use agent.is(e)" },
|
|
31
|
+
"test-reply": {
|
|
32
|
+
old: "reply.… in a test's script",
|
|
33
|
+
instead: "use noAnswer.…, the same calls: a reply answers, these end a turn without an answer",
|
|
34
|
+
},
|
|
35
|
+
"message-silent": {
|
|
36
|
+
old: "message.silent() in a test's message script",
|
|
37
|
+
instead: "return nothing, which ends the turn without a reply",
|
|
38
|
+
},
|
|
39
|
+
"message-fail": {
|
|
40
|
+
old: "message.fail() in a test's message script",
|
|
41
|
+
instead: "return noAnswer.failed(reason), which fails the turn as a failed harness does",
|
|
42
|
+
},
|
|
43
|
+
"test-event-id": {
|
|
44
|
+
old: "an event's id in a test",
|
|
45
|
+
instead: "give it as eventId, as awf emit --event-id does",
|
|
46
|
+
},
|
|
47
|
+
"run-stopped": { old: "run.stopped", instead: 'read run.ending, whose kind is "stopped"' },
|
|
48
|
+
"run-parked": { old: "run.parked", instead: 'read run.ending, whose kind is "parked"' },
|
|
49
|
+
"colon-key": {
|
|
50
|
+
old: "a key with a colon",
|
|
51
|
+
instead: (key: string) =>
|
|
52
|
+
`${key === "" ? "use - in its place" : `name it ${key.replaceAll(":", "-")}`}: a colon is kept for awf's own event names`,
|
|
53
|
+
},
|
|
54
|
+
"agents-stop": {
|
|
55
|
+
old: "wf.agents.stop(key)",
|
|
56
|
+
instead: "call close(reason?) on the agent's ref",
|
|
57
|
+
},
|
|
58
|
+
"fork-runtime": {
|
|
59
|
+
old: "a fork's own placement, metered or effort",
|
|
60
|
+
instead:
|
|
61
|
+
"give them in its runtime, as open does: fork({ key, runtime: { placement, metered, effort } })",
|
|
62
|
+
},
|
|
63
|
+
"timeout-ms": {
|
|
64
|
+
old: "timeoutMs",
|
|
65
|
+
instead: 'give timeout, a Duration such as "90s" or "10m"',
|
|
66
|
+
},
|
|
67
|
+
"deadline-option": {
|
|
68
|
+
old: "a deadline: { unixMilliseconds } option",
|
|
69
|
+
instead: 'give timeout: a Date, or a Duration such as "20m"',
|
|
70
|
+
},
|
|
71
|
+
"nudge-deadline": {
|
|
72
|
+
old: "nudge.deadline",
|
|
73
|
+
instead: "drop it: the turn's timeout bounds its check-ins",
|
|
74
|
+
},
|
|
75
|
+
"keep-alive-timeout-ms": {
|
|
76
|
+
old: "keepAlive.timeoutMs",
|
|
77
|
+
instead: 'give keepAlive.window, a Duration such as "90s"',
|
|
78
|
+
},
|
|
79
|
+
"run-deadline-ms": {
|
|
80
|
+
old: "wf.deadline.unixMilliseconds",
|
|
81
|
+
instead: "wf.deadline is a Date: read wf.deadline.getTime()",
|
|
82
|
+
},
|
|
83
|
+
"stall-ms": { old: "a test's stallMs", instead: 'give stall, a Duration such as "2s"' },
|
|
84
|
+
"test-args": {
|
|
85
|
+
old: "testWorkflow(workflow, args, options)",
|
|
86
|
+
instead: "give args among the options: testWorkflow(workflow, { args, … })",
|
|
87
|
+
},
|
|
88
|
+
"test-recorded": { old: "a test's recorded", instead: "give it as earlier: { stages }" },
|
|
89
|
+
"test-mailbox": { old: "a test's mailbox", instead: "give it as earlier: { mailbox }" },
|
|
90
|
+
"to-code": {
|
|
91
|
+
old: 'to: "code"',
|
|
92
|
+
instead: (key: string) =>
|
|
93
|
+
`declare "${key}:reply": ParticipantReply in events and connect it without to: the code takes its messages as ${key}:reply`,
|
|
94
|
+
},
|
|
95
|
+
"undeclared-ended": {
|
|
96
|
+
old: "start() for an agent whose end isn't declared",
|
|
97
|
+
instead: (key: string) => {
|
|
98
|
+
const named = key.replaceAll(":", "-");
|
|
99
|
+
const declare = `declare "${named}:ended": AgentEnded in events: its end then arrives as ${named}:ended, not agent:ended`;
|
|
100
|
+
return key.includes(":")
|
|
101
|
+
? `rename the key ${named}, which can name its end, and ${declare}`
|
|
102
|
+
: declare;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"participant-is": {
|
|
106
|
+
old: "is(e) on a participant",
|
|
107
|
+
instead: 'read e.name === "{key}:reply", declared in events',
|
|
108
|
+
},
|
|
109
|
+
"test-participant-reply": {
|
|
110
|
+
old: 'a scripted { name: "participant:reply", from } in a test',
|
|
111
|
+
instead: 'name the declared event, { name: "{key}:reply", data }',
|
|
112
|
+
},
|
|
113
|
+
"send-event-id": {
|
|
114
|
+
old: "awf send --event-id",
|
|
115
|
+
instead:
|
|
116
|
+
'use awf emit {run} {key}:reply \'{"text":…,"author":…}\' --event-id {id}, with {key}:reply declared in the workflow\'s events',
|
|
117
|
+
},
|
|
118
|
+
"pane-layout": {
|
|
119
|
+
old: "an agent's layout",
|
|
120
|
+
instead:
|
|
121
|
+
"give pane: { tab, front } or pane: { beside, side, front }; the workspace is the operator's, awf run --workspace, and the session AWF_HERDR_SESSION",
|
|
122
|
+
},
|
|
123
|
+
"keep-pane": {
|
|
124
|
+
old: "an agent's keepPane",
|
|
125
|
+
instead: "drop it: keeping panes is the operator's, awf run --keep-panes",
|
|
126
|
+
},
|
|
127
|
+
"duration-ms": {
|
|
128
|
+
old: "a duration in ms",
|
|
129
|
+
instead: (value: string) =>
|
|
130
|
+
`give ${Number.parseInt(value, 10) / 1000}s: a duration is a decimal and s, m, h or d`,
|
|
131
|
+
},
|
|
132
|
+
"wait-seconds": {
|
|
133
|
+
old: "a bare number for --wait",
|
|
134
|
+
instead: (value: string) => `give a duration, --wait ${value}s`,
|
|
135
|
+
},
|
|
136
|
+
} as const satisfies Record<
|
|
137
|
+
string,
|
|
138
|
+
{ old: string; instead: string | ((subject: string) => string) }
|
|
139
|
+
>;
|
|
140
|
+
|
|
141
|
+
export type Deprecated = keyof typeof DEPRECATIONS;
|
|
142
|
+
|
|
143
|
+
/** One attempt's deprecated uses: each name said once, as a warning in the run's log. */
|
|
144
|
+
export class RunDeprecations {
|
|
145
|
+
/** Each name said, with its subject where it has one: each key with a colon is named. */
|
|
146
|
+
readonly #said = new Set<string>();
|
|
147
|
+
/** The attempt has ended: an outcome kept past it reports to no log. */
|
|
148
|
+
#settled = false;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly warn: (text: string) => void) {}
|
|
151
|
+
|
|
152
|
+
/** `subject`, where given, is what was used, and each subject is said once. */
|
|
153
|
+
used(name: Deprecated, subject?: string): void {
|
|
154
|
+
const said = subject === undefined ? name : `${name}\0${subject}`;
|
|
155
|
+
if (this.#said.has(said)) return;
|
|
156
|
+
this.#said.add(said);
|
|
157
|
+
this.warn(deprecationLine(name, subject));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
settle(): void {
|
|
161
|
+
this.#settled = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** As `used`, for a name the contract reports by its own spelling; one not listed is ignored. */
|
|
165
|
+
reported = (name: unknown): void => {
|
|
166
|
+
if (this.#settled) return;
|
|
167
|
+
if (typeof name === "string" && Object.hasOwn(DEPRECATIONS, name))
|
|
168
|
+
this.used(name as Deprecated);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The line a deprecated use logs. */
|
|
173
|
+
export function deprecationLine(name: Deprecated, subject = ""): string {
|
|
174
|
+
const { old, instead } = DEPRECATIONS[name];
|
|
175
|
+
const named = subject === "" ? "" : `, ${subject},`;
|
|
176
|
+
return `awf: ${old}${named} is deprecated; ${typeof instead === "function" ? instead(subject) : instead}`;
|
|
177
|
+
}
|
package/src/duration.ts
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { durationMilliseconds, positiveDurationMilliseconds } from "@agentswf/contract/workflow";
|
|
2
|
+
import { deprecationLine } from "./deprecations";
|
|
3
|
+
import { messageOf } from "./errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A flag's duration in milliseconds, longer than none: the one grammar, `"90s"`, `"1.5h"`, `"1d"`.
|
|
7
|
+
* A whole number of `ms`, which the CLI took before, is read until 0.1.0 and `deprecated` told.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDuration(
|
|
10
|
+
value: string,
|
|
11
|
+
flag: string,
|
|
12
|
+
deprecated: (line: string) => void,
|
|
13
|
+
): number {
|
|
14
|
+
const ms = /^(\d+)ms$/.exec(value);
|
|
15
|
+
if (!ms) return positiveDurationMilliseconds(value, flag);
|
|
16
|
+
const milliseconds = Number(ms[1]);
|
|
17
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0)
|
|
18
|
+
throw new Error(`${flag} is a positive duration: not ${value}`);
|
|
19
|
+
deprecated(deprecationLine("duration-ms", value));
|
|
11
20
|
return milliseconds;
|
|
12
21
|
}
|
|
13
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `--wait`, in seconds from none to `maxSeconds`: a duration, `"90s"`. A bare number of seconds,
|
|
25
|
+
* which it took before, is read until 0.1.0 and `deprecated` told.
|
|
26
|
+
*/
|
|
27
|
+
export function parseWait(
|
|
28
|
+
value: string,
|
|
29
|
+
maxSeconds: number,
|
|
30
|
+
deprecated: (line: string) => void,
|
|
31
|
+
): number {
|
|
32
|
+
const bare = /^\d+(\.\d+)?$/.test(value);
|
|
33
|
+
let seconds: number;
|
|
34
|
+
try {
|
|
35
|
+
seconds = bare ? Number(value) : durationMilliseconds(value) / 1000;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`--wait takes a duration up to ${maxSeconds}s: ${messageOf(error)}`);
|
|
38
|
+
}
|
|
39
|
+
if (seconds > maxSeconds) throw new Error(`--wait takes a duration up to ${maxSeconds}s`);
|
|
40
|
+
if (bare) deprecated(deprecationLine("wait-seconds", value));
|
|
41
|
+
return seconds;
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
/** `45s`, `6m40s`, `1h02m`. */
|
|
15
|
-
export function
|
|
45
|
+
export function formatDuration(milliseconds: number): string {
|
|
16
46
|
const seconds = Math.max(0, Math.round(milliseconds / 1000));
|
|
17
47
|
if (seconds < 60) return `${seconds}s`;
|
|
18
48
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -22,8 +52,8 @@ export function duration(milliseconds: number): string {
|
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
54
|
* When a timer given `when` is due, epoch ms, from `now`, and `when` as its record keeps it: a
|
|
25
|
-
*
|
|
26
|
-
*
|
|
55
|
+
* `Duration` as given, or a `Date` as an ISO time. A workflow's grammar, apart from the CLI's
|
|
56
|
+
* `parseDuration`: a wait is days long, and `1.5h` reads as written.
|
|
27
57
|
*/
|
|
28
58
|
export function parseWhen(when: unknown, now: number): { due: number; when: string } {
|
|
29
59
|
if (when instanceof Date) {
|
|
@@ -31,11 +61,11 @@ export function parseWhen(when: unknown, now: number): { due: number; when: stri
|
|
|
31
61
|
if (!Number.isFinite(due)) throw new Error("a timer's time is a valid Date");
|
|
32
62
|
return { due, when: when.toISOString() };
|
|
33
63
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
return { due: now +
|
|
64
|
+
let milliseconds: number;
|
|
65
|
+
try {
|
|
66
|
+
milliseconds = durationMilliseconds(when);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`a timer is due in a duration or at a Date: ${messageOf(error)}`);
|
|
69
|
+
}
|
|
70
|
+
return { due: now + milliseconds, when: when as string };
|
|
41
71
|
}
|
package/src/emit-command.ts
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
import type { EmitResultJson } from "@agentswf/contract/records";
|
|
2
2
|
import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
|
|
3
|
-
import { EmitError, emitEvent } from "./emit";
|
|
3
|
+
import { EmitError, emitEvent, emitToRun } from "./emit";
|
|
4
4
|
import { messageOf } from "./errors";
|
|
5
|
-
import
|
|
5
|
+
import help from "./help/emit.txt" with { type: "text" };
|
|
6
|
+
import { type Out, parsePlace, type ReadPlace } from "./run-commands";
|
|
7
|
+
import { findRunByRef } from "./run-state";
|
|
6
8
|
import type { Launch } from "./wake";
|
|
7
9
|
import { loadWorkflowFile } from "./workflow-loader";
|
|
8
10
|
|
|
9
|
-
export const emitUsage =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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");
|
|
11
|
+
export const emitUsage = help.trimEnd();
|
|
12
|
+
|
|
13
|
+
type Event = { name: string; data: JsonValue; id?: string };
|
|
14
|
+
|
|
15
|
+
/** `--target {file} --id {id}`, which may start the run; or a run that is there, by its ref. */
|
|
16
|
+
type Parsed = Event & { json: boolean } & (
|
|
17
|
+
| { target: string; run: string; ref?: undefined }
|
|
18
|
+
| { ref: string; place: ReadPlace; target?: undefined }
|
|
19
|
+
);
|
|
22
20
|
|
|
23
21
|
export async function emitCommand(
|
|
24
22
|
argv: readonly string[],
|
|
@@ -34,37 +32,35 @@ export async function emitCommand(
|
|
|
34
32
|
out.stdout(emitUsage);
|
|
35
33
|
return 0;
|
|
36
34
|
}
|
|
37
|
-
let parsed:
|
|
38
|
-
target: string;
|
|
39
|
-
run: string;
|
|
40
|
-
name: string;
|
|
41
|
-
data: JsonValue;
|
|
42
|
-
id?: string;
|
|
43
|
-
json: boolean;
|
|
44
|
-
};
|
|
35
|
+
let parsed: Parsed;
|
|
45
36
|
try {
|
|
46
|
-
parsed = parse(argv);
|
|
37
|
+
parsed = parse(argv, shellCwd);
|
|
47
38
|
} catch (error) {
|
|
48
39
|
out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
|
|
49
40
|
return 2;
|
|
50
41
|
}
|
|
42
|
+
const event = {
|
|
43
|
+
name: parsed.name,
|
|
44
|
+
data: parsed.data,
|
|
45
|
+
...(parsed.id === undefined ? {} : { id: parsed.id }),
|
|
46
|
+
};
|
|
51
47
|
let result: EmitResultJson;
|
|
52
48
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
if (parsed.ref !== undefined) {
|
|
50
|
+
const status = await findRunByRef(parsed.ref, parsed.place, context.home);
|
|
51
|
+
result = await emitToRun(status, event, context);
|
|
52
|
+
} else {
|
|
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
|
+
{ run: parsed.run, ...event },
|
|
61
|
+
{ from: "operator", ...context },
|
|
62
|
+
);
|
|
57
63
|
}
|
|
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
64
|
} catch (error) {
|
|
69
65
|
out.stderr(`awf: ${messageOf(error)}`);
|
|
70
66
|
return error instanceof EmitError ? 2 : 1;
|
|
@@ -86,35 +82,42 @@ function emitLine(result: EmitResultJson): string {
|
|
|
86
82
|
return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
|
|
87
83
|
}
|
|
88
84
|
|
|
89
|
-
function parse(argv: readonly string[]): {
|
|
90
|
-
|
|
91
|
-
run: string;
|
|
92
|
-
name: string;
|
|
93
|
-
data: JsonValue;
|
|
94
|
-
id?: string;
|
|
95
|
-
json: boolean;
|
|
96
|
-
} {
|
|
85
|
+
function parse(argv: readonly string[], shellCwd: string): Parsed {
|
|
86
|
+
const { place, json, rest } = parsePlace(argv, shellCwd);
|
|
97
87
|
let target: string | undefined;
|
|
98
88
|
let run: string | undefined;
|
|
99
89
|
let id: string | undefined;
|
|
100
|
-
let json = false;
|
|
101
90
|
const words: string[] = [];
|
|
102
|
-
for (let i = 0; i <
|
|
103
|
-
const word =
|
|
91
|
+
for (let i = 0; i < rest.length; i++) {
|
|
92
|
+
const word = rest[i]!;
|
|
104
93
|
const value = () => {
|
|
105
|
-
const next =
|
|
94
|
+
const next = rest[++i];
|
|
106
95
|
if (next === undefined) throw new Error(`${word} needs a value`);
|
|
107
96
|
return next;
|
|
108
97
|
};
|
|
109
98
|
if (word === "--target") target = value();
|
|
110
99
|
else if (word === "--id") run = value();
|
|
111
100
|
else if (word === "--event-id") id = value();
|
|
112
|
-
else if (word === "--json") json = true;
|
|
113
101
|
else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
|
|
114
102
|
else words.push(word);
|
|
115
103
|
}
|
|
116
|
-
|
|
117
|
-
if (
|
|
104
|
+
let form: { target: string; run: string } | { ref: string; place: ReadPlace };
|
|
105
|
+
if (target !== undefined) {
|
|
106
|
+
if (run === undefined) throw new Error("--id names the run the event is for, by its id");
|
|
107
|
+
if (place.repeat.length > 0)
|
|
108
|
+
throw new Error(
|
|
109
|
+
"--cwd and --run-root find a run by its ref; --target's file names its project",
|
|
110
|
+
);
|
|
111
|
+
form = { target, run };
|
|
112
|
+
} else {
|
|
113
|
+
if (run !== undefined) throw new Error("--id goes with --target; a run's ref names it alone");
|
|
114
|
+
const ref = words.shift();
|
|
115
|
+
if (ref === undefined)
|
|
116
|
+
throw new Error(
|
|
117
|
+
"name the run, <workflow>/<id> or <id>, or the workflow by --target and --id",
|
|
118
|
+
);
|
|
119
|
+
form = { ref, place };
|
|
120
|
+
}
|
|
118
121
|
const [name, text, ...more] = words;
|
|
119
122
|
if (name === undefined) throw new Error("the event's name is missing");
|
|
120
123
|
if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
|
|
@@ -126,5 +129,5 @@ function parse(argv: readonly string[]): {
|
|
|
126
129
|
throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
|
-
return {
|
|
132
|
+
return { ...form, name, data, ...(id === undefined ? {} : { id }), json };
|
|
130
133
|
}
|
package/src/emit.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type EmitResultJson,
|
|
5
|
+
MAILBOX_RECORD_VERSION,
|
|
6
|
+
type RunPanes,
|
|
7
|
+
} from "@agentswf/contract/records";
|
|
4
8
|
import { parseJsonSchema, validate } from "@agentswf/contract/schema";
|
|
5
9
|
import {
|
|
6
10
|
AWF_ID_PREFIX,
|
|
11
|
+
awfEventOf,
|
|
7
12
|
eventWorkflowProblem,
|
|
13
|
+
isEventWorkflow,
|
|
8
14
|
type JsonValue,
|
|
9
15
|
nameProblem,
|
|
16
|
+
ParticipantReply,
|
|
10
17
|
} from "@agentswf/contract/workflow";
|
|
11
18
|
import { messageOf } from "./errors";
|
|
12
19
|
import { exists } from "./files";
|
|
@@ -14,9 +21,10 @@ import { appendMailbox, foldMailbox, readMailbox } from "./mailbox";
|
|
|
14
21
|
import { projectOf, projectRunRoot, recordProject } from "./projects";
|
|
15
22
|
import { runLockFile, withRunLock } from "./run-lock";
|
|
16
23
|
import { prepareArgs } from "./run-prepare";
|
|
17
|
-
import
|
|
24
|
+
import type { RunStatus } from "./run-state";
|
|
25
|
+
import { createRun, idProblem, openRun, panesChanged, type Run, readAttempts } from "./runs";
|
|
18
26
|
import { type WakeContext, type Woken, wake } from "./wake";
|
|
19
|
-
import type
|
|
27
|
+
import { type EventWorkflow, loadWorkflowFile } from "./workflow-loader";
|
|
20
28
|
|
|
21
29
|
/** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
|
|
22
30
|
export class EmitError extends Error {
|
|
@@ -43,12 +51,13 @@ export async function emitEvent(
|
|
|
43
51
|
const workflow = target.meta.name;
|
|
44
52
|
const { name, data } = event;
|
|
45
53
|
checkEvent(target, event);
|
|
54
|
+
const file = fileOf(target);
|
|
46
55
|
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
47
56
|
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
48
57
|
const runId = event.run;
|
|
49
58
|
const problem = idProblem(runId);
|
|
50
59
|
if (problem) throw new EmitError(`the run's id: ${problem}`);
|
|
51
|
-
const project = await projectOf(
|
|
60
|
+
const project = await projectOf(file, context.home).catch((error: unknown) => {
|
|
52
61
|
throw new EmitError(messageOf(error));
|
|
53
62
|
});
|
|
54
63
|
const starts = target.starts?.includes(name) ?? true;
|
|
@@ -59,13 +68,69 @@ export async function emitEvent(
|
|
|
59
68
|
runId,
|
|
60
69
|
event: { id, name, data, from: context.from, at },
|
|
61
70
|
deduplicate: event.id !== undefined,
|
|
62
|
-
file
|
|
71
|
+
file,
|
|
63
72
|
...(starts ? { start: { project, target } } : {}),
|
|
64
73
|
},
|
|
65
74
|
context,
|
|
66
75
|
);
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/**
|
|
79
|
+
* `awf emit {ref} {key}:reply …`: a participant's message to the code of a run that is there,
|
|
80
|
+
* checked against the definition in the file a wake would run, its last attempt's, and delivered.
|
|
81
|
+
* It never starts a run, and doesn't ask whether the participant has connected: one sent before
|
|
82
|
+
* the run connects it waits in the mailbox for the run's next wait.
|
|
83
|
+
*/
|
|
84
|
+
export async function emitToRun(
|
|
85
|
+
status: RunStatus,
|
|
86
|
+
event: { name: string; data: JsonValue; id?: string },
|
|
87
|
+
context: Omit<WakeContext, "file">,
|
|
88
|
+
): Promise<EmitResultJson> {
|
|
89
|
+
const { run, ref } = status;
|
|
90
|
+
const reply = awfEventOf(event.name);
|
|
91
|
+
if (reply?.kind !== "reply")
|
|
92
|
+
throw new EmitError(
|
|
93
|
+
`awf emit {run} sends a participant's message to the code, {key}:reply, not ${event.name}; the workflow's own events go by --target {file} --id {id}`,
|
|
94
|
+
);
|
|
95
|
+
if (!run.record.events) throw new EmitError(`${ref} is a run of a workflow without events`);
|
|
96
|
+
// A returned run takes nothing, so its file, since moved or changed, is not read: a late reply is
|
|
97
|
+
// refused as returned, not failed for a retry that never ends.
|
|
98
|
+
if (status.attempts.at(-1)?.outcome === "completed")
|
|
99
|
+
return deliverReply(status, reply.key, event, context);
|
|
100
|
+
const file = status.attempts.at(-1)?.file ?? run.record.file;
|
|
101
|
+
if (file === undefined) throw new Error(`${ref} has no file to check the event against`);
|
|
102
|
+
const { workflow } = await loadWorkflowFile(file, run.record.cwd);
|
|
103
|
+
if (!isEventWorkflow(workflow) || workflow.meta.name !== run.record.workflow)
|
|
104
|
+
throw new Error(`${file} is no longer ${run.record.workflow} with events, which ${ref} ran`);
|
|
105
|
+
checkEvent(workflow, event, true);
|
|
106
|
+
return deliverReply(status, reply.key, event, context);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `{key}:reply` appended to a run that is there, and the run woken: a duplicate id is a duplicate.
|
|
111
|
+
* The caller has checked it.
|
|
112
|
+
*/
|
|
113
|
+
export function deliverReply(
|
|
114
|
+
status: RunStatus,
|
|
115
|
+
key: string,
|
|
116
|
+
event: { data: JsonValue; id?: string },
|
|
117
|
+
context: Omit<WakeContext, "file">,
|
|
118
|
+
): Promise<EmitResultJson> {
|
|
119
|
+
const { run } = status;
|
|
120
|
+
const at = new Date(context.now?.() ?? Date.now()).toISOString();
|
|
121
|
+
const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
|
|
122
|
+
return deliver(
|
|
123
|
+
{
|
|
124
|
+
runRoot: run.root,
|
|
125
|
+
workflow: run.record.workflow,
|
|
126
|
+
runId: run.record.id,
|
|
127
|
+
event: replyRecord(key, id, event.data, at),
|
|
128
|
+
deduplicate: event.id !== undefined,
|
|
129
|
+
},
|
|
130
|
+
context,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
69
134
|
/** An event for a run of a workflow with events, as `deliver` takes it. */
|
|
70
135
|
export type Delivery = {
|
|
71
136
|
runRoot: string;
|
|
@@ -146,6 +211,7 @@ async function createEventRun(
|
|
|
146
211
|
file: string | undefined,
|
|
147
212
|
context: Pick<WakeContext, "home" | "environment">,
|
|
148
213
|
argv: readonly string[] = [],
|
|
214
|
+
panes?: RunPanes,
|
|
149
215
|
): Promise<Run> {
|
|
150
216
|
const run = await createRun(runRoot, {
|
|
151
217
|
id,
|
|
@@ -156,6 +222,7 @@ async function createEventRun(
|
|
|
156
222
|
events: true,
|
|
157
223
|
env: { PATH: context.environment.PATH ?? "" },
|
|
158
224
|
...(file ? { file } : {}),
|
|
225
|
+
...(panes ? { panes } : {}),
|
|
159
226
|
});
|
|
160
227
|
await recordProject(context.home, project);
|
|
161
228
|
return run;
|
|
@@ -167,24 +234,27 @@ export type Opened = { run: string; created: boolean } & Woken;
|
|
|
167
234
|
/**
|
|
168
235
|
* `awf run --id` for a workflow with events: creates the run with no event, under its lock, and
|
|
169
236
|
* 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.
|
|
237
|
+
* `awf tick` would; one that returned is refused, as its events are. `panes`, the operator's
|
|
238
|
+
* `--workspace` and `--keep-panes`, are recorded with a new run, and may not change one there.
|
|
171
239
|
*/
|
|
172
240
|
export async function openEventRun(
|
|
173
241
|
target: EventWorkflow,
|
|
174
242
|
id: string,
|
|
175
243
|
argv: readonly string[],
|
|
176
244
|
context: Omit<WakeContext, "file">,
|
|
245
|
+
panes?: RunPanes,
|
|
177
246
|
): Promise<Opened> {
|
|
178
247
|
const workflow = target.meta.name;
|
|
179
248
|
const problem = idProblem(id);
|
|
180
249
|
if (problem) throw new EmitError(problem);
|
|
181
|
-
const
|
|
250
|
+
const file = fileOf(target);
|
|
251
|
+
const project = await projectOf(file, context.home).catch((error: unknown) => {
|
|
182
252
|
throw new EmitError(messageOf(error));
|
|
183
253
|
});
|
|
184
254
|
const runRoot = projectRunRoot(project);
|
|
185
255
|
const ref = `${workflow}/${id}`;
|
|
186
256
|
return withRunLock(runLockFile(runRoot, workflow, id), async () => {
|
|
187
|
-
const woken = { ...context, file
|
|
257
|
+
const woken = { ...context, file };
|
|
188
258
|
if (await exists(join(runRoot, workflow, id, "run.json"))) {
|
|
189
259
|
const run = await openRun(runRoot, workflow, id);
|
|
190
260
|
if ((await readAttempts(run)).at(-1)?.outcome === "completed")
|
|
@@ -193,6 +263,8 @@ export async function openEventRun(
|
|
|
193
263
|
throw new EmitError(
|
|
194
264
|
`${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
265
|
);
|
|
266
|
+
const changed = panesChanged(run.record, panes);
|
|
267
|
+
if (changed) throw new EmitError(changed);
|
|
196
268
|
return { run: ref, created: false, ...(await wake(run, woken)) };
|
|
197
269
|
}
|
|
198
270
|
try {
|
|
@@ -200,28 +272,65 @@ export async function openEventRun(
|
|
|
200
272
|
} catch (error) {
|
|
201
273
|
throw new EmitError(`prepare: ${messageOf(error)}`);
|
|
202
274
|
}
|
|
203
|
-
const run = await createEventRun(runRoot, workflow, id, project,
|
|
275
|
+
const run = await createEventRun(runRoot, workflow, id, project, file, context, argv, panes);
|
|
204
276
|
return { run: ref, created: true, ...(await wake(run, woken)) };
|
|
205
277
|
});
|
|
206
278
|
}
|
|
207
279
|
|
|
208
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* The target's file, which says which project its runs live in and which a start or wake runs:
|
|
282
|
+
* `file`, or the path awf loaded it from. Only a sender holding the workflow itself, as `wf.emit`
|
|
283
|
+
* does, can be without one.
|
|
284
|
+
*/
|
|
285
|
+
function fileOf(target: EventWorkflow): string {
|
|
286
|
+
if (target.file === undefined)
|
|
287
|
+
throw new EmitError(
|
|
288
|
+
`${target.meta.name} gives no file, by which a sender holding it finds it: file: import.meta.path`,
|
|
289
|
+
);
|
|
290
|
+
return target.file;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `{key}:reply`'s event as the mailbox records it: `participant:reply` from its key, whatever the
|
|
295
|
+
* workflow declares it as, so a wait takes it by one name.
|
|
296
|
+
*/
|
|
297
|
+
export function replyRecord(
|
|
298
|
+
key: string,
|
|
299
|
+
id: string,
|
|
300
|
+
data: JsonValue,
|
|
301
|
+
at: string,
|
|
302
|
+
): Delivery["event"] {
|
|
303
|
+
return { id, name: "participant:reply", data, from: key, at };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit.
|
|
308
|
+
* A name with a colon is awf's: only `{key}:reply`, from the operator (`replies`), is sent.
|
|
309
|
+
*/
|
|
209
310
|
export function checkEvent(
|
|
210
311
|
target: EventWorkflow,
|
|
211
312
|
event: { name: string; data: JsonValue; id?: string },
|
|
313
|
+
replies = false,
|
|
212
314
|
): void {
|
|
213
315
|
const workflow = target.meta.name;
|
|
214
316
|
const problem = eventWorkflowProblem(target);
|
|
215
317
|
if (problem) throw new EmitError(`${workflow}: ${problem}`);
|
|
216
|
-
if (!isAbsolute(target.file))
|
|
318
|
+
if (target.file !== undefined && !isAbsolute(target.file))
|
|
217
319
|
throw new EmitError(`${workflow}: its file, ${target.file}, is import.meta.path, absolute`);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
320
|
+
const reply = awfEventOf(event.name)?.kind === "reply";
|
|
321
|
+
if (event.name.includes(":") && !(replies && reply))
|
|
322
|
+
throw new EmitError(
|
|
323
|
+
reply
|
|
324
|
+
? `${event.name}: a participant's message goes to a run that is there, awf emit ${workflow}/{id} ${event.name} '{"text":…}'`
|
|
325
|
+
: `${event.name}: a name with a colon is awf's own, and not sent`,
|
|
326
|
+
);
|
|
327
|
+
const declared = target.events[event.name];
|
|
328
|
+
if (!declared)
|
|
222
329
|
throw new EmitError(
|
|
223
330
|
`${workflow} declares no event ${event.name}; its events are ${Object.keys(target.events).join(", ")}`,
|
|
224
331
|
);
|
|
332
|
+
// A reply is checked against awf's own schema, not the workflow's copy, which may be older.
|
|
333
|
+
const schema = reply ? ParticipantReply : declared;
|
|
225
334
|
let parsed: ReturnType<typeof parseJsonSchema>;
|
|
226
335
|
try {
|
|
227
336
|
parsed = parseJsonSchema(schema);
|