@growthub/cli 0.13.1 → 0.13.4

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 (33) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +24 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +14 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +74 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +77 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +48 -3
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +123 -27
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +136 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +713 -92
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +224 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxRunPanel.jsx +32 -1
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +514 -9
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +8 -1
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +10 -7
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/RunSetupPanel.jsx +261 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +72 -7
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +778 -140
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +91 -14
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +35 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +384 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +323 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +32 -3
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-eligibility.js +50 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth-redaction.js +64 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +629 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-host-catalog.js +168 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-chart-values.js +542 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +164 -7
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +11 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +111 -1
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
  33. package/package.json +1 -1
@@ -1,22 +1,339 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useState } from "react";
4
- import { ArrowLeft, GitBranch } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { ArrowLeft, Download, GitBranch, Play, RefreshCw, Search, Square } from "lucide-react";
5
5
  import { parseSandboxRunTrace, normalizeRunRecord } from "@/lib/orchestration-run-trace";
6
+ import {
7
+ buildRunTimeline,
8
+ downloadRunBundle,
9
+ filterRunLogTree,
10
+ formatRunDuration,
11
+ normalizeRunConsoleRecord
12
+ } from "@/lib/orchestration-run-console";
6
13
  import { redactSecretsFromText } from "@/lib/orchestration-graph";
7
14
 
