@growthub/cli 0.14.3 → 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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/resolvers/[integrationId]/route.js +157 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/env-status/route.js +5 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +33 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +86 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +30 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +532 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +400 -188
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +36 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +9 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +14 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +24 -19
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-agent-teams.js +211 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-bootstrap-console.js +325 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-cockpit-console.js +206 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +7 -82
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/resolver-constructor.js +217 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-registry.js +99 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/unified-resolver-registry.js +545 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +2 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +30 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +69 -0
- package/package.json +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +0 -141
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +0 -64
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolDraftPanel.jsx +0 -376
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified API Resolver Registry V1 — the keystone read/trace layer for the
|
|
3
|
+
* CMS SDK v1.5.1 enhancement. Pure derivation only.
|
|
4
|
+
*
|
|
5
|
+
* AWaC reality this unifies: today the workspace has TWO disjoint resolver
|
|
6
|
+
* lanes (static files under lib/adapters/integrations/resolvers/, loaded by
|
|
7
|
+
* resolver-loader; and config-driven Nango resolvers built in-memory from
|
|
8
|
+
* `api-registry` rows by nango-config-loader) plus a one-shot helper generator
|
|
9
|
+
* (workspace-resolver-proposal). Nothing correlated a resolver back to the
|
|
10
|
+
* governed `api-registry` record it serves. This module is that correlation:
|
|
11
|
+
* for every api-registry row across `dataModel.objects[]`, it resolves the
|
|
12
|
+
* resolver's connector kind, provenance, file, registered/tested state, the
|
|
13
|
+
* response shape, the activation score + next action, and the governed endpoint
|
|
14
|
+
* the record is exposed at.
|
|
15
|
+
*
|
|
16
|
+
* Invariants (mirrors every other deriver in the workspace):
|
|
17
|
+
* - PURE: no fetch, no process.env, no fs, no React, never throws on partial
|
|
18
|
+
* input. The route injects `files`, `registeredIds`, `fileMeta`, `runtime`,
|
|
19
|
+
* and `sourceRecords`.
|
|
20
|
+
* - SECRET-SAFE: ids, slugs, counts, booleans, and paths only.
|
|
21
|
+
* - ADDITIVE: reads the existing governed record shape; the api-registry row
|
|
22
|
+
* stays the single source of truth. Generated artifacts are projections.
|
|
23
|
+
*
|
|
24
|
+
* Contract: `@growthub/api-contract/resolver-registry`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { deriveApiRegistryCreationState } from "./api-registry-creation-flow.js";
|
|
28
|
+
import { profileApiResponse, recommendResolver } from "./api-response-profile.js";
|
|
29
|
+
|
|
30
|
+
const RESOLVER_REGISTRY_INDEX_KIND = "growthub-resolver-registry-index-v1";
|
|
31
|
+
const RESOLVER_REGISTRY_DIR = "lib/adapters/integrations/resolvers";
|
|
32
|
+
const RESOLVER_REGISTRY_INDEX_FILE = `${RESOLVER_REGISTRY_DIR}/_registry.generated.json`;
|
|
33
|
+
const RESOLVER_ENDPOINT_MANIFEST_FILE = `${RESOLVER_REGISTRY_DIR}/_endpoints.generated.json`;
|
|
34
|
+
const RESOLVER_ENDPOINT_MANIFEST_KIND = "growthub-resolver-endpoint-manifest-v1";
|
|
35
|
+
const RESOLVER_ENDPOINT_BASE = "/api/resolvers";
|
|
36
|
+
const RESOLVER_GENERATED_BANNER =
|
|
37
|
+
"@growthub-resolver generated — do not edit; edit the governed api-registry record";
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clean(value) {
|
|
44
|
+
return String(value == null ? "" : value).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize a governance list field (array or comma-string) to string[]. */
|
|
48
|
+
function parseList(value) {
|
|
49
|
+
if (Array.isArray(value)) return value.map((v) => clean(v)).filter(Boolean);
|
|
50
|
+
return clean(value)
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((v) => v.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mirrors workspace-resolver-proposal slugify so file <-> row correlation is exact. */
|
|
57
|
+
function slugifyIntegrationId(value, fallback = "resolver") {
|
|
58
|
+
const slug = clean(value)
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/\.js$/, "")
|
|
61
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
62
|
+
.replace(/-+/g, "-")
|
|
63
|
+
.replace(/^-|-$/g, "")
|
|
64
|
+
.slice(0, 64);
|
|
65
|
+
return slug || fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Encode a record ref into a slug/whitespace-safe machine tag (base64url of the
|
|
70
|
+
* JSON). Human row names (spaces, colons, slashes, emoji, quotes, newlines)
|
|
71
|
+
* cannot corrupt the generated header this way.
|
|
72
|
+
*/
|
|
73
|
+
function encodeRecordTag(recordRef) {
|
|
74
|
+
if (!recordRef || typeof recordRef !== "object") return "";
|
|
75
|
+
const payload = {
|
|
76
|
+
objectId: clean(recordRef.objectId),
|
|
77
|
+
rowName: clean(recordRef.rowName),
|
|
78
|
+
integrationId: clean(recordRef.integrationId),
|
|
79
|
+
};
|
|
80
|
+
if (!payload.objectId && !payload.rowName && !payload.integrationId) return "";
|
|
81
|
+
try {
|
|
82
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Decode a base64url record tag back into { objectId, rowName, integrationId } or null. */
|
|
89
|
+
function decodeRecordTag(tag) {
|
|
90
|
+
const t = clean(tag);
|
|
91
|
+
if (!t) return null;
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(Buffer.from(t, "base64url").toString("utf8"));
|
|
94
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse the machine-readable provenance header a generated resolver file
|
|
102
|
+
* carries. Server reads the file head; we extract the banner, the slug-safe
|
|
103
|
+
* `integrationId=` token, and decode the base64url `record=` tag back into a
|
|
104
|
+
* full recordRef (so row names with spaces/special chars survive intact). Pure.
|
|
105
|
+
*/
|
|
106
|
+
function parseResolverFileHeader(text) {
|
|
107
|
+
const head = clean(text).slice(0, 600);
|
|
108
|
+
const generated = head.includes(RESOLVER_GENERATED_BANNER);
|
|
109
|
+
const idMatch = head.match(/@growthub-resolver[^\n]*\bintegrationId=([a-z0-9-]+)/i);
|
|
110
|
+
const recordMatch = head.match(/\brecord=([A-Za-z0-9_-]+)/);
|
|
111
|
+
const recordRef = recordMatch ? decodeRecordTag(recordMatch[1]) : null;
|
|
112
|
+
return {
|
|
113
|
+
generated,
|
|
114
|
+
integrationId: idMatch ? idMatch[1] : "",
|
|
115
|
+
record: recordMatch ? recordMatch[1] : "",
|
|
116
|
+
recordRef,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findApiRegistryObjects(workspaceConfig) {
|
|
121
|
+
const objects = Array.isArray(workspaceConfig?.dataModel?.objects)
|
|
122
|
+
? workspaceConfig.dataModel.objects
|
|
123
|
+
: [];
|
|
124
|
+
return objects.filter((o) => isPlainObject(o) && o.objectType === "api-registry");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Kinds that need their own resolver implementation — they cannot be
|
|
128
|
+
// auto-constructed from an HTTP response shape (aligned with the template
|
|
129
|
+
// registry taxonomy: http | custom | tool | mcp | chrome | nango).
|
|
130
|
+
const RESERVED_KINDS = new Set(["mcp", "chrome", "tool"]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The connector kind for a row, honoring the normalized governance taxonomy.
|
|
134
|
+
* `connectorKind` is operator-editable text, so any declared value is respected
|
|
135
|
+
* verbatim (lowercased); only when it is absent do we infer — a materialized /
|
|
136
|
+
* registered resolver is HTTP-shaped, otherwise there is none yet.
|
|
137
|
+
*/
|
|
138
|
+
function deriveConnectorKind(row, { registered, hasFile }) {
|
|
139
|
+
const declared = clean(row?.connectorKind).toLowerCase();
|
|
140
|
+
if (declared) return declared;
|
|
141
|
+
if (hasFile || registered) return "http";
|
|
142
|
+
return "none";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve provenance — how this row's resolver came to exist. The single fact
|
|
147
|
+
* that did not exist before 1.5.1.
|
|
148
|
+
*/
|
|
149
|
+
function deriveProvenance(row, { connectorKind, registered, hasFile, fileGenerated, recommendation }) {
|
|
150
|
+
if (connectorKind === "nango") {
|
|
151
|
+
return registered ? "config-driven" : "missing";
|
|
152
|
+
}
|
|
153
|
+
if (hasFile) {
|
|
154
|
+
return fileGenerated ? "helper-generated" : "static-file";
|
|
155
|
+
}
|
|
156
|
+
if (registered) {
|
|
157
|
+
// Registered with no static file and not nango — a config-driven resolver
|
|
158
|
+
// registered at runtime (future connector kinds reuse this lane).
|
|
159
|
+
return "config-driven";
|
|
160
|
+
}
|
|
161
|
+
// Reserved kinds (mcp/chrome/tool) cannot raw-passthrough as HTTP — with no
|
|
162
|
+
// resolver wired they genuinely NEED one, regardless of test state.
|
|
163
|
+
if (RESERVED_KINDS.has(connectorKind)) return "missing";
|
|
164
|
+
// No resolver present. Passthrough is honest only when shaping isn't required.
|
|
165
|
+
if (recommendation && recommendation.level === "required") return "missing";
|
|
166
|
+
return "passthrough";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Derive one ResolverRegistryEntry for a single api-registry row.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} input
|
|
173
|
+
* @param {object} input.workspaceConfig
|
|
174
|
+
* @param {object} input.object the api-registry Data Model object
|
|
175
|
+
* @param {object} input.row the api-registry row
|
|
176
|
+
* @param {Set<string>} input.registeredSet
|
|
177
|
+
* @param {Set<string>} input.fileSlugs slugs (no .js) of present resolver files
|
|
178
|
+
* @param {object} input.fileMeta { [slug]: { generated, integrationId, record } }
|
|
179
|
+
* @param {object} input.sourceRecords
|
|
180
|
+
* @param {object} input.runtime
|
|
181
|
+
*/
|
|
182
|
+
function deriveEntry(input) {
|
|
183
|
+
const { workspaceConfig, object, row, registeredSet, fileSlugs, fileMeta, sourceRecords, runtime } = input;
|
|
184
|
+
const integrationId = clean(row?.integrationId);
|
|
185
|
+
// Canonical resolver identity. The governed record keeps its human
|
|
186
|
+
// integrationId; the resolver file, registry key, and endpoint path all use
|
|
187
|
+
// the slug. A resolver may register under either form (generated resolvers
|
|
188
|
+
// register under the slug; Nango registers under the raw integrationId), so
|
|
189
|
+
// membership is checked against both — no half-slugged blind spot.
|
|
190
|
+
const slug = slugifyIntegrationId(integrationId, "");
|
|
191
|
+
const resolverId = slug;
|
|
192
|
+
const registered = Boolean(slug) && (registeredSet.has(integrationId) || registeredSet.has(slug));
|
|
193
|
+
const hasFile = Boolean(slug) && fileSlugs.has(slug);
|
|
194
|
+
const meta = (slug && fileMeta && fileMeta[slug]) || null;
|
|
195
|
+
const fileGenerated = Boolean(meta?.generated);
|
|
196
|
+
|
|
197
|
+
const creation = deriveApiRegistryCreationState({
|
|
198
|
+
workspaceConfig,
|
|
199
|
+
registryRow: row,
|
|
200
|
+
sourceRecords,
|
|
201
|
+
runtime,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const profile = creation.tested ? profileApiResponse(row?.lastResponse) : null;
|
|
205
|
+
const recommendation = profile ? recommendResolver(profile) : null;
|
|
206
|
+
|
|
207
|
+
const connectorKind = deriveConnectorKind(row, { registered, hasFile });
|
|
208
|
+
const provenance = deriveProvenance(row, {
|
|
209
|
+
connectorKind,
|
|
210
|
+
registered,
|
|
211
|
+
hasFile,
|
|
212
|
+
fileGenerated,
|
|
213
|
+
recommendation,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const recordRef = {
|
|
217
|
+
objectId: clean(object?.id),
|
|
218
|
+
rowName: clean(row?.Name) || integrationId,
|
|
219
|
+
integrationId,
|
|
220
|
+
};
|
|
221
|
+
const shape = profile
|
|
222
|
+
? {
|
|
223
|
+
arrayPath: clean(profile.arrayPath),
|
|
224
|
+
idField: clean(profile.candidates?.id),
|
|
225
|
+
entityType: clean(profile.suggestedEntityType) || "records",
|
|
226
|
+
hasPagination: Boolean(profile.hasPagination),
|
|
227
|
+
}
|
|
228
|
+
: null;
|
|
229
|
+
const filePath = hasFile ? `${RESOLVER_REGISTRY_DIR}/${slug}.js` : null;
|
|
230
|
+
const endpoint = registered ? `${RESOLVER_ENDPOINT_BASE}/${resolverId}` : null;
|
|
231
|
+
const nextAction = creation.nextAction
|
|
232
|
+
? {
|
|
233
|
+
stepId: clean(creation.nextAction.stepId),
|
|
234
|
+
id: clean(creation.nextAction.id),
|
|
235
|
+
label: clean(creation.nextAction.label),
|
|
236
|
+
}
|
|
237
|
+
: null;
|
|
238
|
+
|
|
239
|
+
// How this resolver would be (or was) constructed — the static, derivable slice
|
|
240
|
+
// of the activation story. Runtime-only facts (artifactWritten, endpointTest,
|
|
241
|
+
// driftStatus) are surfaced by their own surfaces (the resolvers route, the
|
|
242
|
+
// endpoint route, the drift guard), not by this pure derivation.
|
|
243
|
+
const reservedKind = RESERVED_KINDS.has(connectorKind);
|
|
244
|
+
let constructorState;
|
|
245
|
+
if (connectorKind === "nango") constructorState = registered ? "config-driven" : "missing-config";
|
|
246
|
+
else if (reservedKind && !registered && !hasFile) constructorState = "reserved";
|
|
247
|
+
else if (shape) constructorState = "detected";
|
|
248
|
+
else constructorState = registered || hasFile ? "wired" : "untested";
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
recordRef,
|
|
252
|
+
integrationId,
|
|
253
|
+
resolverId,
|
|
254
|
+
connectorKind,
|
|
255
|
+
templateId: clean(row?.resolverTemplateId),
|
|
256
|
+
capabilities: parseList(row?.capabilities),
|
|
257
|
+
executionLane: clean(row?.executionLane),
|
|
258
|
+
provenance,
|
|
259
|
+
filePath,
|
|
260
|
+
registered,
|
|
261
|
+
tested: Boolean(creation.tested),
|
|
262
|
+
shape,
|
|
263
|
+
score: Number.isFinite(creation.score) ? creation.score : 0,
|
|
264
|
+
nextAction,
|
|
265
|
+
endpoint,
|
|
266
|
+
// Secret-safe activation trace (derivable slice). ids / paths / shape facts /
|
|
267
|
+
// booleans only — never values or payloads.
|
|
268
|
+
activationTrace: {
|
|
269
|
+
recordRef,
|
|
270
|
+
testedAt: clean(row?.lastTested) || clean(row?.lastTestedAt) || "",
|
|
271
|
+
resolverId,
|
|
272
|
+
filePath,
|
|
273
|
+
endpoint,
|
|
274
|
+
shape: shape
|
|
275
|
+
? { recordPath: shape.arrayPath, idField: shape.idField, entityType: shape.entityType, hasPagination: shape.hasPagination }
|
|
276
|
+
: null,
|
|
277
|
+
constructorState,
|
|
278
|
+
nextAction: nextAction ? nextAction.label : null,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Derive the full unified registry index. The route reads files + the in-memory
|
|
285
|
+
* registry and injects them; this stays pure and unit-testable.
|
|
286
|
+
*
|
|
287
|
+
* @param {object} input
|
|
288
|
+
* @param {object} input.workspaceConfig
|
|
289
|
+
* @param {string[]} [input.files] resolver filenames (e.g. ["asana.js"])
|
|
290
|
+
* @param {string[]} [input.registeredIds] in-memory registry keys
|
|
291
|
+
* @param {object} [input.fileMeta] { [slug]: { generated, integrationId, record } }
|
|
292
|
+
* @param {object} [input.sourceRecords]
|
|
293
|
+
* @param {object} [input.runtime] { configuredEnvRefs: string[] }
|
|
294
|
+
* @param {string} [input.generatedAt] ISO string (injected for determinism in tests)
|
|
295
|
+
* @returns {object} ResolverRegistryIndex
|
|
296
|
+
*/
|
|
297
|
+
function deriveResolverRegistry(input = {}) {
|
|
298
|
+
const workspaceConfig = isPlainObject(input.workspaceConfig) ? input.workspaceConfig : {};
|
|
299
|
+
const files = Array.isArray(input.files) ? input.files : [];
|
|
300
|
+
const registeredIds = Array.isArray(input.registeredIds) ? input.registeredIds : [];
|
|
301
|
+
const fileMeta = isPlainObject(input.fileMeta) ? input.fileMeta : {};
|
|
302
|
+
const sourceRecords = isPlainObject(input.sourceRecords) ? input.sourceRecords : {};
|
|
303
|
+
const runtime = isPlainObject(input.runtime) ? input.runtime : {};
|
|
304
|
+
const generatedAt = clean(input.generatedAt) || new Date().toISOString();
|
|
305
|
+
|
|
306
|
+
const registeredSet = new Set(registeredIds.map(clean).filter(Boolean));
|
|
307
|
+
const fileSlugs = new Set(
|
|
308
|
+
files
|
|
309
|
+
.map((f) => clean(f))
|
|
310
|
+
.filter((f) => f.endsWith(".js") && !f.startsWith("_") && !f.startsWith("."))
|
|
311
|
+
.map((f) => f.replace(/\.js$/, "")),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const entries = [];
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
for (const object of findApiRegistryObjects(workspaceConfig)) {
|
|
317
|
+
for (const row of Array.isArray(object.rows) ? object.rows : []) {
|
|
318
|
+
if (!isPlainObject(row)) continue;
|
|
319
|
+
const integrationId = clean(row.integrationId);
|
|
320
|
+
if (!integrationId) continue;
|
|
321
|
+
const key = `${clean(object.id)}::${integrationId}`;
|
|
322
|
+
if (seen.has(key)) continue;
|
|
323
|
+
seen.add(key);
|
|
324
|
+
entries.push(
|
|
325
|
+
deriveEntry({ workspaceConfig, object, row, registeredSet, fileSlugs, fileMeta, sourceRecords, runtime }),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Stable ordering: needs-attention first (lowest score), then by resolverId.
|
|
331
|
+
entries.sort((a, b) => (a.score - b.score) || a.resolverId.localeCompare(b.resolverId));
|
|
332
|
+
|
|
333
|
+
// Identity collisions — two distinct governed integrationIds (or the same one
|
|
334
|
+
// in two objects) normalize to the same resolverId, so they would fight over
|
|
335
|
+
// one file / registry key / endpoint. This is a hard governance error: the
|
|
336
|
+
// drift guard fails on it and the UI must surface it (never silently pick one).
|
|
337
|
+
const byResolverId = new Map();
|
|
338
|
+
for (const e of entries) {
|
|
339
|
+
if (!e.resolverId) continue;
|
|
340
|
+
const set = byResolverId.get(e.resolverId) || new Set();
|
|
341
|
+
set.add(`${e.recordRef.objectId}:${e.recordRef.rowName}:${e.integrationId}`);
|
|
342
|
+
byResolverId.set(e.resolverId, set);
|
|
343
|
+
}
|
|
344
|
+
const collisions = [];
|
|
345
|
+
for (const [resolverId, set] of byResolverId) {
|
|
346
|
+
if (set.size > 1) collisions.push({ resolverId, records: [...set] });
|
|
347
|
+
}
|
|
348
|
+
const collidedIds = new Set(collisions.map((c) => c.resolverId));
|
|
349
|
+
|
|
350
|
+
// Trust + agent layer — one truth label, evidence, and a terse agent hint per
|
|
351
|
+
// entry, derived purely from facts already computed. A background agent picks
|
|
352
|
+
// its next move from `trust` + `agentHints` alone; a human reads the same state.
|
|
353
|
+
for (const e of entries) {
|
|
354
|
+
annotateEntryTrust(e, collidedIds.has(e.resolverId));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const summary = {
|
|
358
|
+
total: entries.length,
|
|
359
|
+
registered: entries.filter((e) => e.registered).length,
|
|
360
|
+
tested: entries.filter((e) => e.tested).length,
|
|
361
|
+
needsResolver: entries.filter((e) => e.provenance === "missing").length,
|
|
362
|
+
exposed: entries.filter((e) => e.endpoint).length,
|
|
363
|
+
collisions: collisions.length,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
kind: RESOLVER_REGISTRY_INDEX_KIND,
|
|
368
|
+
version: 1,
|
|
369
|
+
generatedAt,
|
|
370
|
+
entries,
|
|
371
|
+
summary,
|
|
372
|
+
collisions,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Derive a single trust label + a terse, secret-safe agent hint + an evidence
|
|
378
|
+
* trail for one entry. Mutates the entry in place (called after collisions are
|
|
379
|
+
* known). Pure function of facts already on the entry.
|
|
380
|
+
*
|
|
381
|
+
* trust ∈ collision-blocked | reserved-future | missing-config | needs-resolver
|
|
382
|
+
* | endpoint-live | registered | tested | untested
|
|
383
|
+
*/
|
|
384
|
+
function annotateEntryTrust(entry, isCollision) {
|
|
385
|
+
let trust;
|
|
386
|
+
let blockedReason = null;
|
|
387
|
+
if (isCollision) {
|
|
388
|
+
trust = "collision-blocked";
|
|
389
|
+
blockedReason = "Another record normalizes to the same resolverId — rename so ids resolve to distinct slugs.";
|
|
390
|
+
} else if (entry.registered && entry.endpoint && entry.tested) {
|
|
391
|
+
// An already-wired resolver of ANY kind (a registered mcp/chrome static file
|
|
392
|
+
// included) is live — reserved-future is about AUTO-CONSTRUCTION, not trust.
|
|
393
|
+
trust = "endpoint-live";
|
|
394
|
+
} else if (RESERVED_KINDS.has(entry.connectorKind) && entry.provenance === "missing") {
|
|
395
|
+
trust = "reserved-future";
|
|
396
|
+
blockedReason = `Auto-construction for "${entry.connectorKind}" is reserved — wire it with its own resolver (or a custom-http resolver) today.`;
|
|
397
|
+
} else if (entry.connectorKind === "nango" && entry.provenance === "missing") {
|
|
398
|
+
trust = "missing-config";
|
|
399
|
+
blockedReason = "Config-driven row is missing required Nango fields (connectionIds / endpoint).";
|
|
400
|
+
} else if (entry.provenance === "missing") {
|
|
401
|
+
trust = "needs-resolver";
|
|
402
|
+
blockedReason = "The tested response needs a shaping resolver before it produces governed rows.";
|
|
403
|
+
} else if (entry.registered && entry.endpoint) {
|
|
404
|
+
trust = "registered";
|
|
405
|
+
} else if (entry.tested) {
|
|
406
|
+
trust = "tested";
|
|
407
|
+
} else {
|
|
408
|
+
trust = "untested";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Secret-safe evidence: "why this is (not yet) trusted". Booleans/ids/paths only.
|
|
412
|
+
entry.evidence = {
|
|
413
|
+
tested: entry.tested,
|
|
414
|
+
hasShape: Boolean(entry.shape),
|
|
415
|
+
recordPath: entry.shape ? entry.shape.arrayPath : "",
|
|
416
|
+
idField: entry.shape ? entry.shape.idField : "",
|
|
417
|
+
registered: entry.registered,
|
|
418
|
+
endpointLive: trust === "endpoint-live",
|
|
419
|
+
provenance: entry.provenance,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Compact model-context hint: stable, terse, safe. Answers "can I call this,
|
|
423
|
+
// where, for what, and if not — why / what next".
|
|
424
|
+
entry.trust = trust;
|
|
425
|
+
entry.agentHints = {
|
|
426
|
+
callable: trust === "endpoint-live",
|
|
427
|
+
ready: trust === "endpoint-live" || trust === "tested",
|
|
428
|
+
endpoint: entry.endpoint,
|
|
429
|
+
entityType: entry.shape ? entry.shape.entityType : null,
|
|
430
|
+
blockedReason,
|
|
431
|
+
nextAction: entry.nextAction ? entry.nextAction.label : (blockedReason ? "resolve blocker" : null),
|
|
432
|
+
};
|
|
433
|
+
return entry;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Deterministic, key-sorted stringify with the volatile `generatedAt` stripped. */
|
|
437
|
+
function stableStringify(value) {
|
|
438
|
+
return JSON.stringify(value, (key, v) => {
|
|
439
|
+
if (key === "generatedAt") return undefined;
|
|
440
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
441
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
|
|
442
|
+
}
|
|
443
|
+
return v;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Pure artifact drift comparison — the enforcement the drift guard claims.
|
|
449
|
+
* Returns `{ errors: string[] }`. Compares the persisted index/manifest against
|
|
450
|
+
* a fresh re-derivation EXACTLY (minus `generatedAt`), and fails on collisions.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} input
|
|
453
|
+
* @param {object} input.fresh a freshly derived ResolverRegistryIndex
|
|
454
|
+
* @param {object|null} [input.savedIndex] persisted _registry.generated.json
|
|
455
|
+
* @param {object|null} [input.savedManifest] persisted _endpoints.generated.json
|
|
456
|
+
*/
|
|
457
|
+
function diffResolverArtifacts({ fresh, savedIndex = null, savedManifest = null }) {
|
|
458
|
+
const errors = [];
|
|
459
|
+
if (!fresh || fresh.kind !== RESOLVER_REGISTRY_INDEX_KIND) {
|
|
460
|
+
return { errors: ["fresh registry is not a valid index"] };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Collisions are a hard error regardless of artifacts.
|
|
464
|
+
for (const c of Array.isArray(fresh.collisions) ? fresh.collisions : []) {
|
|
465
|
+
errors.push(`identity collision: resolverId "${c.resolverId}" is claimed by ${c.records.length} records (${c.records.join(" | ")}) — they would share one file/key/endpoint.`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (savedIndex) {
|
|
469
|
+
if (savedIndex.kind !== RESOLVER_REGISTRY_INDEX_KIND) {
|
|
470
|
+
errors.push("_registry.generated.json is malformed — regenerate via GET /api/workspace/resolvers.");
|
|
471
|
+
} else if (stableStringify(savedIndex) !== stableStringify(fresh)) {
|
|
472
|
+
const savedById = new Map((savedIndex.entries || []).map((e) => [e.resolverId || e.integrationId, e]));
|
|
473
|
+
const freshById = new Map((fresh.entries || []).map((e) => [e.resolverId || e.integrationId, e]));
|
|
474
|
+
const diffs = [];
|
|
475
|
+
for (const [id, fe] of freshById) {
|
|
476
|
+
const se = savedById.get(id);
|
|
477
|
+
if (!se) { diffs.push(`${id} (missing in artifact)`); continue; }
|
|
478
|
+
if (stableStringify(se) !== stableStringify(fe)) {
|
|
479
|
+
const fields = ["filePath", "endpoint", "score", "tested", "provenance", "registered", "connectorKind", "resolverId"]
|
|
480
|
+
.filter((k) => JSON.stringify(se[k]) !== JSON.stringify(fe[k]));
|
|
481
|
+
diffs.push(`${id} (${fields.join(",") || "shape/recordRef/nextAction"})`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const [id] of savedById) if (!freshById.has(id)) diffs.push(`${id} (stale in artifact)`);
|
|
485
|
+
if (stableStringify(savedIndex.summary) !== stableStringify(fresh.summary)) diffs.push("summary");
|
|
486
|
+
errors.push(`_registry.generated.json drifted from the governed records [${diffs.join("; ")}] — do not hand-edit; regenerate via GET /api/workspace/resolvers.`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (savedManifest) {
|
|
491
|
+
if (savedManifest.kind !== RESOLVER_ENDPOINT_MANIFEST_KIND) {
|
|
492
|
+
errors.push("_endpoints.generated.json is malformed — regenerate via GET /api/workspace/resolvers.");
|
|
493
|
+
} else {
|
|
494
|
+
const freshManifest = buildEndpointManifest(fresh, "drift-check");
|
|
495
|
+
const keyOf = (e) => stableStringify({
|
|
496
|
+
integrationId: e.integrationId, path: e.path, connectorKind: e.connectorKind, recordRef: e.recordRef,
|
|
497
|
+
});
|
|
498
|
+
const savedKeys = new Set((savedManifest.endpoints || []).map(keyOf));
|
|
499
|
+
const freshKeys = new Set((freshManifest.endpoints || []).map(keyOf));
|
|
500
|
+
const stale = [...savedKeys].filter((k) => !freshKeys.has(k));
|
|
501
|
+
const missing = [...freshKeys].filter((k) => !savedKeys.has(k));
|
|
502
|
+
if (stale.length) errors.push(`_endpoints.generated.json has ${stale.length} stale endpoint(s) — regenerate via GET /api/workspace/resolvers.`);
|
|
503
|
+
if (missing.length) errors.push(`_endpoints.generated.json is missing ${missing.length} exposed endpoint(s) — regenerate via GET /api/workspace/resolvers.`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { errors };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Build the endpoint manifest (Phase 3) from a derived index. Pure. */
|
|
511
|
+
function buildEndpointManifest(index, generatedAt) {
|
|
512
|
+
const entries = Array.isArray(index?.entries) ? index.entries : [];
|
|
513
|
+
return {
|
|
514
|
+
kind: RESOLVER_ENDPOINT_MANIFEST_KIND,
|
|
515
|
+
version: 1,
|
|
516
|
+
generatedAt: clean(generatedAt) || clean(index?.generatedAt) || new Date().toISOString(),
|
|
517
|
+
basePath: RESOLVER_ENDPOINT_BASE,
|
|
518
|
+
endpoints: entries
|
|
519
|
+
.filter((e) => e.endpoint)
|
|
520
|
+
.map((e) => ({
|
|
521
|
+
integrationId: e.integrationId,
|
|
522
|
+
path: e.endpoint,
|
|
523
|
+
connectorKind: e.connectorKind,
|
|
524
|
+
recordRef: e.recordRef,
|
|
525
|
+
})),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export {
|
|
530
|
+
RESOLVER_REGISTRY_INDEX_KIND,
|
|
531
|
+
RESOLVER_ENDPOINT_MANIFEST_KIND,
|
|
532
|
+
RESOLVER_REGISTRY_DIR,
|
|
533
|
+
RESOLVER_REGISTRY_INDEX_FILE,
|
|
534
|
+
RESOLVER_ENDPOINT_MANIFEST_FILE,
|
|
535
|
+
RESOLVER_ENDPOINT_BASE,
|
|
536
|
+
RESOLVER_GENERATED_BANNER,
|
|
537
|
+
slugifyIntegrationId,
|
|
538
|
+
parseResolverFileHeader,
|
|
539
|
+
encodeRecordTag,
|
|
540
|
+
decodeRecordTag,
|
|
541
|
+
deriveResolverRegistry,
|
|
542
|
+
buildEndpointManifest,
|
|
543
|
+
diffResolverArtifacts,
|
|
544
|
+
stableStringify,
|
|
545
|
+
};
|
|
@@ -241,7 +241,9 @@ function checkSandboxRow(row, currentRow, path, violations) {
|
|
|
241
241
|
|
|
242
242
|
const incomingStatus = String(row.lifecycleStatus ?? "").trim().toLowerCase();
|
|
243
243
|
const currentStatus = String(currentRow?.lifecycleStatus ?? "").trim().toLowerCase();
|
|
244
|
+
const isHelperSetupRow = rowName(row) === "workspace-helper";
|
|
244
245
|
if (incomingStatus === "live" && currentStatus !== "live") {
|
|
246
|
+
if (isHelperSetupRow) return;
|
|
245
247
|
violations.push(violation(
|
|
246
248
|
"live_publish_via_patch",
|
|
247
249
|
`${path}.lifecycleStatus`,
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
const RESOLVER_PROPOSAL_TYPE = "resolver.create";
|
|
19
19
|
const RESOLVER_AFFECTED_FIELD = "server-file";
|
|
20
20
|
const RESOLVER_DIR = "lib/adapters/integrations/resolvers";
|
|
21
|
+
// The machine-readable provenance banner every generated resolver carries
|
|
22
|
+
// (CMS SDK v1.5.1). Its presence is how the unified resolver registry tags
|
|
23
|
+
// `helper-generated` provenance and how the drift guard recognizes a managed
|
|
24
|
+
// file. Generated code is a projection of the governed record — never hand-edited.
|
|
25
|
+
const RESOLVER_GENERATED_BANNER =
|
|
26
|
+
"@growthub-resolver generated — do not edit; edit the governed api-registry record";
|
|
21
27
|
|
|
22
28
|
function clean(value) {
|
|
23
29
|
return String(value == null ? "" : value).trim();
|
|
@@ -63,8 +69,26 @@ function safeJsString(value) {
|
|
|
63
69
|
* response profile, so the file works for THIS API. The secret is read from the
|
|
64
70
|
* server env via the authRef candidate keys at run time — never inlined.
|
|
65
71
|
*/
|
|
66
|
-
function generateResolverCode({ integrationId, baseUrl, endpoint, method, authRef, headerName, prefix, rootPath, idField, entityType }) {
|
|
72
|
+
function generateResolverCode({ integrationId, baseUrl, endpoint, method, authRef, headerName, prefix, rootPath, idField, entityType, recordRef }) {
|
|
67
73
|
const id = slugify(integrationId, "integration");
|
|
74
|
+
// Slug/whitespace-safe machine tag: base64url(JSON) — a human row name with
|
|
75
|
+
// spaces, colons, slashes, emoji, quotes, or newlines cannot corrupt the
|
|
76
|
+
// header. Decoded by parseResolverFileHeader back into the full recordRef.
|
|
77
|
+
let recordTag = "";
|
|
78
|
+
if (recordRef && (clean(recordRef.objectId) || clean(recordRef.rowName))) {
|
|
79
|
+
try {
|
|
80
|
+
recordTag = Buffer.from(
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
objectId: clean(recordRef.objectId),
|
|
83
|
+
rowName: clean(recordRef.rowName) || id,
|
|
84
|
+
integrationId: id,
|
|
85
|
+
}),
|
|
86
|
+
"utf8",
|
|
87
|
+
).toString("base64url");
|
|
88
|
+
} catch {
|
|
89
|
+
recordTag = "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
68
92
|
const url = `${clean(baseUrl).replace(/\/+$/, "")}/${clean(endpoint).replace(/^\/+/, "")}`.replace(/\/$/, "") || clean(baseUrl) || clean(endpoint);
|
|
69
93
|
const m = (clean(method).toUpperCase() || "GET");
|
|
70
94
|
const candidates = envCandidates(authRef);
|
|
@@ -74,7 +98,9 @@ function generateResolverCode({ integrationId, baseUrl, endpoint, method, authRe
|
|
|
74
98
|
const idf = clean(idField) || "id";
|
|
75
99
|
const ent = clean(entityType) || "records";
|
|
76
100
|
|
|
77
|
-
return `//
|
|
101
|
+
return `// ${RESOLVER_GENERATED_BANNER}
|
|
102
|
+
// @growthub-resolver integrationId=${id} record=${recordTag}
|
|
103
|
+
// Resolver for "${id}" — generated by the governed helper resolver studio.
|
|
78
104
|
// Server file. Reads its secret from the server env at run time (candidates:
|
|
79
105
|
// ${candidates.join(", ") || "none"}); never hard-code a secret here.
|
|
80
106
|
import { registerSourceResolver } from "../source-resolver-registry.js";
|
|
@@ -135,6 +161,7 @@ function buildResolverProposal(input = {}) {
|
|
|
135
161
|
rootPath: input.rootPath,
|
|
136
162
|
idField: input.idField,
|
|
137
163
|
entityType: input.entityType,
|
|
164
|
+
recordRef: input.recordRef,
|
|
138
165
|
});
|
|
139
166
|
return {
|
|
140
167
|
type: RESOLVER_PROPOSAL_TYPE,
|
|
@@ -192,6 +219,7 @@ export {
|
|
|
192
219
|
RESOLVER_PROPOSAL_TYPE,
|
|
193
220
|
RESOLVER_AFFECTED_FIELD,
|
|
194
221
|
RESOLVER_DIR,
|
|
222
|
+
RESOLVER_GENERATED_BANNER,
|
|
195
223
|
resolveResolverFilePath,
|
|
196
224
|
generateResolverCode,
|
|
197
225
|
buildResolverProposal,
|