@growthub/cli 0.13.9 → 0.14.0

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 (25) 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 +130 -5
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +17 -1
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +5 -2
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +200 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +396 -5
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +75 -55
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +2 -2
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +100 -6
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +176 -5
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +2 -4
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +317 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-response-profile.js +207 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/creation-error-recovery.js +103 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +100 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +63 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +215 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-write.js +67 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-upgrade.js +89 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +11 -4
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +8 -1
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +7 -1
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +200 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -1
  25. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Env Status V1 — the honest, secret-safe "which referenced env keys actually
3
+ * resolve right now" signal.
4
+ *
5
+ * The creation cockpit (api-registry drawer) cannot read process.env in the
6
+ * browser, so auth readiness must come from a server signal. This module is the
7
+ * pure core of `GET /api/workspace/env-status`: given the governed config and
8
+ * the runtime environment it returns the set of *referenced* auth/env ref slugs
9
+ * whose candidate keys resolve to a value — slugs only, never a value.
10
+ *
11
+ * Pure + env-injectable so it is deterministically testable.
12
+ */
13
+
14
+ import { describePostgresAdapter } from "./adapters/persistence/postgres.js";
15
+ import { describeQstashKvAdapter } from "./adapters/persistence/qstash-kv.js";
16
+ import { describeProviderManagedAdapter } from "./adapters/persistence/provider-managed.js";
17
+
18
+ function clean(value) {
19
+ return String(value == null ? "" : value).trim();
20
+ }
21
+
22
+ /** Canonical UPPER_SNAKE candidate expansion for a logical ref. */
23
+ function envKeyCandidates(ref) {
24
+ const token = clean(ref).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toUpperCase();
25
+ if (!token) return [];
26
+ return Array.from(new Set([token, `${token}_API_KEY`, `${token}_TOKEN`]));
27
+ }
28
+
29
+ /**
30
+ * Collect every auth/env ref slug referenced by the governed config:
31
+ * - api-registry rows: authRef
32
+ * - data-source rows: authRef
33
+ * - sandbox-environment rows: envRefs (comma-separated)
34
+ * Returns the original ref strings (deduped), preserving the operator's casing
35
+ * so the cockpit can match them against a registry row's authRef.
36
+ */
37
+ function collectReferencedRefs(workspaceConfig) {
38
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
39
+ const refs = new Set();
40
+ for (const object of objects) {
41
+ const rows = Array.isArray(object?.rows) ? object.rows : [];
42
+ if (object?.objectType === "api-registry" || object?.objectType === "data-source") {
43
+ for (const row of rows) {
44
+ const ref = clean(row?.authRef);
45
+ if (ref) refs.add(ref);
46
+ }
47
+ }
48
+ if (object?.objectType === "sandbox-environment") {
49
+ for (const row of rows) {
50
+ for (const part of clean(row?.envRefs).split(",")) {
51
+ const ref = clean(part);
52
+ if (ref) refs.add(ref);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ return Array.from(refs);
58
+ }
59
+
60
+ /**
61
+ * Return the referenced refs whose candidate keys resolve in `env`.
62
+ * `env` is injectable (defaults to process.env). Never returns a value.
63
+ */
64
+ function computeConfiguredEnvRefs(workspaceConfig, env = process.env) {
65
+ const source = env && typeof env === "object" ? env : {};
66
+ const resolves = (ref) => envKeyCandidates(ref).some((key) => Boolean(source[key]));
67
+ return collectReferencedRefs(workspaceConfig).filter(resolves);
68
+ }
69
+
70
+ /**
71
+ * Persistence/serverless adapter env-readiness — single-sourced from the real
72
+ * thin-adapter descriptors (postgres / qstash-kv / provider-managed). These are
73
+ * the durable-runtime layers a serverless workflow needs; the cockpit surfaces
74
+ * exactly which are env-ready so "make this workflow persistent + scheduled"
75
+ * has an honest, actionable signal. Slugs/booleans only — never a value.
76
+ */
77
+ function listPersistenceAdapterReadiness(env = process.env) {
78
+ const source = env && typeof env === "object" ? env : {};
79
+ const descriptors = [describePostgresAdapter(), describeQstashKvAdapter(), describeProviderManagedAdapter()];
80
+ return descriptors.map((d) => {
81
+ const requiredEnv = Array.isArray(d.requiredEnv) ? d.requiredEnv : [];
82
+ const missingEnv = requiredEnv.filter((k) => !source[k]);
83
+ return {
84
+ id: d.id,
85
+ label: d.label,
86
+ mode: d.mode,
87
+ requiredEnv,
88
+ // provider-managed needs no env (the deploy provider owns persistence).
89
+ configured: requiredEnv.length === 0 ? true : missingEnv.length === 0,
90
+ missingEnv,
91
+ };
92
+ });
93
+ }
94
+
95
+ export {
96
+ envKeyCandidates,
97
+ collectReferencedRefs,
98
+ computeConfiguredEnvRefs,
99
+ listPersistenceAdapterReadiness,
100
+ };
@@ -364,6 +364,67 @@ function buildSandboxRowFromApiRegistry(workspaceConfig, registryRow, options =
364
364
  };
365
365
  }
366
366
 
367
+ /**
368
+ * Find existing data-source rows that already resolve through a given API
369
+ * Registry integration (by `registryId`). Mirrors findSandboxRowsForRegistry so
370
+ * the drawer can refuse to create a duplicate Data Source for the same API.
371
+ */
372
+ function findDataSourceRowsForRegistry(workspaceConfig, integrationId) {
373
+ const id = String(integrationId || "").trim();
374
+ if (!id) return [];
375
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
376
+ const rows = [];
377
+ for (const object of objects) {
378
+ if (object?.objectType !== "data-source") continue;
379
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
380
+ if (String(row?.registryId || "").trim() === id) rows.push(row);
381
+ }
382
+ }
383
+ return rows;
384
+ }
385
+
386
+ /**
387
+ * Build a governed Data Source row from a tested API Registry row. The Data
388
+ * Source references the registry entry by `registryId` (the existing
389
+ * resolver-binding relation) and keeps auth as an `authRef` slug only — the
390
+ * secret never lands on the row. Shape matches the OBJECT_TYPE_PRESETS
391
+ * "data-source" columns so it slots straight into the data-source table.
392
+ */
393
+ function buildDataSourceRowFromApiRegistry(workspaceConfig, registryRow, options = {}) {
394
+ const integrationId = String(registryRow?.integrationId || "").trim();
395
+ const baseName = String(options.name || registryRow?.Name || integrationId || "Data Source").trim();
396
+ const name = baseName.endsWith(" Source") ? baseName : `${baseName} Source`;
397
+ const entityType = String(
398
+ options.entityType || registryRow?.entityTypes || "records"
399
+ ).split(",")[0].trim() || "records";
400
+ const sourceId = String(
401
+ options.sourceId || slugifyName(`${integrationId || baseName}-${entityType}`) || slugifyName(baseName)
402
+ ).trim();
403
+ const sourceStorage = String(options.sourceStorage || "workspace-source-records").trim();
404
+ return {
405
+ Name: name,
406
+ slug: options.slug || slugifyName(name) || slugifyName(integrationId),
407
+ objectType: "data-source",
408
+ registryId: integrationId,
409
+ endpoint: String(registryRow?.endpoint || "").trim(),
410
+ authRef: String(options.authRef || registryRow?.authRef || integrationId).trim(),
411
+ baseUrl: String(registryRow?.baseUrl || "").trim(),
412
+ method: String(registryRow?.method || "GET").trim().toUpperCase(),
413
+ status: "draft",
414
+ lastTested: "",
415
+ lastResponse: "",
416
+ entityType,
417
+ sourceId,
418
+ sourceStorage,
419
+ resolverTemplateId: String(options.resolverTemplateId || registryRow?.resolverTemplateId || "").trim(),
420
+ description: String(
421
+ options.description
422
+ || registryRow?.description
423
+ || `Data Source for ${integrationId || baseName} — resolves ${entityType} through the API Registry resolver. authRef ${String(options.authRef || registryRow?.authRef || integrationId).trim()} only; secrets resolve server-side.`
424
+ ).trim()
425
+ };
426
+ }
427
+
367
428
  function extractNodeByType(graph, type) {
368
429
  const parsed = parseOrchestrationGraph(graph) || graph;
369
430
  if (!parsed?.nodes) return null;
@@ -918,6 +979,8 @@ export {
918
979
  getNextCanonicalNodeId,
919
980
  addCanonicalNodeToGraph,
920
981
  buildSandboxRowFromApiRegistry,
982
+ buildDataSourceRowFromApiRegistry,
983
+ findDataSourceRowsForRegistry,
921
984
  extractApiRegistryCallNode,
922
985
  extractInputNode,
923
986
  extractTransformConfig,
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Sandbox Serverless Flow V1 — the governed persistence + scheduling journey for
3
+ * one sandbox-environment workflow row, expressed in the EXACT same step shape
4
+ * as the API Registry creation cockpit (lib/api-registry-creation-flow.js) so it
5
+ * renders through the same cockpit interface and mental model.
6
+ *
7
+ * It connects the dots that already exist:
8
+ * - runLocality local|serverless toggle (sandbox row)
9
+ * - execution adapter (sandbox-adapter-registry)
10
+ * - schedulerRegistryId reference field → an API Registry row that delegates
11
+ * the serverless run (sandbox-run's registry-delegation mode)
12
+ * - the scheduler row's authRef → resolved via env-status configuredEnvRefs
13
+ * - durable persistence via the real thin adapters (postgres / qstash-kv /
14
+ * provider-managed) surfaced by env-status persistenceAdapters
15
+ *
16
+ * Pure + deterministic; never reads process.env, never throws. Secret-safe
17
+ * (slugs/ids/booleans only).
18
+ */
19
+
20
+ function isPlainObject(value) {
21
+ return value !== null && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ function clean(value) {
25
+ return String(value == null ? "" : value).trim();
26
+ }
27
+
28
+ /** Exact env keys a ref resolves through (runtime/.env.local), surfaced so the
29
+ * config loop is concrete — same model as the NANGO_SECRET_KEY activation step. */
30
+ function envCandidates(ref) {
31
+ const token = clean(ref).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toUpperCase();
32
+ if (!token) return [];
33
+ return Array.from(new Set([token, `${token}_API_KEY`, `${token}_TOKEN`]));
34
+ }
35
+
36
+ const SCHEDULER_OK_STATUSES = new Set(["connected", "approved", "ok", "success", "live", "tested"]);
37
+ const STATE_KIND = "growthub-sandbox-serverless-state-v1";
38
+
39
+ function findApiRegistryRow(workspaceConfig, integrationId) {
40
+ const id = clean(integrationId);
41
+ if (!id) return null;
42
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
43
+ for (const object of objects) {
44
+ if (object?.objectType !== "api-registry") continue;
45
+ const match = (object.rows || []).find((r) => clean(r?.integrationId) === id);
46
+ if (match) return match;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Derive the serverless/scheduling/persistence journey for a sandbox row.
53
+ *
54
+ * @param {object} input
55
+ * @param {object} input.sandboxRow the row being edited (drawer draft)
56
+ * @param {object} [input.workspaceConfig] for scheduler row lookup
57
+ * @param {string[]} [input.configuredEnvRefs] auth/env slugs that resolve (env-status)
58
+ * @param {object[]} [input.persistenceAdapters] [{id,label,mode,configured,missingEnv}]
59
+ */
60
+ function deriveSandboxServerlessState(input = {}) {
61
+ const row = isPlainObject(input.sandboxRow) ? input.sandboxRow : {};
62
+ const workspaceConfig = isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {};
63
+ const configuredRefs = new Set((Array.isArray(input.configuredEnvRefs) ? input.configuredEnvRefs : []).map((s) => clean(s).toUpperCase()));
64
+ const adapters = Array.isArray(input.persistenceAdapters) ? input.persistenceAdapters : [];
65
+ // When the cockpit is rendered above the drawer's own editable fields
66
+ // (locality toggle, adapter picker, scheduler reference dropdown), those steps
67
+ // show status only — the inline field is the editor (no duplicate button).
68
+ const inlineEditing = input.inlineEditing === true;
69
+ const inline = (action) => (inlineEditing ? null : action);
70
+
71
+ const locality = clean(row.runLocality).toLowerCase() === "serverless" ? "serverless" : "local";
72
+ const isServerless = locality === "serverless";
73
+ const adapterId = clean(row.adapter);
74
+ const adapterChosen = Boolean(adapterId);
75
+
76
+ const schedulerId = clean(row.schedulerRegistryId);
77
+ const schedulerRow = isServerless ? findApiRegistryRow(workspaceConfig, schedulerId) : null;
78
+ const schedulerLinked = isServerless ? Boolean(schedulerId) : true;
79
+ const schedulerHealthy = Boolean(schedulerRow) && SCHEDULER_OK_STATUSES.has(clean(schedulerRow.status).toLowerCase());
80
+ const schedulerAuthRef = clean(schedulerRow?.authRef).toUpperCase();
81
+ const schedulerAuthConfigured = !schedulerAuthRef || configuredRefs.has(schedulerAuthRef);
82
+
83
+ // Durable persistence: any real adapter that is env-ready. provider-managed is
84
+ // always "ready" (the deploy provider owns persistence). qstash-kv/postgres
85
+ // require their env keys — surfaced honestly with the missing keys.
86
+ const durableAdapters = adapters.filter((a) => a && a.configured);
87
+ const durableReady = durableAdapters.length > 0;
88
+ const envBackedAdapter = durableAdapters.find((a) => Array.isArray(a.requiredEnv) && a.requiredEnv.length > 0) || durableAdapters[0] || null;
89
+
90
+ const steps = [];
91
+
92
+ steps.push({
93
+ id: "locality",
94
+ label: "Choose run locality",
95
+ status: isServerless ? "complete" : "active",
96
+ description: isServerless
97
+ ? "Serverless — runs are delegated to a scheduler and persist across redeploy."
98
+ : "Local — runs execute in-process on this machine.",
99
+ action: inline({ id: "toggle-locality", label: isServerless ? "Switch to local" : "Switch to serverless" }),
100
+ });
101
+
102
+ steps.push({
103
+ id: "adapter",
104
+ label: "Pick an execution adapter",
105
+ status: adapterChosen ? "complete" : "active",
106
+ description: adapterChosen
107
+ ? `Adapter "${adapterId}".`
108
+ : "Select the execution adapter for this workflow.",
109
+ action: adapterChosen ? null : inline({ id: "edit-adapter", label: "Choose adapter" }),
110
+ });
111
+
112
+ if (isServerless) {
113
+ steps.push({
114
+ id: "scheduler",
115
+ label: "Link a scheduler",
116
+ status: schedulerLinked ? (schedulerHealthy ? "complete" : "pending") : "active",
117
+ description: !schedulerLinked
118
+ ? "Set schedulerRegistryId to an API Registry row that delegates the serverless run."
119
+ : schedulerHealthy
120
+ ? `Scheduler "${schedulerId}" is connected.`
121
+ : `Scheduler "${schedulerId}" is linked but not connected yet — test that API Registry row.`,
122
+ hint: schedulerLinked && !schedulerRow ? "The referenced API Registry row was not found." : undefined,
123
+ action: inline({ id: "link-scheduler", label: schedulerLinked ? "Review scheduler" : "Link scheduler" }),
124
+ });
125
+
126
+ steps.push({
127
+ id: "scheduler-auth",
128
+ label: "Scheduler auth resolves",
129
+ status: !schedulerAuthRef
130
+ ? "complete"
131
+ : schedulerAuthConfigured
132
+ ? "complete"
133
+ : (schedulerLinked ? "pending" : "blocked"),
134
+ description: !schedulerAuthRef
135
+ ? "The scheduler needs no secret."
136
+ : schedulerAuthConfigured
137
+ ? `Scheduler secret ${schedulerAuthRef} resolves in this runtime.`
138
+ : `Set one of ${envCandidates(schedulerAuthRef).join(" / ")} in .env.local (or your hosted runtime), then reopen.`,
139
+ action: schedulerAuthRef && !schedulerAuthConfigured ? { id: "open-settings", label: "Manage in Settings", href: "/settings/apis-webhooks" } : null,
140
+ });
141
+
142
+ steps.push({
143
+ id: "persistence",
144
+ label: "Enable durable persistence",
145
+ status: durableReady ? "complete" : "active",
146
+ description: durableReady
147
+ ? `Durable store ready (${(envBackedAdapter || durableAdapters[0]).label}).`
148
+ : "Set a durable store's env keys in .env.local (or your hosted runtime) so serverless runs survive redeploy.",
149
+ // Fully surface every thin adapter + its exact env keys + readiness, so no
150
+ // adapter is assumed server-side without being shown to the operator.
151
+ hint: durableReady
152
+ ? undefined
153
+ : adapters.length
154
+ ? adapters.map((a) => `${a.label}: ${(a.requiredEnv || []).length ? a.requiredEnv.join(", ") : "no env"}${a.configured ? " ✓" : ""}`).join(" · ")
155
+ : "No persistence adapter signal yet — open env-status.",
156
+ action: durableReady ? null : { id: "open-settings", label: "Manage in Settings", href: "/settings" },
157
+ });
158
+ }
159
+
160
+ steps.push({
161
+ id: "run",
162
+ label: isServerless ? "Run on the scheduler" : "Run locally",
163
+ status: "optional",
164
+ description: isServerless
165
+ ? "Once the scheduler, auth, and store are ready, run delegates to the serverless scheduler."
166
+ : "Run this workflow in-process.",
167
+ action: inline({ id: "run-sandbox", label: "Run" }),
168
+ });
169
+
170
+ for (const s of steps) { if (!s.hint) delete s.hint; }
171
+
172
+ const required = steps.filter((s) => s.status !== "optional");
173
+ const completedCount = required.filter((s) => s.status === "complete").length;
174
+ const totalCount = required.length;
175
+ const complete = completedCount >= totalCount;
176
+ const nextStep = steps.find((s) => s.status === "active")
177
+ || steps.find((s) => s.status === "pending")
178
+ || steps.find((s) => s.status === "blocked")
179
+ || null;
180
+
181
+ // Milestone score tied to evidence.
182
+ let score = isServerless ? 10 : 40;
183
+ if (adapterChosen) score = Math.max(score, isServerless ? 25 : 70);
184
+ if (isServerless && schedulerLinked) score = Math.max(score, 45);
185
+ if (isServerless && schedulerHealthy) score = Math.max(score, 60);
186
+ if (isServerless && schedulerAuthConfigured && schedulerLinked) score = Math.max(score, 75);
187
+ if (isServerless && durableReady) score = Math.max(score, 90);
188
+ if (complete) score = 100;
189
+
190
+ return {
191
+ kind: STATE_KIND,
192
+ version: 1,
193
+ locality,
194
+ isServerless,
195
+ adapterChosen,
196
+ schedulerLinked,
197
+ schedulerHealthy,
198
+ schedulerAuthConfigured,
199
+ durableReady,
200
+ completedCount,
201
+ totalCount,
202
+ complete,
203
+ score,
204
+ nextStepId: nextStep ? nextStep.id : null,
205
+ nextAction: nextStep && nextStep.action ? { stepId: nextStep.id, ...nextStep.action } : null,
206
+ headline: !isServerless
207
+ ? "This workflow runs locally."
208
+ : complete
209
+ ? "This workflow is scheduled and durable."
210
+ : "Make this workflow persistent and scheduled.",
211
+ steps,
212
+ };
213
+ }
214
+
215
+ export { STATE_KIND, deriveSandboxServerlessState };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Server Resolver Write V1 — the single, confined, gated fs write for resolver
3
+ * files. Used by the helper apply route's resolver.create lane (and available
4
+ * to register-resolver). Server-only.
5
+ *
6
+ * AWaC topology: writes only in filesystem mode; read-only runtimes throw a
7
+ * coded error with guidance (the same 409 contract the rest of the workspace
8
+ * uses). Path is confined to lib/adapters/integrations/resolvers — never
9
+ * escapes. Never logs file contents.
10
+ */
11
+
12
+ import { promises as fs } from "node:fs";
13
+ import path from "node:path";
14
+ import { describePersistenceMode } from "@/lib/workspace-config";
15
+ import { resolveResolverFilePath } from "@/lib/workspace-resolver-proposal";
16
+
17
+ const MAX_RESOLVER_SIZE = 256 * 1024;
18
+
19
+ /**
20
+ * Write a resolver file from a validated proposal. Returns
21
+ * { saved:true, path, filename } or throws a coded error:
22
+ * - WORKSPACE_PERSISTENCE_READ_ONLY (read-only runtime; carries guidance)
23
+ * - INVALID_RESOLVER_WRITE (bad path / code)
24
+ * - WORKSPACE_PERSISTENCE_PATH_REFUSED (escaped the resolver dir)
25
+ */
26
+ async function writeResolverProposalFile(proposal) {
27
+ const persistence = describePersistenceMode();
28
+ if (!persistence.canSave) {
29
+ const error = new Error(persistence.reason || "resolver write requires a writable filesystem runtime");
30
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
31
+ error.guidance = persistence.guidance || "Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or use local development mode.";
32
+ throw error;
33
+ }
34
+ const target = (proposal?.target && proposal.target.ok)
35
+ ? proposal.target
36
+ : resolveResolverFilePath(proposal?.payload?.integrationId);
37
+ if (!target || !target.ok) {
38
+ const error = new Error(target?.error || "invalid resolver target path");
39
+ error.code = "INVALID_RESOLVER_WRITE";
40
+ throw error;
41
+ }
42
+ const code = String(proposal?.code || "");
43
+ if (!code.includes("registerSourceResolver")) {
44
+ const error = new Error("resolver code must call registerSourceResolver()");
45
+ error.code = "INVALID_RESOLVER_WRITE";
46
+ throw error;
47
+ }
48
+ if (Buffer.byteLength(code, "utf8") > MAX_RESOLVER_SIZE) {
49
+ const error = new Error(`resolver file must be smaller than ${MAX_RESOLVER_SIZE / 1024} KB`);
50
+ error.code = "INVALID_RESOLVER_WRITE";
51
+ throw error;
52
+ }
53
+
54
+ const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), target.dir);
55
+ const outPath = path.join(resolversDir, target.filename);
56
+ if (path.dirname(outPath) !== resolversDir) {
57
+ const error = new Error("invalid filename — path traversal not allowed");
58
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
59
+ throw error;
60
+ }
61
+
62
+ await fs.mkdir(resolversDir, { recursive: true });
63
+ await fs.writeFile(outPath, code, "utf8");
64
+ return { saved: true, path: target.path, filename: target.filename };
65
+ }
66
+
67
+ export { writeResolverProposalFile };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Serverless Upgrade Onboarding V1 — derives the one-time "upgrade your
3
+ * localhost workflow to a persistent, scheduled serverless workflow" nudge from
4
+ * the workspace configuration state, following the same one-time onboarding
5
+ * pattern as the lens walkthrough (workspace-ui-cache dismiss flag).
6
+ *
7
+ * It reads only the existing model: sandbox-environment rows and their
8
+ * `runLocality` field (already part of the governed schema). When the operator
9
+ * has workflows but none are serverless — and hasn't dismissed the nudge — the
10
+ * upgrade path shows once.
11
+ *
12
+ * Pure + deterministic; never throws. The dismiss flag is read by the caller
13
+ * from the governed workspace-ui-cache row and passed in.
14
+ */
15
+
16
+ const SERVERLESS_UPGRADE_DISMISS_FLAG = "serverlessUpgradeDismissed";
17
+ const UPGRADE_STATE_KIND = "growthub-serverless-upgrade-state-v1";
18
+
19
+ function isPlainObject(value) {
20
+ return value !== null && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+
23
+ function clean(value) {
24
+ return String(value == null ? "" : value).trim();
25
+ }
26
+
27
+ /** Is a sandbox row configured to run serverless? */
28
+ function rowIsServerless(row) {
29
+ return isPlainObject(row) && clean(row.runLocality).toLowerCase() === "serverless";
30
+ }
31
+
32
+ /** Collect every sandbox-environment (workflow) row across the data model. */
33
+ function collectWorkflowRows(workspaceConfig) {
34
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
35
+ const rows = [];
36
+ for (const object of objects) {
37
+ if (object?.objectType !== "sandbox-environment") continue;
38
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
39
+ if (isPlainObject(row)) rows.push(row);
40
+ }
41
+ }
42
+ return rows;
43
+ }
44
+
45
+ /**
46
+ * Derive the serverless-upgrade onboarding state.
47
+ *
48
+ * @param {object} workspaceConfig
49
+ * @param {object} [options]
50
+ * @param {boolean} [options.dismissed] dismiss flag from workspace-ui-cache
51
+ */
52
+ function deriveServerlessUpgradeState(workspaceConfig, options = {}) {
53
+ const opts = isPlainObject(options) ? options : {};
54
+ const rows = collectWorkflowRows(workspaceConfig);
55
+ const serverlessCount = rows.filter(rowIsServerless).length;
56
+ const localCount = rows.length - serverlessCount;
57
+ const hasWorkflows = rows.length > 0;
58
+ const dismissed = opts.dismissed === true || String(opts.dismissed || "") === "true";
59
+ // Show the one-time path only when the operator has built workflows but none
60
+ // are serverless yet — the moment the upgrade mental model is most useful.
61
+ const showOnboarding = hasWorkflows && serverlessCount === 0 && !dismissed;
62
+ return {
63
+ kind: UPGRADE_STATE_KIND,
64
+ version: 1,
65
+ hasWorkflows,
66
+ workflowCount: rows.length,
67
+ serverlessCount,
68
+ localCount,
69
+ allLocal: hasWorkflows && serverlessCount === 0,
70
+ dismissed,
71
+ showOnboarding,
72
+ headline: showOnboarding
73
+ ? "Upgrade a workflow to serverless"
74
+ : serverlessCount > 0
75
+ ? "Serverless workflows are active"
76
+ : "Workflows run locally",
77
+ subheadline: showOnboarding
78
+ ? "Make it persistent and scheduled — it runs on invocation through your configured adapter, not just on this machine."
79
+ : "",
80
+ };
81
+ }
82
+
83
+ export {
84
+ SERVERLESS_UPGRADE_DISMISS_FLAG,
85
+ UPGRADE_STATE_KIND,
86
+ rowIsServerless,
87
+ collectWorkflowRows,
88
+ deriveServerlessUpgradeState,
89
+ };
@@ -405,9 +405,15 @@ function deriveBlankWorkspaceActivationState({ workspaceConfig, workspaceSourceR
405
405
  }, 0);
406
406
  const widgetAdded = widgetCount > 0;
407
407
 
408
- const workflowMatch = findWorkflowRow(workspaceConfig, () => true);
408
+ const workflowMatch = findWorkflowRow(workspaceConfig, (_row, object) => safeString(object?.id).trim() !== "workspace-helper-sandbox");
409
409
  const workflowCreated = Boolean(workflowMatch?.row);
410
410
  const workflowRun = deriveLatestRunStatus(workflowMatch?.row);
411
+ const workflowObjectId = safeString(workflowMatch?.object?.id).trim();
412
+ const workflowRowName = safeString(workflowMatch?.row?.Name || workflowMatch?.row?.name || workflowMatch?.row?.slug || workflowMatch?.row?.id).trim();
413
+ const workflowField = workflowMatch?.row?.orchestrationConfig !== undefined ? "orchestrationConfig" : "orchestrationGraph";
414
+ const workflowHref = workflowObjectId && workflowRowName
415
+ ? `/workflows?object=${encodeURIComponent(workflowObjectId)}&row=${encodeURIComponent(workflowRowName)}&field=${encodeURIComponent(workflowField)}`
416
+ : "";
411
417
 
412
418
  const steps = [
413
419
  {
@@ -449,8 +455,9 @@ function deriveBlankWorkspaceActivationState({ workspaceConfig, workspaceSourceR
449
455
  ? "Sandbox workflow scaffolded."
450
456
  : "Open Workflows to assemble your first automation.",
451
457
  status: workflowCreated ? "complete" : "pending",
452
- href: "/workflows",
453
- cta: workflowCreated ? "Open Workflows" : "New workflow",
458
+ href: workflowCreated ? workflowHref : "",
459
+ action: workflowCreated ? "" : "create-workflow",
460
+ cta: workflowCreated ? "Open workflow" : "New workflow",
454
461
  },
455
462
  {
456
463
  id: "run-workflow",
@@ -461,7 +468,7 @@ function deriveBlankWorkspaceActivationState({ workspaceConfig, workspaceSourceR
461
468
  ? "Last run failed — open the trace and fix the failing node."
462
469
  : "Click Test inside the workflow to do a first run.",
463
470
  status: workflowRun.ok ? "complete" : (workflowCreated ? "pending" : "blocked"),
464
- href: "/workflows",
471
+ href: workflowCreated ? workflowHref : "",
465
472
  hint: workflowRun.ok || workflowCreated ? "" : "Create a workflow first.",
466
473
  cta: workflowRun.ok ? "View runs" : "Open workflow",
467
474
  },
@@ -937,6 +937,13 @@ function createTypedBusinessObject(workspaceConfig, { name, objectType = "custom
937
937
  ? workspaceConfig.dataModel
938
938
  : {};
939
939
  const id = uniqueObjectId(workspaceConfig, label);
940
+ const relations = preset.relations
941
+ ? preset.relations.map((relation) => {
942
+ const next = { ...relation };
943
+ if (next.statusAllowlist == null) delete next.statusAllowlist;
944
+ return next;
945
+ })
946
+ : [];
940
947
  const object = {
941
948
  id,
942
949
  label,
@@ -946,7 +953,7 @@ function createTypedBusinessObject(workspaceConfig, { name, objectType = "custom
946
953
  columns,
947
954
  rows: [],
948
955
  binding: { mode: "manual", source: "Data Model" },
949
- relations: preset.relations ? preset.relations.map((r) => ({ ...r })) : [],
956
+ relations,
950
957
  fieldSettings: normalizeFieldSettings({}, columns)
951
958
  };
952
959
  return {
@@ -37,6 +37,9 @@ const WORKSPACE_HELPER_PROPOSAL_TYPES = [
37
37
  "dataModel.row.add",
38
38
  "repair.binding",
39
39
  "explain.object",
40
+ // Server-file lane (AWaC: NOT a config PATCH field). Routed in helper/apply to
41
+ // the confined, gated resolver write — never through writeWorkspaceConfig.
42
+ "resolver.create",
40
43
  ];
41
44
 
42
45
  const PROPOSAL_TYPE_TO_PATCH_FIELD = {
@@ -50,6 +53,9 @@ const PROPOSAL_TYPE_TO_PATCH_FIELD = {
50
53
  "dataModel.row.add": "dataModel",
51
54
  "repair.binding": "dataModel",
52
55
  "explain.object": "dataModel",
56
+ // Sentinel — resolver.create writes a server file, not a config field. It is
57
+ // explicitly excluded from the PATCH allowlist and handled by its own lane.
58
+ "resolver.create": "server-file",
53
59
  };
54
60
 
55
61
  const KNOWN_WIDGET_KINDS = ["chart", "view", "iframe", "rich-text"];
@@ -62,7 +68,7 @@ const INTENT_DESCRIPTIONS = {
62
68
  create_widget:
63
69
  "Suggest which widgetTypes belong on the workspace based on the object schema and target KPIs. Propose widgetType entries and canvas widget placements.",
64
70
  register_api:
65
- "Draft API Registry rows (dataModel object of objectType api-registry) including integration labels, credential prompts, base URL, endpoint, auth header, and method.",
71
+ "Draft API Registry rows (dataModel object of objectType api-registry) including integration labels, credential prompts, base URL, endpoint, auth header, and method. When the user wants the response shaped into governed rows (a Data Source) or describes a nested/paginated response, also recommend a resolver in the `summary` and propose the matching data-source object (objectType data-source) that references the registry row by registryId — guiding them into the resolver / Data Source lane. Credentials are runtime env keys (REF / REF_API_KEY / REF_TOKEN in .env.local), never stored in config.",
66
72
  create_object:
67
73
  "Translate the user's domain language into a new dataModel object: objectType, label, columns, starter rows, and field settings that make sense for their business.",
68
74
  edit_view: