@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +31 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +130 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +17 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +5 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +200 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +396 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +75 -55
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +100 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +176 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +2 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +317 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-response-profile.js +207 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/creation-error-recovery.js +103 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +100 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +215 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-write.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-upgrade.js +89 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +11 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +8 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +7 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +200 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -1
- package/package.json +1 -1
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js
ADDED
|
@@ -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, () =>
|
|
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: "
|
|
453
|
-
|
|
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: "
|
|
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
|
|
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:
|