@agentswf/engine 0.1.0-beta.4 → 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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
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,14 +1,23 @@
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
5
  import help from "./help/emit.txt" with { type: "text" };
6
- import type { Out } from "./run-commands";
6
+ import { type Out, parsePlace, type ReadPlace } from "./run-commands";
7
+ import { findRunByRef } from "./run-state";
7
8
  import type { Launch } from "./wake";
8
9
  import { loadWorkflowFile } from "./workflow-loader";
9
10
 
10
11
  export const emitUsage = help.trimEnd();
11
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
+ );
20
+
12
21
  export async function emitCommand(
13
22
  argv: readonly string[],
14
23
  shellCwd: string,
@@ -23,37 +32,35 @@ export async function emitCommand(
23
32
  out.stdout(emitUsage);
24
33
  return 0;
25
34
  }
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
39
  out.stderr(`awf: ${messageOf(error)}\n\n${emitUsage}`);
38
40
  return 2;
39
41
  }
42
+ const event = {
43
+ name: parsed.name,
44
+ data: parsed.data,
45
+ ...(parsed.id === undefined ? {} : { id: parsed.id }),
46
+ };
40
47
  let result: EmitResultJson;
41
48
  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;
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
+ );
46
63
  }
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
64
  } catch (error) {
58
65
  out.stderr(`awf: ${messageOf(error)}`);
59
66
  return error instanceof EmitError ? 2 : 1;
@@ -75,35 +82,42 @@ function emitLine(result: EmitResultJson): string {
75
82
  return `${result.outcome} ${result.ref}${did === undefined ? "" : `: ${did}`}`;
76
83
  }
77
84
 
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
- } {
85
+ function parse(argv: readonly string[], shellCwd: string): Parsed {
86
+ const { place, json, rest } = parsePlace(argv, shellCwd);
86
87
  let target: string | undefined;
87
88
  let run: string | undefined;
88
89
  let id: string | undefined;
89
- let json = false;
90
90
  const words: string[] = [];
91
- for (let i = 0; i < argv.length; i++) {
92
- const word = argv[i]!;
91
+ for (let i = 0; i < rest.length; i++) {
92
+ const word = rest[i]!;
93
93
  const value = () => {
94
- const next = argv[++i];
94
+ const next = rest[++i];
95
95
  if (next === undefined) throw new Error(`${word} needs a value`);
96
96
  return next;
97
97
  };
98
98
  if (word === "--target") target = value();
99
99
  else if (word === "--id") run = value();
100
100
  else if (word === "--event-id") id = value();
101
- else if (word === "--json") json = true;
102
101
  else if (word.startsWith("--")) throw new Error(`unknown option ${word}`);
103
102
  else words.push(word);
104
103
  }
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");
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
+ }
107
121
  const [name, text, ...more] = words;
108
122
  if (name === undefined) throw new Error("the event's name is missing");
109
123
  if (more.length > 0) throw new Error(`one event at a time; also given: ${more.join(" ")}`);
@@ -115,5 +129,5 @@ function parse(argv: readonly string[]): {
115
129
  throw new Error(`the event's data is not JSON: ${messageOf(error)}`);
116
130
  }
117
131
  }
118
- return { target, run, name, data, ...(id === undefined ? {} : { id }), json };
132
+ return { ...form, name, data, ...(id === undefined ? {} : { id }), json };
119
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);
package/src/help/emit.txt CHANGED
@@ -1,4 +1,5 @@
1
1
  usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--json]
2
+ awf emit <run> <key>:reply '{"text": …, "author": …}' [--event-id <id>] [--cwd <directory>] [--run-root <directory>] [--json]
2
3
 
3
4
  Sends an event to the run of a workflow with events that --id names. That run starts if it has
4
5
  none and the event is one that starts one, and is woken if it has one: told while it runs,
@@ -6,8 +7,15 @@ continued when it is parked or crashed. The run lives in the project of <file>,
6
7
  directory above it holding .awf/, whoever sends. --event-id names the occurrence: the same id
7
8
  again is a duplicate.
8
9
 
10
+ The second form sends a participant's message to the code of a run that is there, <workflow>/<id>
11
+ or an <id> only one workflow has, here or in a project awf knows: <key>:reply, which the workflow
12
+ declares, checked against the file the run's last attempt ran. "author" is optional. Its
13
+ --event-id is the message's own id, so a resend is a duplicate. It never starts a run, and may
14
+ arrive before the run connects the participant: it waits for the run's next wait. The first form
15
+ takes only the workflow's own events.
16
+
9
17
  Prints what became of the event, and what was done to the run: accepted, duplicate or refused.
