@growthub/cli 0.13.9 → 0.14.1

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 (39) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +31 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +227 -5
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +17 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +6 -3
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +200 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +414 -9
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +339 -77
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +2 -2
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +229 -9
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +224 -14
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +2 -4
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +317 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-response-profile.js +207 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/creation-error-recovery.js +103 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +100 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +69 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +215 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-write.js +67 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-upgrade.js +89 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +11 -4
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +8 -1
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +30 -1
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +200 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GET /api/workspace/env-status
3
+ *
4
+ * The honest, secret-safe auth-readiness signal the creation cockpit needs.
5
+ * Returns the referenced auth/env ref SLUGS that currently resolve to a value
6
+ * in the server runtime (process.env) — so the api-registry drawer can mark
7
+ * "auth configured" from real runtime truth instead of guessing. Never returns,
8
+ * logs, or hashes a value.
9
+ */
10
+
11
+ import { NextResponse } from "next/server";
12
+ import { readWorkspaceConfig } from "@/lib/workspace-config";
13
+ import { computeConfiguredEnvRefs, listPersistenceAdapterReadiness } from "@/lib/env-status";
14
+
15
+ async function GET() {
16
+ let workspaceConfig = {};
17
+ try {
18
+ workspaceConfig = await readWorkspaceConfig();
19
+ } catch {
20
+ workspaceConfig = {};
21
+ }
22
+ const configuredEnvRefs = computeConfiguredEnvRefs(workspaceConfig, process.env);
23
+ const persistenceAdapters = listPersistenceAdapterReadiness(process.env);
24
+ return NextResponse.json({
25
+ kind: "growthub-env-status-v1",
26
+ configuredEnvRefs,
27
+ persistenceAdapters,
28
+ });
29
+ }
30
+
31
+ export { GET };
@@ -41,9 +41,168 @@ import {
41
41
  buildApplyReceipt,
42
42
  upsertHelperThreadRow,
43
43
  } from "@/lib/workspace-helper-apply";
44
+ import { RESOLVER_PROPOSAL_TYPE, buildResolverProposal, validateResolverProposal } from "@/lib/workspace-resolver-proposal";
45
+ import { writeResolverProposalFile } from "@/lib/server-resolver-write";
46
+ import {
47
+ SWARM_PROPOSAL_TYPES,
48
+ SWARM_RUN_RESUME_PROPOSAL_TYPE,
49
+ SWARM_WORKFLOWS_OBJECT_ID,
50
+ validateSwarmRunProposal,
51
+ buildSandboxRowFromSwarmProposal,
52
+ deriveHelperWidgetCausationState,
53
+ upsertSwarmRunRow,
54
+ findSwarmRunRows,
55
+ summarizeSwarmRunProposal,
56
+ } from "@/lib/workspace-swarm-proposal";
44
57
 
45
58
  const HELPER_APPLY_SOURCE_KEY = "helper:apply:receipts";
46
59
 
