@growthub/cli 0.13.9 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +31 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +227 -5
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceActivationPanel.jsx +17 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +6 -3
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +200 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +414 -9
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +339 -77
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ReferencePicker.jsx +2 -2
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +229 -9
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +224 -14
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +2 -4
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +317 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-response-profile.js +207 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/creation-error-recovery.js +103 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +100 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +69 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +215 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-write.js +67 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-upgrade.js +89 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +11 -4
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +8 -1
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +30 -1
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +200 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,317 @@
1
+ /**
2
+ * API Registry Creation Flow V1 — the governed creation spine for one API.
3
+ *
4
+ * Pure derivation that powers the creation cockpit inside the api-registry
5
+ * record drawer (DataModelShell). Given the live workspace config, the registry
6
+ * row being edited, the source-records sidecar, and the safe runtime signal, it
7
+ * resolves the full operator journey for THIS API as an ordered list of steps:
8
+ *
9
+ * register → configure auth → test → (resolver) → sandbox tool → data source
10
+ * → refresh records
11
+ *
12
+ * Each step carries a status (complete | active | pending | blocked | optional),
13
+ * a human description, and — when the operator can act — an `action` descriptor
14
+ * the drawer maps to an existing handler (test / create-data-source /
15
+ * create-sandbox-tool / open-data-source / refresh-source). The cockpit renders
16
+ * this verbatim, so the journey is one derivation, not UI guesswork.
17
+ *
18
+ * Invariants:
19
+ * - Pure, deterministic, never throws on partial input. `runtime` /
20
+ * `sourceRecords` are injected (no fetch, no process.env, no React).
21
+ * - Auth "configured" is resolved ONLY from an explicit runtime signal
22
+ * (`runtime.configuredEnvRefs`, slugs) — never from a secret value, never
23
+ * guessed. Absent the signal the auth step stays pending with a verify hint.
24
+ * - Secret-safe: the output contains slugs, ids, counts, and booleans only.
25
+ */
26
+
27
+ function isPlainObject(value) {
28
+ return value !== null && typeof value === "object" && !Array.isArray(value);
29
+ }
30
+
31
+ function clean(value) {
32
+ return String(value == null ? "" : value).trim();
33
+ }
34
+
35
+ /** Exact env keys an authRef resolves through — shown to the operator so the
36
+ * "how do I configure this" loop is concrete (these are runtime/.env.local
37
+ * keys, the same model the NANGO_SECRET_KEY activation step uses). */
38
+ function envCandidates(ref) {
39
+ const token = clean(ref).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toUpperCase();
40
+ if (!token) return [];
41
+ return Array.from(new Set([token, `${token}_API_KEY`, `${token}_TOKEN`]));
42
+ }
43
+
44
+ function parseMaybeJson(value) {
45
+ if (isPlainObject(value)) return value;
46
+ const text = clean(value);
47
+ if (!text) return null;
48
+ try {
49
+ const parsed = JSON.parse(text);
50
+ return isPlainObject(parsed) ? parsed : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** A registry row is "registered" once it has an id and a reachable target. */
57
+ function isRegistered(row) {
58
+ return Boolean(clean(row?.integrationId) && (clean(row?.baseUrl) || clean(row?.endpoint)));
59
+ }
60
+
61
+ /** Does this row's last test indicate success? Mirrors the drawer's testApiRecord. */
62
+ function isTested(row) {
63
+ const status = clean(row?.status).toLowerCase();
64
+ if (["connected", "ok", "success", "live", "tested"].includes(status)) return true;
65
+ const resp = parseMaybeJson(row?.lastResponse);
66
+ if (resp && (resp.ok === true || resp.status === 200)) return true;
67
+ return false;
68
+ }
69
+
70
+ function findObjectsByType(workspaceConfig, objectType) {
71
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
72
+ return objects.filter((o) => isPlainObject(o) && o.objectType === objectType);
73
+ }
74
+
75
+ function sandboxRowsForIntegration(workspaceConfig, integrationId) {
76
+ const id = clean(integrationId);
77
+ if (!id) return [];
78
+ const rows = [];
79
+ for (const object of findObjectsByType(workspaceConfig, "sandbox-environment")) {
80
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
81
+ const envRefs = clean(row?.envRefs);
82
+ const schedulerId = clean(row?.schedulerRegistryId);
83
+ const cfg = parseMaybeJson(row?.orchestrationConfig);
84
+ const callsApi = Array.isArray(cfg?.nodes) && cfg.nodes.some(
85
+ (n) => n?.type === "api-registry-call"
86
+ && clean(n?.config?.registryId || n?.config?.integrationId) === id,
87
+ );
88
+ if (callsApi || schedulerId === id || envRefs.split(",").map(clean).includes(clean(row?.authRef))) {
89
+ rows.push(row);
90
+ }
91
+ }
92
+ }
93
+ return rows;
94
+ }
95
+
96
+ function dataSourceRowsForIntegration(workspaceConfig, integrationId) {
97
+ const id = clean(integrationId);
98
+ if (!id) return [];
99
+ const rows = [];
100
+ for (const object of findObjectsByType(workspaceConfig, "data-source")) {
101
+ for (const row of Array.isArray(object.rows) ? object.rows : []) {
102
+ if (clean(row?.registryId) === id) rows.push({ row, objectId: object.id });
103
+ }
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ function sidecarHasRecords(sourceRecords, sourceId) {
109
+ const key = clean(sourceId);
110
+ if (!key || !isPlainObject(sourceRecords)) return false;
111
+ const sidecar = sourceRecords[key];
112
+ if (!isPlainObject(sidecar)) return false;
113
+ if (Number.isFinite(sidecar.recordCount) && sidecar.recordCount > 0) return true;
114
+ return Array.isArray(sidecar.records) && sidecar.records.length > 0;
115
+ }
116
+
117
+ const STEP_KIND = "growthub-api-registry-creation-state-v1";
118
+
119
+ /**
120
+ * Derive the full creation state for one API Registry row.
121
+ *
122
+ * @param {object} input
123
+ * @param {object} input.workspaceConfig
124
+ * @param {object} input.registryRow the row being edited (drawer draft)
125
+ * @param {object} [input.sourceRecords] source-records sidecar
126
+ * @param {object} [input.runtime] safe runtime signal (configuredEnvRefs[])
127
+ */
128
+ function deriveApiRegistryCreationState(input = {}) {
129
+ const workspaceConfig = isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {};
130
+ const row = isPlainObject(input.registryRow) ? input.registryRow : {};
131
+ const sourceRecords = isPlainObject(input.sourceRecords) ? input.sourceRecords : {};
132
+ const runtime = isPlainObject(input.runtime) ? input.runtime : {};
133
+
134
+ const integrationId = clean(row.integrationId);
135
+ const authRef = clean(row.authRef).toUpperCase();
136
+ const registered = isRegistered(row);
137
+ const tested = isTested(row);
138
+
139
+ const configuredRefs = new Set(
140
+ (Array.isArray(runtime.configuredEnvRefs) ? runtime.configuredEnvRefs : []).map((s) => clean(s).toUpperCase()),
141
+ );
142
+ const haveEnvSignal = Array.isArray(runtime.configuredEnvRefs);
143
+ const authNeeded = Boolean(authRef);
144
+ const authConfigured = !authNeeded || (haveEnvSignal && configuredRefs.has(authRef));
145
+
146
+ const sandboxRows = sandboxRowsForIntegration(workspaceConfig, integrationId);
147
+ const sandboxExists = sandboxRows.length > 0;
148
+ const sourceLinks = dataSourceRowsForIntegration(workspaceConfig, integrationId);
149
+ const sourceExists = sourceLinks.length > 0;
150
+ const linkedSourceId = sourceExists ? clean(sourceLinks[0].row?.sourceId) : "";
151
+ const linkedSourceObjectId = sourceExists ? clean(sourceLinks[0].objectId) : "";
152
+ // refresh-sources keys the sidecar by the data-source OBJECT id; older rows
153
+ // may have keyed by the row's sourceId. Check both so the step is honest.
154
+ const hasRecords = sourceLinks.some(
155
+ (link) => sidecarHasRecords(sourceRecords, clean(link.objectId))
156
+ || sidecarHasRecords(sourceRecords, clean(link.row?.sourceId)),
157
+ );
158
+
159
+ // resolverTemplateId other than the passthrough "custom-http" means a real
160
+ // shaping resolver is wired; "custom-http" / empty means raw passthrough.
161
+ const resolverTemplate = clean(row.resolverTemplateId);
162
+ const resolverWired = Boolean(resolverTemplate) && resolverTemplate !== "custom-http";
163
+
164
+ const steps = [];
165
+ const step = (s) => { steps.push(s); };
166
+
167
+ step({
168
+ id: "register",
169
+ label: "Register the API",
170
+ status: registered ? "complete" : "active",
171
+ description: registered
172
+ ? `Registered as "${integrationId}".`
173
+ : "Fill integrationId and a baseUrl or endpoint on this row.",
174
+ action: registered ? null : { id: "edit", label: "Edit fields" },
175
+ });
176
+
177
+ step({
178
+ id: "auth",
179
+ label: "Configure auth secret",
180
+ status: !authNeeded
181
+ ? (registered ? "complete" : "blocked")
182
+ : authConfigured
183
+ ? "complete"
184
+ : (registered ? "pending" : "blocked"),
185
+ description: !authNeeded
186
+ ? "This API needs no secret."
187
+ : authConfigured
188
+ ? `Secret for ${authRef} resolves in this runtime.`
189
+ : `Set one of ${envCandidates(authRef).join(" / ")} in .env.local (or your hosted runtime), then reopen. The workspace stores only the reference — the value never reaches the browser.`,
190
+ hint: authNeeded && !authConfigured
191
+ ? (haveEnvSignal
192
+ ? "Add the key to your runtime env; the cockpit re-checks resolution on reopen."
193
+ : "Runtime env signal unavailable — once the key is set and resolvable, this turns green.")
194
+ : undefined,
195
+ action: authNeeded && !authConfigured ? { id: "open-settings", label: "Manage in Settings", href: "/settings/apis-webhooks" } : null,
196
+ });
197
+
198
+ step({
199
+ id: "test",
200
+ label: "Test the API",
201
+ status: tested
202
+ ? "complete"
203
+ : (registered && authConfigured ? "active" : "blocked"),
204
+ description: tested
205
+ ? "Last test succeeded — lastResponse saved."
206
+ : "Run a server-side test; the secret stays server-side via authRef.",
207
+ action: !tested && registered && authConfigured ? { id: "test", label: "Test API" } : null,
208
+ });
209
+
210
+ step({
211
+ id: "resolver",
212
+ label: "Shape the response (resolver)",
213
+ status: resolverWired ? "complete" : (tested ? "optional" : "blocked"),
214
+ description: resolverWired
215
+ ? `Resolver "${resolverTemplate}" shapes the response into rows.`
216
+ : "Optional: add a resolver to normalize the response into governed rows. Raw passthrough works without one.",
217
+ action: tested && !resolverWired ? { id: "open-resolver", label: "Add resolver", href: "/api/workspace/resolver-templates" } : null,
218
+ });
219
+
220
+ step({
221
+ id: "data-source",
222
+ label: "Create a Data Source",
223
+ status: sourceExists
224
+ ? "complete"
225
+ : (tested ? "active" : "blocked"),
226
+ description: sourceExists
227
+ ? `Data Source linked (sourceId "${linkedSourceId}").`
228
+ : "Turn the tested API into a governed Data Source.",
229
+ action: !sourceExists && tested
230
+ ? { id: "create-data-source", label: "Create Data Source" }
231
+ : (sourceExists ? { id: "open-data-source", label: "Open Data Source", objectId: linkedSourceObjectId } : null),
232
+ });
233
+
234
+ step({
235
+ id: "refresh",
236
+ label: "Refresh source records",
237
+ status: hasRecords
238
+ ? "complete"
239
+ : (sourceExists ? "active" : "blocked"),
240
+ description: hasRecords
241
+ ? "The Data Source has hydrated records."
242
+ : "Pull live records into the workspace from the Data Source.",
243
+ action: sourceExists && !hasRecords
244
+ ? { id: "refresh-source", label: "Refresh source", sourceId: linkedSourceId, objectId: linkedSourceObjectId }
245
+ : null,
246
+ });
247
+
248
+ // Optional automation lane — a sandbox/workflow that calls this API.
249
+ step({
250
+ id: "sandbox-tool",
251
+ label: "Automate (sandbox tool)",
252
+ status: sandboxExists ? "complete" : (tested ? "optional" : "blocked"),
253
+ description: sandboxExists
254
+ ? "A sandbox tool calls this API."
255
+ : "Optional: wrap this API in a sandbox/workflow you can run or schedule.",
256
+ action: tested && !sandboxExists ? { id: "create-sandbox-tool", label: "Create sandbox tool" } : null,
257
+ });
258
+
259
+ for (const s of steps) { if (!s.hint) delete s.hint; }
260
+
261
+ const required = steps.filter((s) => s.status !== "optional");
262
+ const completedCount = required.filter((s) => s.status === "complete").length;
263
+ const totalCount = required.length;
264
+ const complete = completedCount >= totalCount;
265
+ // The active step (or first pending/blocked) is the operator's next move.
266
+ const nextStep = steps.find((s) => s.status === "active")
267
+ || steps.find((s) => s.status === "pending")
268
+ || steps.find((s) => s.status === "blocked")
269
+ || null;
270
+
271
+ // Activation score — milestone-based, tied to real evidence (not a raw
272
+ // step-count progress bar). Each threshold corresponds to a concrete,
273
+ // derived state transition the operator can verify.
274
+ let score = 0;
275
+ if (registered) score = 20;
276
+ if (registered && authConfigured) score = Math.max(score, 35);
277
+ if (tested) score = Math.max(score, 50);
278
+ if (sourceExists) score = Math.max(score, 65);
279
+ if (hasRecords) score = Math.max(score, 80);
280
+ if (sandboxExists) score = Math.max(score, 90);
281
+ if (complete) score = 100;
282
+
283
+ return {
284
+ kind: STEP_KIND,
285
+ version: 1,
286
+ integrationId,
287
+ registered,
288
+ tested,
289
+ authNeeded,
290
+ authConfigured,
291
+ sandboxExists,
292
+ sourceExists,
293
+ hasRecords,
294
+ completedCount,
295
+ totalCount,
296
+ complete,
297
+ score,
298
+ nextStepId: nextStep ? nextStep.id : null,
299
+ nextAction: nextStep && nextStep.action ? { stepId: nextStep.id, ...nextStep.action } : null,
300
+ headline: !registered
301
+ ? "Register this API to begin."
302
+ : complete
303
+ ? "This API is live end-to-end."
304
+ : "Finish wiring this API into the workspace.",
305
+ steps,
306
+ };
307
+ }
308
+
309
+ export {
310
+ STEP_KIND,
311
+ isRegistered,
312
+ isTested,
313
+ sandboxRowsForIntegration,
314
+ dataSourceRowsForIntegration,
315
+ sidecarHasRecords,
316
+ deriveApiRegistryCreationState,
317
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * API Response Profiler V1 — turns a tested API's `lastResponse` into a
3
+ * business-ready shape analysis, and recommends a resolver mode from it.
4
+ *
5
+ * This is the engine behind the cockpit's "Shape" lane: it is the difference
6
+ * between "API tested" and "API usable". Given the raw (already-fetched,
7
+ * server-side) response text, it finds the record array, infers field roles,
8
+ * proposes an entityType, and classifies what resolver work (if any) is needed
9
+ * to turn the response into governed rows.
10
+ *
11
+ * Pure + deterministic. No fetch, no secrets — it only inspects shape and a few
12
+ * sample values for type/role inference (and redaction is the caller's job for
13
+ * anything rendered). Never throws on malformed input.
14
+ */
15
+
16
+ function isPlainObject(value) {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function clean(value) {
21
+ return String(value == null ? "" : value).trim();
22
+ }
23
+
24
+ function parseResponse(lastResponse) {
25
+ if (isPlainObject(lastResponse) || Array.isArray(lastResponse)) return lastResponse;
26
+ const text = clean(lastResponse);
27
+ if (!text) return null;
28
+ try {
29
+ return JSON.parse(text);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ const ARRAY_CANDIDATE_KEYS = ["data", "items", "results", "records", "rows", "list", "values", "entries", "edges", "nodes"];
36
+ const PAGINATION_KEYS = ["next", "nextPage", "next_page", "cursor", "nextCursor", "next_cursor", "page", "offset", "hasMore", "has_more", "pageInfo", "_links", "paging"];
37
+
38
+ /** Locate the most likely record array and its dotted path within the payload. */
39
+ function findRecordArray(payload) {
40
+ if (Array.isArray(payload)) return { path: "", array: payload };
41
+ if (!isPlainObject(payload)) return { path: "", array: null };
42
+ // Prefer well-known container keys, then any top-level array, then one level deep.
43
+ for (const key of ARRAY_CANDIDATE_KEYS) {
44
+ if (Array.isArray(payload[key])) return { path: key, array: payload[key] };
45
+ }
46
+ for (const [key, value] of Object.entries(payload)) {
47
+ if (Array.isArray(value)) return { path: key, array: value };
48
+ }
49
+ for (const [key, value] of Object.entries(payload)) {
50
+ if (isPlainObject(value)) {
51
+ for (const inner of ARRAY_CANDIDATE_KEYS) {
52
+ if (Array.isArray(value[inner])) return { path: `${key}.${inner}`, array: value[inner] };
53
+ }
54
+ for (const [ik, iv] of Object.entries(value)) {
55
+ if (Array.isArray(iv)) return { path: `${key}.${ik}`, array: iv };
56
+ }
57
+ }
58
+ }
59
+ return { path: "", array: null };
60
+ }
61
+
62
+ function inferType(value) {
63
+ if (value === null || value === undefined) return "null";
64
+ if (Array.isArray(value)) return "array";
65
+ if (typeof value === "object") return "object";
66
+ if (typeof value === "boolean") return "boolean";
67
+ if (typeof value === "number") return "number";
68
+ const s = String(value);
69
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(s)) return "datetime";
70
+ if (/^-?\d+(\.\d+)?$/.test(s)) return "number";
71
+ if (/^(true|false)$/i.test(s)) return "boolean";
72
+ return "text";
73
+ }
74
+
75
+ function detectRole(name, type) {
76
+ const n = clean(name).toLowerCase();
77
+ if (n === "id" || n.endsWith("_id") || n.endsWith("id") && n.length <= 6) return "id";
78
+ if (n === "id" || n === "uuid" || n === "gid" || n === "key") return "id";
79
+ if (n.includes("email")) return "email";
80
+ if (n === "name" || n.endsWith("name") || n === "title" || n === "label") return "name";
81
+ if (n.includes("company") || n.includes("organization") || n.includes("org")) return "company";
82
+ if (type === "datetime" || n.includes("date") || n.includes("_at") || n.includes("time") || n.includes("created") || n.includes("updated")) return "timestamp";
83
+ if (n.includes("status") || n.includes("state") || n.includes("stage")) return "status";
84
+ return "";
85
+ }
86
+
87
+ /** Profile a sample record into typed/roled fields. */
88
+ function profileRecord(record) {
89
+ if (!isPlainObject(record)) return [];
90
+ return Object.entries(record).map(([name, value]) => {
91
+ const type = inferType(value);
92
+ const sample = type === "object" || type === "array" ? `[${type}]` : clean(value).slice(0, 60);
93
+ return { name, type, role: detectRole(name, type), sample };
94
+ });
95
+ }
96
+
97
+ const PROFILE_KIND = "growthub-api-response-profile-v1";
98
+
99
+ /**
100
+ * Profile a tested API response.
101
+ * Returns a typed profile; `usable` is true when a record array was found.
102
+ */
103
+ function profileApiResponse(lastResponse) {
104
+ const empty = {
105
+ kind: PROFILE_KIND,
106
+ parsed: false,
107
+ usable: false,
108
+ topLevelKeys: [],
109
+ arrayPath: "",
110
+ recordCount: 0,
111
+ fields: [],
112
+ candidates: { id: "", name: "", email: "", company: "", timestamp: "" },
113
+ suggestedEntityType: "records",
114
+ hasPagination: false,
115
+ };
116
+ const payload = parseResponse(lastResponse);
117
+ if (payload === null) return empty;
118
+
119
+ const topLevelKeys = isPlainObject(payload) ? Object.keys(payload) : [];
120
+ const hasPagination = isPlainObject(payload)
121
+ && PAGINATION_KEYS.some((k) => Object.prototype.hasOwnProperty.call(payload, k) && payload[k] != null && payload[k] !== false);
122
+
123
+ const { path, array } = findRecordArray(payload);
124
+ if (!Array.isArray(array) || array.length === 0) {
125
+ // No record array — a single object response is still profileable as one row.
126
+ if (isPlainObject(payload)) {
127
+ const fields = profileRecord(payload);
128
+ return {
129
+ ...empty,
130
+ parsed: true,
131
+ usable: fields.length > 0,
132
+ topLevelKeys,
133
+ arrayPath: "",
134
+ recordCount: isPlainObject(payload) ? 1 : 0,
135
+ fields,
136
+ candidates: pickCandidates(fields),
137
+ suggestedEntityType: "record",
138
+ hasPagination,
139
+ };
140
+ }
141
+ return { ...empty, parsed: true, topLevelKeys, hasPagination };
142
+ }
143
+
144
+ const sample = array.find(isPlainObject) || null;
145
+ const fields = profileRecord(sample);
146
+ const suggestedEntityType = path ? clean(path).split(".").pop() : "records";
147
+ return {
148
+ kind: PROFILE_KIND,
149
+ parsed: true,
150
+ usable: fields.length > 0,
151
+ topLevelKeys,
152
+ arrayPath: path,
153
+ recordCount: array.length,
154
+ fields,
155
+ candidates: pickCandidates(fields),
156
+ suggestedEntityType: suggestedEntityType || "records",
157
+ hasPagination,
158
+ };
159
+ }
160
+
161
+ function pickCandidates(fields) {
162
+ const byRole = (role) => (fields.find((f) => f.role === role)?.name) || "";
163
+ return {
164
+ id: byRole("id"),
165
+ name: byRole("name"),
166
+ email: byRole("email"),
167
+ company: byRole("company"),
168
+ timestamp: byRole("timestamp"),
169
+ };
170
+ }
171
+
172
+ const RESOLVER_RECOMMENDATION_KIND = "growthub-resolver-recommendation-v1";
173
+
174
+ /**
175
+ * Recommend a resolver mode from a response profile.
176
+ * none — top-level array, clean records: raw passthrough works.
177
+ * template — records under a known container (data/items/...): simple extraction.
178
+ * custom — nested/non-standard path or single-object: a resolver is recommended.
179
+ * required — pagination detected or no record array: a resolver is required to
180
+ * produce complete, governed rows.
181
+ */
182
+ function recommendResolver(profile) {
183
+ const p = isPlainObject(profile) ? profile : {};
184
+ if (!p.parsed) {
185
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "required", level: "required", rootPath: "", reason: "Response could not be parsed as JSON — a resolver must normalize it into rows." };
186
+ }
187
+ if (p.hasPagination) {
188
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "custom", level: "required", rootPath: p.arrayPath || "", reason: "Pagination detected — a resolver is required to fetch and concatenate all pages." };
189
+ }
190
+ if (!p.usable || p.recordCount === 0) {
191
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "custom", level: "required", rootPath: "", reason: "No record array found — a resolver is required to extract rows from this response." };
192
+ }
193
+ if (!p.arrayPath) {
194
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "none", level: "optional", rootPath: "", reason: "Top-level array of records — raw passthrough works; a resolver is optional." };
195
+ }
196
+ if (p.arrayPath.includes(".")) {
197
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "custom", level: "recommended", rootPath: p.arrayPath, reason: `Records are nested at "${p.arrayPath}" — a resolver is recommended to normalize them.` };
198
+ }
199
+ return { kind: RESOLVER_RECOMMENDATION_KIND, mode: "template", level: "recommended", rootPath: p.arrayPath, reason: `Records under "${p.arrayPath}" — a template resolver can extract them at that path.` };
200
+ }
201
+
202
+ export {
203
+ PROFILE_KIND,
204
+ RESOLVER_RECOMMENDATION_KIND,
205
+ profileApiResponse,
206
+ recommendResolver,
207
+ };
@@ -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 };