@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
@@ -47,7 +47,13 @@ import { ANSI, describeRunSession, type Terminal, watchProgress } from "./progre
47
47
  import { parseCommand, type RunCommand, usage } from "./run-command";
48
48
  import { placeFlags, shellLine, startedLine } from "./run-commands";
49
49
  import { runLockFile, withRunLock } from "./run-lock";
50
- import { claimNext, loadAndPrepare, type PreparedRun, workspaceLabel } from "./run-prepare";
50
+ import {
51
+ claimNext,
52
+ loadAndPrepare,
53
+ type OpensEventRun,
54
+ type PreparedRun,
55
+ workspaceLabel,
56
+ } from "./run-prepare";
51
57
  import { findRun } from "./run-state";
52
58
  import { type Attempt, createRun, discardRun, endAttempt, type Run, RunRefused } from "./runs";
53
59
  import { runsCommand } from "./runs-command";
@@ -254,16 +260,22 @@ function help(
254
260
  * an event starts one, so it runs to its first wait and parks; one already there is only woken.
255
261
  */
256
262
  async function openRun(
257
- { loaded, id, argv }: { loaded: LoadedWorkflow; id: string; argv: readonly string[] },
263
+ { loaded, id, argv, panes }: OpensEventRun,
258
264
  environment: OperatorEnvironment,
259
265
  { stdout, stderr }: Output,
260
266
  ): Promise<number> {
261
267
  try {
262
- const opened = await openEventRun(loaded.workflow as EventWorkflow, id, argv, {
263
- home: environment.home ?? homedir(),
264
- environment: environment.environment ?? process.env,
265
- ...(environment.launch ? { launch: environment.launch } : {}),
266
- });
268
+ const opened = await openEventRun(
269
+ loaded.workflow as EventWorkflow,
270
+ id,
271
+ argv,
272
+ {
273
+ home: environment.home ?? homedir(),
274
+ environment: environment.environment ?? process.env,
275
+ ...(environment.launch ? { launch: environment.launch } : {}),
276
+ },
277
+ panes,
278
+ );
267
279
  const did = opened.did === "none" ? opened.why : opened.did;
268
280
  stdout(
269
281
  `${opened.run}${opened.created ? "" : " was there"}${did === undefined ? "" : `: ${did}`}`,
@@ -285,7 +297,7 @@ async function runCommand(
285
297
  const { stdout, stderr, notice, terminal, now } = output;
286
298
  let parsed: RunCommand | "help";
287
299
  try {
288
- parsed = parseCommand(argv, environment.cwd ?? process.cwd());
300
+ parsed = parseCommand(argv, environment.cwd ?? process.cwd(), stderr);
289
301
  } catch (error) {
290
302
  stderr(`awf: ${messageOf(error)}\n\n${usage}`);
291
303
  return 2;
@@ -423,6 +435,7 @@ async function runAttempt(
423
435
  argv: command.workflowArgs,
424
436
  cwd: command.cwd,
425
437
  sandbox: (command.sandbox ?? null) as JsonValue,
438
+ ...(command.panes ? { panes: command.panes } : {}),
426
439
  }));
427
440
  } catch (error) {
428
441
  return refuse(
@@ -505,7 +518,7 @@ async function runAttempt(
505
518
  await handOver(toldOf(end, kept));
506
519
  return close(end, kept, cleanupAlso);
507
520
  };
508
- // Relied on: the session is told only from a pane agent's `openRun`, inside `startWorkflow`, by
521
+ // Relied on: the session is told only from a TUI agent's `openRun`, inside `startWorkflow`, by
509
522
  // when `placed` says it on the progress.
510
523
  let placed: (session: RunSession) => void = () => undefined;
511
524
  let installed: OperatorRuntimeInstallation;
@@ -581,6 +594,7 @@ async function runAttempt(
581
594
  label: workspaceLabel(meta.name, id, n),
582
595
  ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
583
596
  ...(command.values === undefined ? {} : { values: command.values.stages }),
597
+ ...(run.record.panes ? { panes: run.record.panes } : {}),
584
598
  ...(!events
585
599
  ? {}
586
600
  : {
@@ -75,12 +75,12 @@ export type OperatorRuntimeOptions = {
75
75
  shellCwd?: string;
76
76
  /** The operator's home. */
77
77
  home?: string;
78
- /** Told once, when the first pane agent's session is ready. */
78
+ /** Told once, when the first TUI agent's session is ready. */
79
79
  onRunSession?: (session: RunSession) => void;
80
80
  };
81
81
 
82
82
  /**
83
- * One run host for every agent: a Herdr tab for a pane agent, a subprocess per turn for a
83
+ * One run host for every agent: a Herdr tab for a TUI agent, a subprocess per turn for a
84
84
  * headless one. `WITHHELD_ENVIRONMENT` is cleared from both.
85
85
  */
86
86
  export async function installOperatorRuntime(
@@ -98,7 +98,7 @@ export async function installOperatorRuntime(
98
98
  } = options;
99
99
  const unmetered = withholding(run, WITHHELD_ENVIRONMENT);
100
100
  refuseMeteredCredentials(environment);
101
- // Checked now, before any stage spends anything; made ready only for the first pane agent.
101
+ // Checked now, before any stage spends anything; made ready only for the first TUI agent.
102
102
  const name = runSessionName(environment);
103
103
  const session = runSessionOnce(() => name, { run, environment, home }, onRunSession);
104
104
  const runConfig = (session: string): HerdrConfig => ({
@@ -119,9 +119,9 @@ export async function installOperatorRuntime(
119
119
  const host = loginChecked(
120
120
  unmetered,
121
121
  createPlacementHostFactory({
122
- // The session is made ready when the first pane agent opens, so an all-headless run never
122
+ // The session is made ready when the first TUI agent opens, so an all-headless run never
123
123
  // calls Herdr.
124
- pane: {
124
+ tui: {
125
125
  accounting: createSessionAccounting(unmetered),
126
126
  openRun: async (spec) => {
127
127
  const { name } = await session();
@@ -1,4 +1,11 @@
1
- import type { KeepPane, PaneLayout, TurnOutcome } from "@agentswf/contract/workflow";
1
+ import type { RunPanes } from "@agentswf/contract/records";
2
+ import type {
3
+ KeepPane,
4
+ PaneLayout,
5
+ PaneSpec,
6
+ PaneWorkspace,
7
+ TurnOutcome,
8
+ } from "@agentswf/contract/workflow";
2
9
 
3
10
  /** A terminal session's name: Herdr makes `sessions/{name}` from it, and a leading `-` reads as an option. */
4
11
  export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
@@ -6,53 +13,75 @@ export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
6
13
  const SHARE = { min: 0.2, max: 0.8 };
7
14
  const NEW_TAB_FIELDS = new Set(["session", "workspace", "tab"]);
8
15
  const BESIDE_FIELDS = new Set(["beside", "side", "share"]);
9
- const KEEP: readonly KeepPane[] = ["never", "on-failure", "always"];
16
+ export const KEEP_PANES: readonly KeepPane[] = ["never", "on-failure", "always"];
17
+
18
+ type WrittenPane = { pane?: unknown; layout?: unknown; keepPane?: unknown };
10
19
 
11
20
  /**
12
21
  * Refuses, before anything is placed, what the agent's own spec fixes about its pane: what a
13
- * workflow, untyped at run time, could get wrong in `layout` or `keepPane`, and either on an agent
14
- * with no pane of awf's to place (docs/design/pane-layout.md, "Refused at open").
22
+ * workflow, untyped at run time, could get wrong in `pane`, `layout` or `keepPane`, and any of them
23
+ * on an agent with no pane of awf's to place (docs/design/pane-layout.md, "Refused at open").
15
24
  */
16
25
  export function checkPaneOptions(
17
26
  key: string,
18
- spec: { layout?: unknown; keepPane?: unknown },
27
+ spec: WrittenPane,
19
28
  agent: { headless: boolean; sandboxed: boolean },
20
29
  ): void {
21
30
  const refuse = (why: string): never => {
22
31
  throw new Error(`agent ${key}: ${why}`);
23
32
  };
24
- if (spec.layout === undefined && spec.keepPane === undefined) return;
25
- if (agent.headless) refuse("layout and keepPane place a pane, and a headless agent has none");
33
+ if (spec.pane === undefined && spec.layout === undefined && spec.keepPane === undefined) return;
34
+ const old = spec.pane === undefined;
35
+ if (agent.headless) {
36
+ refuse(
37
+ `${old ? "layout and keepPane place" : "pane places"} a pane, and a headless agent has none`,
38
+ );
39
+ }
26
40
  if (agent.sandboxed) {
27
41
  refuse(
28
- "layout and keepPane are not yet for an agent in a sandbox, whose pane is in its own Herdr",
42
+ `${old ? "layout and keepPane are" : "pane is"} not yet for an agent in a sandbox, whose pane is in its own Herdr`,
29
43
  );
30
44
  }
31
- if (spec.keepPane !== undefined && !KEEP.includes(spec.keepPane as KeepPane)) {
45
+ if (spec.pane !== undefined && spec.layout !== undefined) {
46
+ refuse("give pane, not layout beside it: layout is deprecated");
47
+ }
48
+ if (spec.keepPane !== undefined && !KEEP_PANES.includes(spec.keepPane as KeepPane)) {
32
49
  refuse(`keepPane is never, on-failure or always, not ${JSON.stringify(spec.keepPane)}`);
33
50
  }
34
- if (spec.layout === undefined) return;
35
- const layout = spec.layout;
36
- if (typeof layout !== "object" || layout === null || Array.isArray(layout)) {
37
- refuse(`a layout is an object, not ${JSON.stringify(layout)}`);
51
+ if (spec.pane !== undefined) checkShape(key, "pane", spec.pane, refuse);
52
+ if (spec.layout !== undefined) checkShape(key, "layout", spec.layout, refuse);
53
+ }
54
+
55
+ function checkShape(
56
+ key: string,
57
+ named: "pane" | "layout",
58
+ given: unknown,
59
+ refuse: (why: string) => never,
60
+ ): void {
61
+ const a = named === "pane" ? "a pane" : "a layout";
62
+ if (typeof given !== "object" || given === null || Array.isArray(given)) {
63
+ refuse(`${a} is an object, not ${JSON.stringify(given)}`);
38
64
  }
39
- const fields = Object.keys(layout as object).filter(
40
- (field) => (layout as Record<string, unknown>)[field] !== undefined,
65
+ const layout = given as Record<string, unknown>;
66
+ const fields = Object.keys(layout).filter(
67
+ (field) => layout[field] !== undefined && !(named === "pane" && field === "front"),
41
68
  );
42
- const given = layout as Record<string, unknown>;
43
- if (given.beside !== undefined) {
69
+ if (named === "pane" && layout.front !== undefined && typeof layout.front !== "boolean") {
70
+ refuse(`front is true or false, not ${JSON.stringify(layout.front)}`);
71
+ }
72
+ if (layout.beside !== undefined) {
44
73
  const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
45
74
  if (others.length > 0) {
46
- refuse(`a layout beside another pane takes side and share, not ${others.join(", ")}`);
75
+ refuse(`${a} beside another pane takes side and share, not ${others.join(", ")}`);
47
76
  }
48
- if (typeof given.beside !== "string" || given.beside === "") {
49
- refuse(`beside names an agent's key, not ${JSON.stringify(given.beside)}`);
77
+ if (typeof layout.beside !== "string" || layout.beside === "") {
78
+ refuse(`beside names an agent's key, not ${JSON.stringify(layout.beside)}`);
50
79
  }
51
- if (given.beside === key) refuse("a pane cannot be placed beside itself");
52
- if (given.side !== "right" && given.side !== "below") {
53
- refuse(`side is right or below, not ${JSON.stringify(given.side)}`);
80
+ if (layout.beside === key) refuse("a pane cannot be placed beside itself");
81
+ if (layout.side !== "right" && layout.side !== "below") {
82
+ refuse(`side is right or below, not ${JSON.stringify(layout.side)}`);
54
83
  }
55
- const share = given.share;
84
+ const share = layout.share;
56
85
  if (
57
86
  share !== undefined &&
58
87
  (typeof share !== "number" || !(share >= SHARE.min && share <= SHARE.max))
@@ -61,13 +90,16 @@ export function checkPaneOptions(
61
90
  }
62
91
  return;
63
92
  }
64
- const others = fields.filter((field) => !NEW_TAB_FIELDS.has(field));
93
+ const allowed = named === "pane" ? new Set(["tab"]) : NEW_TAB_FIELDS;
94
+ const others = fields.filter((field) => !allowed.has(field));
65
95
  if (others.length > 0) {
66
96
  refuse(
67
- `a layout takes session, workspace and tab, or beside and side; not ${others.join(", ")}`,
97
+ named === "pane"
98
+ ? `a pane takes tab, or beside and side, and front; not ${others.join(", ")}${others.some((field) => field === "workspace" || field === "session") ? ": the workspace is the operator's, awf run --workspace" : ""}`
99
+ : `a layout takes session, workspace and tab, or beside and side; not ${others.join(", ")}`,
68
100
  );
69
101
  }
70
- const { session, workspace, tab } = given;
102
+ const { session, workspace, tab } = layout;
71
103
  if (session !== undefined && (typeof session !== "string" || !SESSION_NAME.test(session))) {
72
104
  refuse(
73
105
  `session ${JSON.stringify(session)} is not a session name: lowercase letters, digits and -, up to 32, not starting with -`,
@@ -94,13 +126,17 @@ export function checkPaneOptions(
94
126
  }
95
127
 
96
128
  /**
97
- * A checked layout as it is kept and compared: fields given as `undefined` dropped, so a reopen
98
- * that leaves one out matches.
129
+ * A checked `pane` or `layout` as it is kept and compared: fields given as `undefined` dropped, and
130
+ * `front: false`, which is its absence, so a reopen that leaves one out matches.
99
131
  */
100
- export function storedLayout(layout: PaneLayout): PaneLayout {
132
+ export function storedLayout<T extends PaneLayout | PaneSpec>(layout: T): T {
133
+ // Not an object, it is left for `checkPaneOptions` to refuse by name.
134
+ if (typeof layout !== "object" || layout === null || Array.isArray(layout)) return layout;
101
135
  return Object.fromEntries(
102
- Object.entries(structuredClone(layout)).filter(([, value]) => value !== undefined),
103
- ) as PaneLayout;
136
+ Object.entries(structuredClone(layout)).filter(
137
+ ([field, value]) => value !== undefined && !(field === "front" && value === false),
138
+ ),
139
+ ) as T;
104
140
  }
105
141
 
106
142
  function nonEmpty(value: unknown): value is string {
@@ -112,6 +148,57 @@ export function besideOf(layout: PaneLayout | undefined): string | undefined {
112
148
  return layout && "beside" in layout ? layout.beside : undefined;
113
149
  }
114
150
 
151
+ /** What the host is asked for one agent's pane. */
152
+ export type PaneAsked = {
153
+ layout?: PaneLayout;
154
+ front?: true;
155
+ keepPane?: KeepPane;
156
+ /** For a pane beside another: where its own tab opens if its target has no pane. */
157
+ fallbackWorkspace?: PaneWorkspace;
158
+ };
159
+
160
+ /**
161
+ * What the host is asked for an agent's pane: the workflow's tab or neighbour and `front`, in the
162
+ * operator's workspace and kept as the operator says. The operator's flags win over a deprecated
163
+ * `layout`'s session and workspace and a deprecated `keepPane`; absent, those apply as before. An
164
+ * agent with no pane of awf's, headless or in a sandbox, is asked nothing.
165
+ */
166
+ export function paneAsked(
167
+ written: { pane?: PaneSpec; layout?: PaneLayout; keepPane?: KeepPane },
168
+ run: RunPanes | undefined,
169
+ hasPane: boolean,
170
+ ): PaneAsked {
171
+ if (!hasPane) return {};
172
+ const keepPane = run?.keep ?? written.keepPane;
173
+ const workspace = run?.workspace;
174
+ const layout = placedLayout(written, workspace);
175
+ return {
176
+ ...(layout === undefined ? {} : { layout }),
177
+ ...(written.pane?.front ? { front: true as const } : {}),
178
+ ...(keepPane === undefined ? {} : { keepPane }),
179
+ ...(besideOf(layout) !== undefined && workspace !== undefined
180
+ ? { fallbackWorkspace: workspace }
181
+ : {}),
182
+ };
183
+ }
184
+
185
+ function placedLayout(
186
+ { pane, layout }: { pane?: PaneSpec; layout?: PaneLayout },
187
+ workspace: PaneWorkspace | undefined,
188
+ ): PaneLayout | undefined {
189
+ const written = pane ?? layout;
190
+ if (written?.beside !== undefined) {
191
+ const { beside, side, share } = written;
192
+ return { beside, side, ...(share === undefined ? {} : { share }) };
193
+ }
194
+ const tab = written?.tab;
195
+ if (workspace !== undefined) {
196
+ return { workspace, ...(tab === undefined ? {} : { tab }) };
197
+ }
198
+ if (layout !== undefined) return layout;
199
+ return tab === undefined ? undefined : { tab };
200
+ }
201
+
115
202
  /**
116
203
  * Whether a done agent's pane stays, by `keepPane` and how its last operation ended: `on-failure`
117
204
  * keeps it unless that was answered, or there was none.
@@ -123,3 +210,9 @@ export function keepsPane(
123
210
  if (keepPane === "always") return true;
124
211
  return keepPane === "on-failure" && last !== undefined && last !== "answered";
125
212
  }
213
+
214
+ /** `--workspace`'s value: the two keywords, else a workspace's label. */
215
+ export function parseWorkspace(value: string): PaneWorkspace {
216
+ if (value === "run" || value === "origin") return value;
217
+ return { name: value };
218
+ }
@@ -40,7 +40,7 @@ export function renderProgress(
40
40
  paint: Paint;
41
41
  /** Once the run is over: what each stage's agents cost, by stage. */
42
42
  figures?: ReadonlyMap<string, string>;
43
- /** The Herdr session pane agents opened in, once one has. */
43
+ /** The Herdr session TUI agents opened in, once one has. */
44
44
  herdrSession?: string;
45
45
  },
46
46
  ): string[] {
@@ -168,7 +168,7 @@ export function progressEvents(
168
168
  turn.phase === "waiting"
169
169
  ? `waiting (agent-reported): ${oneLine(turn.waitingReason ?? "")}${turn.checkInAt === undefined ? "" : ` · check-in at ${clock(turn.checkInAt - view.startedAt)}`}`
170
170
  : turn.phase === "awaiting-reply"
171
- ? `awaiting ${oneLine(turn.waitingReason ?? "a reply")}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
171
+ ? `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
172
172
  : oneLine(turn.phase.replaceAll("-", " "));
173
173
  add(view.now, "agent phase", `… ${oneLine(agent.key)} · ${note}`);
174
174
  }
@@ -400,7 +400,7 @@ function agentState(
400
400
  function awaitingNote(turn: NonNullable<Agent["turn"]>, now: number): string {
401
401
  const since = turn.phaseAt ?? turn.startedAt;
402
402
  const of = turn.checkInAt === undefined ? "" : ` of ${duration(turn.checkInAt - since)}`;
403
- return `awaiting ${oneLine(turn.waitingReason ?? "a reply")} · ${duration(Math.max(0, now - since))}${of}`;
403
+ return `awaiting-reply${turn.waitingReason === undefined ? "" : ` from ${oneLine(turn.waitingReason)}`} · ${duration(Math.max(0, now - since))}${of}`;
404
404
  }
405
405
 
406
406
  /** A message admitted: who to whom, its size, and whether it asks or answers. */
@@ -412,20 +412,20 @@ export function messageLine(message: WorkflowRunSnapshot["messages"][number]): s
412
412
  }
413
413
 
414
414
  /**
415
- * Where to see a pane agent Herdr read as blocked on a prompt: its session plays no sound, so this
415
+ * Where to see a TUI agent Herdr read as blocked on a prompt: its session plays no sound, so this
416
416
  * is the one sign. Not the calling session's, which is the operator's own pane.
417
417
  */
418
418
  function whereBlocked(agent: Agent, herdrSession: string | undefined): string {
419
419
  return agent.turn?.outcome === "blocked" &&
420
420
  herdrSession !== undefined &&
421
- placementOf(agent.execution) === "pane" &&
421
+ placementOf(agent.execution) === "tui" &&
422
422
  !agent.execution.caller
423
423
  ? ` · herdr session attach ${herdrSession}`
424
424
  : "";
425
425
  }
426
426
 
427
427
  /**
428
- * Where a run's pane agents are, said once as the first opens; the workspace closes with the run.
428
+ * Where a run's TUI agents are, said once as the first opens; the workspace closes with the run.
429
429
  * A client inside Herdr can't attach to another session, so from there it says where to.
430
430
  */
431
431
  export function describeRunSession(
@@ -519,7 +519,7 @@ export function watchProgress(
519
519
  let handle: WorkflowRunHandle<JsonValue> | undefined;
520
520
  let last: WorkflowRunSnapshot | undefined;
521
521
  let herdrSession: string | undefined;
522
- // A pane agent's session can be ready after the run has ended, as a cancel lands while it starts:
522
+ // A TUI agent's session can be ready after the run has ended, as a cancel lands while it starts:
523
523
  // nothing is said under the closing block.
524
524
  let stopped = false;
525
525
  let drawn = 0;
@@ -594,7 +594,7 @@ export function watchProgress(
594
594
  if (under) clear();
595
595
  else tick();
596
596
  },
597
- /** The Herdr session pane agents opened in, which a blocked one's line names. */
597
+ /** The Herdr session TUI agents opened in, which a blocked one's line names. */
598
598
  placedIn(session: string) {
599
599
  if (!stopped) herdrSession = session;
600
600
  },
@@ -1,10 +1,12 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import type { JsonValue } from "@agentswf/contract/workflow";
3
+ import type { RunPanes } from "@agentswf/contract/records";
4
+ import type { JsonValue, KeepPane } from "@agentswf/contract/workflow";
4
5
  import { parseDuration } from "./duration";
5
6
  import { messageOf } from "./errors";
6
7
  import help from "./help/run.txt" with { type: "text" };
7
8
  import { SESSION_CODE } from "./here";
9
+ import { KEEP_PANES, parseWorkspace } from "./pane-layout";
8
10
  import { idProblem, runRootOf, stageNameProblem } from "./runs";
9
11
 
10
12
  export const usage = help.trimEnd();
@@ -37,6 +39,8 @@ export type RunCommand = {
37
39
  watch: boolean;
38
40
  /** The sandbox every agent runs in, as `--sandbox`'s file gave it. */
39
41
  sandbox?: unknown;
42
+ /** `--workspace` and `--keep-panes`, which the run records; absent, neither was given. */
43
+ panes?: RunPanes;
40
44
  /** Started from an agent's shell: start the run in a tab and take that session over. */
41
45
  here: boolean;
42
46
  /** The code the calling session replies with, which the run finds it by. */
@@ -44,7 +48,12 @@ export type RunCommand = {
44
48
  };
45
49
 
46
50
  /** `awf run`'s options and workflow; `"help"` for a `-h` or `--help` anywhere before `--`. */
47
- export function parseCommand(argv: readonly string[], cwd: string): RunCommand | "help" {
51
+ export function parseCommand(
52
+ argv: readonly string[],
53
+ cwd: string,
54
+ /** Told each deprecated form given, once. */
55
+ deprecated: (line: string) => void = () => {},
56
+ ): RunCommand | "help" {
48
57
  if (argv[0] !== "run") throw new Error("expected the run command");
49
58
  const end = argv.indexOf("--");
50
59
  const own = end < 0 ? argv : argv.slice(0, end);
@@ -63,6 +72,7 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
63
72
  let sandbox: unknown;
64
73
  let here = false;
65
74
  let session: string | undefined;
75
+ const panes: RunPanes = {};
66
76
  let workflowFile: string | undefined;
67
77
  // awf's own options may come before or after the workflow file; only `--` ends them.
68
78
  let index = 1;
@@ -91,8 +101,8 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
91
101
  }
92
102
  const value = argv[index + 1];
93
103
  if (option === "--timeout") {
94
- if (!value) throw new Error("--timeout needs a duration such as 30m");
95
- timeoutMilliseconds = parseDuration(value);
104
+ if (!value) throw new Error("--timeout needs a duration such as 30m or 1d");
105
+ timeoutMilliseconds = parseDuration(value, "--timeout", deprecated);
96
106
  timeout = value;
97
107
  } else if (option === "--run-root") {
98
108
  if (!value) throw new Error("--run-root needs a directory");
@@ -122,6 +132,16 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
122
132
  if (!value || !SESSION_CODE.test(value))
123
133
  throw new Error("--session needs the code --here printed");
124
134
  session = value;
135
+ } else if (option === "--workspace") {
136
+ if (!value?.trim() || value.startsWith("-"))
137
+ throw new Error("--workspace needs run, origin or a workspace's name");
138
+ if (panes.workspace !== undefined) throw new Error("--workspace given twice");
139
+ panes.workspace = parseWorkspace(value);
140
+ } else if (option === "--keep-panes") {
141
+ if (!KEEP_PANES.includes(value as KeepPane))
142
+ throw new Error("--keep-panes needs never, on-failure or always");
143
+ if (panes.keep !== undefined) throw new Error("--keep-panes given twice");
144
+ panes.keep = value as KeepPane;
125
145
  } else if (option === "--sandbox") {
126
146
  if (!value) throw new Error("--sandbox needs a JSON file");
127
147
  if (sandbox !== undefined) throw new Error("--sandbox given twice");
@@ -160,6 +180,7 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
160
180
  here,
161
181
  ...(session === undefined ? {} : { session }),
162
182
  ...(sandbox === undefined ? {} : { sandbox }),
183
+ ...(Object.keys(panes).length === 0 ? {} : { panes }),
163
184
  };
164
185
  }
165
186
 
@@ -137,7 +137,7 @@ export function nextLogs(
137
137
  ...filters,
138
138
  "--after",
139
139
  encodeCursor(cursor),
140
- ...(wait ? ["--wait", "90"] : []),
140
+ ...(wait ? ["--wait", "90s"] : []),
141
141
  ];
142
142
  }
143
143
 
@@ -1,9 +1,11 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { type AttemptRecord, RUN_EVENT_VERSIONS } from "@agentswf/contract/records";
3
+ import { join } from "node:path";
4
+ import { type AttemptRecord, RUN_EVENT_VERSIONS, type RunPanes } from "@agentswf/contract/records";
4
5
  import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
5
6
  import { refusalMessage } from "./attempt-view";
6
7
  import { messageOf } from "./errors";
8
+ import { exists } from "./files";
7
9
  import { projectOf, projectRunRoot } from "./projects";
8
10
  import type { RunCommand } from "./run-command";
9
11
  import {
@@ -14,6 +16,7 @@ import {
14
16
  checkFree,
15
17
  claimAttempt,
16
18
  endTurnsLine,
19
+ generateId,
17
20
  idProblem,
18
21
  openRun,
19
22
  previousGone,
@@ -38,6 +41,14 @@ export type PreparedRun = {
38
41
  continued?: { run: Run; attempts: AttemptRecord[]; warnings: string[] };
39
42
  };
40
43
 
44
+ /** `awf run --id` of a workflow with events, which starts the run detached, or wakes it. */
45
+ export type OpensEventRun = {
46
+ loaded: LoadedWorkflow;
47
+ id: string;
48
+ argv: readonly string[];
49
+ panes?: RunPanes;
50
+ };
51
+
41
52
  /**
42
53
  * The command's workflow loaded and its run prepared, or why not, as awf says it: in the shell that
43
54
  * typed the command, before anything starts.
@@ -49,7 +60,7 @@ export async function loadAndPrepare(
49
60
  ): Promise<
50
61
  | { loaded: LoadedWorkflow; prepared: PreparedRun; command: RunCommand }
51
62
  | { refused: string }
52
- | { opens: { loaded: LoadedWorkflow; id: string; argv: readonly string[] } }
63
+ | { opens: OpensEventRun }
53
64
  > {
54
65
  let loaded: LoadedWorkflow;
55
66
  try {
@@ -74,12 +85,23 @@ export async function loadAndPrepare(
74
85
  try {
75
86
  const id =
76
87
  command.id ??
77
- derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project));
88
+ derivedId(loaded.workflow, prepareArgs(loaded.workflow, command.workflowArgs, project)) ??
89
+ // Without starts no sender names its runs, so one may be generated, as a plain run's is.
90
+ (loaded.workflow.starts === undefined
91
+ ? await unusedId(command.runRoot, loaded.workflow.meta.name, now)
92
+ : undefined);
78
93
  if (id === undefined)
79
94
  return {
80
- refused: `a run of ${loaded.file} is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
95
+ refused: `a run of ${loaded.file}, which starts on events, is named by its id, which --id or the workflow's id(args) gives: awf run --id {id} starts one, awf emit --target ${loaded.file} --id {id} {event} {json} starts one with an event`,
81
96
  };
82
- return { opens: { loaded, id, argv: command.workflowArgs } };
97
+ return {
98
+ opens: {
99
+ loaded,
100
+ id,
101
+ argv: command.workflowArgs,
102
+ ...(command.panes ? { panes: command.panes } : {}),
103
+ },
104
+ };
83
105
  } catch (error) {
84
106
  return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
85
107
  }
@@ -115,6 +137,7 @@ async function prepareRun(
115
137
  argv: command.workflowArgs,
116
138
  ...(command.cwdGiven ? { cwd: command.cwd } : {}),
117
139
  ...(command.sandbox === undefined ? {} : { sandbox: command.sandbox }),
140
+ ...(command.panes === undefined ? {} : { panes: command.panes }),
118
141
  });
119
142
  const { id, argv, cwd } = run.record;
120
143
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
@@ -155,13 +178,28 @@ export function prepareArgs(
155
178
  return args;
156
179
  }
157
180
 
181
+ /**
182
+ * A generated id no run of `workflow` holds: `awf run` of one already there would wake it. Taken
183
+ * between this and the open, by a run started the same minute with the same four digits, it is.
184
+ */
185
+ async function unusedId(runRoot: string, workflow: string, now: number): Promise<string> {
186
+ for (let tries = 1; ; tries += 1) {
187
+ const id = generateId(new Date(now));
188
+ if (!(await exists(join(runRoot, workflow, id)))) return id;
189
+ if (tries >= 5) throw new RunRefused("every id generated was taken; --id gives one");
190
+ }
191
+ }
192
+
158
193
  /**
159
194
  * Why `awf run` doesn't take a workflow with `events` as asked: every attempt after the first is
160
- * started by a wake, which passes on none of the flags a run would have to keep.
195
+ * started by a wake, which passes on none of the flags a run would have to keep. A `--from-stage`
196
+ * is the one attempt's, and its records keep what it redid.
161
197
  */
162
198
  function eventRunRefusal(command: RunCommand): string | undefined {
163
- if (command.fromStage !== undefined || command.values !== undefined)
164
- return "a run of a workflow with events takes no --from-stage or --values";
199
+ if (command.values !== undefined)
200
+ return "a run of a workflow with events takes no --values: before --from-stage, a stage with no record runs";
201
+ if (command.fromStage !== undefined && command.continueId === undefined)
202
+ return "a run of a workflow with events takes --from-stage only with --continue";
165
203
  if (command.continueId !== undefined) {
166
204
  if (command.workflowArgs.length > 0)
167
205
  return "a continue takes no arguments: it goes on with those its run was started with";
package/src/run-usage.ts CHANGED
@@ -35,7 +35,7 @@ export type AccountedAgent = {
35
35
  sessions(): readonly string[];
36
36
  /** When its host first prompted it, where that waits on the agent first; see `window`. */
37
37
  promptedAt?(): number | undefined;
38
- /** The caller handed back early by agents.stop. */
38
+ /** The caller handed back early by its close(). */
39
39
  releasedAt?(): number | undefined;
40
40
  /** When a fork's session was made: what it holds from before is its parent's (ADR 0009). */
41
41
  forkedAt?(): number | undefined;