60
+ function findRegistryRow(config, integrationId) {
61
+ const id = String(integrationId || "").trim();
62
+ if (!id) return null;
63
+ const objects = Array.isArray(config?.dataModel?.objects) ? config.dataModel.objects : [];
64
+ for (const object of objects) {
65
+ if (object?.objectType !== "api-registry") continue;
66
+ const row = (Array.isArray(object.rows) ? object.rows : []).find((candidate) =>
67
+ String(candidate?.integrationId || "").trim() === id
68
+ );
69
+ if (row) return row;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function findDataSourceForRegistry(config, integrationId) {
75
+ const id = String(integrationId || "").trim();
76
+ if (!id) return null;
77
+ const objects = Array.isArray(config?.dataModel?.objects) ? config.dataModel.objects : [];
78
+ for (const object of objects) {
79
+ if (object?.objectType !== "data-source") continue;
80
+ const row = (Array.isArray(object.rows) ? object.rows : []).find((candidate) =>
81
+ String(candidate?.registryId || "").trim() === id
82
+ );
83
+ if (row) return { object, row };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function inferRootPathFromLastResponse(lastResponse) {
89
+ if (!lastResponse) return "";
90
+ let parsed = lastResponse;
91
+ if (typeof lastResponse === "string") {
92
+ try { parsed = JSON.parse(lastResponse); } catch { return ""; }
93
+ }
94
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
95
+ for (const key of ["records", "items", "data", "results", "capabilities"]) {
96
+ if (Array.isArray(parsed[key])) return key;
97
+ }
98
+ return "";
99
+ }
100
+
101
+ function normalizeResolverProposal(proposal, config) {
102
+ if (proposal?.type !== RESOLVER_PROPOSAL_TYPE || String(proposal?.code || "").trim()) return proposal;
103
+ const integrationId = proposal?.payload?.integrationId;
104
+ const registryRow = findRegistryRow(config, integrationId);
105
+ if (!registryRow) return proposal;
106
+ const generated = buildResolverProposal({
107
+ integrationId: registryRow.integrationId,
108
+ baseUrl: registryRow.baseUrl,
109
+ endpoint: registryRow.endpoint,
110
+ method: registryRow.method,
111
+ authRef: registryRow.authRef,
112
+ rootPath: proposal?.payload?.rootPath || inferRootPathFromLastResponse(registryRow.lastResponse),
113
+ entityType: proposal?.payload?.entityType || registryRow.entityTypes || "records",
114
+ });
115
+ return {
116
+ ...generated,
117
+ rationale: proposal.rationale || generated.rationale,
118
+ confidence: proposal.confidence || generated.confidence,
119
+ };
120
+ }
121
+
122
+ function normalizeDataModelObjectProposal(proposal, config, fallbackIntegrationId = "") {
123
+ if (proposal?.type !== "dataModel.object.update" || proposal?.payload?.id) return proposal;
124
+ const integrationId = proposal?.payload?.registryId || proposal?.payload?.integrationId || fallbackIntegrationId;
125
+ const target = findDataSourceForRegistry(config, integrationId);
126
+ if (!target?.object) return proposal;
127
+ return {
128
+ ...proposal,
129
+ payload: {
130
+ ...proposal.payload,
131
+ id: target.object.id,
132
+ sourceId: target.object.sourceId || target.row?.sourceId || target.object.binding?.sourceId || "",
133
+ binding: {
134
+ ...(target.object.binding || {}),
135
+ sourceStorage: target.object.binding?.sourceStorage || target.row?.sourceStorage || "workspace-source-records",
136
+ sourceId: target.object.binding?.sourceId || target.row?.sourceId || target.object.sourceId || "",
137
+ registryId: target.object.binding?.registryId || integrationId,
138
+ },
139
+ },
140
+ };
141
+ }
142
+
143
+ function normalizeApplyProposal(proposal, config, context = {}) {
144
+ return normalizeDataModelObjectProposal(normalizeResolverProposal(proposal, config), config, context.integrationId);
145
+ }
146
+
147
+ /**
148
+ * Swarm lane (SWARM_RUN_CONTRACT_V1) — normalize a validated swarm proposal
149
+ * into the next workspace config plus an artifact target the sidecar cockpit
150
+ * can open. The model's intent payload is reduced through
151
+ * buildDefaultAgentSwarmGraph inside buildSandboxRowFromSwarmProposal — final
152
+ * graph JSON from the model is never trusted verbatim. Nothing executes here;
153
+ * runs stay behind POST /api/workspace/sandbox-run.
154
+ *
155
+ * Returns { ok, config, artifact, summary, error? }.
156
+ */
157
+ function normalizeSwarmRunProposal(proposal, workspaceConfig) {
158
+ const validation = validateSwarmRunProposal(proposal);
159
+ if (!validation.ok) {
160
+ return { ok: false, config: workspaceConfig, artifact: null, summary: "", error: validation.error };
161
+ }
162
+
163
+ if (proposal.type === SWARM_RUN_RESUME_PROPOSAL_TYPE) {
164
+ const name = String(proposal.payload?.name || "").trim();
165
+ const matches = findSwarmRunRows(workspaceConfig, { name });
166
+ if (matches.length === 0) {
167
+ return {
168
+ ok: false,
169
+ config: workspaceConfig,
170
+ artifact: null,
171
+ summary: "",
172
+ error: `no governed swarm workflow named "${name}" to resume`,
173
+ };
174
+ }
175
+ // Resume is a governed pointer, not an execution: the receipt records the
176
+ // intent and the cockpit re-launches through sandbox-run.
177
+ return {
178
+ ok: true,
179
+ config: workspaceConfig,
180
+ artifact: { surface: "swarm-run", objectId: matches[0].objectId, name: matches[0].row.Name },
181
+ summary: summarizeSwarmRunProposal(proposal),
182
+ };
183
+ }
184
+
185
+ const helperState = deriveHelperWidgetCausationState(workspaceConfig);
186
+ if (!helperState.ready) {
187
+ return {
188
+ ok: false,
189
+ config: workspaceConfig,
190
+ artifact: null,
191
+ summary: "",
192
+ error: helperState.guidance,
193
+ };
194
+ }
195
+
196
+ const row = buildSandboxRowFromSwarmProposal(workspaceConfig, proposal);
197
+ const nextConfig = upsertSwarmRunRow(workspaceConfig, row);
198
+ return {
199
+ ok: true,
200
+ config: nextConfig,
201
+ artifact: { surface: "swarm-run", objectId: SWARM_WORKFLOWS_OBJECT_ID, name: row.Name },
202
+ summary: summarizeSwarmRunProposal(proposal),
203
+ };
204
+ }
205
+
47
206
  async function POST(request) {
48
207
  let body;
49
208
  try {
@@ -78,7 +237,61 @@ async function POST(request) {
78
237
  const skipped = [];
79
238
  let workingConfig = currentConfig;
80
239
 
81
- for (const proposal of body.proposals) {
240
+ // Resolver-file lane (AWaC: server file, NOT a config PATCH field). Handled
241
+ // separately so it never touches writeWorkspaceConfig and never widens the
242
+ // PATCH allowlist. Gated by filesystem/read-only; emits a receipt either way.
243
+ const fallbackIntegrationId = body.proposals
244
+ .map((proposal) => proposal?.payload?.integrationId || proposal?.payload?.registryId)
245
+ .find((value) => String(value || "").trim());
246
+ const normalizedProposals = body.proposals.map((proposal) =>
247
+ normalizeApplyProposal(proposal, currentConfig, { integrationId: fallbackIntegrationId })
248
+ );
249
+ const resolverProposals = normalizedProposals.filter((p) => p?.type === RESOLVER_PROPOSAL_TYPE);
250
+ const swarmProposals = normalizedProposals.filter((p) => SWARM_PROPOSAL_TYPES.includes(p?.type));
251
+ const configProposals = normalizedProposals.filter(
252
+ (p) => p?.type !== RESOLVER_PROPOSAL_TYPE && !SWARM_PROPOSAL_TYPES.includes(p?.type)
253
+ );
254
+
255
+ for (const proposal of resolverProposals) {
256
+ const validation = validateResolverProposal(proposal);
257
+ if (!validation.ok) {
258
+ skipped.push({ proposal, reason: validation.error || "invalid resolver proposal" });
259
+ continue;
260
+ }
261
+ try {
262
+ const result = await writeResolverProposalFile(proposal);
263
+ applied.push({
264
+ ...buildApplyReceipt(proposal, appliedAt, reviewedBy, sessionId),
265
+ resolverPath: result.path,
266
+ resolverFilename: result.filename,
267
+ });
268
+ } catch (err) {
269
+ if (err?.code === "WORKSPACE_PERSISTENCE_READ_ONLY") {
270
+ skipped.push({ proposal, reason: `read-only runtime — ${err.guidance || "resolver not written"}` });
271
+ } else {
272
+ skipped.push({ proposal, reason: err?.message || "resolver write failed" });
273
+ }
274
+ }
275
+ }
276
+
277
+ // Swarm lane — governed sandbox-environment rows in the EXISTING dataModel
278
+ // patch field. Apply creates/updates the row; execution stays behind
279
+ // POST /api/workspace/sandbox-run, launched explicitly from the cockpit.
280
+ for (const proposal of swarmProposals) {
281
+ const result = normalizeSwarmRunProposal(proposal, workingConfig);
282
+ if (!result.ok) {
283
+ skipped.push({ proposal, reason: result.error || "invalid swarm proposal" });
284
+ continue;
285
+ }
286
+ workingConfig = result.config;
287
+ applied.push({
288
+ ...buildApplyReceipt({ ...proposal, affectedField: "dataModel" }, appliedAt, reviewedBy, sessionId),
289
+ artifact: result.artifact,
290
+ summary: result.summary,
291
+ });
292
+ }
293
+
294
+ for (const proposal of configProposals) {
82
295
  if (
83
296
  !proposal ||
84
297
  typeof proposal.type !== "string" ||
@@ -113,7 +326,13 @@ async function POST(request) {
113
326
  // Patch — collect every affected field from accepted proposals AND
114
327
  // append the thread row update (so the user-visible Helper Threads object
115
328
  // refreshes in the same atomic write as the proposed mutations).
116
- const mutatingApplied = applied.filter((r) => r.type !== "explain.object");
329
+ // resolver.create writes a server file (affectedField "server-file"), so it
330
+ // must NOT contribute a field to the config PATCH — exclude it here.
331
+ // swarm.run.resume is a governed pointer (no config mutation) — exclude it
332
+ // alongside explain.object so it never forces a config write on its own.
333
+ const mutatingApplied = applied.filter(
334
+ (r) => r.type !== "explain.object" && r.type !== SWARM_RUN_RESUME_PROPOSAL_TYPE && r.affectedField !== "server-file"
335
+ );
117
336
 
118
337
  // Upsert the thread row so audit history reflects this apply turn even
119
338
  // when nothing mutated (all skipped / explain-only) and even when the
@@ -129,7 +348,7 @@ async function POST(request) {
129
348
  try {
130
349
  const existingRows = (workingConfig?.dataModel?.objects || []).find((o) => o?.id === "helper-threads")?.rows || [];
131
350
  const existingRow = existingRows.find((r) => r?.id === threadId) || {};
132
- const firstProposal = body.proposals?.[0];
351
+ const firstProposal = normalizedProposals?.[0];
133
352
  const seedTitle = existingRow.title
134
353
  || (firstProposal?.rationale ? String(firstProposal.rationale).slice(0, 72) : "Helper thread");
135
354
 
@@ -183,6 +402,9 @@ async function POST(request) {
183
402
  "dataModel.row.add": "create_object",
184
403
  "repair.binding": "repair",
185
404
  "explain.object": "explain",
405
+ "swarm.run.propose": "swarm",
406
+ "swarm.workflow.save": "swarm",
407
+ "swarm.run.resume": "swarm",
186
408
  };
187
409
  const proposalIntent = firstProposal?.type ? TYPE_TO_INTENT_HINT[firstProposal.type] : null;
188
410
  const safeIntent = existingRow.intent || proposalIntent || "explain";
@@ -192,7 +414,7 @@ async function POST(request) {
192
414
  intent: safeIntent,
193
415
  prompt: existingRow.prompt || "",
194
416
  summary: existingRow.summary || "",
195
- proposals: existingRow.proposals || body.proposals || [],
417
+ proposals: existingRow.proposals || normalizedProposals || [],
196
418
  warnings: existingRow.warnings || [],
197
419
  receipts: existingRow.receipts || null,
198
420
  model: existingRow.model || "external-apply",
@@ -207,7 +429,7 @@ async function POST(request) {
207
429
  affectedField: a.affectedField,
208
430
  rationale: a.rationale,
209
431
  confidence: a.confidence,
210
- payload: body.proposals?.[idx]?.payload ?? null,
432
+ payload: normalizedProposals?.[idx]?.payload ?? null,
211
433
  })),
212
434
  lastSkipped: skipped.map((s) => ({
213
435
  type: s.proposal?.type,
@@ -70,6 +70,7 @@ const VALID_INTENTS = [
70
70
  "edit_view",
71
71
  "repair",
72
72
  "explain",
73
+ "swarm",
73
74
  ];
74
75
 
75
76
  const HELPER_SOURCE_KEY_PREFIX = "helper";
@@ -338,6 +338,8 @@ async function runServerlessScheduler({
338
338
  function buildRunResponse({
339
339
  runId,
340
340
  ranAt,
341
+ objectId,
342
+ name,
341
343
  runLocality,
342
344
  schedulerRegistryId,
343
345
  runtime,
@@ -359,6 +361,10 @@ function buildRunResponse({
359
361
  const base = {
360
362
  runId,
361
363
  ranAt,
364
+ // Identity travels with the persisted record so run-console consumers
365
+ // (lineage, swarm projection title) don't depend on the row context.
366
+ objectId: objectId ? String(objectId).trim() : undefined,
367
+ name: name ? String(name).trim() : undefined,
362
368
  runLocality,
363
369
  schedulerRegistryId: schedulerRegistryId ? String(schedulerRegistryId).trim() : null,
364
370
  runtime,
@@ -439,14 +445,7 @@ async function GET(request) {
439
445
  });
440
446
  }
441
447
 
442
- async function POST(request) {
443
- let body;
444
- try {
445
- body = await request.json();
446
- } catch {
447
- return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
448
- }
449
-
448
+ async function executeSandboxRun(body, { emit } = {}) {
450
449
  const objectId = typeof body?.objectId === "string" ? body.objectId.trim() : "";
451
450
  const name = typeof body?.name === "string" ? body.name.trim() : "";
452
451
  const useDraft = body?.useDraft === true;
@@ -580,7 +579,8 @@ async function POST(request) {
580
579
  instructions,
581
580
  command,
582
581
  timeoutMs,
583
- sandboxName: rowForRun.Name || name
582
+ sandboxName: rowForRun.Name || name,
583
+ onEvent: emit
584
584
  }
585
585
  });
586
586
  if (graphResult !== null) {
@@ -665,6 +665,8 @@ async function POST(request) {
665
665
  const response = buildRunResponse({
666
666
  runId,
667
667
  ranAt,
668
+ objectId,
669
+ name: rowForRun.Name || name,
668
670
  runLocality,
669
671
  schedulerRegistryId: runLocality === "serverless" ? schedulerRegistryId : null,
670
672
  runtime,
@@ -754,4 +756,63 @@ async function POST(request) {
754
756
  });
755
757
  }
756
758
 
759
+ async function POST(request) {
760
+ const accept = request.headers.get("accept") || "";
761
+ let body;
762
+ try {
763
+ body = await request.json();
764
+ } catch {
765
+ return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
766
+ }
767
+
768
+ const wantsStream = body?.stream === true || accept.includes("application/x-ndjson");
769
+ if (!wantsStream) {
770
+ return executeSandboxRun(body);
771
+ }
772
+
773
+ const encoder = new TextEncoder();
774
+ const stream = new ReadableStream({
775
+ start(controller) {
776
+ const emit = (event) => {
777
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
778
+ };
779
+ emit({
780
+ kind: "growthub-sandbox-run-delta-v1",
781
+ type: "sandbox-run.accepted",
782
+ emittedAt: new Date().toISOString(),
783
+ objectId: typeof body?.objectId === "string" ? body.objectId.trim() : "",
784
+ name: typeof body?.name === "string" ? body.name.trim() : ""
785
+ });
786
+ executeSandboxRun(body, { emit })
787
+ .then(async (response) => {
788
+ const finalPayload = await response.json().catch(() => ({ ok: false, error: "stream final payload unreadable" }));
789
+ emit({
790
+ kind: "growthub-sandbox-run-delta-v1",
791
+ type: "sandbox-run.final",
792
+ emittedAt: new Date().toISOString(),
793
+ status: response.status,
794
+ payload: finalPayload
795
+ });
796
+ })
797
+ .catch((error) => {
798
+ emit({
799
+ kind: "growthub-sandbox-run-delta-v1",
800
+ type: "sandbox-run.final",
801
+ emittedAt: new Date().toISOString(),
802
+ status: 500,
803
+ payload: { ok: false, error: error?.message || "sandbox run failed" }
804
+ });
805
+ })
806
+ .finally(() => controller.close());
807
+ }
808
+ });
809
+
810
+ return new Response(stream, {
811
+ headers: {
812
+ "content-type": "application/x-ndjson; charset=utf-8",
813
+ "cache-control": "no-store"
814
+ }
815
+ });
816
+ }
817
+
757
818
  export { GET, POST };
@@ -51,6 +51,7 @@ export function WorkspaceActivationPanel({
51
51
  workspaceSourceRecords,
52
52
  metadataGraph,
53
53
  onOpenHelper,
54
+ onStepAction,
54
55
  compact = false,
55
56
  showLenses = false,
56
57
  }) {
@@ -136,7 +137,22 @@ export function WorkspaceActivationPanel({
136
137
  <span>{step.hint}</span>
137
138
  </p>
138
139
  ) : null}
139
- {step.href ? (
140
+ {step.action && onStepAction ? (
141
+ <a
142
+ href={`#${step.action}`}
143
+ className={
144
+ "workspace-activation-step-cta"
145
+ + (isNext ? " is-primary" : "")
146
+ }
147
+ onClick={(event) => {
148
+ event.preventDefault();
149
+ onStepAction(step);
150
+ }}
151
+ >
152
+ <span>{step.cta || (step.status === "complete" ? "Review" : "Open")}</span>
153
+ <ArrowRight size={12} aria-hidden="true" />
154
+ </a>
155
+ ) : step.href ? (
140
156
  <Link
141
157
  href={step.href}
142
158
  className={
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { createPortal } from "react-dom";
4
5
  import { X } from "lucide-react";
5
6
 
6
7
  const HELPER_SANDBOX_OBJECT_ID = "workspace-helper-sandbox";
@@ -13,7 +14,7 @@ const HELPER_AGENT_CHOICES = [
13
14
  { id: "gemini_local", label: "Gemini CLI (local)", body: "Uses Gemini CLI." },
14
15
  { id: "opencode_local", label: "OpenCode (local)", body: "Uses OpenCode on this machine." },
15
16
  { id: "pi_local", label: "Pi (local)", body: "Uses Pi on this machine." },
16
- { id: "qwen_code_local", label: "Qwen Code (local)", body: "Uses Qwen Code on this machine." },
17
+ { id: "qwen_local", label: "Qwen Code (local)", body: "Uses Qwen Code on this machine." },
17
18
  ];
18
19
 
19
20
  const HELPER_EXECUTION_ADAPTERS = [
@@ -185,7 +186,9 @@ function WorkspaceHelperSetupModal({ workspaceConfig, open, onClose, onSaved })
185
186
  }
186
187
  }
187
188
 
188
- return (
189
+ if (typeof document === "undefined") return null;
190
+
191
+ return createPortal((
189
192
  <div className="workspace-helper-setup-modal-backdrop" role="presentation">
190
193
  <div className="workspace-helper-setup-modal" role="dialog" aria-modal="true" aria-label="Set up workspace helper">
191
194
  <button type="button" className="workspace-helper-setup-modal-close" onClick={onClose} aria-label="Close setup">
@@ -342,7 +345,7 @@ function WorkspaceHelperSetupModal({ workspaceConfig, open, onClose, onSaved })
342
345
  </div>
343
346
  </div>
344
347
  </div>
345
- );
348
+ ), document.body);
346
349
  }
347
350
 
348
351
  export {