10
- --json prints one document. Exits 0 for any of the three; 1 when <file> could not be loaded, and
11
- nothing is kept, or when the run's attempt could not be started (the event is kept, and the next
12
- event or awf tick tries again); 2 on a usage error, for a workflow with no events, or when the
13
- event is wrong for its target: its name, its data, or its id.
18
+ --json prints one document. Exits 0 for any of the three; 1 when <file> could not be loaded, or
19
+ the run isn't found, and nothing is kept, or when the run's attempt could not be started (the
20
+ event is kept, and the next event or awf tick tries again); 2 on a usage error, for a workflow
21
+ with no events, or when the event is wrong for its target: its name, its data, or its id.
@@ -1,11 +1,11 @@
1
- usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cursor>] [--wait <seconds>]
1
+ usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cursor>] [--wait <duration>]
2
2
  [--cwd <directory>] [--run-root <directory>] [--json]
3
3
 
4
4
  What agents said to a participant outside the run, and what it said back: routes, messages,
5
5
  answers, questions that closed unanswered, and each attempt's end, oldest first. Without a run,
6
6
  every run here that connected one; --workflow narrows that. A run is <workflow>/<id>, or an <id>
7
7
  only one workflow has. --after reads on from a cursor the last answer gave; --wait
8
- waits up to that many seconds, at most 300, when nothing is new. --open lists the questions
8
+ waits up to that long, at most 5m, when nothing is new. --open lists the questions
9
9
  still waiting for an answer, a snapshot. --json prints one item per line.
10
10
 
11
11
  Exits 0 on a read that worked, 1 when there is no such run, it has no participant, or the
package/src/help/logs.txt CHANGED
@@ -1,13 +1,14 @@
1
1
  usage: awf logs [<run>] [@<handle>] [--agent <key>] [--stage <name>] [--after <cursor>]
2
- [--limit <n>] [--wait <seconds> | --follow] [--full] [--cwd <directory>]
2
+ [--limit <n>] [--wait <duration> | --follow] [--full] [--cwd <directory>]
3
3
  [--run-root <directory>] [--json]
4
4
 
5
5
  What a run did and is doing: its events and what its agents did, read from their own session
6
6
  files, merged by time. With no run, the newest here. Without --after, the last 40 events; with
7
7
  it, what came after, oldest first. Every answer ends with the command that gets only what is
8
- new. --wait: with nothing to show yet, wait for the first event, at most that many seconds, up
9
- to 300; --follow prints each event as it comes until the run ends. @<handle> names one event,
10
- and --full prints its text, with common credential shapes masked; --json carries it always.
8
+ new. --wait: with nothing to show yet, wait for the first event, at most that long, up to 5m,
9
+ such as 90s (a bare number, seconds, is deprecated); --follow prints each event as it comes until
10
+ the run ends. @<handle> names one event, and --full prints its text, with common credential
11
+ shapes masked; --json carries it always.
11
12
 
12
13
  Exits 0 on any read that worked, 1 when there is no such run or the cursor or handle does not
13
14
  fit it, 2 on a usage error, 3 when the run's files could not be read.
@@ -8,11 +8,11 @@ Answers a question an agent asked a participant outside the run, or messages an
8
8
  over a route the workflow allowed. The text is the argument, or stdin without one. --to names
9
9
  the question, as awf inbox prints it; it may be left out when the agent has one open. --as
10
10
  names the participant when the workflow connects more than one. Neither waits for an answer:
11
- it comes back in awf inbox. With --event-id, send is a reply on a participant the run routes to its
12
- code: no agent, it reaches the run whether it runs or is parked, and the same id again is a
13
- duplicate. The id is one the run has not had from anyone, as a chat's message ids are. It prints
14
- as awf emit does, and exits 0 for whatever the run did with it (accepted, duplicate, or refused
15
- by a run that returned); 1 when the run or participant isn't found, or its attempt didn't start.
11
+ it comes back in awf inbox. send --event-id is deprecated: awf emit <run> <participant>:reply
12
+ '{"text":…,"author":…}' --event-id <id> replaces it, and it forwards there. It still sends a
13
+ message on a participant to the run's code, the id the message's own, and prints as awf emit
14
+ does; it exits 0 for whatever the run did with it (accepted, duplicate, or refused by a run that
15
+ returned); 1 when the run or participant isn't found, or its attempt didn't start.
16
16
 
17
17
  Exits 0 when delivered, 1 when refused (the code says why), 2 on a usage error, 3 when the run
18
18
  could not be reached.
package/src/help/run.txt CHANGED
@@ -2,17 +2,20 @@ usage: awf run [options] <workflow-file> [options] [-- workflow arguments...]
2
2
  awf run [options] <workflow-file> --id <id> --continue
3
3
  run options: --id <id>, --continue, --from-stage <stage>, --values <file>,
4
4
  --timeout <duration>, --run-root <directory>, --cwd <directory>, --sandbox <file>,
5
+ --workspace <run|origin|name>, --keep-panes <never|on-failure|always>,
5
6
  --json, --no-watch, --here
6
7
 
7
8
  Each awf run is an attempt of a run: a new run, with --id's id or a generated one, or, with
