@growthub/cli 0.14.2 → 0.14.4
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/SKILL.md +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +69 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +72 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
- 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/HelperSidecar.jsx +36 -5
- 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 +11 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +22 -165
- 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-publish.js +179 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +402 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +69 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
- 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
|
-
|
|
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 =
|
|
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",
|