@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
package/src/ops.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ type Candidate,
6
+ isKnownOpFile,
7
+ OP_FILE_VERSIONS,
8
+ type OpFile,
9
+ type OpRecord,
10
+ type ResultRecord,
11
+ } from "@agentswf/contract/records";
12
+ import { nameProblem } from "@agentswf/contract/workflow";
13
+ import { messageOf } from "./errors";
14
+ import { isCode, linkNew, writeWholeSync } from "./files";
15
+ import { appendLine, readLines } from "./jsonl";
16
+
17
+ /**
18
+ * A run's `ops/`: one folder per operation, `ops/{key}.{n}/`, the `n`th turn, message operation or
19
+ * compaction of the agent keyed `{key}`, or the `n`th asking of the decision keyed `{key}`. It holds
20
+ * `op.json`, `answers.jsonl`, `result.json`, `decision.json`, `spend.json`, `live.jsonl` and
21
+ * `screen.txt`. The only module that knows where they are.
22
+ */
23
+
24
+ /** An operation's folder. */
25
+ function opDir(runDir: string, op: string): string {
26
+ return join(runDir, "ops", op);
27
+ }
28
+
29
+ /** What the operation is: `OpRecord`. */
30
+ export function opFile(runDir: string, op: string): string {
31
+ return join(opDir(runDir, op), "op.json");
32
+ }
33
+
34
+ /** Its settled usage: billing, spend per model, reported cost. */
35
+ export function spendFile(runDir: string, op: string): string {
36
+ return join(opDir(runDir, op), "spend.json");
37
+ }
38
+
39
+ /** Its delivery and nudge diagnostics, `OperationLivenessRecord` lines. */
40
+ export function liveFile(runDir: string, op: string): string {
41
+ return join(opDir(runDir, op), "live.jsonl");
42
+ }
43
+
44
+ /** A decision's `DecisionArtifact`, relative to the run's folder, as `DecisionRecord.artifact` names it. */
45
+ export function decisionArtifact(op: string): string {
46
+ return join("ops", op, "decision.json");
47
+ }
48
+
49
+ /**
50
+ * The keys earlier attempts numbered operations under: each one's last `n`, and whose it is when its
51
+ * `op.json` says.
52
+ */
53
+ export type EarlierOps = ReadonlyMap<string, { last: number; of?: "agent" | "decision" }>;
54
+
55
+ /**
56
+ * Lists `ops/` once, as an attempt claims its run: `n` is the digits after a folder's last `.`.
57
+ * Whose a key is is read from its highest-numbered `op.json` that can be read.
58
+ */
59
+ export async function listOps(runDir: string): Promise<EarlierOps> {
60
+ let names: string[];
61
+ try {
62
+ names = await readdir(join(runDir, "ops"));
63
+ } catch (error) {
64
+ if (isCode(error, "ENOENT")) return new Map();
65
+ throw error;
66
+ }
67
+ const numbers = new Map<string, number[]>();
68
+ for (const name of names) {
69
+ const match = /^(.+)\.(\d+)$/.exec(name);
70
+ if (!match) continue;
71
+ const list = numbers.get(match[1]!) ?? [];
72
+ list.push(Number(match[2]));
73
+ numbers.set(match[1]!, list);
74
+ }
75
+ const earlier = new Map<string, { last: number; of?: "agent" | "decision" }>();
76
+ await Promise.all(
77
+ [...numbers].map(async ([key, list]) => {
78
+ const descending = list.sort((a, b) => b - a);
79
+ let kind: OpRecord["kind"] | undefined;
80
+ for (const n of descending) {
81
+ kind = (await readOpFile<OpRecord>(opFile(runDir, `${key}.${n}`), "op"))?.kind;
82
+ if (kind !== undefined) break;
83
+ }
84
+ earlier.set(key, {
85
+ last: descending[0]!,
86
+ ...(kind === undefined ? {} : { of: kind === "decision" ? "decision" : "agent" }),
87
+ });
88
+ }),
89
+ );
90
+ return earlier;
91
+ }
92
+
93
+ /**
94
+ * Why `key` can't be an agent's or a decision's: it names folders, so it is a name, as an event's
95
+ * or stage's is, and has no colon, which awf's own event names, `{key}:ended`, split at.
96
+ */
97
+ function keyProblem(key: string): string | undefined {
98
+ if (key.includes(":"))
99
+ return `a colon is kept for awf's own event names, such as {key}:ended; take another key, such as ${key.replaceAll(":", "-")}`;
100
+ return nameProblem(key);
101
+ }
102
+
103
+ /**
104
+ * An attempt's operation ids, `{key}.{n}`, numbered on from the last `n` its claim listed, so no
105
+ * id is reused across attempts. Agents and decisions share the keys: `{key}.{n}` is one operation.
106
+ */
107
+ export class OpIds {
108
+ readonly #last = new Map<string, number>();
109
+ readonly #agents = new Set<string>();
110
+ readonly #decisions = new Set<string>();
111
+
112
+ constructor(
113
+ readonly runDir: string,
114
+ readonly attempt: number,
115
+ earlier: EarlierOps,
116
+ ) {
117
+ for (const [key, { last, of }] of earlier) {
118
+ this.#last.set(key, last);
119
+ if (of) (of === "decision" ? this.#decisions : this.#agents).add(key);
120
+ }
121
+ }
122
+
123
+ /** Refuses a key that isn't a name, or that the other kind already holds in the run. */
124
+ claim(key: string, as: "agent" | "decision"): void {
125
+ const problem = keyProblem(key);
126
+ if (problem) throw new Error(`${as} key ${key} is not a name: ${problem}`);
127
+ const [mine, other] =
128
+ as === "agent" ? [this.#agents, this.#decisions] : [this.#decisions, this.#agents];
129
+ if (other.has(key))
130
+ throw new Error(
131
+ `${key} is ${as === "agent" ? "a decision's" : "an agent's"} key: agents and decisions share one set of names, as each operation's folder is ops/{key}.{n}; ${as === "agent" ? "an agent" : "a decision"} takes another key`,
132
+ );
133
+ mine.add(key);
134
+ }
135
+
136
+ /**
137
+ * The key's next operation id, its folder and `op.json` written at once, before any line names it,
138
+ * so a later claim numbers past it however the operation ends. Synchronous, so nothing waits
139
+ * between an operation's start and its cancellation being registered. `lost` says why `op.json`
140
+ * wasn't written, which fails the operation.
141
+ */
142
+ draw(key: string, what: OpStart): { id: string; lost?: string } {
143
+ const n = (this.#last.get(key) ?? 0) + 1;
144
+ this.#last.set(key, n);
145
+ const id = `${key}.${n}`;
146
+ const record = {
147
+ version: OP_FILE_VERSIONS.op,
148
+ id,
149
+ attempt: this.attempt,
150
+ at: new Date().toISOString(),
151
+ ...what,
152
+ } as OpRecord;
153
+ try {
154
+ mkdirSync(opDir(this.runDir, id), { recursive: true });
155
+ writeWholeSync(opFile(this.runDir, id), `${JSON.stringify(record, null, 2)}\n`);
156
+ return { id };
157
+ } catch (error) {
158
+ return { id, lost: `op.json not written: ${messageOf(error)}` };
159
+ }
160
+ }
161
+ }
162
+
163
+ /** What an operation is, as it is drawn: its `op.json` but its version, id, attempt and time. */
164
+ type OpStart = OpRecord extends infer Each
165
+ ? Each extends OpRecord
166
+ ? Omit<Each, "version" | "id" | "attempt" | "at">
167
+ : never
168
+ : never;
169
+
170
+ /** Appends an answer offered, accepted or not, to its operation's `answers.jsonl`. */
171
+ export async function recordAnswer(runDir: string, op: string, answer: Candidate): Promise<void> {
172
+ await mkdir(opDir(runDir, op), { recursive: true });
173
+ await appendLine(answersFile(runDir, op), JSON.stringify(answer));
174
+ }
175
+
176
+ /** An operation's answers, as tests check them; a run never reads them back. */
177
+ export async function readAnswers(runDir: string, op: string): Promise<Candidate[]> {
178
+ return readLines<Candidate>(answersFile(runDir, op));
179
+ }
180
+
181
+ /** Claims an operation's one accepted result with `value`; false when another holds it. */
182
+ export async function writeAcceptedExclusive(
183
+ runDir: string,
184
+ op: string,
185
+ value: unknown,
186
+ ): Promise<boolean> {
187
+ await mkdir(opDir(runDir, op), { recursive: true });
188
+ const record = {
189
+ version: OP_FILE_VERSIONS.result,
190
+ value,
191
+ at: new Date().toISOString(),
192
+ } as ResultRecord;
193
+ return linkNew(join(opDir(runDir, op), "result.json"), record);
194
+ }
195
+
196
+ /**
197
+ * An operation's accepted result, as tests check it; a run never reads it back. Null
198
+ * distinguishes "no value yet" from an operation whose accepted value happens to be null. A
199
+ * result.json of another version throws: unlike a cost, an answer has no unknown to fall back on.
200
+ */
201
+ export async function readAccepted(runDir: string, op: string): Promise<{ value: unknown } | null> {
202
+ let text: string;
203
+ try {
204
+ text = await readFile(join(opDir(runDir, op), "result.json"), "utf8");
205
+ } catch (error) {
206
+ if (isCode(error, "ENOENT")) return null;
207
+ throw error;
208
+ }
209
+ const record: unknown = JSON.parse(text);
210
+ if (!isKnownOpFile("result", record))
211
+ throw new Error(`${op}'s result.json is not version ${OP_FILE_VERSIONS.result}`);
212
+ return { value: (record as ResultRecord).value };
213
+ }
214
+
215
+ /**
216
+ * An operation's JSON `file` at `path`, or undefined when it is missing, can't be parsed, or has no
217
+ * version or one this reader doesn't know: each is read as a file that isn't there. With `refuse`,
218
+ * a file that parses at another version throws instead, for a reader whose figures it would
219
+ * otherwise leave looking complete.
220
+ */
221
+ export async function readOpFile<T>(
222
+ path: string,
223
+ file: OpFile,
224
+ options: { refuse?: boolean } = {},
225
+ ): Promise<T | undefined> {
226
+ let value: unknown;
227
+ try {
228
+ value = JSON.parse(await readFile(path, "utf8"));
229
+ } catch {
230
+ return undefined;
231
+ }
232
+ if (isKnownOpFile(file, value)) return value as T;
233
+ if (options.refuse && typeof value === "object" && value !== null) {
234
+ const version = (value as { version?: unknown }).version;
235
+ throw new Error(
236
+ `${path} is ${version === undefined ? "of no version" : `version ${JSON.stringify(version)}`}; this awf reads version ${OP_FILE_VERSIONS[file]}`,
237
+ );
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** The most of a screen kept: its last lines, where a pane's scrollback came with it. */
243
+ const SCREEN_BYTES = 64 * 1024;
244
+
245
+ /** An operation's `screen.txt`: what its pane showed as it ended without an answer. */
246
+ export function screenFile(runDir: string, op: string): string {
247
+ return join(opDir(runDir, op), "screen.txt");
248
+ }
249
+
250
+ export async function writeScreen(runDir: string, op: string, screen: string): Promise<void> {
251
+ const bytes = Buffer.from(screen);
252
+ const kept = bytes.length <= SCREEN_BYTES ? screen : tailOf(bytes);
253
+ await mkdir(opDir(runDir, op), { recursive: true });
254
+ await writeFile(screenFile(runDir, op), kept, { mode: 0o600 });
255
+ }
256
+
257
+ /** Its last whole lines within the bound, or its last bytes where one line is longer. */
258
+ function tailOf(bytes: Buffer): string {
259
+ const tail = bytes.subarray(bytes.length - SCREEN_BYTES);
260
+ const line = tail.indexOf(10);
261
+ const whole = line === -1 || line === tail.length - 1 ? tail : tail.subarray(line + 1);
262
+ // A cut inside a character drops its continuation bytes.
263
+ let start = 0;
264
+ while (start < whole.length && (whole[start]! & 0xc0) === 0x80) start += 1;
265
+ return whole.subarray(start).toString();
266
+ }
267
+
268
+ function answersFile(runDir: string, op: string): string {
269
+ return join(opDir(runDir, op), "answers.jsonl");
270
+ }
@@ -11,77 +11,59 @@ import type {
11
11
  export const SESSION_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
12
12
 
13
13
  const SHARE = { min: 0.2, max: 0.8 };
14
- const NEW_TAB_FIELDS = new Set(["session", "workspace", "tab"]);
15
14
  const BESIDE_FIELDS = new Set(["beside", "side", "share"]);
16
15
  export const KEEP_PANES: readonly KeepPane[] = ["never", "on-failure", "always"];
17
16
 
18
- type WrittenPane = { pane?: unknown; layout?: unknown; keepPane?: unknown };
19
-
20
17
  /**
21
- * Refuses, before anything is placed, what the agent's own spec fixes about its pane: what a
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").
18
+ * Refuses, before anything is placed, what a workflow, untyped at run time, could get wrong in an
19
+ * agent's `pane`, and a `pane` on an agent with no pane of awf's to place
20
+ * (docs/design/pane-layout.md, "Refused at open").
24
21
  */
25
22
  export function checkPaneOptions(
26
23
  key: string,
27
- spec: WrittenPane,
24
+ spec: { pane?: unknown },
28
25
  agent: { headless: boolean; sandboxed: boolean },
29
26
  ): void {
30
27
  const refuse = (why: string): never => {
31
28
  throw new Error(`agent ${key}: ${why}`);
32
29
  };
33
- if (spec.pane === undefined && spec.layout === undefined && spec.keepPane === undefined) return;
34
- const old = spec.pane === undefined;
35
- if (agent.headless) {
30
+ // Removed in 0.1.0-beta.6; refused, as they would place or keep a pane otherwise than written.
31
+ const given = spec as { layout?: unknown; keepPane?: unknown };
32
+ if (given.layout !== undefined)
36
33
  refuse(
37
- `${old ? "layout and keepPane place" : "pane places"} a pane, and a headless agent has none`,
34
+ "layout was removed: give pane: { tab } or pane: { beside, side }; the workspace is awf run --workspace",
38
35
  );
39
- }
36
+ if (given.keepPane !== undefined)
37
+ refuse("keepPane was removed: keeping panes is the operator's, awf run --keep-panes");
38
+ const { pane } = spec;
39
+ if (pane === undefined) return;
40
+ if (agent.headless) refuse("pane places a pane, and a headless agent has none");
40
41
  if (agent.sandboxed) {
41
- refuse(
42
- `${old ? "layout and keepPane are" : "pane is"} not yet for an agent in a sandbox, whose pane is in its own Herdr`,
43
- );
42
+ refuse("pane is not yet for an agent in a sandbox, whose pane is in its own Herdr");
44
43
  }
45
- if (spec.pane !== undefined && spec.layout !== undefined) {
46
- refuse("give pane, not layout beside it: layout is deprecated");
44
+ if (typeof pane !== "object" || pane === null || Array.isArray(pane)) {
45
+ refuse(`a pane is an object, not ${JSON.stringify(pane)}`);
47
46
  }
48
- if (spec.keepPane !== undefined && !KEEP_PANES.includes(spec.keepPane as KeepPane)) {
49
- refuse(`keepPane is never, on-failure or always, not ${JSON.stringify(spec.keepPane)}`);
50
- }
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)}`);
64
- }
65
- const layout = given as Record<string, unknown>;
66
- const fields = Object.keys(layout).filter(
67
- (field) => layout[field] !== undefined && !(named === "pane" && field === "front"),
47
+ const shape = pane as Record<string, unknown>;
48
+ const fields = Object.keys(shape).filter(
49
+ (field) => shape[field] !== undefined && field !== "front",
68
50
  );
69
- if (named === "pane" && layout.front !== undefined && typeof layout.front !== "boolean") {
70
- refuse(`front is true or false, not ${JSON.stringify(layout.front)}`);
51
+ if (shape.front !== undefined && typeof shape.front !== "boolean") {
52
+ refuse(`front is true or false, not ${JSON.stringify(shape.front)}`);
71
53
  }
72
- if (layout.beside !== undefined) {
54
+ if (shape.beside !== undefined) {
73
55
  const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
74
56
  if (others.length > 0) {
75
- refuse(`${a} beside another pane takes side and share, not ${others.join(", ")}`);
57
+ refuse(`a pane beside another pane takes side and share, not ${others.join(", ")}`);
76
58
  }
77
- if (typeof layout.beside !== "string" || layout.beside === "") {
78
- refuse(`beside names an agent's key, not ${JSON.stringify(layout.beside)}`);
59
+ if (typeof shape.beside !== "string" || shape.beside === "") {
60
+ refuse(`beside names an agent's key, not ${JSON.stringify(shape.beside)}`);
79
61
  }
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)}`);
62
+ if (shape.beside === key) refuse("a pane cannot be placed beside itself");
63
+ if (shape.side !== "right" && shape.side !== "below") {
64
+ refuse(`side is right or below, not ${JSON.stringify(shape.side)}`);
83
65
  }
84
- const share = layout.share;
66
+ const share = shape.share;
85
67
  if (
86
68
  share !== undefined &&
87
69
  (typeof share !== "number" || !(share >= SHARE.min && share <= SHARE.max))
@@ -90,53 +72,29 @@ function checkShape(
90
72
  }
91
73
  return;
92
74
  }
93
- const allowed = named === "pane" ? new Set(["tab"]) : NEW_TAB_FIELDS;
94
- const others = fields.filter((field) => !allowed.has(field));
75
+ const others = fields.filter((field) => field !== "tab");
95
76
  if (others.length > 0) {
96
77
  refuse(
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(", ")}`,
78
+ `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" : ""}`,
100
79
  );
101
80
  }
102
- const { session, workspace, tab } = layout;
103
- if (session !== undefined && (typeof session !== "string" || !SESSION_NAME.test(session))) {
104
- refuse(
105
- `session ${JSON.stringify(session)} is not a session name: lowercase letters, digits and -, up to 32, not starting with -`,
106
- );
81
+ if (shape.tab !== undefined && !nonEmpty(shape.tab)) {
82
+ refuse(`tab is a label, not ${JSON.stringify(shape.tab)}`);
107
83
  }
108
- if (session !== undefined && workspace === "origin") {
109
- refuse('"origin" is a workspace in its own session, so it takes no session');
110
- }
111
- if (
112
- workspace !== undefined &&
113
- workspace !== "run" &&
114
- workspace !== "origin" &&
115
- !(
116
- typeof workspace === "object" &&
117
- workspace !== null &&
118
- !Array.isArray(workspace) &&
119
- Object.keys(workspace).join() === "name" &&
120
- nonEmpty((workspace as { name?: unknown }).name)
121
- )
122
- ) {
123
- refuse(`workspace is "run", "origin" or { name }, not ${JSON.stringify(workspace)}`);
124
- }
125
- if (tab !== undefined && !nonEmpty(tab)) refuse(`tab is a label, not ${JSON.stringify(tab)}`);
126
84
  }
127
85
 
128
86
  /**
129
- * A checked `pane` or `layout` as it is kept and compared: fields given as `undefined` dropped, and
87
+ * A checked `pane` as it is kept and compared: fields given as `undefined` dropped, and
130
88
  * `front: false`, which is its absence, so a reopen that leaves one out matches.
131
89
  */
132
- export function storedLayout<T extends PaneLayout | PaneSpec>(layout: T): T {
90
+ export function storedPane(pane: PaneSpec): PaneSpec {
133
91
  // Not an object, it is left for `checkPaneOptions` to refuse by name.
134
- if (typeof layout !== "object" || layout === null || Array.isArray(layout)) return layout;
92
+ if (typeof pane !== "object" || pane === null || Array.isArray(pane)) return pane;
135
93
  return Object.fromEntries(
136
- Object.entries(structuredClone(layout)).filter(
94
+ Object.entries(structuredClone(pane)).filter(
137
95
  ([field, value]) => value !== undefined && !(field === "front" && value === false),
138
96
  ),
139
- ) as T;
97
+ ) as PaneSpec;
140
98
  }
141
99
 
142
100
  function nonEmpty(value: unknown): value is string {
@@ -159,22 +117,21 @@ export type PaneAsked = {
159
117
 
160
118
  /**
161
119
  * 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.
120
+ * operator's workspace and kept as the operator says. An agent with no pane of awf's, headless or
121
+ * in a sandbox, is asked nothing.
165
122
  */
166
123
  export function paneAsked(
167
- written: { pane?: PaneSpec; layout?: PaneLayout; keepPane?: KeepPane },
124
+ pane: PaneSpec | undefined,
168
125
  run: RunPanes | undefined,
169
126
  hasPane: boolean,
170
127
  ): PaneAsked {
171
128
  if (!hasPane) return {};
172
- const keepPane = run?.keep ?? written.keepPane;
129
+ const keepPane = run?.keep;
173
130
  const workspace = run?.workspace;
174
- const layout = placedLayout(written, workspace);
131
+ const layout = placedLayout(pane, workspace);
175
132
  return {
176
133
  ...(layout === undefined ? {} : { layout }),
177
- ...(written.pane?.front ? { front: true as const } : {}),
134
+ ...(pane?.front ? { front: true as const } : {}),
178
135
  ...(keepPane === undefined ? {} : { keepPane }),
179
136
  ...(besideOf(layout) !== undefined && workspace !== undefined
180
137
  ? { fallbackWorkspace: workspace }
@@ -183,19 +140,15 @@ export function paneAsked(
183
140
  }
184
141
 
185
142
  function placedLayout(
186
- { pane, layout }: { pane?: PaneSpec; layout?: PaneLayout },
143
+ pane: PaneSpec | undefined,
187
144
  workspace: PaneWorkspace | undefined,
188
145
  ): PaneLayout | undefined {
189
- const written = pane ?? layout;
190
- if (written?.beside !== undefined) {
191
- const { beside, side, share } = written;
146
+ if (pane?.beside !== undefined) {
147
+ const { beside, side, share } = pane;
192
148
  return { beside, side, ...(share === undefined ? {} : { share }) };
193
149
  }
194
- const tab = written?.tab;
195
- if (workspace !== undefined) {
196
- return { workspace, ...(tab === undefined ? {} : { tab }) };
197
- }
198
- if (layout !== undefined) return layout;
150
+ const tab = pane?.tab;
151
+ if (workspace !== undefined) return { workspace, ...(tab === undefined ? {} : { tab }) };
199
152
  return tab === undefined ? undefined : { tab };
200
153
  }
201
154
 
@@ -316,7 +316,7 @@ const GAP = " ";
316
316
 
317
317
  const HOUR_MS = 60 * 60_000;
318
318
 
319
- /** The attempt that recorded a reused stage, and its record's age once that may make it stale. */
319
+ /** The attempt that ran a reused stage, and its line's age once that may make it stale. */
320
320
  function reusedTail(stage: StageProgress, now: number): string {
321
321
  const age = stage.recordedAt === undefined ? 0 : now - stage.recordedAt;
322
322
  return [
@@ -404,7 +404,7 @@ function awaitingNote(turn: NonNullable<Agent["turn"]>, now: number): string {
404
404
  }
405
405
 
406
406
  /** A message admitted: who to whom, its size, and whether it asks or answers. */
407
- export function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
407
+ function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
408
408
  const size =
409
409
  message.bytes < 1024 ? `${message.bytes} B` : `${(message.bytes / 1024).toFixed(1)} KB`;
410
410
  const kind = [message.answers ? "reply" : "", message.asks ? "reply owed" : ""].filter(Boolean);
@@ -0,0 +1,79 @@
1
+ import { dirname } from "node:path";
2
+ import manifest from "../package.json" with { type: "json" };
3
+ import {
4
+ type InstalledEngine,
5
+ installedEngine,
6
+ type ProjectEngine,
7
+ projectEngine,
8
+ } from "./hand-over";
9
+
10
+ /** This engine's version, which every package of a release shares. */
11
+ export const ENGINE_VERSION: string = manifest.version;
12
+
13
+ /** Why an engine won't run a project's runs: `other-engine` or `no-engine`, as a client says it. */
14
+ export class EngineRefused extends Error {
15
+ constructor(
16
+ readonly code: "other-engine" | "no-engine",
17
+ message: string,
18
+ ) {
19
+ super(message);
20
+ this.name = "EngineRefused";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The engine a project's runs run on (ADR 0015): the `agentswf` installed above it; else, for a
26
+ * project with none above it, the one its workflow files resolve, as in a workspace whose root
27
+ * holds `.awf/` and whose packages each have a `node_modules`. Files that resolve two versions, or
28
+ * one other than the project's, are refused: one project runs on one engine.
29
+ */
30
+ export function engineOf(project: string, files: readonly string[] = []): ProjectEngine {
31
+ const engine = projectEngine(project);
32
+ let fromFiles: (InstalledEngine & { file: string }) | undefined;
33
+ for (const file of files) {
34
+ const found = installedEngine(dirname(file));
35
+ if (!found) continue;
36
+ const other = engine.kind === "installed" ? engine : fromFiles;
37
+ if (other && other.version !== found.version)
38
+ throw new EngineRefused(
39
+ "other-engine",
40
+ `${file} resolves agentswf ${found.version}, from ${found.root}, and ${engine.kind === "installed" ? `its project ${project}` : (fromFiles?.file ?? project)} ${other.version}: one project runs on one engine`,
41
+ );
42
+ fromFiles ??= { ...found, file };
43
+ }
44
+ if (engine.kind === "installed" || !fromFiles) return engine;
45
+ const { file: _file, ...installed } = fromFiles;
46
+ return { kind: "installed", ...installed };
47
+ }
48
+
49
+ /**
50
+ * Refuses `project` unless this engine is the one its runs run on, as `engineOf` finds it with
51
+ * `file`: at this version, or, for a project with none, any engine but a client's.
52
+ */
53
+ export function checkProjectEngine(
54
+ project: string,
55
+ options: { file?: string; files?: readonly string[]; client?: boolean; version?: string } = {},
56
+ ): void {
57
+ const own = options.version ?? ENGINE_VERSION;
58
+ const engine = engineOf(project, [
59
+ ...(options.file === undefined ? [] : [options.file]),
60
+ ...(options.files ?? []),
61
+ ]);
62
+ if (engine.kind === "declared")
63
+ throw new EngineRefused(
64
+ "no-engine",
65
+ `${project} declares agentswf in ${engine.manifest} and has none installed: run bun install there`,
66
+ );
67
+ if (engine.kind === "installed" && engine.version !== own)
68
+ throw new EngineRefused(
69
+ "other-engine",
70
+ options.client
71
+ ? `${project} runs on its agentswf ${engine.version}, and this program's agentswf/client is ${own}: install one version in both`
72
+ : `${project} runs on its agentswf ${engine.version}, and this awf is ${own}: run the project's, bunx awf, from ${engine.root}`,
73
+ );
74
+ if (engine.kind === "none" && options.client)
75
+ throw new EngineRefused(
76
+ "no-engine",
77
+ `${project} has no agentswf installed, so it runs on whichever global awf is installed: bun add -d agentswf there`,
78
+ );
79
+ }
package/src/projects.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { stat } from "node:fs/promises";
2
- import { dirname, join, resolve } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { realPath } from "./files";
4
4
  import { appendLine } from "./jsonl";
5
5
  import { withRunLock } from "./run-lock";
@@ -34,7 +34,7 @@ export function projectRunRoot(project: string): string {
34
34
  }
35
35
 
36
36
  /** `~/.awf/projects`: each project awf has started an event run in, one absolute path a line. */
37
- export function projectsFile(home: string): string {
37
+ function projectsFile(home: string): string {
38
38
  return join(home, ".awf", "projects");
39
39
  }
40
40
 
@@ -55,3 +55,14 @@ export async function recordProject(home: string, project: string): Promise<void
55
55
  await appendLine(file, project);
56
56
  });
57
57
  }
58
+
59
+ /**
60
+ * The project a run root's runs belong to: the folder holding it as its `.awf/runs`, else, under a
61
+ * `--run-root` of the operator's, the directory the run works in.
62
+ */
63
+ export function rootProject(runRoot: string, cwd: string): string {
64
+ const root = resolve(runRoot);
65
+ return basename(root) === "runs" && basename(dirname(root)) === ".awf"
66
+ ? dirname(dirname(root))
67
+ : cwd;
68
+ }