@growthub/cli 0.13.4 → 0.13.5

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 (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +184 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +25 -2
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +326 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +6 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +88 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +41 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceGraphInspectorPanel.jsx +226 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +16 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +49 -4
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +14 -1
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +923 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +14 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +216 -5
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +28 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-inputs.js +43 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-trace.js +3 -1
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +36 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-chart-values.js +53 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-graph.js +646 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-selectors.js +249 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +1186 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +5 -0
  23. package/package.json +1 -1
@@ -0,0 +1,1186 @@
1
+ /**
2
+ * Growthub Workspace Metadata Graph V1 — pure metadata store.
3
+ *
4
+ * Derives a typed, read-only projection of the workspace from the existing
5
+ * authoritative artifacts. This module is the seam Twenty has between
6
+ * `object metadata`, `field metadata`, `view metadata`, `workflow metadata`
7
+ * and the rest of its UI — except here the authority order is preserved:
8
+ *
9
+ * 1. growthub.config.json (governed workspace artifact)
10
+ * 2. growthub.source-records.json (live-source sidecar state)
11
+ * 3. sandbox rows + workflow graph (inside Data Model objects)
12
+ * 4. integration entity metadata (resolved server-side)
13
+ * 5. run records (sandbox-run / row.lastResponse)
14
+ * 6. derived metadata graph (this module)
15
+ *
16
+ * Invariants:
17
+ * - No React. No fetch. No mutation of the input.
18
+ * - Never throws on unknown / partial config — returns warnings instead.
19
+ * - Never contains secrets. Provider tokens, API keys, bearer tokens,
20
+ * and OAuth tokens never appear in any metadata item.
21
+ * - Stable, deterministic IDs so dependent UI / agents can diff between
22
+ * calls and compute stale groups safely.
23
+ *
24
+ * The output shape mirrors the V1 contract:
25
+ *
26
+ * {
27
+ * kind: "growthub-workspace-metadata-store-v1",
28
+ * version: 1,
29
+ * objects: workspaceObjectMetadataItems
30
+ * fields: workspaceFieldMetadataItems
31
+ * views: workspaceViewMetadataItems
32
+ * filters: workspaceFilterMetadataItems
33
+ * sorts: workspaceSortMetadataItems
34
+ * widgets: workspaceWidgetMetadataItems
35
+ * dashboards: workspaceDashboardMetadataItems
36
+ * workflows: workspaceWorkflowMetadataItems
37
+ * workflowNodes: workspaceWorkflowNodeMetadataItems
38
+ * runInputs: workspaceRunInputMetadataItems
39
+ * agentHosts: workspaceAgentHostMetadataItems
40
+ * sandboxes: workspaceSandboxMetadataItems
41
+ * integrations: workspaceIntegrationMetadataItems
42
+ * integrationEntities: workspaceIntegrationEntityMetadataItems
43
+ * sourceRecords: workspaceSourceRecordMetadataItems
44
+ * runs: workspaceRunRecordMetadataItems
45
+ * outputArtifacts: workspaceOutputArtifactMetadataItems
46
+ * warnings: string[]
47
+ * }
48
+ */
49
+
50
+ import { parseOrchestrationGraph } from "./orchestration-graph.js";
51
+ import { discoverRunInputSchema } from "./orchestration-run-inputs.js";
52
+
53
+ const METADATA_STORE_KIND = "growthub-workspace-metadata-store-v1";
54
+ const METADATA_STORE_VERSION = 1;
55
+
56
+ const HIDDEN_SANDBOX_OBJECT_IDS = new Set(["workspace-helper-sandbox"]);
57
+
58
+ // Secret-shaped keys are stripped from any metadata item before it is
59
+ // returned. The metadata graph is a read model — it MUST NOT echo provider
60
+ // tokens, API keys, or any other auth material.
61
+ const SECRET_FIELD_NAMES = new Set([
62
+ "accesstoken",
63
+ "refreshtoken",
64
+ "bearertoken",
65
+ "apikey",
66
+ "api_key",
67
+ "secret",
68
+ "password",
69
+ "token",
70
+ "authorization",
71
+ "claudetoken",
72
+ "authheadervalue"
73
+ ]);
74
+
75
+ function isPlainObject(value) {
76
+ return value !== null && typeof value === "object" && !Array.isArray(value);
77
+ }
78
+
79
+ function safeString(value) {
80
+ if (value == null) return "";
81
+ return typeof value === "string" ? value : String(value);
82
+ }
83
+
84
+ function stableId(...parts) {
85
+ return parts
86
+ .map((part) => safeString(part).trim())
87
+ .filter(Boolean)
88
+ .join(":");
89
+ }
90
+
91
+ function isSecretKey(name) {
92
+ const key = safeString(name).trim().toLowerCase().replace(/[\s_-]+/g, "");
93
+ if (!key) return false;
94
+ if (SECRET_FIELD_NAMES.has(key)) return true;
95
+ if (/(token|secret|password|apikey|bearer)$/.test(key)) return true;
96
+ return false;
97
+ }
98
+
99
+ function inferPrimitiveType(value) {
100
+ if (value === null || value === undefined || value === "") return "text";
101
+ if (typeof value === "number") return Number.isFinite(value) ? "number" : "text";
102
+ if (typeof value === "boolean") return "boolean";
103
+ if (Array.isArray(value)) return "list";
104
+ if (isPlainObject(value)) return "json";
105
+ if (typeof value === "string") {
106
+ const trimmed = value.trim();
107
+ if (!trimmed) return "text";
108
+ if (/^-?\d+(\.\d+)?$/.test(trimmed.replace(/,/g, ""))) return "number";
109
+ if (/^(true|false)$/i.test(trimmed)) return "boolean";
110
+ if (!Number.isNaN(Date.parse(trimmed)) && /\d{4}-\d{2}-\d{2}/.test(trimmed)) return "date";
111
+ return "text";
112
+ }
113
+ return "text";
114
+ }
115
+
116
+ function deriveFieldType(column, rows, hints) {
117
+ const hint = hints && typeof hints[column] === "string" ? hints[column].trim() : "";
118
+ if (hint) return hint;
119
+ const sample = rows.find((row) => row != null && row[column] != null && row[column] !== "");
120
+ if (!sample) return "text";
121
+ return inferPrimitiveType(sample[column]);
122
+ }
123
+
124
+ /**
125
+ * Workspace OBJECT metadata items.
126
+ *
127
+ * Sources: workspaceConfig.dataModel.objects (governed). Hidden sandbox-helper
128
+ * objects are excluded from the public metadata projection (they exist for
129
+ * the multi-turn helper, not the user surface).
130
+ */
131
+ function deriveWorkspaceObjectMetadataItems(workspaceConfig) {
132
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
133
+ ? workspaceConfig.dataModel.objects
134
+ : [];
135
+ const items = [];
136
+ const warnings = [];
137
+ for (const object of objects) {
138
+ if (!isPlainObject(object)) continue;
139
+ const id = safeString(object.id).trim();
140
+ if (!id) {
141
+ warnings.push("Skipped dataModel object without id.");
142
+ continue;
143
+ }
144
+ if (HIDDEN_SANDBOX_OBJECT_IDS.has(id)) continue;
145
+ const objectType = safeString(object.objectType || "custom").trim() || "custom";
146
+ const binding = isPlainObject(object.binding) ? object.binding : null;
147
+ const sourceStorage = safeString(binding?.sourceStorage).trim();
148
+ const sourceId = safeString(object.sourceId || binding?.sourceId).trim();
149
+ const isLiveBacked = sourceStorage === "workspace-source-records" && Boolean(sourceId);
150
+ items.push({
151
+ kind: "workspaceObject",
152
+ id,
153
+ metadataId: stableId("object", id),
154
+ label: safeString(object.label || id).trim(),
155
+ objectType,
156
+ isSandbox: objectType === "sandbox-environment",
157
+ isLiveBacked,
158
+ sourceId: isLiveBacked ? sourceId : "",
159
+ integrationId: safeString(binding?.integrationId).trim(),
160
+ bindingMode: safeString(binding?.mode).trim(),
161
+ rowCount: Array.isArray(object.rows) ? object.rows.length : 0,
162
+ columns: Array.isArray(object.columns) ? object.columns.slice() : [],
163
+ readOnly: isLiveBacked,
164
+ sourceAuthority: "workspace-config"
165
+ });
166
+ }
167
+ return { items, warnings };
168
+ }
169
+
170
+ /**
171
+ * Workspace FIELD metadata items.
172
+ *
173
+ * A field belongs to exactly one object and exposes the typed contract the
174
+ * UI needs to render filter/sort/aggregation pickers without re-deriving
175
+ * the type inside every component.
176
+ */
177
+ function deriveWorkspaceFieldMetadataItems(workspaceConfig, objectItems) {
178
+ const items = [];
179
+ const warnings = [];
180
+ const objectsById = new Map(objectItems.map((object) => [object.id, object]));
181
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
182
+ ? workspaceConfig.dataModel.objects
183
+ : [];
184
+ for (const raw of rawObjects) {
185
+ if (!isPlainObject(raw)) continue;
186
+ const objectId = safeString(raw.id).trim();
187
+ if (!objectId || !objectsById.has(objectId)) continue;
188
+ const objectMeta = objectsById.get(objectId);
189
+ const columns = Array.isArray(raw.columns) ? raw.columns : [];
190
+ const rows = Array.isArray(raw.rows) ? raw.rows : [];
191
+ const typeHints = isPlainObject(raw.fieldSettings?.types) ? raw.fieldSettings.types : null;
192
+ for (const column of columns) {
193
+ const fieldId = safeString(column).trim();
194
+ if (!fieldId) continue;
195
+ const isSecret = isSecretKey(fieldId);
196
+ const type = deriveFieldType(fieldId, rows, typeHints);
197
+ const isNumeric = type === "number";
198
+ const isDate = type === "date";
199
+ const isBoolean = type === "boolean";
200
+ items.push({
201
+ kind: "workspaceField",
202
+ id: fieldId,
203
+ metadataId: stableId("field", objectId, fieldId),
204
+ objectId,
205
+ objectMetadataId: objectMeta.metadataId,
206
+ label: fieldId,
207
+ type,
208
+ isNumeric,
209
+ isDate,
210
+ isBoolean,
211
+ isSecret,
212
+ isFilterable: !isSecret,
213
+ isSortable: !isSecret,
214
+ isChartXAxis: !isSecret,
215
+ isChartYAxis: !isSecret && (isNumeric || isBoolean),
216
+ isAggregatable: !isSecret && isNumeric,
217
+ isWritable: !objectMeta.readOnly && !isSecret
218
+ });
219
+ }
220
+ }
221
+ return { items, warnings };
222
+ }
223
+
224
+ /**
225
+ * Workspace VIEW metadata items.
226
+ *
227
+ * Views are read from `dataModel.objects[].savedViews` (when present) and
228
+ * folder workflow shortcuts (`nav-folders` rows of `type:"view"`). The
229
+ * projection always points at the source object — never inlines the
230
+ * underlying rows.
231
+ */
232
+ function deriveWorkspaceViewMetadataItems(workspaceConfig, objectItems) {
233
+ const items = [];
234
+ const warnings = [];
235
+ const objectsById = new Map(objectItems.map((object) => [object.id, object]));
236
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
237
+ ? workspaceConfig.dataModel.objects
238
+ : [];
239
+ for (const raw of rawObjects) {
240
+ if (!isPlainObject(raw)) continue;
241
+ const objectId = safeString(raw.id).trim();
242
+ if (!objectsById.has(objectId)) continue;
243
+ const objectMeta = objectsById.get(objectId);
244
+ const views = Array.isArray(raw.savedViews) ? raw.savedViews : [];
245
+ for (const view of views) {
246
+ if (!isPlainObject(view)) continue;
247
+ const viewId = safeString(view.id || view.name).trim();
248
+ if (!viewId) continue;
249
+ items.push({
250
+ kind: "workspaceView",
251
+ id: viewId,
252
+ metadataId: stableId("view", objectId, viewId),
253
+ objectId,
254
+ objectMetadataId: objectMeta.metadataId,
255
+ label: safeString(view.name || viewId).trim(),
256
+ columns: Array.isArray(view.columns) ? view.columns.slice() : [],
257
+ filterCount: Array.isArray(view.filters) ? view.filters.length : 0,
258
+ hasSort: isPlainObject(view.sort)
259
+ });
260
+ }
261
+ }
262
+ return { items, warnings };
263
+ }
264
+
265
+ function deriveWorkspaceFilterMetadataItems(widgetItems, workflowNodeItems) {
266
+ const items = [];
267
+ for (const widget of widgetItems) {
268
+ for (const clause of widget.filterClauses) {
269
+ items.push({
270
+ kind: "workspaceFilter",
271
+ metadataId: stableId("filter", widget.metadataId, clause.fieldId, clause.operator),
272
+ scope: "widget",
273
+ scopeMetadataId: widget.metadataId,
274
+ objectId: widget.objectId,
275
+ fieldId: clause.fieldId,
276
+ operator: clause.operator,
277
+ hasValue: clause.hasValue
278
+ });
279
+ }
280
+ }
281
+ for (const node of workflowNodeItems) {
282
+ for (const clause of node.filterClauses) {
283
+ items.push({
284
+ kind: "workspaceFilter",
285
+ metadataId: stableId("filter", node.metadataId, clause.fieldId, clause.operator),
286
+ scope: "workflowNode",
287
+ scopeMetadataId: node.metadataId,
288
+ objectId: node.objectId,
289
+ fieldId: clause.fieldId,
290
+ operator: clause.operator,
291
+ hasValue: clause.hasValue
292
+ });
293
+ }
294
+ }
295
+ return { items, warnings: [] };
296
+ }
297
+
298
+ function deriveWorkspaceSortMetadataItems(widgetItems) {
299
+ const items = [];
300
+ for (const widget of widgetItems) {
301
+ if (!widget.sortField) continue;
302
+ items.push({
303
+ kind: "workspaceSort",
304
+ metadataId: stableId("sort", widget.metadataId, widget.sortField),
305
+ scope: "widget",
306
+ scopeMetadataId: widget.metadataId,
307
+ objectId: widget.objectId,
308
+ fieldId: widget.sortField,
309
+ direction: widget.sortDirection || "position"
310
+ });
311
+ }
312
+ return { items, warnings: [] };
313
+ }
314
+
315
+ function collectFilterClauses(filterValue) {
316
+ const clauses = [];
317
+ if (!isPlainObject(filterValue)) return clauses;
318
+ const op = filterValue.op === "or" ? "or" : "and";
319
+ const raw = Array.isArray(filterValue.clauses) ? filterValue.clauses : [];
320
+ for (const clause of raw) {
321
+ if (!isPlainObject(clause)) continue;
322
+ const fieldId = safeString(clause.fieldId).trim();
323
+ if (!fieldId) continue;
324
+ clauses.push({
325
+ fieldId,
326
+ operator: safeString(clause.operator || "eq").trim() || "eq",
327
+ hasValue: clause.value !== undefined && clause.value !== null && clause.value !== "",
328
+ conjunction: op
329
+ });
330
+ }
331
+ return clauses;
332
+ }
333
+
334
+ function deriveWidgetEntries(workspaceConfig) {
335
+ const entries = [];
336
+ const seen = new Set();
337
+ const push = (widget, location) => {
338
+ if (!isPlainObject(widget)) return;
339
+ const widgetId = safeString(widget.id).trim();
340
+ if (!widgetId || seen.has(widgetId)) return;
341
+ seen.add(widgetId);
342
+ entries.push({ widget, location });
343
+ };
344
+ const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
345
+ for (const dashboard of dashboards) {
346
+ const tabs = Array.isArray(dashboard?.tabs) ? dashboard.tabs : [];
347
+ for (const tab of tabs) {
348
+ const widgets = Array.isArray(tab?.widgets) ? tab.widgets : [];
349
+ for (const widget of widgets) {
350
+ push(widget, {
351
+ dashboardId: safeString(dashboard.id).trim(),
352
+ dashboardName: safeString(dashboard.name).trim(),
353
+ tabId: safeString(tab.id).trim(),
354
+ tabName: safeString(tab.name).trim()
355
+ });
356
+ }
357
+ }
358
+ }
359
+ const canvas = isPlainObject(workspaceConfig?.canvas) ? workspaceConfig.canvas : null;
360
+ if (canvas) {
361
+ const tabs = Array.isArray(canvas.tabs) ? canvas.tabs : [];
362
+ for (const tab of tabs) {
363
+ const widgets = Array.isArray(tab?.widgets) ? tab.widgets : [];
364
+ for (const widget of widgets) {
365
+ push(widget, {
366
+ dashboardId: "",
367
+ dashboardName: "",
368
+ tabId: safeString(tab.id).trim(),
369
+ tabName: safeString(tab.name).trim()
370
+ });
371
+ }
372
+ }
373
+ const widgets = Array.isArray(canvas.widgets) ? canvas.widgets : [];
374
+ for (const widget of widgets) {
375
+ push(widget, {
376
+ dashboardId: "",
377
+ dashboardName: "",
378
+ tabId: "",
379
+ tabName: safeString(canvas.name).trim() || "Tab 1"
380
+ });
381
+ }
382
+ }
383
+ return entries;
384
+ }
385
+
386
+ /**
387
+ * Workspace WIDGET metadata items.
388
+ *
389
+ * For each chart / view widget we record the typed dependency contract the
390
+ * Chart Hydration Inspector + Twenty-style sidecars need:
391
+ *
392
+ * - bound object id (Data Model object the widget reads from)
393
+ * - required fields (x-axis, y-axis, groupBy)
394
+ * - filter / sort fields
395
+ * - aggregation operation
396
+ * - source-record key (if backed by a live source)
397
+ * - integration entity (if scoped to one)
398
+ *
399
+ * The metadata layer NEVER inlines source rows — it only points at the
400
+ * Data Model object so consumers can hydrate through the existing
401
+ * chart-values pipeline.
402
+ */
403
+ function deriveWorkspaceWidgetMetadataItems(workspaceConfig, objectItems) {
404
+ const items = [];
405
+ const warnings = [];
406
+ const objectsById = new Map(objectItems.map((object) => [object.id, object]));
407
+ const entries = deriveWidgetEntries(workspaceConfig);
408
+ for (const entry of entries) {
409
+ const widget = entry.widget;
410
+ const widgetId = safeString(widget.id).trim();
411
+ const kind = safeString(widget.kind || "chart").trim() || "chart";
412
+ const config = isPlainObject(widget.config) ? widget.config : {};
413
+ const binding = isPlainObject(config.binding) ? config.binding : null;
414
+ const xAxis = isPlainObject(config.xAxis) ? config.xAxis : null;
415
+ const yAxis = isPlainObject(config.yAxis) ? config.yAxis : null;
416
+ const objectId = safeString(binding?.objectId).trim();
417
+ const objectMeta = objectId ? objectsById.get(objectId) : null;
418
+ const xField = safeString(xAxis?.field).trim();
419
+ const yField = safeString(yAxis?.field).trim();
420
+ const groupField = safeString(yAxis?.groupBy).trim();
421
+ const sortField = xField;
422
+ const sortDirection = safeString(xAxis?.sort).trim();
423
+ const operation = safeString(yAxis?.operation || yAxis?.aggregation).trim();
424
+ const filterClauses = collectFilterClauses(config.filter);
425
+ const requiredFields = Array.from(new Set([xField, yField, groupField].filter(Boolean)));
426
+ const filterFields = Array.from(new Set(filterClauses.map((clause) => clause.fieldId)));
427
+ const sortFields = sortField ? [sortField] : [];
428
+ const aggregationFields = yField ? [yField] : [];
429
+ const isLiveBacked = Boolean(objectMeta?.isLiveBacked);
430
+ const sourceRecordKey = isLiveBacked ? objectMeta.sourceId : "";
431
+ const entityId = safeString(binding?.entityId).trim();
432
+ const entityType = safeString(binding?.entityType).trim();
433
+ const integrationId = safeString(binding?.integrationId || objectMeta?.integrationId).trim();
434
+ const widgetWarnings = [];
435
+ if (objectId && !objectMeta) {
436
+ widgetWarnings.push(`Widget "${widgetId}" binds to unknown object "${objectId}".`);
437
+ }
438
+ if (kind === "chart" && objectMeta && requiredFields.length === 0 && operation !== "count" && operation !== "countAll") {
439
+ widgetWarnings.push(`Widget "${widgetId}" is bound but no axis fields are configured.`);
440
+ }
441
+ items.push({
442
+ kind: "workspaceWidget",
443
+ id: widgetId,
444
+ metadataId: stableId("widget", widgetId),
445
+ widgetKind: kind,
446
+ title: safeString(widget.title).trim() || widgetId,
447
+ objectId,
448
+ objectMetadataId: objectMeta ? objectMeta.metadataId : "",
449
+ sourceAuthority: "workspace-config",
450
+ isLiveBacked,
451
+ sourceRecordKey,
452
+ integrationId,
453
+ entityId,
454
+ entityType,
455
+ requiredFields,
456
+ filterFields,
457
+ sortFields,
458
+ aggregationFields,
459
+ sortField,
460
+ sortDirection,
461
+ operation: operation || "sum",
462
+ filterClauses,
463
+ outputShape: kind === "chart" ? "number[]" : "row[]",
464
+ location: entry.location,
465
+ warnings: widgetWarnings
466
+ });
467
+ warnings.push(...widgetWarnings);
468
+ }
469
+ return { items, warnings };
470
+ }
471
+
472
+ function deriveWorkspaceDashboardMetadataItems(workspaceConfig, widgetItems) {
473
+ const items = [];
474
+ const warnings = [];
475
+ const widgetsByDashboard = new Map();
476
+ for (const widget of widgetItems) {
477
+ const key = widget.location?.dashboardId || "__canvas__";
478
+ if (!widgetsByDashboard.has(key)) widgetsByDashboard.set(key, []);
479
+ widgetsByDashboard.get(key).push(widget);
480
+ }
481
+ const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
482
+ for (const dashboard of dashboards) {
483
+ if (!isPlainObject(dashboard)) continue;
484
+ const id = safeString(dashboard.id).trim();
485
+ if (!id) continue;
486
+ items.push({
487
+ kind: "workspaceDashboard",
488
+ id,
489
+ metadataId: stableId("dashboard", id),
490
+ label: safeString(dashboard.name || id).trim(),
491
+ widgetIds: (widgetsByDashboard.get(id) || []).map((widget) => widget.id),
492
+ widgetCount: (widgetsByDashboard.get(id) || []).length
493
+ });
494
+ }
495
+ const canvas = isPlainObject(workspaceConfig?.canvas) ? workspaceConfig.canvas : null;
496
+ if (canvas) {
497
+ items.push({
498
+ kind: "workspaceDashboard",
499
+ id: "__canvas__",
500
+ metadataId: stableId("dashboard", "__canvas__"),
501
+ label: safeString(canvas.name).trim() || "Workspace canvas",
502
+ widgetIds: (widgetsByDashboard.get("__canvas__") || []).map((widget) => widget.id),
503
+ widgetCount: (widgetsByDashboard.get("__canvas__") || []).length
504
+ });
505
+ }
506
+ return { items, warnings };
507
+ }
508
+
509
+ /**
510
+ * Workspace WORKFLOW metadata items + workflow node metadata items.
511
+ *
512
+ * Each workflow corresponds to a sandbox-environment row whose
513
+ * `orchestrationGraph` JSON parses into a node graph. The projection
514
+ * exposes:
515
+ *
516
+ * - workflow id (objectId + rowId)
517
+ * - declared lifecycle status (draft / live / paused)
518
+ * - sandbox host + adapter
519
+ * - per-node input / output schema
520
+ * - per-node source / runtime dependencies
521
+ * - per-node filter clauses (typed) and required permissions
522
+ */
523
+ function deriveWorkspaceWorkflowMetadataItems(workspaceConfig, objectItems) {
524
+ const workflows = [];
525
+ const nodes = [];
526
+ const runInputs = [];
527
+ const warnings = [];
528
+ const objectsById = new Map(objectItems.map((object) => [object.id, object]));
529
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
530
+ ? workspaceConfig.dataModel.objects
531
+ : [];
532
+ for (const raw of rawObjects) {
533
+ if (!isPlainObject(raw)) continue;
534
+ if (raw.objectType !== "sandbox-environment") continue;
535
+ const objectId = safeString(raw.id).trim();
536
+ if (!objectId || HIDDEN_SANDBOX_OBJECT_IDS.has(objectId)) continue;
537
+ if (!objectsById.has(objectId)) continue;
538
+ const rows = Array.isArray(raw.rows) ? raw.rows : [];
539
+ for (const row of rows) {
540
+ if (!isPlainObject(row)) continue;
541
+ const rowName = safeString(row.Name || row.name).trim();
542
+ if (!rowName) continue;
543
+ const graph = parseOrchestrationGraph(row.orchestrationGraph || row.orchestrationConfig);
544
+ const graphNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
545
+ const graphEdges = Array.isArray(graph?.edges) ? graph.edges : [];
546
+ const workflowMetadataId = stableId("workflow", objectId, rowName);
547
+ const sandboxMetadataId = stableId("sandbox", objectId, rowName);
548
+ const agentHost = safeString(row.agentHost).trim();
549
+ const adapter = safeString(row.adapter).trim();
550
+ const inputSchema = graphNodes.length ? discoverRunInputSchema(graph) : { requiresInput: false, fields: [] };
551
+ const inputFields = Array.isArray(inputSchema?.fields) ? inputSchema.fields : [];
552
+
553
+ workflows.push({
554
+ kind: "workspaceWorkflow",
555
+ id: stableId(objectId, rowName),
556
+ metadataId: workflowMetadataId,
557
+ objectId,
558
+ rowId: rowName,
559
+ label: rowName,
560
+ lifecycleStatus: safeString(row.lifecycleStatus).trim() || "draft",
561
+ version: safeString(row.version).trim() || "1",
562
+ sandboxMetadataId,
563
+ agentHost,
564
+ adapter,
565
+ runLocality: safeString(row.runLocality).trim(),
566
+ nodeCount: graphNodes.length,
567
+ edgeCount: graphEdges.length,
568
+ requiresInput: Boolean(inputSchema?.requiresInput),
569
+ inputFieldCount: inputFields.length
570
+ });
571
+
572
+ for (const node of graphNodes) {
573
+ if (!isPlainObject(node)) continue;
574
+ const nodeId = safeString(node.id).trim();
575
+ if (!nodeId) continue;
576
+ const nodeType = safeString(node.type).trim();
577
+ const config = isPlainObject(node.config) ? node.config : {};
578
+ const sourceType = safeString(config.sourceType).trim();
579
+ const sourceId = safeString(config.sourceId).trim();
580
+ const integrationId = safeString(config.integrationId).trim();
581
+ const filterClauses = collectFilterClauses({ op: config.filterMode, clauses: config.filters });
582
+ const writesObjectId = safeString(config.writeObjectId || config.targetObjectId).trim();
583
+ const readsObjectId = sourceId || safeString(config.objectId).trim();
584
+ const isHumanInput = nodeType === "human-input" || safeString(config.action).trim() === "form";
585
+
586
+ const inputs = isHumanInput ? inputFields : [];
587
+ nodes.push({
588
+ kind: "workspaceWorkflowNode",
589
+ id: nodeId,
590
+ metadataId: stableId("workflowNode", workflowMetadataId, nodeId),
591
+ workflowMetadataId,
592
+ workflowObjectId: objectId,
593
+ workflowRowId: rowName,
594
+ nodeType: nodeType || "unknown",
595
+ label: safeString(node.label || nodeId).trim(),
596
+ objectId: readsObjectId || writesObjectId,
597
+ readsObjectId,
598
+ writesObjectId,
599
+ sourceType,
600
+ integrationId,
601
+ filterClauses,
602
+ requiresHumanInput: isHumanInput,
603
+ inputFieldCount: inputs.length,
604
+ inputFieldIds: inputs.map((field) => field.id),
605
+ sandboxMetadataId,
606
+ agentHost,
607
+ adapter,
608
+ permissions: nodeType === "api-registry-call" ? ["integration:read"] : []
609
+ });
610
+ }
611
+
612
+ for (const field of inputFields) {
613
+ runInputs.push({
614
+ kind: "workspaceRunInput",
615
+ id: field.id,
616
+ metadataId: stableId("runInput", workflowMetadataId, field.id),
617
+ workflowMetadataId,
618
+ objectId,
619
+ rowId: rowName,
620
+ label: safeString(field.label).trim() || field.id,
621
+ type: safeString(field.type).trim() || "text",
622
+ required: Boolean(field.required),
623
+ isSecret: Boolean(field.isSecret),
624
+ secretRefOnly: Boolean(field.isSecret),
625
+ sourceNodeId: "human-input"
626
+ });
627
+ }
628
+ }
629
+ }
630
+ return { workflows, nodes, runInputs, warnings };
631
+ }
632
+
633
+ function deriveWorkspaceSandboxMetadataItems(workspaceConfig) {
634
+ const items = [];
635
+ const agentHosts = new Map();
636
+ const warnings = [];
637
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
638
+ ? workspaceConfig.dataModel.objects
639
+ : [];
640
+ for (const raw of rawObjects) {
641
+ if (!isPlainObject(raw)) continue;
642
+ if (raw.objectType !== "sandbox-environment") continue;
643
+ const objectId = safeString(raw.id).trim();
644
+ if (HIDDEN_SANDBOX_OBJECT_IDS.has(objectId)) continue;
645
+ const rows = Array.isArray(raw.rows) ? raw.rows : [];
646
+ for (const row of rows) {
647
+ if (!isPlainObject(row)) continue;
648
+ const rowName = safeString(row.Name || row.name).trim();
649
+ if (!rowName) continue;
650
+ const agentHost = safeString(row.agentHost).trim();
651
+ const adapter = safeString(row.adapter).trim();
652
+ const runLocality = safeString(row.runLocality).trim();
653
+ const authStatus = safeString(row.agentAuthStatus).trim();
654
+ const authProvider = safeString(row.agentAuthProvider).trim();
655
+ items.push({
656
+ kind: "workspaceSandbox",
657
+ id: stableId(objectId, rowName),
658
+ metadataId: stableId("sandbox", objectId, rowName),
659
+ objectId,
660
+ rowId: rowName,
661
+ label: rowName,
662
+ adapter,
663
+ agentHost,
664
+ runLocality,
665
+ authStatus,
666
+ authProvider,
667
+ lifecycleStatus: safeString(row.lifecycleStatus).trim() || "draft",
668
+ hasGraph: Boolean(parseOrchestrationGraph(row.orchestrationGraph || row.orchestrationConfig))
669
+ });
670
+ if (agentHost) {
671
+ if (!agentHosts.has(agentHost)) {
672
+ agentHosts.set(agentHost, {
673
+ kind: "workspaceAgentHost",
674
+ id: agentHost,
675
+ metadataId: stableId("agentHost", agentHost),
676
+ label: agentHost,
677
+ adapters: new Set(),
678
+ sandboxMetadataIds: [],
679
+ authStatusSummary: authStatus || "unknown"
680
+ });
681
+ }
682
+ const host = agentHosts.get(agentHost);
683
+ if (adapter) host.adapters.add(adapter);
684
+ host.sandboxMetadataIds.push(stableId("sandbox", objectId, rowName));
685
+ // Promote the "best" auth status: active > reachable > stale > missing > unknown.
686
+ const order = { active: 4, reachable: 3, stale: 2, missing: 1, unknown: 0 };
687
+ const current = order[host.authStatusSummary] ?? 0;
688
+ const incoming = order[authStatus] ?? 0;
689
+ if (incoming > current) host.authStatusSummary = authStatus;
690
+ }
691
+ }
692
+ }
693
+ const hostItems = Array.from(agentHosts.values()).map((host) => ({
694
+ ...host,
695
+ adapters: Array.from(host.adapters)
696
+ }));
697
+ return { items, agentHosts: hostItems, warnings };
698
+ }
699
+
700
+ function deriveWorkspaceIntegrationMetadataItems(workspaceConfig) {
701
+ const items = [];
702
+ const entities = [];
703
+ const integrations = isPlainObject(workspaceConfig?.dataModel)
704
+ ? Array.isArray(workspaceConfig.dataModel.integrations)
705
+ ? workspaceConfig.dataModel.integrations
706
+ : []
707
+ : [];
708
+ for (const integration of integrations) {
709
+ if (!isPlainObject(integration)) continue;
710
+ const id = safeString(integration.integrationId || integration.id).trim();
711
+ if (!id) continue;
712
+ items.push({
713
+ kind: "workspaceIntegration",
714
+ id,
715
+ metadataId: stableId("integration", id),
716
+ label: safeString(integration.label || integration.name || id).trim(),
717
+ lane: safeString(integration.lane).trim(),
718
+ status: safeString(integration.status).trim()
719
+ });
720
+ }
721
+ // Also derive integrations referenced by data-model bindings / widgets so
722
+ // an unregistered integration still appears as a graph node (with a
723
+ // warning) rather than silently disappearing.
724
+ const seen = new Set(items.map((item) => item.id));
725
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
726
+ ? workspaceConfig.dataModel.objects
727
+ : [];
728
+ for (const raw of rawObjects) {
729
+ if (!isPlainObject(raw)) continue;
730
+ const integrationId = safeString(raw.binding?.integrationId).trim();
731
+ if (integrationId && !seen.has(integrationId)) {
732
+ items.push({
733
+ kind: "workspaceIntegration",
734
+ id: integrationId,
735
+ metadataId: stableId("integration", integrationId),
736
+ label: integrationId,
737
+ lane: "",
738
+ status: "referenced",
739
+ sourceAuthority: "data-model-binding"
740
+ });
741
+ seen.add(integrationId);
742
+ }
743
+ const entityId = safeString(raw.binding?.entityId).trim();
744
+ const entityType = safeString(raw.binding?.entityType).trim();
745
+ const entityLabel = safeString(raw.binding?.entityLabel).trim();
746
+ if (integrationId && entityId) {
747
+ entities.push({
748
+ kind: "workspaceIntegrationEntity",
749
+ id: entityId,
750
+ metadataId: stableId("integrationEntity", integrationId, entityType || "any", entityId),
751
+ integrationId,
752
+ entityType,
753
+ entityId,
754
+ label: entityLabel || entityId,
755
+ sourceObjectId: safeString(raw.id).trim()
756
+ });
757
+ }
758
+ }
759
+ return { integrations: items, entities, warnings: [] };
760
+ }
761
+
762
+ function deriveWorkspaceSourceRecordMetadataItems(workspaceSourceRecords) {
763
+ if (!isPlainObject(workspaceSourceRecords)) return { items: [], warnings: [] };
764
+ const items = [];
765
+ for (const [key, value] of Object.entries(workspaceSourceRecords)) {
766
+ if (!isPlainObject(value)) continue;
767
+ const records = Array.isArray(value.records) ? value.records : [];
768
+ const id = safeString(key).trim();
769
+ // `sandbox:<objectId>:<rowSlug>` keys are sandbox-run history sidecars
770
+ // (already represented as workspaceRunRecord items). Tag them so the
771
+ // inspector can distinguish them from live data-source records — but
772
+ // never inline raw records into the metadata projection.
773
+ const isSandboxRunHistory = id.startsWith("sandbox:");
774
+ items.push({
775
+ kind: "workspaceSourceRecord",
776
+ id,
777
+ metadataId: stableId("sourceRecord", key),
778
+ integrationId: safeString(value.integrationId).trim(),
779
+ recordCount: Number.isFinite(value.recordCount) ? Number(value.recordCount) : records.length,
780
+ fetchedAt: safeString(value.fetchedAt).trim(),
781
+ sourceKind: isSandboxRunHistory ? "sandbox-run-history" : "live-source"
782
+ });
783
+ }
784
+ return { items, warnings: [] };
785
+ }
786
+
787
+ function sandboxRunSourceIdFor(objectId, rowName) {
788
+ const slug = safeString(rowName).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
789
+ if (!objectId || !slug) return "";
790
+ return `sandbox:${objectId}:${slug}`;
791
+ }
792
+
793
+ function projectRunRecord(parsed, { objectId, rowName, fallbackAgentHost }) {
794
+ const runId = safeString(parsed.runId).trim() || stableId("run", objectId, rowName, safeString(parsed.ranAt));
795
+ const workflowMetadataId = stableId("workflow", objectId, rowName);
796
+ const sandboxMetadataId = stableId("sandbox", objectId, rowName);
797
+ const exitCode = Number.isFinite(parsed.exitCode) ? Number(parsed.exitCode) : null;
798
+ const ok = exitCode === 0 && !safeString(parsed.error).trim();
799
+ return {
800
+ item: {
801
+ kind: "workspaceRunRecord",
802
+ id: runId,
803
+ metadataId: stableId("run", runId),
804
+ runId,
805
+ workflowMetadataId,
806
+ sandboxMetadataId,
807
+ objectId,
808
+ rowId: rowName,
809
+ ranAt: safeString(parsed.ranAt).trim(),
810
+ durationMs: Number.isFinite(parsed.durationMs) ? Number(parsed.durationMs) : null,
811
+ exitCode,
812
+ ok,
813
+ adapter: safeString(parsed.adapter).trim(),
814
+ runtime: safeString(parsed.runtime).trim(),
815
+ runLocality: safeString(parsed.runLocality).trim(),
816
+ agentHost: safeString(parsed.agentHost || fallbackAgentHost).trim(),
817
+ inputFieldCount: countInputFields(parsed.input || parsed.runInputs),
818
+ hasOutput: Boolean(parsed.output ?? parsed.normalizedOutput),
819
+ hasStdout: Boolean(safeString(parsed.stdout).trim()),
820
+ hasStderr: Boolean(safeString(parsed.stderr).trim())
821
+ },
822
+ artifact: (parsed.output != null || parsed.normalizedOutput != null) ? {
823
+ kind: "workspaceOutputArtifact",
824
+ id: stableId("artifact", runId, "output"),
825
+ metadataId: stableId("artifact", runId, "output"),
826
+ runMetadataId: stableId("run", runId),
827
+ artifactKind: "normalized-output",
828
+ mediaType: typeof parsed.output === "string" || typeof parsed.normalizedOutput === "string"
829
+ ? "text/plain"
830
+ : "application/json",
831
+ promotable: ok
832
+ } : null
833
+ };
834
+ }
835
+
836
+ function deriveWorkspaceRunRecordMetadataItems(workspaceConfig, options = {}) {
837
+ const items = [];
838
+ const outputArtifacts = [];
839
+ const seenRunIds = new Set();
840
+ const sourceRecords = isPlainObject(options?.workspaceSourceRecords) ? options.workspaceSourceRecords : null;
841
+ const rawObjects = Array.isArray(workspaceConfig?.dataModel?.objects)
842
+ ? workspaceConfig.dataModel.objects
843
+ : [];
844
+ for (const raw of rawObjects) {
845
+ if (!isPlainObject(raw)) continue;
846
+ if (raw.objectType !== "sandbox-environment") continue;
847
+ const objectId = safeString(raw.id).trim();
848
+ if (HIDDEN_SANDBOX_OBJECT_IDS.has(objectId)) continue;
849
+ const rows = Array.isArray(raw.rows) ? raw.rows : [];
850
+ for (const row of rows) {
851
+ if (!isPlainObject(row)) continue;
852
+ const rowName = safeString(row.Name || row.name).trim();
853
+ if (!rowName) continue;
854
+ const fallbackAgentHost = safeString(row.agentHost).trim();
855
+ const pushProjected = (projected) => {
856
+ if (!projected || seenRunIds.has(projected.item.runId)) return;
857
+ seenRunIds.add(projected.item.runId);
858
+ items.push(projected.item);
859
+ if (projected.artifact) outputArtifacts.push(projected.artifact);
860
+ };
861
+
862
+ // 1) Source-record history (full lineage, up to last 50 runs persisted
863
+ // by POST /api/workspace/sandbox-run into growthub.source-records.json).
864
+ if (sourceRecords) {
865
+ const sourceId = safeString(row.lastSourceId).trim() || sandboxRunSourceIdFor(objectId, rowName);
866
+ const sidecar = sourceId ? sourceRecords[sourceId] : null;
867
+ const records = Array.isArray(sidecar?.records) ? sidecar.records : [];
868
+ for (const rec of records) {
869
+ const parsed = parseLastResponse(rec);
870
+ if (!parsed) continue;
871
+ pushProjected(projectRunRecord(parsed, { objectId, rowName, fallbackAgentHost }));
872
+ }
873
+ }
874
+
875
+ // 2) row.lastResponse (always present after the most recent run, even
876
+ // when source-record persistence is read-only).
877
+ const lastResponseRaw = row.lastResponse;
878
+ if (lastResponseRaw == null || lastResponseRaw === "") continue;
879
+ const parsed = parseLastResponse(lastResponseRaw);
880
+ if (!parsed) continue;
881
+ pushProjected(projectRunRecord(parsed, { objectId, rowName, fallbackAgentHost }));
882
+ }
883
+ }
884
+ return { items, outputArtifacts, warnings: [] };
885
+ }
886
+
887
+ function parseLastResponse(value) {
888
+ if (isPlainObject(value)) return value;
889
+ if (typeof value !== "string") return null;
890
+ const text = value.trim();
891
+ if (!text) return null;
892
+ try {
893
+ const parsed = JSON.parse(text);
894
+ return isPlainObject(parsed) ? parsed : null;
895
+ } catch {
896
+ return null;
897
+ }
898
+ }
899
+
900
+ function countInputFields(value) {
901
+ if (!isPlainObject(value)) return 0;
902
+ const values = isPlainObject(value.values) ? value.values : {};
903
+ return Object.keys(values).length;
904
+ }
905
+
906
+ /**
907
+ * Workflow ACTION metadata items.
908
+ *
909
+ * A workflow action is the concrete behaviour configured on a workflow node
910
+ * (e.g. `human-input → form`, `api-registry-call → request`, `transform → filter`).
911
+ * It is the unit Twenty-style workflow sidecars render forms against.
912
+ */
913
+ function deriveWorkspaceWorkflowActionMetadataItems(workflowNodeItems) {
914
+ const items = [];
915
+ for (const node of workflowNodeItems || []) {
916
+ if (!node || typeof node !== "object") continue;
917
+ const baseAction = node.requiresHumanInput
918
+ ? "form"
919
+ : node.nodeType === "api-registry-call"
920
+ ? "request"
921
+ : node.nodeType === "transform-filter"
922
+ ? "filter"
923
+ : node.nodeType === "tool-result"
924
+ ? "result"
925
+ : node.nodeType || "action";
926
+ items.push({
927
+ kind: "workspaceWorkflowAction",
928
+ id: `${node.workflowMetadataId}::${node.id}`,
929
+ metadataId: stableId("workflowAction", node.metadataId),
930
+ workflowNodeMetadataId: node.metadataId,
931
+ workflowMetadataId: node.workflowMetadataId,
932
+ action: baseAction,
933
+ nodeType: node.nodeType,
934
+ requiresHumanInput: node.requiresHumanInput,
935
+ requiresAgentHost: Boolean(node.agentHost),
936
+ agentHost: node.agentHost,
937
+ adapter: node.adapter,
938
+ permissions: Array.isArray(node.permissions) ? node.permissions.slice() : []
939
+ });
940
+ }
941
+ return { items, warnings: [] };
942
+ }
943
+
944
+ /**
945
+ * Worker kit metadata.
946
+ *
947
+ * The metadata graph is scoped to a single workspace; the worker kit it
948
+ * runs inside is exposed as a single anchor node so the inspector can show
949
+ * "this workspace is materialized from kit X". Worker kit drift, fork
950
+ * authority, and remote sync still belong to the existing CLI/fork
951
+ * authority surfaces — this is read-only.
952
+ */
953
+ function deriveWorkspaceWorkerKitMetadataItems(workspaceConfig) {
954
+ const id = safeString(workspaceConfig?.kit?.id || "growthub-custom-workspace-starter-v1").trim();
955
+ const label = safeString(workspaceConfig?.kit?.name || "Growthub Custom Workspace Starter Kit").trim();
956
+ return {
957
+ items: [{
958
+ kind: "workspaceWorkerKit",
959
+ id,
960
+ metadataId: stableId("workerKit", id),
961
+ label,
962
+ version: safeString(workspaceConfig?.kit?.version || "").trim(),
963
+ family: "studio"
964
+ }],
965
+ warnings: []
966
+ };
967
+ }
968
+
969
+ /**
970
+ * Pipeline health — derived from the sandbox + run set.
971
+ *
972
+ * Aggregates "executable pipelines" (sandboxes with a graph) and their
973
+ * most recent observed status. A pipeline is healthy when its latest run
974
+ * exited 0 within the last 24h; unhealthy when the latest run failed;
975
+ * unknown when no run has been recorded yet. This is intentionally a
976
+ * coarse summary — finer signals (retries, queue depth) are out of scope
977
+ * for V1.
978
+ */
979
+ function deriveWorkspacePipelineHealthMetadataItems(sandboxItems, runItems) {
980
+ const items = [];
981
+ const latestByWorkflow = new Map();
982
+ for (const run of runItems || []) {
983
+ const key = run.workflowMetadataId;
984
+ const existing = latestByWorkflow.get(key);
985
+ const ranAtMs = Date.parse(run.ranAt || "");
986
+ const existingMs = existing ? Date.parse(existing.ranAt || "") : -Infinity;
987
+ if (!existing || (Number.isFinite(ranAtMs) && ranAtMs > existingMs)) {
988
+ latestByWorkflow.set(key, run);
989
+ }
990
+ }
991
+ for (const sandbox of sandboxItems || []) {
992
+ if (!sandbox || typeof sandbox !== "object") continue;
993
+ if (!sandbox.hasGraph) continue;
994
+ const workflowMetadataId = stableId("workflow", sandbox.objectId, sandbox.rowId);
995
+ const latest = latestByWorkflow.get(workflowMetadataId) || null;
996
+ let status = "unknown";
997
+ if (latest) {
998
+ status = latest.ok ? "healthy" : "unhealthy";
999
+ } else if (sandbox.lifecycleStatus === "live") {
1000
+ status = "untested";
1001
+ }
1002
+ items.push({
1003
+ kind: "workspacePipelineHealth",
1004
+ id: stableId(sandbox.objectId, sandbox.rowId),
1005
+ metadataId: stableId("pipelineHealth", sandbox.objectId, sandbox.rowId),
1006
+ sandboxMetadataId: sandbox.metadataId,
1007
+ workflowMetadataId,
1008
+ label: sandbox.label,
1009
+ lifecycleStatus: sandbox.lifecycleStatus,
1010
+ authStatus: sandbox.authStatus,
1011
+ status,
1012
+ latestRunId: latest ? latest.runId : "",
1013
+ latestRanAt: latest ? latest.ranAt : "",
1014
+ latestOk: latest ? latest.ok : null
1015
+ });
1016
+ }
1017
+ return { items, warnings: [] };
1018
+ }
1019
+
1020
+ /**
1021
+ * Build the workspace metadata store from authoritative inputs.
1022
+ *
1023
+ * Inputs:
1024
+ * - workspaceConfig: parsed growthub.config.json (governed)
1025
+ * - workspaceSourceRecords: parsed growthub.source-records.json (sidecar)
1026
+ *
1027
+ * Returns a typed envelope. Never throws. Unknown shapes contribute warnings.
1028
+ */
1029
+ function buildWorkspaceMetadataStore({
1030
+ workspaceConfig,
1031
+ workspaceSourceRecords
1032
+ } = {}) {
1033
+ const warnings = [];
1034
+ const safeConfig = isPlainObject(workspaceConfig) ? workspaceConfig : {};
1035
+ const safeSourceRecords = isPlainObject(workspaceSourceRecords) ? workspaceSourceRecords : {};
1036
+
1037
+ const objects = deriveWorkspaceObjectMetadataItems(safeConfig);
1038
+ warnings.push(...objects.warnings);
1039
+
1040
+ const fields = deriveWorkspaceFieldMetadataItems(safeConfig, objects.items);
1041
+ warnings.push(...fields.warnings);
1042
+
1043
+ const views = deriveWorkspaceViewMetadataItems(safeConfig, objects.items);
1044
+ warnings.push(...views.warnings);
1045
+
1046
+ const widgets = deriveWorkspaceWidgetMetadataItems(safeConfig, objects.items);
1047
+ warnings.push(...widgets.warnings);
1048
+
1049
+ const dashboards = deriveWorkspaceDashboardMetadataItems(safeConfig, widgets.items);
1050
+ warnings.push(...dashboards.warnings);
1051
+
1052
+ const workflows = deriveWorkspaceWorkflowMetadataItems(safeConfig, objects.items);
1053
+ warnings.push(...workflows.warnings);
1054
+
1055
+ const filters = deriveWorkspaceFilterMetadataItems(widgets.items, workflows.nodes);
1056
+ const sorts = deriveWorkspaceSortMetadataItems(widgets.items);
1057
+
1058
+ const sandboxes = deriveWorkspaceSandboxMetadataItems(safeConfig);
1059
+ warnings.push(...sandboxes.warnings);
1060
+
1061
+ const integrations = deriveWorkspaceIntegrationMetadataItems(safeConfig);
1062
+ warnings.push(...integrations.warnings);
1063
+
1064
+ const sourceRecords = deriveWorkspaceSourceRecordMetadataItems(safeSourceRecords);
1065
+ warnings.push(...sourceRecords.warnings);
1066
+
1067
+ const runs = deriveWorkspaceRunRecordMetadataItems(safeConfig, { workspaceSourceRecords: safeSourceRecords });
1068
+ warnings.push(...runs.warnings);
1069
+
1070
+ const actions = deriveWorkspaceWorkflowActionMetadataItems(workflows.nodes);
1071
+ warnings.push(...actions.warnings);
1072
+
1073
+ const workerKits = deriveWorkspaceWorkerKitMetadataItems(safeConfig);
1074
+ warnings.push(...workerKits.warnings);
1075
+
1076
+ const pipelineHealth = deriveWorkspacePipelineHealthMetadataItems(sandboxes.items, runs.items);
1077
+ warnings.push(...pipelineHealth.warnings);
1078
+
1079
+ return {
1080
+ kind: METADATA_STORE_KIND,
1081
+ version: METADATA_STORE_VERSION,
1082
+ objects: objects.items,
1083
+ fields: fields.items,
1084
+ views: views.items,
1085
+ filters: filters.items,
1086
+ sorts: sorts.items,
1087
+ widgets: widgets.items,
1088
+ dashboards: dashboards.items,
1089
+ workflows: workflows.workflows,
1090
+ workflowNodes: workflows.nodes,
1091
+ workflowActions: actions.items,
1092
+ runInputs: workflows.runInputs,
1093
+ agentHosts: sandboxes.agentHosts,
1094
+ sandboxes: sandboxes.items,
1095
+ integrations: integrations.integrations,
1096
+ integrationEntities: integrations.entities,
1097
+ sourceRecords: sourceRecords.items,
1098
+ runs: runs.items,
1099
+ outputArtifacts: runs.outputArtifacts,
1100
+ workerKits: workerKits.items,
1101
+ pipelineHealth: pipelineHealth.items,
1102
+ warnings
1103
+ };
1104
+ }
1105
+
1106
+ // Spec alias: `deriveWorkspaceRunMetadataItems(sourceRecords)` — exposes the
1107
+ // same per-run projection but resolved from the run-records sidecar key
1108
+ // space (sandbox runs are persisted both in `growthub.source-records.json`
1109
+ // and `row.lastResponse`; this helper accepts either).
1110
+ function deriveWorkspaceRunMetadataItems(input) {
1111
+ if (isPlainObject(input) && Array.isArray(input?.dataModel?.objects)) {
1112
+ return deriveWorkspaceRunRecordMetadataItems(input);
1113
+ }
1114
+ // Treat the input as a sourceRecords-shaped sidecar: each value is a
1115
+ // run-record envelope.
1116
+ const items = [];
1117
+ const outputArtifacts = [];
1118
+ const safe = isPlainObject(input) ? input : {};
1119
+ for (const [key, value] of Object.entries(safe)) {
1120
+ const parsed = parseLastResponse(value);
1121
+ if (!parsed) continue;
1122
+ const runId = safeString(parsed.runId).trim() || stableId("run", key);
1123
+ const exitCode = Number.isFinite(parsed.exitCode) ? Number(parsed.exitCode) : null;
1124
+ const ok = exitCode === 0 && !safeString(parsed.error).trim();
1125
+ items.push({
1126
+ kind: "workspaceRunRecord",
1127
+ id: runId,
1128
+ metadataId: stableId("run", runId),
1129
+ runId,
1130
+ workflowMetadataId: "",
1131
+ sandboxMetadataId: "",
1132
+ objectId: "",
1133
+ rowId: "",
1134
+ ranAt: safeString(parsed.ranAt).trim(),
1135
+ durationMs: Number.isFinite(parsed.durationMs) ? Number(parsed.durationMs) : null,
1136
+ exitCode,
1137
+ ok,
1138
+ adapter: safeString(parsed.adapter).trim(),
1139
+ runtime: safeString(parsed.runtime).trim(),
1140
+ runLocality: safeString(parsed.runLocality).trim(),
1141
+ agentHost: safeString(parsed.agentHost).trim(),
1142
+ inputFieldCount: countInputFields(parsed.input || parsed.runInputs),
1143
+ hasOutput: Boolean(parsed.output ?? parsed.normalizedOutput),
1144
+ hasStdout: Boolean(safeString(parsed.stdout).trim()),
1145
+ hasStderr: Boolean(safeString(parsed.stderr).trim())
1146
+ });
1147
+ if (parsed.output != null || parsed.normalizedOutput != null) {
1148
+ outputArtifacts.push({
1149
+ kind: "workspaceOutputArtifact",
1150
+ id: stableId("artifact", runId, "output"),
1151
+ metadataId: stableId("artifact", runId, "output"),
1152
+ runMetadataId: stableId("run", runId),
1153
+ artifactKind: "normalized-output",
1154
+ mediaType: typeof parsed.output === "string" || typeof parsed.normalizedOutput === "string"
1155
+ ? "text/plain"
1156
+ : "application/json",
1157
+ promotable: ok
1158
+ });
1159
+ }
1160
+ }
1161
+ return { items, outputArtifacts, warnings: [] };
1162
+ }
1163
+
1164
+ export {
1165
+ METADATA_STORE_KIND,
1166
+ METADATA_STORE_VERSION,
1167
+ HIDDEN_SANDBOX_OBJECT_IDS,
1168
+ buildWorkspaceMetadataStore,
1169
+ deriveWorkspaceObjectMetadataItems,
1170
+ deriveWorkspaceFieldMetadataItems,
1171
+ deriveWorkspaceViewMetadataItems,
1172
+ deriveWorkspaceFilterMetadataItems,
1173
+ deriveWorkspaceSortMetadataItems,
1174
+ deriveWorkspaceWidgetMetadataItems,
1175
+ deriveWorkspaceDashboardMetadataItems,
1176
+ deriveWorkspaceWorkflowMetadataItems,
1177
+ deriveWorkspaceWorkflowActionMetadataItems,
1178
+ deriveWorkspaceSandboxMetadataItems,
1179
+ deriveWorkspaceIntegrationMetadataItems,
1180
+ deriveWorkspaceSourceRecordMetadataItems,
1181
+ deriveWorkspaceRunRecordMetadataItems,
1182
+ deriveWorkspaceRunMetadataItems,
1183
+ deriveWorkspaceWorkerKitMetadataItems,
1184
+ deriveWorkspacePipelineHealthMetadataItems,
1185
+ isSecretKey
1186
+ };