@growthub/cli 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +99 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +3 -2
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +84 -10
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +2 -2
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +107 -34
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +72 -15
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +264 -22
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +179 -117
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +136 -3
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +61 -13
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +224 -11
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +254 -4
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +10 -2
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +412 -1
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +1 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +23 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +554 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  40. 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
+ }