8
- --continue, the run --id names, with the arguments, --cwd and --sandbox it was started with. A continue reuses
9
- the stages that succeeded, up to the first with no record or to --from-stage, and runs the rest.
9
+ --continue, the run --id names, with the arguments, --cwd, --sandbox, --workspace and
10
+ --keep-panes it was started with. A continue reuses the stages that succeeded, up to the first
11
+ with no record or to --from-stage, and runs the rest.
10
12
  A new run with --from-stage starts there. A stage before it with no record to reuse takes its
11
13
  value from --values, a JSON object by stage name, checked by its result; one that returns
12
14
  nothing is passed. Without one the attempt stops, naming the stage and its schema.
13
15
  A run is kept in .awf/runs/<workflow>/<id> under --cwd, unless --run-root names another folder
14
16
  for .awf/runs.
15
- A run has no deadline unless --timeout gives it one, per attempt.
17
+ A run has no deadline unless --timeout gives it one, per attempt: a duration, a decimal and s, m,
18
+ h or d, such as 20m or 1d.
16
19
  A workflow that knows how to present its result prints that; --json prints the full result instead.
17
20
  Either way the full result is kept as output.json in the run's folder, beside report.md
18
21
  when the workflow writes one. A run that fails or is cancelled once its agents have started keeps
@@ -24,13 +27,23 @@ such as {"read": ["/data/request.md"], "srt": {}}, naming srt or docker, whose r
24
27
  from --cwd. A workflow that opens a sandbox of its own is refused.
25
28
  A sandbox with its own Herdr, as a docker box has, gets a tab in the run's workspace showing its
26
29
  panes; --no-watch leaves it out, and awf still prints the command that shows them.
30
+ --workspace says where each TUI agent's tab opens: run, the run's own Herdr workspace (the
31
+ default); origin, the one awf run was typed in; or any other word, the workspace with that label,
32
+ found or made. --keep-panes says whether an agent's pane stays once it is done: never (the
33
+ default), on-failure, when its last turn wasn't answered, or always. A kept pane's harness is left
34
+ running, and yours. Both are kept with the run for every attempt, a woken one too, and win over a
35
+ workflow's deprecated layout and keepPane. A run an event started has neither, and takes the
36
+ defaults. A continue may give either again with the run's value, or its default where the run had
37
+ none, and no other.
27
38
  --here, run by an agent in a Herdr pane, starts the run in a new tab and has it take that agent's
28
39
  session over as one of its agents, from its next turn until the run ends. It prints a line for
29
40
  the agent to end its turn with, which is how the run finds the session.
30
- A run of a workflow with events is named by --id, or by the workflow's id(args). awf run starts it
31
- with its arguments and no event, detached in its project, as awf emit would, and the run waits at
32
- its first receive. A run already there is only woken. A run an event starts has no argv: its args
33
- are what prepare makes of none.
41
+ A run of a workflow with events is named by --id, or by the workflow's id(args); one without
42
+ starts may have an id made, as any run. awf run starts it with its arguments and no event,
43
+ detached in its project, as awf emit would, and the run waits at its first receive. A run already
44
+ there is only woken. A run an event starts has no argv: its args are what prepare makes of none.
45
+ Its --continue takes --from-stage, as any run's does, and no --values. A --from-stage the attempt
46
+ never reaches runs the attempt until it would park or return, and stops it there.
34
47
  Exits 0 when the run completed or parked, 1 when it failed or timed out, 2 on a usage error or
35
48
  when the run is refused, 3 when it stopped and can go on with --continue, and 128 plus the
36
49
  signal's number when it was interrupted.
@@ -40,5 +53,6 @@ Examples:
40
53
  awf run --timeout 20m examples/minimum-review/review-loop.ts
41
54
  awf run examples/minimum-review/review-loop.ts -- packages/engine/src
42
55
  awf run examples/minimum-review/review-loop.ts --cwd ../other-repo
56
+ awf run examples/pane-layout/workflow.ts --workspace origin --keep-panes on-failure
43
57
 
44
58
  Workflow files are trusted code and run with your filesystem and process authority.
@@ -17,7 +17,7 @@ import { machinePaths } from "./machine";
17
17
  import { SESSION_NAME } from "./pane-layout";
18
18
  import type { ProcessProbe } from "./runs";
19
19
 
20
- /** Where a run's pane agents open when `AWF_HERDR_SESSION` names nowhere else. */
20
+ /** Where a run's TUI agents open when `AWF_HERDR_SESSION` names nowhere else. */
21
21
  const DEFAULT_RUN_SESSION = "awf";
22
22
 
23
23
  /** What may start: the shared session is awf's by its name, since which run created it is not knowable. */
@@ -101,7 +101,7 @@ export function runSessionName(environment: Readonly<Record<string, string | und
101
101
  }
102
102
 
103
103
  /**
104
- * The Herdr session a run's pane agents open in, running and kept: started detached when down and
104
+ * The Herdr session a run's TUI agents open in, running and kept: started detached when down and
105
105
  * named `awf` or `awf-…`, from a minimal environment and a quiet config, so it outlives this run
106
106
  * for the next. Any other name that is down is refused: it may be an operator's stopped session,
107
107
  * and bringing it back headless is not awf's call. Then the workspaces of runs that ended are