@growthub/cli 0.14.6 → 0.14.8

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 (13) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +5 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +5 -4
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +58 -4
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +31 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/StatusPill.jsx +22 -6
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ToggleField.jsx +5 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceDataModelCanvas.jsx +457 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +188 -2
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +67 -3
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-map/page.jsx +14 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +48 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-node-status.js +55 -0
  13. package/package.json +1 -1
@@ -413,6 +413,11 @@ function buildRunResponse({
413
413
  if (result && typeof result === "object" && Array.isArray(result.logTree)) {
414
414
  base.logTree = result.logTree;
415
415
  }
416
+ // Per-node orchestration execution trace (general pipeline, not swarm) so the
417
+ // Workflow Canvas can settle per-node status from the persisted record.
418
+ if (result && typeof result === "object" && Array.isArray(result.nodeTrace)) {
419
+ base.nodeTrace = result.nodeTrace;
420
+ }
416
421
  base.exports = {
417
422
  available: ["download-json", "copy-output", "download-stdout", "download-stderr", "download-log-node"],
418
423
  external: []
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { useEffect, useMemo, useState } from "react";
22
22
  import Link from "next/link";
23
- import { Activity, BarChart3, Check, Copy, Eye, GitBranch, MoreVertical, Search } from "lucide-react";
23
+ import { Activity, BarChart3, Check, Copy, Eye, GitBranch, MoreVertical, Search, Share2 } from "lucide-react";
24
24
  import { deriveWorkspaceState, deriveSwarmConditionPacket, deriveWorkspaceContributions, deriveLensWalkthroughState, LENS_WALKTHROUGH_DISMISS_FLAG } from "@/lib/workspace-activation";
25
25
  import { WorkspaceContributionGraph } from "./WorkspaceContributionGraph.jsx";
26
26
  import { WorkspaceLensWalkthrough } from "./WorkspaceLensWalkthrough.jsx";
@@ -244,9 +244,10 @@ export function WorkspaceLensPanel({ workspaceConfig, workspaceSourceRecords, me
244
244
  <h1 className="workspace-lens-title">Workspace Lens</h1>
245
245
  <p className="workspace-lens-subtitle">Live derived state for this workspace.</p>
246
246
  </div>
247
- <p className="workspace-lens-score" aria-label="Workspace lens summary">
248
- {counts.total} lenses · {counts.ready} ready · {counts.blocked} blocked · {counts.assignable} agent-assignable
249
- </p>
247
+ <Link className="workspace-lens-map-link" href="/workspace-map" aria-label="Open Workspace Map">
248
+ <Share2 size={15} aria-hidden="true" />
249
+ <span>Workspace Map</span>
250
+ </Link>
250
251
  </header>
251
252
 
252
253
  {showWalk ? (
@@ -2390,6 +2390,7 @@ function DataModelTableSurface({
2390
2390
  const [selectMenuOpen, setSelectMenuOpen] = useState(false);
2391
2391
  const [pageSize, setPageSize] = useState(15);
2392
2392
  const [pageIndex, setPageIndex] = useState(0);
2393
+ const [search, setSearch] = useState("");
2393
2394
  const fieldInputRef = useRef(null);
2394
2395
  const selectedOriginalIndex = selectedRecordIndex ?? localSelectedOriginalIndex;
2395
2396
 
@@ -2407,6 +2408,7 @@ function DataModelTableSurface({
2407
2408
  setLastSelectedRowIndex(null);
2408
2409
  setSelectMenuOpen(false);
2409
2410
  setPageIndex(0);
2411
+ setSearch("");
2410
2412
  }, [table.id]);
2411
2413
 
2412
2414
  useEffect(() => {
@@ -2428,7 +2430,14 @@ function DataModelTableSurface({
2428
2430
  const visibleColumns = useMemo(() => orderedColumns.filter((column) => !settings.hidden.includes(column)), [orderedColumns, settings.hidden]);
2429
2431
  const rowEntries = useMemo(() => {
2430
2432
  const indexed = (table.rows || []).map((row, originalIndex) => ({ row, originalIndex }));
2431
- const filtered = indexed.filter((entry) => rowMatchesFilter(entry.row, settings.filter));
2433
+ const needle = search.trim().toLowerCase();
2434
+ const filtered = indexed.filter((entry) => {
2435
+ if (!rowMatchesFilter(entry.row, settings.filter)) return false;
2436
+ if (!needle) return true;
2437
+ // Quick search across the visible columns only — additive to the
2438
+ // column-level filter clauses, never replaces them.
2439
+ return visibleColumns.some((column) => String(entry.row?.[column] ?? "").toLowerCase().includes(needle));
2440
+ });
2432
2441
  if (!settings.sort?.length) return filtered;
2433
2442
  const clauses = settings.sort;
2434
2443
  return [...filtered].sort((left, right) => {
@@ -2439,7 +2448,7 @@ function DataModelTableSurface({
2439
2448
  }
2440
2449
  return 0;
2441
2450
  });
2442
- }, [table.rows, settings]);
2451
+ }, [table.rows, settings, search, visibleColumns]);
2443
2452
  const selectedRowCount = selectedRows.size;
2444
2453
  const pageCount = Math.max(1, Math.ceil(rowEntries.length / pageSize));
2445
2454
  const safePageIndex = Math.min(pageIndex, pageCount - 1);
@@ -2491,7 +2500,7 @@ function DataModelTableSurface({
2491
2500
  setSelectedRow(null);
2492
2501
  setLastSelectedRowIndex(null);
2493
2502
  setSelectMenuOpen(false);
2494
- }, [settings.filter, settings.sort, pageSize]);
2503
+ }, [settings.filter, settings.sort, pageSize, search]);
2495
2504
 
2496
2505
  function commitField() {
2497
2506
  const name = fieldName.trim();
@@ -2690,9 +2699,25 @@ function DataModelTableSurface({
2690
2699
  ))}
2691
2700
  </div>
2692
2701
  <div className="dm-records-actions">
2702
+ <label className="dm-toolbar-search">
2703
+ <Search size={13} aria-hidden="true" />
2704
+ <input
2705
+ value={search}
2706
+ placeholder="Search records"
2707
+ onChange={(event) => setSearch(event.target.value)}
2708
+ aria-label={`Search ${table.label || "records"}`}
2709
+ />
2710
+ </label>
2711
+ <span className="dm-toolbar-count">
2712
+ {(search.trim() || settings.filter?.clauses?.length)
2713
+ ? `${rowEntries.length} of ${pluralize(table.rows?.length || 0, "record")}`
2714
+ : pluralize(table.rows?.length || 0, "record")}
2715
+ </span>
2716
+ <span className="dm-toolbar-divider" aria-hidden="true" />
2693
2717
  <span className="dm-filter-anchor">
2694
- <button type="button" className="dm-btn-ghost" onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
2718
+ <button type="button" className={`dm-btn-ghost${settings.filter?.clauses?.length ? " is-active" : ""}`} onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
2695
2719
  <Filter size={13} />Filter
2720
+ {settings.filter?.clauses?.length > 0 && <span className="dm-filter-chip-count">{settings.filter.clauses.length}</span>}
2696
2721
  </button>
2697
2722
  {filterTarget === "toolbar" && (
2698
2723
  <div className="dm-filter-popover dm-filter-popover-toolbar">
@@ -2843,6 +2868,35 @@ function DataModelTableSurface({
2843
2868
  </tr>
2844
2869
  </thead>
2845
2870
  <tbody>
2871
+ {rowEntries.length === 0 && (
2872
+ <tr className="dm-db-empty-row">
2873
+ <td colSpan={visibleColumns.length + 1 + (table.mutable ? 1 : 0)}>
2874
+ <div className="dm-db-empty-state">
2875
+ {(search.trim() || settings.filter?.clauses?.length) ? (
2876
+ <>
2877
+ <strong>No records match</strong>
2878
+ <span>Try a different search or clear your filters.</span>
2879
+ <div className="dm-db-empty-actions">
2880
+ {search.trim() && <button type="button" className="dm-btn-outline" onClick={() => setSearch("")}>Clear search</button>}
2881
+ {settings.filter?.clauses?.length > 0 && <button type="button" className="dm-btn-outline" onClick={resetView}>Reset view</button>}
2882
+ </div>
2883
+ </>
2884
+ ) : (
2885
+ <>
2886
+ <strong>No records yet</strong>
2887
+ <span>{table.mutable ? "Add your first row or import a CSV to start using this object." : "Records resolve at runtime for this live-backed object."}</span>
2888
+ {table.mutable && (
2889
+ <div className="dm-db-empty-actions">
2890
+ <button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}><Plus size={13} />Add record</button>
2891
+ <button type="button" className="dm-btn-outline" onClick={() => setCsvOpen(true)}><Upload size={13} />Import CSV</button>
2892
+ </div>
2893
+ )}
2894
+ </>
2895
+ )}
2896
+ </div>
2897
+ </td>
2898
+ </tr>
2899
+ )}
2846
2900
  {pageEntries.map(({ row, originalIndex }, rowIndex) => {
2847
2901
  const visibleIndex = pageStart + rowIndex;
2848
2902
  const displayIndex = visibleIndex + 1;
@@ -103,6 +103,21 @@ function nodeRecordName(node) {
103
103
  return "";
104
104
  }
105
105
 
106
+ // Per-node run-status pill (Attio-style), docked outside the node top-right.
107
+ // Driven only by the real general-orchestration run signal passed in
108
+ // `nodeStatuses` — the streamed orchestration.node.* deltas and the persisted
109
+ // nodeTrace (see lib/orchestration-node-status.js). Never fabricated here.
110
+ const NODE_STATUS_CHIP = {
111
+ completed: { cls: "is-ok", label: "Completed" },
112
+ ok: { cls: "is-ok", label: "Completed" },
113
+ running: { cls: "is-running", label: "Running" },
114
+ executing: { cls: "is-running", label: "Running" },
115
+ failed: { cls: "is-bad", label: "Failed" },
116
+ skipped: { cls: "is-waiting", label: "Skipped" },
117
+ pending: { cls: "is-waiting", label: "Waiting" },
118
+ queued: { cls: "is-waiting", label: "Waiting" },
119
+ };
120
+
106
121
  export function OrchestrationGraphCanvas({
107
122
  graph,
108
123
  selectedNodeId,
@@ -112,6 +127,8 @@ export function OrchestrationGraphCanvas({
112
127
  onRunTest,
113
128
  runStatus,
114
129
  runMessage,
130
+ nodeStatuses,
131
+ onNodeStatusClick,
115
132
  statusLabel = "Draft",
116
133
  }) {
117
134
  const parsed = useMemo(() => parseOrchestrationGraph(graph) || graph, [graph]);
@@ -222,6 +239,9 @@ export function OrchestrationGraphCanvas({
222
239
  const isSelected = activeId === id;
223
240
  const prevId = index > 0 ? String(nodes[index - 1].id || "") : "";
224
241
  const Icon = NODE_ICONS[node.type] || ArrowDownToLine;
242
+ const nodeStatusChip = nodeStatuses
243
+ ? NODE_STATUS_CHIP[String(nodeStatuses[id] || "").toLowerCase()] || null
244
+ : null;
225
245
 
226
246
  return (
227
247
  <div key={id || index} className="dm-orchestration-canvas__step">
@@ -285,6 +305,17 @@ export function OrchestrationGraphCanvas({
285
305
  onSelectNode?.(node);
286
306
  }}
287
307
  >
308
+ {nodeStatusChip && (
309
+ <button
310
+ type="button"
311
+ className={`dm-status-chip ${nodeStatusChip.cls} dm-orchestration-node__status`}
312
+ title={`${nodeStatusChip.label} — open run trace`}
313
+ onClick={(event) => { event.stopPropagation(); onNodeStatusClick?.(node); }}
314
+ >
315
+ <span className="dm-status-dot" aria-hidden="true" />
316
+ {nodeStatusChip.label}
317
+ </button>
318
+ )}
288
319
  <span className="dm-orchestration-node__icon" aria-hidden="true">
289
320
  <Icon size={14} />
290
321
  </span>
@@ -1,13 +1,29 @@
1
1
  "use client";
2
2
 
3
+ // Maps a free-form status string to one calm, consistent chip state.
4
+ // Backwards-compatible: same { value } prop, same default ("untested").
5
+ const OK = ["connected", "approved", "ok", "success", "succeeded", "complete", "completed", "passed", "live", "ready"];
6
+ const BAD = ["failed", "error", "errored", "disconnected", "rejected"];
7
+ const WARN = ["warning", "warn", "pending", "untrusted", "stale", "degraded"];
8
+ const RUNNING = ["running", "in_progress", "in-progress", "active", "executing", "started"];
9
+ const WAITING = ["waiting", "queued", "idle", "scheduled"];
10
+
11
+ function classifyStatus(status) {
12
+ if (OK.includes(status)) return "is-ok";
13
+ if (BAD.includes(status)) return "is-bad";
14
+ if (RUNNING.includes(status)) return "is-running";
15
+ if (WARN.includes(status)) return "is-warn";
16
+ if (WAITING.includes(status)) return "is-waiting";
17
+ return "";
18
+ }
19
+
3
20
  export function StatusPill({ value }) {
4
- const status = String(value || "untested").toLowerCase();
5
- const ok = ["connected", "approved", "ok", "success"].includes(status);
6
- const bad = ["failed", "error", "disconnected"].includes(status);
21
+ const raw = value || "untested";
22
+ const state = classifyStatus(String(raw).toLowerCase());
7
23
  return (
8
- <span className={`dm-db-status ${ok ? "ok" : bad ? "bad" : ""}`}>
9
- <span />
10
- {value || "untested"}
24
+ <span className={`dm-status-chip ${state}`.trim()}>
25
+ <span className="dm-status-dot" />
26
+ {raw}
11
27
  </span>
12
28
  );
13
29
  }
@@ -2,16 +2,17 @@
2
2
 
3
3
  export function ToggleField({ checked, disabled, label, onChange, description }) {
4
4
  return (
5
- <label className="dm-check-row">
5
+ <label className={`dm-switch-row${disabled ? " is-disabled" : ""}`}>
6
6
  <input
7
7
  type="checkbox"
8
8
  checked={Boolean(checked)}
9
9
  disabled={disabled}
10
10
  onChange={(event) => onChange(event.target.checked)}
11
11
  />
12
- <span>
13
- {label}
14
- {description && <span className="dm-cell-empty" style={{ display: "block", marginTop: 4 }}>{description}</span>}
12
+ <span className="dm-switch-track" aria-hidden="true" />
13
+ <span className="dm-switch-label">
14
+ <span>{label}</span>
15
+ {description && <span className="dm-switch-desc">{description}</span>}
15
16
  </span>
16
17
  </label>
17
18
  );
@@ -0,0 +1,457 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Workspace Map — read-only schema/relationship canvas.
5
+ *
6
+ * Renders the workspace data model as a node canvas using the SAME visual
7
+ * grammar as the workflow OrchestrationGraphCanvas (dotted grid, card nodes,
8
+ * connector edges, zoom controls), but the CONTENT is the metadata graph, not
9
+ * an executable workflow.
10
+ *
11
+ * Hard rules (mirrors the kit's governance invariants):
12
+ * - Graph data comes ONLY from buildWorkspaceMetadataStore →
13
+ * buildWorkspaceMetadataGraph. No ad-hoc config parsing here.
14
+ * - No mutations, no PATCH, no localStorage. This surface only reads
15
+ * /api/workspace and navigates to existing surfaces on click.
16
+ * - Secrets never reach the graph (the store strips them); we render only
17
+ * labels, counts, and types.
18
+ */
19
+
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import Link from "next/link";
22
+ import { useRouter } from "next/navigation";
23
+ import { ArrowLeft, Maximize2, Search, X, ZoomIn, ZoomOut } from "lucide-react";
24
+
25
+ import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
26
+ import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
27
+ import { LucideIcon, OBJECT_TYPE_PRESETS, objectTypeBadge, pluralize } from "./dm-shared.jsx";
28
+
29
+ // Which node types appear on the map, in lane (left→right) order, with the
30
+ // icon + legend swatch for each. Workflow execution detail (nodes, inputs,
31
+ // runs) stays in the Workflow Canvas — the map is the structural overview.
32
+ const LANES = [
33
+ { key: "integration", types: ["integration"], label: "Integrations", icon: "Globe", color: "#0ea5e9" },
34
+ { key: "source", types: ["sourceRecord"], label: "Sources", icon: "Database", color: "#14b8a6" },
35
+ { key: "object", types: ["dataModelObject"], label: "Objects", icon: "Box", color: "#4f6bed" },
36
+ { key: "flow", types: ["workflow", "sandbox"], label: "Workflows", icon: "Zap", color: "#8b5cf6" },
37
+ { key: "dashboard", types: ["dashboard"], label: "Dashboards", icon: "LayoutDashboard", color: "#f97316" },
38
+ ];
39
+
40
+ const RENDERED_TYPES = new Set(LANES.flatMap((lane) => lane.types));
41
+ const NODE_WIDTH = 220;
42
+ const COL_GAP = 60;
43
+ const ROW_GAP = 24;
44
+ const PAD = 28;
45
+
46
+ function nodeHeight(node) {
47
+ // Object cards carry a field strip + stat → taller. Everything else is compact.
48
+ return node.type === "dataModelObject" ? 122 : 80;
49
+ }
50
+
51
+ function nodeIconName(node) {
52
+ if (node.type === "dataModelObject") {
53
+ return node.summary?.objectType
54
+ ? OBJECT_TYPE_PRESETS[node.summary.objectType]?.icon || "Box"
55
+ : "Box";
56
+ }
57
+ const lane = LANES.find((entry) => entry.types.includes(node.type));
58
+ return lane?.icon || "Box";
59
+ }
60
+
61
+ export function WorkspaceDataModelCanvas() {
62
+ const router = useRouter();
63
+ const [workspaceConfig, setWorkspaceConfig] = useState(null);
64
+ const [sourceRecords, setSourceRecords] = useState({});
65
+ const [loading, setLoading] = useState(true);
66
+ const [error, setError] = useState("");
67
+ const [scale, setScale] = useState(1);
68
+ const [selectedId, setSelectedId] = useState("");
69
+ const [query, setQuery] = useState("");
70
+ const canvasRef = useRef(null);
71
+ const dragRef = useRef(null);
72
+ const [isPanning, setIsPanning] = useState(false);
73
+
74
+ const load = useCallback(async () => {
75
+ setLoading(true);
76
+ setError("");
77
+ try {
78
+ const res = await fetch("/api/workspace", { cache: "no-store" });
79
+ const payload = await res.json();
80
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
81
+ setWorkspaceConfig(payload.workspaceConfig || null);
82
+ setSourceRecords(
83
+ payload.workspaceSourceRecords && typeof payload.workspaceSourceRecords === "object"
84
+ ? payload.workspaceSourceRecords
85
+ : {}
86
+ );
87
+ } catch (err) {
88
+ setError(err.message || "Failed to load workspace");
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ }, []);
93
+
94
+ useEffect(() => { load(); }, [load]);
95
+
96
+ // Derived graph — never throws on malformed config (the store/graph guard).
97
+ const graph = useMemo(() => {
98
+ if (!workspaceConfig) return { nodes: [], edges: [] };
99
+ try {
100
+ const store = buildWorkspaceMetadataStore({
101
+ workspaceConfig,
102
+ workspaceSourceRecords: sourceRecords,
103
+ });
104
+ return buildWorkspaceMetadataGraph(store);
105
+ } catch {
106
+ return { nodes: [], edges: [] };
107
+ }
108
+ }, [workspaceConfig, sourceRecords]);
109
+
110
+ // Group field labels by the object id they belong to, for the object cards.
111
+ const fieldsByObjectId = useMemo(() => {
112
+ const map = new Map();
113
+ for (const node of graph.nodes) {
114
+ if (node.type !== "field") continue;
115
+ const objectId = node.summary?.objectId;
116
+ if (!objectId) continue;
117
+ if (!map.has(objectId)) map.set(objectId, []);
118
+ map.get(objectId).push(node.label);
119
+ }
120
+ return map;
121
+ }, [graph.nodes]);
122
+
123
+ // Deterministic lane layout — no randomness, stable across renders.
124
+ const { placed, positions, width, height } = useMemo(() => {
125
+ const positionMap = new Map();
126
+ const placedNodes = [];
127
+ let maxBottom = 0;
128
+ LANES.forEach((lane, laneIndex) => {
129
+ const laneNodes = graph.nodes.filter((node) => lane.types.includes(node.type));
130
+ const x = PAD + laneIndex * (NODE_WIDTH + COL_GAP);
131
+ let y = PAD;
132
+ for (const node of laneNodes) {
133
+ const h = nodeHeight(node);
134
+ positionMap.set(node.id, { x, y, w: NODE_WIDTH, h });
135
+ placedNodes.push(node);
136
+ y += h + ROW_GAP;
137
+ }
138
+ maxBottom = Math.max(maxBottom, y);
139
+ });
140
+ return {
141
+ placed: placedNodes,
142
+ positions: positionMap,
143
+ width: PAD + LANES.length * (NODE_WIDTH + COL_GAP),
144
+ height: Math.max(maxBottom, 460),
145
+ };
146
+ }, [graph.nodes]);
147
+
148
+ // Only edges whose BOTH endpoints are rendered on the map. Deduped by id.
149
+ const edges = useMemo(() => {
150
+ const seen = new Set();
151
+ const out = [];
152
+ for (const edge of graph.edges) {
153
+ if (!positions.has(edge.from) || !positions.has(edge.to)) continue;
154
+ if (seen.has(edge.id)) continue;
155
+ seen.add(edge.id);
156
+ out.push(edge);
157
+ }
158
+ return out;
159
+ }, [graph.edges, positions]);
160
+
161
+ const needle = query.trim().toLowerCase();
162
+ const matches = useCallback(
163
+ (node) => {
164
+ if (!needle) return true;
165
+ const s = node.summary || {};
166
+ // Match the fields a customer actually searches by — object type, source
167
+ // id, integration/status, workflow lifecycle, adapter/auth, fetched date —
168
+ // not just label/type.
169
+ const haystack = [
170
+ node.label, node.type,
171
+ s.objectType, s.objectId, s.status, s.lane, s.lifecycleStatus,
172
+ s.adapter, s.authStatus, s.authProvider, s.integrationId,
173
+ s.runLocality, s.fetchedAt, s.sourceAuthority,
174
+ ].filter(Boolean).join(" ").toLowerCase();
175
+ return haystack.includes(needle);
176
+ },
177
+ [needle]
178
+ );
179
+
180
+ // Human-readable label for the open action, by node type.
181
+ function openLabel(node) {
182
+ if (node.type === "workflow" || node.type === "sandbox") return "Open in Workflow Canvas";
183
+ return "Open in Data Model";
184
+ }
185
+
186
+ // Read-only metadata rows for the detail panel — derived from the graph
187
+ // summary only (no secrets, no config parsing).
188
+ function detailRows(node) {
189
+ const s = node.summary || {};
190
+ const rows = [];
191
+ const push = (k, v) => { if (v !== undefined && v !== null && v !== "") rows.push({ k, v: String(v) }); };
192
+ if (node.type === "dataModelObject") {
193
+ push("Type", s.objectType);
194
+ push("Records", Number.isFinite(s.rowCount) ? s.rowCount : undefined);
195
+ push("Backing", s.isLiveBacked ? "live" : s.readOnly ? "read-only" : "manual");
196
+ push("Source authority", s.sourceAuthority);
197
+ } else if (node.type === "sourceRecord") {
198
+ push("Records", Number.isFinite(s.recordCount) ? s.recordCount : 0);
199
+ push("Integration", s.integrationId);
200
+ push("Fetched", s.fetchedAt ? String(s.fetchedAt).slice(0, 10) : undefined);
201
+ } else if (node.type === "workflow") {
202
+ push("Steps", Number.isFinite(s.nodeCount) ? s.nodeCount : 0);
203
+ push("Lifecycle", s.lifecycleStatus);
204
+ push("Requires input", s.requiresInput ? "yes" : undefined);
205
+ } else if (node.type === "sandbox") {
206
+ push("Adapter", s.adapter);
207
+ push("Auth", s.authStatus);
208
+ push("Locality", s.runLocality);
209
+ push("Lifecycle", s.lifecycleStatus);
210
+ } else if (node.type === "integration") {
211
+ push("Status", s.status);
212
+ push("Lane", s.lane);
213
+ } else if (node.type === "dashboard") {
214
+ push("Widgets", Number.isFinite(s.widgetCount) ? s.widgetCount : 0);
215
+ }
216
+ return rows;
217
+ }
218
+
219
+ const selectedNode = useMemo(
220
+ () => placed.find((node) => node.id === selectedId) || null,
221
+ [placed, selectedId]
222
+ );
223
+
224
+ function handleOpen(node) {
225
+ const summary = node.summary || {};
226
+ if (node.type === "dataModelObject") {
227
+ router.push(`/data-model?object=${encodeURIComponent(summary.objectId || "")}`);
228
+ } else if (node.type === "workflow" || node.type === "sandbox") {
229
+ const params = new URLSearchParams();
230
+ if (summary.objectId) params.set("object", summary.objectId);
231
+ if (summary.rowId) params.set("row", summary.rowId);
232
+ // Explicit field for consistency with the CEO/Agent Team handoff pattern;
233
+ // WorkflowSurface still falls back to orchestrationGraph if absent.
234
+ params.set("field", "orchestrationConfig");
235
+ router.push(`/workflows${params.toString() ? `?${params.toString()}` : ""}`);
236
+ } else {
237
+ router.push("/data-model");
238
+ }
239
+ }
240
+
241
+ function zoom(delta) {
242
+ setScale((current) => Math.min(1.4, Math.max(0.6, Math.round((current + delta) * 10) / 10)));
243
+ }
244
+
245
+ const handleCanvasPointerDown = useCallback((event) => {
246
+ if (event.button !== 0) return;
247
+ const target = event.target;
248
+ if (target?.closest?.("button, a, input, label, .wm-detail, .wm-zoom")) return;
249
+ const canvas = canvasRef.current;
250
+ if (!canvas) return;
251
+ dragRef.current = {
252
+ pointerId: event.pointerId,
253
+ startX: event.clientX,
254
+ startY: event.clientY,
255
+ scrollLeft: canvas.scrollLeft,
256
+ scrollTop: canvas.scrollTop,
257
+ };
258
+ setIsPanning(true);
259
+ event.currentTarget.setPointerCapture?.(event.pointerId);
260
+ }, []);
261
+
262
+ const handleCanvasPointerMove = useCallback((event) => {
263
+ const drag = dragRef.current;
264
+ const canvas = canvasRef.current;
265
+ if (!drag || !canvas || drag.pointerId !== event.pointerId) return;
266
+ event.preventDefault();
267
+ canvas.scrollLeft = drag.scrollLeft - (event.clientX - drag.startX);
268
+ canvas.scrollTop = drag.scrollTop - (event.clientY - drag.startY);
269
+ }, []);
270
+
271
+ const endCanvasPan = useCallback((event) => {
272
+ if (dragRef.current?.pointerId !== event.pointerId) return;
273
+ dragRef.current = null;
274
+ setIsPanning(false);
275
+ }, []);
276
+
277
+ const hasNodes = placed.length > 0;
278
+
279
+ return (
280
+ <div className="wm-shell">
281
+ <div className="wm-toolbar">
282
+ <Link className="wm-back-link" href="/workspace-lens" aria-label="Back to Workspace Lens">
283
+ <ArrowLeft size={16} aria-hidden="true" />
284
+ </Link>
285
+ <div>
286
+ <h1>Workspace Map</h1>
287
+ <span className="wm-sub">
288
+ {hasNodes ? `${pluralize(placed.length, "node")} · ${pluralize(edges.length, "link")}` : "Read-only view of your workspace data model"}
289
+ </span>
290
+ </div>
291
+ <label className="dm-toolbar-search" style={{ marginLeft: 16 }}>
292
+ <Search size={13} aria-hidden="true" />
293
+ <input
294
+ value={query}
295
+ placeholder="Search objects, sources, workflows"
296
+ onChange={(event) => setQuery(event.target.value)}
297
+ aria-label="Search the workspace map"
298
+ />
299
+ </label>
300
+ <div className="wm-legend">
301
+ {LANES.map((lane) => (
302
+ <span key={lane.key} className="wm-legend-item">
303
+ <span className="wm-legend-swatch" style={{ background: lane.color }} />
304
+ {lane.label}
305
+ </span>
306
+ ))}
307
+ </div>
308
+ </div>
309
+
310
+ <div
311
+ ref={canvasRef}
312
+ className={`wm-canvas${isPanning ? " is-panning" : ""}`}
313
+ onPointerDown={handleCanvasPointerDown}
314
+ onPointerMove={handleCanvasPointerMove}
315
+ onPointerUp={endCanvasPan}
316
+ onPointerCancel={endCanvasPan}
317
+ onPointerLeave={endCanvasPan}
318
+ >
319
+ {loading && <div className="wm-empty"><span>Loading workspace map…</span></div>}
320
+ {!loading && error && <div className="wm-empty"><strong>Could not load the map</strong><span>{error}</span></div>}
321
+ {!loading && !error && !hasNodes && (
322
+ <div className="wm-empty">
323
+ <strong>Nothing to map yet</strong>
324
+ <span>Add objects, link a data source, or publish a workflow and they will appear here.</span>
325
+ </div>
326
+ )}
327
+ {!loading && !error && hasNodes && (
328
+ <>
329
+ {/* Sizer reserves the SCALED footprint so .wm-canvas (overflow:auto)
330
+ scrolls fully when zoomed in on dense workspaces. The inner
331
+ layer holds the unscaled coordinate system and is transformed. */}
332
+ <div className="wm-canvas-inner" style={{ width: width * scale, height: height * scale }}>
333
+ <div className="wm-canvas-scale" style={{ width, height, transform: `scale(${scale})` }}>
334
+ <svg className="wm-edge" width={width} height={height} style={{ left: 0, top: 0 }}>
335
+ <defs>
336
+ <marker id="wm-arrow" markerWidth="7" markerHeight="7" refX="6" refY="3" orient="auto" markerUnits="userSpaceOnUse">
337
+ <path className="wm-arrow-head" d="M0,0 L6,3 L0,6 Z" />
338
+ </marker>
339
+ </defs>
340
+ {edges.map((edge) => {
341
+ const a = positions.get(edge.from);
342
+ const b = positions.get(edge.to);
343
+ if (!a || !b) return null;
344
+ const overlapsX = Math.max(a.x, b.x) < Math.min(a.x + a.w, b.x + b.w);
345
+ const [top, bottom] = a.y <= b.y ? [a, b] : [b, a];
346
+ const [left, right] = a.x <= b.x ? [a, b] : [b, a];
347
+ const cls = ["backedBySourceRecord", "boundToIntegration", "belongsToIntegration"].includes(edge.relation)
348
+ ? "is-source"
349
+ : "";
350
+ const d = overlapsX
351
+ ? `M ${top.x + top.w / 2} ${top.y + top.h} L ${bottom.x + bottom.w / 2} ${bottom.y}`
352
+ : [
353
+ `M ${left.x + left.w} ${left.y + left.h / 2}`,
354
+ `H ${(left.x + left.w + right.x) / 2}`,
355
+ `V ${right.y + right.h / 2}`,
356
+ `H ${right.x}`,
357
+ ].join(" ");
358
+ return <path key={edge.id} className={cls} d={d} markerEnd="url(#wm-arrow)" />;
359
+ })}
360
+ </svg>
361
+ {placed.map((node) => {
362
+ const pos = positions.get(node.id);
363
+ const dim = needle && !matches(node);
364
+ const isObject = node.type === "dataModelObject";
365
+ const badge = isObject ? objectTypeBadge(node.summary?.objectType) : null;
366
+ const fields = isObject ? (fieldsByObjectId.get(node.summary?.objectId) || []).slice(0, 5) : [];
367
+ const rowCount = node.summary?.rowCount;
368
+ const recordCount = node.summary?.recordCount;
369
+ return (
370
+ <button
371
+ type="button"
372
+ key={node.id}
373
+ className={`wm-node${node.id === selectedId ? " is-selected" : ""}`}
374
+ style={{ left: pos.x, top: pos.y, width: pos.w, opacity: dim ? 0.32 : 1 }}
375
+ onClick={() => setSelectedId(node.id)}
376
+ aria-pressed={node.id === selectedId}
377
+ title={`Inspect ${node.label}`}
378
+ >
379
+ <span className="wm-node-head">
380
+ <span className="wm-node-icon"><LucideIcon name={nodeIconName(node)} size={14} /></span>
381
+ <span className="wm-node-title">{node.label || node.type}</span>
382
+ {badge && <span className={`dm-badge ${badge.cls}`}>{badge.label}</span>}
383
+ </span>
384
+ <span className="wm-node-body">
385
+ {isObject && (
386
+ <span className="wm-node-stat">
387
+ {Number.isFinite(rowCount) ? pluralize(rowCount, "record") : "—"}
388
+ {node.summary?.isLiveBacked ? " · live" : node.summary?.readOnly ? " · read-only" : " · manual"}
389
+ </span>
390
+ )}
391
+ {node.type === "sourceRecord" && (
392
+ <span className="wm-node-stat">
393
+ {Number.isFinite(recordCount) ? pluralize(recordCount, "record") : "no records"}
394
+ {node.summary?.fetchedAt ? ` · fetched ${String(node.summary.fetchedAt).slice(0, 10)}` : ""}
395
+ </span>
396
+ )}
397
+ {node.type === "workflow" && (
398
+ <span className="wm-node-stat">
399
+ {pluralize(node.summary?.nodeCount || 0, "step")}
400
+ {node.summary?.lifecycleStatus ? ` · ${node.summary.lifecycleStatus}` : ""}
401
+ </span>
402
+ )}
403
+ {node.type === "sandbox" && (
404
+ <span className="wm-node-stat">
405
+ {node.summary?.adapter || "sandbox"}
406
+ {node.summary?.authStatus ? ` · ${node.summary.authStatus}` : ""}
407
+ </span>
408
+ )}
409
+ {node.type === "integration" && (
410
+ <span className="wm-node-stat">{node.summary?.status || node.summary?.lane || "integration"}</span>
411
+ )}
412
+ {node.type === "dashboard" && (
413
+ <span className="wm-node-stat">{pluralize(node.summary?.widgetCount || 0, "widget")}</span>
414
+ )}
415
+ {fields.length > 0 && (
416
+ <span className="wm-node-fields">
417
+ {fields.map((field, index) => (
418
+ <span key={`${node.id}-f-${index}`} className="wm-node-field">{field}</span>
419
+ ))}
420
+ </span>
421
+ )}
422
+ </span>
423
+ </button>
424
+ );
425
+ })}
426
+ </div>
427
+ </div>
428
+ <div className="wm-zoom" role="group" aria-label="Zoom controls">
429
+ <button type="button" onClick={() => zoom(-0.1)} aria-label="Zoom out"><ZoomOut size={15} /></button>
430
+ <button type="button" onClick={() => setScale(1)} aria-label="Reset zoom"><Maximize2 size={14} /></button>
431
+ <button type="button" onClick={() => zoom(0.1)} aria-label="Zoom in"><ZoomIn size={15} /></button>
432
+ </div>
433
+ {selectedNode && (
434
+ <aside className="wm-detail" aria-label={`${selectedNode.label} detail`}>
435
+ <div className="wm-detail-head">
436
+ <span className="wm-node-icon"><LucideIcon name={nodeIconName(selectedNode)} size={14} /></span>
437
+ <span className="wm-detail-title">{selectedNode.label || selectedNode.type}</span>
438
+ <button type="button" className="wm-detail-close" aria-label="Close detail" onClick={() => setSelectedId("")}><X size={14} /></button>
439
+ </div>
440
+ <dl className="wm-detail-meta">
441
+ {detailRows(selectedNode).map((row) => (
442
+ <div key={row.k} className="wm-detail-row"><dt>{row.k}</dt><dd>{row.v}</dd></div>
443
+ ))}
444
+ </dl>
445
+ <button type="button" className="dm-btn-primary-sm wm-detail-cta" onClick={() => handleOpen(selectedNode)}>
446
+ {openLabel(selectedNode)}
447
+ </button>
448
+ </aside>
449
+ )}
450
+ </>
451
+ )}
452
+ </div>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ export default WorkspaceDataModelCanvas;
@@ -8977,10 +8977,14 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
8977
8977
  .workspace-lens-surface { width: 100%; padding: 0; background: #f7f7f8; overflow-y: auto; overflow-x: hidden; }
8978
8978
  .workspace-lens-shell { width: 100%; max-width: none; margin: 0; padding: 24px 28px 32px; }
8979
8979
  .workspace-lens-locked { border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; padding: 28px; max-width: 560px; margin: 48px auto; display: flex; flex-direction: column; gap: 8px; }
8980
- .workspace-lens-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
8980
+ .workspace-lens-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
8981
+ .workspace-lens-head > div { min-width: 0; }
8981
8982
  .workspace-lens-title { font-size: 18px; font-weight: 600; color: #111827; margin: 0; }
8982
8983
  .workspace-lens-subtitle { font-size: 13px; color: #6b7280; margin: 4px 0 0; }
8983
- .workspace-lens-score { font-size: 12px; color: #4b5563; font-variant-numeric: tabular-nums; margin: 2px 0 0; }
8984
+ .workspace-lens-map-link { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 34px; border: 1px solid #d8dee8; border-radius: 8px; background: #ffffff; color: #111827; padding: 0 12px; text-decoration: none; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
8985
+ .workspace-lens-map-link:hover { background: #f9fafb; border-color: #cfd7e3; color: #111827; }
8986
+ .workspace-lens-map-link:focus-visible { outline: 2px solid #bfdbfe; outline-offset: 2px; }
8987
+ .workspace-lens-map-link svg { color: #4b5563; flex-shrink: 0; }
8984
8988
  .workspace-lens-controls.workspace-builder-filterbar { margin: 0 0 14px; border: 1px solid #e8edf3; border-radius: 8px; background: #fff; }
8985
8989
  .workspace-lens-filters.workspace-builder-filterbar__segments { max-width: 100%; overflow-x: auto; }
8986
8990
  .workspace-lens-filter { white-space: nowrap; }
@@ -9034,12 +9038,17 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
9034
9038
  .workspace-lens-action-menu button:hover,
9035
9039
  .workspace-lens-action-menu a:hover { background: #f3f4f6; }
9036
9040
  @media (max-width: 1100px) {
9041
+ .workspace-lens-head { align-items: flex-start; }
9037
9042
  .workspace-lens-control-grid { grid-template-columns: 1fr; }
9038
9043
  .workspace-lens-branch-row { grid-template-columns: 1fr; align-items: flex-start; padding: 10px 12px; gap: 8px; }
9039
9044
  .workspace-lens-branch-actions { justify-content: flex-start; flex-wrap: wrap; }
9040
9045
  .workspace-lens-branch-summary,
9041
9046
  .workspace-lens-branch-next { margin-left: 22px; }
9042
9047
  }
9048
+ @media (max-width: 640px) {
9049
+ .workspace-lens-head { flex-direction: column; }
9050
+ .workspace-lens-map-link { align-self: stretch; }
9051
+ }
9043
9052
  .workspace-lens-stream { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
9044
9053
  .workspace-lens-card { border: 1px solid #e8edf3; border-radius: 10px; background: #fff; padding: 12px 14px; }
9045
9054
  .workspace-lens-card.is-ready { opacity: 0.7; }
@@ -9261,3 +9270,180 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
9261
9270
  .dm-swarm-expand-head { justify-content: flex-start; gap: 8px; }
9262
9271
  .dm-swarm-expand-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px; }
9263
9272
  .dm-swarm-expand-body .dm-helper-toolcall-json { max-height: none; }
9273
+
9274
+ /* ════════════════════════════════════════════════════════════════════════
9275
+ SaaS facelift layer — Attio-inspired polish (v0.14.8)
9276
+ Additive only. Refines existing dm-* light-theme primitives via the
9277
+ cascade (equal specificity, later wins) and introduces the shared
9278
+ vocabulary consumed by the table, drawer, Workspace Map, and run-status
9279
+ work. No new theme, no token renames — existing classes keep working.
9280
+ ──────────────────────────────────────────────────────────────────────── */
9281
+ :root {
9282
+ /* Calm, unified surface system for the light data-model surfaces. */
9283
+ --dm-line: #e7eaee; /* default hairline border */
9284
+ --dm-line-soft: #f0f2f5; /* inner cell / row separators */
9285
+ --dm-surface: #ffffff;
9286
+ --dm-surface-soft: #fafbfc; /* headers, toolbars, footers */
9287
+ --dm-hover: #f6f8fa; /* calm row/control hover */
9288
+ --dm-ink: #1f2733; /* primary text */
9289
+ --dm-muted: #6b7686; /* secondary text */
9290
+ --dm-faint: #9aa4b2; /* tertiary / placeholder */
9291
+ --dm-accent: #4f6bed; /* single accent — active states only */
9292
+ --dm-accent-soft: #eef1fe; /* accent background wash */
9293
+ --dm-accent-line: #c9d3fb; /* accent border */
9294
+ --dm-radius: 8px;
9295
+ --dm-radius-sm: 6px;
9296
+ --dm-radius-lg: 12px;
9297
+ --dm-shadow-pop: 0 12px 32px rgba(20, 28, 46, 0.12), 0 2px 6px rgba(20, 28, 46, 0.06);
9298
+ --dm-ok: #16a34a;
9299
+ --dm-ok-soft: #f0fdf4;
9300
+ --dm-ok-line: #bbf7d0;
9301
+ --dm-bad: #dc2626;
9302
+ --dm-bad-soft: #fef2f2;
9303
+ --dm-bad-line: #fecaca;
9304
+ --dm-warn: #d97706;
9305
+ --dm-warn-soft: #fffbeb;
9306
+ --dm-warn-line: #fde68a;
9307
+ --dm-run: #4f6bed;
9308
+ --dm-run-soft: #eef1fe;
9309
+ --dm-run-line: #c9d3fb;
9310
+ }
9311
+
9312
+ /* ── Toolbar + buttons: softer borders, unified radius/hover ───────────── */
9313
+ .dm-db-toolbar { border-bottom-color: var(--dm-line); }
9314
+ .dm-btn-ghost { border-radius: var(--dm-radius-sm); border-color: var(--dm-line); color: var(--dm-muted); }
9315
+ .dm-btn-ghost:hover { background: var(--dm-hover); border-color: #d3d9e0; color: var(--dm-ink); }
9316
+ .dm-btn-ghost.is-active { background: var(--dm-accent-soft); border-color: var(--dm-accent-line); color: var(--dm-accent); }
9317
+ .dm-btn-outline { border-radius: var(--dm-radius-sm); border-color: var(--dm-line); }
9318
+ .dm-btn-primary-sm { border-radius: var(--dm-radius-sm); }
9319
+
9320
+ /* ── Compact search field for the table toolbar (P1) ──────────────────── */
9321
+ .dm-toolbar-search { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 9px; border: 1px solid var(--dm-line); border-radius: var(--dm-radius-sm); background: var(--dm-surface); color: var(--dm-faint); transition: border-color .12s, box-shadow .12s; }
9322
+ .dm-toolbar-search:focus-within { border-color: var(--dm-accent-line); box-shadow: 0 0 0 3px var(--dm-accent-soft); }
9323
+ .dm-toolbar-search input { border: 0; outline: 0; background: transparent; font: inherit; font-size: 12px; color: var(--dm-ink); width: 150px; }
9324
+ .dm-toolbar-search input::placeholder { color: var(--dm-faint); }
9325
+ .dm-toolbar-count { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dm-surface-soft); border: 1px solid var(--dm-line); color: var(--dm-muted); font-size: 11px; font-weight: 650; font-variant-numeric: tabular-nums; }
9326
+ .dm-toolbar-divider { width: 1px; align-self: stretch; margin: 2px 2px; background: var(--dm-line); }
9327
+
9328
+ /* ── Filter / sort pill chips: calmer, clearer active state ───────────── */
9329
+ .dm-filter-chip { border-radius: var(--dm-radius-sm); border-color: var(--dm-accent-line); background: var(--dm-accent-soft); color: var(--dm-accent); font-weight: 600; transition: background .12s, border-color .12s; }
9330
+ .dm-filter-chip:hover { background: #e4e9fd; }
9331
+ .dm-filter-chip-count { margin-left: 1px; min-width: 16px; height: 16px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--dm-accent); color: #fff; font-size: 10px; font-weight: 700; }
9332
+ .dm-selection-count { border-color: var(--dm-line); background: var(--dm-surface); color: var(--dm-muted); }
9333
+
9334
+ /* ── Grid: calmer hairlines + hover, refined sticky header ────────────── */
9335
+ .dm-db-grid-wrap { border-color: var(--dm-line); border-radius: var(--dm-radius); }
9336
+ .dm-db-grid th { background: var(--dm-surface-soft); color: var(--dm-muted); border-bottom-color: var(--dm-line); border-right-color: var(--dm-line-soft); letter-spacing: .01em; }
9337
+ .dm-db-grid td { border-bottom-color: var(--dm-line-soft); border-right-color: var(--dm-line-soft); }
9338
+ .dm-db-grid tbody tr:hover td { background: var(--dm-hover); }
9339
+ .dm-db-grid tbody tr.selected td { background: var(--dm-accent-soft); box-shadow: inset 2px 0 0 var(--dm-accent); }
9340
+ .dm-db-field-type { background: var(--dm-surface-soft); color: var(--dm-faint); border-radius: 5px; }
9341
+
9342
+ /* ── Object sidebar: calmer active state (less heavy than solid black) ── */
9343
+ .dm-obj-row { border-radius: var(--dm-radius-sm); }
9344
+ .dm-obj-row:hover { background: var(--dm-hover); }
9345
+ .dm-obj-row.active { background: var(--dm-accent-soft); color: var(--dm-accent); font-weight: 600; }
9346
+ .dm-obj-row.active .dm-obj-icon { color: var(--dm-accent); }
9347
+ .dm-obj-row.active .dm-badge { background: rgba(79,107,237,.12); color: var(--dm-accent); border-color: var(--dm-accent-line); }
9348
+ /* Record count on the live object picker rows (P2). The picker — not the
9349
+ legacy ObjectSidebar — is the rendered object nav. */
9350
+ .dm-picker-meta { margin-left: auto; flex: 0 0 auto; color: var(--dm-faint); font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
9351
+ .dm-picker-item.active .dm-picker-meta { color: var(--dm-muted); font-weight: 500; }
9352
+
9353
+ /* ── Badges: thinner, calmer ──────────────────────────────────────────── */
9354
+ .dm-badge { border-radius: 5px; border-color: var(--dm-line); }
9355
+
9356
+ /* ════ Live status chips — shared by StatusPill + run-status work (P4) ══ */
9357
+ .dm-status-chip { display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px; border: 1px solid var(--dm-line); border-radius: 999px; background: var(--dm-surface-soft); color: var(--dm-muted); font-size: 11px; font-weight: 650; line-height: 1; white-space: nowrap; }
9358
+ .dm-status-chip > .dm-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dm-faint); flex: 0 0 auto; }
9359
+ .dm-status-chip.is-ok { border-color: var(--dm-ok-line); background: var(--dm-ok-soft); color: #166534; }
9360
+ .dm-status-chip.is-ok > .dm-status-dot { background: var(--dm-ok); }
9361
+ .dm-status-chip.is-bad { border-color: var(--dm-bad-line); background: var(--dm-bad-soft); color: #991b1b; }
9362
+ .dm-status-chip.is-bad > .dm-status-dot { background: var(--dm-bad); }
9363
+ .dm-status-chip.is-warn { border-color: var(--dm-warn-line); background: var(--dm-warn-soft); color: #92400e; }
9364
+ .dm-status-chip.is-warn > .dm-status-dot { background: var(--dm-warn); }
9365
+ .dm-status-chip.is-running { border-color: var(--dm-run-line); background: var(--dm-run-soft); color: #3a4fc0; }
9366
+ .dm-status-chip.is-running > .dm-status-dot { background: var(--dm-run); animation: dm-pulse 1.15s ease-in-out infinite; }
9367
+ .dm-status-chip.is-waiting > .dm-status-dot { background: var(--dm-faint); }
9368
+ @keyframes dm-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.72); } }
9369
+ @media (prefers-reduced-motion: reduce) { .dm-status-chip.is-running > .dm-status-dot { animation: none; } }
9370
+
9371
+ /* ── Toggle switch — upgrades ToggleField's checkbox into a real switch ─ */
9372
+ .dm-switch-row { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; font-size: 13px; color: var(--dm-ink); }
9373
+ .dm-switch-row.is-disabled { opacity: .55; cursor: not-allowed; }
9374
+ .dm-switch-row > input { position: absolute; opacity: 0; width: 0; height: 0; }
9375
+ .dm-switch-track { position: relative; flex: 0 0 auto; width: 34px; height: 20px; margin-top: 1px; border-radius: 999px; background: #d7dce3; transition: background .14s; }
9376
+ .dm-switch-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(20,28,46,.28); transition: transform .14s; }
9377
+ .dm-switch-row > input:checked + .dm-switch-track { background: var(--dm-accent); }
9378
+ .dm-switch-row > input:checked + .dm-switch-track::after { transform: translateX(14px); }
9379
+ .dm-switch-row > input:focus-visible + .dm-switch-track { box-shadow: 0 0 0 3px var(--dm-accent-soft); }
9380
+ .dm-switch-label { display: grid; gap: 2px; }
9381
+ .dm-switch-desc { color: var(--dm-muted); font-size: 12px; }
9382
+
9383
+ /* ════ Workspace Map / Data Model Canvas (P3) ═════════════════════════════
9384
+ Schema node-canvas reusing the dotted-grid + card language. Read-only. */
9385
+ .wm-shell { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--dm-surface); }
9386
+ .wm-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--dm-line); background: var(--dm-surface); flex-wrap: wrap; }
9387
+ .wm-back-link { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--dm-line); border-radius: 8px; background: var(--dm-surface); color: var(--dm-muted); text-decoration: none; box-shadow: 0 1px 2px rgba(20,28,46,.04); flex: 0 0 auto; }
9388
+ .wm-back-link:hover { background: var(--dm-hover); color: var(--dm-ink); border-color: #cfd6df; }
9389
+ .wm-back-link:focus-visible { outline: 2px solid #bfdbfe; outline-offset: 2px; }
9390
+ .wm-toolbar h1 { margin: 0; font-size: 15px; font-weight: 650; color: var(--dm-ink); }
9391
+ .wm-toolbar .wm-sub { font-size: 12px; color: var(--dm-faint); }
9392
+ .wm-legend { display: inline-flex; align-items: center; gap: 12px; margin-left: auto; flex-wrap: wrap; }
9393
+ .wm-legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--dm-muted); }
9394
+ .wm-legend-swatch { width: 9px; height: 9px; border-radius: 3px; }
9395
+ .wm-canvas { position: relative; flex: 1; min-height: 460px; overflow: auto; background-color: #fbfcfd; background-image: radial-gradient(circle, #dde2e8 1px, transparent 1px); background-size: 22px 22px; cursor: grab; touch-action: none; }
9396
+ .wm-canvas.is-panning { cursor: grabbing; user-select: none; }
9397
+ .wm-canvas-inner { position: relative; }
9398
+ .wm-canvas-scale { position: relative; transform-origin: 0 0; }
9399
+ .wm-edge { position: absolute; pointer-events: none; }
9400
+ .wm-edge path { stroke: #cbd5e1; stroke-width: 1.25; fill: none; stroke-linecap: round; stroke-linejoin: round; }
9401
+ .wm-edge .wm-arrow-head { fill: #cbd5e1; stroke: none; }
9402
+ .wm-edge path.is-source { stroke: #cbd5e1; }
9403
+ .wm-edge path.is-warn { stroke: var(--dm-warn-line); stroke-dasharray: 4 4; }
9404
+ .wm-node { position: absolute; width: 220px; border: 1px solid var(--dm-line); border-radius: var(--dm-radius-lg); background: var(--dm-surface); box-shadow: 0 1px 2px rgba(20,28,46,.06); cursor: pointer; transition: box-shadow .12s, border-color .12s, transform .12s; text-align: left; padding: 0; font: inherit; }
9405
+ .wm-node:hover { border-color: #cfd6df; box-shadow: var(--dm-shadow-pop); transform: translateY(-1px); }
9406
+ .wm-node.is-selected { border-color: var(--dm-accent); box-shadow: 0 0 0 3px var(--dm-accent-soft); }
9407
+ .wm-node-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--dm-line-soft); }
9408
+ .wm-node-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 7px; background: var(--dm-surface-soft); color: var(--dm-muted); flex: 0 0 auto; }
9409
+ .wm-node-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 650; color: var(--dm-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9410
+ .wm-node-body { padding: 9px 12px; display: grid; gap: 4px; }
9411
+ .wm-node-stat { font-size: 12px; color: var(--dm-muted); font-variant-numeric: tabular-nums; }
9412
+ .wm-node-fields { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
9413
+ .wm-node-field { font-size: 10.5px; color: var(--dm-faint); background: var(--dm-surface-soft); border: 1px solid var(--dm-line-soft); border-radius: 4px; padding: 1px 5px; }
9414
+ .wm-node-warn { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--dm-warn); }
9415
+ .wm-zoom { position: sticky; bottom: 12px; left: 12px; display: inline-flex; gap: 4px; padding: 4px; border: 1px solid var(--dm-line); border-radius: 999px; background: var(--dm-surface); box-shadow: var(--dm-shadow-pop); width: fit-content; margin: 12px; }
9416
+ .wm-zoom button { width: 28px; height: 28px; border: 0; border-radius: 999px; background: transparent; color: var(--dm-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
9417
+ .wm-zoom button:hover { background: var(--dm-hover); color: var(--dm-ink); }
9418
+ .wm-empty { display: grid; place-items: center; gap: 8px; padding: 64px 24px; text-align: center; color: var(--dm-muted); }
9419
+ .wm-empty strong { font-size: 16px; color: var(--dm-ink); }
9420
+ /* Read-only selected-node detail panel — additive, docks top-right of canvas. */
9421
+ .wm-detail { position: sticky; top: 12px; margin: -12px 12px 12px auto; float: right; width: 248px; background: var(--dm-surface); border: 1px solid var(--dm-line); border-radius: var(--dm-radius-lg); box-shadow: var(--dm-shadow-pop); z-index: 5; }
9422
+ .wm-detail-head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 10px 12px; border-bottom: 1px solid var(--dm-line-soft); }
9423
+ .wm-detail-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 650; color: var(--dm-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9424
+ .wm-detail-close { width: 24px; height: 24px; border: 0; border-radius: 6px; background: transparent; color: var(--dm-faint); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; }
9425
+ .wm-detail-close:hover { background: var(--dm-hover); color: var(--dm-ink); }
9426
+ .wm-detail-meta { margin: 0; padding: 10px 12px; display: grid; gap: 6px; }
9427
+ .wm-detail-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
9428
+ .wm-detail-row dt { color: var(--dm-faint); }
9429
+ .wm-detail-row dd { margin: 0; color: var(--dm-ink); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9430
+ .wm-detail-cta { margin: 0 12px 12px; width: calc(100% - 24px); justify-content: center; }
9431
+
9432
+ /* Per-node run-status pill on the Workflow Canvas — docked outside the node's
9433
+ top-right corner. Driven by the general orchestration stream
9434
+ (orchestration.node.* deltas) + the persisted nodeTrace; clickable to open
9435
+ the Live Runs trace. */
9436
+ .dm-orchestration-node { position: relative; overflow: visible; }
9437
+ .dm-orchestration-canvas__step { overflow: visible; }
9438
+ .dm-orchestration-node__status { position: absolute; top: -18px; right: 14px; z-index: 3; height: 16px; min-height: 16px; padding: 0 6px; gap: 3px; border: 1px solid var(--dm-line); background: var(--dm-surface); box-shadow: 0 1px 3px rgba(20,28,46,.12); cursor: pointer; font-size: 9.5px; font-weight: 700; line-height: 1; transform: translateY(-2px); }
9439
+ .dm-orchestration-node__status > .dm-status-dot { width: 5px; height: 5px; }
9440
+ .dm-orchestration-node__status:hover { box-shadow: 0 2px 6px rgba(20,28,46,.2); }
9441
+ .dm-orchestration-node__status:focus-visible { outline: 2px solid var(--dm-accent); outline-offset: 1px; }
9442
+
9443
+ /* ── Table empty / no-results state (P1) ──────────────────────────────── */
9444
+ .dm-db-empty-row td { padding: 0 !important; background: var(--dm-surface) !important; cursor: default !important; }
9445
+ .dm-db-empty-row:hover td { background: var(--dm-surface) !important; }
9446
+ .dm-db-empty-state { display: grid; justify-items: center; gap: 6px; padding: 48px 24px; text-align: center; }
9447
+ .dm-db-empty-state strong { font-size: 15px; color: var(--dm-ink); font-weight: 650; }
9448
+ .dm-db-empty-state span { font-size: 12.5px; color: var(--dm-muted); max-width: 360px; }
9449
+ .dm-db-empty-actions { display: inline-flex; gap: 8px; margin-top: 8px; }
@@ -44,6 +44,7 @@ import {
44
44
  validateOrchestrationGraph
45
45
  } from "@/lib/orchestration-graph";
46
46
  import { resolveConnectorAction } from "@/lib/orchestration-sidecar-routing";
47
+ import { deriveOrchestrationNodeStatuses } from "@/lib/orchestration-node-status";
47
48
  import {
48
49
  nodeSandboxRecordRef,
49
50
  patchSandboxRowInConfig,
@@ -83,6 +84,45 @@ function withUiCacheFlag(workspaceConfig, flag, value) {
83
84
  return { ...workspaceConfig, dataModel: { ...dm, objects: nextObjects } };
84
85
  }
85
86
 
87
+ // Read the sandbox-run NDJSON delta stream (same shape SwarmRunCockpit
88
+ // consumes): push each growthub-sandbox-run-delta-v1 event to `onEvent` for
89
+ // live canvas hydration, and return the sandbox-run.final payload (the run
90
+ // result). Falls back to plain JSON when the response is not a stream.
91
+ async function readSandboxRunStream(response, onEvent) {
92
+ if (!response?.body || typeof response.body.getReader !== "function") {
93
+ return response.json().catch(() => null);
94
+ }
95
+ const reader = response.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let buffer = "";
98
+ let finalPayload = null;
99
+ const handle = async (line) => {
100
+ const trimmed = line.trim();
101
+ if (!trimmed) return;
102
+ try {
103
+ const event = JSON.parse(trimmed);
104
+ if (event.kind !== "growthub-sandbox-run-delta-v1") return;
105
+ if (event.type === "sandbox-run.final") finalPayload = event.payload || finalPayload;
106
+ else if (typeof onEvent === "function") {
107
+ onEvent((prev) => [...prev, event].slice(-300));
108
+ await new Promise((resolve) => setTimeout(resolve, 90));
109
+ }
110
+ } catch {
111
+ // Ignore malformed cosmetic chunks; the final payload still arrives.
112
+ }
113
+ };
114
+ while (true) {
115
+ const { value, done } = await reader.read();
116
+ if (done) break;
117
+ buffer += decoder.decode(value, { stream: true });
118
+ const lines = buffer.split("\n");
119
+ buffer = lines.pop() || "";
120
+ for (const line of lines) await handle(line);
121
+ }
122
+ if (buffer.trim()) await handle(buffer);
123
+ return finalPayload;
124
+ }
125
+
86
126
  // Workspace Metadata Graph V1 — read-only dependency metadata for workflow
87
127
  // sidecars. The runtime path (sandbox-run, publish, draft/live) is
88
128
  // unchanged; this only exposes typed dependency descriptors so the sidecar
@@ -281,6 +321,7 @@ export default function WorkflowSurface() {
281
321
  const [publishing, setPublishing] = useState(false);
282
322
  const [saveMessage, setSaveMessage] = useState("");
283
323
  const [running, setRunning] = useState(false);
324
+ const [liveRunEvents, setLiveRunEvents] = useState([]);
284
325
  const [runMessage, setRunMessage] = useState("");
285
326
  const [sidecarMode, setSidecarMode] = useState(runId ? "trace" : "graph");
286
327
 
@@ -327,12 +368,29 @@ export default function WorkflowSurface() {
327
368
 
328
369
  useEffect(() => { load(); }, [load]);
329
370
 
371
+ // Reset live per-node deltas when the active workflow changes, so a prior
372
+ // run's stream never bleeds onto a different workflow's canvas — the new
373
+ // workflow settles from its own persisted nodeTrace until it is run.
374
+ useEffect(() => { setLiveRunEvents([]); }, [objectId, rowId]);
375
+
330
376
  const resolved = useMemo(
331
377
  () => (workspaceConfig ? findSandboxRowByWorkflowRef(workspaceConfig, objectId, rowId) : { object: null, row: null, rowIndex: -1 }),
332
378
  [workspaceConfig, objectId, rowId]
333
379
  );
334
380
 
335
381
  const sandboxRow = resolved.row;
382
+
383
+ // Per-node Workflow Canvas pill status — GENERAL orchestration (not swarm).
384
+ // Live from the streamed orchestration.node.* deltas while a run is in
385
+ // flight; settled from the persisted run record's nodeTrace once complete.
386
+ const runNodeStatuses = useMemo(() => {
387
+ let record = sandboxRow?.lastResponse;
388
+ if (typeof record === "string") {
389
+ try { record = JSON.parse(record); } catch { record = null; }
390
+ }
391
+ const map = deriveOrchestrationNodeStatuses({ events: liveRunEvents, record });
392
+ return Object.keys(map).length ? map : null;
393
+ }, [liveRunEvents, sandboxRow]);
336
394
  const hasGraphValue = (value) => Boolean(parseOrchestrationGraph(value));
337
395
  const effectiveFieldName = hasGraphValue(sandboxRow?.[fieldName])
338
396
  ? fieldName
@@ -564,17 +622,21 @@ export default function WorkflowSurface() {
564
622
  : null;
565
623
  setRunning(true);
566
624
  setRunMessage("");
625
+ setLiveRunEvents([]);
567
626
  try {
568
627
  const draft = await saveDraft({ orchestrationDraftStatus: "testing" });
569
628
  const draftGraph = draft?.serialized || serializeCurrentGraph();
570
- const body = { objectId, name: rowId, useDraft: true, draftGraph };
629
+ const body = { objectId, name: rowId, useDraft: true, draftGraph, stream: true };
571
630
  if (runInputs) body.runInputs = runInputs;
572
631
  const res = await fetch("/api/workspace/sandbox-run", {
573
632
  method: "POST",
574
- headers: { "content-type": "application/json" },
633
+ headers: { "content-type": "application/json", accept: "application/x-ndjson" },
575
634
  body: JSON.stringify(body),
576
635
  });
577
- const payload = await res.json();
636
+ // Consume the NDJSON delta stream: per-node orchestration.node.* events
637
+ // hydrate the canvas pills live; the sandbox-run.final payload is the
638
+ // run result we persist. Falls back to plain JSON if not a stream.
639
+ const payload = (await readSandboxRunStream(res, setLiveRunEvents)) || {};
578
640
  const responseText = redactSecretsFromText(JSON.stringify(payload.response ?? payload, null, 2));
579
641
  const status = payload.ok && String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
580
642
  const pass = isPassingRun(payload);
@@ -1042,6 +1104,8 @@ export default function WorkflowSurface() {
1042
1104
  setConfigTab("node");
1043
1105
  }}
1044
1106
  onConnectorAction={handleConnectorAction}
1107
+ nodeStatuses={runNodeStatuses}
1108
+ onNodeStatusClick={(node) => { setSelectedNodeId(String(node?.id || "")); openTraceMode(); }}
1045
1109
  statusLabel={isDraftMode ? "Draft" : "Live"}
1046
1110
  />
1047
1111
  {nextNodeId && (
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { Suspense } from "react";
4
+ import WorkspaceDataModelCanvas from "../data-model/components/WorkspaceDataModelCanvas.jsx";
5
+
6
+ // Read-only workspace-level schema canvas. No mutation lane, no new runtime —
7
+ // it reads /api/workspace and renders the derived metadata graph.
8
+ export default function WorkspaceMapPage() {
9
+ return (
10
+ <Suspense fallback={null}>
11
+ <WorkspaceDataModelCanvas />
12
+ </Suspense>
13
+ );
14
+ }
@@ -301,11 +301,43 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
301
301
  const inputPayload = { ...baseInputPayload, ...manualPayload };
302
302
  const consumedInputKeys = Object.keys(manualPayload);
303
303
  const transformConfig = extractTransformConfig(graph);
304
+ const transformNode = (Array.isArray(graph.nodes) ? graph.nodes : [])
305
+ .find((n) => n?.type === "transform-filter" || n?.type === "normalize-output");
304
306
  const resultNode = graph.nodes?.find((n) => n?.type === "tool-result");
305
307
  const successCodes = Array.isArray(resultNode?.config?.successStatusCodes)
306
308
  ? resultNode.config.successStatusCodes.map(Number).filter(Number.isFinite)
307
309
  : [200];
308
310
 
311
+ // Per-node execution deltas for the GENERAL orchestration pipeline (not
312
+ // swarm-specific): stream node lifecycle through the same onEvent NDJSON hook
313
+ // the route already wires, and record a terminal nodeTrace persisted on the
314
+ // run record. Every event corresponds to a real pipeline stage executing.
315
+ const onEvent = typeof executionContext?.onEvent === "function" ? executionContext.onEvent : null;
316
+ const runId = String(executionContext?.runId || "").trim();
317
+ const nodeTrace = [];
318
+ const emitNode = (nodeId, status, error) => {
319
+ const id = String(nodeId || "").trim();
320
+ if (!id) return;
321
+ if (status === "completed" || status === "failed" || status === "skipped") {
322
+ nodeTrace.push(error ? { id, status, error: String(error) } : { id, status });
323
+ }
324
+ if (onEvent) {
325
+ onEvent({
326
+ kind: "growthub-sandbox-run-delta-v1",
327
+ type: `orchestration.node.${status}`,
328
+ nodeId: id,
329
+ runId,
330
+ emittedAt: new Date().toISOString(),
331
+ ...(error ? { error: String(error) } : {})
332
+ });
333
+ }
334
+ };
335
+
336
+ // Input stage — payload is assembled above; it ran.
337
+ if (inputNode?.id) { emitNode(inputNode.id, "started"); emitNode(inputNode.id, "completed"); }
338
+
339
+ // API Registry call stage.
340
+ if (apiNode?.id) emitNode(apiNode.id, "started");
309
341
  const raw = await executeApiRegistryCall(
310
342
  workspaceConfig,
311
343
  apiNode.config,
@@ -320,14 +352,29 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
320
352
  raw.exitCode = 1;
321
353
  raw.error = `HTTP ${httpStatus} is not in successStatusCodes`;
322
354
  }
355
+ }
356
+
357
+ if (apiNode?.id) {
358
+ if (raw.ok) emitNode(apiNode.id, "completed");
359
+ else emitNode(apiNode.id, "failed", raw.error);
360
+ }
361
+
362
+ if (raw.ok && raw.rawPayload !== undefined) {
363
+ if (transformNode?.id) emitNode(transformNode.id, "started");
323
364
  const transformed = transformProviderPayload(raw.rawPayload, transformConfig);
324
365
  raw.stdout = typeof transformed === "string"
325
366
  ? transformed
326
367
  : normalizeJsonAtPath(transformed, "");
327
368
  delete raw.rawPayload;
328
369
  delete raw.httpStatus;
370
+ if (transformNode?.id) emitNode(transformNode.id, "completed");
371
+ if (resultNode?.id) { emitNode(resultNode.id, "started"); emitNode(resultNode.id, "completed"); }
329
372
  } else if (raw.error) {
330
373
  raw.error = redactSecretsFromText(raw.error);
374
+ // Downstream stages never executed → record them as skipped (not-run), not
375
+ // failed: only the api stage actually failed.
376
+ if (transformNode?.id) emitNode(transformNode.id, "skipped");
377
+ if (resultNode?.id) emitNode(resultNode.id, "skipped");
331
378
  }
332
379
 
333
380
  if (raw.stdout) {
@@ -336,6 +383,7 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
336
383
 
337
384
  return {
338
385
  ...raw,
386
+ nodeTrace,
339
387
  adapterMeta: {
340
388
  ...(raw.adapterMeta || {}),
341
389
  orchestrationProvider: graph.provider,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Orchestration per-node run status — GENERAL (not swarm).
3
+ *
4
+ * Maps the real per-node execution signal of a sandbox/orchestration run onto
5
+ * a plain { nodeId: status } map the Workflow Canvas pills consume:
6
+ *
7
+ * - live: growthub-sandbox-run-delta-v1 events of type
8
+ * `orchestration.node.{started|completed|failed|skipped}` streamed
9
+ * from POST /api/workspace/sandbox-run while the run is in flight.
10
+ * - settled: the persisted run record's `nodeTrace` (written by the
11
+ * orchestration runner) once the run completes.
12
+ *
13
+ * This is the general orchestration pipeline signal (input → api-registry-call
14
+ * → transform → tool-result, human-input, etc.) — distinct from the swarm
15
+ * cockpit projection. Each entry corresponds to a real pipeline stage that
16
+ * executed; nothing is fabricated. Pure, never throws.
17
+ *
18
+ * Status vocabulary returned: "running" | "completed" | "failed" | "skipped".
19
+ */
20
+
21
+ const NODE_EVENT_PREFIX = "orchestration.node.";
22
+
23
+ function deriveOrchestrationNodeStatuses({ events, record } = {}) {
24
+ const out = {};
25
+ try {
26
+ // Live events win while a run streams — later events overwrite earlier.
27
+ const list = Array.isArray(events) ? events : [];
28
+ for (const event of list) {
29
+ if (!event || typeof event !== "object") continue;
30
+ if (event.kind && event.kind !== "growthub-sandbox-run-delta-v1") continue;
31
+ const type = String(event.type || "");
32
+ if (!type.startsWith(NODE_EVENT_PREFIX)) continue;
33
+ const id = String(event.nodeId || "").trim();
34
+ if (!id) continue;
35
+ const phase = type.slice(NODE_EVENT_PREFIX.length);
36
+ out[id] = phase === "started" ? "running" : phase;
37
+ }
38
+ if (Object.keys(out).length) return out;
39
+
40
+ // Settled from the persisted per-node trace.
41
+ const trace = record && Array.isArray(record.nodeTrace) ? record.nodeTrace : [];
42
+ for (const entry of trace) {
43
+ if (!entry || typeof entry !== "object") continue;
44
+ const id = String(entry.id || "").trim();
45
+ if (!id) continue;
46
+ const status = String(entry.status || "").trim();
47
+ if (status) out[id] = status;
48
+ }
49
+ return out;
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ export { deriveOrchestrationNodeStatuses };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growthub/cli",
3
- "version": "0.14.6",
3
+ "version": "0.14.8",
4
4
  "description": "CLI control plane for Growthub Local and Agent Workspace as Code: export, fork, inspect, operate, sync, and optionally activate governed AI workspaces.",
5
5
  "type": "module",
6
6
  "bin": {