@growthub/cli 0.9.14 → 0.9.17

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 (19) hide show
  1. package/README.md +17 -5
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +712 -54
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +55 -3
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +2 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +107 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +103 -1
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
  18. package/dist/index.js +41066 -1761
  19. package/package.json +2 -2
@@ -10,6 +10,7 @@ import {
10
10
  Building2,
11
11
  Calendar,
12
12
  CheckSquare,
13
+ ChevronDown,
13
14
  ChevronRight,
14
15
  Code2,
15
16
  Database,
@@ -20,10 +21,14 @@ import {
20
21
  Link2,
21
22
  List,
22
23
  Mail,
24
+ Maximize2,
23
25
  Plus,
26
+ Play,
27
+ Search,
24
28
  ShoppingCart,
25
29
  Star,
26
30
  Tag,
31
+ Terminal,
27
32
  ToggleLeft,
28
33
  Type,
29
34
  Users,
@@ -40,7 +45,10 @@ import {
40
45
  describeBindingLane,
41
46
  exportTableAsCsv,
42
47
  importTableFromCsv,
48
+ listSavedEnvRefs,
43
49
  listWorkspaceDataModelTables,
50
+ parseSandboxAllowList,
51
+ parseSandboxEnvRefs,
44
52
  replaceTableContent,
45
53
  updateTableCell,
46
54
  } from "@/lib/workspace-data-model";
@@ -50,13 +58,13 @@ import {
50
58
  const LUCIDE_MAP = {
51
59
  Activity, BarChart2, Box, Building2, Calendar, CheckSquare, Code2,
52
60
  Database, FileText, Globe, Hash, Layers, Link2, List, Mail, Plus,
53
- ShoppingCart, Star, Tag, Type, Users, Zap,
61
+ ShoppingCart, Star, Tag, Terminal, Type, Users, Zap,
54
62
  };
55
63
 
56
64
  const ICON_PICKER_SET = [
57
65
  "Database", "Globe", "Code2", "Users", "CheckSquare", "Building2",
58
66
  "Tag", "Star", "Zap", "FileText", "Mail", "BarChart2",
59
- "Layers", "Box", "Activity", "ShoppingCart",
67
+ "Layers", "Box", "Activity", "ShoppingCart", "Terminal",
60
68
  ];
61
69
 
62
70
  function LucideIcon({ name, size = 14, className, style }) {
@@ -91,6 +99,12 @@ const OBJECT_TYPE_DEFS = [
91
99
  label: "Tasks",
92
100
  description: "Action items, to-dos, and work tracking.",
93
101
  },
102
+ {
103
+ type: "sandbox-environment",
104
+ icon: Terminal,
105
+ label: "Sandbox Environment",
106
+ description: "Localized py/node/bash terminal sandbox or local agent host (Claude / Codex / Cursor / Gemini / Hermes). Server-side execution with versioned run history. Cannot bind directly to a widget.",
107
+ },
94
108
  {
95
109
  type: "custom",
96
110
  icon: Plus,
@@ -102,13 +116,38 @@ const OBJECT_TYPE_DEFS = [
102
116
  // ─── Lane / badge meta ────────────────────────────────────────────────────────
103
117
 
104
118
  const OBJECT_TYPE_BADGE = {
105
- "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
106
- "api-registry": { label: "API Registry", cls: "dm-badge-registry" },
107
- people: { label: "People", cls: "dm-badge-people" },
108
- tasks: { label: "Tasks", cls: "dm-badge-tasks" },
109
- custom: { label: "Custom", cls: "dm-badge-manual" },
119
+ "data-source": { label: "Data Source", cls: "dm-badge-datasource" },
120
+ "api-registry": { label: "API Registry", cls: "dm-badge-registry" },
121
+ "sandbox-environment": { label: "Sandbox Environment", cls: "dm-badge-sandbox" },
122
+ people: { label: "People", cls: "dm-badge-people" },
123
+ tasks: { label: "Tasks", cls: "dm-badge-tasks" },
124
+ custom: { label: "Custom", cls: "dm-badge-manual" },
110
125
  };
111
126
 
127
+ const SANDBOX_ROW_FIELDS = new Set([
128
+ "Name",
129
+ "lifecycleStatus",
130
+ "version",
131
+ "runLocality",
132
+ "schedulerRegistryId",
133
+ "runtime",
134
+ "adapter",
135
+ "agentHost",
136
+ "envRefs",
137
+ "networkAllow",
138
+ "allowList",
139
+ "instructions",
140
+ "command",
141
+ "timeoutMs",
142
+ "status",
143
+ "lastTested",
144
+ "lastRunId",
145
+ "lastSourceId",
146
+ "lastResponse"
147
+ ]);
148
+
149
+ const SANDBOX_RUNTIME_OPTIONS = ["python", "node", "bash"];
150
+
112
151
  const FIELD_TYPE_ICON_NAMES = {
113
152
  text: "Type", number: "Hash", date: "Calendar", url: "Link2", select: "List", boolean: "ToggleLeft",
114
153
  };
@@ -245,36 +284,556 @@ function referenceOptions(tables, relation) {
245
284
  }
246
285
 
247
286
  function ReferenceSelect({ value, options, disabled, onChange }) {
287
+ const normalizedOptions = useMemo(() => options.map((option) => ({
288
+ value: String(option.value ?? ""),
289
+ label: String(option.label ?? option.value ?? ""),
290
+ source: option.source ? String(option.source) : ""
291
+ })), [options]);
248
292
  return (
249
- <select
250
- className="dm-reference-select"
293
+ <SearchableSelect
251
294
  value={value || ""}
295
+ options={normalizedOptions}
252
296
  disabled={disabled}
297
+ placeholder="Select reference..."
298
+ onChange={onChange}
299
+ />
300
+ );
301
+ }
302
+
303
+ function SearchableSelect({ value, options, disabled, placeholder = "Select...", onChange, pageSize = 8 }) {
304
+ const [open, setOpen] = useState(false);
305
+ const [query, setQuery] = useState("");
306
+ const [page, setPage] = useState(0);
307
+ const selected = options.find((option) => option.value === String(value || ""));
308
+ const filtered = useMemo(() => {
309
+ const needle = query.trim().toLowerCase();
310
+ if (!needle) return options;
311
+ return options.filter((option) => `${option.label} ${option.value} ${option.source}`.toLowerCase().includes(needle));
312
+ }, [options, query]);
313
+ const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
314
+ const currentPage = Math.min(page, pageCount - 1);
315
+ const visibleOptions = filtered.slice(currentPage * pageSize, currentPage * pageSize + pageSize);
316
+
317
+ useEffect(() => {
318
+ setPage(0);
319
+ }, [query, options.length]);
320
+
321
+ return (
322
+ <div
323
+ className={`dm-select${open ? " open" : ""}${disabled ? " disabled" : ""}`}
253
324
  onClick={(event) => event.stopPropagation()}
254
- onChange={(event) => onChange(event.target.value)}
325
+ onBlur={(event) => {
326
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
327
+ }}
255
328
  >
256
- <option value="">Select reference…</option>
257
- {options.map((option) => (
258
- <option key={`${option.source}:${option.value}`} value={option.value}>
259
- {option.label}
260
- </option>
261
- ))}
262
- </select>
329
+ <button
330
+ type="button"
331
+ className="dm-select-trigger"
332
+ disabled={disabled}
333
+ aria-haspopup="listbox"
334
+ aria-expanded={open}
335
+ onClick={() => setOpen((current) => !current)}
336
+ >
337
+ <span className={selected ? "" : "empty"}>{selected?.label || placeholder}</span>
338
+ <ChevronDown size={15} aria-hidden="true" />
339
+ </button>
340
+ {open && (
341
+ <div className="dm-select-popover">
342
+ <label className="dm-select-search">
343
+ <Search size={14} aria-hidden="true" />
344
+ <input
345
+ autoFocus
346
+ value={query}
347
+ placeholder="Search..."
348
+ onChange={(event) => setQuery(event.target.value)}
349
+ />
350
+ </label>
351
+ <div className="dm-select-list" role="listbox">
352
+ <button
353
+ type="button"
354
+ className={`dm-select-option${!value ? " selected" : ""}`}
355
+ role="option"
356
+ aria-selected={!value}
357
+ onClick={() => {
358
+ onChange("");
359
+ setOpen(false);
360
+ }}
361
+ >
362
+ <span>{placeholder}</span>
363
+ </button>
364
+ {visibleOptions.map((option) => (
365
+ <button
366
+ type="button"
367
+ key={`${option.source}:${option.value}`}
368
+ className={`dm-select-option${option.value === String(value || "") ? " selected" : ""}`}
369
+ role="option"
370
+ aria-selected={option.value === String(value || "")}
371
+ onClick={() => {
372
+ onChange(option.value);
373
+ setOpen(false);
374
+ setQuery("");
375
+ }}
376
+ >
377
+ <span>{option.label}</span>
378
+ {option.source && <em>{option.source}</em>}
379
+ </button>
380
+ ))}
381
+ {visibleOptions.length === 0 && <p className="dm-select-empty">No matches</p>}
382
+ </div>
383
+ {filtered.length > pageSize && (
384
+ <div className="dm-select-pager">
385
+ <button type="button" disabled={currentPage === 0} onClick={() => setPage((next) => Math.max(0, next - 1))}>Prev</button>
386
+ <span>{currentPage + 1} / {pageCount}</span>
387
+ <button type="button" disabled={currentPage >= pageCount - 1} onClick={() => setPage((next) => Math.min(pageCount - 1, next + 1))}>Next</button>
388
+ </div>
389
+ )}
390
+ </div>
391
+ )}
392
+ </div>
393
+ );
394
+ }
395
+
396
+ function StaticSelect({ value, options, disabled, onChange, placeholder = "Select..." }) {
397
+ const normalizedOptions = useMemo(() => options.map((option) => (
398
+ typeof option === "string" ? { value: option, label: option } : option
399
+ )), [options]);
400
+ return (
401
+ <SearchableSelect
402
+ value={value || ""}
403
+ options={normalizedOptions}
404
+ disabled={disabled}
405
+ placeholder={placeholder}
406
+ onChange={onChange}
407
+ />
408
+ );
409
+ }
410
+
411
+ function DrawerSection({ title, children, defaultOpen = false }) {
412
+ const [open, setOpen] = useState(defaultOpen);
413
+ return (
414
+ <section className={`dm-drawer-section${open ? " open" : ""}`}>
415
+ <button type="button" className="dm-drawer-section-toggle" onClick={() => setOpen((current) => !current)}>
416
+ <ChevronRight size={14} aria-hidden="true" />
417
+ <span>{title}</span>
418
+ </button>
419
+ {open && <div className="dm-drawer-section-body">{children}</div>}
420
+ </section>
421
+ );
422
+ }
423
+
424
+ const GENERIC_FIELD_SECTIONS = [
425
+ {
426
+ title: "Identity",
427
+ columns: new Set(["Name", "name", "id", "integrationId", "registryId", "authRef"])
428
+ },
429
+ {
430
+ title: "Connection",
431
+ columns: new Set(["baseUrl", "endpoint", "method", "schedulerRegistryId"])
432
+ },
433
+ {
434
+ title: "Status & Response",
435
+ columns: new Set(["status", "lastTested", "lastRunId", "lastSourceId", "lastResponse"])
436
+ }
437
+ ];
438
+
439
+ function groupRecordColumns(columns) {
440
+ const groups = GENERIC_FIELD_SECTIONS.map((section) => ({
441
+ title: section.title,
442
+ columns: columns.filter((column) => section.columns.has(column))
443
+ })).filter((section) => section.columns.length > 0);
444
+ const grouped = new Set(groups.flatMap((section) => section.columns));
445
+ const otherColumns = columns.filter((column) => !grouped.has(column));
446
+ if (otherColumns.length) groups.push({ title: "Details", columns: otherColumns });
447
+ return groups;
448
+ }
449
+
450
+ function RecordFieldEditor({ table, tables, column, value, saving, onDraft, onCommit, onExpandJson }) {
451
+ const relation = relationForColumn(table, column);
452
+ const options = referenceOptions(tables, relation);
453
+ const large = column === "lastResponse" || value.length > 120;
454
+ if (relation) {
455
+ return (
456
+ <label className="dm-record-field">
457
+ <span>{column}</span>
458
+ <ReferenceSelect
459
+ value={value}
460
+ options={options}
461
+ disabled={!table.mutable || saving}
462
+ onChange={(nextValue) => onCommit(column, nextValue)}
463
+ />
464
+ </label>
465
+ );
466
+ }
467
+ if (column === "lastResponse") {
468
+ return (
469
+ <label className="dm-record-field dm-json-field">
470
+ <span>{column}</span>
471
+ <button
472
+ type="button"
473
+ className="dm-json-expand"
474
+ aria-label="Expand lastResponse JSON"
475
+ title="Expand JSON"
476
+ disabled={!value}
477
+ onClick={onExpandJson}
478
+ >
479
+ <Maximize2 size={14} aria-hidden="true" />
480
+ </button>
481
+ <textarea
482
+ value={value}
483
+ rows={10}
484
+ disabled={!table.mutable || saving}
485
+ onChange={(event) => onDraft(column, event.target.value)}
486
+ onBlur={(event) => onCommit(column, event.target.value)}
487
+ />
488
+ </label>
489
+ );
490
+ }
491
+ return (
492
+ <label className="dm-record-field">
493
+ <span>{column}</span>
494
+ {large ? (
495
+ <textarea
496
+ value={value}
497
+ rows={4}
498
+ disabled={!table.mutable || saving}
499
+ onChange={(event) => onDraft(column, event.target.value)}
500
+ onBlur={(event) => onCommit(column, event.target.value)}
501
+ />
502
+ ) : (
503
+ <input
504
+ value={value}
505
+ disabled={!table.mutable || saving}
506
+ onChange={(event) => onDraft(column, event.target.value)}
507
+ onBlur={(event) => onCommit(column, event.target.value)}
508
+ />
509
+ )}
510
+ </label>
511
+ );
512
+ }
513
+
514
+ function SandboxRecordFields({
515
+ draft,
516
+ setDraft,
517
+ table,
518
+ tables,
519
+ workspaceConfig,
520
+ saving,
521
+ onSave,
522
+ rowIndex,
523
+ sandboxHistory,
524
+ sandboxHistoryMessage,
525
+ loadingSandboxHistory,
526
+ onLoadSandboxHistory,
527
+ onExpandLastResponse
528
+ }) {
529
+ const [sandboxAdapters, setSandboxAdapters] = useState([]);
530
+ useEffect(() => {
531
+ fetch("/api/workspace/sandbox-adapters", { cache: "no-store" })
532
+ .then((res) => res.json())
533
+ .then((payload) => setSandboxAdapters(Array.isArray(payload.adapters) ? payload.adapters : []))
534
+ .catch(() => setSandboxAdapters([]));
535
+ }, []);
536
+
537
+ const locality = String(draft.runLocality || "local").trim().toLowerCase() === "serverless" ? "serverless" : "local";
538
+ const savedEnvRefs = useMemo(() => listSavedEnvRefs(workspaceConfig || {}), [workspaceConfig]);
539
+ const selectedEnvSlugs = useMemo(() => new Set(parseSandboxEnvRefs(draft.envRefs)), [draft.envRefs]);
540
+ const schedulerRelation = relationForColumn(table, "schedulerRegistryId");
541
+ const schedulerOptions = referenceOptions(tables, schedulerRelation);
542
+ const selectedAdapterMeta = sandboxAdapters.find((a) => a.id === String(draft.adapter || "").trim());
543
+
544
+ function patchFields(fields) {
545
+ setDraft((c) => ({ ...c, ...fields }));
546
+ onSave((cfg) => Object.entries(fields).reduce(
547
+ (acc, [column, value]) => updateTableCell(acc, table, rowIndex, column, value),
548
+ cfg
549
+ ));
550
+ }
551
+
552
+ function setRunLocality(next) {
553
+ const fields = { runLocality: next };
554
+ if (next === "serverless" && String(draft.adapter || "").trim() === "local-agent-host") {
555
+ fields.adapter = "local-process";
556
+ }
557
+ patchFields(fields);
558
+ }
559
+
560
+ function toggleEnvRef(slug) {
561
+ const next = new Set(selectedEnvSlugs);
562
+ if (next.has(slug)) next.delete(slug);
563
+ else next.add(slug);
564
+ patchFields({ envRefs: [...next].join(",") });
565
+ }
566
+
567
+ const netOn = ["true", "1", "on", "yes"].includes(String(draft.networkAllow || "").trim().toLowerCase());
568
+
569
+ return (
570
+ <div className="dm-sandbox-config">
571
+ <DrawerSection title="Identity & Mode">
572
+ <label className="dm-record-field">
573
+ <span>Name</span>
574
+ <input
575
+ value={draft.Name ?? ""}
576
+ disabled={!table.mutable || saving}
577
+ onChange={(event) => setDraft((c) => ({ ...c, Name: event.target.value }))}
578
+ onBlur={(event) => patchFields({ Name: event.target.value })}
579
+ />
580
+ </label>
581
+
582
+ <label className="dm-record-field">
583
+ <span>Status mode</span>
584
+ <StaticSelect
585
+ value={String(draft.lifecycleStatus || "draft").trim().toLowerCase() === "live" ? "live" : "draft"}
586
+ disabled={!table.mutable || saving}
587
+ options={["draft", "live"]}
588
+ onChange={(nextValue) => patchFields({ lifecycleStatus: nextValue })}
589
+ />
590
+ </label>
591
+
592
+ <label className="dm-record-field">
593
+ <span>Version</span>
594
+ <input
595
+ value={draft.version ?? ""}
596
+ disabled={!table.mutable || saving}
597
+ onChange={(event) => setDraft((c) => ({ ...c, version: event.target.value }))}
598
+ onBlur={(event) => patchFields({ version: event.target.value })}
599
+ />
600
+ </label>
601
+ </DrawerSection>
602
+
603
+ <DrawerSection title="Execution Target">
604
+ <div className="dm-record-field">
605
+ <span>Where it runs</span>
606
+ <div className="dm-radio-row">
607
+ <label>
608
+ <input
609
+ type="radio"
610
+ name="sandbox-run-locality"
611
+ checked={locality === "local"}
612
+ disabled={!table.mutable || saving}
613
+ onChange={() => setRunLocality("local")}
614
+ />
615
+ <span>Local - process sandbox or Paperclip local agent host on this machine</span>
616
+ </label>
617
+ <label>
618
+ <input
619
+ type="radio"
620
+ name="sandbox-run-locality"
621
+ checked={locality === "serverless"}
622
+ disabled={!table.mutable || saving}
623
+ onChange={() => setRunLocality("serverless")}
624
+ />
625
+ <span>Serverless - delegate to scheduler URL (API Registry); no local agent CLI</span>
626
+ </label>
627
+ </div>
628
+ </div>
629
+
630
+ {locality === "serverless" && (
631
+ <label className="dm-record-field">
632
+ <span>Scheduler (API Registry)</span>
633
+ <ReferenceSelect
634
+ value={draft.schedulerRegistryId || ""}
635
+ options={schedulerOptions}
636
+ disabled={!table.mutable || saving}
637
+ onChange={(nextValue) => patchFields({ schedulerRegistryId: nextValue })}
638
+ />
639
+ <span className="dm-cell-empty" style={{ fontSize: 11, marginTop: 4, display: "block" }}>
640
+ POST sends <code>growthub-sandbox-run-v1</code> JSON; auth from registry <code>authRef</code> (server env only).
641
+ </span>
642
+ </label>
643
+ )}
644
+
645
+ <label className="dm-record-field">
646
+ <span>Execution adapter</span>
647
+ <StaticSelect
648
+ value={String(draft.adapter || "local-process").trim() || "local-process"}
649
+ disabled={!table.mutable || saving}
650
+ options={sandboxAdapters.length === 0 ? [{ value: "local-process", label: "local-process" }] : sandboxAdapters.map((a) => ({ value: a.id, label: a.label }))}
651
+ onChange={(nextValue) => patchFields({ adapter: nextValue })}
652
+ />
653
+ </label>
654
+
655
+ {locality === "local" && String(draft.adapter || "").trim() === "local-agent-host" && (
656
+ <label className="dm-record-field">
657
+ <span>Agent host (Paperclip)</span>
658
+ <StaticSelect
659
+ value={draft.agentHost || ""}
660
+ disabled={!table.mutable || saving}
661
+ placeholder="Select host..."
662
+ options={(selectedAdapterMeta?.hostCatalog || []).map((h) => ({ value: h.slug, label: h.label }))}
663
+ onChange={(nextValue) => patchFields({ agentHost: nextValue })}
664
+ />
665
+ </label>
666
+ )}
667
+
668
+ <label className="dm-record-field">
669
+ <span>Runtime</span>
670
+ <StaticSelect
671
+ value={draft.runtime || "node"}
672
+ disabled={!table.mutable || saving}
673
+ options={SANDBOX_RUNTIME_OPTIONS}
674
+ onChange={(nextValue) => patchFields({ runtime: nextValue })}
675
+ />
676
+ </label>
677
+ </DrawerSection>
678
+
679
+ <DrawerSection title="Environment & Network">
680
+ <div className="dm-record-field">
681
+ <span>Env key references</span>
682
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
683
+ {savedEnvRefs.length === 0 ? (
684
+ <span className="dm-cell-empty">Add keys under Settings -&gt; APIs &amp; Webhooks.</span>
685
+ ) : savedEnvRefs.map((ref) => (
686
+ <button
687
+ key={ref.endpointRef}
688
+ type="button"
689
+ className={`dm-btn-ghost${selectedEnvSlugs.has(ref.endpointRef) ? " dm-chip-active" : ""}`}
690
+ style={{ padding: "2px 8px", borderRadius: 999, fontSize: 11 }}
691
+ disabled={!table.mutable || saving}
692
+ onClick={() => toggleEnvRef(ref.endpointRef)}
693
+ >
694
+ {ref.endpointRef}
695
+ </button>
696
+ ))}
697
+ </div>
698
+ </div>
699
+
700
+ <label className="dm-check-row">
701
+ <input
702
+ type="checkbox"
703
+ checked={netOn}
704
+ disabled={!table.mutable || saving}
705
+ onChange={(event) => patchFields({ networkAllow: event.target.checked ? "true" : "false" })}
706
+ />
707
+ <span>Network allow-list mode (locals see <code>GROWTHUB_SANDBOX_NET_*</code>)</span>
708
+ </label>
709
+
710
+ <label className="dm-record-field">
711
+ <span>Allow list (comma-separated hosts)</span>
712
+ <input
713
+ value={draft.allowList ?? ""}
714
+ disabled={!table.mutable || saving}
715
+ onChange={(event) => setDraft((c) => ({ ...c, allowList: event.target.value }))}
716
+ onBlur={(event) => patchFields({ allowList: event.target.value })}
717
+ />
718
+ </label>
719
+ </DrawerSection>
720
+
721
+ <DrawerSection title="Prompt & Limits">
722
+ <label className="dm-record-field">
723
+ <span>Instructions</span>
724
+ <textarea
725
+ rows={5}
726
+ value={draft.instructions ?? ""}
727
+ disabled={!table.mutable || saving}
728
+ onChange={(event) => setDraft((c) => ({ ...c, instructions: event.target.value }))}
729
+ onBlur={(event) => patchFields({ instructions: event.target.value })}
730
+ />
731
+ </label>
732
+
733
+ <label className="dm-record-field">
734
+ <span>Command / prompt</span>
735
+ <textarea
736
+ rows={6}
737
+ value={draft.command ?? ""}
738
+ disabled={!table.mutable || saving}
739
+ onChange={(event) => setDraft((c) => ({ ...c, command: event.target.value }))}
740
+ onBlur={(event) => patchFields({ command: event.target.value })}
741
+ />
742
+ </label>
743
+
744
+ <label className="dm-record-field">
745
+ <span>timeoutMs</span>
746
+ <input
747
+ type="number"
748
+ min={1000}
749
+ max={600000}
750
+ value={draft.timeoutMs ?? ""}
751
+ disabled={!table.mutable || saving}
752
+ onChange={(event) => setDraft((c) => ({ ...c, timeoutMs: event.target.value }))}
753
+ onBlur={(event) => patchFields({ timeoutMs: event.target.value })}
754
+ />
755
+ </label>
756
+ </DrawerSection>
757
+
758
+ <DrawerSection title="Response & History">
759
+ <label className="dm-record-field">
760
+ <span>lastRunId</span>
761
+ <input readOnly value={draft.lastRunId ?? ""} />
762
+ </label>
763
+
764
+ <label className="dm-record-field">
765
+ <span>lastSourceId</span>
766
+ <input readOnly value={draft.lastSourceId ?? ""} />
767
+ </label>
768
+
769
+ <label className="dm-record-field dm-json-field">
770
+ <span>lastResponse</span>
771
+ <button
772
+ type="button"
773
+ className="dm-json-expand"
774
+ aria-label="Expand lastResponse JSON"
775
+ title="Expand JSON"
776
+ disabled={!draft.lastResponse}
777
+ onClick={onExpandLastResponse}
778
+ >
779
+ <Maximize2 size={14} aria-hidden="true" />
780
+ </button>
781
+ <textarea rows={10} readOnly value={draft.lastResponse ?? ""} />
782
+ </label>
783
+
784
+ <div className="dm-record-field">
785
+ <span>Run history</span>
786
+ <button type="button" className="dm-btn-ghost" disabled={loadingSandboxHistory} onClick={onLoadSandboxHistory}>
787
+ {loadingSandboxHistory ? "Loading..." : "Load previous runs"}
788
+ </button>
789
+ {sandboxHistoryMessage && <span className="dm-cell-empty">{sandboxHistoryMessage}</span>}
790
+ {Array.isArray(sandboxHistory) && sandboxHistory.length > 0 && (
791
+ <div style={{ display: "grid", gap: 8, marginTop: 8 }}>
792
+ {sandboxHistory.slice(0, 8).map((record) => (
793
+ <pre key={record.runId || record.ranAt} className="dm-source-preview" style={{ margin: 0, maxHeight: 160, overflow: "auto" }}>
794
+ {JSON.stringify({
795
+ runId: record.runId,
796
+ ranAt: record.ranAt,
797
+ lifecycleStatus: record.lifecycleStatus,
798
+ version: record.version,
799
+ status: record.exitCode === 0 && !record.error ? "connected" : "failed",
800
+ stdout: record.stdout,
801
+ error: record.error
802
+ }, null, 2)}
803
+ </pre>
804
+ ))}
805
+ </div>
806
+ )}
807
+ </div>
808
+ </DrawerSection>
809
+ </div>
263
810
  );
264
811
  }
265
812
 
266
- function DataModelRecordDrawer({ table, tables, rowIndex, row, saving, onClose, onSave }) {
813
+ function DataModelRecordDrawer({ table, tables, workspaceConfig, rowIndex, row, saving, onClose, onSave }) {
267
814
  const [draft, setDraft] = useState(row || {});
268
815
  const [testing, setTesting] = useState(false);
269
816
  const [testMessage, setTestMessage] = useState("");
817
+ const [sandboxRunning, setSandboxRunning] = useState(false);
818
+ const [sandboxMessage, setSandboxMessage] = useState("");
819
+ const [sandboxHistory, setSandboxHistory] = useState([]);
820
+ const [sandboxHistoryMessage, setSandboxHistoryMessage] = useState("");
821
+ const [loadingSandboxHistory, setLoadingSandboxHistory] = useState(false);
822
+ const [expandedJson, setExpandedJson] = useState(null);
270
823
 
271
824
  useEffect(() => {
272
825
  setDraft(row || {});
273
826
  setTestMessage("");
827
+ setSandboxMessage("");
828
+ setSandboxHistory([]);
829
+ setSandboxHistoryMessage("");
830
+ setExpandedJson(null);
274
831
  }, [row, rowIndex]);
275
832
 
276
833
  if (rowIndex === null || rowIndex === undefined || !row) return null;
277
834
 
835
+ const isSandbox = table.objectType === "sandbox-environment";
836
+
278
837
  function updateField(column, value) {
279
838
  setDraft((current) => ({ ...current, [column]: value }));
280
839
  onSave((config) => updateTableCell(config, table, rowIndex, column, value));
@@ -314,6 +873,80 @@ function DataModelRecordDrawer({ table, tables, rowIndex, row, saving, onClose,
314
873
  }
315
874
  }
316
875
 
876
+ async function runSandbox() {
877
+ if (!table.objectId) {
878
+ setSandboxMessage("Missing object id for this sandbox table.");
879
+ return;
880
+ }
881
+ const rowName = String(draft?.Name ?? "").trim();
882
+ if (!rowName) {
883
+ setSandboxMessage("Row Name is required.");
884
+ return;
885
+ }
886
+ setSandboxRunning(true);
887
+ setSandboxMessage("");
888
+ try {
889
+ const res = await fetch("/api/workspace/sandbox-run", {
890
+ method: "POST",
891
+ headers: { "content-type": "application/json" },
892
+ body: JSON.stringify({ objectId: table.objectId, name: rowName }),
893
+ });
894
+ const payload = await res.json();
895
+ const responseText = JSON.stringify(payload.response ?? payload, null, 2);
896
+ const status = String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
897
+ const testedAt = payload.response?.ranAt || new Date().toISOString();
898
+ const lastRunId = payload.runId || payload.response?.runId || "";
899
+ const lastSourceId = payload.sourceId || payload.response?.sourceId || "";
900
+ onSave((config) => {
901
+ let next = updateTableCell(config, table, rowIndex, "status", status);
902
+ next = updateTableCell(next, table, rowIndex, "lastTested", testedAt);
903
+ next = updateTableCell(next, table, rowIndex, "lastRunId", lastRunId);
904
+ next = updateTableCell(next, table, rowIndex, "lastSourceId", lastSourceId);
905
+ next = updateTableCell(next, table, rowIndex, "lastResponse", responseText);
906
+ return next;
907
+ });
908
+ setDraft((current) => ({ ...current, status, lastTested: testedAt, lastRunId, lastSourceId, lastResponse: responseText }));
909
+ setSandboxHistory((current) => payload.response ? [payload.response, ...current].slice(0, 25) : current);
910
+ setSandboxMessage(payload.ok ? "Sandbox run recorded" : (payload.response?.error || payload.error || "Run failed"));
911
+ } catch (err) {
912
+ setSandboxMessage(err.message || "Sandbox run failed");
913
+ } finally {
914
+ setSandboxRunning(false);
915
+ }
916
+ }
917
+
918
+ async function loadSandboxHistory() {
919
+ if (!table.objectId || !String(draft?.Name || "").trim()) {
920
+ setSandboxHistoryMessage("Sandbox Name is required.");
921
+ return;
922
+ }
923
+ setLoadingSandboxHistory(true);
924
+ setSandboxHistoryMessage("");
925
+ try {
926
+ const params = new URLSearchParams({ objectId: table.objectId, name: String(draft.Name || "").trim() });
927
+ const res = await fetch(`/api/workspace/sandbox-run?${params.toString()}`, { cache: "no-store" });
928
+ const payload = await res.json();
929
+ if (!payload.ok) throw new Error(payload.error || "Could not load run history");
930
+ setSandboxHistory(Array.isArray(payload.records) ? payload.records : []);
931
+ setSandboxHistoryMessage(`${payload.recordCount || 0} saved run${payload.recordCount === 1 ? "" : "s"} · ${payload.sourceId || ""}`);
932
+ } catch (err) {
933
+ setSandboxHistory([]);
934
+ setSandboxHistoryMessage(err.message || "Could not load run history");
935
+ } finally {
936
+ setLoadingSandboxHistory(false);
937
+ }
938
+ }
939
+
940
+ function expandLastResponse() {
941
+ const text = String(draft.lastResponse || "");
942
+ if (!text) return;
943
+ try {
944
+ setExpandedJson(JSON.stringify(JSON.parse(text), null, 2));
945
+ } catch {
946
+ setExpandedJson(text);
947
+ }
948
+ }
949
+
317
950
  return (
318
951
  <>
319
952
  <div className="dm-record-backdrop" onClick={onClose} />
@@ -336,48 +969,72 @@ function DataModelRecordDrawer({ table, tables, rowIndex, row, saving, onClose,
336
969
  {testMessage && <span>{testMessage}</span>}
337
970
  </div>
338
971
  )}
972
+ {isSandbox && (
973
+ <div className="dm-record-testbar">
974
+ <ConnectionPill value={draft.status} />
975
+ <button type="button" className="dm-btn-primary-sm" disabled={sandboxRunning || saving || !String(draft.Name || "").trim()} onClick={runSandbox}>
976
+ {sandboxRunning ? "Running…" : (<><Play size={13} aria-hidden /> Run sandbox</>)}
977
+ </button>
978
+ {sandboxMessage && <span>{sandboxMessage}</span>}
979
+ </div>
980
+ )}
339
981
  <div className="dm-record-fields">
340
- {table.columns.map((column) => {
341
- const value = String(draft?.[column] ?? "");
342
- const large = column === "lastResponse" || value.length > 120;
343
- const relation = relationForColumn(table, column);
344
- const options = referenceOptions(tables, relation);
345
- return (
346
- <label key={column} className="dm-record-field">
347
- <span>{column}</span>
348
- {relation ? (
349
- <ReferenceSelect
350
- value={value}
351
- options={options}
352
- disabled={!table.mutable || saving}
353
- onChange={(nextValue) => updateField(column, nextValue)}
354
- />
355
- ) : large ? (
356
- <textarea
357
- value={value}
358
- rows={column === "lastResponse" ? 10 : 4}
359
- disabled={!table.mutable || saving}
360
- onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
361
- onBlur={(event) => updateField(column, event.target.value)}
362
- />
363
- ) : (
364
- <input
365
- value={value}
366
- disabled={!table.mutable || saving}
367
- onChange={(event) => setDraft((current) => ({ ...current, [column]: event.target.value }))}
368
- onBlur={(event) => updateField(column, event.target.value)}
369
- />
370
- )}
371
- </label>
372
- );
373
- })}
982
+ {isSandbox ? (
983
+ <SandboxRecordFields
984
+ draft={draft}
985
+ setDraft={setDraft}
986
+ table={table}
987
+ tables={tables}
988
+ workspaceConfig={workspaceConfig}
989
+ saving={saving}
990
+ onSave={onSave}
991
+ rowIndex={rowIndex}
992
+ sandboxHistory={sandboxHistory}
993
+ sandboxHistoryMessage={sandboxHistoryMessage}
994
+ loadingSandboxHistory={loadingSandboxHistory}
995
+ onLoadSandboxHistory={loadSandboxHistory}
996
+ onExpandLastResponse={expandLastResponse}
997
+ />
998
+ ) : groupRecordColumns(table.columns || []).map((section) => (
999
+ <DrawerSection key={section.title} title={section.title}>
1000
+ {section.columns.map((column) => (
1001
+ <RecordFieldEditor
1002
+ key={column}
1003
+ table={table}
1004
+ tables={tables}
1005
+ column={column}
1006
+ value={String(draft?.[column] ?? "")}
1007
+ saving={saving}
1008
+ onDraft={(field, nextValue) => setDraft((current) => ({ ...current, [field]: nextValue }))}
1009
+ onCommit={updateField}
1010
+ onExpandJson={expandLastResponse}
1011
+ />
1012
+ ))}
1013
+ </DrawerSection>
1014
+ ))}
374
1015
  </div>
375
1016
  </aside>
1017
+ {expandedJson !== null && (
1018
+ <div className="dm-json-modal-backdrop" onClick={() => setExpandedJson(null)}>
1019
+ <section className="dm-json-modal" role="dialog" aria-modal="true" aria-label="lastResponse JSON" onClick={(event) => event.stopPropagation()}>
1020
+ <header>
1021
+ <div>
1022
+ <p>lastResponse</p>
1023
+ <h2>{draft.Name || draft.integrationId || "Record response"}</h2>
1024
+ </div>
1025
+ <button type="button" className="dm-sidebar-close" onClick={() => setExpandedJson(null)} aria-label="Close expanded JSON">
1026
+ <X size={16} />
1027
+ </button>
1028
+ </header>
1029
+ <pre>{expandedJson}</pre>
1030
+ </section>
1031
+ </div>
1032
+ )}
376
1033
  </>
377
1034
  );
378
1035
  }
379
1036
 
380
- function DataModelTableSurface({ table, tables, saving, onSave }) {
1037
+ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave }) {
381
1038
  const [selectedRow, setSelectedRow] = useState(null);
382
1039
  const [addingField, setAddingField] = useState(false);
383
1040
  const [fieldName, setFieldName] = useState("");
@@ -529,6 +1186,7 @@ function DataModelTableSurface({ table, tables, saving, onSave }) {
529
1186
  <DataModelRecordDrawer
530
1187
  table={table}
531
1188
  tables={tables}
1189
+ workspaceConfig={workspaceConfig}
532
1190
  rowIndex={selectedRow}
533
1191
  row={selectedRecord}
534
1192
  saving={saving}
@@ -853,7 +1511,7 @@ export default function DataModelPage() {
853
1511
  </div>
854
1512
  <SourceValidationBanner table={selectedTable} />
855
1513
  </div>
856
- <DataModelTableSurface table={selectedTable} tables={tables} saving={saving} onSave={save} />
1514
+ <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} />
857
1515
  </section>
858
1516
  )}
859
1517
  </div>