pi-agent-fleet 0.2.0 → 0.4.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/README.md +84 -23
- package/assets/canvas-node-peek.png +0 -0
- package/assets/canvas.png +0 -0
- package/package.json +16 -8
- package/src/canvas-client.tsx +669 -0
- package/src/canvas.ts +881 -0
- package/src/command.ts +247 -0
- package/src/controller.ts +302 -0
- package/src/dag.ts +72 -3
- package/src/edits.ts +107 -0
- package/src/fleet-store.ts +55 -0
- package/src/index.ts +8 -594
- package/src/insert.ts +90 -0
- package/src/model-resolution.ts +73 -0
- package/src/planner.ts +148 -0
- package/src/preferences.ts +103 -0
- package/src/prompts.ts +7 -0
- package/src/runner.ts +41 -8
- package/src/scheduler.ts +159 -8
- package/src/tools.ts +348 -0
- package/src/types.ts +7 -1
- package/src/ui.ts +51 -12
- package/src/viz.ts +3 -2
- package/src/worktree.ts +75 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import {
|
|
5
|
+
Background,
|
|
6
|
+
BackgroundVariant,
|
|
7
|
+
Controls,
|
|
8
|
+
Handle,
|
|
9
|
+
MarkerType,
|
|
10
|
+
MiniMap,
|
|
11
|
+
Position,
|
|
12
|
+
ReactFlow,
|
|
13
|
+
ReactFlowProvider,
|
|
14
|
+
useEdgesState,
|
|
15
|
+
useNodesState,
|
|
16
|
+
useReactFlow,
|
|
17
|
+
} from "@xyflow/react";
|
|
18
|
+
import type { Edge, Node, NodeProps } from "@xyflow/react";
|
|
19
|
+
|
|
20
|
+
/* ---------- payload types (mirror canvas.ts) ---------- */
|
|
21
|
+
interface CanvasNodeView {
|
|
22
|
+
id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
task: string;
|
|
25
|
+
status: string;
|
|
26
|
+
model: string;
|
|
27
|
+
effort?: string;
|
|
28
|
+
turns: number;
|
|
29
|
+
tokens: number;
|
|
30
|
+
cost_usd_estimate: number;
|
|
31
|
+
status_note?: string;
|
|
32
|
+
produced_outputs: string[];
|
|
33
|
+
outputs: Array<{ path: string; kind: string; required: boolean }>;
|
|
34
|
+
depends_on: string[];
|
|
35
|
+
iterate: boolean;
|
|
36
|
+
worktree: boolean;
|
|
37
|
+
}
|
|
38
|
+
interface CanvasPayload {
|
|
39
|
+
fleet_name: string;
|
|
40
|
+
status: string;
|
|
41
|
+
created_at: string;
|
|
42
|
+
iteration: number;
|
|
43
|
+
lgtm_streak: number;
|
|
44
|
+
paused: boolean;
|
|
45
|
+
cost_usd_estimate: number;
|
|
46
|
+
demo?: boolean;
|
|
47
|
+
empty?: boolean;
|
|
48
|
+
loop?: { gate: string; max_iterations: number; lgtm_count: number };
|
|
49
|
+
config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
|
|
50
|
+
nodes: CanvasNodeView[];
|
|
51
|
+
edges: Array<{ from: string; to: string }>;
|
|
52
|
+
iterations: Array<{ n: number; verdict: string | null; cost: number; tokens: number; duration_ms: number }>;
|
|
53
|
+
generated_at: string;
|
|
54
|
+
}
|
|
55
|
+
interface FleetInfo { name: string; status: string }
|
|
56
|
+
interface SessionEntry { role: string; text: string }
|
|
57
|
+
interface SessionResp { entries: SessionEntry[]; task?: string }
|
|
58
|
+
|
|
59
|
+
/* ---------- helpers ---------- */
|
|
60
|
+
const NODE_W = 284;
|
|
61
|
+
function statusClass(s: string): string {
|
|
62
|
+
return "st-" + s.replace(/\s+/g, "_");
|
|
63
|
+
}
|
|
64
|
+
function cssVar(name: string): string {
|
|
65
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#8b949e";
|
|
66
|
+
}
|
|
67
|
+
function minimapColor(status: string): string {
|
|
68
|
+
if (status === "completed") return cssVar("--ok");
|
|
69
|
+
if (status === "running") return cssVar("--accent");
|
|
70
|
+
if (status === "failed" || status === "contract_failed") return cssVar("--bad");
|
|
71
|
+
if (status === "killed" || status === "blocked") return cssVar("--wire");
|
|
72
|
+
return cssVar("--line");
|
|
73
|
+
}
|
|
74
|
+
function j<T>(u: string): Promise<T> {
|
|
75
|
+
return fetch(u).then((r) => {
|
|
76
|
+
if (!r.ok) throw new Error(String(r.status));
|
|
77
|
+
return r.json() as Promise<T>;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* topo-layer layout, ported from the legacy canvas */
|
|
82
|
+
function topoLayers(nodes: CanvasNodeView[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
83
|
+
const ids = nodes.map((n) => n.id);
|
|
84
|
+
const indeg: Record<string, number> = {};
|
|
85
|
+
const rev: Record<string, string[]> = {};
|
|
86
|
+
ids.forEach((i) => { indeg[i] = 0; rev[i] = []; });
|
|
87
|
+
edges.forEach((e) => { if (e.from in indeg) { indeg[e.to]++; rev[e.from].push(e.to); } });
|
|
88
|
+
const layers: string[][] = [];
|
|
89
|
+
let cur = ids.filter((i) => indeg[i] === 0);
|
|
90
|
+
const seen: Record<string, boolean> = {};
|
|
91
|
+
while (cur.length) {
|
|
92
|
+
layers.push(cur);
|
|
93
|
+
cur.forEach((i) => { seen[i] = true; });
|
|
94
|
+
const next: string[] = [];
|
|
95
|
+
cur.forEach((i) => rev[i].forEach((m) => { if (--indeg[m] === 0) next.push(m); }));
|
|
96
|
+
cur = next;
|
|
97
|
+
}
|
|
98
|
+
ids.forEach((i) => { if (!seen[i]) { layers.push([i]); seen[i] = true; } });
|
|
99
|
+
return layers;
|
|
100
|
+
}
|
|
101
|
+
function median(arr: number[]): number {
|
|
102
|
+
const s = arr.slice().sort((a, b) => a - b);
|
|
103
|
+
const m = Math.floor(s.length / 2);
|
|
104
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
105
|
+
}
|
|
106
|
+
function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
|
|
107
|
+
for (let li = 1; li < layers.length; li++) {
|
|
108
|
+
const prevPos: Record<string, number> = {};
|
|
109
|
+
layers[li - 1].forEach((id, i) => { prevPos[id] = i; });
|
|
110
|
+
const key = (id: string) => median(edges.filter((e) => e.to === id).map((e) => prevPos[e.from] ?? 0));
|
|
111
|
+
layers[li].sort((a, b) => key(a) - key(b));
|
|
112
|
+
}
|
|
113
|
+
return layers;
|
|
114
|
+
}
|
|
115
|
+
function computePositions(p: CanvasPayload): Record<string, { x: number; y: number }> {
|
|
116
|
+
const layers = reduceCrossings(topoLayers(p.nodes, p.edges), p.edges);
|
|
117
|
+
const pos: Record<string, { x: number; y: number }> = {};
|
|
118
|
+
layers.forEach((layer, li) => layer.forEach((id, ni) => { pos[id] = { x: li * 360, y: ni * 170 }; }));
|
|
119
|
+
return pos;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ---------- custom node ---------- */
|
|
123
|
+
type FleetNodeData = {
|
|
124
|
+
view: CanvasNodeView;
|
|
125
|
+
selected: boolean;
|
|
126
|
+
demo: boolean;
|
|
127
|
+
gate: boolean;
|
|
128
|
+
fleet: string | null;
|
|
129
|
+
onOpen: (id: string) => void;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
133
|
+
const { view: n, selected } = data;
|
|
134
|
+
const running = n.status === "running";
|
|
135
|
+
|
|
136
|
+
const flags: Array<{ label: string; title: string }> = [];
|
|
137
|
+
if (data.gate) flags.push({ label: "⟳ loop gate", title: "Reviewer gate: its verdict decides whether the fleet iterates again" });
|
|
138
|
+
if (n.iterate === false) flags.push({ label: "once", title: "Runs once; not re-run on loop iterations" });
|
|
139
|
+
if (n.worktree) flags.push({ label: "worktree", title: "Runs in an isolated git worktree" });
|
|
140
|
+
|
|
141
|
+
const activate = () => { data.onOpen(n.id); };
|
|
142
|
+
|
|
143
|
+
const isFailed = n.status === "failed" || n.status === "contract_failed";
|
|
144
|
+
const missingRequired = n.outputs.filter((o) => o.required && !n.produced_outputs.includes(o.path)).map((o) => o.path);
|
|
145
|
+
const failReason = isFailed && !n.status_note
|
|
146
|
+
? (missingRequired.length ? `missing required output: ${missingRequired.join(", ")}` : "worker did not complete — open for details")
|
|
147
|
+
: "";
|
|
148
|
+
const ariaLabel =
|
|
149
|
+
`${n.id}, ${n.type}, ${n.status}, ${n.turns} turns, ${(Number(n.tokens || 0) / 1000).toFixed(1)}k tokens, ` +
|
|
150
|
+
`$${Number(n.cost_usd_estimate || 0).toFixed(2)}${n.status_note ? `, ${n.status_note}` : failReason ? `, ${failReason}` : ""}`;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className={"node " + statusClass(n.status) + (selected ? " sel" : "")}
|
|
155
|
+
data-node-id={n.id}
|
|
156
|
+
role="button"
|
|
157
|
+
tabIndex={0}
|
|
158
|
+
aria-pressed={selected}
|
|
159
|
+
aria-label={ariaLabel}
|
|
160
|
+
onClick={activate}
|
|
161
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
|
|
162
|
+
>
|
|
163
|
+
<Handle type="target" position={Position.Left} />
|
|
164
|
+
<Handle type="target" position={Position.Bottom} id="loopIn" />
|
|
165
|
+
<div className="card-body">
|
|
166
|
+
<div className="node-header">
|
|
167
|
+
<span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
|
|
168
|
+
<span className="id" title={n.id}>{n.id}</span>
|
|
169
|
+
<span className="badge">{n.type}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="status-row">
|
|
172
|
+
{running && <span className="spinner" />}
|
|
173
|
+
<span className="st-word">{n.status}</span>
|
|
174
|
+
{n.effort && <><span>·</span><span>{n.effort}</span></>}
|
|
175
|
+
<span>·</span><span>{n.model}</span>
|
|
176
|
+
</div>
|
|
177
|
+
<div className="stats">
|
|
178
|
+
{(n.turns | 0)} turns · {(Number(n.tokens || 0) / 1000).toFixed(1)}k tok · ${Number(n.cost_usd_estimate || 0).toFixed(2)}
|
|
179
|
+
</div>
|
|
180
|
+
{n.outputs?.length > 0 && (
|
|
181
|
+
<div className="outputs">
|
|
182
|
+
{n.outputs.map((o, i) => (
|
|
183
|
+
<span className="out-chip" key={i}>{o.path} · {o.kind}</span>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
{flags.length > 0 && (
|
|
188
|
+
<div className="flags">
|
|
189
|
+
{flags.map((f, i) => (
|
|
190
|
+
<span key={i} title={f.title}>{i > 0 ? " · " : ""}{f.label}</span>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
{n.status_note && <div className="note">{n.status_note}</div>}
|
|
195
|
+
{failReason && <div className="fail-reason">{failReason}</div>}
|
|
196
|
+
</div>
|
|
197
|
+
<Handle type="source" position={Position.Right} />
|
|
198
|
+
<Handle type="source" position={Position.Bottom} id="loop" />
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const nodeTypes = { fleet: FleetNode };
|
|
203
|
+
|
|
204
|
+
/* ---------- side panel ---------- */
|
|
205
|
+
function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | null; demo: boolean; selected: string | null; task: string | null; onClose: () => void }) {
|
|
206
|
+
const [resp, setResp] = useState<SessionResp | null>(null);
|
|
207
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
208
|
+
// move focus into the panel on open (silently, no visible ring); restore to the node on close
|
|
209
|
+
useEffect(() => { if (selected) boxRef.current?.focus(); }, [selected]);
|
|
210
|
+
const closeAndRestore = () => {
|
|
211
|
+
const id = selected;
|
|
212
|
+
onClose();
|
|
213
|
+
if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!selected || demo) { setResp(null); return; }
|
|
218
|
+
let alive = true;
|
|
219
|
+
const load = () => {
|
|
220
|
+
const q = fleet ? "&fleet=" + encodeURIComponent(fleet) : "";
|
|
221
|
+
j<SessionResp>("/api/session/" + selected + "?tail=30" + q).then((r) => alive && setResp(r)).catch(() => {});
|
|
222
|
+
};
|
|
223
|
+
load();
|
|
224
|
+
const t = setInterval(load, 2000);
|
|
225
|
+
return () => { alive = false; clearInterval(t); };
|
|
226
|
+
}, [selected, demo, fleet]);
|
|
227
|
+
|
|
228
|
+
// close on Escape while open (and restore focus to the node)
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!selected) return;
|
|
231
|
+
const onKey = (e: KeyboardEvent) => {
|
|
232
|
+
if (e.key !== "Escape") return;
|
|
233
|
+
const id = selected;
|
|
234
|
+
onClose();
|
|
235
|
+
if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus();
|
|
236
|
+
};
|
|
237
|
+
window.addEventListener("keydown", onKey);
|
|
238
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
239
|
+
}, [selected, onClose]);
|
|
240
|
+
|
|
241
|
+
// follow the latest transcript turn when the operator is already near the bottom
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
const el = boxRef.current;
|
|
244
|
+
if (!el) return;
|
|
245
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
|
246
|
+
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
247
|
+
}, [resp]);
|
|
248
|
+
|
|
249
|
+
if (!selected) return null;
|
|
250
|
+
return (
|
|
251
|
+
<div id="side" className="open" ref={boxRef} tabIndex={-1} role="complementary" aria-label={`${selected} session`}>
|
|
252
|
+
<div className="side-head">
|
|
253
|
+
<span className="meta"># {selected} — session</span>
|
|
254
|
+
<button className="icon-btn" onClick={closeAndRestore} aria-label="Close session panel" title="Close (Esc)">×</button>
|
|
255
|
+
</div>
|
|
256
|
+
{(resp?.task || task) && (
|
|
257
|
+
<div className="taskbox-side">
|
|
258
|
+
<div className="taskbox-side-label">task</div>
|
|
259
|
+
{resp?.task || task}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{demo && <div className="msg">Session transcripts hidden in demo mode.</div>}
|
|
263
|
+
{(resp?.entries ?? []).map((e, i) => (
|
|
264
|
+
<div className="msg" key={i}>
|
|
265
|
+
<div className={"role role-" + e.role}>{e.role}</div>
|
|
266
|
+
{e.text}
|
|
267
|
+
</div>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ---------- fleet picker (custom dropdown) ---------- */
|
|
274
|
+
type FpOption = { value: string | null; name: string; status: string };
|
|
275
|
+
|
|
276
|
+
function FleetPicker({ fleets, value, onChange }: { fleets: FleetInfo[]; value: string | null; onChange: (v: string | null) => void }) {
|
|
277
|
+
const [open, setOpen] = useState(false);
|
|
278
|
+
const [q, setQ] = useState("");
|
|
279
|
+
const [active, setActive] = useState(0);
|
|
280
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
281
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
282
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
283
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!open) return;
|
|
287
|
+
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
288
|
+
document.addEventListener("mousedown", onDoc);
|
|
289
|
+
setTimeout(() => searchRef.current?.focus(), 0);
|
|
290
|
+
return () => document.removeEventListener("mousedown", onDoc);
|
|
291
|
+
}, [open]);
|
|
292
|
+
|
|
293
|
+
const filtered = useMemo(
|
|
294
|
+
() => fleets.filter((f) => f.name.toLowerCase().includes(q.toLowerCase())),
|
|
295
|
+
[fleets, q],
|
|
296
|
+
);
|
|
297
|
+
const options: FpOption[] = useMemo(
|
|
298
|
+
() => [{ value: null, name: "live fleet", status: "live" }, ...filtered.map((f) => ({ value: f.name, name: f.name, status: f.status }))],
|
|
299
|
+
[filtered],
|
|
300
|
+
);
|
|
301
|
+
const current = fleets.find((f) => f.name === value);
|
|
302
|
+
const pick = (v: string | null) => { onChange(v); setOpen(false); setQ(""); triggerRef.current?.focus(); };
|
|
303
|
+
|
|
304
|
+
// keep the active option in range and scrolled into view
|
|
305
|
+
useEffect(() => { setActive((a) => Math.min(Math.max(a, 0), Math.max(options.length - 1, 0))); }, [options.length]);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!open) return;
|
|
308
|
+
listRef.current?.querySelector<HTMLElement>(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
309
|
+
}, [active, open]);
|
|
310
|
+
|
|
311
|
+
const onKey = (e: ReactKeyboardEvent) => {
|
|
312
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, options.length - 1)); }
|
|
313
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
|
314
|
+
else if (e.key === "Home") { e.preventDefault(); setActive(0); }
|
|
315
|
+
else if (e.key === "End") { e.preventDefault(); setActive(options.length - 1); }
|
|
316
|
+
else if (e.key === "Enter") { e.preventDefault(); if (options[active]) pick(options[active].value); }
|
|
317
|
+
else if (e.key === "Escape") { e.preventDefault(); setOpen(false); triggerRef.current?.focus(); }
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const listId = "fp-listbox";
|
|
321
|
+
return (
|
|
322
|
+
<div className="fp" ref={ref}>
|
|
323
|
+
<button
|
|
324
|
+
ref={triggerRef}
|
|
325
|
+
className="fp-trigger"
|
|
326
|
+
onClick={() => setOpen((v) => !v)}
|
|
327
|
+
aria-haspopup="listbox"
|
|
328
|
+
aria-controls={listId}
|
|
329
|
+
aria-expanded={open}
|
|
330
|
+
aria-label={`Fleet: ${current ? `${current.name}, ${current.status}` : "live fleet"}. Change fleet`}
|
|
331
|
+
>
|
|
332
|
+
{current ? <span className="dot" style={{ background: minimapColor(current.status) }} aria-hidden="true" /> : <span className="dot live" aria-hidden="true" />}
|
|
333
|
+
<span className="fp-label">{current ? current.name : "live fleet"}</span>
|
|
334
|
+
{current && <span className="fp-trigger-status">{current.status}</span>}
|
|
335
|
+
<span className="fp-caret" aria-hidden="true">▾</span>
|
|
336
|
+
</button>
|
|
337
|
+
{open && (
|
|
338
|
+
<div className="fp-menu">
|
|
339
|
+
<input
|
|
340
|
+
ref={searchRef}
|
|
341
|
+
className="fp-search"
|
|
342
|
+
placeholder="filter fleets…"
|
|
343
|
+
value={q}
|
|
344
|
+
onChange={(e) => { setQ(e.target.value); setActive(0); }}
|
|
345
|
+
onKeyDown={onKey}
|
|
346
|
+
role="combobox"
|
|
347
|
+
aria-expanded="true"
|
|
348
|
+
aria-controls={listId}
|
|
349
|
+
aria-activedescendant={`fp-opt-${active}`}
|
|
350
|
+
aria-autocomplete="list"
|
|
351
|
+
aria-label="Filter fleets"
|
|
352
|
+
/>
|
|
353
|
+
<div className="fp-list" id={listId} role="listbox" ref={listRef} aria-label="Fleets">
|
|
354
|
+
{options.map((o, i) => (
|
|
355
|
+
<div
|
|
356
|
+
key={o.value ?? "__live"}
|
|
357
|
+
id={`fp-opt-${i}`}
|
|
358
|
+
data-i={i}
|
|
359
|
+
role="option"
|
|
360
|
+
aria-selected={value === o.value}
|
|
361
|
+
className={"fp-item" + (value === o.value ? " selected" : "") + (i === active ? " active" : "")}
|
|
362
|
+
onMouseEnter={() => setActive(i)}
|
|
363
|
+
onClick={() => pick(o.value)}
|
|
364
|
+
>
|
|
365
|
+
{o.value === null
|
|
366
|
+
? <span className="dot live" aria-hidden="true" />
|
|
367
|
+
: <span className="dot" style={{ background: minimapColor(o.status) }} aria-hidden="true" />}
|
|
368
|
+
<span className="fp-name" title={o.name}>{o.name}</span>
|
|
369
|
+
{o.value !== null && <span className="fp-status">{o.status}</span>}
|
|
370
|
+
</div>
|
|
371
|
+
))}
|
|
372
|
+
{options.length === 1 && q && <div className="fp-empty">no match</div>}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ---------- theme ---------- */
|
|
381
|
+
function currentTheme(): string {
|
|
382
|
+
return document.documentElement.getAttribute("data-theme") || "dark";
|
|
383
|
+
}
|
|
384
|
+
function applyTheme(t: string) {
|
|
385
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
386
|
+
document.body.className = t;
|
|
387
|
+
try { localStorage.setItem("fleet-canvas-theme", t); } catch { /* ignore */ }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ---------- flow ---------- */
|
|
391
|
+
function Flow() {
|
|
392
|
+
const qs = new URLSearchParams(location.search);
|
|
393
|
+
const [demo, setDemo] = useState(qs.get("demo") === "1");
|
|
394
|
+
const [fleet, setFleet] = useState<string | null>(qs.get("fleet"));
|
|
395
|
+
const [fleets, setFleets] = useState<FleetInfo[]>([]);
|
|
396
|
+
const [payload, setPayload] = useState<CanvasPayload | null>(null);
|
|
397
|
+
const [selected, setSelected] = useState<string | null>(qs.get("node"));
|
|
398
|
+
const [conn, setConn] = useState<string>("");
|
|
399
|
+
const [legendOpen, setLegendOpen] = useState(false);
|
|
400
|
+
const [nonce, setNonce] = useState(0);
|
|
401
|
+
const [demoFallback, setDemoFallback] = useState(false);
|
|
402
|
+
const { fitView } = useReactFlow();
|
|
403
|
+
const resetView = useCallback(() => fitView({ padding: 0.2, duration: 300 }), [fitView]);
|
|
404
|
+
|
|
405
|
+
// keyboard: F = fit graph to view, R = reset (same), ignoring typing in inputs
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
const onKey = (e: KeyboardEvent) => {
|
|
408
|
+
const t = e.target as HTMLElement;
|
|
409
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
|
410
|
+
if (e.key === "f" || e.key === "F" || e.key === "r" || e.key === "R") { e.preventDefault(); resetView(); }
|
|
411
|
+
};
|
|
412
|
+
window.addEventListener("keydown", onKey);
|
|
413
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
414
|
+
}, [resetView]);
|
|
415
|
+
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
j<{ fleets: FleetInfo[] }>("/api/fleets").then((r) => setFleets(r.fleets)).catch(() => {});
|
|
418
|
+
}, []);
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
let alive = true;
|
|
422
|
+
const loadDemo = () => j<CanvasPayload>("/api/demo")
|
|
423
|
+
.then((d) => { if (!alive) return; setConn(""); setPayload(d); setDemoFallback(!demo); })
|
|
424
|
+
.catch(() => { if (!alive) return; setPayload(null); setDemoFallback(false); });
|
|
425
|
+
const tick = () => {
|
|
426
|
+
if (demo) { loadDemo(); return; }
|
|
427
|
+
j<CanvasPayload>("/api/state" + (fleet ? "?fleet=" + encodeURIComponent(fleet) : ""))
|
|
428
|
+
.then((s) => {
|
|
429
|
+
if (!alive) return;
|
|
430
|
+
setConn("");
|
|
431
|
+
// nothing live and no specific past fleet chosen -> show the baked sample fleet
|
|
432
|
+
if (s.empty && !fleet) { loadDemo(); return; }
|
|
433
|
+
setDemoFallback(false);
|
|
434
|
+
setPayload(s.empty ? null : s);
|
|
435
|
+
})
|
|
436
|
+
.catch(() => { if (!alive) return; setConn(fleet ? "fleet unavailable" : "connection lost"); });
|
|
437
|
+
};
|
|
438
|
+
tick();
|
|
439
|
+
const t = setInterval(tick, 1500);
|
|
440
|
+
return () => { alive = false; clearInterval(t); };
|
|
441
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
442
|
+
}, [demo, fleet, nonce]);
|
|
443
|
+
|
|
444
|
+
const onOpen = useCallback((id: string) => setSelected(id), []);
|
|
445
|
+
|
|
446
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<Node<FleetNodeData>>([]);
|
|
447
|
+
const [edges, setEdges] = useEdgesState<Edge>([]);
|
|
448
|
+
const idsKey = payload ? payload.nodes.map((n) => n.id).sort().join(",") : "";
|
|
449
|
+
|
|
450
|
+
// Rebuild topology only when the node set changes; otherwise patch data in place
|
|
451
|
+
// so drag positions and measured sizes (needed by the minimap) survive polling.
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
if (!payload) { setNodes([]); return; }
|
|
454
|
+
const pos = computePositions(payload);
|
|
455
|
+
const gateId = payload.loop
|
|
456
|
+
? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
|
|
457
|
+
: undefined;
|
|
458
|
+
setNodes((prev) => {
|
|
459
|
+
const byId: Record<string, Node<FleetNodeData>> = {};
|
|
460
|
+
prev.forEach((n) => { byId[n.id] = n; });
|
|
461
|
+
return payload.nodes.map((v) => {
|
|
462
|
+
const existing = byId[v.id];
|
|
463
|
+
const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, fleet, onOpen };
|
|
464
|
+
return existing
|
|
465
|
+
? { ...existing, data }
|
|
466
|
+
: { id: v.id, type: "fleet", position: pos[v.id] ?? { x: 0, y: 0 }, data, width: NODE_W };
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
470
|
+
}, [idsKey, payload, selected, demo, fleet, onOpen, setNodes]);
|
|
471
|
+
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
if (!payload) { setEdges([]); return; }
|
|
474
|
+
const forward: Edge[] = payload.edges.map((e, i) => ({
|
|
475
|
+
id: "e" + i,
|
|
476
|
+
source: e.from,
|
|
477
|
+
target: e.to,
|
|
478
|
+
animated: payload.nodes.find((n) => n.id === e.to)?.status === "running",
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
// feedback loop: the gate node re-triggers the iterate roots each iteration
|
|
482
|
+
const loop = payload.loop;
|
|
483
|
+
const gate = loop
|
|
484
|
+
? (payload.nodes.find((n) => n.id === loop.gate) ?? payload.nodes.find((n) => n.type === loop.gate))
|
|
485
|
+
: undefined;
|
|
486
|
+
const roots = payload.nodes.filter((n) => n.iterate && n.depends_on.length === 0);
|
|
487
|
+
const looping = payload.status === "running" && !!loop && payload.iteration < loop.max_iterations && payload.lgtm_streak < loop.lgtm_count;
|
|
488
|
+
const loopEdges: Edge[] = gate && loop
|
|
489
|
+
? roots.map((r, i) => ({
|
|
490
|
+
id: "loop" + i,
|
|
491
|
+
source: gate.id,
|
|
492
|
+
target: r.id,
|
|
493
|
+
sourceHandle: "loop",
|
|
494
|
+
targetHandle: "loopIn",
|
|
495
|
+
type: "smoothstep",
|
|
496
|
+
pathOptions: { borderRadius: 12 },
|
|
497
|
+
animated: looping,
|
|
498
|
+
zIndex: 0,
|
|
499
|
+
label: i === 0 ? `iterate ${payload.iteration}/${loop.max_iterations}` : undefined,
|
|
500
|
+
labelStyle: { fill: "var(--warn)", fontSize: 11, fontWeight: 600 },
|
|
501
|
+
labelBgStyle: { fill: "var(--bg)", fillOpacity: 0.9 },
|
|
502
|
+
labelBgPadding: [4, 2] as [number, number],
|
|
503
|
+
labelBgBorderRadius: 4,
|
|
504
|
+
style: { stroke: "var(--warn)", strokeDasharray: "5 4", strokeWidth: 1.5, opacity: 0.65 },
|
|
505
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: "var(--warn)" } as Edge["markerEnd"],
|
|
506
|
+
}))
|
|
507
|
+
: [];
|
|
508
|
+
setEdges([...loopEdges, ...forward]);
|
|
509
|
+
}, [payload, setEdges]);
|
|
510
|
+
|
|
511
|
+
const done = payload ? payload.nodes.filter((n) => n.status === "completed").length : 0;
|
|
512
|
+
const failed = payload ? payload.nodes.filter((n) => n.status === "failed" || n.status === "contract_failed") : [];
|
|
513
|
+
const running = payload ? payload.nodes.filter((n) => n.status === "running").length : 0;
|
|
514
|
+
const cycleFailed = () => {
|
|
515
|
+
if (!failed.length) return;
|
|
516
|
+
const cur = failed.findIndex((f) => f.id === selected);
|
|
517
|
+
setSelected(failed[(cur + 1) % failed.length].id);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<>
|
|
522
|
+
<header>
|
|
523
|
+
<span className="name">fleet canvas</span>
|
|
524
|
+
<FleetPicker
|
|
525
|
+
fleets={fleets}
|
|
526
|
+
value={fleet}
|
|
527
|
+
onChange={(v) => { setFleet(v); setSelected(null); try { localStorage.setItem("fleet-canvas-fleet", v ?? ""); } catch { /* ignore */ } }}
|
|
528
|
+
/>
|
|
529
|
+
<span id="hdr">
|
|
530
|
+
{conn ? (
|
|
531
|
+
<span className="conn">
|
|
532
|
+
<span className="pill pill-bad">{conn === "connection lost" ? "Canvas server unreachable" : "Fleet unavailable"}</span>
|
|
533
|
+
<button className="link-btn" onClick={() => { setConn(""); setNonce((n) => n + 1); }}>Retry</button>
|
|
534
|
+
</span>
|
|
535
|
+
) : payload ? (
|
|
536
|
+
<>
|
|
537
|
+
<span className="fleet-title">{payload.fleet_name}</span>
|
|
538
|
+
{demoFallback
|
|
539
|
+
? <span className="pill" title="No fleet is live — showing a sample fleet">sample</span>
|
|
540
|
+
: payload.demo && <span className="pill">demo</span>}
|
|
541
|
+
<span className={"pill status-" + payload.status}>{payload.status}</span>
|
|
542
|
+
{payload.paused && <span className="pill">paused</span>}
|
|
543
|
+
{running > 0 && <span className="stat"><span className="dot dot-run" aria-hidden="true" />{running} running</span>}
|
|
544
|
+
<span className="stat">{done}/{payload.nodes.length} done</span>
|
|
545
|
+
{failed.length > 0 && (
|
|
546
|
+
<button
|
|
547
|
+
className="pill pill-bad pill-btn"
|
|
548
|
+
onClick={cycleFailed}
|
|
549
|
+
title={failed.length > 1 ? "Jump to next failed worker" : "Jump to the failed worker"}
|
|
550
|
+
>⚠ {failed.length} failed</button>
|
|
551
|
+
)}
|
|
552
|
+
<span className="stat" title="Estimated spend so far">${payload.cost_usd_estimate.toFixed(2)}</span>
|
|
553
|
+
{payload.loop && (
|
|
554
|
+
<span className="stat" title={`Reviewer-gated loop: re-runs up to ${payload.loop.max_iterations}× until ${payload.loop.lgtm_count} consecutive LGTM verdicts`}>
|
|
555
|
+
iter {payload.iteration}/{payload.loop.max_iterations} · streak {payload.lgtm_streak}/{payload.loop.lgtm_count}
|
|
556
|
+
</span>
|
|
557
|
+
)}
|
|
558
|
+
</>
|
|
559
|
+
) : <span className="stat meta">no live fleet</span>}
|
|
560
|
+
</span>
|
|
561
|
+
<span className="spacer" />
|
|
562
|
+
<button onClick={resetView} title="Fit graph to view (F)">reset view</button>
|
|
563
|
+
<button className={legendOpen ? "toggled" : ""} aria-pressed={legendOpen} onClick={() => setLegendOpen((v) => !v)}>legend</button>
|
|
564
|
+
<button aria-pressed={demo} onClick={() => { setDemo((v) => !v); setSelected(null); }}>{demo ? "live" : "demo"}</button>
|
|
565
|
+
<button onClick={() => applyTheme(currentTheme() === "light" ? "dark" : "light")}>theme</button>
|
|
566
|
+
</header>
|
|
567
|
+
<main>
|
|
568
|
+
<div id="stage">
|
|
569
|
+
<ReactFlow
|
|
570
|
+
key={(demo ? "demo" : "live") + ":" + (fleet ?? "") + ":" + (demoFallback ? "s" : "")}
|
|
571
|
+
nodes={nodes}
|
|
572
|
+
edges={edges}
|
|
573
|
+
onNodesChange={onNodesChange}
|
|
574
|
+
nodeTypes={nodeTypes}
|
|
575
|
+
fitView
|
|
576
|
+
fitViewOptions={{ padding: 0.2 }}
|
|
577
|
+
minZoom={0.2}
|
|
578
|
+
maxZoom={2.5}
|
|
579
|
+
proOptions={{ hideAttribution: true }}
|
|
580
|
+
defaultEdgeOptions={{ type: "smoothstep", pathOptions: { borderRadius: 18 } }}
|
|
581
|
+
>
|
|
582
|
+
<Background variant={BackgroundVariant.Dots} gap={22} size={1} />
|
|
583
|
+
<Controls showInteractive={false} />
|
|
584
|
+
<MiniMap pannable zoomable nodeColor={(n) => minimapColor((n.data as FleetNodeData).view.status)} />
|
|
585
|
+
</ReactFlow>
|
|
586
|
+
{legendOpen && <Legend hasLoop={!!payload?.loop} onClose={() => setLegendOpen(false)} />}
|
|
587
|
+
{!payload && !conn && !demo && <EmptyState hasFleets={fleets.length > 0} onDemo={() => setDemo(true)} />}
|
|
588
|
+
</div>
|
|
589
|
+
<SidePanel
|
|
590
|
+
fleet={fleet}
|
|
591
|
+
demo={demo || demoFallback}
|
|
592
|
+
selected={selected}
|
|
593
|
+
task={payload?.nodes.find((n) => n.id === selected)?.task ?? null}
|
|
594
|
+
onClose={() => setSelected(null)}
|
|
595
|
+
/>
|
|
596
|
+
</main>
|
|
597
|
+
</>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/* ---------- legend ---------- */
|
|
602
|
+
const LEGEND_ROWS: Array<{ status: string; label: string }> = [
|
|
603
|
+
{ status: "running", label: "running" },
|
|
604
|
+
{ status: "completed", label: "completed" },
|
|
605
|
+
{ status: "failed", label: "failed / contract failed" },
|
|
606
|
+
{ status: "blocked", label: "blocked / killed" },
|
|
607
|
+
{ status: "pending", label: "pending / ready" },
|
|
608
|
+
];
|
|
609
|
+
function Legend({ hasLoop, onClose }: { hasLoop: boolean; onClose: () => void }) {
|
|
610
|
+
return (
|
|
611
|
+
<div className="legend" role="region" aria-label="Status legend">
|
|
612
|
+
<div className="legend-head">
|
|
613
|
+
<span>status</span>
|
|
614
|
+
<button className="icon-btn sm" onClick={onClose} aria-label="Close legend" title="Close">×</button>
|
|
615
|
+
</div>
|
|
616
|
+
{LEGEND_ROWS.map((r) => (
|
|
617
|
+
<div className="legend-row" key={r.status}>
|
|
618
|
+
<span className={"swatch " + statusClass(r.status)} aria-hidden="true" />
|
|
619
|
+
<span>{r.label}</span>
|
|
620
|
+
</div>
|
|
621
|
+
))}
|
|
622
|
+
{hasLoop && (
|
|
623
|
+
<div className="legend-row legend-loop">
|
|
624
|
+
<span className="swatch-line" aria-hidden="true" />
|
|
625
|
+
<span>iteration loop (gate → roots)</span>
|
|
626
|
+
</div>
|
|
627
|
+
)}
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/* ---------- empty state ---------- */
|
|
633
|
+
function EmptyState({ hasFleets, onDemo }: { hasFleets: boolean; onDemo: () => void }) {
|
|
634
|
+
return (
|
|
635
|
+
<div className="empty">
|
|
636
|
+
<div className="empty-title">No fleet running</div>
|
|
637
|
+
<p className="empty-body">
|
|
638
|
+
This canvas shows a live DAG of agent workers — status, tokens, cost, and reviewer-gated iteration loops — as a fleet runs.
|
|
639
|
+
</p>
|
|
640
|
+
<ul className="empty-steps">
|
|
641
|
+
<li>Start a fleet from pi with <code>/fleet</code>, then it appears here automatically.</li>
|
|
642
|
+
{hasFleets
|
|
643
|
+
? <li>Or open a past run from the <strong>fleet selector</strong> at the top left.</li>
|
|
644
|
+
: <li>Past runs will be listed in the <strong>fleet selector</strong> once you have some.</li>}
|
|
645
|
+
</ul>
|
|
646
|
+
<button className="empty-cta" onClick={onDemo}>Explore a demo fleet</button>
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/* ---------- boot ---------- */
|
|
652
|
+
(function initTheme() {
|
|
653
|
+
const qs = new URLSearchParams(location.search);
|
|
654
|
+
let t = qs.get("theme");
|
|
655
|
+
if (t !== "light" && t !== "dark") {
|
|
656
|
+
try { t = localStorage.getItem("fleet-canvas-theme"); } catch { /* ignore */ }
|
|
657
|
+
if (t !== "light" && t !== "dark" && window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches) t = "light";
|
|
658
|
+
}
|
|
659
|
+
applyTheme(t === "light" || t === "dark" ? t : "dark");
|
|
660
|
+
})();
|
|
661
|
+
|
|
662
|
+
const root = createRoot(document.getElementById("root")!);
|
|
663
|
+
root.render(
|
|
664
|
+
<StrictMode>
|
|
665
|
+
<ReactFlowProvider>
|
|
666
|
+
<Flow />
|
|
667
|
+
</ReactFlowProvider>
|
|
668
|
+
</StrictMode>,
|
|
669
|
+
);
|