@growthub/cli 0.14.4 → 0.14.5

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 (20) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/resolvers/[integrationId]/route.js +157 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +5 -1
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +86 -4
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +30 -5
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +2 -2
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +400 -188
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +1 -1
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +1 -1
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +1 -1
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +3 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +24 -19
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +7 -82
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/resolver-constructor.js +217 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-registry.js +99 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/unified-resolver-registry.js +545 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +30 -2
  17. package/package.json +2 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +0 -141
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +0 -64
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +0 -376
@@ -16,7 +16,7 @@ export function OrchestrationGraphEmptyCanvas({
16
16
  <div className="dm-orchestration-canvas dm-orchestration-canvas--empty-state" aria-label="Empty orchestration graph">
17
17
  <div className="dm-orchestration-canvas__empty-card">
18
18
  <h3>Start orchestration graph</h3>
19
- <p>Create a governed run plan for this sandbox tool. Nothing executes until Run sandbox.</p>
19
+ <p>Create a governed workflow plan. Nothing executes until you run the workflow.</p>
20
20
  <div className="dm-orchestration-canvas__empty-actions">
21
21
  <button type="button" className="dm-btn-primary-sm" disabled={disabled} onClick={onStartFromRegistry}>
22
22
  Start from API Registry
@@ -531,7 +531,7 @@ export function OrchestrationRunTracePanel({
531
531
  Runs <span aria-hidden="true">/</span> <code>{activeConsoleRecord?.runId || "preview"}</code>
532
532
  </span>
533
533
  <h2>Run console</h2>
534
- <p>{summaryText} · {row?.Name || "Sandbox tool"}</p>
534
+ <p>{summaryText} · {row?.Name || "Workflow"}</p>
535
535
  </div>
536
536
  <div className="dm-run-console__head-actions">
537
537
  {canReplay && (
@@ -131,7 +131,7 @@ export function SandboxOrchestrationEditorPanel({
131
131
  </button>
132
132
  <div className="dm-orchestration-header__titles">
133
133
  <h2>Orchestration graph</h2>
134
- <p>{sandboxRow?.Name || "Sandbox tool"}</p>
134
+ <p>{sandboxRow?.Name || "Workflow"}</p>
135
135
  </div>
136
136
  </header>
137
137
 
@@ -5046,6 +5046,9 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
5046
5046
  }
5047
5047
  .dm-api-action-card-success { grid-template-columns: 1fr auto; }
5048
5048
  .dm-api-action-card-muted { grid-template-columns: 1fr; background: #fafafa; }
5049
+ .dm-api-action-card-workflow { grid-template-columns: 1fr; }
5050
+ .dm-api-action-card-workflow .dm-api-action-card-actions { align-items: flex-start; }
5051
+ .dm-api-action-card-workflow .dm-api-action-card-cta { align-self: flex-start; }
5049
5052
  .dm-api-action-card-note { font-size: 11px; color: #6b7280; margin-top: 4px; }
5050
5053
  .dm-api-action-checklist { margin: 8px 0 0; padding: 0; list-style: none; display: grid; gap: 6px; }
5051
5054
  .dm-api-action-checklist li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #374151; }
@@ -6,13 +6,13 @@
6
6
  * row being edited, the source-records sidecar, and the safe runtime signal, it
7
7
  * resolves the full operator journey for THIS API as an ordered list of steps:
8
8
  *
9
- * register → configure auth → test → (resolver) → sandbox tooldata source
10
- * refresh records
9
+ * register → configure auth → test → (resolver) → data sourcerefresh
10
+ * records, then a separate workflow canvas CTA after activation.
11
11
  *
12
12
  * Each step carries a status (complete | active | pending | blocked | optional),
13
13
  * a human description, and — when the operator can act — an `action` descriptor
14
14
  * the drawer maps to an existing handler (test / create-data-source /
15
- * create-sandbox-tool / open-data-source / refresh-source). The cockpit renders
15
+ * open-data-source / refresh-source / create-workflow-canvas). The cockpit renders
16
16
  * this verbatim, so the journey is one derivation, not UI guesswork.
17
17
  *
18
18
  * Invariants:
@@ -78,14 +78,18 @@ function sandboxRowsForIntegration(workspaceConfig, integrationId) {
78
78
  const rows = [];
79
79
  for (const object of findObjectsByType(workspaceConfig, "sandbox-environment")) {
80
80
  for (const row of Array.isArray(object.rows) ? object.rows : []) {
81
- const envRefs = clean(row?.envRefs);
82
81
  const schedulerId = clean(row?.schedulerRegistryId);
83
- const cfg = parseMaybeJson(row?.orchestrationConfig);
82
+ const cfg = parseMaybeJson(
83
+ row?.orchestrationConfig
84
+ || row?.orchestrationGraph
85
+ || row?.orchestrationDraftConfig
86
+ || row?.orchestrationDraftGraph
87
+ );
84
88
  const callsApi = Array.isArray(cfg?.nodes) && cfg.nodes.some(
85
89
  (n) => n?.type === "api-registry-call"
86
90
  && clean(n?.config?.registryId || n?.config?.integrationId) === id,
87
91
  );
88
- if (callsApi || schedulerId === id || envRefs.split(",").map(clean).includes(clean(row?.authRef))) {
92
+ if (callsApi || schedulerId === id) {
89
93
  rows.push(row);
90
94
  }
91
95
  }
@@ -213,8 +217,11 @@ function deriveApiRegistryCreationState(input = {}) {
213
217
  status: resolverWired ? "complete" : (tested ? "optional" : "blocked"),
214
218
  description: resolverWired
215
219
  ? `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,
220
+ : "Optional: construct a resolver to normalize the response into governed rows. Raw passthrough works without one.",
221
+ // CMS SDK v1.5.1 the action constructs the governed resolver from the
222
+ // tested response shape (no blank form). The cockpit stages it for one-screen
223
+ // review, applies through the governed lane, and re-tests.
224
+ action: tested && !resolverWired ? { id: "construct-resolver", label: "Construct resolver" } : null,
218
225
  });
219
226
 
220
227
  step({
@@ -245,17 +252,6 @@ function deriveApiRegistryCreationState(input = {}) {
245
252
  : null,
246
253
  });
247
254
 
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
255
  for (const s of steps) { if (!s.hint) delete s.hint; }
260
256
 
261
257
  const required = steps.filter((s) => s.status !== "optional");
@@ -297,6 +293,15 @@ function deriveApiRegistryCreationState(input = {}) {
297
293
  score,
298
294
  nextStepId: nextStep ? nextStep.id : null,
299
295
  nextAction: nextStep && nextStep.action ? { stepId: nextStep.id, ...nextStep.action } : null,
296
+ workflowAction: tested
297
+ ? {
298
+ id: sandboxExists ? "open-workflow-canvas" : "create-workflow-canvas",
299
+ label: sandboxExists ? "Open workflow canvas" : "Create workflow",
300
+ description: sandboxExists
301
+ ? "Open the workflow canvas that uses this API Registry node."
302
+ : "Open a workflow canvas with this API Registry call already drafted.",
303
+ }
304
+ : null,
300
305
  headline: !registered
301
306
  ? "Register this API to begin."
302
307
  : complete
@@ -28,7 +28,7 @@ const KNOWN_NODE_TYPES = new Set([
28
28
 
29
29
  const API_REGISTRY_SETUP_FIELDS = ["integrationId", "baseUrl", "endpoint", "method", "authRef"];
30
30
 
31
- const CANONICAL_NODE_ORDER = ["input", "api-request", "transform", "result"];
31
+ const CANONICAL_NODE_ORDER = ["human-input", "input", "api-request", "transform", "result"];
32
32
 
33
33
  function slugifyName(value) {
34
34
  return String(value || "")
@@ -91,34 +91,6 @@ function isApiRegistrySetupComplete(registryRow) {
91
91
  return getApiRegistrySetupChecklist(registryRow).every((item) => item.ok);
92
92
  }
93
93
 
94
- /**
95
- * Sidecar action state for API Registry → sandbox tool bridge (UI only).
96
- */
97
- function getApiRegistrySandboxToolState(registryRow, workspaceConfig) {
98
- if (!isApiRegistrySetupComplete(registryRow)) {
99
- return { kind: "incomplete", checklist: getApiRegistrySetupChecklist(registryRow) };
100
- }
101
- if (!isApiRegistryTestSuccessful(registryRow)) {
102
- const status = String(registryRow?.status || "").trim().toLowerCase();
103
- if (status === "failed") {
104
- return {
105
- kind: "failed",
106
- message: "Connection test failed. Fix the endpoint or auth reference, then test again."
107
- };
108
- }
109
- return {
110
- kind: "untested",
111
- message: "Test connection first. Sandbox tool creation unlocks after a successful test."
112
- };
113
- }
114
- const integrationId = String(registryRow?.integrationId || "").trim();
115
- const existing = findSandboxRowsForRegistry(workspaceConfig, integrationId);
116
- if (existing.length > 0) {
117
- return { kind: "existing", row: existing[0] };
118
- }
119
- return { kind: "create" };
120
- }
121
-
122
94
  function validateOrchestrationGraph(graph) {
123
95
  const errors = [];
124
96
  if (!graph || typeof graph !== "object") {
@@ -306,7 +278,12 @@ function findSandboxRowsForRegistry(workspaceConfig, integrationId) {
306
278
  if (!sandboxObject) return [];
307
279
  const rows = Array.isArray(sandboxObject.rows) ? sandboxObject.rows : [];
308
280
  return rows.filter((row) => {
309
- const graph = parseOrchestrationGraph(row?.orchestrationConfig || row?.orchestrationGraph);
281
+ const graph = parseOrchestrationGraph(
282
+ row?.orchestrationConfig
283
+ || row?.orchestrationGraph
284
+ || row?.orchestrationDraftConfig
285
+ || row?.orchestrationDraftGraph
286
+ );
310
287
  if (!graph?.nodes) return String(row?.schedulerRegistryId || "").trim() === id;
311
288
  return graph.nodes.some(
312
289
  (node) => node?.type === "api-registry-call"
@@ -315,56 +292,6 @@ function findSandboxRowsForRegistry(workspaceConfig, integrationId) {
315
292
  });
316
293
  }
317
294
 
318
- function buildSandboxRowFromApiRegistry(workspaceConfig, registryRow, options = {}) {
319
- const integrationId = String(registryRow?.integrationId || "").trim();
320
- const baseName = String(options.name || registryRow?.Name || integrationId || "Sandbox Tool").trim();
321
- const name = baseName.endsWith(" Tool") ? baseName : `${baseName} Tool`;
322
- const runLocality = String(options.runLocality || "local").trim() === "serverless" ? "serverless" : "local";
323
- const adapter = String(options.adapter || (runLocality === "serverless" ? "serverless" : "local-process")).trim();
324
- const graph = options.orchestrationGraph
325
- ? (typeof options.orchestrationGraph === "string"
326
- ? parseOrchestrationGraph(options.orchestrationGraph)
327
- : options.orchestrationGraph)
328
- : buildDefaultOrchestrationGraphFromRegistry(registryRow, options);
329
-
330
- const apiNode = graph?.nodes?.find((n) => n?.type === "api-registry-call");
331
- const authRef = String(options.authRef || apiNode?.config?.authRef || registryRow?.authRef || integrationId).trim();
332
- const transformNode = graph?.nodes?.find((n) => n?.type === "transform-filter" || n?.type === "normalize-output");
333
- const rootPath = transformNode?.config?.rootPath || "data";
334
- const method = String(registryRow?.method || apiNode?.config?.method || "GET").trim().toUpperCase();
335
- const endpoint = String(registryRow?.endpoint || apiNode?.config?.endpoint || "").trim();
336
- const baseUrl = String(registryRow?.baseUrl || apiNode?.config?.baseUrl || "").trim();
337
-
338
- return {
339
- Name: name,
340
- slug: options.slug || slugifyName(name) || slugifyName(integrationId),
341
- objectType: "sandbox-environment",
342
- lifecycleStatus: "draft",
343
- version: "1",
344
- runLocality,
345
- schedulerRegistryId: runLocality === "serverless" ? integrationId : "",
346
- runtime: String(options.runtime || "node").trim(),
347
- adapter,
348
- agentHost: String(options.agentHost || "").trim(),
349
- envRefs: Array.isArray(options.envRefs) ? options.envRefs.join(",") : String(options.envRefs || "").trim(),
350
- networkAllow: options.networkAllow === true ? "true" : "",
351
- allowList: String(options.allowList || "").trim(),
352
- instructions: String(
353
- options.instructions
354
- || `Governed sandbox tool for ${integrationId}. Calls ${method} ${endpoint || baseUrl} and normalizes at "${rootPath}". authRef ${authRef} only — secrets resolve server-side.`
355
- ).trim(),
356
- command: String(options.command || "").trim(),
357
- timeoutMs: String(options.timeoutMs || "30000").trim(),
358
- status: "untested",
359
- lastTested: "",
360
- lastRunId: "",
361
- lastSourceId: "",
362
- lastResponse: "",
363
- orchestrationConfig: serializeOrchestrationGraph(graph),
364
- description: String(options.description || registryRow?.description || "").trim()
365
- };
366
- }
367
-
368
295
  /**
369
296
  * Find existing data-source rows that already resolve through a given API
370
297
  * Registry integration (by `registryId`). Mirrors findSandboxRowsForRegistry so
@@ -986,7 +913,6 @@ export {
986
913
  getOrchestrationGraphUiState,
987
914
  getNextCanonicalNodeId,
988
915
  addCanonicalNodeToGraph,
989
- buildSandboxRowFromApiRegistry,
990
916
  buildDataSourceRowFromApiRegistry,
991
917
  findDataSourceRowsForRegistry,
992
918
  extractApiRegistryCallNode,
@@ -996,7 +922,6 @@ export {
996
922
  findSandboxObject,
997
923
  findSandboxRowsForRegistry,
998
924
  getApiRegistrySetupChecklist,
999
- getApiRegistrySandboxToolState,
1000
925
  isApiRegistrySetupComplete,
1001
926
  isApiRegistryTestSuccessful,
1002
927
  normalizeJsonAtPath,
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Resolver Constructor V1 (CMS SDK v1.5.1) — closes the no-code "too many open
3
+ * fields" gap. Instead of asking a non-technical user to hand-author a
4
+ * resolver's rootPath / idField / entityType / auth header, this constructs the
5
+ * governed resolver from facts ALREADY computed: the tested response profile
6
+ * (`profileApiResponse`) and the resolver recommendation (`recommendResolver`),
7
+ * plus the row's own auth config (mirrored from how test-api-record sent it, so
8
+ * the resolver behaves exactly like the test that just passed).
9
+ *
10
+ * Agnostic across the normalized governance taxonomy (http | custom | tool | mcp
11
+ * | chrome | nango), via a single builder dispatch (`getResolverBuilder`), the
12
+ * Nango precedent generalized:
13
+ * - HTTP-shaped kinds (http / custom / and any non-reserved value, including a
14
+ * literal "webhook" an operator may type) materialize a server file built
15
+ * from the response shape;
16
+ * - nango is config-driven (no file) with honest readiness;
17
+ * - reserved kinds (mcp | chrome | tool) cannot be auto-constructed from an
18
+ * HTTP response — advertised truthfully with a concrete next action, never
19
+ * left blank or mislabeled.
20
+ *
21
+ * `connectorKind` is operator-editable text and is honored verbatim — it is
22
+ * never silently normalized.
23
+ *
24
+ * Pure: no fetch, no secrets, never throws. The returned `proposal` (file mode)
25
+ * flows ONLY through the governed apply lane (helper/apply → writeResolverProposalFile)
26
+ * and the no-code cockpit — never a hand edit.
27
+ */
28
+
29
+ import { buildResolverProposal } from "./workspace-resolver-proposal.js";
30
+ import { slugifyIntegrationId, RESOLVER_ENDPOINT_BASE } from "./unified-resolver-registry.js";
31
+
32
+ function clean(value) {
33
+ return String(value == null ? "" : value).trim();
34
+ }
35
+
36
+ /** The canonical governed endpoint a row will be exposed at once registered. */
37
+ function endpointFor(integrationId) {
38
+ const slug = slugifyIntegrationId(integrationId, "");
39
+ return slug ? `${RESOLVER_ENDPOINT_BASE}/${slug}` : null;
40
+ }
41
+
42
+ /**
43
+ * A plain-language "what the system detected" summary + a confidence band, so the
44
+ * review panel can SHOW understanding ("I found 42 records under data.items…")
45
+ * instead of a developer form. Pure; derived from the tested response profile.
46
+ * high — top-level/clean records: direct apply is safe.
47
+ * medium — records nested under a container: review the mapping, then apply.
48
+ * low — pagination or no record array: needs human review before trust.
49
+ */
50
+ function detectShape(profile, recommendation) {
51
+ if (!profile || !profile.parsed) return null;
52
+ const level = clean(recommendation?.level);
53
+ let confidence = "high";
54
+ if (profile.hasPagination || !profile.usable) confidence = "low";
55
+ else if (level === "recommended") confidence = "medium";
56
+ else if (level === "required") confidence = "low";
57
+ const recordPath = clean(profile.arrayPath);
58
+ const entityType = clean(profile.suggestedEntityType) || "records";
59
+ const idField = clean(profile.candidates?.id) || "id";
60
+ return {
61
+ confidence,
62
+ recordCount: Number.isFinite(profile.recordCount) ? profile.recordCount : 0,
63
+ recordPath,
64
+ idField,
65
+ entityType,
66
+ hasPagination: Boolean(profile.hasPagination),
67
+ // One human sentence the panel can show verbatim.
68
+ sentence: profile.usable
69
+ ? `Found ${profile.recordCount} ${entityType}${recordPath ? ` under "${recordPath}"` : " (top-level)"}, keyed by "${idField}"${profile.hasPagination ? " — paginated, so a resolver is required to fetch every page" : ""}.`
70
+ : "No record array detected — review the response before activating.",
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The auth header/prefix the resolver must send — mirrored from how
76
+ * test-api-record built its request (authHeaderName || authHeader || x-api-key;
77
+ * authPrefix), so a constructed resolver matches the test that just succeeded.
78
+ */
79
+ function deriveAuthHeader(row) {
80
+ const headerName = clean(row?.authHeaderName) || clean(row?.authHeader) || "x-api-key";
81
+ const prefix = clean(row?.authPrefix);
82
+ return { headerName, prefix };
83
+ }
84
+
85
+ function constructCustomHttpProposal({ row, profile, recommendation, recordRef }) {
86
+ const { headerName, prefix } = deriveAuthHeader(row);
87
+ const rootPath = clean(profile?.arrayPath) || clean(recommendation?.rootPath);
88
+ const idField = clean(profile?.candidates?.id) || "id";
89
+ const entityType = clean(profile?.suggestedEntityType) || clean(row?.entityTypes) || "records";
90
+
91
+ const blanks = [];
92
+ if (!clean(row?.integrationId)) blanks.push("integrationId");
93
+ if (!clean(row?.baseUrl) && !clean(row?.endpoint)) blanks.push("target (baseUrl or endpoint)");
94
+
95
+ const proposal = buildResolverProposal({
96
+ integrationId: row?.integrationId,
97
+ baseUrl: row?.baseUrl,
98
+ endpoint: row?.endpoint,
99
+ method: row?.method,
100
+ authRef: row?.authRef,
101
+ headerName,
102
+ prefix,
103
+ rootPath,
104
+ idField,
105
+ entityType,
106
+ recordRef,
107
+ });
108
+
109
+ const detected = detectShape(profile, recommendation);
110
+ // Respect the row's declared governance kind (http / custom / webhook / …);
111
+ // default to http when unset. Only the resolver IMPLEMENTATION is HTTP here.
112
+ const declaredKind = clean(row?.connectorKind).toLowerCase() || "http";
113
+ return {
114
+ ok: blanks.length === 0,
115
+ mode: "file",
116
+ connectorKind: declaredKind,
117
+ endpoint: endpointFor(row?.integrationId),
118
+ proposal,
119
+ prefill: { rootPath, idField, entityType, headerName, prefix },
120
+ detected,
121
+ confidence: detected ? detected.confidence : "low",
122
+ authRef: clean(row?.authRef),
123
+ blanks,
124
+ reason: blanks.length
125
+ ? `Fill ${blanks.join(", ")} on the row before constructing a resolver.`
126
+ : (recommendation?.reason || "Resolver constructed from the tested response shape."),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Honest readiness for a config-driven (Nango) row. A resolver registers from
132
+ * the row automatically — but only if the row actually carries the minimum
133
+ * binding. We never report "nothing to apply / done" when the endpoint would not
134
+ * be usable; missing config returns an actionable next step instead.
135
+ */
136
+ function constructNangoReadiness({ row }) {
137
+ const blanks = [];
138
+ const providerKey = clean(row?.providerConfigKey) || clean(row?.integrationId);
139
+ if (!providerKey) blanks.push("providerConfigKey (or integrationId)");
140
+ const hasConnection =
141
+ clean(row?.connectionIds) || clean(row?.connectionId) || clean(row?.nangoConnectionId);
142
+ if (!hasConnection) blanks.push("connectionIds");
143
+ if (!clean(row?.endpoint)) blanks.push("endpoint (Nango proxy path)");
144
+ const ready = blanks.length === 0;
145
+ return {
146
+ ok: ready,
147
+ mode: "config-driven",
148
+ connectorKind: "nango",
149
+ endpoint: endpointFor(row?.integrationId),
150
+ proposal: null,
151
+ prefill: null,
152
+ detected: null,
153
+ blanks,
154
+ state: ready ? "config-driven-ready" : "config-driven-missing-config",
155
+ reason: ready
156
+ ? "Config-driven via Nango — the resolver registers from this row automatically once it loads; no file to write. Confirm it appears as registered in the registry."
157
+ : `Config-driven via Nango, but the row is missing ${blanks.join(", ")}. Add these so the resolver can register and the endpoint becomes usable.`,
158
+ nextAction: ready ? null : { id: "edit", label: `Add ${blanks.join(", ")} to the row` },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Resolve a builder for a connector kind. Each builder emits the same
164
+ * `{ ok, mode, connectorKind, proposal, prefill, blanks, reason }` contract.
165
+ * - custom-http (default) → file mode (materialized resolver)
166
+ * - nango → config-driven (no file; built from the row)
167
+ * - mcp/webhook/chrome → not yet supported (truthful, not blank)
168
+ */
169
+ function getResolverBuilder(connectorKind) {
170
+ const kind = clean(connectorKind).toLowerCase();
171
+ if (kind === "nango") {
172
+ return (args) => constructNangoReadiness(args);
173
+ }
174
+ // Reserved for auto-construction — these need their own resolver implementation
175
+ // and cannot be derived from an HTTP response shape (taxonomy: mcp|chrome|tool).
176
+ if (["mcp", "chrome", "tool"].includes(kind)) {
177
+ return (args) => ({
178
+ ok: false,
179
+ mode: "unsupported",
180
+ reserved: true,
181
+ connectorKind: kind,
182
+ endpoint: endpointFor(args?.row?.integrationId),
183
+ proposal: null,
184
+ prefill: null,
185
+ detected: null,
186
+ blanks: [],
187
+ // Reserved should build confidence, not feel like a dead end: say what is
188
+ // reserved, what works now, and the concrete next move.
189
+ reason: `Auto-construction for "${kind}" connectors is reserved for a future release. What works today: set this row's connector to custom-http and construct a resolver, or ask the governed helper to propose a plan. Your record stays governed either way.`,
190
+ nextAction: { id: "use-custom-http", label: "Switch to a custom-http resolver" },
191
+ });
192
+ }
193
+ return (args) => constructCustomHttpProposal(args);
194
+ }
195
+
196
+ /**
197
+ * Construct a governed resolver for one API Registry row from its tested shape.
198
+ *
199
+ * @param {object} input
200
+ * @param {object} input.row the api-registry row (drawer draft)
201
+ * @param {object} [input.profile] profileApiResponse(row.lastResponse)
202
+ * @param {object} [input.recommendation] recommendResolver(profile)
203
+ * @param {object} [input.recordRef] { objectId, rowName } of the governed record
204
+ * @returns {{ ok, mode, connectorKind, proposal, prefill, blanks, reason }}
205
+ */
206
+ function constructResolverProposal(input = {}) {
207
+ const row = input.row && typeof input.row === "object" ? input.row : {};
208
+ const builder = getResolverBuilder(row.connectorKind);
209
+ return builder({
210
+ row,
211
+ profile: input.profile || null,
212
+ recommendation: input.recommendation || null,
213
+ recordRef: input.recordRef || null,
214
+ });
215
+ }
216
+
217
+ export { constructResolverProposal, getResolverBuilder };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Server Resolver Registry IO V1 — the confined, server-only bridge between the
3
+ * pure unified-resolver-registry deriver and the filesystem.
4
+ *
5
+ * Responsibilities (server-only; the browser never imports this):
6
+ * - read the provenance header off each resolver file (to tag
7
+ * helper-generated vs static-file provenance)
8
+ * - persist the externalized, agent-readable index artifact and the endpoint
9
+ * manifest (gated by persistence mode — read-only runtimes skip silently
10
+ * and the live derivation is still returned over the API)
11
+ *
12
+ * The artifacts are PROJECTIONS of the governed records — do-not-edit, and kept
13
+ * in sync by this write-through. Never logs file contents. Contract:
14
+ * `@growthub/api-contract/resolver-registry`.
15
+ */
16
+
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+ import { describePersistenceMode } from "@/lib/workspace-config";
20
+ import {
21
+ RESOLVER_REGISTRY_DIR,
22
+ RESOLVER_REGISTRY_INDEX_FILE,
23
+ RESOLVER_ENDPOINT_MANIFEST_FILE,
24
+ parseResolverFileHeader,
25
+ slugifyIntegrationId,
26
+ buildEndpointManifest,
27
+ } from "@/lib/unified-resolver-registry";
28
+
29
+ const HEADER_BYTES = 600;
30
+
31
+ function resolversDirAbs() {
32
+ return path.resolve(/*turbopackIgnore: true*/ process.cwd(), RESOLVER_REGISTRY_DIR);
33
+ }
34
+
35
+ /**
36
+ * Read the provenance header of every resolver file.
37
+ * Returns { [slug]: { generated, integrationId, record } }. Never throws.
38
+ */
39
+ async function readResolverFileMeta() {
40
+ const dir = resolversDirAbs();
41
+ const meta = {};
42
+ let entries;
43
+ try {
44
+ entries = await fs.readdir(dir);
45
+ } catch {
46
+ return meta;
47
+ }
48
+ const jsFiles = entries.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."));
49
+ await Promise.all(
50
+ jsFiles.map(async (file) => {
51
+ const slug = slugifyIntegrationId(file, "");
52
+ if (!slug) return;
53
+ try {
54
+ const handle = await fs.open(path.join(dir, file), "r");
55
+ try {
56
+ const buf = Buffer.alloc(HEADER_BYTES);
57
+ const { bytesRead } = await handle.read(buf, 0, HEADER_BYTES, 0);
58
+ meta[slug] = parseResolverFileHeader(buf.toString("utf8", 0, bytesRead));
59
+ } finally {
60
+ await handle.close();
61
+ }
62
+ } catch {
63
+ // Unreadable file — leave it out of meta; the deriver still sees the
64
+ // filename via `files` and classifies it as static-file.
65
+ }
66
+ }),
67
+ );
68
+ return meta;
69
+ }
70
+
71
+ /**
72
+ * Persist the index + endpoint manifest artifacts (gated). Returns
73
+ * { written: boolean, reason?: string }. Read-only runtimes are not an error —
74
+ * the live API derivation remains the source of truth at request time.
75
+ */
76
+ async function persistResolverRegistryArtifacts(index) {
77
+ const persistence = describePersistenceMode();
78
+ if (!persistence.canSave) {
79
+ return { written: false, reason: persistence.reason || "read-only runtime" };
80
+ }
81
+ const dir = resolversDirAbs();
82
+ try {
83
+ await fs.mkdir(dir, { recursive: true });
84
+ const indexPath = path.resolve(/*turbopackIgnore: true*/ process.cwd(), RESOLVER_REGISTRY_INDEX_FILE);
85
+ const manifestPath = path.resolve(/*turbopackIgnore: true*/ process.cwd(), RESOLVER_ENDPOINT_MANIFEST_FILE);
86
+ // Confinement — both artifacts live directly in the resolvers dir.
87
+ if (path.dirname(indexPath) !== dir || path.dirname(manifestPath) !== dir) {
88
+ return { written: false, reason: "artifact path escaped the resolvers dir" };
89
+ }
90
+ const manifest = buildEndpointManifest(index, index.generatedAt);
91
+ await fs.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8");
92
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
93
+ return { written: true };
94
+ } catch (err) {
95
+ return { written: false, reason: err?.message || "artifact write failed" };
96
+ }
97
+ }
98
+
99
+ export { readResolverFileMeta, persistResolverRegistryArtifacts };