@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
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @growthub/cli
2
2
 
3
- `@growthub/cli` is the CLI control plane for Growthub Local.
3
+ `@growthub/cli` is the local control plane for Growthub Local and Agent Workspace as Code (AWaC).
4
4
 
5
- It creates governed **Workspaces** from any source repo, skill, kit, template, or starter. The Workspace is the top-level product object; this CLI is the local executor that creates, customizes, and inspects them.
5
+ It turns repos, skills, starters, kits, and templates into governed **Workspaces** that can be exported, forked, inspected, operated by agents, kept current, and optionally connected to hosted authority. The Workspace is the top-level product object; the CLI is the executor that moves it through the lifecycle.
6
6
 
7
7
  ## Start here: create a governed Workspace
8
8
 
@@ -34,14 +34,26 @@ npm install -g @growthub/cli
34
34
 
35
35
  Reference contracts: [Workspace Config Contract V1](../docs/WORKSPACE_CONFIG_CONTRACT_V1.md) · [Governed Workspace Topology V1](../docs/GOVERNED_WORKSPACE_TOPOLOGY_V1.md) · [Workspace Builder Runtime V1](../docs/WORKSPACE_BUILDER_RUNTIME_V1.md)
36
36
 
37
+ ## CLI role in the governed workspace architecture
38
+
39
+ Growthub Local keeps the Workspace as the owned artifact: a forkable app, `growthub.config.json`, `.growthub-fork/` lifecycle state, builder state, agent-readable contracts, and optional hosted authority.
40
+
41
+ The CLI is the machine-readable path through that architecture:
42
+
43
+ - **Export** a starter, repo, skill, template, or worker kit into a local Workspace.
44
+ - **Register and inspect forks** so customization carries identity, policy, and trace instead of becoming an untracked copy.
45
+ - **Operate ongoing lifecycle checks** for workspace status, QA, deploy readiness, upstream drift, surface detection, and portal preparation.
46
+ - **Connect optional authority** through Growthub auth, bridge-backed integrations, hosted agents, and capability activation when local value is already clear.
47
+ - **Expose the same contracts to agents and humans** through structured commands, JSON output, skill manifests, helper scripts, and the Workspace Builder.
48
+
37
49
  ## Profile-first setup (recommended)
38
50
 
39
51
  The guided flow is profile-first before deeper harness/workflow choices:
40
52
 
41
53
  ```bash
42
- npm create growthub-local@latest -- --profile gtm
43
- npm create growthub-local@latest -- --profile dx
44
- npm create growthub-local@latest -- --profile workspace --out ./my-workspace
54
+ npm create @growthub/growthub-local@latest -- --profile gtm
55
+ npm create @growthub/growthub-local@latest -- --profile dx
56
+ npm create @growthub/growthub-local@latest -- --profile workspace --out ./my-workspace
45
57
  ```
46
58
 
47
59
  ## Discovery lanes
