@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
@@ -232,6 +232,50 @@ function filterNavFolderRows(rows, query, typeFilter) {
232
232
  });
233
233
  }
234
234
 
235
+ /**
236
+ * Derive which nav-folder item is "active" for the current route. The
237
+ * destination URLs here mirror `openDashboardItem`/`openViewItem`/
238
+ * `openWorkflowItem` below — so a freshly mounted rail can recover the
239
+ * active item (and therefore its parent folder) purely from
240
+ * pathname + searchParams, without depending on transient client state.
241
+ */
242
+ function isNavItemActive(item, pathname, searchParams) {
243
+ if (!item || !pathname) return false;
244
+ const get = (key) => (searchParams && typeof searchParams.get === "function"
245
+ ? searchParams.get(key)
246
+ : null);
247
+ if (item.type === "dashboard") {
248
+ return pathname === "/" && get("dashboard") === String(item.refId || "");
249
+ }
250
+ if (item.type === "view") {
251
+ return pathname.startsWith("/data-model")
252
+ && get("object") === String(item.objectId || "");
253
+ }
254
+ if (item.type === "workflow") {
255
+ return pathname.startsWith("/workflows")
256
+ && get("object") === String(item.objectId || "")
257
+ && get("row") === String(item.rowId || "");
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /**
263
+ * Walk the persisted nav-folder rows and return the folder id whose item
264
+ * matches the active route, or null if none do. This is the anchor the
265
+ * rail uses to keep the parent folder open while the user navigates
266
+ * between its dashboards / views / workflows.
267
+ */
268
+ function deriveActiveNavFolderId(rows, pathname, searchParams) {
269
+ if (!Array.isArray(rows) || !rows.length) return null;
270
+ for (const folder of rows) {
271
+ const items = Array.isArray(folder?.items) ? folder.items : [];
272
+ for (const item of items) {
273
+ if (isNavItemActive(item, pathname, searchParams)) return folder.id;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
235
279
  function hexToTintBg(hex, alpha = 0.1) {
236
280
  const h = String(hex || "").replace("#", "");
237
281
  if (!/^[0-9a-f]{6}$/i.test(h)) return "#f5f5f5";
@@ -415,6 +459,19 @@ function NavFoldersSection({
415
459
  const dashboards = useMemo(() => listAvailableDashboards(workspaceConfig), [workspaceConfig]);
416
460
  const viewableObjects = useMemo(() => listAvailableObjectsForViews(workspaceConfig), [workspaceConfig]);
417
461
  const workflows = useMemo(() => listAvailableWorkflows(workspaceConfig), [workspaceConfig]);
462
+ const activeFolderId = useMemo(
463
+ () => deriveActiveNavFolderId(rows, pathname, searchParams),
464
+ [rows, pathname, searchParams],
465
+ );
466
+
467
+ // When the user lands on a route owned by a folder item (deep-link,
468
+ // reload, cross-page navigation), expand the Folders section so the
469
+ // active folder/item is visible without an extra click. Manual
470
+ // collapse still wins — the effect only fires when activeFolderId
471
+ // changes, not on every render.
472
+ useEffect(() => {
473
+ if (activeFolderId) setSectionCollapsed(false);
474
+ }, [activeFolderId]);
418
475
  const filteredEntries = useMemo(
419
476
  () => filterNavFolderRows(rows, filterQuery, filterType),
420
477
  [rows, filterQuery, filterType],
@@ -875,7 +932,10 @@ function NavFoldersSection({
875
932
  type="button"
876
933
  role="menuitem"
877
934
  className="workspace-rail-thread-menu-item"
878
- onClick={() => startCustomizeItem(folder, item)}
935
+ onClick={(e) => {
936
+ e.stopPropagation();
937
+ startCustomizeItem(folder, item);
938
+ }}
879
939
  >
880
940
  <Pencil size={13} aria-hidden="true" /> Customize
881
941
  </button>
@@ -883,7 +943,10 @@ function NavFoldersSection({
883
943
  type="button"
884
944
  role="menuitem"
885
945
  className="workspace-rail-thread-menu-item is-destructive"
886
- onClick={() => deleteItem(folder.id, item.id)}
946
+ onClick={(e) => {
947
+ e.stopPropagation();
948
+ deleteItem(folder.id, item.id);
949
+ }}
887
950
  >
888
951
  <Trash2 size={13} aria-hidden="true" /> Remove
889
952
  </button>
@@ -896,11 +959,7 @@ function NavFoldersSection({
896
959
  const renderItemRow = (folder, item) => {
897
960
  const composedId = `${folder.id}::${item.id}`;
898
961
  const isMenuOpen = openMenuId === composedId;
899
- const isActive = item.type === "view" && pathname.startsWith(`/views/${encodeURIComponent(item.id)}`)
900
- || (item.type === "workflow"
901
- && pathname.startsWith("/workflows")
902
- && searchParams.get("object") === item.objectId
903
- && searchParams.get("row") === item.rowId);
962
+ const isActive = isNavItemActive(item, pathname, searchParams);
904
963
  const style = navItemStyle(item);
905
964
  const typeHint = item.type === "dashboard" ? "Dashboard" : item.type === "workflow" ? "Workflow" : "View";
906
965
  return (
@@ -918,7 +977,11 @@ function NavFoldersSection({
918
977
  <button
919
978
  type="button"
920
979
  className="workspace-rail-nav-row-main"
921
- onClick={() => {
980
+ onClick={(e) => {
981
+ // Child clicks must never reach the folder toggle — keep
982
+ // navigation independent from accordion state so the parent
983
+ // folder stays open while the user moves between its items.
984
+ e.stopPropagation();
922
985
  if (item.type === "dashboard") openDashboardItem(item);
923
986
  else if (item.type === "workflow") openWorkflowItem(item);
924
987
  else openViewItem(item);
@@ -996,7 +1059,10 @@ function NavFoldersSection({
996
1059
  type="button"
997
1060
  role="menuitem"
998
1061
  className="workspace-rail-thread-menu-item"
999
- onClick={() => startCustomizeFolder(folder)}
1062
+ onClick={(e) => {
1063
+ e.stopPropagation();
1064
+ startCustomizeFolder(folder);
1065
+ }}
1000
1066
  >
1001
1067
  <Pencil size={13} aria-hidden="true" /> Customize
1002
1068
  </button>
@@ -1005,7 +1071,8 @@ function NavFoldersSection({
1005
1071
  role="menuitem"
1006
1072
  className="workspace-rail-thread-menu-item"
1007
1073
  disabled={dashboards.length === 0}
1008
- onClick={() => {
1074
+ onClick={(e) => {
1075
+ e.stopPropagation();
1009
1076
  setOpenMenuId(null);
1010
1077
  setMenuAnchor(null);
1011
1078
  setAddPickerFor({ folderId: folder.id, kind: "dashboard" });
@@ -1018,7 +1085,8 @@ function NavFoldersSection({
1018
1085
  role="menuitem"
1019
1086
  className="workspace-rail-thread-menu-item"
1020
1087
  disabled={viewableObjects.length === 0}
1021
- onClick={() => {
1088
+ onClick={(e) => {
1089
+ e.stopPropagation();
1022
1090
  setOpenMenuId(null);
1023
1091
  setMenuAnchor(null);
1024
1092
  setAddPickerFor({ folderId: folder.id, kind: "view" });
@@ -1031,7 +1099,8 @@ function NavFoldersSection({
1031
1099
  role="menuitem"
1032
1100
  className="workspace-rail-thread-menu-item"
1033
1101
  disabled={workflows.length === 0}
1034
- onClick={() => {
1102
+ onClick={(e) => {
1103
+ e.stopPropagation();
1035
1104
  setOpenMenuId(null);
1036
1105
  setMenuAnchor(null);
1037
1106
  setAddPickerFor({ folderId: folder.id, kind: "workflow" });
@@ -1043,7 +1112,10 @@ function NavFoldersSection({
1043
1112
  type="button"
1044
1113
  role="menuitem"
1045
1114
  className="workspace-rail-thread-menu-item is-destructive"
1046
- onClick={() => deleteFolder(folder.id)}
1115
+ onClick={(e) => {
1116
+ e.stopPropagation();
1117
+ deleteFolder(folder.id);
1118
+ }}
1047
1119
  >
1048
1120
  <Trash2 size={13} aria-hidden="true" /> Delete
1049
1121
  </button>
@@ -1057,7 +1129,12 @@ function NavFoldersSection({
1057
1129
  const { folder, items, expand: forceExpand } = entry;
1058
1130
  const isMenuOpen = openMenuId === folder.id;
1059
1131
  const isCustomizing = customizeTarget?.scope === "folder" && customizeTarget.folderId === folder.id;
1060
- const collapsed = Boolean(folder.collapsed) && !forceExpand;
1132
+ // Active-folder preservation: if a child item matches the current
1133
+ // route, the parent folder stays open regardless of its persisted
1134
+ // `collapsed` flag — so navigating between sibling items inside a
1135
+ // folder never collapses the folder underneath the user.
1136
+ const isActiveFolder = activeFolderId === folder.id;
1137
+ const collapsed = !isActiveFolder && Boolean(folder.collapsed) && !forceExpand;
1061
1138
  const isExpanded = !collapsed;
1062
1139
  const style = navFolderStyle(folder);
1063
1140
  const visibleItems = items;
@@ -30,3 +30,38 @@ Workspace Builder excludes **`sandbox-environment`** from View widget bindings (
30
30
  ## Extension points
31
31
 
32
32
  - Custom adapters: `apps/workspace/lib/adapters/sandboxes/adapters/` (see `README.md` there).
33
+
34
+ ## Local agent auth onboarding
35
+
36
+ Sandbox rows whose **`adapter` is `local-agent-host`** route execution through whichever local agent CLI is registered for `agentHost` (Claude Code, Codex, Cursor, Gemini, OpenCode, Pi, Qwen, Hermes, OpenClaw Gateway). The record sidecar exposes a **uniform** auth onboarding panel beside the existing **Run sandbox** bar — the mental model is identical for every host:
37
+
38
+ 1. **Check status** — probes the host CLI for reachability and (where the catalog declares one) a real auth-status subcommand.
39
+ 2. **Run login** — only shown when the host catalog declares a documented `loginCommand`. Spawns it server-side and surfaces stdout / stderr / login URL.
40
+ 3. **Log out** — only shown when the host catalog declares a documented `logoutCommand`.
41
+ 4. **Run sandbox** — existing button (unchanged execution path).
42
+
43
+ Per-host capabilities (binary path, install hint, login/logout subcommands, notes for hosts without a documented login flow) are declared in **`apps/workspace/lib/sandbox-agent-host-catalog.js`**. Adding a new host means adding one entry there — never extending the auth helper or the panel component. The catalog is the single source of truth for "what does this host's onboarding look like?".
44
+
45
+ Wired through `POST /api/workspace/sandbox-agent-auth/{status,login,logout}` and the helper at `apps/workspace/lib/sandbox-agent-auth.js`. The Claude flow mirrors the upstream Paperclip server route in `server/src/routes/agents.ts` (`claude auth login` / `claude auth logout`) so behaviour matches the hosted agents surface.
46
+
47
+ ### Status semantics — uniform across every host
48
+
49
+ | Status | Meaning |
50
+ | ----------- | -------------------------------------------------------------------------------- |
51
+ | `active` | A real auth probe confirmed authentication (login exit 0, or `auth status` exit 0). |
52
+ | `reachable` | The CLI is callable (`--version` exit 0) — but authentication is **not** yet confirmed. |
53
+ | `stale` | The CLI is reachable but printed auth-shaped failure output. |
54
+ | `missing` | The binary is not on PATH. |
55
+ | `checking` | Transient UI state during a probe. |
56
+ | `unknown` | Indeterminate. |
57
+
58
+ A `--version` (or equivalent reachability) probe **never** promotes to `active`. The next sandbox-run is the source of truth for session readiness.
59
+
60
+ ### Authority invariants
61
+
62
+ - Auth setup is **separate** from the `local-agent-host` execution adapter — the adapter at `lib/adapters/sandboxes/default-local-agent-host.js` stays execution-only and does not mutate any host config file.
63
+ - Raw host tokens **never** enter `growthub.config.json`. Each CLI manages its own on-disk auth state. The sandbox row stores only safe metadata: `agentAuthStatus`, `agentAuthProvider`, `agentAuthLastChecked`, `agentAuthLastExitCode`, `agentAuthLastMessage`, `agentAuthLastLoginUrl`.
64
+ - Token-shaped output (`sk-ant-…`, `sk-…`, JWT, `Bearer …`, prefix patterns like `access_token=`, `api_key=`) is redacted server-side before crossing the response boundary.
65
+ - The schema rejects out-of-band PATCHes that try to stash a secret field (`token`, `apiKey`, `accessToken`, `refreshToken`, `bearer`, `password`, `secret`, `sessionKey`) on a sandbox row.
66
+ - The panel is hidden when `runLocality === "serverless"` (the local CLI is irrelevant in that case), when `adapter !== "local-agent-host"`, or when `agentHost` is not registered in the host auth catalog.
67
+ - Hosts without a documented login subcommand show only the **Check status** button plus the catalog's `notes` line directing the operator to sign in via the host CLI directly. No invented subcommands.
@@ -13,6 +13,7 @@ import {
13
13
  redactSecretsFromText,
14
14
  substituteVariables
15
15
  } from "./orchestration-graph.js";
16
+ import { buildInputPayloadForRunner } from "./orchestration-run-inputs.js";
16
17
 
17
18
  function normalizeMethod(value) {
18
19
  const method = String(value || "GET").trim().toUpperCase();
@@ -255,8 +256,13 @@ async function executeApiRegistryCall(workspaceConfig, nodeConfig, inputPayload,
255
256
  /**
256
257
  * Run a growthub-native orchestration graph when present on the sandbox row.
257
258
  * Returns null when the row has no executable graph (caller falls back to adapter path).
259
+ *
260
+ * `runInputs` (V2) — normalized manual run input envelope. When provided, the
261
+ * non-secret values override matching keys in the input node's samplePayload
262
+ * for `human-input` / form workflows. Secret values (those stored as
263
+ * `{ secretRef }`) are never expanded into the runner payload.
258
264
  */
259
- async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs }) {
265
+ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs, runInputs }) {
260
266
  const graph = parseOrchestrationGraph(row?.orchestrationGraph || row?.orchestrationConfig);
261
267
  if (!graph || String(graph.provider || "").trim() !== "growthub-native") return null;
262
268
 
@@ -274,7 +280,10 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs
274
280
  }
275
281
 
276
282
  const inputNode = extractInputNode(graph);
277
- const inputPayload = parseInputPayload(inputNode);
283
+ const baseInputPayload = parseInputPayload(inputNode);
284
+ const manualPayload = runInputs ? buildInputPayloadForRunner(runInputs) : {};
285
+ const inputPayload = { ...baseInputPayload, ...manualPayload };
286
+ const consumedInputKeys = Object.keys(manualPayload);
278
287
  const transformConfig = extractTransformConfig(graph);
279
288
  const resultNode = graph.nodes?.find((n) => n?.type === "tool-result");
280
289
  const successCodes = Array.isArray(resultNode?.config?.successStatusCodes)
@@ -314,7 +323,10 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs
314
323
  adapterMeta: {
315
324
  ...(raw.adapterMeta || {}),
316
325
  orchestrationProvider: graph.provider,
317
- orchestrationVersion: graph.version
326
+ orchestrationVersion: graph.version,
327
+ ...(consumedInputKeys.length
328
+ ? { runInputsConsumed: consumedInputKeys, runInputSource: String(runInputs?.source || "manual") }
329
+ : {})
318
330
  }
319
331
  };
320
332
  }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Live Runs Console model layer.
3
+ *
4
+ * Pure, framework-free transformation of sandbox run records (saved under
5
+ * `growthub.source-records.json` and `row.lastResponse`) into the shape the
6
+ * Live Runs Console UI consumes: lifecycle, log tree, timeline, search, and
7
+ * a redacted JSON bundle that can be downloaded client-side.
8
+ *
9
+ * This module deliberately does NOT import React, does NOT call fetch, and
10
+ * does NOT mutate workspace config. It is the seam between the AWaC run
11
+ * substrate (sandbox-run route + source records) and the observability UI.
12
+ *
13
+ * See:
14
+ * - app/api/workspace/sandbox-run/route.js (writes records this consumes)
15
+ * - app/data-model/components/OrchestrationRunTracePanel.jsx (consumer)
16
+ * - lib/orchestration-run-trace.js (lower-level record parser)
17
+ */
18
+
19
+ import { redactSecretsFromText } from "./orchestration-graph.js";
20
+ import { redactRunInputsEnvelope, summarizeRunInputs } from "./orchestration-run-inputs.js";
21
+
22
+ const RUN_LOG_BUNDLE_KIND = "growthub-sandbox-run-log-v1";
23
+ const DEFAULT_EXPORT_TARGETS = Object.freeze([
24
+ "download-json",
25
+ "copy-output",
26
+ "download-stdout",
27
+ "download-stderr",
28
+ "download-normalized-output",
29
+ "download-log-node"
30
+ ]);
31
+
32
+ function safeString(value) {
33
+ if (value == null) return "";
34
+ return typeof value === "string" ? value : String(value);
35
+ }
36
+
37
+ function safeJsonString(value) {
38
+ if (value == null) return "";
39
+ if (typeof value === "string") return value;
40
+ try {
41
+ return JSON.stringify(value, null, 2);
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ function parseDateMs(value) {
48
+ const text = safeString(value).trim();
49
+ if (!text) return null;
50
+ const ms = Date.parse(text);
51
+ return Number.isFinite(ms) ? ms : null;
52
+ }
53
+
54
+ function clampNumber(value) {
55
+ const n = Number(value);
56
+ return Number.isFinite(n) ? n : null;
57
+ }
58
+
59
+ function deriveRunSummary(record) {
60
+ if (!record || typeof record !== "object") {
61
+ return { status: "unknown", ok: false, label: "unknown" };
62
+ }
63
+ const exitCode = clampNumber(record.exitCode);
64
+ const errorText = safeString(record.error).trim();
65
+ const httpStatus = clampNumber(record?.adapterMeta?.httpStatus);
66
+ const aborted = record?.adapterMeta?.aborted === true;
67
+ let status = "unknown";
68
+ let ok = false;
69
+ if (aborted) {
70
+ status = "canceled";
71
+ } else if (exitCode === 0 && !errorText && (httpStatus == null || (httpStatus >= 200 && httpStatus < 300))) {
72
+ status = "completed";
73
+ ok = true;
74
+ } else if (errorText || (exitCode != null && exitCode !== 0)) {
75
+ status = "failed";
76
+ } else if (record?.lifecycleStatus === "executing" || record?.lifecycleStatus === "queued") {
77
+ status = String(record.lifecycleStatus);
78
+ }
79
+ return { status, ok, label: status };
80
+ }
81
+
82
+ function deriveRunLifecycle(record) {
83
+ if (!record || typeof record !== "object") return [];
84
+ const ranAtMs = parseDateMs(record.ranAt);
85
+ const durationMs = clampNumber(record.durationMs) || 0;
86
+ const finishedAtMs = ranAtMs != null ? ranAtMs + durationMs : null;
87
+ const lifecycle = [];
88
+ if (ranAtMs != null) {
89
+ lifecycle.push({ label: "Triggered", at: new Date(ranAtMs).toISOString(), durationMs: 0 });
90
+ lifecycle.push({ label: "Dequeued", at: new Date(ranAtMs).toISOString(), durationMs: 0 });
91
+ lifecycle.push({ label: "Started", at: new Date(ranAtMs).toISOString(), durationMs });
92
+ if (finishedAtMs != null) {
93
+ lifecycle.push({ label: "Finished", at: new Date(finishedAtMs).toISOString(), durationMs: 0 });
94
+ }
95
+ }
96
+ return lifecycle;
97
+ }
98
+
99
+ function buildLogChildren(record, summary) {
100
+ const children = [];
101
+ const stdout = safeString(record?.stdout).trim();
102
+ const stderr = safeString(record?.stderr).trim();
103
+ const errorText = safeString(record?.error).trim();
104
+ const output = safeJsonString(record?.output ?? record?.normalizedOutput ?? record?.response).trim();
105
+ const adapterMeta = record?.adapterMeta;
106
+ const durationMs = clampNumber(record?.durationMs) || 0;
107
+
108
+ if (errorText) {
109
+ children.push({
110
+ id: "error",
111
+ label: "error",
112
+ type: "error",
113
+ status: "failed",
114
+ durationMs: 0,
115
+ text: redactSecretsFromText(errorText)
116
+ });
117
+ }
118
+ if (stdout) {
119
+ children.push({
120
+ id: "stdout",
121
+ label: "stdout",
122
+ type: "stream",
123
+ status: "info",
124
+ durationMs,
125
+ text: redactSecretsFromText(stdout)
126
+ });
127
+ }
128
+ if (stderr) {
129
+ children.push({
130
+ id: "stderr",
131
+ label: "stderr",
132
+ type: "stream",
133
+ status: "failed",
134
+ durationMs: 0,
135
+ text: redactSecretsFromText(stderr)
136
+ });
137
+ }
138
+ if (output && output !== stdout) {
139
+ children.push({
140
+ id: "normalized-output",
141
+ label: "normalized output",
142
+ type: "output",
143
+ status: "info",
144
+ durationMs: 0,
145
+ text: redactSecretsFromText(output)
146
+ });
147
+ }
148
+ if (adapterMeta && typeof adapterMeta === "object") {
149
+ children.push({
150
+ id: "adapter-meta",
151
+ label: "adapter meta",
152
+ type: "meta",
153
+ status: "info",
154
+ durationMs: 0,
155
+ text: redactSecretsFromText(safeJsonString(adapterMeta))
156
+ });
157
+ }
158
+ return children;
159
+ }
160
+
161
+ function buildRunLogTree(record) {
162
+ if (!record || typeof record !== "object") return [];
163
+ const summary = deriveRunSummary(record);
164
+ const durationMs = clampNumber(record?.durationMs) || 0;
165
+ const attemptChildren = buildLogChildren(record, summary);
166
+ const attemptNode = {
167
+ id: "attempt-1",
168
+ label: "Attempt 1",
169
+ type: "attempt",
170
+ status: summary.status,
171
+ durationMs,
172
+ children: attemptChildren
173
+ };
174
+ const rootNode = {
175
+ id: "root",
176
+ label: safeString(record?.adapter || "agent-run").trim() || "agent-run",
177
+ type: "root",
178
+ status: summary.status,
179
+ durationMs,
180
+ children: [attemptNode]
181
+ };
182
+ return [rootNode];
183
+ }
184
+
185
+ function buildExportsForRecord(record, stdoutText, stderrText, outputText) {
186
+ const declared = record?.exports?.available;
187
+ if (Array.isArray(declared) && declared.length > 0) {
188
+ return {
189
+ available: declared.map((id) => safeString(id).trim()).filter(Boolean),
190
+ external: Array.isArray(record?.exports?.external) ? record.exports.external.slice() : []
191
+ };
192
+ }
193
+ const available = ["download-json"];
194
+ if (stdoutText || outputText) available.push("copy-output");
195
+ if (stdoutText) available.push("download-stdout");
196
+ if (stderrText) available.push("download-stderr");
197
+ if (outputText && outputText !== stdoutText) available.push("download-normalized-output");
198
+ available.push("download-log-node");
199
+ return { available, external: [] };
200
+ }
201
+
202
+ function normalizeRunConsoleRecord(record) {
203
+ if (!record || typeof record !== "object") return null;
204
+ const summary = deriveRunSummary(record);
205
+ const lifecycle = deriveRunLifecycle(record);
206
+ const ranAtMs = parseDateMs(record.ranAt);
207
+ const durationMs = clampNumber(record.durationMs);
208
+ const finishedAt = ranAtMs != null && durationMs != null
209
+ ? new Date(ranAtMs + durationMs).toISOString()
210
+ : "";
211
+ const adapterMeta = record?.adapterMeta && typeof record.adapterMeta === "object"
212
+ ? record.adapterMeta
213
+ : null;
214
+ const templateTrace = record?.templateTrace && typeof record.templateTrace === "object"
215
+ ? record.templateTrace
216
+ : null;
217
+
218
+ const stdoutText = safeString(typeof record.stdout === "string" ? record.stdout : safeJsonString(record.stdout));
219
+ const stderrText = safeString(record.stderr);
220
+ const errorText = safeString(record.error);
221
+ const outputRaw = record.output ?? record.normalizedOutput ?? record.response;
222
+ const outputText = typeof outputRaw === "string" ? outputRaw : safeJsonString(outputRaw);
223
+ const rawInput = record.input || record.runInputs || null;
224
+ const safeInput = rawInput ? redactRunInputsEnvelope(rawInput) : null;
225
+ const inputSummary = safeInput ? summarizeRunInputs(safeInput) : null;
226
+ const exports = buildExportsForRecord(record, stdoutText, stderrText, outputText);
227
+
228
+ return {
229
+ runId: safeString(record.runId).trim(),
230
+ status: summary.status,
231
+ ok: summary.ok,
232
+ exitCode: clampNumber(record.exitCode),
233
+ ranAt: safeString(record.ranAt).trim(),
234
+ finishedAt,
235
+ durationMs: durationMs == null ? null : durationMs,
236
+ queueMs: 0,
237
+ runtime: safeString(record.runtime).trim(),
238
+ adapter: safeString(record.adapter).trim(),
239
+ runLocality: safeString(record.runLocality).trim(),
240
+ lifecycleStatus: safeString(record.lifecycleStatus).trim(),
241
+ version: safeString(record.version).trim(),
242
+ sourceId: safeString(record.sourceId).trim(),
243
+ lifecycle,
244
+ payload: {
245
+ objectId: safeString(record.objectId).trim(),
246
+ name: safeString(record.name || record.sandboxName).trim(),
247
+ runtime: safeString(record.runtime).trim(),
248
+ adapter: safeString(record.adapter).trim(),
249
+ command: redactSecretsFromText(safeString(record.command)),
250
+ instructions: redactSecretsFromText(safeString(record.instructions)),
251
+ useDraft: Boolean(record.useDraft),
252
+ version: safeString(record.version).trim(),
253
+ schedulerRegistryId: safeString(record.schedulerRegistryId).trim(),
254
+ agentHost: safeString(record.agentHost).trim(),
255
+ timeoutMs: clampNumber(record.timeoutMs),
256
+ runInputs: safeInput,
257
+ inputSource: inputSummary ? inputSummary.source : "",
258
+ inputFieldCount: inputSummary ? inputSummary.fieldCount : 0,
259
+ inputFileCount: inputSummary ? inputSummary.fileCount : 0,
260
+ inputSummary
261
+ },
262
+ exports,
263
+ output: {
264
+ stdout: redactSecretsFromText(stdoutText),
265
+ stderr: redactSecretsFromText(stderrText),
266
+ error: redactSecretsFromText(errorText),
267
+ normalizedOutput: redactSecretsFromText(outputText),
268
+ exitCode: clampNumber(record.exitCode)
269
+ },
270
+ context: {
271
+ envRefsResolved: Array.isArray(record.envRefsResolved) ? record.envRefsResolved.slice() : [],
272
+ envRefsMissing: Array.isArray(record.envRefsMissing) ? record.envRefsMissing.slice() : [],
273
+ networkAllow: Boolean(record.networkAllow),
274
+ allowList: Array.isArray(record.allowList) ? record.allowList.slice() : [],
275
+ adapterMeta,
276
+ templateTrace
277
+ },
278
+ logTree: buildRunLogTree(record)
279
+ };
280
+ }
281
+
282
+ function buildRunTimeline(records) {
283
+ const list = Array.isArray(records) ? records : [];
284
+ const items = list
285
+ .map((record) => {
286
+ const normalized = normalizeRunConsoleRecord(record);
287
+ if (!normalized) return null;
288
+ const startedMs = parseDateMs(normalized.ranAt);
289
+ return {
290
+ runId: normalized.runId,
291
+ status: normalized.status,
292
+ durationMs: normalized.durationMs == null ? 0 : normalized.durationMs,
293
+ startedMs,
294
+ ranAt: normalized.ranAt
295
+ };
296
+ })
297
+ .filter(Boolean);
298
+
299
+ const max = items.reduce((m, it) => Math.max(m, it.durationMs || 0), 0);
300
+ return items.map((it) => ({
301
+ ...it,
302
+ barRatio: max > 0 ? Math.min(1, (it.durationMs || 0) / max) : 0
303
+ }));
304
+ }
305
+
306
+ function nodeMatchesQuery(node, query) {
307
+ const text = String(query || "").trim().toLowerCase();
308
+ if (!text) return true;
309
+ const haystack = [
310
+ node?.id,
311
+ node?.label,
312
+ node?.type,
313
+ node?.status,
314
+ node?.text
315
+ ]
316
+ .filter(Boolean)
317
+ .join(" ")
318
+ .toLowerCase();
319
+ return haystack.includes(text);
320
+ }
321
+
322
+ function nodeIsError(node) {
323
+ if (!node) return false;
324
+ if (node.type === "error") return true;
325
+ if (node.type === "stream" && node.id === "stderr") return true;
326
+ if (node.status === "failed" && node.type !== "stream") return true;
327
+ return false;
328
+ }
329
+
330
+ function filterNodeTree(nodes, predicate) {
331
+ if (!Array.isArray(nodes)) return [];
332
+ const out = [];
333
+ for (const node of nodes) {
334
+ const children = filterNodeTree(node?.children, predicate);
335
+ const selfMatches = predicate(node);
336
+ if (selfMatches || children.length > 0) {
337
+ out.push({ ...node, children });
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function filterRunLogTree(tree, { query = "", errorsOnly = false } = {}) {
344
+ return filterNodeTree(tree, (node) => {
345
+ if (errorsOnly && !nodeIsError(node) && !(Array.isArray(node?.children) && node.children.some(nodeIsError))) {
346
+ return false;
347
+ }
348
+ return nodeMatchesQuery(node, query);
349
+ });
350
+ }
351
+
352
+ function formatRunDuration(ms) {
353
+ const n = clampNumber(ms);
354
+ if (n == null) return "—";
355
+ if (n < 1000) return `${Math.round(n)} ms`;
356
+ if (n < 60_000) return `${(n / 1000).toFixed(1)} s`;
357
+ const minutes = Math.floor(n / 60_000);
358
+ const seconds = Math.round((n % 60_000) / 1000);
359
+ return `${minutes}m ${seconds}s`;
360
+ }
361
+
362
+ function downloadRunBundle({ record, runId, sourceId } = {}) {
363
+ const normalized = normalizeRunConsoleRecord(record || {});
364
+ return {
365
+ kind: RUN_LOG_BUNDLE_KIND,
366
+ exportedAt: new Date().toISOString(),
367
+ runId: safeString(runId).trim() || (normalized ? normalized.runId : ""),
368
+ sourceId: safeString(sourceId).trim() || (normalized ? normalized.sourceId : ""),
369
+ record: normalized
370
+ };
371
+ }
372
+
373
+ export {
374
+ RUN_LOG_BUNDLE_KIND,
375
+ DEFAULT_EXPORT_TARGETS,
376
+ normalizeRunConsoleRecord,
377
+ deriveRunSummary,
378
+ deriveRunLifecycle,
379
+ buildRunLogTree,
380
+ buildRunTimeline,
381
+ filterRunLogTree,
382
+ formatRunDuration,
383
+ downloadRunBundle
384
+ };