@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.
- package/README.md +17 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-adapters/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +634 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +1349 -222
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1048 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1540 -433
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +32 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapter-loader.js +58 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/README.md +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +284 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +194 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +33 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +113 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +211 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +126 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +16 -0
- package/dist/index.js +1764 -40677
- package/package.json +2 -2
|
@@ -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 };
|