@growthub/cli 0.13.8 → 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/codex-sites/route.js +13 -0
- 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 +501 -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 +215 -13
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/codex-sites-data-model-card.jsx +81 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +31 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/settings-accordion-section.jsx +50 -0
- 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/app/workspace-builder.jsx +137 -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/codex-sites-local-state.js +139 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/codex-sites-workspace-adapter.js +156 -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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const CODEX_SITES_OBJECT_ID = "workspace-codex-sites";
|
|
2
|
+
const CODEX_SITES_COLUMNS = [
|
|
3
|
+
"Name",
|
|
4
|
+
"app",
|
|
5
|
+
"client",
|
|
6
|
+
"url",
|
|
7
|
+
"status",
|
|
8
|
+
"accessMode",
|
|
9
|
+
"dashboardId",
|
|
10
|
+
"lastRecordedAt",
|
|
11
|
+
"notes"
|
|
12
|
+
];
|
|
13
|
+
const CODEX_SITES_SOURCE_ID = "codex-sites";
|
|
14
|
+
|
|
15
|
+
function isCodexSiteUrl(value) {
|
|
16
|
+
return /^https?:\/\//i.test(String(value || "").trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function defaultAppSource(apps) {
|
|
20
|
+
const first = Array.isArray(apps) ? apps.find((app) => app?.source) : null;
|
|
21
|
+
return first?.source || "apps/workspace";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createCodexSitesObject(apps = []) {
|
|
25
|
+
return {
|
|
26
|
+
id: CODEX_SITES_OBJECT_ID,
|
|
27
|
+
label: "Codex Sites",
|
|
28
|
+
source: "Workspace Apps",
|
|
29
|
+
sourceId: CODEX_SITES_OBJECT_ID,
|
|
30
|
+
objectType: "custom",
|
|
31
|
+
icon: "Rocket",
|
|
32
|
+
columns: CODEX_SITES_COLUMNS,
|
|
33
|
+
rows: [],
|
|
34
|
+
binding: {
|
|
35
|
+
mode: "manual",
|
|
36
|
+
source: "Settings / Apps",
|
|
37
|
+
sourceType: "workspace-data-model",
|
|
38
|
+
sourceAuthority: "workspace-config",
|
|
39
|
+
objectId: CODEX_SITES_OBJECT_ID,
|
|
40
|
+
sourceId: CODEX_SITES_SOURCE_ID,
|
|
41
|
+
entityType: "codex-site",
|
|
42
|
+
app: defaultAppSource(apps)
|
|
43
|
+
},
|
|
44
|
+
fieldSettings: {
|
|
45
|
+
hidden: [],
|
|
46
|
+
order: CODEX_SITES_COLUMNS,
|
|
47
|
+
views: [
|
|
48
|
+
{
|
|
49
|
+
id: "codex-sites-live",
|
|
50
|
+
name: "Live",
|
|
51
|
+
favorite: true,
|
|
52
|
+
order: CODEX_SITES_COLUMNS,
|
|
53
|
+
filter: { op: "and", clauses: [{ fieldId: "status", operator: "eq", value: "live" }] }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "codex-sites-review",
|
|
57
|
+
name: "Draft & Review",
|
|
58
|
+
order: CODEX_SITES_COLUMNS,
|
|
59
|
+
filter: { op: "or", clauses: [
|
|
60
|
+
{ fieldId: "status", operator: "eq", value: "draft" },
|
|
61
|
+
{ fieldId: "status", operator: "eq", value: "review" }
|
|
62
|
+
] }
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
activeViewId: "codex-sites-live",
|
|
66
|
+
types: {
|
|
67
|
+
Name: "text",
|
|
68
|
+
app: "text",
|
|
69
|
+
client: "text",
|
|
70
|
+
url: "url",
|
|
71
|
+
status: "select",
|
|
72
|
+
accessMode: "select",
|
|
73
|
+
dashboardId: "text",
|
|
74
|
+
lastRecordedAt: "date",
|
|
75
|
+
notes: "text"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeCodexSiteRecord(record = {}) {
|
|
82
|
+
const url = String(record.url || record.liveUrl || record.current_live_url || "").trim();
|
|
83
|
+
return {
|
|
84
|
+
id: String(record.id || record.projectId || record.project_id || record.slug || url).trim(),
|
|
85
|
+
Name: String(record.Name || record.name || record.title || record.slug || "Codex Site").trim(),
|
|
86
|
+
app: String(record.app || record.source || "apps/workspace").trim(),
|
|
87
|
+
client: String(record.client || record.workspace || "Workspace").trim(),
|
|
88
|
+
url,
|
|
89
|
+
status: String(record.status || (url ? "live" : "draft")).trim(),
|
|
90
|
+
accessMode: String(record.accessMode || record.access_mode || "workspace").trim(),
|
|
91
|
+
dashboardId: String(record.dashboardId || record.dashboard_id || record.slug || record.id || "").trim(),
|
|
92
|
+
lastRecordedAt: String(record.lastRecordedAt || record.updated_at || record.created_at || "").trim(),
|
|
93
|
+
notes: String(record.notes || record.description || "").trim()
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function codexSiteRecordToRow(record = {}) {
|
|
98
|
+
const site = normalizeCodexSiteRecord(record);
|
|
99
|
+
return {
|
|
100
|
+
Name: site.Name,
|
|
101
|
+
app: site.app,
|
|
102
|
+
client: site.client,
|
|
103
|
+
url: site.url,
|
|
104
|
+
status: site.status,
|
|
105
|
+
accessMode: site.accessMode,
|
|
106
|
+
dashboardId: site.dashboardId,
|
|
107
|
+
lastRecordedAt: site.lastRecordedAt || new Date().toISOString(),
|
|
108
|
+
notes: site.notes
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function recordsFromSourceEntry(entry) {
|
|
113
|
+
if (Array.isArray(entry)) return entry;
|
|
114
|
+
if (Array.isArray(entry?.records)) return entry.records;
|
|
115
|
+
if (Array.isArray(entry?.sites)) return entry.sites;
|
|
116
|
+
if (Array.isArray(entry?.items)) return entry.items;
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function listAvailableCodexSites(workspaceConfig = {}, workspaceSourceRecords = {}) {
|
|
121
|
+
const sidecarRecords = [
|
|
122
|
+
...recordsFromSourceEntry(workspaceSourceRecords?.[CODEX_SITES_SOURCE_ID]),
|
|
123
|
+
...recordsFromSourceEntry(workspaceSourceRecords?.[CODEX_SITES_OBJECT_ID])
|
|
124
|
+
];
|
|
125
|
+
const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
|
|
126
|
+
const object = objects.find((item) => item?.id === CODEX_SITES_OBJECT_ID);
|
|
127
|
+
const rowRecords = Array.isArray(object?.rows) ? object.rows : [];
|
|
128
|
+
const byUrl = new Map();
|
|
129
|
+
[...sidecarRecords, ...rowRecords].forEach((record) => {
|
|
130
|
+
const site = normalizeCodexSiteRecord(record);
|
|
131
|
+
if (!isCodexSiteUrl(site.url)) return;
|
|
132
|
+
byUrl.set(site.url, site);
|
|
133
|
+
});
|
|
134
|
+
return Array.from(byUrl.values());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ensureCodexSitesDataModel(dataModel, apps = []) {
|
|
138
|
+
const objects = Array.isArray(dataModel?.objects) ? dataModel.objects : [];
|
|
139
|
+
if (objects.some((object) => object?.id === CODEX_SITES_OBJECT_ID)) return dataModel || {};
|
|
140
|
+
return {
|
|
141
|
+
...(dataModel || {}),
|
|
142
|
+
objects: [...objects, createCodexSitesObject(apps)]
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
CODEX_SITES_COLUMNS,
|
|
148
|
+
CODEX_SITES_OBJECT_ID,
|
|
149
|
+
CODEX_SITES_SOURCE_ID,
|
|
150
|
+
codexSiteRecordToRow,
|
|
151
|
+
createCodexSitesObject,
|
|
152
|
+
ensureCodexSitesDataModel,
|
|
153
|
+
isCodexSiteUrl,
|
|
154
|
+
listAvailableCodexSites,
|
|
155
|
+
normalizeCodexSiteRecord
|
|
156
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creation Error Recovery V1 — turns raw failure signals from the creation
|
|
3
|
+
* lane (test / create / refresh / resolver / read-only runtime) into a
|
|
4
|
+
* structured, machine-readable recovery the cockpit can render as an exact next
|
|
5
|
+
* action instead of a generic error string.
|
|
6
|
+
*
|
|
7
|
+
* Pure + deterministic. Input is already-safe (callers redact secrets before
|
|
8
|
+
* passing detail). Output:
|
|
9
|
+
* { errorKind, retryable, requiredAction, suggestedRoute, safeDetail }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function clean(value) {
|
|
13
|
+
return String(value == null ? "" : value).trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RECOVERY = {
|
|
17
|
+
missing_auth_ref: {
|
|
18
|
+
requiredAction: "Set an authRef on this API Registry row, then save the secret in Settings.",
|
|
19
|
+
suggestedRoute: "/settings",
|
|
20
|
+
retryable: false,
|
|
21
|
+
},
|
|
22
|
+
env_not_configured: {
|
|
23
|
+
requiredAction: "Save the secret for this authRef in Settings → APIs & Webhooks (writes .env.local), then reopen.",
|
|
24
|
+
suggestedRoute: "/settings",
|
|
25
|
+
retryable: true,
|
|
26
|
+
},
|
|
27
|
+
api_test_failed: {
|
|
28
|
+
requiredAction: "Check the baseUrl, endpoint, method, and auth header, then Test again.",
|
|
29
|
+
suggestedRoute: "",
|
|
30
|
+
retryable: true,
|
|
31
|
+
},
|
|
32
|
+
not_live_backed: {
|
|
33
|
+
requiredAction: "Recreate the Data Source from the API Registry row so it is live-backed (sourceStorage + integrationId).",
|
|
34
|
+
suggestedRoute: "/data-model",
|
|
35
|
+
retryable: false,
|
|
36
|
+
},
|
|
37
|
+
missing_resolver: {
|
|
38
|
+
requiredAction: "Add a resolver for this integration so refresh can shape the response into rows.",
|
|
39
|
+
suggestedRoute: "/api/workspace/resolver-templates",
|
|
40
|
+
retryable: true,
|
|
41
|
+
},
|
|
42
|
+
missing_integration_id: {
|
|
43
|
+
requiredAction: "Set the Data Source object's integrationId to match the API Registry integrationId.",
|
|
44
|
+
suggestedRoute: "/data-model",
|
|
45
|
+
retryable: false,
|
|
46
|
+
},
|
|
47
|
+
source_refresh_failed: {
|
|
48
|
+
requiredAction: "Re-run Test on the API, confirm the resolver returns rows, then Refresh again.",
|
|
49
|
+
suggestedRoute: "",
|
|
50
|
+
retryable: true,
|
|
51
|
+
},
|
|
52
|
+
read_only_runtime: {
|
|
53
|
+
requiredAction: "Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true on a writable runtime (or edit growthub.config.json locally) to persist this.",
|
|
54
|
+
suggestedRoute: "",
|
|
55
|
+
retryable: false,
|
|
56
|
+
},
|
|
57
|
+
unknown: {
|
|
58
|
+
requiredAction: "Retry; if it persists, inspect the response in the row's lastResponse.",
|
|
59
|
+
suggestedRoute: "",
|
|
60
|
+
retryable: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Classify a failure from a creation-lane action.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} input
|
|
68
|
+
* @param {string} input.phase "test" | "create" | "refresh" | "resolver"
|
|
69
|
+
* @param {number} [input.httpStatus]
|
|
70
|
+
* @param {string} [input.reason] route-supplied reason (e.g. "missing-resolver")
|
|
71
|
+
* @param {string} [input.detail] already-safe human message
|
|
72
|
+
* @param {boolean} [input.readOnly] true when persistence is read-only (409)
|
|
73
|
+
*/
|
|
74
|
+
function classifyCreationError(input = {}) {
|
|
75
|
+
const phase = clean(input.phase);
|
|
76
|
+
const reason = clean(input.reason).toLowerCase().replace(/-/g, "_");
|
|
77
|
+
const httpStatus = Number(input.httpStatus) || 0;
|
|
78
|
+
const detail = clean(input.detail);
|
|
79
|
+
|
|
80
|
+
let errorKind = "unknown";
|
|
81
|
+
if (input.readOnly || httpStatus === 409) {
|
|
82
|
+
errorKind = "read_only_runtime";
|
|
83
|
+
} else if (reason && RECOVERY[reason]) {
|
|
84
|
+
errorKind = reason; // route reasons like missing_resolver / not_live_backed
|
|
85
|
+
} else if (reason === "not_live_backed") {
|
|
86
|
+
errorKind = "not_live_backed";
|
|
87
|
+
} else if (phase === "test") {
|
|
88
|
+
errorKind = "api_test_failed";
|
|
89
|
+
} else if (phase === "refresh") {
|
|
90
|
+
errorKind = "source_refresh_failed";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const recovery = RECOVERY[errorKind] || RECOVERY.unknown;
|
|
94
|
+
return {
|
|
95
|
+
errorKind,
|
|
96
|
+
retryable: recovery.retryable,
|
|
97
|
+
requiredAction: recovery.requiredAction,
|
|
98
|
+
suggestedRoute: recovery.suggestedRoute,
|
|
99
|
+
safeDetail: detail || errorKind.replace(/_/g, " "),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { classifyCreationError };
|
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 };
|