agent-horde 0.1.0 → 0.1.2
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/README.md +2 -2
- package/docs/REFERENCE.md +1 -1
- package/package.json +1 -1
- package/src/run-graph-svg.ts +88 -17
- package/src/run-graph.ts +13 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://www.npmjs.com/package/agent-horde"><img src="https://img.shields.io/npm/v/agent-horde" alt="npm version"></a>
|
|
9
|
-
<a href="LICENSE"><img src="https://img.shields.io/
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
10
10
|
<img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node 20 or newer">
|
|
11
11
|
<a href="https://pi.dev/packages"><img src="https://img.shields.io/badge/pi-package-6f42c1" alt="pi package"></a>
|
|
12
12
|
<a href="https://github.com/eliyastein/agent-horde/issues"><img src="https://img.shields.io/github/issues/eliyastein/agent-horde" alt="open issues"></a>
|
|
@@ -88,7 +88,7 @@ pi install npm:agent-horde
|
|
|
88
88
|
Or from the repository, pinned to a tag:
|
|
89
89
|
|
|
90
90
|
```
|
|
91
|
-
pi install git:github.com/eliyastein/agent-horde@v0.1.
|
|
91
|
+
pi install git:github.com/eliyastein/agent-horde@v0.1.2
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
|
package/docs/REFERENCE.md
CHANGED
|
@@ -81,7 +81,7 @@ Every page has a JSON twin under `/api/<same path>`.
|
|
|
81
81
|
| `/runs/<id>/sessions[/<file>[/<i>]]` | session files not mapped to a member (runs older than per-member session dirs) |
|
|
82
82
|
| `/runs/<id>/files/<NAME>` | any run document; 404 until written |
|
|
83
83
|
| `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
|
|
84
|
-
| `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`) |
|
|
84
|
+
| `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
|
|
85
85
|
| `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
|
|
86
86
|
| `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them |
|
|
87
87
|
|
package/package.json
CHANGED
package/src/run-graph-svg.ts
CHANGED
|
@@ -21,6 +21,9 @@ const HEADER_H = 36;
|
|
|
21
21
|
const AXIS_H = 18;
|
|
22
22
|
const TICK_STEPS_MS = [5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 900_000, 1_800_000, 3_600_000, 7_200_000];
|
|
23
23
|
const MAX_TICKS = 14;
|
|
24
|
+
/** Share of the plot for everything after the last member exits, when that tail would otherwise dominate. */
|
|
25
|
+
const TAIL_FRACTION = 0.15;
|
|
26
|
+
const TAIL_AXIS_H = 30;
|
|
24
27
|
|
|
25
28
|
const TONE_COLORS: Record<EdgeTone, string> = {
|
|
26
29
|
structure: "#000", collaboration: "#1f5fbf", verification: "#1a7f37", negative: "#c62828", lifecycle: "#888",
|
|
@@ -57,13 +60,49 @@ interface LaneBand {
|
|
|
57
60
|
interface Layout {
|
|
58
61
|
width: number;
|
|
59
62
|
height: number;
|
|
63
|
+
axisH: number;
|
|
60
64
|
t0: number;
|
|
61
65
|
t1: number;
|
|
66
|
+
cut?: number | undefined;
|
|
62
67
|
bands: LaneBand[];
|
|
63
68
|
placed: Map<string, Placed>;
|
|
64
69
|
x: (t: number) => number;
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
export interface TimeScale {
|
|
73
|
+
t0: number;
|
|
74
|
+
t1: number;
|
|
75
|
+
/** Where the axis switches from real time to the compressed tail; absent when the axis is linear. */
|
|
76
|
+
cut?: number | undefined;
|
|
77
|
+
x: (t: number) => number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function unit(value: number): number {
|
|
81
|
+
return Math.min(Math.max(0, value), 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Real time until the last member exits. The synthesis that follows can outlast discovery, so it
|
|
86
|
+
* gets a fixed share of the width instead of squeezing the members' work into the left margin.
|
|
87
|
+
* The cut applies as soon as a parent phase starts after the members are done, so a live run's
|
|
88
|
+
* main axis freezes the moment synthesis begins; without such a phase only a long tail triggers it.
|
|
89
|
+
*/
|
|
90
|
+
export function graphTimeScale(graph: RunGraph, window: GraphWindow): TimeScale {
|
|
91
|
+
const { t0, t1 } = windowOf(graph, window);
|
|
92
|
+
const plotW = window.width;
|
|
93
|
+
const cut = graph.membersEnd;
|
|
94
|
+
const phaseAfterMembers = graph.phases.some((phase) => phase.from >= cut && phase.from < t1);
|
|
95
|
+
const longTail = (t1 - cut) / (t1 - t0) > TAIL_FRACTION;
|
|
96
|
+
if (cut <= t0 || cut >= t1 || (!phaseAfterMembers && !longTail)) {
|
|
97
|
+
return { t0, t1, x: (t) => LABEL_W + unit((t - t0) / (t1 - t0)) * plotW };
|
|
98
|
+
}
|
|
99
|
+
const mainW = plotW * (1 - TAIL_FRACTION);
|
|
100
|
+
const tailW = plotW - mainW;
|
|
101
|
+
const x = (t: number): number =>
|
|
102
|
+
t <= cut ? LABEL_W + unit((t - t0) / (cut - t0)) * mainW : LABEL_W + mainW + unit((t - cut) / (t1 - cut)) * tailW;
|
|
103
|
+
return { t0, t1, cut, x };
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
function nodeWidth(node: GraphNode): number {
|
|
68
107
|
return node.marker ? DOT_R * 2 : Math.max(MIN_BOX_W, Math.round(node.label.length * CHAR_W) + 8);
|
|
69
108
|
}
|
|
@@ -75,13 +114,16 @@ function windowOf(graph: RunGraph, window: GraphWindow): { t0: number; t1: numbe
|
|
|
75
114
|
return { t0, t1: requested > t0 ? requested : t0 + 1_000 };
|
|
76
115
|
}
|
|
77
116
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Rows within a lane: a node takes the first row whose last occupant ends before it starts. A box
|
|
119
|
+
* anchored near the end of the axis is pulled left so its right edge stays inside the plot.
|
|
120
|
+
*/
|
|
121
|
+
function packLane(nodes: GraphNode[], x: (t: number) => number, top: number, plotEnd: number): { placed: Placed[]; rows: number } {
|
|
80
122
|
const rowEnds: number[] = [];
|
|
81
123
|
const placed: Placed[] = [];
|
|
82
124
|
for (const node of nodes) {
|
|
83
125
|
const w = nodeWidth(node);
|
|
84
|
-
const left = node.marker ? x(node.at) - DOT_R : x(node.at);
|
|
126
|
+
const left = node.marker ? x(node.at) - DOT_R : Math.min(x(node.at), plotEnd - w);
|
|
85
127
|
let row = rowEnds.findIndex((end) => end + GAP <= left);
|
|
86
128
|
if (row === -1) row = rowEnds.length;
|
|
87
129
|
rowEnds[row] = left + w;
|
|
@@ -91,22 +133,22 @@ function packLane(nodes: GraphNode[], x: (t: number) => number, top: number): {
|
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
function layoutGraph(graph: RunGraph, window: GraphWindow): Layout {
|
|
94
|
-
const { t0, t1 } =
|
|
136
|
+
const { t0, t1, cut, x } = graphTimeScale(graph, window);
|
|
95
137
|
const plotW = window.width;
|
|
96
|
-
const x = (t: number): number => LABEL_W + Math.min(Math.max(0, (t - t0) / (t1 - t0)), 1) * plotW;
|
|
97
138
|
const visible = graph.nodes.filter((node) => node.at >= t0 && node.at <= t1).sort((a, b) => a.at - b.at);
|
|
98
139
|
const placed = new Map<string, Placed>();
|
|
99
140
|
const bands: LaneBand[] = [];
|
|
100
141
|
let y = HEADER_H;
|
|
101
142
|
for (const lane of graph.lanes) {
|
|
102
143
|
const own = visible.filter((node) => node.lane === lane.id);
|
|
103
|
-
const packed = packLane(own, x, y);
|
|
144
|
+
const packed = packLane(own, x, y, LABEL_W + plotW);
|
|
104
145
|
for (const item of packed.placed) placed.set(item.node.id, item);
|
|
105
146
|
const h = PAD * 2 + packed.rows * ROW_H;
|
|
106
147
|
bands.push({ lane, y, h });
|
|
107
148
|
y += h;
|
|
108
149
|
}
|
|
109
|
-
|
|
150
|
+
const axisH = cut === undefined ? AXIS_H : TAIL_AXIS_H;
|
|
151
|
+
return { width: LABEL_W + plotW + PAD, height: y + axisH, axisH, t0, t1, cut, bands, placed, x };
|
|
110
152
|
}
|
|
111
153
|
|
|
112
154
|
function renderDefs(): string {
|
|
@@ -117,8 +159,16 @@ function renderDefs(): string {
|
|
|
117
159
|
return `<defs>${markers.join("")}</defs>`;
|
|
118
160
|
}
|
|
119
161
|
|
|
162
|
+
/** The full label if the band is wide enough, else the bare id, else nothing; the band's title keeps the full text. */
|
|
163
|
+
function phaseText(id: string, label: string, bandW: number): string | undefined {
|
|
164
|
+
const fits = (text: string): boolean => text.length * CHAR_W + 6 <= bandW;
|
|
165
|
+
if (fits(label)) return label;
|
|
166
|
+
if (fits(id)) return id;
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
function renderPhases(graph: RunGraph, layout: Layout): string {
|
|
121
|
-
const bottom = layout.height -
|
|
171
|
+
const bottom = layout.height - layout.axisH;
|
|
122
172
|
return graph.phases
|
|
123
173
|
.map((phase, index) => {
|
|
124
174
|
const from = Math.max(phase.from, layout.t0);
|
|
@@ -127,9 +177,10 @@ function renderPhases(graph: RunGraph, layout: Layout): string {
|
|
|
127
177
|
const x0 = layout.x(from);
|
|
128
178
|
const w = Math.max(1, layout.x(to) - x0);
|
|
129
179
|
const label = `${phase.id} +${formatElapsed(phase.from - graph.start)} to +${formatElapsed(phase.to - graph.start)}`;
|
|
180
|
+
const text = phaseText(phase.id, label, w);
|
|
130
181
|
return (
|
|
131
|
-
`<rect class="phase${index % 2 ? " phase-alt" : ""}" x="${px(x0)}" y="${HEADER_H - 16}" width="${px(w)}" height="${px(bottom - HEADER_H + 16)}"
|
|
132
|
-
`<text x="${px(x0 + 3)}" y="${HEADER_H - 5}" class="phase-label"
|
|
182
|
+
`<rect class="phase${index % 2 ? " phase-alt" : ""}" x="${px(x0)}" y="${HEADER_H - 16}" width="${px(w)}" height="${px(bottom - HEADER_H + 16)}"><title>${e(label)}</title></rect>` +
|
|
183
|
+
(text === undefined ? "" : `<text x="${px(x0 + 3)}" y="${HEADER_H - 5}" class="phase-label">${e(text)}</text>`)
|
|
133
184
|
);
|
|
134
185
|
})
|
|
135
186
|
.join("");
|
|
@@ -146,17 +197,36 @@ function renderLanes(layout: Layout): string {
|
|
|
146
197
|
.join("");
|
|
147
198
|
}
|
|
148
199
|
|
|
200
|
+
function tick(graph: RunGraph, layout: Layout, t: number, cls: string): string {
|
|
201
|
+
const x = layout.x(t);
|
|
202
|
+
const y = layout.height - layout.axisH;
|
|
203
|
+
const anchor = t >= layout.t1 ? ` text-anchor="end" x="${px(x - 2)}"` : ` x="${px(x + 2)}"`;
|
|
204
|
+
return (
|
|
205
|
+
`<line class="tick" x1="${px(x)}" y1="${HEADER_H}" x2="${px(x)}" y2="${px(y)}"/>` +
|
|
206
|
+
`<text${anchor} y="${px(y + 12)}" class="${cls}">+${e(formatElapsed(t - graph.start))}</text>`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Regular ticks up to the cut; past it only phase boundaries and the end, since the scale differs. */
|
|
149
211
|
function renderAxis(graph: RunGraph, layout: Layout): string {
|
|
150
|
-
const
|
|
212
|
+
const mainEnd = layout.cut ?? layout.t1;
|
|
213
|
+
const span = mainEnd - layout.t0;
|
|
151
214
|
const step = TICK_STEPS_MS.find((candidate) => span / candidate <= MAX_TICKS) ?? TICK_STEPS_MS[TICK_STEPS_MS.length - 1]!;
|
|
152
215
|
const firstTick = graph.start + Math.ceil((layout.t0 - graph.start) / step) * step;
|
|
153
216
|
const parts: string[] = [];
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
217
|
+
for (let t = firstTick; t <= mainEnd; t += step) parts.push(tick(graph, layout, t, "tick-label"));
|
|
218
|
+
if (layout.cut === undefined) return parts.join("");
|
|
219
|
+
const bottom = layout.height - layout.axisH;
|
|
220
|
+
const cx = layout.x(layout.cut);
|
|
221
|
+
parts.push(`<line class="cut" x1="${px(cx)}" y1="${HEADER_H - 16}" x2="${px(cx)}" y2="${px(bottom)}"/>`);
|
|
222
|
+
const note = `members done +${formatElapsed(layout.cut - graph.start)}; the rest is compressed`;
|
|
223
|
+
parts.push(`<text x="${px(cx - 3)}" y="${px(bottom + 24)}" text-anchor="end" class="tick-label tail">${e(note)}</text>`);
|
|
224
|
+
// One tick per distinct second: phase boundaries and the end often sit milliseconds apart.
|
|
225
|
+
const { cut, t1 } = layout;
|
|
226
|
+
const tailTicks = new Map<string, number>();
|
|
227
|
+
const candidates = graph.phases.flatMap((phase) => [phase.from, phase.to]).filter((t) => t > cut && t < t1);
|
|
228
|
+
for (const t of [...candidates, layout.t1]) tailTicks.set(formatElapsed(t - graph.start), t);
|
|
229
|
+
for (const t of [...tailTicks.values()].sort((a, b) => a - b)) parts.push(tick(graph, layout, t, "tick-label tail"));
|
|
160
230
|
return parts.join("");
|
|
161
231
|
}
|
|
162
232
|
|
|
@@ -216,6 +286,7 @@ export const GRAPH_CSS =
|
|
|
216
286
|
".graph{overflow:auto;border:1px solid #000;margin:4px 0;background:#fff}" +
|
|
217
287
|
"svg text{fill:#000}svg .phase{fill:#000;opacity:.04}svg .phase-alt{opacity:.09}svg .phase-label{font-weight:bold}" +
|
|
218
288
|
"svg .lane-line{stroke:#bbb}svg .tick{stroke:#e5e5e5}svg .tick-label{fill:#555}" +
|
|
289
|
+
"svg .cut{stroke:#999;stroke-width:1.5;stroke-dasharray:4 3}svg .tail{font-style:italic}" +
|
|
219
290
|
"svg .n{stroke:#000;stroke-width:1}svg .n-prior{stroke-dasharray:3 2}" +
|
|
220
291
|
"svg .m{stroke:#333;stroke-width:1}svg .m-rejected{stroke:#c62828;stroke-width:2}svg .m-noop{stroke-dasharray:2 1}" +
|
|
221
292
|
"svg a:hover .n,svg a:hover .m{stroke-width:2.5}svg a:hover text{text-decoration:underline}" +
|
package/src/run-graph.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface RunGraph {
|
|
|
54
54
|
runId: string;
|
|
55
55
|
start: number;
|
|
56
56
|
end: number;
|
|
57
|
+
/** When the last member activity happened; equals `end` while members still run. */
|
|
58
|
+
membersEnd: number;
|
|
57
59
|
lanes: GraphLane[];
|
|
58
60
|
phases: GraphPhase[];
|
|
59
61
|
nodes: GraphNode[];
|
|
@@ -553,6 +555,16 @@ function phasesOf(draft: Draft, nodes: GraphNode[], start: number, end: number):
|
|
|
553
555
|
return phases;
|
|
554
556
|
}
|
|
555
557
|
|
|
558
|
+
const TERMINAL_STATUSES = new Set(["done", "failed", "stopped"]);
|
|
559
|
+
|
|
560
|
+
/** The last member-lane event once every member has exited; the whole span while any still runs. */
|
|
561
|
+
function membersEndOf(nodes: GraphNode[], members: GraphMember[], live: boolean, start: number, end: number): number {
|
|
562
|
+
const running = live && members.some((m) => m.status !== undefined && !TERMINAL_STATUSES.has(m.status));
|
|
563
|
+
if (running) return end;
|
|
564
|
+
const times = nodes.filter((n) => n.lane !== "parent" && !n.placeholder).map((n) => n.at);
|
|
565
|
+
return Math.min(end, Math.max(start, ...times));
|
|
566
|
+
}
|
|
567
|
+
|
|
556
568
|
/** Project a run's events (plus what the parent knows about its members) into lanes, phases, nodes, and edges. */
|
|
557
569
|
export function buildRunGraph(input: RunGraphInput): RunGraph {
|
|
558
570
|
const events = input.events.filter((event) => event.outcome !== "intent");
|
|
@@ -570,6 +582,7 @@ export function buildRunGraph(input: RunGraphInput): RunGraph {
|
|
|
570
582
|
runId: input.runId,
|
|
571
583
|
start,
|
|
572
584
|
end,
|
|
585
|
+
membersEnd: membersEndOf(nodes, members, input.now !== undefined, start, end),
|
|
573
586
|
lanes: lanesOf(draft, nodes, members),
|
|
574
587
|
phases: phasesOf(draft, nodes, start, end),
|
|
575
588
|
nodes,
|