@growthub/cli 0.14.1 → 0.14.3

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 (49) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +4 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +36 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +3 -2
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +86 -2
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +49 -2
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +54 -11
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +113 -36
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +7 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +35 -169
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +85 -7
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +8 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +4 -2
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +1 -0
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +24 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +3 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
  49. package/package.json +2 -2
@@ -0,0 +1,539 @@
1
+ /**
2
+ * App Registry — the runtime surface-metadata source of truth that roadmap
3
+ * Item 4 (Multi-app / Fleet lens) was staged on. Contract:
4
+ * `@growthub/api-contract/workspace-apps`.
5
+ *
6
+ * An application is a FIRST-CLASS GOVERNED ENTITY: one row of the well-known
7
+ * Data Model object `workspace-app-registry` (objectType "app-surface"),
8
+ * living in `growthub.config.json#dataModel.objects[]` like every other
9
+ * governed object — same PATCH allowlist, same validator, same mutation
10
+ * policy, same receipts. No parallel registry service, no new persistence.
11
+ *
12
+ * A row REFERENCES the app's governed parts (ids, never embedded copies):
13
+ * dashboardIds — comma-separated dashboard ids
14
+ * workflowRefs — comma-separated "objectId:RowName" sandbox-row refs
15
+ * dataSourceIds — comma-separated Data Model object ids (source-backed)
16
+ * registryIds — comma-separated API Registry integrationIds
17
+ * plus identity/operational columns (appId, surfacePath, framework, owner,
18
+ * environment, deployTarget, status, exportStatus, description).
19
+ *
20
+ * Everything in this module is PURE derivation over workspaceConfig +
21
+ * workspaceSourceRecords (+ optional precomputed runtime flags): no
22
+ * mutation, no secrets, never throws on partial config. Mutations flow
23
+ * through the existing governed routes only.
24
+ */
25
+
26
+ const APP_REGISTRY_OBJECT_ID = "workspace-app-registry";
27
+ const APP_SURFACE_OBJECT_TYPE = "app-surface";
28
+ const APP_ASSIGNMENT_PACKET_KIND = "growthub-app-assignment-packet-v1";
29
+
30
+ function isPlainObject(value) {
31
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ function safeString(value) {
35
+ return typeof value === "string" ? value : value == null ? "" : String(value);
36
+ }
37
+
38
+ function splitIds(value) {
39
+ return safeString(value)
40
+ .split(",")
41
+ .map((s) => s.trim())
42
+ .filter(Boolean);
43
+ }
44
+
45
+ /** The registry object: matched by well-known id OR objectType. */
46
+ function findAppRegistryObject(workspaceConfig) {
47
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
48
+ return (
49
+ objects.find((o) => isPlainObject(o) && o.id === APP_REGISTRY_OBJECT_ID) ||
50
+ objects.find((o) => isPlainObject(o) && o.objectType === APP_SURFACE_OBJECT_TYPE) ||
51
+ null
52
+ );
53
+ }
54
+
55
+ function listAppSurfaceRows(workspaceConfig) {
56
+ const object = findAppRegistryObject(workspaceConfig);
57
+ const rows = Array.isArray(object?.rows) ? object.rows : [];
58
+ return rows.filter((r) => isPlainObject(r) && safeString(r.Name).trim());
59
+ }
60
+
61
+ /** Resolve a row's references against the live config. Found vs missing — missing refs are themselves governance signal. */
62
+ function resolveAppLinks(workspaceConfig, workspaceSourceRecords, row) {
63
+ const cfg = isPlainObject(workspaceConfig) ? workspaceConfig : {};
64
+ const records = isPlainObject(workspaceSourceRecords) ? workspaceSourceRecords : {};
65
+ const objects = Array.isArray(cfg?.dataModel?.objects) ? cfg.dataModel.objects : [];
66
+ const dashboards = Array.isArray(cfg?.dashboards) ? cfg.dashboards : [];
67
+
68
+ const links = {
69
+ dashboards: { found: [], missing: [] },
70
+ workflows: { found: [], missing: [] },
71
+ dataSources: { found: [], missing: [] },
72
+ apis: { found: [], missing: [] }
73
+ };
74
+
75
+ for (const id of splitIds(row?.dashboardIds)) {
76
+ const hit = dashboards.find((d) => safeString(d?.id) === id);
77
+ (hit ? links.dashboards.found : links.dashboards.missing).push({ id, name: safeString(hit?.name) || id });
78
+ }
79
+
80
+ for (const ref of splitIds(row?.workflowRefs)) {
81
+ const at = ref.indexOf(":");
82
+ const objectId = at === -1 ? "" : ref.slice(0, at).trim();
83
+ const rowName = at === -1 ? "" : ref.slice(at + 1).trim();
84
+ const object = objects.find((o) => o?.id === objectId && o?.objectType === "sandbox-environment");
85
+ const wfRow = (Array.isArray(object?.rows) ? object.rows : [])
86
+ .find((r) => safeString(r?.Name).trim() === rowName);
87
+ if (wfRow) {
88
+ const lifecycleStatus = safeString(wfRow.lifecycleStatus).trim().toLowerCase();
89
+ links.workflows.found.push({
90
+ ref,
91
+ objectId,
92
+ rowName,
93
+ lifecycleStatus,
94
+ live: lifecycleStatus === "live",
95
+ lastRunOk: safeString(wfRow.status) === "connected" && Boolean(safeString(wfRow.lastRunId).trim()),
96
+ hasDraft: Boolean(safeString(wfRow.orchestrationDraftConfig).trim() || safeString(wfRow.orchestrationDraftGraph).trim())
97
+ });
98
+ } else {
99
+ links.workflows.missing.push({ ref, objectId, rowName });
100
+ }
101
+ }
102
+
103
+ for (const id of splitIds(row?.dataSourceIds)) {
104
+ const object = objects.find((o) => o?.id === id);
105
+ if (object) {
106
+ const sourceId = safeString(object.sourceId).trim();
107
+ const hydrated = Boolean(sourceId && isPlainObject(records[sourceId]) &&
108
+ (Array.isArray(records[sourceId].records) ? records[sourceId].records.length : 0) > 0);
109
+ links.dataSources.found.push({ id, sourceId: sourceId || null, hydrated });
110
+ } else {
111
+ links.dataSources.missing.push({ id });
112
+ }
113
+ }
114
+
115
+ const registryRows = objects
116
+ .filter((o) => o?.objectType === "api-registry")
117
+ .flatMap((o) => (Array.isArray(o.rows) ? o.rows : []));
118
+ for (const id of splitIds(row?.registryIds)) {
119
+ const hit = registryRows.find((r) => safeString(r?.integrationId).trim() === id);
120
+ if (hit) {
121
+ links.apis.found.push({ integrationId: id, connected: safeString(hit.status) === "connected" });
122
+ } else {
123
+ links.apis.missing.push({ integrationId: id });
124
+ }
125
+ }
126
+
127
+ return links;
128
+ }
129
+
130
+ /**
131
+ * Per-app health rollup. `runtimeFlags` ({ durable, readOnly, deployReady })
132
+ * are precomputed by the caller from the safe runtime descriptor so this
133
+ * module stays dependency-free.
134
+ */
135
+ function deriveAppHealth(workspaceConfig, workspaceSourceRecords, row, runtimeFlags = {}) {
136
+ const links = resolveAppLinks(workspaceConfig, workspaceSourceRecords, row);
137
+ const blockers = [];
138
+
139
+ const missingRefs =
140
+ links.dashboards.missing.length + links.workflows.missing.length +
141
+ links.dataSources.missing.length + links.apis.missing.length;
142
+ if (missingRefs > 0) blockers.push(`${missingRefs} referenced object(s) do not resolve — fix the row's refs`);
143
+
144
+ const apisDown = links.apis.found.filter((a) => !a.connected);
145
+ if (apisDown.length > 0) blockers.push(`${apisDown.length} API integration(s) not connected — test via the API Registry cockpit`);
146
+
147
+ const dry = links.dataSources.found.filter((s) => !s.hydrated);
148
+ if (dry.length > 0) blockers.push(`${dry.length} data source(s) have no hydrated records — run refresh-sources`);
149
+
150
+ const unpublished = links.workflows.found.filter((w) => !w.live);
151
+ if (unpublished.length > 0) blockers.push(`${unpublished.length} workflow(s) not live — draft → useDraft proof → workflow/publish`);
152
+
153
+ const unproven = links.workflows.found.filter((w) => w.live && !w.lastRunOk);
154
+ if (unproven.length > 0) blockers.push(`${unproven.length} live workflow(s) without passing run evidence`);
155
+
156
+ if (runtimeFlags.readOnly === true) blockers.push("persistence is read-only — mutations cannot land in this runtime");
157
+ else if (runtimeFlags.durable === false) blockers.push("persistence is not durable — app state will not survive");
158
+ if (runtimeFlags.deployReady === false) blockers.push("deploy readiness checks are not clear");
159
+
160
+ const linkedCount =
161
+ links.dashboards.found.length + links.workflows.found.length +
162
+ links.dataSources.found.length + links.apis.found.length;
163
+
164
+ // "empty" wins over runtime blockers: an app with nothing linked has one
165
+ // first action (link its governed parts) regardless of runtime state.
166
+ let status = "ready";
167
+ if (linkedCount === 0 && links.dashboards.missing.length + links.workflows.missing.length + links.dataSources.missing.length + links.apis.missing.length === 0) {
168
+ status = "empty";
169
+ } else if (blockers.length > 0) {
170
+ status = "blocked";
171
+ }
172
+
173
+ return { status, blockers, linkedCount, links };
174
+ }
175
+
176
+ /** The single next action for an app, in lifecycle order. */
177
+ function deriveAppNextAction(row, health) {
178
+ if (health.linkedCount === 0) {
179
+ return {
180
+ label: "Link the app's governed parts",
181
+ description: "Reference its dashboards, workflows, data sources, and APIs on the registry row.",
182
+ href: `/data-model?object=${APP_REGISTRY_OBJECT_ID}`
183
+ };
184
+ }
185
+ if (health.blockers.length > 0) {
186
+ const first = health.blockers[0];
187
+ let href = `/data-model?object=${APP_REGISTRY_OBJECT_ID}`;
188
+ if (first.includes("API")) href = "/data-model";
189
+ if (first.includes("workflow")) {
190
+ const wf = health.links.workflows.found.find((w) => !w.live) || health.links.workflows.found[0];
191
+ if (wf) href = `/workflows?object=${wf.objectId}&row=${encodeURIComponent(wf.rowName)}`;
192
+ }
193
+ if (first.includes("persistence") || first.includes("deploy")) href = "/settings";
194
+ if (first.includes("data source")) href = "/data-model";
195
+ return { label: first, description: "Clear this blocker to move the app forward.", href };
196
+ }
197
+ return {
198
+ label: "App is healthy — package or extend it",
199
+ description: "Export the workspace artifact, or assign the next capability to an agent.",
200
+ href: "/settings"
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Machine-readable, app-scoped swarm assignment. Mirrors the swarm-condition
206
+ * packet shape and adds the governed scope: allowed routes, forbidden
207
+ * actions, and the object refs the agent may touch. No secrets ever.
208
+ */
209
+ function buildAppAssignmentPacket(workspaceConfig, workspaceSourceRecords, row, runtimeFlags = {}) {
210
+ const health = deriveAppHealth(workspaceConfig, workspaceSourceRecords, row, runtimeFlags);
211
+ const next = deriveAppNextAction(row, health);
212
+ const appId = safeString(row.appId).trim() || safeString(row.Name).trim();
213
+ return {
214
+ kind: APP_ASSIGNMENT_PACKET_KIND,
215
+ version: 1,
216
+ appId,
217
+ appName: safeString(row.Name).trim(),
218
+ surfacePath: safeString(row.surfacePath).trim() || null,
219
+ goal: `Move application "${safeString(row.Name).trim()}" to a healthy, published, proven state.`,
220
+ currentState: health.status,
221
+ blockers: health.blockers,
222
+ nextAction: next,
223
+ objectRefs: [
224
+ { objectId: APP_REGISTRY_OBJECT_ID, rowName: safeString(row.Name).trim() },
225
+ ...health.links.workflows.found.map((w) => ({ objectId: w.objectId, rowName: w.rowName })),
226
+ ...health.links.dataSources.found.map((s) => ({ objectId: s.id }))
227
+ ],
228
+ // Truthful capability advertisement (OpenClaw pattern): every route
229
+ // listed here ENFORCES x-growthub-app-scope at runtime — send the header
230
+ // on every call. Routes an app-scoped agent may NOT use are named in
231
+ // operatorOnlyRoutes, never silently omitted.
232
+ allowedRoutes: [
233
+ "GET /api/workspace",
234
+ "GET /api/workspace/apps",
235
+ "GET /api/workspace/agent-outcomes",
236
+ `POST /api/workspace/patch/preflight (header x-growthub-app-scope: ${appId} → appScopeVerdict)`,
237
+ `PATCH /api/workspace (header x-growthub-app-scope: ${appId}; scope runtime-enforced)`,
238
+ `POST /api/workspace/test-source (header; integrationId must be in registryIds)`,
239
+ `POST /api/workspace/refresh-sources (header; sourceIds must be in dataSourceIds)`,
240
+ `POST /api/workspace/sandbox-run (header; workflow must be in scope; useDraft:true for drafts)`,
241
+ `POST /api/workspace/workflow/publish (header; workflow must be in scope)`
242
+ ],
243
+ operatorOnlyRoutes: [
244
+ "POST /api/workspace/helper/apply (human-reviewed proposal lane — rejected under app scope)"
245
+ ],
246
+ forbiddenActions: [
247
+ "mutating objects not referenced by this app's registry row",
248
+ "direct PATCH of live workflow fields, version bumps, or lifecycleStatus live",
249
+ "writing secrets into rows, prompts, or PATCH bodies (authRef/envRef names only)",
250
+ "writing growthub.config.json or growthub.source-records.json directly",
251
+ "inventing routes outside the allowed list"
252
+ ],
253
+ expectedEvidence: [
254
+ "outcome receipts in workspace:agent-outcomes citing this app's object refs",
255
+ "run records under sandbox:<objectId>:<slug(Name)> for workflow proofs",
256
+ "registry row health rollup improving (blockers shrinking)"
257
+ ]
258
+ };
259
+ }
260
+
261
+ /**
262
+ * The set of Data Model object ids inside an app's governed scope:
263
+ * the registry object itself, the app's workflow objects, its data-source
264
+ * objects, and every api-registry object holding one of its integrationIds.
265
+ * Returns null when the app is not registered.
266
+ */
267
+ function resolveAppScopeObjectIds(workspaceConfig, appId) {
268
+ const wanted = safeString(appId).trim();
269
+ if (!wanted) return null;
270
+ const row = listAppSurfaceRows(workspaceConfig)
271
+ .find((r) => (safeString(r.appId).trim() || safeString(r.Name).trim()) === wanted);
272
+ if (!row) return null;
273
+ const registryObject = findAppRegistryObject(workspaceConfig);
274
+ const scope = new Set();
275
+ if (registryObject?.id) scope.add(safeString(registryObject.id));
276
+ for (const ref of splitIds(row.workflowRefs)) {
277
+ const at = ref.indexOf(":");
278
+ if (at > 0) scope.add(ref.slice(0, at).trim());
279
+ }
280
+ for (const id of splitIds(row.dataSourceIds)) scope.add(id);
281
+ const wantedIntegrations = new Set(splitIds(row.registryIds));
282
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
283
+ for (const object of objects) {
284
+ if (object?.objectType !== "api-registry") continue;
285
+ const rows = Array.isArray(object.rows) ? object.rows : [];
286
+ if (rows.some((r) => wantedIntegrations.has(safeString(r?.integrationId).trim()))) {
287
+ scope.add(safeString(object.id));
288
+ }
289
+ }
290
+ return { row, objectIds: scope, dashboardIds: new Set(splitIds(row.dashboardIds)) };
291
+ }
292
+
293
+ function stable(value) {
294
+ if (value === undefined) return "undefined";
295
+ return JSON.stringify(value, (key, v) => {
296
+ if (v && typeof v === "object" && !Array.isArray(v)) {
297
+ const sorted = {};
298
+ for (const k of Object.keys(v).sort()) sorted[k] = v[k];
299
+ return sorted;
300
+ }
301
+ return v;
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Runtime enforcement for app-scoped agents (claim: "agents cannot mutate
307
+ * unrelated app infrastructure"). Opt-in: a harness working from an
308
+ * AppAssignmentPacket sets `x-growthub-app-scope: <appId>` on PATCH; the
309
+ * route then rejects any mutation outside the app's governed scope:
310
+ *
311
+ * - dataModel objects that CHANGED (stable-compare vs persisted) or are
312
+ * NEW must be in the app's object-id scope;
313
+ * - dashboards that changed/appeared must be in the app's dashboardIds;
314
+ * - `canvas` / `widgetTypes` are workspace-global surfaces — always out
315
+ * of scope under an app-scoped mutation.
316
+ *
317
+ * Pure: (currentConfig, patch, appId) → { ok, violations[] }. Unscoped
318
+ * PATCHes (no header) are untouched — scope is a tightening, never a
319
+ * widening, of the mutation policy.
320
+ */
321
+ function evaluateAppScope(currentConfig, patch, appId) {
322
+ const violations = [];
323
+ const scope = resolveAppScopeObjectIds(currentConfig, appId);
324
+ if (!scope) {
325
+ return {
326
+ ok: false,
327
+ violations: [{
328
+ code: "app_scope_violation",
329
+ path: "",
330
+ message: `appId "${safeString(appId).trim()}" is not registered in ${APP_REGISTRY_OBJECT_ID} — register the app row before working app-scoped`
331
+ }]
332
+ };
333
+ }
334
+ if (!isPlainObject(patch)) return { ok: true, violations };
335
+
336
+ for (const key of ["canvas", "widgetTypes"]) {
337
+ if (patch[key] !== undefined && !sameStable(patch[key], currentConfig?.[key])) {
338
+ violations.push({
339
+ code: "app_scope_violation",
340
+ path: key,
341
+ message: `${key} is a workspace-global surface — out of scope for app "${safeString(appId).trim()}"`
342
+ });
343
+ }
344
+ }
345
+
346
+ if (patch.dashboards !== undefined && Array.isArray(patch.dashboards)) {
347
+ const currentDashboards = Array.isArray(currentConfig?.dashboards) ? currentConfig.dashboards : [];
348
+ const currentById = new Map(currentDashboards.map((d) => [safeString(d?.id), d]));
349
+ patch.dashboards.forEach((dashboard, index) => {
350
+ const id = safeString(dashboard?.id);
351
+ if (sameStable(dashboard, currentById.get(id))) return;
352
+ if (!scope.dashboardIds.has(id)) {
353
+ violations.push({
354
+ code: "app_scope_violation",
355
+ path: `dashboards[${index}]`,
356
+ message: `dashboard "${id}" is not referenced by app "${safeString(appId).trim()}" (dashboardIds)`
357
+ });
358
+ }
359
+ });
360
+ }
361
+
362
+ if (patch.dataModel !== undefined && isPlainObject(patch.dataModel) && Array.isArray(patch.dataModel.objects)) {
363
+ const currentObjects = Array.isArray(currentConfig?.dataModel?.objects) ? currentConfig.dataModel.objects : [];
364
+ const currentById = new Map(currentObjects.map((o) => [safeString(o?.id), o]));
365
+ patch.dataModel.objects.forEach((object, index) => {
366
+ const id = safeString(object?.id);
367
+ if (sameStable(object, currentById.get(id))) return;
368
+ if (!scope.objectIds.has(id)) {
369
+ violations.push({
370
+ code: "app_scope_violation",
371
+ path: `dataModel.objects[${index}]`,
372
+ message: `object "${id}" is outside app "${safeString(appId).trim()}"'s governed scope — ` +
373
+ "only the app's registry object, workflows, data sources, and API registry objects may change"
374
+ });
375
+ }
376
+ });
377
+ }
378
+
379
+ return { ok: violations.length === 0, violations };
380
+ }
381
+
382
+ function sameStable(a, b) {
383
+ return stable(a) === stable(b);
384
+ }
385
+
386
+ /**
387
+ * Full scope context for the unified route gate (`requireAppScope`).
388
+ * Superset of resolveAppScopeObjectIds: adds workflowRefs ("objectId:Name"),
389
+ * dataSourceIds (registry-row references = Data Model object ids), the
390
+ * derived sidecar sourceIds of those objects, and registryIds
391
+ * (api-registry integrationIds). Null when the app is not registered.
392
+ */
393
+ function resolveAppScopeContext(workspaceConfig, appId) {
394
+ const base = resolveAppScopeObjectIds(workspaceConfig, appId);
395
+ if (!base) return null;
396
+ const { row } = base;
397
+ const workflowRefs = new Set(splitIds(row.workflowRefs).map((r) => r.trim()));
398
+ const dataSourceIds = new Set(splitIds(row.dataSourceIds));
399
+ const registryIds = new Set(splitIds(row.registryIds));
400
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
401
+ const sourceIds = new Set();
402
+ for (const object of objects) {
403
+ if (dataSourceIds.has(safeString(object?.id)) && safeString(object?.sourceId).trim()) {
404
+ sourceIds.add(safeString(object.sourceId).trim());
405
+ }
406
+ }
407
+ return {
408
+ appId: safeString(row.appId).trim() || safeString(row.Name).trim(),
409
+ row,
410
+ objectIds: base.objectIds,
411
+ dashboardIds: base.dashboardIds,
412
+ workflowRefs,
413
+ dataSourceIds,
414
+ sourceIds,
415
+ registryIds
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Structured violation envelope (SDK: AppScopeViolation) — every scoped
421
+ * route returns this shape so agents self-correct programmatically.
422
+ */
423
+ function buildAppScopeViolation(appScope, violationType, offendingPaths, suggestedAction, context) {
424
+ return {
425
+ error: "app scope violation",
426
+ appScope: safeString(appScope).trim(),
427
+ violationType,
428
+ offendingPaths: Array.isArray(offendingPaths) ? offendingPaths : [],
429
+ suggestedAction,
430
+ // Structured, machine-followable repair steps (Hermes-style: the agent
431
+ // resolves programmatically instead of retrying variations).
432
+ repairPlan: [
433
+ suggestedAction,
434
+ `Verify your scope first: GET /api/workspace/apps and read the "${safeString(appScope).trim()}" assignment packet (objectRefs + allowedRoutes)`
435
+ ],
436
+ ...(context ? { allowedObjectIds: Array.from(context.objectIds) } : {})
437
+ };
438
+ }
439
+
440
+ /**
441
+ * The unified scope gate every governed mutation/execution route calls
442
+ * (Next route handlers are functions, so the "middleware" is this shared
443
+ * helper). Returns:
444
+ * { scoped: false } — no header; route proceeds unscoped
445
+ * { scoped: true, appId, context } — verified scope context
446
+ * { scoped: true, violation, status: 422 } — structured rejection body
447
+ */
448
+ function requireAppScope(request, workspaceConfig) {
449
+ const header = typeof request?.headers?.get === "function" ? request.headers.get("x-growthub-app-scope") : null;
450
+ const appScope = safeString(header).trim();
451
+ if (!appScope) return { scoped: false };
452
+ const context = resolveAppScopeContext(workspaceConfig, appScope);
453
+ if (!context) {
454
+ return {
455
+ scoped: true,
456
+ status: 422,
457
+ violation: buildAppScopeViolation(
458
+ appScope,
459
+ "app_not_registered",
460
+ [],
461
+ `Register appId "${appScope}" as a row of ${APP_REGISTRY_OBJECT_ID} before working app-scoped`,
462
+ null
463
+ )
464
+ };
465
+ }
466
+ return { scoped: true, appId: context.appId, context };
467
+ }
468
+
469
+ /** Pure per-route access checks against a verified scope context. */
470
+ function checkScopedWorkflowAccess(context, objectId, rowName) {
471
+ // Row-precise on purpose: execution/publish scope is the explicit
472
+ // workflowRefs list, never the whole sandbox object — PATCH's object-level
473
+ // grain does not leak into the execution lanes.
474
+ const ref = `${safeString(objectId).trim()}:${safeString(rowName).trim()}`;
475
+ if (context.workflowRefs.has(ref)) return null;
476
+ return buildAppScopeViolation(
477
+ context.appId,
478
+ "workflow_outside_app",
479
+ [ref],
480
+ `Add "${ref}" to the app's workflowRefs on its ${APP_REGISTRY_OBJECT_ID} row, or work on a workflow the app references`,
481
+ context
482
+ );
483
+ }
484
+
485
+ function checkScopedSourceAccess(context, sourceIds) {
486
+ const offending = (Array.isArray(sourceIds) ? sourceIds : [])
487
+ .map((id) => safeString(id).trim())
488
+ .filter((id) => id && !context.dataSourceIds.has(id) && !context.sourceIds.has(id));
489
+ if (offending.length === 0) return null;
490
+ return buildAppScopeViolation(
491
+ context.appId,
492
+ "data_source_outside_app",
493
+ offending,
494
+ "Reference these sources on the app's dataSourceIds before refreshing them app-scoped",
495
+ context
496
+ );
497
+ }
498
+
499
+ function checkScopedRegistryAccess(context, integrationId) {
500
+ const id = safeString(integrationId).trim();
501
+ if (context.registryIds.has(id)) return null;
502
+ return buildAppScopeViolation(
503
+ context.appId,
504
+ "registry_outside_app",
505
+ [id],
506
+ "Reference this integrationId on the app's registryIds before testing it app-scoped",
507
+ context
508
+ );
509
+ }
510
+
511
+ function summarizeFleet(apps) {
512
+ return {
513
+ total: apps.length,
514
+ ready: apps.filter((a) => a.health.status === "ready").length,
515
+ blocked: apps.filter((a) => a.health.status === "blocked").length,
516
+ empty: apps.filter((a) => a.health.status === "empty").length
517
+ };
518
+ }
519
+
520
+ export {
521
+ APP_ASSIGNMENT_PACKET_KIND,
522
+ APP_REGISTRY_OBJECT_ID,
523
+ APP_SURFACE_OBJECT_TYPE,
524
+ buildAppAssignmentPacket,
525
+ buildAppScopeViolation,
526
+ checkScopedRegistryAccess,
527
+ checkScopedSourceAccess,
528
+ checkScopedWorkflowAccess,
529
+ deriveAppHealth,
530
+ deriveAppNextAction,
531
+ evaluateAppScope,
532
+ findAppRegistryObject,
533
+ listAppSurfaceRows,
534
+ requireAppScope,
535
+ resolveAppLinks,
536
+ resolveAppScopeContext,
537
+ resolveAppScopeObjectIds,
538
+ summarizeFleet
539
+ };
@@ -89,7 +89,15 @@ function describePersistenceMode() {
89
89
  return baseFilesystem("Local development");
90
90
  }
91
91
 
92
- function applyPatch(currentConfig, patch) {
92
+ /**
93
+ * Pure merge step shared by the real write path and the preflight dry-run
94
+ * (`POST /api/workspace/patch/preflight`). Canvas patches MERGE over the
95
+ * current canvas (layout/bindings preserved, single-tab vs multi-tab fields
96
+ * stripped, `null` deletes a key) — they are NOT top-level replacements.
97
+ * Preflight must call this exact function so it can never disagree with
98
+ * `writeWorkspaceConfig` about what the merged config will be.
99
+ */
100
+ function applyWorkspaceConfigPatch(currentConfig, patch) {
93
101
  const next = { ...currentConfig };
94
102
  if (patch.dashboards !== undefined) next.dashboards = patch.dashboards;
95
103
  if (patch.widgetTypes !== undefined) next.widgetTypes = patch.widgetTypes;
@@ -137,7 +145,7 @@ async function writeWorkspaceConfig(patch) {
137
145
  throw error;
138
146
  }
139
147
  const current = await readWorkspaceConfig();
140
- const next = applyPatch(current, patch);
148
+ const next = applyWorkspaceConfigPatch(current, patch);
141
149
  validateWorkspaceConfig({
142
150
  dashboards: next.dashboards,
143
151
  widgetTypes: next.widgetTypes,
@@ -420,6 +428,7 @@ export {
420
428
  KNOWN_WIDGET_KINDS,
421
429
  PERSISTENCE_ADAPTERS,
422
430
  READ_ONLY_GUIDANCE,
431
+ applyWorkspaceConfigPatch,
423
432
  describePersistenceMode,
424
433
  readWorkspaceConfig,
425
434
  readWorkspaceSourceRecords,
@@ -848,6 +848,29 @@ const OBJECT_TYPE_PRESETS = {
848
848
  ],
849
849
  relations: []
850
850
  },
851
+ "app-surface": {
852
+ label: "App Surface",
853
+ icon: "LayoutGrid",
854
+ description: "A managed application: one row per app surface in the workspace/monorepo. References the app's governed parts by id (dashboardIds, workflowRefs as objectId:RowName, dataSourceIds, registryIds) — never embedded copies. The Fleet lens and GET /api/workspace/apps derive per-app health, blockers, next action, and the agent assignment packet from these rows.",
855
+ columns: [
856
+ "Name",
857
+ "appId",
858
+ "surfacePath",
859
+ "framework",
860
+ "packageName",
861
+ "owner",
862
+ "environment",
863
+ "deployTarget",
864
+ "status",
865
+ "dashboardIds",
866
+ "workflowRefs",
867
+ "dataSourceIds",
868
+ "registryIds",
869
+ "exportStatus",
870
+ "description"
871
+ ],
872
+ relations: []
873
+ },
851
874
  "people": {
852
875
  label: "People",
853
876
  icon: "Users",
@@ -881,6 +904,7 @@ const OBJECT_TYPE_PRESETS = {
881
904
  "envRefs",
882
905
  "networkAllow",
883
906
  "allowList",
907
+ "browserAccess",
884
908
  "instructions",
885
909
  "command",
886
910
  "timeoutMs",