@growthub/cli 0.9.13 → 0.9.16

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 (34) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
  33. package/dist/index.js +1764 -40677
  34. package/package.json +2 -2
@@ -338,6 +338,82 @@ async function writeWorkspaceApiWebhookSettings(patch) {
338
338
  return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
339
339
  }
340
340
 
341
+ /**
342
+ * Source Records persistence — sidecar store for live-backed dataModel objects.
343
+ *
344
+ * Records are written by POST /api/workspace/refresh-sources when a resolver
345
+ * fetches live data for a source with `binding.sourceStorage: "workspace-source-records"`.
346
+ *
347
+ * Persistence is keyed by `sourceId` and stored in a JSON sidecar file
348
+ * (`growthub.source-records.json`) beside `growthub.config.json`. The same
349
+ * filesystem / read-only / database mode rules apply: in read-only mode writes
350
+ * are rejected gracefully so the refresh button surface is disabled.
351
+ *
352
+ * Shape: { [sourceId]: { records: Record[], integrationId: string, fetchedAt: string } }
353
+ */
354
+
355
+ const SOURCE_RECORDS_FILENAME = "growthub.source-records.json";
356
+
357
+ function resolveSourceRecordsPath() {
358
+ return path.resolve(/*turbopackIgnore: true*/ process.cwd(), SOURCE_RECORDS_FILENAME);
359
+ }
360
+
361
+ async function readWorkspaceSourceRecords(sourceId) {
362
+ const recordsPath = resolveSourceRecordsPath();
363
+ try {
364
+ const raw = await fs.readFile(recordsPath, "utf8");
365
+ const all = JSON.parse(raw);
366
+ if (sourceId) {
367
+ return all[sourceId] || null;
368
+ }
369
+ return all;
370
+ } catch {
371
+ return sourceId ? null : {};
372
+ }
373
+ }
374
+
375
+ async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
376
+ const persistence = describePersistenceMode();
377
+ if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
378
+ const error = new Error(persistence.reason);
379
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
380
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
381
+ throw error;
382
+ }
383
+ if (typeof sourceId !== "string" || !sourceId.trim()) {
384
+ const error = new Error("sourceId must be a non-empty string");
385
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
386
+ throw error;
387
+ }
388
+ if (!Array.isArray(records)) {
389
+ const error = new Error("records must be an array");
390
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
391
+ throw error;
392
+ }
393
+ const recordsPath = resolveSourceRecordsPath();
394
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
395
+ if (path.dirname(recordsPath) !== expectedDir) {
396
+ const error = new Error(`refused to write outside workspace cwd: ${recordsPath}`);
397
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
398
+ throw error;
399
+ }
400
+ let all = {};
401
+ try {
402
+ const raw = await fs.readFile(recordsPath, "utf8");
403
+ all = JSON.parse(raw);
404
+ } catch {
405
+ all = {};
406
+ }
407
+ all[sourceId.trim()] = {
408
+ records,
409
+ integrationId: metadata.integrationId || null,
410
+ fetchedAt: metadata.fetchedAt || new Date().toISOString(),
411
+ recordCount: records.length
412
+ };
413
+ await fs.writeFile(recordsPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
414
+ return all[sourceId.trim()];
415
+ }
416
+
341
417
  export {
342
418
  GRID_COLUMNS,
343
419
  GRID_ROWS,
@@ -346,9 +422,11 @@ export {
346
422
  READ_ONLY_GUIDANCE,
347
423
  describePersistenceMode,
348
424
  readWorkspaceConfig,
425
+ readWorkspaceSourceRecords,
349
426
  resolveWorkspaceConfigPath,
350
427
  validateWorkspaceConfig,
351
428
  writeWorkspaceConfig,
352
429
  writeWorkspaceApiWebhookSettings,
353
- writeWorkspaceIdentitySettings
430
+ writeWorkspaceIdentitySettings,
431
+ writeWorkspaceSourceRecords
354
432
  };
@@ -171,9 +171,12 @@ function deriveManualObjectTable(object) {
171
171
  id: `manual-object:${object.id || source}`,
172
172
  label: object.label || object.name || source,
173
173
  source,
174
+ objectType: object.objectType || "custom",
175
+ icon: object.icon || null,
174
176
  columns,
175
177
  rows,
176
178
  binding: object.binding || { mode: "manual", source: "Data Model" },
179
+ relations: Array.isArray(object.relations) ? object.relations : [],
177
180
  mutable: true,
178
181
  storage: "manual-object",
179
182
  objectId: object.id,
@@ -313,6 +316,141 @@ function uniqueObjectId(workspaceConfig, name) {
313
316
  return `${base}-${index}`;
314
317
  }
315
318
 
319
+ /**
320
+ * Top-level object type presets.
321
+ * Each entry defines: label, icon (Lucide name), description, default columns, and
322
+ * any built-in relations. These are the five first-class types the UI offers when
323
+ * a user clicks "New object" — they act like schema templates, not hard constraints.
324
+ *
325
+ * Relation shape:
326
+ * {
327
+ * id: string, // stable slug within this object
328
+ * name: string, // display label
329
+ * field: string, // FK column on THIS object
330
+ * targetObjectType:string, // objectType of the referenced object
331
+ * type: "belongs-to" | "has-many",
332
+ * description: string
333
+ * }
334
+ */
335
+ const OBJECT_TYPE_PRESETS = {
336
+ "data-source": {
337
+ label: "Data Source",
338
+ icon: "Globe",
339
+ description: "Custom API, webhook, or external feed. References an API Registry record while credentials stay in workspace settings.",
340
+ columns: ["Name", "registryId", "endpoint", "authRef", "baseUrl", "status", "lastTested", "lastResponse"],
341
+ relations: [
342
+ {
343
+ id: "resolver-binding",
344
+ name: "Resolver",
345
+ field: "registryId",
346
+ targetObjectType: "api-registry",
347
+ type: "belongs-to",
348
+ description: "The API Registry entry whose fetchRecords function resolves this source. Set registryId to match the resolver integrationId."
349
+ }
350
+ ]
351
+ },
352
+ "api-registry": {
353
+ label: "API Registry",
354
+ icon: "Code2",
355
+ description: "HTTP API records with endpoint config, auth references, connection status, and stored test output.",
356
+ columns: ["integrationId", "authRef", "baseUrl", "endpoint", "method", "status", "lastTested", "lastResponse", "entityTypes", "description"],
357
+ relations: []
358
+ },
359
+ "people": {
360
+ label: "People",
361
+ icon: "Users",
362
+ description: "Contacts, leads, or team members with standard CRM fields.",
363
+ columns: ["Name", "Email", "Phone", "Company", "Status", "Role"],
364
+ relations: []
365
+ },
366
+ "tasks": {
367
+ label: "Tasks",
368
+ icon: "CheckSquare",
369
+ description: "Action items, to-dos, or work items.",
370
+ columns: ["Name", "Status", "DueDate", "Assignee", "Priority"],
371
+ relations: []
372
+ },
373
+ "sandbox-environment": {
374
+ label: "Sandbox Environment",
375
+ icon: "Terminal",
376
+ description: "Execution locality: local (process sandbox or Paperclip thin local agent-host CLI) or serverless (delegates to an API Registry HTTP target: Edge/QStash/cron webhook). Env refs resolve server-side; run history in growthub.source-records.json. Not a widget binding source.",
377
+ columns: [
378
+ "Name",
379
+ "lifecycleStatus",
380
+ "version",
381
+ "runLocality",
382
+ "schedulerRegistryId",
383
+ "runtime",
384
+ "adapter",
385
+ "agentHost",
386
+ "envRefs",
387
+ "networkAllow",
388
+ "allowList",
389
+ "instructions",
390
+ "command",
391
+ "timeoutMs",
392
+ "status",
393
+ "lastTested",
394
+ "lastRunId",
395
+ "lastSourceId",
396
+ "lastResponse"
397
+ ],
398
+ relations: [
399
+ {
400
+ id: "scheduler-registry-binding",
401
+ name: "Scheduler (serverless)",
402
+ field: "schedulerRegistryId",
403
+ targetObjectType: "api-registry",
404
+ type: "belongs-to",
405
+ description: "When runLocality is serverless, POST /api/workspace/sandbox-run sends growthub-sandbox-run-v1 to this API Registry record (METHOD, baseUrl, endpoint, authRef resolved server-side). Use for Supabase Edge URL, QStash forwarder, Vercel-exposed webhook, cron targets, etc."
406
+ }
407
+ ]
408
+ },
409
+ "custom": {
410
+ label: "Custom",
411
+ icon: "Plus",
412
+ description: "Start with a blank table — define your own fields and records.",
413
+ columns: ["Name"],
414
+ relations: []
415
+ }
416
+ };
417
+
418
+ /**
419
+ * Create a typed business object from a preset template.
420
+ * Accepts objectType (one of the OBJECT_TYPE_PRESETS keys) and an optional icon override.
421
+ * The object is stored in dataModel.objects[] alongside manual objects.
422
+ */
423
+ function createTypedBusinessObject(workspaceConfig, { name, objectType = "custom", icon } = {}) {
424
+ const label = String(name || "").trim();
425
+ if (!label) return workspaceConfig;
426
+ const preset = OBJECT_TYPE_PRESETS[objectType] || OBJECT_TYPE_PRESETS.custom;
427
+ const columns = [...preset.columns];
428
+ const dataModel =
429
+ workspaceConfig.dataModel && typeof workspaceConfig.dataModel === "object" && !Array.isArray(workspaceConfig.dataModel)
430
+ ? workspaceConfig.dataModel
431
+ : {};
432
+ const id = uniqueObjectId(workspaceConfig, label);
433
+ const object = {
434
+ id,
435
+ label,
436
+ source: label,
437
+ objectType,
438
+ icon: icon || preset.icon,
439
+ columns,
440
+ rows: [],
441
+ binding: { mode: "manual", source: "Data Model" },
442
+ relations: preset.relations ? preset.relations.map((r) => ({ ...r })) : [],
443
+ fieldSettings: { hidden: [], order: columns }
444
+ };
445
+ return {
446
+ ...workspaceConfig,
447
+ dataModel: {
448
+ ...dataModel,
449
+ objects: [...normalizeManualObjects(workspaceConfig), object]
450
+ }
451
+ };
452
+ }
453
+
316
454
  function createManualBusinessObject(workspaceConfig, { name, fields } = {}) {
317
455
  const label = String(name || "").trim();
318
456
  const columns = Array.from(new Set((Array.isArray(fields) ? fields : String(fields || "").split(","))
@@ -408,6 +546,73 @@ function describeBindingLane(binding) {
408
546
  return "manual";
409
547
  }
410
548
 
549
+ /**
550
+ * Saved env-key references — name-only projection of workspace integrations[].
551
+ *
552
+ * Used by the sandbox-environment drawer's env-ref multi-select. The browser
553
+ * receives the `endpointRef` slug only (never the secret value); the sandbox
554
+ * run route resolves the slug to a server-side env value using the same
555
+ * `envKeyCandidates(authRef)` pattern as `test-api-record/route.js`.
556
+ *
557
+ * Returns: [{ id, endpointRef, kind, hasSecret }]
558
+ */
559
+ function listSavedEnvRefs(workspaceConfig) {
560
+ const integrations = Array.isArray(workspaceConfig?.integrations) ? workspaceConfig.integrations : [];
561
+ return integrations
562
+ .filter((entry) => entry?.sourceType === "custom-api-webhooks" && typeof entry.endpointRef === "string" && entry.endpointRef.trim())
563
+ .map((entry) => ({
564
+ id: entry.id || entry.endpointRef,
565
+ endpointRef: entry.endpointRef,
566
+ kind: entry.kind === "webhook" ? "webhook" : "api",
567
+ hasSecret: entry.hasSecret === true
568
+ }));
569
+ }
570
+
571
+ /**
572
+ * Parse a sandbox-environment row's `envRefs` column into a clean string array.
573
+ * Stored as a comma-separated string in the row to keep the column flat under
574
+ * the existing governed Data Model schema; rendered as a multi-select chip
575
+ * group in the drawer. The server reads the same comma-separated form.
576
+ */
577
+ function parseSandboxEnvRefs(value) {
578
+ if (Array.isArray(value)) {
579
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
580
+ }
581
+ if (typeof value !== "string") return [];
582
+ return value
583
+ .split(",")
584
+ .map((item) => item.trim())
585
+ .filter(Boolean);
586
+ }
587
+
588
+ /**
589
+ * Parse a sandbox-environment row's `allowList` column into a clean array of
590
+ * domain hostnames. Stored as comma-separated string for governed flatness;
591
+ * the run route enforces the list when `networkAllow` is truthy.
592
+ */
593
+ function parseSandboxAllowList(value) {
594
+ if (Array.isArray(value)) {
595
+ return value.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean);
596
+ }
597
+ if (typeof value !== "string") return [];
598
+ return value
599
+ .split(",")
600
+ .map((item) => item.trim().toLowerCase())
601
+ .filter(Boolean);
602
+ }
603
+
604
+ /**
605
+ * Stable sourceId for a sandbox-environment row's run history sidecar.
606
+ * Keyed by object id + slugified Name so the key survives reorder of rows
607
+ * inside the same object. The sandbox-run route uses this id to read/write
608
+ * `growthub.source-records.json`.
609
+ */
610
+ function sandboxRunSourceId(objectId, name) {
611
+ const slug = String(name || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
612
+ if (!objectId || !slug) return null;
613
+ return `sandbox:${objectId}:${slug}`;
614
+ }
615
+
411
616
  function describeBindingMode(binding) {
412
617
  const lane = describeBindingLane(binding);
413
618
  if (lane === "data-source") return { label: "Data source scope", description: "Integration reference selected in the existing widget source flow. Dynamic data resolves through the governed server-side integration path." };
@@ -417,17 +622,23 @@ function describeBindingMode(binding) {
417
622
  }
418
623
 
419
624
  export {
625
+ OBJECT_TYPE_PRESETS,
420
626
  addTableField,
421
627
  addTableRow,
422
628
  appendRowsToTable,
423
629
  createManualBusinessObject,
630
+ createTypedBusinessObject,
424
631
  deleteTableRow,
425
632
  describeBindingLane,
426
633
  describeBindingMode,
427
634
  duplicateTableRow,
428
635
  exportTableAsCsv,
429
636
  importTableFromCsv,
637
+ listSavedEnvRefs,
430
638
  listWorkspaceDataModelTables,
639
+ parseSandboxAllowList,
640
+ parseSandboxEnvRefs,
431
641
  replaceTableContent,
642
+ sandboxRunSourceId,
432
643
  updateTableCell
433
644
  };
@@ -47,6 +47,35 @@ const KNOWN_FILTER_OPERATORS = ["eq", "ne", "contains", "gt", "lt", "isEmpty", "
47
47
  const KNOWN_FILTER_CONJUNCTIONS = ["and", "or"];
48
48
  const KNOWN_SORT_DIRECTIONS = ["asc", "desc"];
49
49
  const KNOWN_AGGREGATIONS = ["sum", "avg", "count", "min", "max"];
50
+ const KNOWN_SANDBOX_RUNTIMES = ["python", "node", "bash"];
51
+ /** Where execution is delegated: locally (process / agent-host CLI) or to a scheduler webhook (Supabase Edge, QStash, Vercel cron hitting your URL, etc.). */
52
+ const KNOWN_SANDBOX_RUN_LOCALITY = ["local", "serverless"];
53
+ const KNOWN_SANDBOX_LIFECYCLE_STATUSES = ["draft", "live"];
54
+ const DEFAULT_SANDBOX_RUN_LOCALITY = "local";
55
+ const DEFAULT_SANDBOX_ADAPTER = "local-process";
56
+ const SANDBOX_DEFAULT_TIMEOUT_MS = 60000;
57
+ const SANDBOX_MAX_TIMEOUT_MS = 600000;
58
+ /**
59
+ * Canonical Paperclip local agent-host slugs — mirrors the upstream
60
+ * `AGENT_ADAPTER_TYPES` enum in `packages/shared/src/constants.ts`. The
61
+ * sandbox-environment row's `agentHost` column accepts any of these values
62
+ * when `adapter === "local-agent-host"`. The standalone workspace starter
63
+ * does NOT import the @paperclipai/adapter-* packages directly — instead the
64
+ * default `local-agent-host` adapter spawns the host CLI binary the user has
65
+ * on PATH (cross-platform: macOS / Windows / Linux), keeping the workspace
66
+ * starter portable and thin.
67
+ */
68
+ const KNOWN_SANDBOX_AGENT_HOSTS = [
69
+ "claude_local",
70
+ "codex_local",
71
+ "cursor",
72
+ "gemini_local",
73
+ "opencode_local",
74
+ "pi_local",
75
+ "qwen_local",
76
+ "openclaw_gateway",
77
+ "hermes_local"
78
+ ];
50
79
 
51
80
  const NORMALIZED_OBJECT_FIELD_IDS = ["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"];
52
81
  const WORKSPACE_TEMPLATE_KIND = "growthub-workspace-template";
@@ -152,7 +181,9 @@ const WIDGET_SCHEMA_CONTRACTS = {
152
181
  lane: "string optional (when mode === 'integration')",
153
182
  entityId: "string optional — stable source object ID (never a token or credential)",
154
183
  entityType: "string optional — adapter-provided object type",
155
- entityLabel: "string optional — display-only resolved label, not authoritative"
184
+ entityLabel: "string optional — display-only resolved label, not authoritative",
185
+ sourceStorage: "'workspace-source-records' optional — marks this binding as live-backed; records are written by POST /api/workspace/refresh-sources and keyed by dataModel.objects[].sourceId",
186
+ sourceId: "string optional — stable key in growthub.source-records.json; required when sourceStorage === 'workspace-source-records'"
156
187
  },
157
188
  NormalizedIntegrationEntity: {
158
189
  id: "non-empty string — stable source object ID",
@@ -200,19 +231,19 @@ const SAMPLE_DATA_BINDINGS = {
200
231
  function defaultConfigFor(kind) {
201
232
  switch (kind) {
202
233
  case "chart":
203
- return { values: [58, 36, 72, 48, 64], binding: SAMPLE_DATA_BINDINGS.reportingJson };
234
+ return { values: [], binding: { mode: "manual", source: "", rows: [] } };
204
235
  case "view":
205
236
  return {
206
237
  source: "",
207
238
  layout: "Table",
208
239
  columns: [],
209
240
  rows: [],
210
- binding: { mode: "manual", source: "Static rows", rows: [] }
241
+ binding: { mode: "manual", source: "", rows: [] }
211
242
  };
212
243
  case "iframe":
213
244
  return { url: "" };
214
245
  case "rich-text":
215
- return { text: "", binding: { mode: "manual", source: "Manual text", rows: [] } };
246
+ return { text: "", binding: { mode: "manual", source: "", rows: [] } };
216
247
  default:
217
248
  return {};
218
249
  }
@@ -396,8 +427,11 @@ function validateStaticDataBinding(binding, path, errors) {
396
427
  if (typeof binding.integrationId !== "string" || !binding.integrationId.trim()) {
397
428
  errors.push(`${path}.integrationId is required when mode is integration`);
398
429
  }
399
- if (typeof binding.lane !== "string" || !binding.lane.trim()) {
400
- errors.push(`${path}.lane is required when mode is integration`);
430
+ // lane is not required when sourceStorage delegates routing to a registry resolver
431
+ if (binding.sourceStorage !== "workspace-source-records") {
432
+ if (typeof binding.lane !== "string" || !binding.lane.trim()) {
433
+ errors.push(`${path}.lane is required when mode is integration`);
434
+ }
401
435
  }
402
436
  }
403
437
  if (binding.source !== undefined && typeof binding.source !== "string") {
@@ -439,6 +473,14 @@ function validateStaticDataBinding(binding, path, errors) {
439
473
  if (binding.entityLabel !== undefined && typeof binding.entityLabel !== "string") {
440
474
  errors.push(`${path}.entityLabel must be a string`);
441
475
  }
476
+ if (binding.sourceStorage !== undefined) {
477
+ if (binding.sourceStorage !== "workspace-source-records") {
478
+ errors.push(`${path}.sourceStorage must be "workspace-source-records" when present`);
479
+ }
480
+ }
481
+ if (binding.sourceId !== undefined && typeof binding.sourceId !== "string") {
482
+ errors.push(`${path}.sourceId must be a string`);
483
+ }
442
484
  }
443
485
 
444
486
  function validateFieldSettings(fieldSettings, path, errors) {
@@ -778,6 +820,65 @@ function validateCanvasConfig(canvas, errors) {
778
820
  }
779
821
  }
780
822
 
823
+ function validateSandboxEnvironmentRow(row, path, errors) {
824
+ if (!isPlainObject(row)) return;
825
+ const lifecycleStatus = String(row.lifecycleStatus || "").trim().toLowerCase();
826
+ if (row.lifecycleStatus !== undefined && row.lifecycleStatus !== "" && !KNOWN_SANDBOX_LIFECYCLE_STATUSES.includes(lifecycleStatus)) {
827
+ errors.push(`${path}.lifecycleStatus must be one of ${KNOWN_SANDBOX_LIFECYCLE_STATUSES.join(", ")}`);
828
+ }
829
+ if (row.version !== undefined && typeof row.version !== "string" && typeof row.version !== "number") {
830
+ errors.push(`${path}.version must be a string or number`);
831
+ }
832
+ const runLocalityNorm = String(row.runLocality || "").trim().toLowerCase();
833
+ if (row.runLocality !== undefined && row.runLocality !== "" && !KNOWN_SANDBOX_RUN_LOCALITY.includes(runLocalityNorm)) {
834
+ errors.push(`${path}.runLocality must be one of ${KNOWN_SANDBOX_RUN_LOCALITY.join(", ")}`);
835
+ }
836
+ if (runLocalityNorm === "serverless") {
837
+ if (typeof row.schedulerRegistryId !== "string" || !row.schedulerRegistryId.trim()) {
838
+ errors.push(`${path}.schedulerRegistryId must reference an API Registry integrationId when runLocality is serverless`);
839
+ }
840
+ }
841
+ if (runLocalityNorm === "local" && row.runtime !== undefined && row.runtime !== "" && !KNOWN_SANDBOX_RUNTIMES.includes(row.runtime)) {
842
+ errors.push(`${path}.runtime must be one of ${KNOWN_SANDBOX_RUNTIMES.join(", ")}`);
843
+ }
844
+ if (row.adapter !== undefined && typeof row.adapter !== "string") {
845
+ errors.push(`${path}.adapter must be a string`);
846
+ }
847
+ if (row.agentHost !== undefined && row.agentHost !== "" && !KNOWN_SANDBOX_AGENT_HOSTS.includes(row.agentHost)) {
848
+ errors.push(`${path}.agentHost must be one of ${KNOWN_SANDBOX_AGENT_HOSTS.join(", ")}`);
849
+ }
850
+ if (row.envRefs !== undefined && typeof row.envRefs !== "string" && !Array.isArray(row.envRefs)) {
851
+ errors.push(`${path}.envRefs must be a comma-separated string or array of env-ref slugs (never values)`);
852
+ }
853
+ if (row.networkAllow !== undefined) {
854
+ const value = String(row.networkAllow).trim().toLowerCase();
855
+ if (!["", "true", "false", "0", "1", "on", "off"].includes(value)) {
856
+ errors.push(`${path}.networkAllow must coerce to a boolean (true/false/on/off)`);
857
+ }
858
+ }
859
+ if (row.allowList !== undefined && typeof row.allowList !== "string" && !Array.isArray(row.allowList)) {
860
+ errors.push(`${path}.allowList must be a comma-separated string or array of hostnames`);
861
+ }
862
+ if (row.instructions !== undefined && typeof row.instructions !== "string") {
863
+ errors.push(`${path}.instructions must be a string`);
864
+ }
865
+ if (row.command !== undefined && typeof row.command !== "string") {
866
+ errors.push(`${path}.command must be a string`);
867
+ }
868
+ if (row.lastRunId !== undefined && typeof row.lastRunId !== "string") {
869
+ errors.push(`${path}.lastRunId must be a string`);
870
+ }
871
+ if (row.lastSourceId !== undefined && typeof row.lastSourceId !== "string") {
872
+ errors.push(`${path}.lastSourceId must be a string`);
873
+ }
874
+ if (row.timeoutMs !== undefined && row.timeoutMs !== "") {
875
+ const ms = Number(row.timeoutMs);
876
+ if (!Number.isFinite(ms) || ms < 0 || ms > SANDBOX_MAX_TIMEOUT_MS) {
877
+ errors.push(`${path}.timeoutMs must be a finite number between 0 and ${SANDBOX_MAX_TIMEOUT_MS}`);
878
+ }
879
+ }
880
+ }
881
+
781
882
  function validateDataModelConfig(dataModel, errors) {
782
883
  if (dataModel === undefined) return;
783
884
  if (!isPlainObject(dataModel)) {
@@ -805,15 +906,25 @@ function validateDataModelConfig(dataModel, errors) {
805
906
  }
806
907
  if (typeof object.label !== "string" || !object.label.trim()) errors.push(`${prefix}.label must be a non-empty string`);
807
908
  if (object.source !== undefined && typeof object.source !== "string") errors.push(`${prefix}.source must be a string`);
909
+ if (object.sourceId !== undefined && typeof object.sourceId !== "string") errors.push(`${prefix}.sourceId must be a string`);
808
910
  validateStringArray(object.columns, `${prefix}.columns`, errors);
809
911
  if (!Array.isArray(object.rows)) {
810
912
  errors.push(`${prefix}.rows must be an array`);
811
913
  } else {
812
914
  object.rows.forEach((row, rowIndex) => {
813
- if (!isPlainObject(row)) errors.push(`${prefix}.rows[${rowIndex}] must be a plain object`);
915
+ if (!isPlainObject(row)) {
916
+ errors.push(`${prefix}.rows[${rowIndex}] must be a plain object`);
917
+ return;
918
+ }
919
+ if (object.objectType === "sandbox-environment") {
920
+ validateSandboxEnvironmentRow(row, `${prefix}.rows[${rowIndex}]`, errors);
921
+ }
814
922
  });
815
923
  }
816
924
  validateStaticDataBinding(object.binding, `${prefix}.binding`, errors);
925
+ if (object.binding?.sourceStorage === "workspace-source-records" && typeof object.sourceId !== "string") {
926
+ errors.push(`${prefix}.sourceId is required when binding.sourceStorage is "workspace-source-records"`);
927
+ }
817
928
  validateFieldSettings(object.fieldSettings, `${prefix}.fieldSettings`, errors);
818
929
  });
819
930
  }
@@ -1062,11 +1173,19 @@ export {
1062
1173
  KNOWN_AGGREGATIONS,
1063
1174
  KNOWN_CHART_TYPES,
1064
1175
  KNOWN_DATA_BINDING_MODES,
1176
+ DEFAULT_SANDBOX_RUN_LOCALITY,
1177
+ KNOWN_SANDBOX_LIFECYCLE_STATUSES,
1065
1178
  KNOWN_FIELDS,
1066
1179
  KNOWN_FILTER_CONJUNCTIONS,
1067
1180
  KNOWN_FILTER_OPERATORS,
1181
+ KNOWN_SANDBOX_AGENT_HOSTS,
1182
+ KNOWN_SANDBOX_RUN_LOCALITY,
1183
+ KNOWN_SANDBOX_RUNTIMES,
1068
1184
  KNOWN_SORT_DIRECTIONS,
1069
1185
  KNOWN_WIDGET_KINDS,
1186
+ DEFAULT_SANDBOX_ADAPTER,
1187
+ SANDBOX_DEFAULT_TIMEOUT_MS,
1188
+ SANDBOX_MAX_TIMEOUT_MS,
1070
1189
  NORMALIZED_OBJECT_FIELD_IDS,
1071
1190
  SAMPLE_DATA_BINDINGS,
1072
1191
  SAMPLE_VIEW_ROWS,
@@ -10,6 +10,7 @@
10
10
  "lint": "next lint"
11
11
  },
12
12
  "dependencies": {
13
+ "@tanstack/react-table": "^8.21.3",
13
14
  "lucide-react": "^0.468.0",
14
15
  "next": "16.2.4",
15
16
  "react": "19.2.4",
@@ -61,6 +61,7 @@
61
61
  "studio/src/main.jsx",
62
62
  "studio/src/App.jsx",
63
63
  "studio/src/app.css",
64
+ "apps/workspace/docs/sandbox-environment-primitive.md",
64
65
  "apps/workspace/README.md",
65
66
  "apps/workspace/.env.example",
66
67
  "apps/workspace/package.json",
@@ -76,6 +77,12 @@
76
77
  "apps/workspace/app/workspace-builder.jsx",
77
78
  "apps/workspace/app/settings/integrations/page.jsx",
78
79
  "apps/workspace/app/api/workspace/route.js",
80
+ "apps/workspace/app/api/workspace/refresh-sources/route.js",
81
+ "apps/workspace/app/api/workspace/test-source/route.js",
82
+ "apps/workspace/app/api/workspace/register-resolver/route.js",
83
+ "apps/workspace/app/api/workspace/resolvers/route.js",
84
+ "apps/workspace/app/api/workspace/sandbox-adapters/route.js",
85
+ "apps/workspace/app/api/workspace/sandbox-run/route.js",
79
86
  "apps/workspace/app/api/settings/integrations/route.js",
80
87
  "apps/workspace/lib/workspace-schema.js",
81
88
  "apps/workspace/lib/workspace-config.js",
@@ -85,6 +92,15 @@
85
92
  "apps/workspace/lib/adapters/auth/index.js",
86
93
  "apps/workspace/lib/adapters/integrations/index.js",
87
94
  "apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js",
95
+ "apps/workspace/lib/adapters/integrations/source-resolver-registry.js",
96
+ "apps/workspace/lib/adapters/integrations/resolver-loader.js",
97
+ "apps/workspace/lib/adapters/integrations/resolvers/README.md",
98
+ "apps/workspace/lib/adapters/sandboxes/adapter-loader.js",
99
+ "apps/workspace/lib/adapters/sandboxes/adapters/README.md",
100
+ "apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js",
101
+ "apps/workspace/lib/adapters/sandboxes/default-local-process.js",
102
+ "apps/workspace/lib/adapters/sandboxes/index.js",
103
+ "apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js",
88
104
  "apps/workspace/lib/adapters/payments/index.js",
89
105
  "apps/workspace/lib/adapters/persistence/index.js",
90
106
  "apps/workspace/lib/adapters/persistence/postgres.js",