@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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- 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
|
+
}
|
package/src/pane-layout.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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:
|
|
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
|
-
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
46
|
-
refuse(
|
|
44
|
+
if (typeof pane !== "object" || pane === null || Array.isArray(pane)) {
|
|
45
|
+
refuse(`a pane is an object, not ${JSON.stringify(pane)}`);
|
|
47
46
|
}
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
70
|
-
refuse(`front is true or false, not ${JSON.stringify(
|
|
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 (
|
|
54
|
+
if (shape.beside !== undefined) {
|
|
73
55
|
const others = fields.filter((field) => !BESIDE_FIELDS.has(field));
|
|
74
56
|
if (others.length > 0) {
|
|
75
|
-
refuse(
|
|
57
|
+
refuse(`a pane beside another pane takes side and share, not ${others.join(", ")}`);
|
|
76
58
|
}
|
|
77
|
-
if (typeof
|
|
78
|
-
refuse(`beside names an agent's key, not ${JSON.stringify(
|
|
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 (
|
|
81
|
-
if (
|
|
82
|
-
refuse(`side is right or below, not ${JSON.stringify(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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`
|
|
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
|
|
90
|
+
export function storedPane(pane: PaneSpec): PaneSpec {
|
|
133
91
|
// Not an object, it is left for `checkPaneOptions` to refuse by name.
|
|
134
|
-
if (typeof
|
|
92
|
+
if (typeof pane !== "object" || pane === null || Array.isArray(pane)) return pane;
|
|
135
93
|
return Object.fromEntries(
|
|
136
|
-
Object.entries(structuredClone(
|
|
94
|
+
Object.entries(structuredClone(pane)).filter(
|
|
137
95
|
([field, value]) => value !== undefined && !(field === "front" && value === false),
|
|
138
96
|
),
|
|
139
|
-
) as
|
|
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.
|
|
163
|
-
*
|
|
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
|
-
|
|
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
|
|
129
|
+
const keepPane = run?.keep;
|
|
173
130
|
const workspace = run?.workspace;
|
|
174
|
-
const layout = placedLayout(
|
|
131
|
+
const layout = placedLayout(pane, workspace);
|
|
175
132
|
return {
|
|
176
133
|
...(layout === undefined ? {} : { layout }),
|
|
177
|
-
...(
|
|
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
|
-
|
|
143
|
+
pane: PaneSpec | undefined,
|
|
187
144
|
workspace: PaneWorkspace | undefined,
|
|
188
145
|
): PaneLayout | undefined {
|
|
189
|
-
|
|
190
|
-
|
|
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 =
|
|
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
|
|
package/src/progress-view.ts
CHANGED
|
@@ -316,7 +316,7 @@ const GAP = " ";
|
|
|
316
316
|
|
|
317
317
|
const HOUR_MS = 60 * 60_000;
|
|
318
318
|
|
|
319
|
-
/** The attempt that
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|