@signal9/era-ui 4.6.0 → 4.7.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/dist/ai/error-panel/error-panel.svelte +1 -30
- package/dist/ai/index.d.ts +1 -0
- package/dist/ai/index.js +1 -0
- package/dist/ai/runtime-feed/runtime-feed-item.svelte +4 -1
- package/dist/ai/swarm/context.d.ts +26 -0
- package/dist/ai/swarm/context.js +14 -0
- package/dist/ai/swarm/index.d.ts +16 -0
- package/dist/ai/swarm/index.js +15 -0
- package/dist/ai/swarm/swarm-agent.svelte +132 -0
- package/dist/ai/swarm/swarm-agent.svelte.d.ts +16 -0
- package/dist/ai/swarm/swarm-artifact.svelte +45 -0
- package/dist/ai/swarm/swarm-artifact.svelte.d.ts +8 -0
- package/dist/ai/swarm/swarm-artifacts.svelte +42 -0
- package/dist/ai/swarm/swarm-artifacts.svelte.d.ts +15 -0
- package/dist/ai/swarm/swarm-barrier.svelte +63 -0
- package/dist/ai/swarm/swarm-barrier.svelte.d.ts +10 -0
- package/dist/ai/swarm/swarm-counts.svelte +52 -0
- package/dist/ai/swarm/swarm-counts.svelte.d.ts +13 -0
- package/dist/ai/swarm/swarm-dot.svelte +52 -0
- package/dist/ai/swarm/swarm-dot.svelte.d.ts +23 -0
- package/dist/ai/swarm/swarm-footer.svelte +63 -0
- package/dist/ai/swarm/swarm-footer.svelte.d.ts +16 -0
- package/dist/ai/swarm/swarm-grid.svelte +51 -0
- package/dist/ai/swarm/swarm-grid.svelte.d.ts +14 -0
- package/dist/ai/swarm/swarm-header.svelte +64 -0
- package/dist/ai/swarm/swarm-header.svelte.d.ts +14 -0
- package/dist/ai/swarm/swarm-node.svelte +258 -0
- package/dist/ai/swarm/swarm-node.svelte.d.ts +32 -0
- package/dist/ai/swarm/swarm-role.svelte +71 -0
- package/dist/ai/swarm/swarm-role.svelte.d.ts +17 -0
- package/dist/ai/swarm/swarm-root.svelte +55 -0
- package/dist/ai/swarm/swarm-root.svelte.d.ts +16 -0
- package/dist/ai/swarm/swarm-tree.svelte +201 -0
- package/dist/ai/swarm/swarm-tree.svelte.d.ts +25 -0
- package/dist/ai/swarm/swarm.d.ts +218 -0
- package/dist/ai/swarm/swarm.js +187 -0
- package/dist/ai/swarm/swarm.svelte.d.ts +59 -0
- package/dist/ai/swarm/swarm.svelte.js +125 -0
- package/dist/apps/llm-shell/index.d.ts +1 -1
- package/dist/apps/llm-shell/llm-shell.svelte +65 -1
- package/dist/apps/llm-shell/llm-shell.svelte.d.ts +7 -0
- package/dist/apps/llm-shell/types.d.ts +22 -0
- package/dist/dev/audit/audits/clipped-glyphs.js +7 -0
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/llm-shell.md +1 -1
- package/dist/generated-docs/llms-full.txt +53 -11
- package/dist/generated-docs/llms.txt +1 -0
- package/dist/generated-docs/manifest.json +16 -5
- package/dist/generated-docs/utilities.json +12 -2
- package/dist/generated-docs/utilities.md +51 -10
- package/dist/styles/index.css +63 -9
- package/dist/styles/themes.css +27 -15
- package/dist/ui/step/step-content.svelte +5 -34
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { cn } from '../../utils/index.js';
|
|
5
|
+
import Node from './swarm-node.svelte';
|
|
6
|
+
import { buildSwarmTree, type SwarmAgent, type SwarmNode } from './swarm.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
ref = $bindable(null),
|
|
10
|
+
agents = [],
|
|
11
|
+
expandable,
|
|
12
|
+
detail,
|
|
13
|
+
meta,
|
|
14
|
+
empty,
|
|
15
|
+
onToggle,
|
|
16
|
+
class: className,
|
|
17
|
+
...restProps
|
|
18
|
+
}: Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
19
|
+
ref?: HTMLDivElement | null;
|
|
20
|
+
/** The swarm's agents, flat. The tree is built from each one's `parentId`. */
|
|
21
|
+
agents?: SwarmAgent[];
|
|
22
|
+
/**
|
|
23
|
+
* Which rows can expand beyond their children. Defaults to "every row,
|
|
24
|
+
* once a detail snippet exists" — pass a predicate when the detail is empty
|
|
25
|
+
* for some agents, so those rows don't get a chevron that opens nothing.
|
|
26
|
+
*/
|
|
27
|
+
expandable?: (agent: SwarmAgent) => boolean;
|
|
28
|
+
/** Expanded body for a row — artifacts, a transcript, tool output. */
|
|
29
|
+
detail?: Snippet<[SwarmAgent]>;
|
|
30
|
+
/** Trailing row content, rendered before the elapsed readout. */
|
|
31
|
+
meta?: Snippet<[SwarmAgent]>;
|
|
32
|
+
/** Replaces the built-in "no agents yet" line. */
|
|
33
|
+
empty?: Snippet;
|
|
34
|
+
/** Fired when a row expands or collapses, with that agent. */
|
|
35
|
+
onToggle?: (agent: SwarmAgent, open: boolean) => void;
|
|
36
|
+
} = $props();
|
|
37
|
+
|
|
38
|
+
const tree = $derived(buildSwarmTree(agents));
|
|
39
|
+
|
|
40
|
+
/** One answer for the row, the keyboard walk, and the chevron — they must agree. */
|
|
41
|
+
const nodeExpandable = (node: SwarmNode) =>
|
|
42
|
+
node.children.length > 0 || (detail != null && (expandable?.(node.agent) ?? true));
|
|
43
|
+
|
|
44
|
+
// Rows default to OPEN and remember only what the user closed. A swarm streams
|
|
45
|
+
// in — an agent that appears three seconds from now has no entry here, and
|
|
46
|
+
// defaulting to open is what makes it visible the moment it lands instead of
|
|
47
|
+
// hiding new work inside a collapsed parent.
|
|
48
|
+
let closed = $state<Record<string, boolean>>({});
|
|
49
|
+
const isOpen = (id: string) => !closed[id];
|
|
50
|
+
|
|
51
|
+
function setOpen(agent: SwarmAgent, open: boolean) {
|
|
52
|
+
closed = { ...closed, [agent.id]: !open };
|
|
53
|
+
onToggle?.(agent, open);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The visible rows, in the order the eye reads them — which is exactly the
|
|
58
|
+
* order the arrow keys have to move through, so navigation is derived from
|
|
59
|
+
* the same walk that renders rather than from the DOM.
|
|
60
|
+
*/
|
|
61
|
+
type Row = { id: string; parentId: string | null; expandable: boolean; open: boolean };
|
|
62
|
+
const rows = $derived.by(() => {
|
|
63
|
+
const out: Row[] = [];
|
|
64
|
+
const walk = (nodes: SwarmNode[], parentId: string | null) => {
|
|
65
|
+
for (const node of nodes) {
|
|
66
|
+
const canExpand = nodeExpandable(node);
|
|
67
|
+
const open = isOpen(node.agent.id);
|
|
68
|
+
out.push({ id: node.agent.id, parentId, expandable: canExpand, open });
|
|
69
|
+
if (canExpand && open) walk(node.children, node.agent.id);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(tree, null);
|
|
73
|
+
return out;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Roving tabindex: exactly one row is in the tab order, and Tab out of the
|
|
77
|
+
// tree returns to it. Falls back to the first row so a freshly rendered tree
|
|
78
|
+
// is reachable at all.
|
|
79
|
+
let activeId = $state<string | null>(null);
|
|
80
|
+
const focusedId = $derived(
|
|
81
|
+
activeId && rows.some((row) => row.id === activeId) ? activeId : (rows[0]?.id ?? null)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
function focusRow(id: string | undefined | null) {
|
|
85
|
+
if (!id || !ref) return;
|
|
86
|
+
activeId = id;
|
|
87
|
+
ref.querySelector<HTMLElement>(`[data-agent-id="${CSS.escape(id)}"]`)?.focus();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Focus follows the DOM, so clicking a row (or one of its buttons) roves too. */
|
|
91
|
+
function onfocusin(event: FocusEvent) {
|
|
92
|
+
const el = (event.target as HTMLElement | null)?.closest?.('[data-agent-id]');
|
|
93
|
+
const id = el?.getAttribute('data-agent-id');
|
|
94
|
+
if (id) activeId = id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* WAI-ARIA tree navigation. Enter and Space are deliberately absent — they
|
|
99
|
+
* activate the row, which the node itself owns (it is the thing that knows
|
|
100
|
+
* whether there is an `onOpen` to run).
|
|
101
|
+
*/
|
|
102
|
+
function onkeydown(event: KeyboardEvent) {
|
|
103
|
+
const index = rows.findIndex((row) => row.id === focusedId);
|
|
104
|
+
const current = index >= 0 ? rows[index] : undefined;
|
|
105
|
+
|
|
106
|
+
switch (event.key) {
|
|
107
|
+
case 'ArrowDown':
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
focusRow(rows[Math.min(index + 1, rows.length - 1)]?.id);
|
|
110
|
+
break;
|
|
111
|
+
case 'ArrowUp':
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
focusRow(rows[Math.max(index - 1, 0)]?.id);
|
|
114
|
+
break;
|
|
115
|
+
case 'ArrowRight':
|
|
116
|
+
// Collapsed: open it. Open: step into the branch it just revealed.
|
|
117
|
+
if (!current) break;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
if (current.expandable && !current.open) {
|
|
120
|
+
const agent = agents.find((a) => a.id === current.id);
|
|
121
|
+
if (agent) setOpen(agent, true);
|
|
122
|
+
} else if (rows[index + 1]?.parentId === current.id) {
|
|
123
|
+
focusRow(rows[index + 1].id);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
case 'ArrowLeft':
|
|
127
|
+
// Open: close it. Closed (or a leaf): climb to the parent.
|
|
128
|
+
if (!current) break;
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
if (current.expandable && current.open) {
|
|
131
|
+
const agent = agents.find((a) => a.id === current.id);
|
|
132
|
+
if (agent) setOpen(agent, false);
|
|
133
|
+
} else {
|
|
134
|
+
focusRow(current.parentId);
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
case 'Home':
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
focusRow(rows[0]?.id);
|
|
140
|
+
break;
|
|
141
|
+
case 'End':
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
focusRow(rows.at(-1)?.id);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<!-- The delegation tree. `@container` scopes the responsive rules inside to the
|
|
150
|
+
TREE's width rather than the viewport's: the same swarm renders inside a
|
|
151
|
+
narrow chat message and in a full-width console, and only the component can
|
|
152
|
+
know which one it is in. -->
|
|
153
|
+
<div
|
|
154
|
+
bind:this={ref}
|
|
155
|
+
role="tree"
|
|
156
|
+
aria-label="Swarm agents"
|
|
157
|
+
{onkeydown}
|
|
158
|
+
{onfocusin}
|
|
159
|
+
class={cn('@container min-w-0 py-(--era-gap)', className)}
|
|
160
|
+
{...restProps}
|
|
161
|
+
>
|
|
162
|
+
{#if rows.length === 0}
|
|
163
|
+
<div class="px-(--era-inset-md) py-(--era-gap) text-body text-muted">
|
|
164
|
+
{#if empty}{@render empty()}{:else}No agents yet.{/if}
|
|
165
|
+
</div>
|
|
166
|
+
{:else}
|
|
167
|
+
{#each tree as node (node.agent.id)}
|
|
168
|
+
{@render nodeView(node, 1)}
|
|
169
|
+
{/each}
|
|
170
|
+
{/if}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<!-- The recursion lives in a snippet rather than in the node component: the tree
|
|
174
|
+
owns expansion and focus for the whole forest (it is the only thing that
|
|
175
|
+
knows the visible order), and a self-recursing node would have to hand every
|
|
176
|
+
level a copy of that state. -->
|
|
177
|
+
{#snippet nodeView(node: SwarmNode, level: number)}
|
|
178
|
+
<!-- A leaf gets NO children snippet, rather than one that renders nothing:
|
|
179
|
+
the node reads the snippet's presence to decide whether it is expandable,
|
|
180
|
+
so an always-passed empty group would put a dead chevron on every leaf. -->
|
|
181
|
+
{@const props = {
|
|
182
|
+
agent: node.agent,
|
|
183
|
+
level,
|
|
184
|
+
expandable: nodeExpandable(node),
|
|
185
|
+
detail,
|
|
186
|
+
meta,
|
|
187
|
+
open: isOpen(node.agent.id),
|
|
188
|
+
active: focusedId === node.agent.id,
|
|
189
|
+
tabindex: focusedId === node.agent.id ? 0 : -1,
|
|
190
|
+
onToggle: (open: boolean) => setOpen(node.agent, open)
|
|
191
|
+
}}
|
|
192
|
+
{#if node.children.length > 0}
|
|
193
|
+
<Node {...props}>
|
|
194
|
+
{#each node.children as child (child.agent.id)}
|
|
195
|
+
{@render nodeView(child, level + 1)}
|
|
196
|
+
{/each}
|
|
197
|
+
</Node>
|
|
198
|
+
{:else}
|
|
199
|
+
<Node {...props} />
|
|
200
|
+
{/if}
|
|
201
|
+
{/snippet}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { type SwarmAgent } from './swarm.js';
|
|
4
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
5
|
+
ref?: HTMLDivElement | null;
|
|
6
|
+
/** The swarm's agents, flat. The tree is built from each one's `parentId`. */
|
|
7
|
+
agents?: SwarmAgent[];
|
|
8
|
+
/**
|
|
9
|
+
* Which rows can expand beyond their children. Defaults to "every row,
|
|
10
|
+
* once a detail snippet exists" — pass a predicate when the detail is empty
|
|
11
|
+
* for some agents, so those rows don't get a chevron that opens nothing.
|
|
12
|
+
*/
|
|
13
|
+
expandable?: (agent: SwarmAgent) => boolean;
|
|
14
|
+
/** Expanded body for a row — artifacts, a transcript, tool output. */
|
|
15
|
+
detail?: Snippet<[SwarmAgent]>;
|
|
16
|
+
/** Trailing row content, rendered before the elapsed readout. */
|
|
17
|
+
meta?: Snippet<[SwarmAgent]>;
|
|
18
|
+
/** Replaces the built-in "no agents yet" line. */
|
|
19
|
+
empty?: Snippet;
|
|
20
|
+
/** Fired when a row expands or collapses, with that agent. */
|
|
21
|
+
onToggle?: (agent: SwarmAgent, open: boolean) => void;
|
|
22
|
+
};
|
|
23
|
+
declare const SwarmTree: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
24
|
+
type SwarmTree = ReturnType<typeof SwarmTree>;
|
|
25
|
+
export default SwarmTree;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swarm model — the shape of a multi-agent run, and the projection logic
|
|
3
|
+
* the UI needs, with no transport attached.
|
|
4
|
+
*
|
|
5
|
+
* A swarm is one orchestrated effort: a root agent that decomposes a task,
|
|
6
|
+
* workers it delegates to, optional reviewers, and fan-in gates (barriers)
|
|
7
|
+
* where parallel branches rejoin before the work is merged into artifacts.
|
|
8
|
+
* Every real implementation streams that state as an append-only event log with
|
|
9
|
+
* a monotonic sequence number, because an hour-long run WILL outlive a socket.
|
|
10
|
+
*
|
|
11
|
+
* era ships the projection half of that contract and nothing else. The host
|
|
12
|
+
* owns the transport (oRPC, SSE, WebSocket, polling — era does not care) and
|
|
13
|
+
* feeds `SwarmController` snapshots and events; the controller owns the one
|
|
14
|
+
* invariant that makes reconnection tractable:
|
|
15
|
+
*
|
|
16
|
+
* the view is a pure function of (snapshot at seq S, every event with seq > S)
|
|
17
|
+
*
|
|
18
|
+
* which turns "resume a live view" into a watermark comparison instead of a
|
|
19
|
+
* state-transfer negotiation. Take a snapshot, subscribe from its seq, and a
|
|
20
|
+
* dropped socket, a server restart and a browser refresh all re-enter through
|
|
21
|
+
* the same door.
|
|
22
|
+
*
|
|
23
|
+
* This module is the pure half — shapes and projections, no runes, no clock.
|
|
24
|
+
* `SwarmController` (swarm.svelte.ts) is the live half, and it is optional: a
|
|
25
|
+
* host that already holds swarm state in its own store calls these functions
|
|
26
|
+
* directly and never constructs one.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* What an agent was spawned to be.
|
|
30
|
+
*
|
|
31
|
+
* `orchestrator` plans and delegates (usually the root), `worker` does a
|
|
32
|
+
* delegated subtask, `reviewer` and `verifier` check other agents' output
|
|
33
|
+
* (review reads, verification re-runs), `aggregator` merges a branch's results
|
|
34
|
+
* into one artifact.
|
|
35
|
+
*/
|
|
36
|
+
export type SwarmRole = 'orchestrator' | 'worker' | 'reviewer' | 'verifier' | 'aggregator';
|
|
37
|
+
/**
|
|
38
|
+
* An agent's lifecycle.
|
|
39
|
+
*
|
|
40
|
+
* The two waiting states are deliberately distinct, and collapsing them hides
|
|
41
|
+
* the thing you open a swarm console to diagnose: `queued` is waiting for a
|
|
42
|
+
* slot (bounded parallelism), `blocked` is waiting on someone else (a barrier
|
|
43
|
+
* or an unfinished dependency). One clears itself; the other may never.
|
|
44
|
+
*/
|
|
45
|
+
export type SwarmAgentStatus = 'blocked' | 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
|
|
46
|
+
/** The plan's own lifecycle — `active` until every branch has landed. */
|
|
47
|
+
export type SwarmPlanStatus = 'active' | 'done' | 'failed' | 'cancelled';
|
|
48
|
+
/** A fan-in gate: `open` while any member is still working. */
|
|
49
|
+
export type SwarmBarrierStatus = 'open' | 'done' | 'failed' | 'cancelled';
|
|
50
|
+
/**
|
|
51
|
+
* The stream's health, which is NOT the swarm's health — a swarm can be running
|
|
52
|
+
* perfectly while your connection to it is dead. Surfaced separately (see
|
|
53
|
+
* `Swarm.Footer`) for exactly that reason.
|
|
54
|
+
*/
|
|
55
|
+
export type SwarmConnection = 'idle' | 'loading' | 'live' | 'reconnecting' | 'error';
|
|
56
|
+
/** The VM an agent executes in, when it has one. */
|
|
57
|
+
export interface SwarmSandbox {
|
|
58
|
+
/** Provider id, shown on hover. */
|
|
59
|
+
id?: string;
|
|
60
|
+
status: 'running' | 'paused' | 'terminated';
|
|
61
|
+
}
|
|
62
|
+
/** One agent in the swarm. */
|
|
63
|
+
export interface SwarmAgent {
|
|
64
|
+
id: string;
|
|
65
|
+
/** Who delegated this agent. Absent (or unknown) makes it a root. */
|
|
66
|
+
parentId?: string | null;
|
|
67
|
+
role?: SwarmRole;
|
|
68
|
+
/** What it was asked to do — the row's primary line. */
|
|
69
|
+
task: string;
|
|
70
|
+
status: SwarmAgentStatus;
|
|
71
|
+
/** Specialization, when the role alone is too coarse: "api", "docs", "perf". */
|
|
72
|
+
label?: string;
|
|
73
|
+
/** The plan's stable key for this task ("t3"), rendered as a tag. */
|
|
74
|
+
key?: string;
|
|
75
|
+
/** One-line outcome, shown under the task once the agent lands. */
|
|
76
|
+
result?: string;
|
|
77
|
+
/** Fan-in gate this agent reports into. */
|
|
78
|
+
barrierId?: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* The agent's own transcript, when it has one. This GATES the open
|
|
81
|
+
* affordance: a row or card shows the open button only when the host has both
|
|
82
|
+
* an `onOpen` handler and an agent with somewhere to go.
|
|
83
|
+
*/
|
|
84
|
+
conversationId?: string;
|
|
85
|
+
/**
|
|
86
|
+
* The agent's VM.
|
|
87
|
+
*
|
|
88
|
+
* `null` means "known to have none"; `undefined` in an EVENT means "not
|
|
89
|
+
* reported" and leaves the last known value alone (see `mergeAgent`) —
|
|
90
|
+
* sandbox lifecycle is usually not journaled as swarm events, so a value
|
|
91
|
+
* carried on every agent update would be stale by construction.
|
|
92
|
+
*/
|
|
93
|
+
sandbox?: SwarmSandbox | null;
|
|
94
|
+
/** Epoch ms. `startedAt` + `endedAt` drive the elapsed readout. */
|
|
95
|
+
startedAt?: number;
|
|
96
|
+
endedAt?: number;
|
|
97
|
+
}
|
|
98
|
+
/** The decomposition the root agent committed to. */
|
|
99
|
+
export interface SwarmPlan {
|
|
100
|
+
id?: string;
|
|
101
|
+
title: string;
|
|
102
|
+
status?: SwarmPlanStatus;
|
|
103
|
+
}
|
|
104
|
+
/** A fan-in gate: the point where parallel branches rejoin. */
|
|
105
|
+
export interface SwarmBarrier {
|
|
106
|
+
id: string;
|
|
107
|
+
/** Short stable key ("research"), for the tag beside the title. */
|
|
108
|
+
key?: string;
|
|
109
|
+
title: string;
|
|
110
|
+
/** Omit to let `summarizeBarrier` derive it from the members. */
|
|
111
|
+
status?: SwarmBarrierStatus;
|
|
112
|
+
}
|
|
113
|
+
/** A summarized output: a merged finding, a report, a deliverable. */
|
|
114
|
+
export interface SwarmArtifact {
|
|
115
|
+
id: string;
|
|
116
|
+
/** Which agent produced it. */
|
|
117
|
+
agentId?: string;
|
|
118
|
+
/** Set when the artifact is a barrier's merged summary. */
|
|
119
|
+
barrierId?: string | null;
|
|
120
|
+
/** Free-form producer tag ("report", "diff", "barrier_summary"). */
|
|
121
|
+
kind: string;
|
|
122
|
+
title?: string;
|
|
123
|
+
summary: string;
|
|
124
|
+
/** Epoch ms. */
|
|
125
|
+
createdAt?: number;
|
|
126
|
+
}
|
|
127
|
+
/** One agent plus its subtree, as `buildSwarmTree` returns it. */
|
|
128
|
+
export interface SwarmNode {
|
|
129
|
+
agent: SwarmAgent;
|
|
130
|
+
/** Distance from the root, computed from the tree — never trusted from data. */
|
|
131
|
+
depth: number;
|
|
132
|
+
children: SwarmNode[];
|
|
133
|
+
}
|
|
134
|
+
/** Aggregate agent counts, as `summarizeSwarm` returns them. */
|
|
135
|
+
export interface SwarmCounts {
|
|
136
|
+
total: number;
|
|
137
|
+
blocked: number;
|
|
138
|
+
queued: number;
|
|
139
|
+
running: number;
|
|
140
|
+
done: number;
|
|
141
|
+
failed: number;
|
|
142
|
+
cancelled: number;
|
|
143
|
+
/** blocked + queued + running — everything that has not landed yet. */
|
|
144
|
+
active: number;
|
|
145
|
+
/** Agents holding a VM that is not terminated. */
|
|
146
|
+
sandboxes: number;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Build the delegation forest from a flat agent list.
|
|
150
|
+
*
|
|
151
|
+
* Roots are agents with no parent AND agents whose parent is not in the list,
|
|
152
|
+
* so a filtered view (one branch, a search result) still renders as a tree
|
|
153
|
+
* instead of vanishing. Sibling order is the input order — the caller sorts.
|
|
154
|
+
*
|
|
155
|
+
* Cycle-safe: a parent chain that loops (which only a buggy producer can emit)
|
|
156
|
+
* drops the repeat instead of recursing forever.
|
|
157
|
+
*/
|
|
158
|
+
export declare function buildSwarmTree(agents: SwarmAgent[]): SwarmNode[];
|
|
159
|
+
/** Walk a forest depth-first, parents before children (visible tree order). */
|
|
160
|
+
export declare function flattenSwarmTree(nodes: SwarmNode[]): SwarmNode[];
|
|
161
|
+
/** Count agents by status, plus live VMs. Pure — safe to call in a `$derived`. */
|
|
162
|
+
export declare function summarizeSwarm(agents: SwarmAgent[]): SwarmCounts;
|
|
163
|
+
/**
|
|
164
|
+
* A barrier's progress, derived from its members.
|
|
165
|
+
*
|
|
166
|
+
* The gate is only as done as the branch under it, so `status` is computed from
|
|
167
|
+
* the members unless the barrier carries an explicit one: any member still
|
|
168
|
+
* working keeps it `open`, a failure fails it, a cancellation cancels it. A
|
|
169
|
+
* barrier with no members yet reads `open` with a zero total, which is exactly
|
|
170
|
+
* what a just-declared gate is.
|
|
171
|
+
*/
|
|
172
|
+
export declare function summarizeBarrier(barrier: SwarmBarrier, agents: SwarmAgent[]): {
|
|
173
|
+
done: number;
|
|
174
|
+
failed: number;
|
|
175
|
+
total: number;
|
|
176
|
+
status: SwarmBarrierStatus;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* How long an agent has been working, in ms — `null` when it never started.
|
|
180
|
+
*
|
|
181
|
+
* `now` is an argument rather than a `Date.now()` call so the same inputs always
|
|
182
|
+
* render the same output (testable, SSR-safe, and a host that ticks a clock in
|
|
183
|
+
* `$state` gets a live counter for free).
|
|
184
|
+
*/
|
|
185
|
+
export declare function swarmElapsed(agent: SwarmAgent, now: number): number | null;
|
|
186
|
+
/**
|
|
187
|
+
* How long ago something happened, as a console reads it: `now`, `4m`, `2h`,
|
|
188
|
+
* `3d`. Same clock rule as everything else here — `now` is an argument.
|
|
189
|
+
*/
|
|
190
|
+
export declare function formatSwarmAge(at: number, now: number): string;
|
|
191
|
+
/** Compact elapsed readout: `840ms`, `4.2s`, `3m 07s`, `1h 04m`. */
|
|
192
|
+
export declare function formatSwarmDuration(ms: number): string;
|
|
193
|
+
/**
|
|
194
|
+
* One record from the swarm's event log.
|
|
195
|
+
*
|
|
196
|
+
* `seq` is the whole resumption contract: monotonic per swarm, and the client's
|
|
197
|
+
* watermark is the last one APPLIED — never the number of records held. Those
|
|
198
|
+
* differ the moment an event updates an agent already in the map, which is most
|
|
199
|
+
* events, and confusing the two silently re-requests or skips history.
|
|
200
|
+
*/
|
|
201
|
+
export interface SwarmEvent {
|
|
202
|
+
seq: number;
|
|
203
|
+
/** Free-form producer label ("agent.completed"), passed through untouched. */
|
|
204
|
+
kind?: string;
|
|
205
|
+
agent?: SwarmAgent;
|
|
206
|
+
plan?: SwarmPlan;
|
|
207
|
+
barrier?: SwarmBarrier;
|
|
208
|
+
artifact?: SwarmArtifact;
|
|
209
|
+
}
|
|
210
|
+
/** A point-in-time view of the whole swarm, as the host's snapshot API returns it. */
|
|
211
|
+
export interface SwarmSnapshot {
|
|
212
|
+
/** The seq this snapshot is current as of — subscribe from here. */
|
|
213
|
+
seq?: number;
|
|
214
|
+
plan?: SwarmPlan | null;
|
|
215
|
+
agents?: SwarmAgent[];
|
|
216
|
+
barriers?: SwarmBarrier[];
|
|
217
|
+
artifacts?: SwarmArtifact[];
|
|
218
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swarm model — the shape of a multi-agent run, and the projection logic
|
|
3
|
+
* the UI needs, with no transport attached.
|
|
4
|
+
*
|
|
5
|
+
* A swarm is one orchestrated effort: a root agent that decomposes a task,
|
|
6
|
+
* workers it delegates to, optional reviewers, and fan-in gates (barriers)
|
|
7
|
+
* where parallel branches rejoin before the work is merged into artifacts.
|
|
8
|
+
* Every real implementation streams that state as an append-only event log with
|
|
9
|
+
* a monotonic sequence number, because an hour-long run WILL outlive a socket.
|
|
10
|
+
*
|
|
11
|
+
* era ships the projection half of that contract and nothing else. The host
|
|
12
|
+
* owns the transport (oRPC, SSE, WebSocket, polling — era does not care) and
|
|
13
|
+
* feeds `SwarmController` snapshots and events; the controller owns the one
|
|
14
|
+
* invariant that makes reconnection tractable:
|
|
15
|
+
*
|
|
16
|
+
* the view is a pure function of (snapshot at seq S, every event with seq > S)
|
|
17
|
+
*
|
|
18
|
+
* which turns "resume a live view" into a watermark comparison instead of a
|
|
19
|
+
* state-transfer negotiation. Take a snapshot, subscribe from its seq, and a
|
|
20
|
+
* dropped socket, a server restart and a browser refresh all re-enter through
|
|
21
|
+
* the same door.
|
|
22
|
+
*
|
|
23
|
+
* This module is the pure half — shapes and projections, no runes, no clock.
|
|
24
|
+
* `SwarmController` (swarm.svelte.ts) is the live half, and it is optional: a
|
|
25
|
+
* host that already holds swarm state in its own store calls these functions
|
|
26
|
+
* directly and never constructs one.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Build the delegation forest from a flat agent list.
|
|
30
|
+
*
|
|
31
|
+
* Roots are agents with no parent AND agents whose parent is not in the list,
|
|
32
|
+
* so a filtered view (one branch, a search result) still renders as a tree
|
|
33
|
+
* instead of vanishing. Sibling order is the input order — the caller sorts.
|
|
34
|
+
*
|
|
35
|
+
* Cycle-safe: a parent chain that loops (which only a buggy producer can emit)
|
|
36
|
+
* drops the repeat instead of recursing forever.
|
|
37
|
+
*/
|
|
38
|
+
export function buildSwarmTree(agents) {
|
|
39
|
+
const nodes = new Map();
|
|
40
|
+
for (const agent of agents)
|
|
41
|
+
nodes.set(agent.id, { agent, depth: 0, children: [] });
|
|
42
|
+
const roots = [];
|
|
43
|
+
for (const agent of agents) {
|
|
44
|
+
const node = nodes.get(agent.id);
|
|
45
|
+
const parent = agent.parentId ? nodes.get(agent.parentId) : undefined;
|
|
46
|
+
if (parent && parent !== node)
|
|
47
|
+
parent.children.push(node);
|
|
48
|
+
else
|
|
49
|
+
roots.push(node);
|
|
50
|
+
}
|
|
51
|
+
// Depth from the structure, and the visited set is what makes a cyclic chain
|
|
52
|
+
// terminate: a node reached twice is already placed, so it is not descended
|
|
53
|
+
// into again.
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const assign = (node, depth) => {
|
|
56
|
+
if (seen.has(node.agent.id))
|
|
57
|
+
return;
|
|
58
|
+
seen.add(node.agent.id);
|
|
59
|
+
node.depth = depth;
|
|
60
|
+
for (const child of node.children)
|
|
61
|
+
assign(child, depth + 1);
|
|
62
|
+
};
|
|
63
|
+
for (const root of roots)
|
|
64
|
+
assign(root, 0);
|
|
65
|
+
// A cycle leaves its members unreachable from any root — surface them as
|
|
66
|
+
// roots rather than dropping agents the caller can see in the list.
|
|
67
|
+
for (const agent of agents) {
|
|
68
|
+
if (seen.has(agent.id))
|
|
69
|
+
continue;
|
|
70
|
+
const node = nodes.get(agent.id);
|
|
71
|
+
roots.push(node);
|
|
72
|
+
assign(node, 0);
|
|
73
|
+
}
|
|
74
|
+
return roots;
|
|
75
|
+
}
|
|
76
|
+
/** Walk a forest depth-first, parents before children (visible tree order). */
|
|
77
|
+
export function flattenSwarmTree(nodes) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const walk = (list) => {
|
|
80
|
+
for (const node of list) {
|
|
81
|
+
out.push(node);
|
|
82
|
+
walk(node.children);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
walk(nodes);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Count agents by status, plus live VMs. Pure — safe to call in a `$derived`. */
|
|
89
|
+
export function summarizeSwarm(agents) {
|
|
90
|
+
const counts = {
|
|
91
|
+
total: agents.length,
|
|
92
|
+
blocked: 0,
|
|
93
|
+
queued: 0,
|
|
94
|
+
running: 0,
|
|
95
|
+
done: 0,
|
|
96
|
+
failed: 0,
|
|
97
|
+
cancelled: 0,
|
|
98
|
+
active: 0,
|
|
99
|
+
sandboxes: 0
|
|
100
|
+
};
|
|
101
|
+
for (const agent of agents) {
|
|
102
|
+
counts[agent.status]++;
|
|
103
|
+
if (agent.status === 'blocked' || agent.status === 'queued' || agent.status === 'running') {
|
|
104
|
+
counts.active++;
|
|
105
|
+
}
|
|
106
|
+
if (agent.sandbox && agent.sandbox.status !== 'terminated')
|
|
107
|
+
counts.sandboxes++;
|
|
108
|
+
}
|
|
109
|
+
return counts;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A barrier's progress, derived from its members.
|
|
113
|
+
*
|
|
114
|
+
* The gate is only as done as the branch under it, so `status` is computed from
|
|
115
|
+
* the members unless the barrier carries an explicit one: any member still
|
|
116
|
+
* working keeps it `open`, a failure fails it, a cancellation cancels it. A
|
|
117
|
+
* barrier with no members yet reads `open` with a zero total, which is exactly
|
|
118
|
+
* what a just-declared gate is.
|
|
119
|
+
*/
|
|
120
|
+
export function summarizeBarrier(barrier, agents) {
|
|
121
|
+
let done = 0;
|
|
122
|
+
let failed = 0;
|
|
123
|
+
let total = 0;
|
|
124
|
+
let open = false;
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
for (const agent of agents) {
|
|
127
|
+
if (agent.barrierId !== barrier.id)
|
|
128
|
+
continue;
|
|
129
|
+
total++;
|
|
130
|
+
if (agent.status === 'done')
|
|
131
|
+
done++;
|
|
132
|
+
else if (agent.status === 'failed')
|
|
133
|
+
failed++;
|
|
134
|
+
else if (agent.status === 'cancelled')
|
|
135
|
+
cancelled = true;
|
|
136
|
+
else
|
|
137
|
+
open = true;
|
|
138
|
+
}
|
|
139
|
+
const derived = open
|
|
140
|
+
? 'open'
|
|
141
|
+
: failed > 0
|
|
142
|
+
? 'failed'
|
|
143
|
+
: cancelled
|
|
144
|
+
? 'cancelled'
|
|
145
|
+
: 'done';
|
|
146
|
+
return { done, failed, total, status: barrier.status ?? (total === 0 ? 'open' : derived) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* How long an agent has been working, in ms — `null` when it never started.
|
|
150
|
+
*
|
|
151
|
+
* `now` is an argument rather than a `Date.now()` call so the same inputs always
|
|
152
|
+
* render the same output (testable, SSR-safe, and a host that ticks a clock in
|
|
153
|
+
* `$state` gets a live counter for free).
|
|
154
|
+
*/
|
|
155
|
+
export function swarmElapsed(agent, now) {
|
|
156
|
+
if (agent.startedAt == null)
|
|
157
|
+
return null;
|
|
158
|
+
return Math.max(0, (agent.endedAt ?? now) - agent.startedAt);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* How long ago something happened, as a console reads it: `now`, `4m`, `2h`,
|
|
162
|
+
* `3d`. Same clock rule as everything else here — `now` is an argument.
|
|
163
|
+
*/
|
|
164
|
+
export function formatSwarmAge(at, now) {
|
|
165
|
+
const seconds = Math.max(0, Math.floor((now - at) / 1000));
|
|
166
|
+
if (seconds < 60)
|
|
167
|
+
return 'now';
|
|
168
|
+
const minutes = Math.floor(seconds / 60);
|
|
169
|
+
if (minutes < 60)
|
|
170
|
+
return `${minutes}m`;
|
|
171
|
+
const hours = Math.floor(minutes / 60);
|
|
172
|
+
if (hours < 24)
|
|
173
|
+
return `${hours}h`;
|
|
174
|
+
return `${Math.floor(hours / 24)}d`;
|
|
175
|
+
}
|
|
176
|
+
/** Compact elapsed readout: `840ms`, `4.2s`, `3m 07s`, `1h 04m`. */
|
|
177
|
+
export function formatSwarmDuration(ms) {
|
|
178
|
+
if (ms < 1000)
|
|
179
|
+
return `${Math.round(ms)}ms`;
|
|
180
|
+
const seconds = ms / 1000;
|
|
181
|
+
if (seconds < 60)
|
|
182
|
+
return `${seconds.toFixed(1)}s`;
|
|
183
|
+
const minutes = Math.floor(seconds / 60);
|
|
184
|
+
if (minutes < 60)
|
|
185
|
+
return `${minutes}m ${String(Math.floor(seconds % 60)).padStart(2, '0')}s`;
|
|
186
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live half of the swarm model: one reconnectable, reactive view of a run.
|
|
3
|
+
*
|
|
4
|
+
* The shapes and the pure projections live next door in `swarm.ts` — everything
|
|
5
|
+
* here is state, so this is the only file in the family that needs runes (and
|
|
6
|
+
* the only one a host has to construct). See that module for the model itself
|
|
7
|
+
* and for why the sequence number is the whole resumption contract.
|
|
8
|
+
*/
|
|
9
|
+
import { type SwarmAgent, type SwarmArtifact, type SwarmBarrier, type SwarmConnection, type SwarmEvent, type SwarmPlan, type SwarmSnapshot } from './swarm.js';
|
|
10
|
+
/**
|
|
11
|
+
* A reconnectable view of one swarm.
|
|
12
|
+
*
|
|
13
|
+
* Backend-free by design: the host does the fetching and the socket handling and
|
|
14
|
+
* hands the results here. A typical wiring is
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const swarm = new SwarmController();
|
|
18
|
+
* swarm.connection = 'loading';
|
|
19
|
+
* swarm.snapshot(await api.swarmSnapshot(id)); // sets the watermark
|
|
20
|
+
* swarm.connection = 'live';
|
|
21
|
+
* for await (const event of api.swarmStream(id, swarm.seq)) swarm.apply(event);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* and the retry path is the same three lines — re-snapshot (never rewinding the
|
|
25
|
+
* watermark), then subscribe from `seq` again. Everything the components need
|
|
26
|
+
* (`tree`, `counts`) is derived, so nothing has to be recomputed by hand.
|
|
27
|
+
*/
|
|
28
|
+
export declare class SwarmController {
|
|
29
|
+
#private;
|
|
30
|
+
agents: SwarmAgent[];
|
|
31
|
+
plan: SwarmPlan | null;
|
|
32
|
+
barriers: SwarmBarrier[];
|
|
33
|
+
artifacts: SwarmArtifact[];
|
|
34
|
+
/** Stream health, for `Swarm.Footer`. The host sets it; nothing here does. */
|
|
35
|
+
connection: SwarmConnection;
|
|
36
|
+
/** Last transport error, shown beside the watermark. */
|
|
37
|
+
error: string | null;
|
|
38
|
+
/** The watermark: the last APPLIED event seq, and the resume point. */
|
|
39
|
+
seq: number;
|
|
40
|
+
constructor(snapshot?: SwarmSnapshot);
|
|
41
|
+
readonly tree: import("./swarm.js").SwarmNode[];
|
|
42
|
+
readonly counts: import("./swarm.js").SwarmCounts;
|
|
43
|
+
/**
|
|
44
|
+
* Merge a snapshot in.
|
|
45
|
+
*
|
|
46
|
+
* The watermark only ever moves FORWARD: a refresh taken while live events
|
|
47
|
+
* are already flowing is older than the view it is refreshing, and rewinding
|
|
48
|
+
* to its seq would replay every event since.
|
|
49
|
+
*/
|
|
50
|
+
snapshot(snapshot: SwarmSnapshot): void;
|
|
51
|
+
/**
|
|
52
|
+
* Apply one event. Returns false when it was already applied — a backfill
|
|
53
|
+
* that overlaps what the snapshot already covered is a no-op, not a
|
|
54
|
+
* double-apply, which is what lets the resume path be careless about overlap.
|
|
55
|
+
*/
|
|
56
|
+
apply(event: SwarmEvent): boolean;
|
|
57
|
+
/** Drop everything — switching to a different swarm, not reconnecting to this one. */
|
|
58
|
+
reset(): void;
|
|
59
|
+
}
|