15
+ const ACTIVE_STATUSES = new Set(["executing", "queued", "testing", "running"]);
16
+ const LIVE_POLL_MS = 1500;
17
+
18
+ function formatTimestamp(iso) {
19
+ const text = String(iso || "").trim();
20
+ if (!text) return "—";
21
+ const ms = Date.parse(text);
22
+ if (!Number.isFinite(ms)) return text;
23
+ try {
24
+ return new Date(ms).toLocaleString();
25
+ } catch {
26
+ return text;
27
+ }
28
+ }
29
+
30
+ function statusToVariant(status) {
31
+ switch (status) {
32
+ case "completed":
33
+ return "ok";
34
+ case "failed":
35
+ return "fail";
36
+ case "executing":
37
+ case "queued":
38
+ case "testing":
39
+ case "running":
40
+ return "active";
41
+ case "canceled":
42
+ return "canceled";
43
+ default:
44
+ return "neutral";
45
+ }
46
+ }
47
+
48
+ function describeStatus(status) {
49
+ switch (status) {
50
+ case "completed":
51
+ return "Completed";
52
+ case "failed":
53
+ return "Failed";
54
+ case "executing":
55
+ return "Executing";
56
+ case "queued":
57
+ return "Queued";
58
+ case "testing":
59
+ return "Testing";
60
+ case "running":
61
+ return "Running";
62
+ case "canceled":
63
+ return "Canceled";
64
+ default:
65
+ return "Unknown";
66
+ }
67
+ }
68
+
69
+ function previewRecordFromRow(row, rowTrace) {
70
+ const ranAt = rowTrace.ranAt || row?.lastTested || "";
71
+ const previewSource = {
72
+ runId: rowTrace.runId || row?.lastRunId || "",
73
+ ranAt,
74
+ exitCode: rowTrace.exitCode ?? null,
75
+ durationMs: rowTrace.durationMs ?? null,
76
+ error: rowTrace.error || "",
77
+ stdout: rowTrace.stdout || "",
78
+ stderr: rowTrace.stderr || "",
79
+ output: rowTrace.output || "",
80
+ runtime: rowTrace.runtime || row?.runtime || "",
81
+ adapter: rowTrace.adapter || row?.adapter || "",
82
+ runLocality: rowTrace.runLocality || row?.runLocality || "",
83
+ envRefsResolved: rowTrace.envRefsResolved || [],
84
+ envRefsMissing: rowTrace.envRefsMissing || [],
85
+ sourceId: row?.lastSourceId || "",
86
+ version: row?.version || "",
87
+ lifecycleStatus: row?.lifecycleStatus || ""
88
+ };
89
+ return normalizeRunConsoleRecord(previewSource);
90
+ }
91
+
92
+ function bundleToBlobUrl(bundle) {
93
+ const text = JSON.stringify(bundle, null, 2);
94
+ const blob = new Blob([text], { type: "application/json" });
95
+ return URL.createObjectURL(blob);
96
+ }
97
+
98
+ function downloadFile(filename, bundle) {
99
+ if (typeof window === "undefined") return;
100
+ const url = bundleToBlobUrl(bundle);
101
+ const link = document.createElement("a");
102
+ link.href = url;
103
+ link.download = filename;
104
+ document.body.appendChild(link);
105
+ link.click();
106
+ link.remove();
107
+ setTimeout(() => URL.revokeObjectURL(url), 0);
108
+ }
109
+
110
+ function LogTreeNode({ node, depth, selectedId, onSelect, timelineMax }) {
111
+ const isSelected = String(selectedId) === String(node.id);
112
+ const ratio = timelineMax > 0 && node.durationMs > 0
113
+ ? Math.min(1, node.durationMs / timelineMax)
114
+ : 0;
115
+ const variant = statusToVariant(node.status);
116
+ return (
117
+ <div className="dm-run-console__tree-block" data-depth={depth}>
118
+ <button
119
+ type="button"
120
+ className={`dm-run-console__tree-row${isSelected ? " is-active" : ""}`}
121
+ data-variant={variant}
122
+ onClick={() => onSelect(node)}
123
+ title={node.label}
124
+ >
125
+ <span className="dm-run-console__tree-indent" aria-hidden="true" style={{ width: `${depth * 12}px` }} />
126
+ <span className="dm-run-console__tree-dot" data-variant={variant} aria-hidden="true" />
127
+ <span className="dm-run-console__tree-label">{node.label}</span>
128
+ <span className="dm-run-console__tree-meta">{formatRunDuration(node.durationMs)}</span>
129
+ <span className="dm-run-console__tree-bar" aria-hidden="true">
130
+ <span style={{ width: `${Math.round(ratio * 100)}%` }} data-variant={variant} />
131
+ </span>
132
+ </button>
133
+ {Array.isArray(node.children) && node.children.length > 0 ? (
134
+ <div className="dm-run-console__tree-children">
135
+ {node.children.map((child) => (
136
+ <LogTreeNode
137
+ key={child.id}
138
+ node={child}
139
+ depth={depth + 1}
140
+ selectedId={selectedId}
141
+ onSelect={onSelect}
142
+ timelineMax={timelineMax}
143
+ />
144
+ ))}
145
+ </div>
146
+ ) : null}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function LifecycleBlock({ lifecycle }) {
152
+ if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
153
+ return <p className="dm-run-console__hint">Lifecycle timestamps not available.</p>;
154
+ }
155
+ return (
156
+ <ol className="dm-run-console__lifecycle">
157
+ {lifecycle.map((step) => (
158
+ <li key={step.label}>
159
+ <span className="dm-run-console__lifecycle-label">{step.label}</span>
160
+ <span className="dm-run-console__lifecycle-at">{formatTimestamp(step.at)}</span>
161
+ {step.durationMs > 0 ? (
162
+ <span className="dm-run-console__lifecycle-dur">{formatRunDuration(step.durationMs)}</span>
163
+ ) : null}
164
+ </li>
165
+ ))}
166
+ </ol>
167
+ );
168
+ }
169
+
170
+ function KeyValueBlock({ entries }) {
171
+ const list = entries.filter(([, value]) => value !== "" && value != null);
172
+ if (!list.length) return null;
173
+ return (
174
+ <dl className="dm-run-console__kv">
175
+ {list.map(([label, value]) => (
176
+ <div key={label}>
177
+ <dt>{label}</dt>
178
+ <dd>{value}</dd>
179
+ </div>
180
+ ))}
181
+ </dl>
182
+ );
183
+ }
184
+
185
+ function CodeBlock({ label, body }) {
186
+ if (!body) return null;
187
+ return (
188
+ <div className="dm-run-console__code">
189
+ <span>{label}</span>
190
+ <pre>{body}</pre>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function downloadText(filename, text, mime = "text/plain") {
196
+ if (typeof window === "undefined") return;
197
+ const blob = new Blob([text || ""], { type: mime });
198
+ const url = URL.createObjectURL(blob);
199
+ const link = document.createElement("a");
200
+ link.href = url;
201
+ link.download = filename;
202
+ document.body.appendChild(link);
203
+ link.click();
204
+ link.remove();
205
+ setTimeout(() => URL.revokeObjectURL(url), 0);
206
+ }
207
+
208
+ async function copyToClipboard(text) {
209
+ if (typeof navigator === "undefined" || !navigator?.clipboard?.writeText) return false;
210
+ try {
211
+ await navigator.clipboard.writeText(text || "");
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function InputsSection({ payload }) {
219
+ const runInputs = payload?.runInputs;
220
+ const summary = payload?.inputSummary;
221
+ if (!runInputs && (!summary || summary.fieldCount === 0)) {
222
+ return (
223
+ <section className="dm-run-console__section">
224
+ <h3>Inputs</h3>
225
+ <p className="dm-run-console__hint">No manual inputs were submitted for this run.</p>
226
+ </section>
227
+ );
228
+ }
229
+ const valueEntries = runInputs && runInputs.values && typeof runInputs.values === "object"
230
+ ? Object.entries(runInputs.values)
231
+ : [];
232
+ return (
233
+ <section className="dm-run-console__section">
234
+ <h3>Inputs</h3>
235
+ <KeyValueBlock
236
+ entries={[
237
+ ["Source", summary?.source || runInputs?.source || "manual"],
238
+ ["Fields", String(summary?.fieldCount ?? valueEntries.length)],
239
+ ["Files", String(summary?.fileCount ?? (Array.isArray(runInputs?.files) ? runInputs.files.length : 0))]
240
+ ]}
241
+ />
242
+ {valueEntries.length > 0 ? (
243
+ <dl className="dm-run-console__kv">
244
+ {valueEntries.map(([key, value]) => {
245
+ const isSecretRef = value && typeof value === "object" && "secretRef" in value;
246
+ const display = isSecretRef
247
+ ? `secretRef: ${String(value.secretRef || "[redacted]")}`
248
+ : typeof value === "string"
249
+ ? value
250
+ : JSON.stringify(value);
251
+ return (
252
+ <div key={key}>
253
+ <dt>{key}</dt>
254
+ <dd>{display}</dd>
255
+ </div>
256
+ );
257
+ })}
258
+ </dl>
259
+ ) : null}
260
+ </section>
261
+ );
262
+ }
263
+
264
+ function ExportActions({ exports, output, record, selectedLogNode }) {
265
+ const available = Array.isArray(exports?.available) ? exports.available : [];
266
+ if (available.length === 0) {
267
+ return <p className="dm-run-console__hint">No exports available for this run.</p>;
268
+ }
269
+ const runLabel = String(record?.runId || "run").replace(/[^a-zA-Z0-9_-]+/g, "-") || "run";
270
+ const [copied, setCopied] = useState("");
271
+
272
+ async function handleCopy() {
273
+ const ok = await copyToClipboard(output?.normalizedOutput || output?.stdout || "");
274
+ setCopied(ok ? "Output copied" : "Clipboard unavailable");
275
+ setTimeout(() => setCopied(""), 1600);
276
+ }
277
+
278
+ return (
279
+ <div className="dm-run-console__exports">
280
+ {available.includes("copy-output") && (
281
+ <button type="button" className="dm-btn-outline" onClick={handleCopy}>Copy output</button>
282
+ )}
283
+ {available.includes("download-stdout") && output?.stdout && (
284
+ <button
285
+ type="button"
286
+ className="dm-btn-outline"
287
+ onClick={() => downloadText(`growthub-run-${runLabel}-stdout.txt`, output.stdout)}
288
+ >Download stdout</button>
289
+ )}
290
+ {available.includes("download-stderr") && output?.stderr && (
291
+ <button
292
+ type="button"
293
+ className="dm-btn-outline"
294
+ onClick={() => downloadText(`growthub-run-${runLabel}-stderr.txt`, output.stderr)}
295
+ >Download stderr</button>
296
+ )}
297
+ {available.includes("download-normalized-output") && output?.normalizedOutput && output.normalizedOutput !== output.stdout && (
298
+ <button
299
+ type="button"
300
+ className="dm-btn-outline"
301
+ onClick={() => downloadText(`growthub-run-${runLabel}-output.json`, output.normalizedOutput, "application/json")}
302
+ >Download output JSON</button>
303
+ )}
304
+ {available.includes("download-log-node") && selectedLogNode?.text && (
305
+ <button
306
+ type="button"
307
+ className="dm-btn-outline"
308
+ onClick={() => downloadText(`growthub-run-${runLabel}-${selectedLogNode.id || "log"}.txt`, selectedLogNode.text)}
309
+ >Download selected log node</button>
310
+ )}
311
+ {copied && <span className="dm-run-console__export-toast">{copied}</span>}
312
+ </div>
313
+ );
314
+ }
315
+
8
316
  export function OrchestrationRunTracePanel({
9
317
  row,
10
318
  objectId,
11
319
  fieldName,
12
320
  selectedRunId,
13
321
  onBack,
14
- onOpenGraph
322
+ onOpenGraph,
323
+ onReplay,
324
+ running
15
325
  }) {
16
326
  const [history, setHistory] = useState([]);
17
327
  const [historyMessage, setHistoryMessage] = useState("");
18
328
  const [loading, setLoading] = useState(false);
19
329
  const [activeRunId, setActiveRunId] = useState(String(selectedRunId || row?.lastRunId || "").trim());
330
+ const [query, setQuery] = useState("");
331
+ const [errorsOnly, setErrorsOnly] = useState(false);
332
+ const [showQueueTime, setShowQueueTime] = useState(false);
333
+ const [activeDetailTab, setActiveDetailTab] = useState("overview");
334
+ const [selectedLogId, setSelectedLogId] = useState("root");
335
+ const [replayPending, setReplayPending] = useState(false);
336
+ const abortRef = useRef(null);
20
337
 
21
338
  const rowTrace = useMemo(() => parseSandboxRunTrace(row?.lastResponse), [row?.lastResponse]);
22
339
 
@@ -24,136 +341,440 @@ export function OrchestrationRunTracePanel({
24
341
  setActiveRunId(String(selectedRunId || row?.lastRunId || "").trim());
25
342
  }, [selectedRunId, row?.lastRunId, row?.lastResponse]);
26
343
 
27
- useEffect(() => {
344
+ const loadHistory = useCallback(async (signal) => {
28
345
  const objectIdValue = String(objectId || "").trim();
29
346
  const name = String(row?.Name || "").trim();
30
347
  if (!objectIdValue || !name) return;
31
348
  setLoading(true);
32
349
  setHistoryMessage("");
33
- fetch(`/api/workspace/sandbox-run?objectId=${encodeURIComponent(objectIdValue)}&name=${encodeURIComponent(name)}`, {
34
- cache: "no-store"
35
- })
36
- .then((res) => res.json())
37
- .then((payload) => {
38
- if (!payload.ok) throw new Error(payload.error || "Could not load run history");
39
- setHistory(Array.isArray(payload.records) ? payload.records.map(normalizeRunRecord).filter(Boolean) : []);
40
- setHistoryMessage(`${payload.recordCount || 0} saved run${payload.recordCount === 1 ? "" : "s"}`);
41
- })
42
- .catch((err) => {
43
- setHistory([]);
44
- setHistoryMessage(err.message || "Could not load run history");
45
- })
46
- .finally(() => setLoading(false));
350
+ try {
351
+ const res = await fetch(
352
+ `/api/workspace/sandbox-run?objectId=${encodeURIComponent(objectIdValue)}&name=${encodeURIComponent(name)}`,
353
+ { cache: "no-store", signal }
354
+ );
355
+ const payload = await res.json();
356
+ if (!payload.ok) throw new Error(payload.error || "Could not load run history");
357
+ const normalized = Array.isArray(payload.records)
358
+ ? payload.records.map(normalizeRunRecord).filter(Boolean)
359
+ : [];
360
+ setHistory(normalized);
361
+ setHistoryMessage(`${payload.recordCount || normalized.length} saved run${(payload.recordCount || normalized.length) === 1 ? "" : "s"}`);
362
+ } catch (err) {
363
+ if (err?.name === "AbortError") return;
364
+ setHistory([]);
365
+ setHistoryMessage(err.message || "Could not load run history");
366
+ } finally {
367
+ setLoading(false);
368
+ }
47
369
  }, [objectId, row?.Name]);
48
370
 
49
- const activeRecord = useMemo(() => {
371
+ useEffect(() => {
372
+ const controller = new AbortController();
373
+ loadHistory(controller.signal);
374
+ return () => controller.abort();
375
+ }, [loadHistory]);
376
+
377
+ const isClientRunning = Boolean(running) || replayPending;
378
+ const liveReloading = isClientRunning;
379
+
380
+ useEffect(() => {
381
+ if (!liveReloading) return undefined;
382
+ const id = setInterval(() => {
383
+ loadHistory();
384
+ }, LIVE_POLL_MS);
385
+ return () => clearInterval(id);
386
+ }, [liveReloading, loadHistory]);
387
+
388
+ const activeRawRecord = useMemo(() => {
50
389
  if (activeRunId && history.length) {
51
390
  const match = history.find((r) => r.runId === activeRunId);
52
391
  if (match) return match;
53
392
  }
54
393
  if (fieldName === "lastResponse" || !activeRunId) {
55
- return {
56
- runId: rowTrace.runId || row?.lastRunId || "",
57
- ranAt: rowTrace.ranAt || row?.lastTested || "",
58
- exitCode: rowTrace.exitCode,
59
- durationMs: rowTrace.durationMs,
60
- error: rowTrace.error,
61
- stdout: rowTrace.stdout,
62
- stderr: rowTrace.stderr,
63
- output: rowTrace.output
64
- };
394
+ return null;
65
395
  }
66
396
  return history[0] || null;
67
- }, [activeRunId, history, rowTrace, fieldName, row?.lastRunId, row?.lastTested]);
397
+ }, [activeRunId, history, fieldName]);
398
+
399
+ const activeConsoleRecord = useMemo(() => {
400
+ if (activeRawRecord) return normalizeRunConsoleRecord(activeRawRecord);
401
+ return previewRecordFromRow(row, rowTrace);
402
+ }, [activeRawRecord, row, rowTrace]);
68
403
 
69
- const summary = fieldName === "lastSourceId"
404
+ const timeline = useMemo(() => buildRunTimeline(history), [history]);
405
+ const timelineMax = timeline.reduce((m, item) => Math.max(m, item.durationMs || 0), 0);
406
+
407
+ const filteredTree = useMemo(() => (
408
+ filterRunLogTree(activeConsoleRecord?.logTree || [], { query, errorsOnly })
409
+ ), [activeConsoleRecord, query, errorsOnly]);
410
+
411
+ const selectedLogNode = useMemo(() => {
412
+ const tree = activeConsoleRecord?.logTree || [];
413
+ const stack = [...tree];
414
+ while (stack.length) {
415
+ const node = stack.pop();
416
+ if (!node) continue;
417
+ if (String(node.id) === String(selectedLogId)) return node;
418
+ if (Array.isArray(node.children)) stack.push(...node.children);
419
+ }
420
+ return tree[0] || null;
421
+ }, [activeConsoleRecord, selectedLogId]);
422
+
423
+ useEffect(() => {
424
+ setSelectedLogId("root");
425
+ }, [activeRunId]);
426
+
427
+ const summaryText = fieldName === "lastSourceId"
70
428
  ? `Source ${String(row?.lastSourceId || "").trim()}`
71
429
  : fieldName === "lastRunId"
72
430
  ? `Run ${String(row?.lastRunId || activeRunId || "").trim()}`
73
431
  : "Latest sandbox run";
74
432
 
433
+ const statusVariant = statusToVariant(activeConsoleRecord?.status);
434
+ const statusLabel = describeStatus(activeConsoleRecord?.status);
435
+
436
+ const canReplay = typeof onReplay === "function";
437
+
438
+ function handleReplay() {
439
+ if (!canReplay) return;
440
+ setReplayPending(true);
441
+ const controller = new AbortController();
442
+ abortRef.current = controller;
443
+ try {
444
+ const result = onReplay({ signal: controller.signal });
445
+ Promise.resolve(result)
446
+ .catch(() => {})
447
+ .finally(() => {
448
+ setReplayPending(false);
449
+ abortRef.current = null;
450
+ loadHistory();
451
+ });
452
+ } catch {
453
+ setReplayPending(false);
454
+ abortRef.current = null;
455
+ }
456
+ }
457
+
458
+ function handleCancel() {
459
+ if (abortRef.current) {
460
+ try { abortRef.current.abort(); } catch { /* noop */ }
461
+ }
462
+ setReplayPending(false);
463
+ }
464
+
465
+ function handleDownload() {
466
+ if (!activeConsoleRecord) return;
467
+ const bundle = downloadRunBundle({
468
+ record: activeRawRecord || activeConsoleRecord,
469
+ runId: activeConsoleRecord.runId,
470
+ sourceId: activeConsoleRecord.sourceId || row?.lastSourceId || ""
471
+ });
472
+ const runLabel = String(bundle.runId || "run").replace(/[^a-zA-Z0-9_-]+/g, "-") || "run";
473
+ downloadFile(`growthub-run-${runLabel}.json`, bundle);
474
+ }
475
+
476
+ const lifecycleEntries = activeConsoleRecord?.lifecycle || [];
477
+ const payload = activeConsoleRecord?.payload || {};
478
+ const output = activeConsoleRecord?.output || {};
479
+ const context = activeConsoleRecord?.context || {};
480
+
75
481
  return (
76
- <section className="dm-orchestration-trace" aria-label="Run trace viewer">
77
- <header className="dm-orchestration-trace__head">
78
- <button type="button" className="dm-orchestration-header__back" onClick={onBack} aria-label="Back to record">
79
- <ArrowLeft size={16} />
80
- </button>
81
- <div>
82
- <h2>Run trace</h2>
83
- <p>{summary} · {row?.Name || "Sandbox tool"}</p>
84
- </div>
85
- {onOpenGraph && (
86
- <button type="button" className="dm-btn-outline" onClick={onOpenGraph}>
87
- <GitBranch size={14} aria-hidden="true" />
88
- Edit graph
482
+ <section className="dm-run-console" aria-label="Live runs console">
483
+ <header className="dm-run-console__head">
484
+ {onBack && (
485
+ <button type="button" className="dm-orchestration-header__back" onClick={onBack} aria-label="Back to record">
486
+ <ArrowLeft size={16} />
89
487
  </button>
90
488
  )}
91
- </header>
92
-
93
- <div className="dm-orchestration-trace__body">
94
- <aside className="dm-orchestration-trace__list">
95
- <p className="dm-orchestration-trace__list-title">Run history</p>
96
- {loading && <p className="dm-orchestration-config__hint">Loading…</p>}
97
- {!loading && historyMessage && <p className="dm-orchestration-config__hint">{historyMessage}</p>}
489
+ <div className="dm-run-console__head-titles">
490
+ <span className="dm-run-console__crumbs">
491
+ Runs <span aria-hidden="true">/</span> <code>{activeConsoleRecord?.runId || "preview"}</code>
492
+ </span>
493
+ <h2>Run console</h2>
494
+ <p>{summaryText} · {row?.Name || "Sandbox tool"}</p>
495
+ </div>
496
+ <div className="dm-run-console__head-actions">
497
+ {canReplay && (
498
+ <button
499
+ type="button"
500
+ className="dm-btn-outline"
501
+ onClick={handleReplay}
502
+ disabled={isClientRunning}
503
+ title="Replay current saved config"
504
+ >
505
+ <Play size={13} aria-hidden="true" />
506
+ {replayPending ? "Replaying" : "Replay current config"}
507
+ </button>
508
+ )}
509
+ {isClientRunning && (
510
+ <button
511
+ type="button"
512
+ className="dm-btn-outline dm-run-console__cancel"
513
+ onClick={handleCancel}
514
+ title="Cancel the in-flight client request"
515
+ >
516
+ <Square size={13} aria-hidden="true" />
517
+ Cancel request
518
+ </button>
519
+ )}
98
520
  <button
99
521
  type="button"
100
- className={`dm-orchestration-trace__run${!activeRunId ? " is-active" : ""}`}
101
- onClick={() => setActiveRunId("")}
522
+ className="dm-btn-outline"
523
+ onClick={handleDownload}
524
+ disabled={!activeConsoleRecord}
525
+ title="Download redacted JSON log for this run"
102
526
  >
103
- <span>Row preview</span>
104
- <span>{row?.status || rowTrace.status || "—"} · {row?.lastTested || rowTrace.ranAt || "—"}</span>
527
+ <Download size={13} aria-hidden="true" />
528
+ Download logs
105
529
  </button>
106
- {history.map((record) => (
530
+ {onOpenGraph && (
531
+ <button type="button" className="dm-btn-outline" onClick={onOpenGraph}>
532
+ <GitBranch size={13} aria-hidden="true" />
533
+ Edit graph
534
+ </button>
535
+ )}
536
+ </div>
537
+ </header>
538
+
539
+ <div className="dm-run-console__split">
540
+ <aside className="dm-run-console__left">
541
+ <div className="dm-run-console__toolbar">
542
+ <label className="dm-run-console__search">
543
+ <Search size={12} aria-hidden="true" />
544
+ <input
545
+ type="search"
546
+ placeholder="Search log"
547
+ value={query}
548
+ onChange={(e) => setQuery(e.target.value)}
549
+ aria-label="Search log"
550
+ />
551
+ </label>
552
+ <label className="dm-run-console__toggle">
553
+ <input
554
+ type="checkbox"
555
+ checked={showQueueTime}
556
+ onChange={(e) => setShowQueueTime(e.target.checked)}
557
+ />
558
+ <span>Queue time</span>
559
+ </label>
560
+ <label className="dm-run-console__toggle">
561
+ <input
562
+ type="checkbox"
563
+ checked={errorsOnly}
564
+ onChange={(e) => setErrorsOnly(e.target.checked)}
565
+ />
566
+ <span>Errors only</span>
567
+ </label>
568
+ <span
569
+ className={`dm-run-console__live${liveReloading ? " is-on" : ""}`}
570
+ aria-live="polite"
571
+ >
572
+ <span className="dm-run-console__live-dot" aria-hidden="true" />
573
+ {liveReloading ? "Live reloading" : "Idle"}
574
+ </span>
575
+ </div>
576
+
577
+ <div className="dm-run-console__history">
578
+ <p className="dm-run-console__history-title">Run history</p>
579
+ {loading && <p className="dm-run-console__hint">Loading…</p>}
580
+ {!loading && historyMessage && <p className="dm-run-console__hint">{historyMessage}</p>}
107
581
  <button
108
- key={record.runId || record.ranAt}
109
582
  type="button"
110
- className={`dm-orchestration-trace__run${activeRunId === record.runId ? " is-active" : ""}`}
111
- onClick={() => setActiveRunId(record.runId)}
583
+ className={`dm-run-console__history-row${!activeRunId ? " is-active" : ""}`}
584
+ onClick={() => setActiveRunId("")}
112
585
  >
113
- <span>{record.runId || "run"}</span>
114
- <span>
115
- {record.exitCode === 0 && !record.error ? "success" : "failed"}
116
- {record.ranAt ? ` · ${record.ranAt}` : ""}
586
+ <span className="dm-run-console__history-label">Row preview</span>
587
+ <span className="dm-run-console__history-meta">
588
+ {row?.status || rowTrace.status || ""} · {formatTimestamp(row?.lastTested || rowTrace.ranAt)}
117
589
  </span>
118
590
  </button>
119
- ))}
120
- </aside>
591
+ {history.map((record) => {
592
+ const isActive = activeRunId === record.runId;
593
+ const variant = statusToVariant(normalizeRunConsoleRecord(record)?.status);
594
+ return (
595
+ <button
596
+ key={record.runId || record.ranAt}
597
+ type="button"
598
+ className={`dm-run-console__history-row${isActive ? " is-active" : ""}`}
599
+ data-variant={variant}
600
+ onClick={() => setActiveRunId(record.runId)}
601
+ >
602
+ <span className="dm-run-console__history-label">
603
+ <span className="dm-run-console__tree-dot" data-variant={variant} aria-hidden="true" />
604
+ {record.runId || "run"}
605
+ </span>
606
+ <span className="dm-run-console__history-meta">
607
+ {record.exitCode === 0 && !record.error ? "completed" : "failed"} · {formatTimestamp(record.ranAt)}
608
+ </span>
609
+ </button>
610
+ );
611
+ })}
612
+ </div>
613
+
614
+ <div className="dm-run-console__tree">
615
+ <p className="dm-run-console__history-title">Log tree</p>
616
+ {filteredTree.length === 0 && (
617
+ <p className="dm-run-console__hint">
618
+ {errorsOnly ? "No error entries in this run." : "No log entries yet."}
619
+ </p>
620
+ )}
621
+ {filteredTree.map((node) => (
622
+ <LogTreeNode
623
+ key={node.id}
624
+ node={node}
625
+ depth={0}
626
+ selectedId={selectedLogNode?.id}
627
+ onSelect={(n) => setSelectedLogId(n.id)}
628
+ timelineMax={timelineMax}
629
+ />
630
+ ))}
631
+ </div>
121
632
 
122
- <div className="dm-orchestration-trace__detail">
123
- <dl className="dm-orchestration-trace__meta">
124
- <div><dt>Status</dt><dd>{row?.status || rowTrace.status || (activeRecord?.exitCode === 0 ? "connected" : "—")}</dd></div>
125
- <div><dt>Run ID</dt><dd>{activeRecord?.runId || row?.lastRunId || "—"}</dd></div>
126
- <div><dt>Exit code</dt><dd>{activeRecord?.exitCode ?? rowTrace.exitCode ?? "—"}</dd></div>
127
- <div><dt>Duration</dt><dd>{activeRecord?.durationMs ?? rowTrace.durationMs ?? "—"} ms</dd></div>
128
- <div><dt>Runtime</dt><dd>{rowTrace.runtime || row?.runtime || ""}</dd></div>
129
- <div><dt>Adapter</dt><dd>{rowTrace.adapter || row?.adapter || ""}</dd></div>
130
- <div><dt>Run locality</dt><dd>{rowTrace.runLocality || row?.runLocality || ""}</dd></div>
131
- <div><dt>Tested</dt><dd>{activeRecord?.ranAt || row?.lastTested || rowTrace.ranAt || "—"}</dd></div>
132
- </dl>
133
-
134
- {(activeRecord?.error || rowTrace.error) && (
135
- <div className="dm-orchestration-trace__error">
136
- <span>Error</span>
137
- <pre>{redactSecretsFromText(activeRecord?.error || rowTrace.error)}</pre>
633
+ {showQueueTime && timeline.length > 0 && (
634
+ <div className="dm-run-console__timeline">
635
+ <p className="dm-run-console__history-title">Timeline</p>
636
+ {timeline.map((item) => {
637
+ const variant = statusToVariant(item.status);
638
+ return (
639
+ <div key={item.runId || item.ranAt} className="dm-run-console__timeline-row" data-variant={variant}>
640
+ <span className="dm-run-console__timeline-label" title={item.runId}>{item.runId || "run"}</span>
641
+ <span className="dm-run-console__timeline-bar" aria-hidden="true">
642
+ <span style={{ width: `${Math.round((item.barRatio || 0) * 100)}%` }} data-variant={variant} />
643
+ </span>
644
+ <span className="dm-run-console__timeline-dur">{formatRunDuration(item.durationMs)}</span>
645
+ </div>
646
+ );
647
+ })}
138
648
  </div>
139
649
  )}
650
+ </aside>
140
651
 
141
- <div className="dm-orchestration-trace__output">
142
- <span>Stdout</span>
143
- <pre>{redactSecretsFromText(activeRecord?.stdout || rowTrace.stdout || "")}</pre>
652
+ <div className="dm-run-console__right">
653
+ <div className="dm-run-console__detail-head">
654
+ <div className="dm-run-console__detail-title">
655
+ <span className={`dm-run-console__status-pill is-${statusVariant}`}>{statusLabel}</span>
656
+ <strong>{selectedLogNode?.label || "agent-run"}</strong>
657
+ <small>{activeConsoleRecord?.runId || "—"}</small>
658
+ </div>
659
+ <div className="dm-run-console__detail-tabs" role="tablist">
660
+ {["overview", "detail", "context"].map((tab) => (
661
+ <button
662
+ key={tab}
663
+ type="button"
664
+ role="tab"
665
+ className={`dm-run-console__detail-tab${activeDetailTab === tab ? " is-active" : ""}`}
666
+ onClick={() => setActiveDetailTab(tab)}
667
+ >
668
+ {tab === "overview" ? "Overview" : tab === "detail" ? "Detail" : "Context"}
669
+ </button>
670
+ ))}
671
+ </div>
144
672
  </div>
145
673
 
146
- {(activeRecord?.output || rowTrace.output) && (
147
- <div className="dm-orchestration-trace__output">
148
- <span>Normalized output</span>
149
- <pre>{redactSecretsFromText(activeRecord?.output || rowTrace.output)}</pre>
674
+ {activeDetailTab === "overview" && (
675
+ <div className="dm-run-console__detail-body">
676
+ <section className="dm-run-console__section">
677
+ <h3>Lifecycle</h3>
678
+ <LifecycleBlock lifecycle={lifecycleEntries} />
679
+ </section>
680
+ <section className="dm-run-console__section">
681
+ <h3>Summary</h3>
682
+ <KeyValueBlock
683
+ entries={[
684
+ ["Status", statusLabel],
685
+ ["Run ID", activeConsoleRecord?.runId || "—"],
686
+ ["Exit code", activeConsoleRecord?.exitCode == null ? "—" : String(activeConsoleRecord.exitCode)],
687
+ ["Duration", formatRunDuration(activeConsoleRecord?.durationMs)],
688
+ ["Runtime", activeConsoleRecord?.runtime || "—"],
689
+ ["Adapter", activeConsoleRecord?.adapter || "—"],
690
+ ["Run locality", activeConsoleRecord?.runLocality || "—"],
691
+ ["Tested", formatTimestamp(activeConsoleRecord?.ranAt)],
692
+ ["Finished", formatTimestamp(activeConsoleRecord?.finishedAt)]
693
+ ]}
694
+ />
695
+ </section>
696
+ <section className="dm-run-console__section">
697
+ <h3>Payload</h3>
698
+ <KeyValueBlock
699
+ entries={[
700
+ ["Object", payload.objectId || "—"],
701
+ ["Name", payload.name || row?.Name || "—"],
702
+ ["Runtime", payload.runtime || "—"],
703
+ ["Adapter", payload.adapter || "—"],
704
+ ["Version", payload.version || "—"],
705
+ ["Agent host", payload.agentHost || "—"],
706
+ ["Scheduler", payload.schedulerRegistryId || "—"],
707
+ ["Timeout", payload.timeoutMs ? `${payload.timeoutMs} ms` : "—"]
708
+ ]}
709
+ />
710
+ <CodeBlock label="Command" body={payload.command} />
711
+ <CodeBlock label="Instructions" body={payload.instructions} />
712
+ </section>
713
+ <InputsSection payload={payload} />
714
+ </div>
715
+ )}
716
+
717
+ {activeDetailTab === "detail" && (
718
+ <div className="dm-run-console__detail-body">
719
+ <section className="dm-run-console__section">
720
+ <h3>Export</h3>
721
+ <ExportActions
722
+ exports={activeConsoleRecord?.exports}
723
+ output={output}
724
+ record={activeConsoleRecord}
725
+ selectedLogNode={selectedLogNode}
726
+ />
727
+ </section>
728
+ <section className="dm-run-console__section">
729
+ <h3>Output</h3>
730
+ <CodeBlock label="Error" body={output.error} />
731
+ <CodeBlock label="Stdout" body={output.stdout || "—"} />
732
+ {output.normalizedOutput && output.normalizedOutput !== output.stdout && (
733
+ <CodeBlock label="Normalized output" body={output.normalizedOutput} />
734
+ )}
735
+ <CodeBlock label="Stderr" body={output.stderr} />
736
+ </section>
737
+ {selectedLogNode?.text ? (
738
+ <section className="dm-run-console__section">
739
+ <h3>{selectedLogNode.label}</h3>
740
+ <CodeBlock label={selectedLogNode.type || "log"} body={selectedLogNode.text} />
741
+ </section>
742
+ ) : null}
150
743
  </div>
151
744
  )}
152
745
 
153
- {(activeRecord?.stderr || rowTrace.stderr) && (
154
- <div className="dm-orchestration-trace__output">
155
- <span>Stderr</span>
156
- <pre>{redactSecretsFromText(activeRecord?.stderr || rowTrace.stderr)}</pre>
746
+ {activeDetailTab === "context" && (
747
+ <div className="dm-run-console__detail-body">
748
+ <section className="dm-run-console__section">
749
+ <h3>Environment</h3>
750
+ <KeyValueBlock
751
+ entries={[
752
+ ["Network allow", context.networkAllow ? "true" : "false"],
753
+ ["Allow list", context.allowList?.join(", ") || "—"],
754
+ ["Env refs resolved", context.envRefsResolved?.join(", ") || "—"],
755
+ ["Env refs missing", context.envRefsMissing?.join(", ") || "—"],
756
+ ["Source ID", activeConsoleRecord?.sourceId || row?.lastSourceId || "—"]
757
+ ]}
758
+ />
759
+ </section>
760
+ {context.adapterMeta && (
761
+ <section className="dm-run-console__section">
762
+ <h3>Adapter metadata</h3>
763
+ <CodeBlock
764
+ label="adapterMeta"
765
+ body={redactSecretsFromText(JSON.stringify(context.adapterMeta, null, 2))}
766
+ />
767
+ </section>
768
+ )}
769
+ {context.templateTrace && (
770
+ <section className="dm-run-console__section">
771
+ <h3>Template trace</h3>
772
+ <CodeBlock
773
+ label="templateTrace"
774
+ body={redactSecretsFromText(JSON.stringify(context.templateTrace, null, 2))}
775
+ />
776
+ </section>
777
+ )}
157
778
  </div>
158
779
  )}
159
780
  </div>