@agentswf/engine 0.1.0-beta.4 → 0.1.0

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -1,14 +1,27 @@
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 { type Out, parsePlace, type ReadPlace } from "./command-line";
4
+ import { EmitError, emitEvent, emitToRun } from "./emit";
4
5
  import { messageOf } from "./errors";
5
6
  import help from "./help/emit.txt" with { type: "text" };
6
- import type { Out } from "./run-commands";
7
- import type { Launch } from "./wake";
7
+ import { asksJson, printRefusal, readFailureCode } from "./refusals";
8
+ import { findRunByRef } from "./run-state";
9
+ import type { Launch, Woken } from "./wake";
8
10
  import { loadWorkflowFile } from "./workflow-loader";
9
11
 
12
+ /** `awf emit --json`'s `"v"`, which its refusals carry too. */
13
+ export const EMIT_V = 1 satisfies EmitResultJson["v"];
14
+
10
15
  export const emitUsage = help.trimEnd();
11
16
 
17
+ type Event = { name: string; data: JsonValue; id?: string; source?: string };
18
+
19
+ /** `--target {file} --id {id}`, which may start the run; or a run that is there, by its ref. */
20
+ type Parsed = Event & { json: boolean } & (
21
+ | { target: string; run: string; ref?: undefined }
22
+ | { ref: string; place: ReadPlace; target?: undefined }
23
+ );
24
+
12
25
  export async function emitCommand(
13
26
  argv: readonly string[],
14
27
  shellCwd: string,
@@ -19,91 +32,101 @@ export async function emitCommand(
19
32
  launch?: Launch;
20
33
  },
21
34
  ): Promise<number> {
22
- if (argv.includes("-h") || argv.includes("--help")) {
23
- out.stdout(emitUsage);
24
- return 0;
25
- }
26
- let parsed: {
27
- target: string;
28
- run: string;
29
- name: string;
30
- data: JsonValue;
31
- id?: string;
32
- json: boolean;
33
- };
35
+ let parsed: Parsed;
34
36
  try {
35
- parsed = parse(argv);
37
+ parsed = parse(argv, shellCwd);
36
38
  } catch (error) {
37
- out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
39
+ printRefusal(out, { json: asksJson(argv), v: EMIT_V }, error, "usage", emitUsage);
38
40
  return 2;
39
41
  }
42
+ const json = { json: parsed.json, v: EMIT_V };
43
+ const event = {
44
+ name: parsed.name,
45
+ data: parsed.data,
46
+ ...(parsed.id === undefined ? {} : { id: parsed.id }),
47
+ ...(parsed.source === undefined ? {} : { source: parsed.source }),
48
+ };
40
49
  let result: EmitResultJson;
41
50
  try {
42
- const loaded = await loadWorkflowFile(parsed.target, shellCwd);
43
- if (!isEventWorkflow(loaded.workflow)) {
44
- out.stderr(`awf: ${loaded.file} declares no events; awf run starts it`);
45
- return 2;
51
+ if (parsed.ref !== undefined) {
52
+ const status = await findRunByRef(parsed.ref, parsed.place, context.home);
53
+ result = await emitToRun(status, event, context);
54
+ } else {
55
+ const loaded = await loadWorkflowFile(parsed.target, shellCwd, context.home);
56
+ if (!isEventWorkflow(loaded.workflow)) {
57
+ printRefusal(out, json, `${loaded.file} declares no events; awf run starts it`, "event");
58
+ return 2;
59
+ }
60
+ result = await emitEvent(
61
+ loaded.workflow,
62
+ { run: parsed.run, ...event },
63
+ { from: "operator", ...context },
64
+ );
46
65
  }
47
- result = await emitEvent(
48
- loaded.workflow,
49
- {
50
- run: parsed.run,
51
- name: parsed.name,
52
- data: parsed.data,
53
- ...(parsed.id === undefined ? {} : { id: parsed.id }),
54
- },
55
- { from: "operator", ...context },
56
- );
57
66
  } catch (error) {
58
- out.stderr(`awf: ${messageOf(error)}`);
67
+ printRefusal(out, json, error, readFailureCode(error));
59
68
  return error instanceof EmitError ? 2 : 1;
60
69
  }
61
70
  return printEmit(result, parsed.json, out);
62
71
  }
63
72
 
64
- /** Prints an emit's result, as `awf emit` and `awf send` do: 1 when the run's attempt didn't start. */
65
- export function printEmit(result: EmitResultJson, json: boolean, out: Out): number {
66
- out.stdout(json ? JSON.stringify(result) : emitLine(result));
67
- if (result.why !== "launch-failed") return 0;
68
- out.stderr(`awf: the run's attempt did not start: ${result.error}`);
69
- return 1;
73
+ /** Prints an emit's result, `accepted dd-case/kombo: started`: 1 when the run's attempt didn't start. */
74
+ function printEmit(result: EmitResultJson, json: boolean, out: Out): number {
75
+ out.stdout(json ? JSON.stringify(result) : `${result.outcome} ${result.ref}${didOf(result)}`);
76
+ return launchExit(result, out);
77
+ }
78
+
79
+ /** What waking a run did, `: started`, or why it did nothing; empty when neither is said. */
80
+ export function didOf(woken: Pick<Woken, "did" | "why">): string {
81
+ const did = woken.did === "none" ? woken.why : woken.did;
82
+ return did === undefined ? "" : `: ${did}`;
70
83
  }
71
84
 
72
- /** `accepted dd-case/kombo: started`. */
73
- function emitLine(result: EmitResultJson): string {
74
- const did = result.did === "none" ? result.why : result.did;
75
- return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
85
+ /** 0; or 1, said on stderr, when the run's attempt did not start. */
86
+ export function launchExit(woken: Pick<Woken, "why" | "error">, out: Out): number {
87
+ if (woken.why !== "launch-failed") return 0;
88
+ out.stderr(`awf: the run's attempt did not start: ${woken.error}`);
89
+ return 1;
76
90
  }
77
91
 
78
- function parse(argv: readonly string[]): {
79
- target: string;
80
- run: string;
81
- name: string;
82
- data: JsonValue;
83
- id?: string;
84
- json: boolean;
85
- } {
92
+ function parse(argv: readonly string[], shellCwd: string): Parsed {
93
+ const { place, json, rest } = parsePlace(argv, shellCwd);
86
94
  let target: string | undefined;
87
95
  let run: string | undefined;
88
96
  let id: string | undefined;
89
- let json = false;
97
+ let source: string | undefined;
90
98
  const words: string[] = [];
91
- for (let i = 0; i < argv.length; i++) {
92
- const word = argv[i]!;
99
+ for (let i = 0; i < rest.length; i++) {
100
+ const word = rest[i]!;
93
101
  const value = () => {
94
- const next = argv[++i];
102
+ const next = rest[++i];
95
103
  if (next === undefined) throw new Error(`${word} needs a value`);
96
104
  return next;
97
105
  };
98
106
  if (word === "--target") target = value();
99
107
  else if (word === "--id") run = value();
100
108
  else if (word === "--event-id") id = value();
101
- else if (word === "--json") json = true;
109
+ else if (word === "--source") source = value();
102
110
  else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
103
111
  else words.push(word);
104
112
  }
105
- if (target === undefined) throw new Error("--target names the workflow file the event is for");
106
- if (run === undefined) throw new Error("--id names the run the event is for, by its id");
113
+ let form: { target: string; run: string } | { ref: string; place: ReadPlace };
114
+ if (target !== undefined) {
115
+ if (run === undefined) throw new Error("--id names the run the event is for, by its id");
116
+ if (place.repeat.length > 0)
117
+ throw new Error(
118
+ "--cwd and --run-root find a run by its ref; --target's file names its project",
119
+ );
120
+ form = { target, run };
121
+ } else {
122
+ if (run !== undefined) throw new Error("--id goes with --target; a run's ref names it alone");
123
+ const ref = words.shift();
124
+ if (ref === undefined)
125
+ throw new Error(
126
+ "name the run, <workflow>/<id> or <id>, or the workflow by --target and --id",
127
+ );
128
+ form = { ref, place };
129
+ }
107
130
  const [name, text, ...more] = words;
108
131
  if (name === undefined) throw new Error("the event's name is missing");
109
132
  if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
@@ -115,5 +138,12 @@ function parse(argv: readonly string[]): {
115
138
  throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
116
139
  }
117
140
  }
118
- return { target, run, name, data, ...(id === undefined ? {} : { id }), json };
141
+ return {
142
+ ...form,
143
+ name,
144
+ data,
145
+ ...(id === undefined ? {} : { id }),
146
+ ...(source === undefined ? {} : { source }),
147
+ json,
148
+ };
119
149
  }
package/src/emit.ts CHANGED
@@ -1,26 +1,58 @@
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 type { ClientErrorCode } from "@agentswf/contract/client";
4
+ import {
5
+ type EmitResultJson,
6
+ type EventLine,
7
+ emitOk,
8
+ type RunPanes,
9
+ } from "@agentswf/contract/records";
4
10
  import { parseJsonSchema, validate } from "@agentswf/contract/schema";
5
11
  import {
6
12
  AWF_ID_PREFIX,
13
+ awfEventOf,
7
14
  eventWorkflowProblem,
15
+ isEventWorkflow,
8
16
  type JsonValue,
9
17
  nameProblem,
18
+ ParticipantReply,
10
19
  } from "@agentswf/contract/workflow";
11
20
  import { messageOf } from "./errors";
12
21
  import { exists } from "./files";
13
- import { appendMailbox, foldMailbox, readMailbox } from "./mailbox";
22
+ import { appendIn, readIn } from "./in-lines";
23
+ import { checkProjectEngine, EngineRefused } from "./project-engine";
14
24
  import { projectOf, projectRunRoot, recordProject } from "./projects";
15
25
  import { runLockFile, withRunLock } from "./run-lock";
16
26
  import { prepareArgs } from "./run-prepare";
17
- import { createRun, idProblem, openRun, type Run, readAttempts } from "./runs";
27
+ import type { RunStatus } from "./run-state";
28
+ import {
29
+ createRun,
30
+ idProblem,
31
+ openRun,
32
+ panesChanged,
33
+ type Run,
34
+ RunTaken,
35
+ readAttempts,
36
+ } from "./runs";
18
37
  import { type WakeContext, type Woken, wake } from "./wake";
19
- import type { EventWorkflow } from "./workflow-loader";
38
+ import { type EventWorkflow, loadWorkflowFile, WorkflowFileError } from "./workflow-loader";
39
+
40
+ /**
41
+ * What an `EmitError` is about, as a program's `ClientError` names it: the event, an id, the
42
+ * target's `prepare` or `id(args)`, its file, its project, the project's engine, or a run there
43
+ * started with other arguments or options.
44
+ */
45
+ export type EmitCode = Extract<
46
+ ClientErrorCode,
47
+ "event" | "id" | "prepare" | "file" | "project" | "other-engine" | "no-engine" | "other-args"
48
+ >;
20
49
 
21
50
  /** An event awf refuses to take at all: a name, shape or id no run of the target could have. */
22
51
  export class EmitError extends Error {
23
- constructor(message: string) {
52
+ constructor(
53
+ message: string,
54
+ readonly code: EmitCode = "event",
55
+ ) {
24
56
  super(message);
25
57
  this.name = "EmitError";
26
58
  }
@@ -37,41 +69,113 @@ export type EmitContext = Omit<WakeContext, "file"> & {
37
69
  */
38
70
  export async function emitEvent(
39
71
  target: EventWorkflow,
40
- event: { run: string; name: string; data: JsonValue; id?: string },
72
+ event: { run: string; name: string; data: JsonValue; id?: string; source?: string },
41
73
  context: EmitContext,
42
74
  ): Promise<EmitResultJson> {
43
75
  const workflow = target.meta.name;
44
- const { name, data } = event;
76
+ const { name, data, source } = event;
45
77
  checkEvent(target, event);
78
+ const badSource = source === undefined ? undefined : nameProblem(source);
79
+ if (badSource) throw new EmitError(`the source ${JSON.stringify(source)}: ${badSource}`);
80
+ const file = fileOf(target);
46
81
  const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
47
82
  const at = new Date(context.now?.() ?? Date.now()).toISOString();
48
83
  const runId = event.run;
49
84
  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
- });
85
+ if (problem) throw new EmitError(`the run's id: ${problem}`, "id");
86
+ const project = await targetProject(file, context.home);
54
87
  const starts = target.starts?.includes(name) ?? true;
55
88
  return deliver(
56
89
  {
57
90
  runRoot: projectRunRoot(project),
58
91
  workflow,
59
92
  runId,
60
- event: { id, name, data, from: context.from, at },
93
+ event: {
94
+ id,
95
+ name,
96
+ data,
97
+ from: context.from,
98
+ ...(source === undefined ? {} : { source }),
99
+ at,
100
+ },
61
101
  deduplicate: event.id !== undefined,
62
- file: target.file,
102
+ file,
63
103
  ...(starts ? { start: { project, target } } : {}),
64
104
  },
65
105
  context,
66
106
  );
67
107
  }
68
108
 
109
+ /**
110
+ * `awf emit {ref} {key}:reply …`: a participant's message to the code of a run that is there,
111
+ * checked against the definition in the file a wake would run, its last attempt's, and delivered.
112
+ * It never starts a run, and doesn't ask whether the participant has connected: one sent before
113
+ * the run connects it waits in `in.jsonl` for the run's next wait.
114
+ */
115
+ export async function emitToRun(
116
+ status: RunStatus,
117
+ event: { name: string; data: JsonValue; id?: string; source?: string },
118
+ context: Omit<WakeContext, "file">,
119
+ ): Promise<EmitResultJson> {
120
+ const { run, ref } = status;
121
+ const reply = awfEventOf(event.name);
122
+ if (reply?.kind !== "reply")
123
+ throw new EmitError(
124
+ `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}`,
125
+ );
126
+ if (!run.record.events) throw new EmitError(`${ref} is a run of a workflow without events`);
127
+ // A returned run takes nothing, so its file, since moved or changed, is not read: a late reply is
128
+ // refused as returned, not failed for a retry that never ends.
129
+ if (status.attempts.at(-1)?.outcome === "completed")
130
+ return deliverReply(status, reply.key, event, context);
131
+ const file = status.attempts.at(-1)?.file ?? run.record.file;
132
+ if (file === undefined)
133
+ throw new WorkflowFileError(`${ref} has no file to check the event against`);
134
+ const { workflow } = await loadWorkflowFile(file, run.record.cwd, context.home);
135
+ if (!isEventWorkflow(workflow) || workflow.meta.name !== run.record.workflow)
136
+ throw new WorkflowFileError(
137
+ `${file} is no longer ${run.record.workflow} with events, which ${ref} ran`,
138
+ );
139
+ checkEvent(workflow, event, true);
140
+ return deliverReply(status, reply.key, event, context);
141
+ }
142
+
143
+ /**
144
+ * `{key}:reply` appended to a run that is there, and the run woken: a duplicate id is a duplicate.
145
+ * The caller has checked it.
146
+ */
147
+ export function deliverReply(
148
+ status: Pick<RunStatus, "run">,
149
+ key: string,
150
+ event: { data: JsonValue; id?: string; source?: string },
151
+ context: Omit<WakeContext, "file">,
152
+ ): Promise<EmitResultJson> {
153
+ const { run } = status;
154
+ const problem = event.source === undefined ? undefined : nameProblem(event.source);
155
+ if (problem) throw new EmitError(`the source ${JSON.stringify(event.source)}: ${problem}`);
156
+ const at = new Date(context.now?.() ?? Date.now()).toISOString();
157
+ const id = event.id ?? `${AWF_ID_PREFIX}${randomBytes(8).toString("hex")}`;
158
+ return deliver(
159
+ {
160
+ runRoot: run.root,
161
+ workflow: run.record.workflow,
162
+ runId: run.record.id,
163
+ event: {
164
+ ...replyRecord(key, id, event.data, at),
165
+ ...(event.source === undefined ? {} : { source: event.source }),
166
+ },
167
+ deduplicate: event.id !== undefined,
168
+ },
169
+ context,
170
+ );
171
+ }
172
+
69
173
  /** An event for a run of a workflow with events, as `deliver` takes it. */
70
174
  export type Delivery = {
71
175
  runRoot: string;
72
176
  workflow: string;
73
177
  runId: string;
74
- event: { id: string; name: string; data: JsonValue; from: string; at: string };
178
+ event: { id: string; name: string; data: JsonValue; from: string; source?: string; at: string };
75
179
  /** False for an id awf made at random, which nothing repeats. */
76
180
  deduplicate: boolean;
77
181
  /** Given when the event may start the run: what a new run records, and the workflow it prepares. */
@@ -82,10 +186,10 @@ export type Delivery = {
82
186
 
83
187
  /**
84
188
  * 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.
189
+ * own. Under the run's lock it finds a duplicate, refuses the event, or appends the event, to a run
190
+ * there or into a new one's `in.jsonl` before the run exists, and wakes the run.
87
191
  */
88
- export async function deliver(
192
+ async function deliver(
89
193
  delivery: Delivery,
90
194
  context: Omit<WakeContext, "file">,
91
195
  ): Promise<EmitResultJson> {
@@ -98,27 +202,46 @@ export async function deliver(
98
202
  : undefined;
99
203
  // What was due is `awf tick`'s to say; an emit's caller knows it sent an event.
100
204
  const result = (outcome: EmitResultJson["outcome"], { cause: _, ...woken }: Woken) =>
101
- ({ v: 1, outcome, ref, eventId: event.id, ...woken }) satisfies EmitResultJson;
205
+ ({
206
+ v: 1,
207
+ ok: emitOk(outcome, woken.why),
208
+ outcome,
209
+ ref,
210
+ eventId: event.id,
211
+ ...woken,
212
+ }) satisfies EmitResultJson;
102
213
  if (run) {
214
+ // A disk that doesn't tell case apart opens another run for an id differing only in case.
215
+ if (run.record.id !== runId)
216
+ throw new EmitError(
217
+ `${ref} differs only in case from ${run.record.id}, which is there`,
218
+ "id",
219
+ );
220
+ if (!run.record.events)
221
+ throw new EmitError(`${ref} is a run of a workflow without events, which take none`);
103
222
  // Not appended again, but it still wakes the run: a sender retrying after a launch that failed
104
223
  // sends the same id.
105
- if (delivery.deduplicate && foldMailbox(await readMailbox(run.dir)).events.has(event.id))
224
+ if (delivery.deduplicate && (await readIn(run.dir)).some((line) => line.id === event.id))
106
225
  return result("duplicate", await wake(run, { ...woken, nudgeLive: false }));
107
226
  if ((await readAttempts(run)).at(-1)?.outcome === "completed")
108
227
  return result("refused", { did: "none", why: "returned" });
109
228
  }
110
- const starts = !run;
111
- if (!run) {
229
+ if (run) await appendIn(run.dir, event);
230
+ else {
112
231
  if (!start) return result("refused", { did: "none", why: "not-a-start" });
113
232
  checkStartArgs(start.target, start.project);
114
- run = await createEventRun(runRoot, workflow, runId, start.project, file, context);
233
+ // In the folder before it is renamed into place: no run exists without its first event.
234
+ const first: EventLine = { ...event, starts: true };
235
+ try {
236
+ run = await createEventRun(runRoot, workflow, runId, start.project, file, context, [], {
237
+ first,
238
+ });
239
+ } catch (error) {
240
+ // A disk that tells case apart: another case's run is found only as the folder is claimed.
241
+ if (error instanceof RunTaken) throw new EmitError(messageOf(error), "id");
242
+ throw error;
243
+ }
115
244
  }
116
- await appendMailbox(run.dir, {
117
- version: MAILBOX_RECORD_VERSION,
118
- kind: "event",
119
- ...event,
120
- ...(starts ? { starts: true as const } : {}),
121
- });
122
245
  return result("accepted", await wake(run, woken));
123
246
  });
124
247
  }
@@ -133,6 +256,7 @@ function checkStartArgs(target: EventWorkflow, project: string): void {
133
256
  } catch (error) {
134
257
  throw new EmitError(
135
258
  `${target.meta.name}: a run an event starts has no arguments, which its prepare refuses: ${messageOf(error)}`,
259
+ "prepare",
136
260
  );
137
261
  }
138
262
  }
@@ -146,18 +270,31 @@ async function createEventRun(
146
270
  file: string | undefined,
147
271
  context: Pick<WakeContext, "home" | "environment">,
148
272
  argv: readonly string[] = [],
273
+ given: { panes?: RunPanes; first?: EventLine } = {},
149
274
  ): 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);
275
+ const { panes, first } = given;
276
+ const run = await createRun(
277
+ runRoot,
278
+ {
279
+ id,
280
+ workflow,
281
+ argv: [...argv],
282
+ cwd: project,
283
+ sandbox: null,
284
+ events: true,
285
+ env: { PATH: context.environment.PATH ?? "" },
286
+ ...(file ? { file } : {}),
287
+ ...(panes ? { panes } : {}),
288
+ },
289
+ undefined,
290
+ first,
291
+ );
292
+ try {
293
+ await recordProject(context.home, project);
294
+ } catch (error) {
295
+ // awf's own record of projects, not the run's: `internal-error`, as `awf run` refuses it.
296
+ throw new Error(`~/.awf/projects was not written: ${messageOf(error)}`);
297
+ }
161
298
  return run;
162
299
  }
163
300
 
@@ -167,61 +304,122 @@ export type Opened = { run: string; created: boolean } & Woken;
167
304
  /**
168
305
  * `awf run --id` for a workflow with events: creates the run with no event, under its lock, and
169
306
  * 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.
307
+ * `awf tick` would; one that returned is refused, as its events are. `panes`, the operator's
308
+ * `--workspace` and `--keep-panes`, are recorded with a new run, and may not change one there.
171
309
  */
172
310
  export async function openEventRun(
173
311
  target: EventWorkflow,
174
312
  id: string,
175
313
  argv: readonly string[],
176
314
  context: Omit<WakeContext, "file">,
315
+ panes?: RunPanes,
177
316
  ): Promise<Opened> {
178
317
  const workflow = target.meta.name;
179
318
  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
- });
319
+ if (problem) throw new EmitError(problem, "id");
320
+ const file = fileOf(target);
321
+ const project = await targetProject(file, context.home);
184
322
  const runRoot = projectRunRoot(project);
185
323
  const ref = `${workflow}/${id}`;
186
324
  return withRunLock(runLockFile(runRoot, workflow, id), async () => {
187
- const woken = { ...context, file: target.file };
325
+ const woken = { ...context, file };
188
326
  if (await exists(join(runRoot, workflow, id, "run.json"))) {
189
327
  const run = await openRun(runRoot, workflow, id);
190
328
  if ((await readAttempts(run)).at(-1)?.outcome === "completed")
191
329
  return { run: ref, created: false, did: "none", why: "returned" };
192
330
  if (argv.length > 0 && JSON.stringify(argv) !== JSON.stringify(run.record.argv))
193
331
  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`,
332
+ `${ref} is there already, started with ${run.record.argv.length > 0 || run.record.args !== undefined ? "arguments of its own" : "none"}; awf run --id ${id} with none wakes it`,
333
+ "other-args",
195
334
  );
335
+ const changed = panesChanged(run.record, panes);
336
+ if (changed) throw new EmitError(changed, "other-args");
196
337
  return { run: ref, created: false, ...(await wake(run, woken)) };
197
338
  }
198
339
  try {
199
340
  prepareArgs(target, argv, project);
200
341
  } catch (error) {
201
- throw new EmitError(`prepare: ${messageOf(error)}`);
342
+ throw new EmitError(`prepare: ${messageOf(error)}`, "prepare");
202
343
  }
203
- const run = await createEventRun(runRoot, workflow, id, project, target.file, context, argv);
344
+ const run = await createEventRun(runRoot, workflow, id, project, file, context, argv, {
345
+ ...(panes ? { panes } : {}),
346
+ });
204
347
  return { run: ref, created: true, ...(await wake(run, woken)) };
205
348
  });
206
349
  }
207
350
 
208
- /** The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit. */
351
+ /** The project `file`'s runs live in, which must run on this engine (ADR 0015). */
352
+ async function targetProject(file: string, home: string): Promise<string> {
353
+ const project = await projectOf(file, home).catch((error: unknown) => {
354
+ throw new EmitError(messageOf(error), "project");
355
+ });
356
+ try {
357
+ checkProjectEngine(project, { file });
358
+ } catch (error) {
359
+ if (error instanceof EngineRefused) throw new EmitError(error.message, error.code);
360
+ throw error;
361
+ }
362
+ return project;
363
+ }
364
+
365
+ /**
366
+ * The target's file, which says which project its runs live in and which a start or wake runs:
367
+ * `file`, or the path awf loaded it from. Only a sender holding the workflow itself, as `wf.emit`
368
+ * does, can be without one.
369
+ */
370
+ function fileOf(target: EventWorkflow): string {
371
+ if (target.file === undefined)
372
+ throw new EmitError(
373
+ `${target.meta.name} gives no file, by which a sender holding it finds it: file: import.meta.path`,
374
+ "file",
375
+ );
376
+ return target.file;
377
+ }
378
+
379
+ /**
380
+ * `{key}:reply`'s event as `in.jsonl` records it: `participant:reply` from its key, whatever the
381
+ * workflow declares it as, so a wait takes it by one name.
382
+ */
383
+ export function replyRecord(
384
+ key: string,
385
+ id: string,
386
+ data: JsonValue,
387
+ at: string,
388
+ ): Delivery["event"] {
389
+ return { id, name: "participant:reply", data, from: key, at };
390
+ }
391
+
392
+ /**
393
+ * The event's name, shape and id, as the target declares them; an `EmitError` when they don't fit.
394
+ * A name with a colon is awf's: only `{key}:reply`, from the operator (`replies`), is sent.
395
+ */
209
396
  export function checkEvent(
210
397
  target: EventWorkflow,
211
398
  event: { name: string; data: JsonValue; id?: string },
399
+ replies = false,
212
400
  ): void {
213
401
  const workflow = target.meta.name;
214
402
  const problem = eventWorkflowProblem(target);
215
403
  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)
404
+ if (target.file !== undefined && !isAbsolute(target.file))
405
+ throw new EmitError(
406
+ `${workflow}: its file, ${target.file}, is import.meta.path, absolute`,
407
+ "file",
408
+ );
409
+ const reply = awfEventOf(event.name)?.kind === "reply";
410
+ if (event.name.includes(":") && !(replies && reply))
411
+ throw new EmitError(
412
+ reply
413
+ ? `${event.name}: a participant's message goes to a run that is there, awf emit ${workflow}/{id} ${event.name} '{"text":…}'`
414
+ : `${event.name}: a name with a colon is awf's own, and not sent`,
415
+ );
416
+ const declared = target.events[event.name];
417
+ if (!declared)
222
418
  throw new EmitError(
223
419
  `${workflow} declares no event ${event.name}; its events are ${Object.keys(target.events).join(", ")}`,
224
420
  );
421
+ // A reply is checked against awf's own schema, not the workflow's copy, which may be older.
422
+ const schema = reply ? ParticipantReply : declared;
225
423
  let parsed: ReturnType<typeof parseJsonSchema>;
226
424
  try {
227
425
  parsed = parseJsonSchema(schema);
@@ -234,7 +432,7 @@ export function checkEvent(
234
432
  `${event.name}'s data does not fit its schema: ${errors.map((e) => `${e.path || "/"}: ${e.message}`).join("; ")}`,
235
433
  );
236
434
  const badId = event.id === undefined ? undefined : eventIdProblem(event.id);
237
- if (badId) throw new EmitError(`the id ${JSON.stringify(event.id)}: ${badId}`);
435
+ if (badId) throw new EmitError(`the id ${JSON.stringify(event.id)}: ${badId}`, "id");
238
436
  }
239
437
 
240
438
  /** What is wrong with an id a sender gives an event, if anything: ids starting `awf.` are awf's. */