@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.
Files changed (29) 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/helper/apply/route.js +33 -1
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +86 -4
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryCreationCockpit.jsx +30 -5
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryReviewModal.jsx +2 -2
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +532 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +400 -188
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +36 -5
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphEmptyCanvas.jsx +1 -1
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationRunTracePanel.jsx +1 -1
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxOrchestrationEditorPanel.jsx +1 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +9 -1
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +14 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/api-registry-creation-flow.js +24 -19
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-agent-teams.js +211 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-bootstrap-console.js +325 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/ceo-cockpit-console.js +206 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +7 -82
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/resolver-constructor.js +217 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-resolver-registry.js +99 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/unified-resolver-registry.js +545 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +2 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-resolver-proposal.js +30 -2
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +69 -0
  26. package/package.json +2 -2
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +0 -141
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxToolConfirmModal.jsx +0 -64
  29. 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 `// Resolver for "${id}" — generated by the governed helper resolver studio.
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,