@growthub/cli 0.14.0 → 0.14.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/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +99 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +18 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +264 -22
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +129 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +48 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +23 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
- package/package.json +1 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SwarmRunCockpit — governed swarm run surface inside the Workspace Helper
|
|
5
|
+
* sidecar (SWARM_RUN_CONTRACT_V1, Phases 4 + 7, parity P1–P5).
|
|
6
|
+
*
|
|
7
|
+
* Renders Running / Finished swarm runs as "Background tasks" using ONLY the
|
|
8
|
+
* existing helper / tool-call / run-console grammar:
|
|
9
|
+
*
|
|
10
|
+
* data lane: sandbox-environment rows (findSwarmRunRows)
|
|
11
|
+
* → declared-phase skeleton from the row's graph
|
|
12
|
+
* (deriveSwarmGraphProjection — renders BEFORE any run)
|
|
13
|
+
* → row.lastResponse + GET /api/workspace/sandbox-run history
|
|
14
|
+
* (deriveSwarmRunProjection — the source of truth)
|
|
15
|
+
*
|
|
16
|
+
* execution: POST /api/workspace/sandbox-run ONLY (the existing route —
|
|
17
|
+
* nothing new). While the request is in flight the card shows
|
|
18
|
+
* the declared skeleton with pending dots and a truthful
|
|
19
|
+
* elapsed ticker; history polling converges on the persisted
|
|
20
|
+
* record. The cockpit never mutates workspace config and
|
|
21
|
+
* never spawns its own runtime.
|
|
22
|
+
*
|
|
23
|
+
* Truthful telemetry: pending/running cells render BLANK; "—" is reserved
|
|
24
|
+
* for terminal agents whose adapter never reported a count. No estimates,
|
|
25
|
+
* no null-to-zero coercion.
|
|
26
|
+
*
|
|
27
|
+
* Stop cancels the active client request only (no durable cancel primitive
|
|
28
|
+
* exists). Clear hides finished cards from the local visible list only —
|
|
29
|
+
* source-record history is never deleted from here.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
33
|
+
// Icon set is strictly inherited grammar: ArrowUpRight/ChevronDown/
|
|
34
|
+
// ChevronRight from HelperSidecar, Play/Square from the existing
|
|
35
|
+
// run-console surfaces (OrchestrationRunTracePanel, SandboxRunPanel).
|
|
36
|
+
import { ArrowUpRight, ChevronDown, ChevronRight, Play, Square } from "lucide-react";
|
|
37
|
+
import {
|
|
38
|
+
deriveSwarmDeltaProjection,
|
|
39
|
+
deriveSwarmGraphProjection,
|
|
40
|
+
deriveSwarmRunProjection,
|
|
41
|
+
formatCompactRunDuration,
|
|
42
|
+
} from "@/lib/orchestration-run-console";
|
|
43
|
+
import {
|
|
44
|
+
deriveHelperWidgetCausationState,
|
|
45
|
+
deriveSwarmWorkflowExecutionEligibility,
|
|
46
|
+
findSwarmRunRows,
|
|
47
|
+
} from "@/lib/workspace-swarm-proposal";
|
|
48
|
+
|
|
49
|
+
const RUN_POLL_MS = 3500;
|
|
50
|
+
|
|
51
|
+
function runKeyOf(objectId, name) {
|
|
52
|
+
return `${objectId}::${name}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Truthful display: pending/running agents show BLANK cells (the run has
|
|
56
|
+
// not reported yet); terminal agents with null telemetry show "—" (ran,
|
|
57
|
+
// never reported). Reported numbers are k-formatted (16.3k).
|
|
58
|
+
function formatCount(value, pending) {
|
|
59
|
+
if (pending) return "";
|
|
60
|
+
if (value == null || !Number.isFinite(Number(value))) return "—";
|
|
61
|
+
const n = Number(value);
|
|
62
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
63
|
+
return String(n);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatTokensLabel(value) {
|
|
67
|
+
if (value == null || !Number.isFinite(Number(value))) return "— Tokens";
|
|
68
|
+
return `${formatCount(value, false)} Tokens`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseRowRecord(row) {
|
|
72
|
+
const raw = row?.lastResponse;
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
if (typeof raw === "object") return raw;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(String(raw));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Dot tri-state (+failure): hollow = pending, blue = active, filled green =
|
|
83
|
+
// done, red = failed, solid grey = canceled/unknown.
|
|
84
|
+
function dotVariantFor(status) {
|
|
85
|
+
if (status === "pending") return "pending";
|
|
86
|
+
if (status === "completed") return "ok";
|
|
87
|
+
if (status === "failed") return "fail";
|
|
88
|
+
if (status === "running" || status === "executing" || status === "info") return "active";
|
|
89
|
+
return "canceled";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function SwarmAgentTranscript({ agent, onCollapse }) {
|
|
93
|
+
if (!agent) return null;
|
|
94
|
+
return (
|
|
95
|
+
<div className="dm-swarm-transcript" data-swarm-transcript="">
|
|
96
|
+
<div className="dm-swarm-transcript-head">
|
|
97
|
+
<span className="dm-helper-toolcall-title">{agent.label}</span>
|
|
98
|
+
{onCollapse && (
|
|
99
|
+
<button type="button" className="dm-btn-ghost" onClick={onCollapse}>
|
|
100
|
+
Hide transcript
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<pre className="dm-helper-toolcall-json">
|
|
105
|
+
{agent.transcript || "(no output)"}
|
|
106
|
+
</pre>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function SwarmAgentRow({ agent, selected, onSelect }) {
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
className={`dm-swarm-agent-row${selected ? " is-selected" : ""}`}
|
|
116
|
+
data-swarm-agent={agent.id}
|
|
117
|
+
data-swarm-agent-status={agent.status}
|
|
118
|
+
onClick={onSelect}
|
|
119
|
+
>
|
|
120
|
+
<span className="dm-swarm-agent-name">
|
|
121
|
+
<span className="dm-run-console__tree-dot" data-variant={dotVariantFor(agent.status)} />
|
|
122
|
+
{agent.label}
|
|
123
|
+
</span>
|
|
124
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">{formatCount(agent.tokens, agent.pending)}</span>
|
|
125
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">{formatCount(agent.tools, agent.pending)}</span>
|
|
126
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">
|
|
127
|
+
{agent.pending ? "" : agent.durationMs ? formatCompactRunDuration(agent.durationMs) : "—"}
|
|
128
|
+
</span>
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function SwarmPhaseGroup({ phase, expanded, onToggle, selectedAgentId, onSelectAgent, onExpandTranscript }) {
|
|
134
|
+
const agents = Array.isArray(phase.agents) ? phase.agents : [];
|
|
135
|
+
const selectedAgent = agents.find((a) => a.id === selectedAgentId) || null;
|
|
136
|
+
return (
|
|
137
|
+
<div className="dm-helper-toolcall dm-swarm-phase" data-swarm-phase={phase.id}>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="dm-helper-toolcall-row dm-swarm-phase-head"
|
|
141
|
+
onClick={onToggle}
|
|
142
|
+
aria-expanded={expanded}
|
|
143
|
+
>
|
|
144
|
+
<span className="dm-helper-toolcall-title">{phase.label}</span>
|
|
145
|
+
{expanded
|
|
146
|
+
? <ChevronDown size={14} className="dm-helper-toolcall-chevron" aria-hidden="true" />
|
|
147
|
+
: <ChevronRight size={14} className="dm-helper-toolcall-chevron" aria-hidden="true" />}
|
|
148
|
+
</button>
|
|
149
|
+
<div className="dm-swarm-dotstrip" aria-label={`${phase.label} agent states`}>
|
|
150
|
+
{agents.map((agent) => (
|
|
151
|
+
<span
|
|
152
|
+
key={agent.id}
|
|
153
|
+
className="dm-run-console__tree-dot"
|
|
154
|
+
data-variant={dotVariantFor(agent.status)}
|
|
155
|
+
title={`${agent.label} — ${agent.status}`}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
{expanded && agents.length > 0 && (
|
|
160
|
+
<div className="dm-helper-toolcall-body dm-swarm-agent-table">
|
|
161
|
+
<div className="dm-swarm-agent-row dm-swarm-agent-header" aria-hidden="true">
|
|
162
|
+
<span className="dm-swarm-agent-name dm-run-console__hint">Agent</span>
|
|
163
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">Tokens</span>
|
|
164
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">Tools</span>
|
|
165
|
+
<span className="dm-swarm-agent-cell dm-run-console__hint">Time</span>
|
|
166
|
+
</div>
|
|
167
|
+
{agents.map((agent) => (
|
|
168
|
+
<SwarmAgentRow
|
|
169
|
+
key={agent.id}
|
|
170
|
+
agent={agent}
|
|
171
|
+
selected={agent.id === selectedAgentId}
|
|
172
|
+
onSelect={() => onSelectAgent(agent.id === selectedAgentId ? null : agent.id)}
|
|
173
|
+
/>
|
|
174
|
+
))}
|
|
175
|
+
{selectedAgent && (
|
|
176
|
+
<>
|
|
177
|
+
<SwarmAgentTranscript agent={selectedAgent} onCollapse={() => onSelectAgent(null)} />
|
|
178
|
+
{onExpandTranscript && (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="dm-btn-ghost dm-swarm-transcript-expand"
|
|
182
|
+
onClick={() => onExpandTranscript(selectedAgent)}
|
|
183
|
+
>
|
|
184
|
+
Expand
|
|
185
|
+
<ArrowUpRight size={12} aria-hidden="true" />
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function SwarmRunCard({
|
|
197
|
+
entry,
|
|
198
|
+
projection,
|
|
199
|
+
running,
|
|
200
|
+
elapsedMs,
|
|
201
|
+
eligibility,
|
|
202
|
+
helperWidgetState,
|
|
203
|
+
onStop,
|
|
204
|
+
onLaunch,
|
|
205
|
+
launchDisabled,
|
|
206
|
+
onExpandTranscript,
|
|
207
|
+
}) {
|
|
208
|
+
const [expandedPhases, setExpandedPhases] = useState({});
|
|
209
|
+
const [selectedAgentId, setSelectedAgentId] = useState(null);
|
|
210
|
+
const phases = projection?.phases || [];
|
|
211
|
+
const description = String(entry.row?.description || entry.row?.instructions || "").trim();
|
|
212
|
+
const neverRun = !running && projection?.status === "pending";
|
|
213
|
+
const finished = !running && projection && projection.status !== "pending";
|
|
214
|
+
const ready = eligibility?.ready !== false && helperWidgetState?.ready !== false;
|
|
215
|
+
const blockedGuidance = helperWidgetState?.ready === false
|
|
216
|
+
? helperWidgetState.guidance
|
|
217
|
+
: eligibility?.guidance;
|
|
218
|
+
const statusLabel = running
|
|
219
|
+
? formatCompactRunDuration(elapsedMs)
|
|
220
|
+
: neverRun
|
|
221
|
+
? "Not run yet"
|
|
222
|
+
: projection
|
|
223
|
+
? projection.status === "completed" ? "Completed" : projection.status
|
|
224
|
+
: "Not run yet";
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div className="dm-helper-toolcall dm-swarm-card" data-swarm-run={entry.row.Name} data-swarm-running={running ? "true" : "false"}>
|
|
228
|
+
<div className="dm-swarm-card-head">
|
|
229
|
+
<span
|
|
230
|
+
className="dm-run-console__tree-dot"
|
|
231
|
+
data-variant={running ? "active" : neverRun ? "pending" : projection ? dotVariantFor(projection.status) : "pending"}
|
|
232
|
+
/>
|
|
233
|
+
<span className="dm-helper-toolcall-title dm-swarm-card-title">{entry.row.Name}</span>
|
|
234
|
+
{finished && (
|
|
235
|
+
<span className="dm-run-console__hint" data-swarm-total-duration="">
|
|
236
|
+
{formatCompactRunDuration(projection.elapsedMs)}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
{running ? (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
className="dm-btn-ghost dm-swarm-card-action"
|
|
243
|
+
onClick={onStop}
|
|
244
|
+
aria-label="Stop run"
|
|
245
|
+
title="Stop — cancels the active request"
|
|
246
|
+
>
|
|
247
|
+
<Square size={12} aria-hidden="true" />
|
|
248
|
+
</button>
|
|
249
|
+
) : onLaunch ? (
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
className="dm-btn-ghost dm-swarm-card-action"
|
|
253
|
+
onClick={onLaunch}
|
|
254
|
+
disabled={launchDisabled || !ready}
|
|
255
|
+
aria-label="Run swarm"
|
|
256
|
+
title={ready ? "Run through sandbox-run" : blockedGuidance || "Execution target is not ready"}
|
|
257
|
+
>
|
|
258
|
+
<Play size={12} aria-hidden="true" />
|
|
259
|
+
</button>
|
|
260
|
+
) : null}
|
|
261
|
+
</div>
|
|
262
|
+
<div className="dm-swarm-card-meta">
|
|
263
|
+
<span className="dm-run-console__hint dm-swarm-card-kind">Workflow</span>
|
|
264
|
+
<span className="dm-run-console__hint">{statusLabel}</span>
|
|
265
|
+
</div>
|
|
266
|
+
{projection && (
|
|
267
|
+
<div className="dm-swarm-card-meta">
|
|
268
|
+
<span className="dm-run-console__hint">{`${projection.agentCount} Agents`}</span>
|
|
269
|
+
<span className="dm-run-console__hint">{formatTokensLabel(projection.totalTokens)}</span>
|
|
270
|
+
{eligibility && (
|
|
271
|
+
<span className="dm-run-console__hint">
|
|
272
|
+
{eligibility.ready ? `${eligibility.adapter}${eligibility.agentHost ? ` · ${eligibility.agentHost}` : ""}` : "Execution target needed"}
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
{!ready && (
|
|
278
|
+
<div className="dm-helper-error" role="status">
|
|
279
|
+
<span>{blockedGuidance || "Execution target is not ready."}</span>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
{ready && neverRun && (
|
|
283
|
+
<div className="dm-helper-stream dm-swarm-card-desc">
|
|
284
|
+
{eligibility.guidance}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
{description && (
|
|
288
|
+
<div className="dm-helper-stream dm-swarm-card-desc">{description}</div>
|
|
289
|
+
)}
|
|
290
|
+
{phases.length > 0 && (
|
|
291
|
+
<div className="dm-swarm-phases">
|
|
292
|
+
<span className="dm-helper-toolcall-title">Phases</span>
|
|
293
|
+
{phases.map((phase) => (
|
|
294
|
+
<SwarmPhaseGroup
|
|
295
|
+
key={phase.id}
|
|
296
|
+
phase={phase}
|
|
297
|
+
expanded={!!expandedPhases[phase.id]}
|
|
298
|
+
onToggle={() => setExpandedPhases((prev) => ({ ...prev, [phase.id]: !prev[phase.id] }))}
|
|
299
|
+
selectedAgentId={selectedAgentId}
|
|
300
|
+
onSelectAgent={setSelectedAgentId}
|
|
301
|
+
onExpandTranscript={onExpandTranscript}
|
|
302
|
+
/>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function SwarmRunList({
|
|
311
|
+
workflows,
|
|
312
|
+
runningKeys,
|
|
313
|
+
elapsedByKey,
|
|
314
|
+
projections,
|
|
315
|
+
hiddenFinished,
|
|
316
|
+
onStop,
|
|
317
|
+
onLaunch,
|
|
318
|
+
onClearFinished,
|
|
319
|
+
onExpandTranscript,
|
|
320
|
+
helperWidgetState,
|
|
321
|
+
}) {
|
|
322
|
+
const running = workflows.filter((entry) => runningKeys.has(runKeyOf(entry.objectId, entry.row.Name)));
|
|
323
|
+
const finished = workflows.filter((entry) => {
|
|
324
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
325
|
+
return !runningKeys.has(key) && !hiddenFinished.has(key);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className="dm-swarm-cockpit-list">
|
|
330
|
+
{running.length > 0 && (
|
|
331
|
+
<>
|
|
332
|
+
<span className="dm-run-console__hint">Running</span>
|
|
333
|
+
{running.map((entry) => {
|
|
334
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
335
|
+
return (
|
|
336
|
+
<SwarmRunCard
|
|
337
|
+
key={key}
|
|
338
|
+
entry={entry}
|
|
339
|
+
projection={projections.get(key) || null}
|
|
340
|
+
running
|
|
341
|
+
elapsedMs={elapsedByKey.get(key) || 0}
|
|
342
|
+
eligibility={deriveSwarmWorkflowExecutionEligibility(entry)}
|
|
343
|
+
helperWidgetState={helperWidgetState}
|
|
344
|
+
onStop={() => onStop(entry)}
|
|
345
|
+
onExpandTranscript={onExpandTranscript}
|
|
346
|
+
/>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
<div className="dm-swarm-section-row">
|
|
353
|
+
<span className="dm-run-console__hint">Finished</span>
|
|
354
|
+
{finished.length > 0 && (
|
|
355
|
+
<button type="button" className="dm-btn-ghost" onClick={onClearFinished} title="Hide finished cards from this list — run history stays in source records">
|
|
356
|
+
Clear
|
|
357
|
+
</button>
|
|
358
|
+
)}
|
|
359
|
+
</div>
|
|
360
|
+
{finished.length === 0 && running.length === 0 && (
|
|
361
|
+
<p className="dm-run-console__hint">
|
|
362
|
+
No swarm runs yet. Use /swarm in the composer to propose one — apply
|
|
363
|
+
creates the governed workflow row, then launch it from here.
|
|
364
|
+
</p>
|
|
365
|
+
)}
|
|
366
|
+
{finished.map((entry) => {
|
|
367
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
368
|
+
return (
|
|
369
|
+
<SwarmRunCard
|
|
370
|
+
key={key}
|
|
371
|
+
entry={entry}
|
|
372
|
+
projection={projections.get(key) || null}
|
|
373
|
+
running={false}
|
|
374
|
+
elapsedMs={0}
|
|
375
|
+
eligibility={deriveSwarmWorkflowExecutionEligibility(entry)}
|
|
376
|
+
helperWidgetState={helperWidgetState}
|
|
377
|
+
onLaunch={() => onLaunch(entry)}
|
|
378
|
+
launchDisabled={runningKeys.size > 0}
|
|
379
|
+
onExpandTranscript={onExpandTranscript}
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function SwarmRunCockpit({ workspaceConfig, focus, onConfigRefresh, onExpandTranscript }) {
|
|
388
|
+
const helperWidgetState = useMemo(
|
|
389
|
+
() => deriveHelperWidgetCausationState(workspaceConfig),
|
|
390
|
+
[workspaceConfig]
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Governed workflow rows — the ONLY data source besides run history.
|
|
394
|
+
const workflows = useMemo(() => {
|
|
395
|
+
const all = findSwarmRunRows(workspaceConfig);
|
|
396
|
+
if (!focus) return all;
|
|
397
|
+
const focused = all.filter(
|
|
398
|
+
(entry) => entry.objectId === focus.objectId && String(entry.row?.Name || "") === String(focus.name || "")
|
|
399
|
+
);
|
|
400
|
+
// Tool-output Open is thread-bounded: render only the targeted swarm workflow row.
|
|
401
|
+
return focused;
|
|
402
|
+
}, [workspaceConfig, focus]);
|
|
403
|
+
|
|
404
|
+
// In-flight launches — client state only. Stop aborts the request.
|
|
405
|
+
const [runningKeys, setRunningKeys] = useState(() => new Set());
|
|
406
|
+
const [elapsedByKey, setElapsedByKey] = useState(() => new Map());
|
|
407
|
+
const [hiddenFinished, setHiddenFinished] = useState(() => new Set());
|
|
408
|
+
const [launchError, setLaunchError] = useState("");
|
|
409
|
+
const [liveEventsByKey, setLiveEventsByKey] = useState(() => new Map());
|
|
410
|
+
// Latest run record per workflow, sourced from source-record history so a
|
|
411
|
+
// page refresh keeps runs visible. Falls back to row.lastResponse.
|
|
412
|
+
const [historyByKey, setHistoryByKey] = useState(() => new Map());
|
|
413
|
+
const controllersRef = useRef(new Map());
|
|
414
|
+
const startedRef = useRef(new Map());
|
|
415
|
+
|
|
416
|
+
const refreshHistory = useCallback(async (entries) => {
|
|
417
|
+
const updates = [];
|
|
418
|
+
await Promise.all(entries.map(async (entry) => {
|
|
419
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
420
|
+
try {
|
|
421
|
+
const res = await fetch(
|
|
422
|
+
`/api/workspace/sandbox-run?objectId=${encodeURIComponent(entry.objectId)}&name=${encodeURIComponent(entry.row.Name)}`
|
|
423
|
+
);
|
|
424
|
+
const data = await res.json();
|
|
425
|
+
const latest = Array.isArray(data?.records) && data.records.length > 0 ? data.records[0] : null;
|
|
426
|
+
if (latest) updates.push([key, latest]);
|
|
427
|
+
} catch {
|
|
428
|
+
// Non-fatal — row.lastResponse remains the fallback.
|
|
429
|
+
}
|
|
430
|
+
}));
|
|
431
|
+
if (updates.length > 0) {
|
|
432
|
+
setHistoryByKey((prev) => {
|
|
433
|
+
const next = new Map(prev);
|
|
434
|
+
for (const [key, record] of updates) next.set(key, record);
|
|
435
|
+
return next;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}, []);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (workflows.length > 0) refreshHistory(workflows);
|
|
442
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
443
|
+
}, [workflows.length]);
|
|
444
|
+
|
|
445
|
+
// Elapsed ticker + light history polling while a run is in flight — the
|
|
446
|
+
// poll stays as the convergence/fallback path even when streaming.
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (runningKeys.size === 0) return undefined;
|
|
449
|
+
const tick = setInterval(() => {
|
|
450
|
+
setElapsedByKey(() => {
|
|
451
|
+
const next = new Map();
|
|
452
|
+
for (const [key, startedAt] of startedRef.current) {
|
|
453
|
+
next.set(key, Date.now() - startedAt);
|
|
454
|
+
}
|
|
455
|
+
return next;
|
|
456
|
+
});
|
|
457
|
+
}, 1000);
|
|
458
|
+
const poll = setInterval(() => {
|
|
459
|
+
const active = workflows.filter((entry) => runningKeys.has(runKeyOf(entry.objectId, entry.row.Name)));
|
|
460
|
+
if (active.length > 0) refreshHistory(active);
|
|
461
|
+
}, RUN_POLL_MS);
|
|
462
|
+
return () => { clearInterval(tick); clearInterval(poll); };
|
|
463
|
+
}, [runningKeys, workflows, refreshHistory]);
|
|
464
|
+
|
|
465
|
+
const projections = useMemo(() => {
|
|
466
|
+
const map = new Map();
|
|
467
|
+
for (const entry of workflows) {
|
|
468
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
469
|
+
const skeleton = deriveSwarmGraphProjection(entry.graph, { title: entry.row.Name });
|
|
470
|
+
if (runningKeys.has(key)) {
|
|
471
|
+
const elapsedMs = elapsedByKey.get(key) || 0;
|
|
472
|
+
const liveProjection = deriveSwarmDeltaProjection(entry.graph, liveEventsByKey.get(key) || [], {
|
|
473
|
+
title: entry.row.Name,
|
|
474
|
+
elapsedMs
|
|
475
|
+
});
|
|
476
|
+
// Mid-run: live deltas hydrate the same projection shape as persisted
|
|
477
|
+
// history. If the browser misses a chunk, the skeleton and polling
|
|
478
|
+
// fallback still keep the card truthful until the final record lands.
|
|
479
|
+
if (liveProjection) map.set(key, liveProjection);
|
|
480
|
+
else if (skeleton) map.set(key, { ...skeleton, status: "running", elapsedMs });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const record = historyByKey.get(key) || parseRowRecord(entry.row);
|
|
484
|
+
const projection = record ? deriveSwarmRunProjection(record) : null;
|
|
485
|
+
// Never-run rows show the full declared phase skeleton upfront (P1).
|
|
486
|
+
if (projection) map.set(key, projection);
|
|
487
|
+
else if (skeleton) map.set(key, skeleton);
|
|
488
|
+
}
|
|
489
|
+
return map;
|
|
490
|
+
}, [workflows, historyByKey, runningKeys, elapsedByKey, liveEventsByKey]);
|
|
491
|
+
|
|
492
|
+
const appendLiveEvent = useCallback((key, event) => {
|
|
493
|
+
if (!event || typeof event !== "object") return;
|
|
494
|
+
if (event.kind !== "growthub-sandbox-run-delta-v1") return;
|
|
495
|
+
setLiveEventsByKey((prev) => {
|
|
496
|
+
const next = new Map(prev);
|
|
497
|
+
const prior = next.get(key) || [];
|
|
498
|
+
next.set(key, [...prior, event].slice(-200));
|
|
499
|
+
return next;
|
|
500
|
+
});
|
|
501
|
+
}, []);
|
|
502
|
+
|
|
503
|
+
const readRunStream = useCallback(async ({ response, key }) => {
|
|
504
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
505
|
+
return response.json().catch(() => null);
|
|
506
|
+
}
|
|
507
|
+
const reader = response.body.getReader();
|
|
508
|
+
const decoder = new TextDecoder();
|
|
509
|
+
let buffer = "";
|
|
510
|
+
let finalPayload = null;
|
|
511
|
+
while (true) {
|
|
512
|
+
const { value, done } = await reader.read();
|
|
513
|
+
if (done) break;
|
|
514
|
+
buffer += decoder.decode(value, { stream: true });
|
|
515
|
+
const lines = buffer.split("\n");
|
|
516
|
+
buffer = lines.pop() || "";
|
|
517
|
+
for (const line of lines) {
|
|
518
|
+
const trimmed = line.trim();
|
|
519
|
+
if (!trimmed) continue;
|
|
520
|
+
try {
|
|
521
|
+
const event = JSON.parse(trimmed);
|
|
522
|
+
appendLiveEvent(key, event);
|
|
523
|
+
if (event.type === "sandbox-run.final") finalPayload = event.payload || null;
|
|
524
|
+
} catch {
|
|
525
|
+
// Ignore malformed cosmetic chunks; the final persisted record is
|
|
526
|
+
// still fetched below.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const tail = buffer.trim();
|
|
531
|
+
if (tail) {
|
|
532
|
+
try {
|
|
533
|
+
const event = JSON.parse(tail);
|
|
534
|
+
appendLiveEvent(key, event);
|
|
535
|
+
if (event.type === "sandbox-run.final") finalPayload = event.payload || null;
|
|
536
|
+
} catch {
|
|
537
|
+
// Non-fatal.
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return finalPayload;
|
|
541
|
+
}, [appendLiveEvent]);
|
|
542
|
+
|
|
543
|
+
const launch = useCallback(async (entry) => {
|
|
544
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
545
|
+
if (runningKeys.has(key)) return;
|
|
546
|
+
setLaunchError("");
|
|
547
|
+
const controller = new AbortController();
|
|
548
|
+
controllersRef.current.set(key, controller);
|
|
549
|
+
startedRef.current.set(key, Date.now());
|
|
550
|
+
setRunningKeys((prev) => new Set(prev).add(key));
|
|
551
|
+
setLiveEventsByKey((prev) => {
|
|
552
|
+
const next = new Map(prev);
|
|
553
|
+
next.set(key, []);
|
|
554
|
+
return next;
|
|
555
|
+
});
|
|
556
|
+
setHiddenFinished((prev) => {
|
|
557
|
+
if (!prev.has(key)) return prev;
|
|
558
|
+
const next = new Set(prev);
|
|
559
|
+
next.delete(key);
|
|
560
|
+
return next;
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
const res = await fetch("/api/workspace/sandbox-run", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "content-type": "application/json", accept: "application/x-ndjson" },
|
|
566
|
+
body: JSON.stringify({ objectId: entry.objectId, name: entry.row.Name, stream: true }),
|
|
567
|
+
signal: controller.signal,
|
|
568
|
+
});
|
|
569
|
+
const data = await readRunStream({ response: res, key });
|
|
570
|
+
if (data && data.ok === false && data.error) setLaunchError(String(data.error));
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (err?.name !== "AbortError") setLaunchError(err?.message || "run failed");
|
|
573
|
+
} finally {
|
|
574
|
+
controllersRef.current.delete(key);
|
|
575
|
+
startedRef.current.delete(key);
|
|
576
|
+
setRunningKeys((prev) => {
|
|
577
|
+
const next = new Set(prev);
|
|
578
|
+
next.delete(key);
|
|
579
|
+
return next;
|
|
580
|
+
});
|
|
581
|
+
// The persisted record is the source of truth — converge on it.
|
|
582
|
+
await refreshHistory([entry]);
|
|
583
|
+
if (typeof onConfigRefresh === "function") onConfigRefresh();
|
|
584
|
+
}
|
|
585
|
+
}, [runningKeys, refreshHistory, onConfigRefresh, readRunStream]);
|
|
586
|
+
|
|
587
|
+
const stop = useCallback((entry) => {
|
|
588
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
589
|
+
const controller = controllersRef.current.get(key);
|
|
590
|
+
if (controller) controller.abort();
|
|
591
|
+
}, []);
|
|
592
|
+
|
|
593
|
+
const clearFinished = useCallback(() => {
|
|
594
|
+
setHiddenFinished(() => {
|
|
595
|
+
const next = new Set();
|
|
596
|
+
for (const entry of workflows) {
|
|
597
|
+
const key = runKeyOf(entry.objectId, entry.row.Name);
|
|
598
|
+
if (!runningKeys.has(key)) next.add(key);
|
|
599
|
+
}
|
|
600
|
+
return next;
|
|
601
|
+
});
|
|
602
|
+
}, [workflows, runningKeys]);
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<div className="dm-swarm-cockpit" data-swarm-cockpit="">
|
|
606
|
+
{launchError && (
|
|
607
|
+
<div className="dm-helper-error" role="alert">
|
|
608
|
+
<span>{launchError}</span>
|
|
609
|
+
</div>
|
|
610
|
+
)}
|
|
611
|
+
<SwarmRunList
|
|
612
|
+
workflows={workflows}
|
|
613
|
+
runningKeys={runningKeys}
|
|
614
|
+
elapsedByKey={elapsedByKey}
|
|
615
|
+
projections={projections}
|
|
616
|
+
hiddenFinished={hiddenFinished}
|
|
617
|
+
onStop={stop}
|
|
618
|
+
onLaunch={launch}
|
|
619
|
+
onClearFinished={clearFinished}
|
|
620
|
+
onExpandTranscript={onExpandTranscript}
|
|
621
|
+
helperWidgetState={helperWidgetState}
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
}
|