@@ -0,0 +1,21 @@
1
+ /**
2
+ * GET /api/workspace/sandbox-adapters
3
+ *
4
+ * Lists every registered sandbox adapter — the default `local-process`
5
+ * shipped at `lib/adapters/sandboxes/default-local-process.js` plus any
6
+ * drop-zone adapter file added under `lib/adapters/sandboxes/adapters/`.
7
+ *
8
+ * Used by the Data Model drawer's adapter dropdown for the
9
+ * `sandbox-environment` object type. Returns provider-agnostic metadata only.
10
+ */
11
+
12
+ import { NextResponse } from "next/server";
13
+ import { describeRegisteredSandboxAdapters, ensureSandboxAdaptersLoaded } from "@/lib/adapters/sandboxes";
14
+
15
+ async function GET() {
16
+ await ensureSandboxAdaptersLoaded();
17
+ const adapters = describeRegisteredSandboxAdapters();
18
+ return NextResponse.json({ adapters });
19
+ }
20
+
21
+ export { GET };
@@ -0,0 +1,634 @@
1
+ /**
2
+ * POST /api/workspace/sandbox-run
3
+ *
4
+ * Executes one row of a `sandbox-environment` governed Data Model object via
5
+ * the registered sandbox adapter, then writes the result into:
6
+ *
7
+ * 1. `growthub.source-records.json` (sidecar, versioned run history) —
8
+ * keyed by `sandbox:<objectId>:<slug(name)>`. Each invocation appends a
9
+ * record so the full history travels with the workspace artifact.
10
+ * 2. The row in `growthub.config.json` — stamps `status`, `lastTested`,
11
+ * and a compact `lastResponse` JSON so the existing Data Model drawer
12
+ * test bar surfaces the result with no UI rewrite.
13
+ *
14
+ * The route is provider-agnostic for **local** runs: adapters live under
15
+ * `lib/adapters/sandboxes/adapters/` plus bundled defaults.
16
+ *
17
+ * When `runLocality === "serverless"`, execution is delegated with an outbound
18
+ * HTTP request to an **API Registry** row referenced by `schedulerRegistryId`
19
+ * (same FK pattern as Data Source → registryId). Credentials resolve
20
+ * server-side only (authRef env); the JSON body never includes secret values.
21
+ * Your Edge function / QStash worker / cron handler returns JSON or plain text,
22
+ * surfaced as stdout / exitCode — keeping the sandbox row shape identical so
23
+ * Data Sources downstream can normalize either locality.
24
+ *
25
+ * Request body:
26
+ * { objectId: string, name: string }
27
+ *
28
+ * Response (success):
29
+ * {
30
+ * ok: boolean,
31
+ * status: "connected" | "failed",
32
+ * runId: string,
33
+ * adapter: string,
34
+ * runtime: string,
35
+ * exitCode: number | null,
36
+ * durationMs: number,
37
+ * persisted: boolean,
38
+ * sourceId: string | null,
39
+ * response: { // saved into row.lastResponse
40
+ * runLocality, schedulerRegistryId?, runtime, adapter, exitCode, durationMs,
41
+ * stdout, stderr, error?,
42
+ * envRefsResolved: string[], // slug names only — never values
43
+ * envRefsMissing: string[],
44
+ * networkAllow: boolean,
45
+ * allowList: string[],
46
+ * adapterMeta?: Record<string, unknown>
47
+ * }
48
+ * }
49
+ */
50
+
51
+ import { NextResponse } from "next/server";
52
+ import { promises as fs } from "node:fs";
53
+ import os from "node:os";
54
+ import path from "node:path";
55
+ import {
56
+ describePersistenceMode,
57
+ readWorkspaceConfig,
58
+ readWorkspaceSourceRecords,
59
+ writeWorkspaceConfig,
60
+ writeWorkspaceSourceRecords
61
+ } from "@/lib/workspace-config";
62
+ import {
63
+ DEFAULT_SANDBOX_ADAPTER,
64
+ DEFAULT_SANDBOX_RUN_LOCALITY,
65
+ KNOWN_SANDBOX_RUNTIMES,
66
+ SANDBOX_DEFAULT_TIMEOUT_MS,
67
+ SANDBOX_MAX_TIMEOUT_MS
68
+ } from "@/lib/workspace-schema";
69
+ import {
70
+ parseSandboxAllowList,
71
+ parseSandboxEnvRefs,
72
+ sandboxRunSourceId
73
+ } from "@/lib/workspace-data-model";
74
+ import {
75
+ ensureSandboxAdaptersLoaded,
76
+ getSandboxAdapter
77
+ } from "@/lib/adapters/sandboxes";
78
+
79
+ function envKeyCandidates(ref) {
80
+ const token = String(ref || "")
81
+ .trim()
82
+ .replace(/[^a-z0-9]+/gi, "_")
83
+ .replace(/^_+|_+$/g, "")
84
+ .toUpperCase();
85
+ return Array.from(new Set([
86
+ token,
87
+ token ? `${token}_API_KEY` : "",
88
+ token ? `${token}_TOKEN` : ""
89
+ ].filter(Boolean)));
90
+ }
91
+
92
+ function readServerSecret(authRef) {
93
+ for (const key of envKeyCandidates(authRef)) {
94
+ if (process.env[key]) return { key, value: process.env[key] };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function coerceBoolean(value) {
100
+ if (value === true || value === false) return value;
101
+ const text = String(value ?? "").trim().toLowerCase();
102
+ return ["true", "1", "on", "yes"].includes(text);
103
+ }
104
+
105
+ function normalizeRunLocality(row) {
106
+ const raw = String(row?.runLocality ?? "").trim().toLowerCase();
107
+ if (raw === "serverless") return "serverless";
108
+ if (raw === "local") return "local";
109
+ return DEFAULT_SANDBOX_RUN_LOCALITY;
110
+ }
111
+
112
+ function normalizeMethod(value) {
113
+ const method = String(value || "GET").trim().toUpperCase();
114
+ return ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method) ? method : "GET";
115
+ }
116
+
117
+ function buildSchedulerUrl(record) {
118
+ const baseUrl = String(record?.baseUrl || "").trim();
119
+ const endpoint = String(record?.endpoint || "").trim();
120
+ const raw = endpoint || baseUrl;
121
+ if (!raw) throw new Error("baseUrl or endpoint is required");
122
+ if (/^https?:\/\//i.test(endpoint)) return endpoint;
123
+ if (!baseUrl) throw new Error("baseUrl is required when endpoint is relative");
124
+ return `${baseUrl.replace(/\/+$/, "")}/${endpoint.replace(/^\/+/, "")}`;
125
+ }
126
+
127
+ function buildAuthHeaders(record, secretValue) {
128
+ if (!secretValue) return {};
129
+ const headerName = String(record?.authHeaderName || record?.authHeader || "x-api-key").trim();
130
+ if (!headerName) return {};
131
+ const prefix = String(record?.authPrefix || "").trim();
132
+ return { [headerName]: prefix ? `${prefix} ${secretValue}` : secretValue };
133
+ }
134
+
135
+ function findRegistryRecord(workspaceConfig, registryId) {
136
+ const id = String(registryId || "").trim();
137
+ if (!id) return null;
138
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
139
+ for (const objectItem of objects) {
140
+ if (objectItem?.objectType !== "api-registry") continue;
141
+ const rows = Array.isArray(objectItem.rows) ? objectItem.rows : [];
142
+ const match = rows.find(
143
+ (r) => String(r?.integrationId || "").trim() === id
144
+ || String(r?.id || "").trim() === id
145
+ || String(r?.Name || "").trim() === id
146
+ );
147
+ if (match) return match;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ async function runServerlessScheduler({
153
+ workspaceConfig,
154
+ row,
155
+ runId,
156
+ ranAt,
157
+ workspaceId,
158
+ objectId,
159
+ sandboxName,
160
+ runtime,
161
+ adapterId,
162
+ agentHost,
163
+ command,
164
+ instructions,
165
+ timeoutMs,
166
+ networkAllow,
167
+ allowList,
168
+ envRefSlugs,
169
+ envRefsResolved,
170
+ envRefsMissing
171
+ }) {
172
+ const registryId = String(row.schedulerRegistryId || "").trim();
173
+ if (!registryId) {
174
+ return {
175
+ ok: false,
176
+ exitCode: null,
177
+ durationMs: 0,
178
+ stdout: "",
179
+ stderr: "",
180
+ error: "schedulerRegistryId is required when runLocality is serverless",
181
+ adapterMeta: { locality: "serverless", mode: "registry-delegation", registryId: null }
182
+ };
183
+ }
184
+
185
+ const registryRecord = findRegistryRecord(workspaceConfig, registryId);
186
+ if (!registryRecord) {
187
+ return {
188
+ ok: false,
189
+ exitCode: null,
190
+ durationMs: 0,
191
+ stdout: "",
192
+ stderr: "",
193
+ error: `no API Registry row for integrationId ${registryId}`,
194
+ adapterMeta: { locality: "serverless", mode: "registry-delegation", registryId }
195
+ };
196
+ }
197
+
198
+ let url;
199
+ try {
200
+ url = buildSchedulerUrl(registryRecord);
201
+ } catch (err) {
202
+ return {
203
+ ok: false,
204
+ exitCode: null,
205
+ durationMs: 0,
206
+ stdout: "",
207
+ stderr: "",
208
+ error: err.message || "invalid scheduler URL",
209
+ adapterMeta: { locality: "serverless", registryId }
210
+ };
211
+ }
212
+
213
+ let method = normalizeMethod(registryRecord.method);
214
+ if (!["POST", "PUT", "PATCH"].includes(method)) {
215
+ method = "POST";
216
+ }
217
+
218
+ const authRef = registryRecord.authRef || registryRecord.integrationId;
219
+ const secretEntry = readServerSecret(authRef);
220
+ const secret = secretEntry?.value || "";
221
+
222
+ const outboundTimeout = Math.min(Math.max(timeoutMs, 1000), 120000);
223
+ const startedAt = Date.now();
224
+ const controller = new AbortController();
225
+ const timer = setTimeout(() => controller.abort(), outboundTimeout);
226
+
227
+ const payloadBody = {
228
+ kind: "growthub-sandbox-run-v1",
229
+ runId,
230
+ ranAt,
231
+ workspaceId: workspaceId || null,
232
+ runLocality: "serverless",
233
+ objectId,
234
+ name: sandboxName,
235
+ sandbox: {
236
+ runtime,
237
+ adapter: adapterId,
238
+ agentHost: agentHost || null,
239
+ lifecycleStatus: String(row.lifecycleStatus || "draft").trim().toLowerCase() === "live" ? "live" : "draft",
240
+ version: row.version ?? "",
241
+ instructions,
242
+ command,
243
+ timeoutMs,
244
+ networkAllow,
245
+ allowList,
246
+ envRefSlugs,
247
+ envRefsResolved,
248
+ envRefsMissing
249
+ }
250
+ };
251
+
252
+ try {
253
+ const response = await fetch(url, {
254
+ method,
255
+ headers: {
256
+ accept: "application/json, text/plain;q=0.9,*/*;q=0.8",
257
+ "content-type": "application/json",
258
+ ...buildAuthHeaders(registryRecord, secret)
259
+ },
260
+ body: JSON.stringify(payloadBody),
261
+ signal: controller.signal
262
+ });
263
+ const durationMs = Date.now() - startedAt;
264
+ const contentType = response.headers.get("content-type") || "";
265
+ const rawPayload = contentType.includes("application/json") ? await response.json() : await response.text();
266
+
267
+ if (typeof rawPayload === "string") {
268
+ return {
269
+ ok: response.ok,
270
+ exitCode: response.ok ? 0 : 1,
271
+ durationMs,
272
+ stdout: rawPayload,
273
+ stderr: "",
274
+ error: response.ok ? undefined : `HTTP ${response.status}`,
275
+ adapterMeta: {
276
+ locality: "serverless",
277
+ registryId,
278
+ url,
279
+ httpStatus: response.status,
280
+ schedulerMethod: method
281
+ }
282
+ };
283
+ }
284
+
285
+ const stdout = typeof rawPayload.stdout === "string"
286
+ ? rawPayload.stdout
287
+ : JSON.stringify(rawPayload.result ?? rawPayload, null, 2);
288
+ const stderr = typeof rawPayload.stderr === "string" ? rawPayload.stderr : "";
289
+ let exitCode;
290
+ if (typeof rawPayload.exitCode === "number") {
291
+ exitCode = rawPayload.exitCode;
292
+ } else if (response.ok && rawPayload.ok !== false) {
293
+ exitCode = 0;
294
+ } else {
295
+ exitCode = 1;
296
+ }
297
+ const innerOk = response.ok && rawPayload.ok !== false && exitCode === 0;
298
+
299
+ return {
300
+ ok: innerOk,
301
+ exitCode,
302
+ durationMs: typeof rawPayload.durationMs === "number" ? rawPayload.durationMs : durationMs,
303
+ stdout,
304
+ stderr,
305
+ error: rawPayload.error || (!innerOk ? `HTTP ${response.status}` : undefined),
306
+ adapterMeta: {
307
+ locality: "serverless",
308
+ registryId,
309
+ url,
310
+ httpStatus: response.status,
311
+ schedulerMethod: method
312
+ }
313
+ };
314
+ } catch (error) {
315
+ const durationMs = Date.now() - startedAt;
316
+ return {
317
+ ok: false,
318
+ exitCode: null,
319
+ durationMs,
320
+ stdout: "",
321
+ stderr: "",
322
+ error: error.name === "AbortError" ? `scheduler request timed out after ${outboundTimeout}ms` : (error.message || "scheduler fetch failed"),
323
+ adapterMeta: { locality: "serverless", registryId, url, aborted: error.name === "AbortError" }
324
+ };
325
+ } finally {
326
+ clearTimeout(timer);
327
+ }
328
+ }
329
+
330
+ function buildRunResponse({
331
+ runId,
332
+ ranAt,
333
+ runLocality,
334
+ schedulerRegistryId,
335
+ runtime,
336
+ adapterId,
337
+ agentHost,
338
+ command,
339
+ instructions,
340
+ lifecycleStatus,
341
+ version,
342
+ envRefsResolved,
343
+ envRefsMissing,
344
+ networkAllow,
345
+ allowList,
346
+ result,
347
+ timeoutMs
348
+ }) {
349
+ return {
350
+ runId,
351
+ ranAt,
352
+ runLocality,
353
+ schedulerRegistryId: schedulerRegistryId ? String(schedulerRegistryId).trim() : null,
354
+ runtime,
355
+ adapter: adapterId,
356
+ agentHost: agentHost || null,
357
+ lifecycleStatus,
358
+ version,
359
+ instructions,
360
+ command,
361
+ timeoutMs,
362
+ exitCode: result.exitCode,
363
+ durationMs: result.durationMs,
364
+ stdout: result.stdout,
365
+ stderr: result.stderr,
366
+ error: result.error || undefined,
367
+ envRefsResolved,
368
+ envRefsMissing,
369
+ networkAllow,
370
+ allowList,
371
+ adapterMeta: result.adapterMeta || null
372
+ };
373
+ }
374
+
375
+ function findSandboxRow(workspaceConfig, objectId, name) {
376
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
377
+ const object = objects.find((entry) => entry?.id === objectId && entry?.objectType === "sandbox-environment");
378
+ if (!object) return { object: null, row: null, rowIndex: -1 };
379
+ const wantedName = String(name || "").trim();
380
+ const rows = Array.isArray(object.rows) ? object.rows : [];
381
+ const rowIndex = rows.findIndex((row) => String(row?.Name || "").trim() === wantedName);
382
+ if (rowIndex === -1) return { object, row: null, rowIndex: -1 };
383
+ return { object, row: rows[rowIndex], rowIndex };
384
+ }
385
+
386
+ async function GET(request) {
387
+ const { searchParams } = new URL(request.url);
388
+ const objectId = String(searchParams.get("objectId") || "").trim();
389
+ const name = String(searchParams.get("name") || "").trim();
390
+ if (!objectId || !name) {
391
+ return NextResponse.json({ ok: false, error: "objectId and name are required" }, { status: 400 });
392
+ }
393
+
394
+ const sourceId = sandboxRunSourceId(objectId, name);
395
+ if (!sourceId) {
396
+ return NextResponse.json({ ok: false, error: "could not derive sandbox sourceId" }, { status: 400 });
397
+ }
398
+
399
+ const existing = await readWorkspaceSourceRecords(sourceId);
400
+ const records = Array.isArray(existing?.records) ? existing.records : [];
401
+ return NextResponse.json({
402
+ ok: true,
403
+ sourceId,
404
+ recordCount: records.length,
405
+ records: records.slice(-25).reverse()
406
+ });
407
+ }
408
+
409
+ async function POST(request) {
410
+ let body;
411
+ try {
412
+ body = await request.json();
413
+ } catch {
414
+ return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
415
+ }
416
+
417
+ const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
418
+ const name = typeof body?.name === "string" ? body.name.trim() : "";
419
+ if (!objectId || !name) {
420
+ return NextResponse.json({ ok: false, error: "objectId and name are required" }, { status: 400 });
421
+ }
422
+
423
+ const workspaceConfig = await readWorkspaceConfig();
424
+ const { object, row, rowIndex } = findSandboxRow(workspaceConfig, objectId, name);
425
+ if (!object) {
426
+ return NextResponse.json({ ok: false, error: `no sandbox-environment object with id ${objectId}` }, { status: 404 });
427
+ }
428
+ if (!row) {
429
+ return NextResponse.json({ ok: false, error: `no sandbox row named ${name} in object ${objectId}` }, { status: 404 });
430
+ }
431
+
432
+ const runLocality = normalizeRunLocality(row);
433
+ const runtime = KNOWN_SANDBOX_RUNTIMES.includes(row.runtime) ? row.runtime : "node";
434
+ let adapterId = (typeof row.adapter === "string" && row.adapter.trim()) ? row.adapter.trim() : DEFAULT_SANDBOX_ADAPTER;
435
+ const agentHost = typeof row.agentHost === "string" ? row.agentHost.trim() : "";
436
+ const schedulerRegistryId = typeof row.schedulerRegistryId === "string" ? row.schedulerRegistryId.trim() : "";
437
+ const networkAllow = coerceBoolean(row.networkAllow);
438
+ const allowList = parseSandboxAllowList(row.allowList);
439
+ const envRefSlugs = parseSandboxEnvRefs(row.envRefs);
440
+ const command = typeof row.command === "string" ? row.command : "";
441
+ const instructions = typeof row.instructions === "string" ? row.instructions.trim() : "";
442
+ const agentCommand = instructions
443
+ ? `Instructions:\n${instructions}\n\nPrompt:\n${command}`
444
+ : command;
445
+ const lifecycleStatus = String(row.lifecycleStatus || "draft").trim().toLowerCase() === "live" ? "live" : "draft";
446
+ const version = row.version ?? "";
447
+ const requestedTimeout = Number(row.timeoutMs);
448
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
449
+ ? Math.min(requestedTimeout, SANDBOX_MAX_TIMEOUT_MS)
450
+ : SANDBOX_DEFAULT_TIMEOUT_MS;
451
+
452
+ if (runLocality === "serverless" && adapterId === "local-agent-host") {
453
+ return NextResponse.json({
454
+ ok: false,
455
+ error: "`local-agent-host` applies only when runLocality is local. Switch run locality or choose a process adapter for serverless delegation."
456
+ }, { status: 400 });
457
+ }
458
+
459
+ const env = {};
460
+ const envRefsResolved = [];
461
+ const envRefsMissing = [];
462
+ for (const slug of envRefSlugs) {
463
+ const resolved = readServerSecret(slug);
464
+ if (resolved) {
465
+ env[resolved.key] = resolved.value;
466
+ envRefsResolved.push(slug);
467
+ } else {
468
+ envRefsMissing.push(slug);
469
+ }
470
+ }
471
+
472
+ const runId = `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
473
+ const ranAt = new Date().toISOString();
474
+
475
+ let result;
476
+ let effectiveAdapterId = adapterId;
477
+
478
+ if (runLocality === "serverless") {
479
+ effectiveAdapterId = "serverless";
480
+ result = await runServerlessScheduler({
481
+ workspaceConfig,
482
+ row,
483
+ runId,
484
+ ranAt,
485
+ workspaceId: workspaceConfig?.id ?? null,
486
+ objectId,
487
+ sandboxName: row.Name || name,
488
+ runtime,
489
+ adapterId,
490
+ agentHost,
491
+ command,
492
+ instructions,
493
+ timeoutMs,
494
+ networkAllow,
495
+ allowList,
496
+ envRefSlugs,
497
+ envRefsResolved,
498
+ envRefsMissing
499
+ });
500
+ } else {
501
+ await ensureSandboxAdaptersLoaded();
502
+ const adapter = getSandboxAdapter(adapterId);
503
+ if (!adapter) {
504
+ return NextResponse.json({
505
+ ok: false,
506
+ error: `sandbox adapter not registered: ${adapterId}`,
507
+ hint: "Drop a file under lib/adapters/sandboxes/adapters/ that calls registerSandboxAdapter()"
508
+ }, { status: 404 });
509
+ }
510
+ if (Array.isArray(adapter.supportedRuntimes) && adapter.supportedRuntimes.length && !adapter.supportedRuntimes.includes(runtime)) {
511
+ return NextResponse.json({
512
+ ok: false,
513
+ error: `adapter ${adapterId} does not support runtime ${runtime}`,
514
+ supportedRuntimes: adapter.supportedRuntimes
515
+ }, { status: 400 });
516
+ }
517
+
518
+ const workdir = await fs.mkdtemp(path.join(os.tmpdir(), "growthub-sandbox-"));
519
+ try {
520
+ result = await adapter.run({
521
+ runId,
522
+ name: row.Name || name,
523
+ runtime,
524
+ agentHost,
525
+ command: adapterId === "local-agent-host" ? agentCommand : command,
526
+ timeoutMs,
527
+ networkAllow,
528
+ allowList,
529
+ env,
530
+ envRefSlugs,
531
+ envRefsMissing,
532
+ workdir,
533
+ ranAt
534
+ });
535
+ } catch (error) {
536
+ result = {
537
+ ok: false,
538
+ exitCode: null,
539
+ durationMs: 0,
540
+ stdout: "",
541
+ stderr: "",
542
+ error: error?.message || "adapter threw",
543
+ adapterMeta: { adapter: adapterId }
544
+ };
545
+ } finally {
546
+ fs.rm(workdir, { recursive: true, force: true }).catch(() => {});
547
+ }
548
+ }
549
+
550
+ const response = buildRunResponse({
551
+ runId,
552
+ ranAt,
553
+ runLocality,
554
+ schedulerRegistryId: runLocality === "serverless" ? schedulerRegistryId : null,
555
+ runtime,
556
+ adapterId: effectiveAdapterId,
557
+ agentHost,
558
+ command,
559
+ instructions,
560
+ lifecycleStatus,
561
+ version,
562
+ envRefsResolved,
563
+ envRefsMissing,
564
+ networkAllow,
565
+ allowList,
566
+ result,
567
+ timeoutMs
568
+ });
569
+
570
+ const sourceId = sandboxRunSourceId(objectId, row.Name || name);
571
+ const persistence = describePersistenceMode();
572
+ const status = response.exitCode === 0 && !response.error ? "connected" : "failed";
573
+
574
+ let persisted = false;
575
+ let persistError = null;
576
+
577
+ if (sourceId && persistence.canSave) {
578
+ try {
579
+ const existing = await readWorkspaceSourceRecords(sourceId);
580
+ const priorRecords = Array.isArray(existing?.records) ? existing.records : [];
581
+ const nextRecords = [...priorRecords, response].slice(-50);
582
+ await writeWorkspaceSourceRecords(sourceId, nextRecords, {
583
+ integrationId: sourceId,
584
+ fetchedAt: ranAt
585
+ });
586
+ persisted = true;
587
+ } catch (error) {
588
+ persistError = error?.message || "failed to persist sandbox run record";
589
+ }
590
+
591
+ try {
592
+ const compactResponse = JSON.stringify(response, null, 2);
593
+ const sourceIdValue = sourceId || "";
594
+ const objects = Array.isArray(workspaceConfig.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
595
+ const nextObjects = objects.map((entry) => {
596
+ if (entry.id !== object.id) return entry;
597
+ const rows = Array.isArray(entry.rows) ? entry.rows : [];
598
+ const nextRows = rows.map((existingRow, index) => {
599
+ if (index !== rowIndex) return existingRow;
600
+ return {
601
+ ...existingRow,
602
+ status,
603
+ lastTested: ranAt,
604
+ lastRunId: runId,
605
+ lastSourceId: sourceIdValue,
606
+ lastResponse: compactResponse
607
+ };
608
+ });
609
+ return { ...entry, rows: nextRows };
610
+ });
611
+ await writeWorkspaceConfig({
612
+ dataModel: { ...(workspaceConfig.dataModel || {}), objects: nextObjects }
613
+ });
614
+ } catch (error) {
615
+ persistError = persistError || error?.message || "failed to stamp row status";
616
+ }
617
+ }
618
+
619
+ return NextResponse.json({
620
+ ok: response.exitCode === 0 && !response.error,
621
+ status,
622
+ runId,
623
+ adapter: effectiveAdapterId,
624
+ runtime,
625
+ exitCode: response.exitCode,
626
+ durationMs: response.durationMs,
627
+ persisted,
628
+ persistError,
629
+ sourceId,
630
+ response
631
+ });
632
+ }
633
+
634
+ export { GET, POST };