@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.
Files changed (72) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +611 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +3 -15
  6. package/src/attempt-ending.ts +2 -4
  7. package/src/attempt-output.ts +3 -2
  8. package/src/attempt-view.ts +1 -1
  9. package/src/control-plane.ts +38 -6
  10. package/src/coordinator-socket.ts +15 -6
  11. package/src/deadlines.ts +69 -2
  12. package/src/decisions/directory.ts +9 -11
  13. package/src/deprecations.ts +177 -0
  14. package/src/duration.ts +50 -20
  15. package/src/emit-command.ts +57 -54
  16. package/src/emit.ts +124 -15
  17. package/src/help/allowance.txt +13 -0
  18. package/src/help/emit.txt +21 -0
  19. package/src/help/inbox.txt +12 -0
  20. package/src/help/init.txt +12 -0
  21. package/src/help/logs.txt +14 -0
  22. package/src/help/reply.txt +18 -0
  23. package/src/help/run.txt +58 -0
  24. package/src/help/runs.txt +11 -0
  25. package/src/help/test.txt +9 -0
  26. package/src/help/tick.txt +12 -0
  27. package/src/herdr-run-session.ts +2 -2
  28. package/src/here.ts +1 -1
  29. package/src/holder-command.ts +38 -51
  30. package/src/inbox-command.ts +9 -24
  31. package/src/init-command.ts +2 -11
  32. package/src/init-template/AGENTS.md.template +3 -3
  33. package/src/init-template/hello/workflow.test.ts.template +3 -3
  34. package/src/init-template/hello/workflow.ts.template +6 -6
  35. package/src/keep-alive.ts +28 -13
  36. package/src/logs-command.ts +14 -27
  37. package/src/mailbox.ts +16 -21
  38. package/src/messaging/limits.ts +5 -5
  39. package/src/messaging/messaging.ts +48 -43
  40. package/src/messaging/routes.ts +2 -2
  41. package/src/messaging/text.ts +24 -22
  42. package/src/operation-liveness.ts +1 -1
  43. package/src/operator-cli.ts +23 -9
  44. package/src/operator-runtime.ts +9 -12
  45. package/src/pane-layout.ts +125 -32
  46. package/src/progress-view.ts +8 -8
  47. package/src/result-validation.ts +1 -1
  48. package/src/run-command.ts +27 -47
  49. package/src/run-commands.ts +1 -1
  50. package/src/run-prepare.ts +46 -8
  51. package/src/run-usage.ts +2 -2
  52. package/src/run-view.ts +9 -4
  53. package/src/runs-command.ts +14 -23
  54. package/src/runs.ts +44 -8
  55. package/src/sandboxes.ts +3 -2
  56. package/src/simple-input.ts +13 -3
  57. package/src/sources.ts +42 -6
  58. package/src/stage-ledger.ts +43 -13
  59. package/src/stage-plan.ts +37 -1
  60. package/src/test-command.ts +9 -14
  61. package/src/testing.ts +8 -9
  62. package/src/tick-command.ts +2 -13
  63. package/src/tools.ts +62 -25
  64. package/src/workflow-loader.ts +5 -2
  65. package/src/workflow-runner.ts +636 -271
  66. package/src/workflow-testing/coordinator.typecheck.ts +12 -13
  67. package/src/workflow-testing/coordinators.ts +2 -2
  68. package/src/workflow-testing/host.ts +115 -46
  69. package/src/workflow-testing/index.ts +45 -837
  70. package/src/workflow-testing/run-test.ts +1213 -0
  71. package/src/workflow-testing/script.ts +106 -45
  72. 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
- /** A duration such as `500ms`, `30s`, `20m` or `2h`, in milliseconds. */
2
- export function parseDuration(value: string): number {
3
- const matched = /^(\d+)(ms|s|m|h)$/.exec(value);
4
- if (!matched) throw new Error(`invalid duration: ${value}`);
5
- const amount = Number(matched[1]);
6
- if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error(`invalid duration: ${value}`);
7
- const unit = matched[2];
8
- const multiplier = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000;
9
- const milliseconds = amount * multiplier;
10
- if (!Number.isSafeInteger(milliseconds)) throw new Error(`duration is too large: ${value}`);
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 duration(milliseconds: number): string {
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
- * 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.
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
- 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 };
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
  }
@@ -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 type { Out } from "./run-commands";
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
- "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");
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
- 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;
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
- target: string;
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 < argv.length; i++) {
103
- const word = argv[i]!;
91
+ for (let i = 0; i < rest.length; i++) {
92
+ const word = rest[i]!;
104
93
  const value = () => {
105
- const next = argv[++i];
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
- 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");
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 { target, run, name, data, ...(id === undefined ? {} : { id }), json };
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 { type EmitResultJson, MAILBOX_RECORD_VERSION } from "@agentswf/contract/records";
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 { createRun, idProblem, openRun, type Run, readAttempts } from "./runs";
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 { EventWorkflow } from "./workflow-loader";
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(target.file, context.home).catch((error: unknown) => {
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: target.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 project = await projectOf(target.file, context.home).catch((error: unknown) => {
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: target.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, target.file, context, argv);
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
- /** The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit. */
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
- 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)
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);