@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.
- package/README.md +17 -5
- 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/data-model/page.jsx +712 -54
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +55 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +2 -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/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-data-model.js +107 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +103 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +9 -0
- package/dist/index.js +41066 -1761
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @growthub/cli
|
|
2
2
|
|
|
3
|
-
`@growthub/cli` is the
|
|
3
|
+
`@growthub/cli` is the local control plane for Growthub Local and Agent Workspace as Code (AWaC).
|
|
4
4
|
|
|
5
|
-
It
|
|
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 };
|