@agentswf/engine 0.0.0-stage → 0.1.0-beta.1
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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/init-command.ts +240 -0
- package/src/init-template/AGENTS.md.template +16 -0
- package/src/init-template/hello/workflow.test.ts.template +17 -0
- package/src/init-template/hello/workflow.ts.template +26 -0
- package/src/init-template/tsconfig.json.template +11 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +600 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +242 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +521 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3666 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { type JsonValue, placementOf, type StageOutcome } from "@agentswf/contract/workflow";
|
|
2
|
+
import { ago, duration } from "./accounting/format";
|
|
3
|
+
import { NO_STAGE } from "./accounting/summary";
|
|
4
|
+
import type { RunSession } from "./herdr-run-session";
|
|
5
|
+
import type { StageProgress } from "./stage-ledger";
|
|
6
|
+
import type { WorkflowRunHandle, WorkflowRunSnapshot } from "./workflow-runner";
|
|
7
|
+
|
|
8
|
+
type Agent = WorkflowRunSnapshot["agents"][number];
|
|
9
|
+
|
|
10
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
|
+
/** Past this, an active stage lists only what is running or went wrong, then a count. */
|
|
12
|
+
const MAX_LISTED = 8;
|
|
13
|
+
|
|
14
|
+
export type Paint = {
|
|
15
|
+
dim(text: string): string;
|
|
16
|
+
ok(text: string): string;
|
|
17
|
+
bad(text: string): string;
|
|
18
|
+
busy(text: string): string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const PLAIN: Paint = { dim: (t) => t, ok: (t) => t, bad: (t) => t, busy: (t) => t };
|
|
22
|
+
export const ANSI: Paint = {
|
|
23
|
+
dim: (t) => `\x1b[2m${t}\x1b[22m`,
|
|
24
|
+
ok: (t) => `\x1b[32m${t}\x1b[39m`,
|
|
25
|
+
bad: (t) => `\x1b[31m${t}\x1b[39m`,
|
|
26
|
+
busy: (t) => `\x1b[33m${t}\x1b[39m`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The live block: the run's stages in the order entered, the current one with its agents, and
|
|
31
|
+
* those an earlier attempt recorded still to come; then one line per labelled `parallel`, and
|
|
32
|
+
* under the active ones, the agents worth watching.
|
|
33
|
+
*/
|
|
34
|
+
export function renderProgress(
|
|
35
|
+
snapshot: WorkflowRunSnapshot,
|
|
36
|
+
view: {
|
|
37
|
+
name: string;
|
|
38
|
+
startedAt: number;
|
|
39
|
+
now: number;
|
|
40
|
+
paint: Paint;
|
|
41
|
+
/** Once the run is over: what each stage's agents cost, by stage. */
|
|
42
|
+
figures?: ReadonlyMap<string, string>;
|
|
43
|
+
/** The Herdr session pane agents opened in, once one has. */
|
|
44
|
+
herdrSession?: string;
|
|
45
|
+
},
|
|
46
|
+
): string[] {
|
|
47
|
+
const { now, paint } = view;
|
|
48
|
+
const working = snapshot.agents.filter(isRunning).length;
|
|
49
|
+
const current = snapshot.stages.findLast((stage) => stage.endedAt === undefined);
|
|
50
|
+
const lines = [
|
|
51
|
+
`${view.name}${current ? ` · ${current.stage}` : ""} ${paint.dim(`· ${duration(now - view.startedAt)}`)}${working ? paint.dim(` · ${working} working`) : ""}`,
|
|
52
|
+
];
|
|
53
|
+
lines.push(...stageLines(snapshot, now, paint, view.figures ?? new Map(), view.herdrSession));
|
|
54
|
+
const width = Math.max(...snapshot.agents.map((agent) => agent.key.length), 0);
|
|
55
|
+
const model = Math.max(...snapshot.agents.map((agent) => agent.execution.model.length), 0);
|
|
56
|
+
const agentLine = (agent: Agent) => {
|
|
57
|
+
const [mark, note] = agentState(agent, now, paint, view.herdrSession);
|
|
58
|
+
const took = agent.turn ? duration((agent.turn.settledAt ?? now) - agent.turn.startedAt) : "";
|
|
59
|
+
return ` ${mark} ${agent.key.padEnd(width)} ${paint.dim(agent.execution.model.padEnd(model))} ${took.padStart(6)}${note ? ` ${note}` : ""}`;
|
|
60
|
+
};
|
|
61
|
+
const listAgents = (agents: readonly Agent[]) => {
|
|
62
|
+
const shown =
|
|
63
|
+
agents.length <= MAX_LISTED
|
|
64
|
+
? agents
|
|
65
|
+
: agents.filter((a) => isRunning(a) || isFailed(a) || a.stopped);
|
|
66
|
+
lines.push(...shown.slice(0, MAX_LISTED).map(agentLine));
|
|
67
|
+
const hidden = agents.length - Math.min(shown.length, MAX_LISTED);
|
|
68
|
+
if (hidden > 0) lines.push(paint.dim(` … ${hidden} more`));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Under the current stage, the agents are listed there instead.
|
|
72
|
+
const inStage = (agent: Agent) => current !== undefined && agent.turn?.stage === current.stage;
|
|
73
|
+
snapshot.groups.forEach((group, index) => {
|
|
74
|
+
const members = snapshot.agents.filter((agent) => agent.group === index);
|
|
75
|
+
const agents = members.filter((agent) => !inStage(agent));
|
|
76
|
+
const failed = members.filter(isFailed).length;
|
|
77
|
+
const failures = failed ? paint.bad(` · ${failed} failed`) : "";
|
|
78
|
+
if (group.endedAt !== undefined) {
|
|
79
|
+
// A finished stage's line already says what went well in it.
|
|
80
|
+
const folded = members.every((agent) =>
|
|
81
|
+
snapshot.stages.some((stage) => stage.stage === agent.turn?.stage && stage.endedAt),
|
|
82
|
+
);
|
|
83
|
+
if (!failed && members.length > 0 && folded) return;
|
|
84
|
+
const mark = failed ? paint.bad("✗") : paint.ok("✓");
|
|
85
|
+
lines.push(
|
|
86
|
+
`${mark} ${group.label} ${paint.dim(`${group.done}/${group.total} · ${duration(group.endedAt - group.startedAt)}`)}${failures}`,
|
|
87
|
+
);
|
|
88
|
+
listAgents(agents.filter(isFailed));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const queued = group.total - group.started;
|
|
92
|
+
lines.push(
|
|
93
|
+
`${paint.busy(spin(now))} ${group.label} ${paint.dim(`${group.done}/${group.total}${queued ? ` · ${queued} queued` : ""}`)}${failures}`,
|
|
94
|
+
);
|
|
95
|
+
listAgents(agents);
|
|
96
|
+
});
|
|
97
|
+
// Agents opened outside any labelled parallel.
|
|
98
|
+
const loose = snapshot.agents.filter((agent) => agent.group === undefined && !inStage(agent));
|
|
99
|
+
if (loose.some((agent) => isRunning(agent) || isFailed(agent) || agent.stopped))
|
|
100
|
+
listAgents(loose);
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** For a log rather than a terminal: one line per change between two snapshots, when it happened. */
|
|
105
|
+
export function progressEvents(
|
|
106
|
+
before: WorkflowRunSnapshot | undefined,
|
|
107
|
+
after: WorkflowRunSnapshot,
|
|
108
|
+
view: { startedAt: number; now: number; herdrSession?: string },
|
|
109
|
+
): string[] {
|
|
110
|
+
// Each stamped and ordered by when it happened; at the same instant, what ends goes before what
|
|
111
|
+
// starts, and the inner before the outer as each ends, the outer before the inner as each starts.
|
|
112
|
+
const events: { time: number; rank: number; text: string }[] = [];
|
|
113
|
+
const add = (time: number, rank: Rank, text: string) =>
|
|
114
|
+
events.push({
|
|
115
|
+
time,
|
|
116
|
+
rank: RANKS.indexOf(rank),
|
|
117
|
+
text: `[${clock(time - view.startedAt)}] ${text}`,
|
|
118
|
+
});
|
|
119
|
+
after.groups.forEach((group, index) => {
|
|
120
|
+
const earlier = before?.groups[index];
|
|
121
|
+
if (!earlier) add(group.startedAt, "group start", `▶ ${group.label} (${group.total})`);
|
|
122
|
+
if (group.endedAt !== undefined && earlier?.endedAt === undefined) {
|
|
123
|
+
const failed = after.agents.filter((a) => a.group === index && isFailed(a)).length;
|
|
124
|
+
add(
|
|
125
|
+
group.endedAt,
|
|
126
|
+
group.endedAt === group.startedAt ? "zero-length end" : "group end",
|
|
127
|
+
`${failed ? "✗" : "✓"} ${group.label} done ${group.done}/${group.total} in ${duration(group.endedAt - group.startedAt)}${failed ? `, ${failed} failed` : ""}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
for (const agent of after.agents) {
|
|
132
|
+
const earlier = before?.agents.find((a) => a.key === agent.key);
|
|
133
|
+
const turn = agent.turn;
|
|
134
|
+
if (agent.forkedFrom !== undefined && earlier?.forkedFrom === undefined) {
|
|
135
|
+
add(turn?.startedAt ?? view.now, "fork", `↳ ${agent.key} forked from ${agent.forkedFrom}`);
|
|
136
|
+
}
|
|
137
|
+
if (agent.stopped && !earlier?.stopped) {
|
|
138
|
+
add(
|
|
139
|
+
Math.max(agent.stopped.at, turn?.settledAt ?? agent.stopped.at),
|
|
140
|
+
"agent stop",
|
|
141
|
+
`■ ${agent.key} stopped: ${oneLine(agent.stopped.reason)}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!turn) continue;
|
|
145
|
+
const fresh = earlier?.turns !== agent.turns;
|
|
146
|
+
if (fresh && (turn.outcome === undefined || agent.stopped))
|
|
147
|
+
add(turn.startedAt, "agent start", `▶ ${agent.key} · ${agent.execution.model}`);
|
|
148
|
+
if (
|
|
149
|
+
turn.outcome === undefined &&
|
|
150
|
+
turn.phase &&
|
|
151
|
+
(fresh
|
|
152
|
+
? turn.phase !== "working"
|
|
153
|
+
: turn.phase !== earlier?.turn?.phase ||
|
|
154
|
+
turn.waitingReason !== earlier?.turn?.waitingReason ||
|
|
155
|
+
turn.checkInAt !== earlier?.turn?.checkInAt)
|
|
156
|
+
) {
|
|
157
|
+
const note =
|
|
158
|
+
turn.phase === "waiting"
|
|
159
|
+
? `waiting (agent-reported): ${oneLine(turn.waitingReason ?? "")}${turn.checkInAt === undefined ? "" : ` · check-in at ${clock(turn.checkInAt - view.startedAt)}`}`
|
|
160
|
+
: turn.phase === "awaiting-reply"
|
|
161
|
+
? `awaiting ${oneLine(turn.waitingReason ?? "a reply")}${turn.checkInAt === undefined ? "" : ` · until ${clock(turn.checkInAt - view.startedAt)}`}`
|
|
162
|
+
: oneLine(turn.phase.replaceAll("-", " "));
|
|
163
|
+
add(view.now, "agent phase", `… ${oneLine(agent.key)} · ${note}`);
|
|
164
|
+
}
|
|
165
|
+
if (turn.outcome !== undefined && (fresh || earlier?.turn?.outcome === undefined)) {
|
|
166
|
+
const settledAt = turn.settledAt ?? view.now;
|
|
167
|
+
const took = duration(settledAt - turn.startedAt);
|
|
168
|
+
add(
|
|
169
|
+
settledAt,
|
|
170
|
+
settledAt === turn.startedAt ? "zero-length end" : "agent end",
|
|
171
|
+
turn.outcome === "answered"
|
|
172
|
+
? `✓ ${agent.key} · ${took}`
|
|
173
|
+
: `${cancelledByStop(agent) ? "■" : "✗"} ${agent.key} · ${took} · ${turn.outcome}${turn.reason && !cancelledByStop(agent) ? `: ${oneLine(turn.reason)}` : ""}${whereBlocked(agent, view.herdrSession)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const message of after.messages.slice(before?.messages.length ?? 0)) {
|
|
178
|
+
add(message.at, "message", messageLine(message));
|
|
179
|
+
}
|
|
180
|
+
after.stages.forEach((stage, index) => {
|
|
181
|
+
const earlier = before?.stages[index];
|
|
182
|
+
if (stage.source === "reused") {
|
|
183
|
+
if (!earlier) {
|
|
184
|
+
add(
|
|
185
|
+
stage.startedAt,
|
|
186
|
+
"stage start",
|
|
187
|
+
`↺ stage ${[stage.stage, ...summaryOf(stage), ...providedOf(stage), `attempt ${stage.attempt}`].join(" · ")}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const ended = stage.endedAt !== undefined && earlier?.endedAt === undefined;
|
|
193
|
+
// A stage that ended as it was entered, as a stop before it does, is one line.
|
|
194
|
+
const instant = ended && !earlier && stage.endedAt === stage.startedAt;
|
|
195
|
+
if (!earlier && !instant) add(stage.startedAt, "stage start", `▶ stage ${stage.stage}`);
|
|
196
|
+
if (ended) {
|
|
197
|
+
add(
|
|
198
|
+
stage.endedAt!,
|
|
199
|
+
instant ? "stage start" : "stage end",
|
|
200
|
+
`${stageMark(stage.outcome, PLAIN)} stage ${[stage.stage, duration(stage.endedAt! - stage.startedAt), ...summaryOf(stage)].join(" · ")}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
return events
|
|
205
|
+
.map((event, order) => ({ ...event, order }))
|
|
206
|
+
.sort((a, b) => a.time - b.time || a.rank - b.rank || a.order - b.order)
|
|
207
|
+
.map((event) => event.text);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const RANKS = [
|
|
211
|
+
"agent end",
|
|
212
|
+
"group end",
|
|
213
|
+
"stage end",
|
|
214
|
+
"stage start",
|
|
215
|
+
"group start",
|
|
216
|
+
"fork",
|
|
217
|
+
"agent start",
|
|
218
|
+
"agent phase",
|
|
219
|
+
"message",
|
|
220
|
+
"zero-length end",
|
|
221
|
+
"agent stop",
|
|
222
|
+
] as const;
|
|
223
|
+
type Rank = (typeof RANKS)[number];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The stages, a row each in columns: the time; the summary; and what its agents cost once the run
|
|
227
|
+
* is over, or for a reused one, marked `↺`, whether its value was given, the attempt that ran it,
|
|
228
|
+
* and how long ago once that is over an hour. The current one lists each agent working in it; those an earlier attempt recorded
|
|
229
|
+
* still to come are dim. Once the run is over, what ran between stages has a row of its own, so the
|
|
230
|
+
* stages add up. None for a run without stages. A stage that did not succeed shows only its mark:
|
|
231
|
+
* why is said once, where the run's ending is.
|
|
232
|
+
*/
|
|
233
|
+
function stageLines(
|
|
234
|
+
snapshot: WorkflowRunSnapshot,
|
|
235
|
+
now: number,
|
|
236
|
+
paint: Paint,
|
|
237
|
+
figures: ReadonlyMap<string, string>,
|
|
238
|
+
herdrSession: string | undefined,
|
|
239
|
+
): string[] {
|
|
240
|
+
// Once the run is closing, what it never reached isn't coming.
|
|
241
|
+
const upcoming =
|
|
242
|
+
snapshot.state === "closing" || snapshot.state === "closed" ? [] : snapshot.upcoming;
|
|
243
|
+
const between = figures.get(NO_STAGE);
|
|
244
|
+
const width = Math.max(
|
|
245
|
+
...snapshot.stages.map((stage) => stage.stage.length),
|
|
246
|
+
...upcoming.map((name) => name.length),
|
|
247
|
+
between === undefined ? 0 : NO_STAGE.length,
|
|
248
|
+
0,
|
|
249
|
+
);
|
|
250
|
+
type Row = { mark: string; name: string; cells: [string, string, string]; stage?: StageProgress };
|
|
251
|
+
const rows: Row[] = snapshot.stages.map((stage) => {
|
|
252
|
+
const summary = summaryOf(stage).join("");
|
|
253
|
+
if (stage.source === "reused") {
|
|
254
|
+
return { mark: "↺", name: stage.stage, cells: ["", summary, reusedTail(stage, now)], stage };
|
|
255
|
+
}
|
|
256
|
+
const took = duration((stage.endedAt ?? now) - stage.startedAt);
|
|
257
|
+
const cost = figures.get(stage.stage);
|
|
258
|
+
if (stage.endedAt === undefined) {
|
|
259
|
+
return { mark: paint.busy(spin(now)), name: stage.stage, cells: [took, "", ""], stage };
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
mark: stageMark(stage.outcome, paint),
|
|
263
|
+
name: stage.stage,
|
|
264
|
+
cells: [took, summary, cost ?? ""],
|
|
265
|
+
stage,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
if (between !== undefined) {
|
|
269
|
+
rows.push({ mark: paint.dim("·"), name: NO_STAGE, cells: ["", "", between] });
|
|
270
|
+
}
|
|
271
|
+
const widths = [0, 1, 2].map((column) =>
|
|
272
|
+
Math.max(0, ...rows.map((row) => row.cells[column]!.length)),
|
|
273
|
+
);
|
|
274
|
+
const lines: string[] = [];
|
|
275
|
+
for (const { mark, name, cells, stage } of rows) {
|
|
276
|
+
// Up to the last cell with anything in it, an empty column in no row taking no room.
|
|
277
|
+
const last = cells.findLastIndex((cell) => cell !== "");
|
|
278
|
+
const shown = cells
|
|
279
|
+
.map((cell, column) => ({ cell, column }))
|
|
280
|
+
.filter(({ column }) => column <= last && widths[column]! > 0)
|
|
281
|
+
.map(({ cell, column }) => {
|
|
282
|
+
const text = column === last ? cell : cell.padEnd(widths[column]!);
|
|
283
|
+
// A stage that ran has its summary dim; a reused one is dim whole.
|
|
284
|
+
return stage?.source !== "reused" && column === 1 ? paint.dim(text) : text;
|
|
285
|
+
});
|
|
286
|
+
const line = [name.padEnd(last < 0 ? 0 : width), ...shown].join(GAP);
|
|
287
|
+
if (stage?.source === "reused") {
|
|
288
|
+
lines.push(paint.dim(`${mark} ${line}`));
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (stage !== undefined && stage.endedAt === undefined) {
|
|
292
|
+
lines.push(`${mark} ${name.padEnd(width)}${GAP}${paint.dim(cells[0])}`);
|
|
293
|
+
const agents = snapshot.agents.filter((agent) => agent.turn?.stage === stage.stage);
|
|
294
|
+
const keys = Math.max(...agents.map((agent) => agent.key.length), 0);
|
|
295
|
+
lines.push(...agents.map((agent) => stageAgentLine(agent, keys, now, paint, herdrSession)));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
lines.push(`${mark} ${line}`);
|
|
299
|
+
}
|
|
300
|
+
lines.push(...upcoming.map((name) => paint.dim(`· ${name}`)));
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const GAP = " ";
|
|
305
|
+
|
|
306
|
+
const HOUR_MS = 60 * 60_000;
|
|
307
|
+
|
|
308
|
+
/** The attempt that recorded a reused stage, and its record's age once that may make it stale. */
|
|
309
|
+
function reusedTail(stage: StageProgress, now: number): string {
|
|
310
|
+
const age = stage.recordedAt === undefined ? 0 : now - stage.recordedAt;
|
|
311
|
+
return [
|
|
312
|
+
...providedOf(stage),
|
|
313
|
+
`attempt ${stage.attempt}`,
|
|
314
|
+
...(age > HOUR_MS ? [ago(age)] : []),
|
|
315
|
+
].join(" · ");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function providedOf(stage: StageProgress): string[] {
|
|
319
|
+
return stage.provided ? ["provided"] : [];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A finished stage's mark. */
|
|
323
|
+
function stageMark(outcome: StageOutcome | undefined, paint: Paint): string {
|
|
324
|
+
if (outcome === "succeeded") return paint.ok("✓");
|
|
325
|
+
return outcome === "stopped" ? paint.busy("■") : paint.bad("✗");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function summaryOf(stage: StageProgress): string[] {
|
|
329
|
+
return stage.summary ? [oneLine(stage.summary)] : [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** An agent in the current stage: its placement, its turn's label and time; done, it waits. */
|
|
333
|
+
function stageAgentLine(
|
|
334
|
+
agent: Agent,
|
|
335
|
+
keys: number,
|
|
336
|
+
now: number,
|
|
337
|
+
paint: Paint,
|
|
338
|
+
herdrSession: string | undefined,
|
|
339
|
+
): string {
|
|
340
|
+
const turn = agent.turn!;
|
|
341
|
+
const [mark, note] =
|
|
342
|
+
turn.outcome === "answered" && !agent.stopped
|
|
343
|
+
? [paint.dim("·"), paint.dim("waiting")]
|
|
344
|
+
: agentState(agent, now, paint, herdrSession);
|
|
345
|
+
const took = duration((turn.settledAt ?? now) - turn.startedAt);
|
|
346
|
+
const label = turn.label ?? (turn.kind === "turn" ? undefined : turn.kind);
|
|
347
|
+
const labelled = label ? ` ${label}` : "";
|
|
348
|
+
return ` ${mark} ${agent.key.padEnd(keys)} ${paint.dim(`${agent.execution.model} · ${placementOf(agent.execution)}`)}${labelled} ${took}${note ? ` ${note}` : ""}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function agentState(
|
|
352
|
+
agent: Agent,
|
|
353
|
+
now: number,
|
|
354
|
+
paint: Paint,
|
|
355
|
+
herdrSession?: string,
|
|
356
|
+
): [string, string] {
|
|
357
|
+
if (agent.stopped && !isFailed(agent))
|
|
358
|
+
return [paint.dim("■"), paint.dim(`stopped: ${oneLine(agent.stopped.reason)}`)];
|
|
359
|
+
const turn = agent.turn;
|
|
360
|
+
if (!turn) {
|
|
361
|
+
return agent.state === "missing" || agent.state === "quarantined"
|
|
362
|
+
? [paint.bad("✗"), paint.bad(oneLine(agent.detail ?? agent.state))]
|
|
363
|
+
: [paint.dim("·"), paint.dim(agent.state)];
|
|
364
|
+
}
|
|
365
|
+
if (turn.outcome === undefined) {
|
|
366
|
+
const phase = turn.phase;
|
|
367
|
+
const note =
|
|
368
|
+
phase === "waiting"
|
|
369
|
+
? `waiting (agent-reported): ${oneLine(turn.waitingReason ?? "")}${turn.checkInAt === undefined ? "" : ` · check-in in ${duration(Math.max(0, turn.checkInAt - now))}`}`
|
|
370
|
+
: phase === "awaiting-reply"
|
|
371
|
+
? awaitingNote(turn, now)
|
|
372
|
+
: phase && phase !== "working"
|
|
373
|
+
? phase.replaceAll("-", " ")
|
|
374
|
+
: "";
|
|
375
|
+
const queued = agent.queued ? `${agent.queued} queued` : "";
|
|
376
|
+
return [paint.busy(spin(now)), [note, queued].filter(Boolean).join(" · ")];
|
|
377
|
+
}
|
|
378
|
+
if (turn.outcome === "answered") return [paint.ok("✓"), ""];
|
|
379
|
+
return [
|
|
380
|
+
paint.bad("✗"),
|
|
381
|
+
paint.bad(
|
|
382
|
+
`${turn.outcome}${turn.reason ? `: ${oneLine(turn.reason)}` : ""}${whereBlocked(agent, herdrSession)}`,
|
|
383
|
+
),
|
|
384
|
+
];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Whose reply a turn awaits, how long it has, and for how long. */
|
|
388
|
+
function awaitingNote(turn: NonNullable<Agent["turn"]>, now: number): string {
|
|
389
|
+
const since = turn.phaseAt ?? turn.startedAt;
|
|
390
|
+
const of = turn.checkInAt === undefined ? "" : ` of ${duration(turn.checkInAt - since)}`;
|
|
391
|
+
return `awaiting ${oneLine(turn.waitingReason ?? "a reply")} · ${duration(Math.max(0, now - since))}${of}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A message admitted: who to whom, its size, and whether it asks or answers. */
|
|
395
|
+
export function messageLine(message: WorkflowRunSnapshot["messages"][number]): string {
|
|
396
|
+
const size =
|
|
397
|
+
message.bytes < 1024 ? `${message.bytes} B` : `${(message.bytes / 1024).toFixed(1)} KB`;
|
|
398
|
+
const kind = [message.answers ? "reply" : "", message.asks ? "reply owed" : ""].filter(Boolean);
|
|
399
|
+
return `${oneLine(message.from)} → ${oneLine(message.to)} ${[size, ...kind].join(", ")}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Where to see a pane agent Herdr read as blocked on a prompt: its session plays no sound, so this
|
|
404
|
+
* is the one sign. Not the calling session's, which is the operator's own pane.
|
|
405
|
+
*/
|
|
406
|
+
function whereBlocked(agent: Agent, herdrSession: string | undefined): string {
|
|
407
|
+
return agent.turn?.outcome === "blocked" &&
|
|
408
|
+
herdrSession !== undefined &&
|
|
409
|
+
placementOf(agent.execution) === "pane" &&
|
|
410
|
+
!agent.execution.caller
|
|
411
|
+
? ` · herdr session attach ${herdrSession}`
|
|
412
|
+
: "";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Where a run's pane agents are, said once as the first opens; the workspace closes with the run.
|
|
417
|
+
* A client inside Herdr can't attach to another session, so from there it says where to.
|
|
418
|
+
*/
|
|
419
|
+
export function describeRunSession(
|
|
420
|
+
session: RunSession,
|
|
421
|
+
workspace: string,
|
|
422
|
+
insideHerdr: boolean,
|
|
423
|
+
): string[] {
|
|
424
|
+
const { name, stale, closed, unclaimed, restartedFrom } = session;
|
|
425
|
+
const attach = `HERDR_DISABLE_SOUND=1 herdr session attach ${name}`;
|
|
426
|
+
return [
|
|
427
|
+
...(restartedFrom !== undefined
|
|
428
|
+
? [`restarted herdr session ${name}, which ran herdr ${restartedFrom}, on the one installed`]
|
|
429
|
+
: session.started
|
|
430
|
+
? [`started herdr session ${name}, headless; stop it with herdr session stop ${name}`]
|
|
431
|
+
: []),
|
|
432
|
+
...(stale
|
|
433
|
+
? [
|
|
434
|
+
`herdr session ${name} runs herdr ${stale.server}, not the ${stale.installed} installed; restart it with herdr session stop ${name} once no run is using it`,
|
|
435
|
+
]
|
|
436
|
+
: []),
|
|
437
|
+
...(closed.length > 0
|
|
438
|
+
? [
|
|
439
|
+
`closed in herdr session ${name}, their runs over: ${closed.map((label) => `"${label}"`).join(", ")}`,
|
|
440
|
+
]
|
|
441
|
+
: []),
|
|
442
|
+
...unclaimed.map(
|
|
443
|
+
({ id, label }) =>
|
|
444
|
+
`left "${label}" in herdr session ${name}, as no run of it is known; close it with herdr --session ${name} workspace close ${id}`,
|
|
445
|
+
),
|
|
446
|
+
...(session.kept ?? []).map(
|
|
447
|
+
({ run, session: where, paneId }) =>
|
|
448
|
+
`kept by "${run}": pane ${paneId} in herdr session ${where}; close it with herdr --session ${where} pane close ${paneId}`,
|
|
449
|
+
),
|
|
450
|
+
`agents herdr session ${session.name} · workspace "${workspace}" · ${attach}${insideHerdr ? " from a terminal outside Herdr" : ""}`,
|
|
451
|
+
];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isRunning(agent: Agent): boolean {
|
|
455
|
+
return !agent.stopped && agent.turn !== undefined && agent.turn.outcome === undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function cancelledByStop(agent: Agent): boolean {
|
|
459
|
+
return agent.stopped?.cancelledTurn === true && agent.turn?.outcome === "cancelled";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isFailed(agent: Agent): boolean {
|
|
463
|
+
if (cancelledByStop(agent)) return false;
|
|
464
|
+
if (!agent.turn) return agent.state === "missing" || agent.state === "quarantined";
|
|
465
|
+
return agent.turn.outcome !== undefined && agent.turn.outcome !== "answered";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function spin(now: number): string {
|
|
469
|
+
return SPINNER[Math.floor(now / 100) % SPINNER.length]!;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function oneLine(text: string): string {
|
|
473
|
+
const line = text
|
|
474
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip agent-supplied terminal styling from log lines.
|
|
475
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
476
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: untrusted reasons must not contain terminal controls.
|
|
477
|
+
.replace(/[\x00-\x1f\x7f-\x9f\s]+/g, " ")
|
|
478
|
+
.trim();
|
|
479
|
+
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function clock(milliseconds: number): string {
|
|
483
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
484
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Where progress is redrawn in place, and whether in color. */
|
|
488
|
+
export type Terminal = { write(text: string): void; color: boolean };
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Polls the run's snapshot. On a terminal it keeps one block redrawn under the log; elsewhere it
|
|
492
|
+
* writes a line per change, so a log file or a calling agent reads what happened and when.
|
|
493
|
+
*/
|
|
494
|
+
export function watchProgress(
|
|
495
|
+
name: string,
|
|
496
|
+
startedAt: number,
|
|
497
|
+
output: { stderr: (text: string) => void; terminal: Terminal | undefined; now: () => number },
|
|
498
|
+
) {
|
|
499
|
+
const { stderr, terminal, now } = output;
|
|
500
|
+
let handle: WorkflowRunHandle<JsonValue> | undefined;
|
|
501
|
+
let last: WorkflowRunSnapshot | undefined;
|
|
502
|
+
let herdrSession: string | undefined;
|
|
503
|
+
// A pane agent's session can be ready after the run has ended, as a cancel lands while it starts:
|
|
504
|
+
// nothing is said under the closing block.
|
|
505
|
+
let stopped = false;
|
|
506
|
+
let drawn = 0;
|
|
507
|
+
const clear = () => {
|
|
508
|
+
if (terminal && drawn > 0) terminal.write(`\x1b[${drawn}F\x1b[0J`);
|
|
509
|
+
drawn = 0;
|
|
510
|
+
};
|
|
511
|
+
const tick = (final = false, figures?: ReadonlyMap<string, string>) => {
|
|
512
|
+
if (!handle) return;
|
|
513
|
+
const snapshot = handle.inspect();
|
|
514
|
+
if (terminal) {
|
|
515
|
+
// A message is an event: a line above the block, as a log line is.
|
|
516
|
+
const sent = snapshot.messages.slice(last?.messages.length ?? 0);
|
|
517
|
+
if (sent.length > 0) {
|
|
518
|
+
clear();
|
|
519
|
+
for (const message of sent) stderr(messageLine(message));
|
|
520
|
+
}
|
|
521
|
+
const lines = renderProgress(snapshot, {
|
|
522
|
+
name,
|
|
523
|
+
startedAt,
|
|
524
|
+
now: now(),
|
|
525
|
+
paint: terminal.color ? ANSI : PLAIN,
|
|
526
|
+
...(figures ? { figures } : {}),
|
|
527
|
+
// The run's workspace closes with it: once over, there is nothing to attach to.
|
|
528
|
+
...(herdrSession && !final ? { herdrSession } : {}),
|
|
529
|
+
});
|
|
530
|
+
// The header's name and clock are the command's and the accounting's once the run is over.
|
|
531
|
+
if (final) lines.shift();
|
|
532
|
+
clear();
|
|
533
|
+
if (lines.length > 0) terminal.write(`${lines.join("\n")}\n`);
|
|
534
|
+
drawn = lines.length;
|
|
535
|
+
} else {
|
|
536
|
+
const view = { startedAt, now: now(), ...(herdrSession ? { herdrSession } : {}) };
|
|
537
|
+
for (const line of progressEvents(last, snapshot, view)) stderr(line);
|
|
538
|
+
}
|
|
539
|
+
last = snapshot;
|
|
540
|
+
};
|
|
541
|
+
// Lines too long for the terminal are clipped rather than wrapped, so the redraw stays exact.
|
|
542
|
+
terminal?.write("\x1b[?25l\x1b[?7l");
|
|
543
|
+
const timer = setInterval(() => tick(), terminal ? 100 : 1000);
|
|
544
|
+
return {
|
|
545
|
+
watch(started: WorkflowRunHandle<JsonValue>) {
|
|
546
|
+
handle = started;
|
|
547
|
+
tick();
|
|
548
|
+
},
|
|
549
|
+
log(text: string) {
|
|
550
|
+
if (stopped) return;
|
|
551
|
+
clear();
|
|
552
|
+
stderr(text);
|
|
553
|
+
if (terminal) tick();
|
|
554
|
+
},
|
|
555
|
+
/** The Herdr session pane agents opened in, which a blocked one's line names. */
|
|
556
|
+
placedIn(session: string) {
|
|
557
|
+
if (!stopped) herdrSession = session;
|
|
558
|
+
},
|
|
559
|
+
/** `figures`, what each stage's agents cost, once the run's usage is read. */
|
|
560
|
+
stop(figures?: ReadonlyMap<string, string>) {
|
|
561
|
+
if (stopped) return;
|
|
562
|
+
stopped = true;
|
|
563
|
+
clearInterval(timer);
|
|
564
|
+
tick(true, figures);
|
|
565
|
+
terminal?.write("\x1b[?7h\x1b[?25h");
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|