@growthub/cli 0.14.9 → 0.14.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +49 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +3 -1
- package/dist/index.js +3024 -4191
- package/package.json +1 -1
|
@@ -35,6 +35,11 @@ import { readWorkspaceConfig, readWorkspaceSourceRecords } from "@/lib/workspace
|
|
|
35
35
|
import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
|
|
36
36
|
import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
|
|
37
37
|
import { selectStaleMetadataGroups } from "@/lib/workspace-metadata-selectors";
|
|
38
|
+
import { deriveBlastRadius } from "@/lib/workspace-metadata-impact";
|
|
39
|
+
import { deriveStaleSurfaces } from "@/lib/workspace-stale-surfaces";
|
|
40
|
+
import { deriveWorkflowImpact } from "@/lib/workspace-workflow-impact";
|
|
41
|
+
import { deriveProvenanceLineage } from "@/lib/workspace-provenance-lineage";
|
|
42
|
+
import { deriveAppReadiness } from "@/lib/workspace-app-readiness";
|
|
38
43
|
|
|
39
44
|
const ENVELOPE_KIND = "growthub-workspace-metadata-graph-v1";
|
|
40
45
|
const ENVELOPE_VERSION = 1;
|
|
@@ -111,10 +116,17 @@ async function GET(request) {
|
|
|
111
116
|
// Optional stale-group selector via query params.
|
|
112
117
|
let staleGroups = [];
|
|
113
118
|
let staleReasons = [];
|
|
119
|
+
// Parse all causal query params from one URL read.
|
|
120
|
+
let impactId = "";
|
|
121
|
+
let lineageId = "";
|
|
122
|
+
let lineageDirection = "both";
|
|
114
123
|
try {
|
|
115
124
|
const url = request && request.url ? new URL(request.url) : null;
|
|
116
125
|
const staleKind = url ? (url.searchParams.get("staleKind") || "").trim() : "";
|
|
117
126
|
const staleId = url ? (url.searchParams.get("staleId") || "").trim() : "";
|
|
127
|
+
impactId = url ? (url.searchParams.get("impactId") || "").trim() : "";
|
|
128
|
+
lineageId = url ? (url.searchParams.get("lineageId") || "").trim() : "";
|
|
129
|
+
lineageDirection = url ? (url.searchParams.get("lineageDirection") || "both").trim() : "both";
|
|
118
130
|
if (staleKind && staleId) {
|
|
119
131
|
const result = selectStaleMetadataGroups(metadataStore, { kind: staleKind, id: staleId });
|
|
120
132
|
staleGroups = Array.isArray(result?.groups) ? result.groups : [];
|
|
@@ -124,6 +136,30 @@ async function GET(request) {
|
|
|
124
136
|
warnings.push(`Failed to compute stale groups: ${error?.message || "unknown error"}`);
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
// Causal derivations over the same read-only graph (Mutation → Law →
|
|
140
|
+
// Intelligence). All pure, all bounded, all secret-free. `staleSurfaces` is
|
|
141
|
+
// the unconditional freshness baseline (timestamps already in the graph);
|
|
142
|
+
// `impact` and `lineage` are computed on demand for one node.
|
|
143
|
+
let staleSurfaces = null;
|
|
144
|
+
let readiness = null;
|
|
145
|
+
let impact = null;
|
|
146
|
+
let lineage = null;
|
|
147
|
+
try {
|
|
148
|
+
staleSurfaces = deriveStaleSurfaces(graph);
|
|
149
|
+
readiness = deriveAppReadiness(graph);
|
|
150
|
+
if (impactId) {
|
|
151
|
+
impact = {
|
|
152
|
+
blastRadius: deriveBlastRadius(graph, impactId),
|
|
153
|
+
workflowImpact: deriveWorkflowImpact(graph, impactId)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (lineageId) {
|
|
157
|
+
lineage = deriveProvenanceLineage(graph, lineageId, { direction: lineageDirection });
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
warnings.push(`Failed to compute causal derivations: ${error?.message || "unknown error"}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
return NextResponse.json({
|
|
128
164
|
kind: ENVELOPE_KIND,
|
|
129
165
|
version: ENVELOPE_VERSION,
|
|
@@ -163,6 +199,11 @@ async function GET(request) {
|
|
|
163
199
|
groups: staleGroups,
|
|
164
200
|
reasons: staleReasons
|
|
165
201
|
},
|
|
202
|
+
// Causal intelligence layer — read-only derivations over `graph` above.
|
|
203
|
+
staleSurfaces,
|
|
204
|
+
readiness,
|
|
205
|
+
...(impact ? { impact } : {}),
|
|
206
|
+
...(lineage ? { lineage } : {}),
|
|
166
207
|
warnings,
|
|
167
208
|
selectors: {
|
|
168
209
|
// Manifest of selectors the route honours. Only `selectStaleMetadataGroups`
|
|
@@ -170,7 +211,14 @@ async function GET(request) {
|
|
|
170
211
|
// selectors are exposed as importable helpers for server-side consumers
|
|
171
212
|
// and the read-only inspector; they are NOT toggled through query
|
|
172
213
|
// params in V1.
|
|
173
|
-
httpEnabled: [
|
|
214
|
+
httpEnabled: [
|
|
215
|
+
"selectStaleMetadataGroups",
|
|
216
|
+
"deriveStaleSurfaces",
|
|
217
|
+
"deriveBlastRadius",
|
|
218
|
+
"deriveWorkflowImpact",
|
|
219
|
+
"deriveProvenanceLineage",
|
|
220
|
+
"deriveAppReadiness"
|
|
221
|
+
],
|
|
174
222
|
helperOnly: [
|
|
175
223
|
"selectWidgetRequiredFields",
|
|
176
224
|
"selectWorkflowNodeInputSchema",
|
|
@@ -35,6 +35,38 @@ import {
|
|
|
35
35
|
} from "@/lib/workspace-patch-policy";
|
|
36
36
|
import { evaluateAppScope, requireAppScope } from "@/lib/workspace-app-registry";
|
|
37
37
|
import { appendOutcomeReceipt } from "@/lib/workspace-outcome-receipts";
|
|
38
|
+
import { readWorkspaceSourceRecords } from "@/lib/workspace-config";
|
|
39
|
+
import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
|
|
40
|
+
import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
|
|
41
|
+
import { derivePatchImpact } from "@/lib/workspace-patch-impact";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Report the blast radius of a proposed patch BEFORE the write — the S1
|
|
45
|
+
* spine's intended preflight consumption. Builds the metadata graph from the
|
|
46
|
+
* MERGED config (what the workspace becomes if this patch lands), maps the
|
|
47
|
+
* patched dataModel objects / dashboards to their graph node ids, and runs the
|
|
48
|
+
* pure `deriveStaleSurfaces` seed path over them. Pure, additive, never
|
|
49
|
+
* throws — on any failure the preflight verdict is unaffected and `impact`
|
|
50
|
+
* is simply omitted.
|
|
51
|
+
*/
|
|
52
|
+
async function computePatchImpact(currentConfig, mergedConfig, patch) {
|
|
53
|
+
try {
|
|
54
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return null;
|
|
55
|
+
let sourceRecords = {};
|
|
56
|
+
try { sourceRecords = (await readWorkspaceSourceRecords()) || {}; } catch { sourceRecords = {}; }
|
|
57
|
+
// Build BOTH graphs so the impact deriver can report not just added/modified
|
|
58
|
+
// (on the merged graph) but also REMOVED objects/dashboards (whose downstream
|
|
59
|
+
// lived in the current graph). One shared, unit-tested deriver does the diff.
|
|
60
|
+
const buildGraph = (cfg) => buildWorkspaceMetadataGraph(buildWorkspaceMetadataStore({ workspaceConfig: cfg || {}, workspaceSourceRecords: sourceRecords }));
|
|
61
|
+
const currentGraph = buildGraph(currentConfig);
|
|
62
|
+
const mergedGraph = buildGraph(mergedConfig);
|
|
63
|
+
const impact = derivePatchImpact(currentGraph, mergedGraph, currentConfig || {}, mergedConfig || {});
|
|
64
|
+
if (!impact.total && !impact.removed.length) return null;
|
|
65
|
+
return impact;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
38
70
|
|
|
39
71
|
async function POST(request) {
|
|
40
72
|
let patch;
|
|
@@ -66,12 +98,14 @@ async function POST(request) {
|
|
|
66
98
|
// PATCH about the merged result. Skipped when the body is not a plain
|
|
67
99
|
// object (policy already reports that).
|
|
68
100
|
let schema = { ok: true, errors: [] };
|
|
101
|
+
let mergedConfig = null;
|
|
69
102
|
if (patch && typeof patch === "object" && !Array.isArray(patch)) {
|
|
70
103
|
const sanitized = {};
|
|
71
104
|
for (const key of WORKSPACE_PATCH_ALLOWED_FIELDS) {
|
|
72
105
|
if (Object.prototype.hasOwnProperty.call(patch, key)) sanitized[key] = patch[key];
|
|
73
106
|
}
|
|
74
107
|
const merged = applyWorkspaceConfigPatch(currentConfig || {}, sanitized);
|
|
108
|
+
mergedConfig = merged;
|
|
75
109
|
try {
|
|
76
110
|
validateWorkspaceConfig({
|
|
77
111
|
dashboards: merged.dashboards,
|
|
@@ -103,6 +137,9 @@ async function POST(request) {
|
|
|
103
137
|
}
|
|
104
138
|
}
|
|
105
139
|
|
|
140
|
+
// Blast radius of this patch BEFORE the write (additive; never blocks).
|
|
141
|
+
const impact = await computePatchImpact(currentConfig, mergedConfig, patch);
|
|
142
|
+
|
|
106
143
|
const persistence = describePersistenceMode();
|
|
107
144
|
const ok = policy.ok && schema.ok && (appScopeVerdict ? appScopeVerdict.allowed : true);
|
|
108
145
|
const repairPlan = repairPlanForViolations(policy.violations);
|
|
@@ -140,6 +177,7 @@ async function POST(request) {
|
|
|
140
177
|
schema,
|
|
141
178
|
repairPlan,
|
|
142
179
|
...(appScopeVerdict ? { appScopeVerdict } : {}),
|
|
180
|
+
...(impact ? { impact } : {}),
|
|
143
181
|
...(safeNextStep ? { safeNextStep } : {}),
|
|
144
182
|
persistence: {
|
|
145
183
|
mode: persistence.mode,
|
|
@@ -62,6 +62,27 @@ function buildAuthHeaders(record, secretValue) {
|
|
|
62
62
|
return { [headerName]: prefix ? `${prefix} ${secretValue}` : secretValue };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function executeFeatureSeedMock(url, { registryId, method, startedAt }) {
|
|
66
|
+
if (!String(url || "").startsWith("mock://growthub-feature-seed/")) return null;
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
exitCode: 0,
|
|
70
|
+
durationMs: Date.now() - startedAt,
|
|
71
|
+
stdout: JSON.stringify({ ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] }, null, 2),
|
|
72
|
+
stderr: "",
|
|
73
|
+
rawPayload: { ok: true, status: 200, data: [{ id: "rec-1", label: "Probe record", registryId }] },
|
|
74
|
+
httpStatus: 200,
|
|
75
|
+
adapterMeta: {
|
|
76
|
+
mode: "orchestration-graph",
|
|
77
|
+
registryId,
|
|
78
|
+
url,
|
|
79
|
+
httpStatus: 200,
|
|
80
|
+
method,
|
|
81
|
+
transport: "feature-seed-mock"
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
function findRegistryRecord(workspaceConfig, registryId) {
|
|
66
87
|
const id = String(registryId || "").trim();
|
|
67
88
|
if (!id) return null;
|
|
@@ -203,6 +224,12 @@ async function executeApiRegistryCall(workspaceConfig, nodeConfig, inputPayload,
|
|
|
203
224
|
}
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
const mockResult = executeFeatureSeedMock(url, { registryId, method, startedAt });
|
|
228
|
+
if (mockResult) {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
return mockResult;
|
|
231
|
+
}
|
|
232
|
+
|
|
206
233
|
try {
|
|
207
234
|
const response = await fetch(url, {
|
|
208
235
|
method,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Growthub Workspace App-Readiness V1 — ship-readiness deriver.
|
|
3
|
+
*
|
|
4
|
+
* Composes the signals the workspace already records into ONE app-scoped
|
|
5
|
+
* eligibility verdict: `{ ready, blocking[], nextAction }`. Eligibility, not a
|
|
6
|
+
* flag — readiness is computed from the live state every call, never stored.
|
|
7
|
+
*
|
|
8
|
+
* Signal sources, all already present in the read-only metadata graph the
|
|
9
|
+
* Workspace Map builds:
|
|
10
|
+
* - integration.status — an unconnected integration blocks its dependents
|
|
11
|
+
* - sandbox.authStatus — an unauthenticated sandbox cannot run
|
|
12
|
+
* - pipelineHealth.status / latestOk — an untested or failing pipeline blocks
|
|
13
|
+
* - workflow.lifecycleStatus — a draft-only workflow is a soft (non-blocking)
|
|
14
|
+
* signal that proof is still pending
|
|
15
|
+
*
|
|
16
|
+
* Callers that hold richer signals (env-status, deploy check shape, swarm
|
|
17
|
+
* eligibility) may pass them via `options.extraBlockers` / `options.extraSignals`
|
|
18
|
+
* — they are merged in without changing the shape. Pure: no fetch, no fs, no
|
|
19
|
+
* writes, no secrets. Deterministic ordering so receipts/diffs stay clean.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const APP_READINESS_KIND = "growthub-workspace-app-readiness-v1";
|
|
23
|
+
const APP_READINESS_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
// Severity rank → lower sorts first (most urgent blocker becomes nextAction).
|
|
26
|
+
const SEVERITY = { blocker: 0, warning: 1 };
|
|
27
|
+
|
|
28
|
+
function safeString(value) {
|
|
29
|
+
if (value == null) return "";
|
|
30
|
+
return typeof value === "string" ? value : String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isConnected(status) {
|
|
34
|
+
const s = safeString(status).toLowerCase();
|
|
35
|
+
return s === "connected" || s === "ok" || s === "active" || s === "ready";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Three-state auth: "authed" | "unauthed" | "unknown". Empty is UNKNOWN, never
|
|
39
|
+
// silently "authed" — unknown auth must not pass as ready (review finding D).
|
|
40
|
+
function authState(status) {
|
|
41
|
+
const s = safeString(status).toLowerCase();
|
|
42
|
+
if (["authed", "authenticated", "ok", "connected", "ready"].includes(s)) return "authed";
|
|
43
|
+
if (!s) return "unknown";
|
|
44
|
+
return "unauthed";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A local / no-auth sandbox legitimately has no credential — an intentional
|
|
48
|
+
// exception, not a silent default.
|
|
49
|
+
function isNoAuthSandbox(summary) {
|
|
50
|
+
const locality = safeString(summary.runLocality).toLowerCase();
|
|
51
|
+
const adapter = safeString(summary.adapter).toLowerCase();
|
|
52
|
+
const provider = safeString(summary.authProvider).toLowerCase();
|
|
53
|
+
return locality === "local" || adapter.includes("local") || provider === "none" || provider === "local";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} graph a `buildWorkspaceMetadataGraph` envelope
|
|
58
|
+
* @param {object} [options]
|
|
59
|
+
* @param {string} [options.appId] restrict to nodes scoped to this app (when
|
|
60
|
+
* node summaries carry `appId`/`appScope`); omit for workspace scope.
|
|
61
|
+
* @param {Array<{severity?:string,code:string,message:string,nextAction?:string}>} [options.extraBlockers]
|
|
62
|
+
* @param {object} [options.extraSignals] merged verbatim into `signals`.
|
|
63
|
+
* @returns {object} `{ kind, version, appId, ready, score, blocking[], warnings[], signals, nextAction, summary }`
|
|
64
|
+
*/
|
|
65
|
+
function deriveAppReadiness(graph, options = {}) {
|
|
66
|
+
const appId = safeString(options.appId).trim() || null;
|
|
67
|
+
|
|
68
|
+
const empty = (warning) => ({
|
|
69
|
+
kind: APP_READINESS_KIND,
|
|
70
|
+
version: APP_READINESS_VERSION,
|
|
71
|
+
appId,
|
|
72
|
+
ready: false,
|
|
73
|
+
score: 0,
|
|
74
|
+
blocking: [],
|
|
75
|
+
warnings: warning ? [warning] : [],
|
|
76
|
+
signals: {},
|
|
77
|
+
nextAction: null,
|
|
78
|
+
summary: "No readiness computed."
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
|
|
82
|
+
return empty("graph missing or malformed");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const inScope = (node) => {
|
|
86
|
+
if (!appId) return true;
|
|
87
|
+
const scope = node.summary && (node.summary.appId || node.summary.appScope);
|
|
88
|
+
return safeString(scope) === appId;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const nodes = graph.nodes.filter(inScope);
|
|
92
|
+
const issues = [];
|
|
93
|
+
const counts = { integrations: 0, sandboxes: 0, pipelines: 0, workflows: 0 };
|
|
94
|
+
|
|
95
|
+
for (const node of nodes) {
|
|
96
|
+
const s = node.summary || {};
|
|
97
|
+
if (node.type === "integration") {
|
|
98
|
+
counts.integrations += 1;
|
|
99
|
+
if (!isConnected(s.status)) {
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: "blocker",
|
|
102
|
+
code: "integration_not_connected",
|
|
103
|
+
subject: node.label || node.id,
|
|
104
|
+
message: `Integration "${node.label || node.id}" is ${safeString(s.status) || "not connected"}.`,
|
|
105
|
+
nextAction: `Connect integration "${node.label || node.id}" (test-source / auth), then re-check readiness.`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} else if (node.type === "sandbox") {
|
|
109
|
+
counts.sandboxes += 1;
|
|
110
|
+
const state = authState(s.authStatus);
|
|
111
|
+
const subject = node.label || node.id;
|
|
112
|
+
if (state === "authed" || isNoAuthSandbox(s)) {
|
|
113
|
+
// authed, or a deliberate local/no-auth sandbox — ready.
|
|
114
|
+
} else if (state === "unknown") {
|
|
115
|
+
issues.push({
|
|
116
|
+
severity: "warning",
|
|
117
|
+
code: "sandbox_auth_unknown",
|
|
118
|
+
subject,
|
|
119
|
+
message: `Sandbox "${subject}" has no auth status — cannot confirm it can run, and it is not marked local/no-auth.`,
|
|
120
|
+
nextAction: `Authenticate sandbox "${subject}", or mark it local/no-auth (runLocality: local) if it needs no credential.`
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
issues.push({
|
|
124
|
+
severity: "blocker",
|
|
125
|
+
code: "sandbox_unauthenticated",
|
|
126
|
+
subject,
|
|
127
|
+
message: `Sandbox "${subject}" auth status is ${safeString(s.authStatus)}.`,
|
|
128
|
+
nextAction: `Authenticate sandbox "${subject}" before running.`
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
} else if (node.type === "pipelineHealth") {
|
|
132
|
+
counts.pipelines += 1;
|
|
133
|
+
const status = safeString(s.status).toLowerCase();
|
|
134
|
+
if (status === "untested" || s.latestOk === false) {
|
|
135
|
+
issues.push({
|
|
136
|
+
severity: status === "untested" ? "warning" : "blocker",
|
|
137
|
+
code: status === "untested" ? "pipeline_untested" : "pipeline_failing",
|
|
138
|
+
subject: node.label || node.id,
|
|
139
|
+
message: `Pipeline "${node.label || node.id}" is ${status || "failing"}.`,
|
|
140
|
+
nextAction: status === "untested"
|
|
141
|
+
? `Run "${node.label || node.id}" once to prove it (POST /api/workspace/sandbox-run).`
|
|
142
|
+
: `Investigate the last failing run of "${node.label || node.id}".`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} else if (node.type === "workflow") {
|
|
146
|
+
counts.workflows += 1;
|
|
147
|
+
if (safeString(s.lifecycleStatus).toLowerCase() === "draft") {
|
|
148
|
+
issues.push({
|
|
149
|
+
severity: "warning",
|
|
150
|
+
code: "workflow_draft_only",
|
|
151
|
+
subject: node.label || node.id,
|
|
152
|
+
message: `Workflow "${node.label || node.id}" is draft-only — durable proof pending.`,
|
|
153
|
+
nextAction: `Prove "${node.label || node.id}" with a sandbox run, then publish.`
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const extra of Array.isArray(options.extraBlockers) ? options.extraBlockers : []) {
|
|
160
|
+
if (!extra || !extra.code) continue;
|
|
161
|
+
issues.push({
|
|
162
|
+
severity: extra.severity === "warning" ? "warning" : "blocker",
|
|
163
|
+
code: safeString(extra.code),
|
|
164
|
+
subject: safeString(extra.subject) || safeString(extra.code),
|
|
165
|
+
message: safeString(extra.message) || safeString(extra.code),
|
|
166
|
+
nextAction: extra.nextAction ? safeString(extra.nextAction) : null
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
issues.sort((a, b) =>
|
|
171
|
+
(SEVERITY[a.severity] ?? 9) - (SEVERITY[b.severity] ?? 9) ||
|
|
172
|
+
a.code.localeCompare(b.code) ||
|
|
173
|
+
a.subject.localeCompare(b.subject)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const blocking = issues.filter((i) => i.severity === "blocker");
|
|
177
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
178
|
+
const ready = blocking.length === 0;
|
|
179
|
+
|
|
180
|
+
// Score: 100 when ready and clean; each blocker −25, each warning −5, floored at 0.
|
|
181
|
+
const score = Math.max(0, 100 - blocking.length * 25 - warnings.length * 5);
|
|
182
|
+
const nextAction = (blocking[0]?.nextAction)
|
|
183
|
+
|| (warnings[0]?.nextAction)
|
|
184
|
+
|| (ready ? "Ready — promote/deploy through the governed lane." : null);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
kind: APP_READINESS_KIND,
|
|
188
|
+
version: APP_READINESS_VERSION,
|
|
189
|
+
appId,
|
|
190
|
+
ready,
|
|
191
|
+
score,
|
|
192
|
+
blocking,
|
|
193
|
+
warnings,
|
|
194
|
+
signals: { ...counts, ...(options.extraSignals && typeof options.extraSignals === "object" ? options.extraSignals : {}) },
|
|
195
|
+
nextAction,
|
|
196
|
+
summary: summarizeReadiness(appId, ready, blocking, warnings, score)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function summarizeReadiness(appId, ready, blocking, warnings, score) {
|
|
201
|
+
const scope = appId ? `App "${appId}"` : "Workspace";
|
|
202
|
+
if (ready && !warnings.length) return `${scope} is ready to ship (score ${score}).`;
|
|
203
|
+
if (ready) return `${scope} is ready (score ${score}) with ${warnings.length} warning(s).`;
|
|
204
|
+
return `${scope} is blocked (score ${score}): ${blocking.length} blocker(s), ${warnings.length} warning(s).`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export {
|
|
208
|
+
APP_READINESS_KIND,
|
|
209
|
+
APP_READINESS_VERSION,
|
|
210
|
+
deriveAppReadiness,
|
|
211
|
+
summarizeReadiness
|
|
212
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Growthub Workspace Contract-Compliance V1 — governed-mutation predicate.
|
|
3
|
+
*
|
|
4
|
+
* Given a PROPOSED mutation and a role/SKILL contract, derive the set of proofs,
|
|
5
|
+
* ledgers, and review states that must be satisfied for the mutation to be
|
|
6
|
+
* legal — and which of those are already satisfied by the supplied evidence.
|
|
7
|
+
*
|
|
8
|
+
* This is a PURE PREDICATE over law artifacts the caller already holds (the
|
|
9
|
+
* contract rules + the evidence gathered from receipts/review state). It reads
|
|
10
|
+
* NO state itself, writes nothing, and exposes no secrets. It does not widen
|
|
11
|
+
* any mutation boundary — it only reports what the existing governed lanes
|
|
12
|
+
* require, the same rules the `governed-workspace-mutation` SKILL encodes:
|
|
13
|
+
* - live workflow fields are never PATCHed directly — they need a draft, a
|
|
14
|
+
* proving sandbox-run, then publish;
|
|
15
|
+
* - dataModel / dashboard mutations need a preflight receipt;
|
|
16
|
+
* - anything outside the PATCH allowlist is rejected by construction.
|
|
17
|
+
*
|
|
18
|
+
* Default contract (when none supplied) mirrors the shipped boundary so the
|
|
19
|
+
* predicate is useful out of the box; callers pass a stricter contract per role.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CONTRACT_COMPLIANCE_KIND = "growthub-workspace-contract-compliance-v1";
|
|
23
|
+
const CONTRACT_COMPLIANCE_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
// The permanent PATCH allowlist — the floor every contract inherits.
|
|
26
|
+
const DEFAULT_ALLOWED_FIELDS = ["dashboards", "widgetTypes", "canvas", "dataModel"];
|
|
27
|
+
|
|
28
|
+
function safeString(value) {
|
|
29
|
+
if (value == null) return "";
|
|
30
|
+
return typeof value === "string" ? value : String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asArray(value) {
|
|
34
|
+
return Array.isArray(value) ? value : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultContract() {
|
|
38
|
+
return {
|
|
39
|
+
role: "default",
|
|
40
|
+
allowedFields: DEFAULT_ALLOWED_FIELDS.slice(),
|
|
41
|
+
// Field groups that require a recorded preflight receipt before the write.
|
|
42
|
+
requiresReceiptFor: ["dataModel", "dashboards"],
|
|
43
|
+
// Field groups that require a human/super-admin review state.
|
|
44
|
+
requiresReviewFor: [],
|
|
45
|
+
// Live workflow changes must go draft → sandbox-run proof → publish.
|
|
46
|
+
liveWorkflowRequiresPublishProof: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} mutation the proposed change.
|
|
52
|
+
* `{ changedFields: string[], touchesLiveWorkflow?: boolean, lane?: string }`
|
|
53
|
+
* @param {object} [contract] role/SKILL contract (see `defaultContract`).
|
|
54
|
+
* @param {object} [evidence] proof already gathered.
|
|
55
|
+
* `{ hasPreflightReceipt?: boolean, hasPublishProof?: boolean, reviewState?: string }`
|
|
56
|
+
* @returns {object} `{ kind, version, role, compliant, required[], satisfied[], missing[], violations[], nextAction, summary }`
|
|
57
|
+
*/
|
|
58
|
+
function deriveContractCompliance(mutation, contract, evidence = {}) {
|
|
59
|
+
const rules = { ...defaultContract(), ...(contract && typeof contract === "object" ? contract : {}) };
|
|
60
|
+
const role = safeString(rules.role) || "default";
|
|
61
|
+
|
|
62
|
+
const empty = (warning) => ({
|
|
63
|
+
kind: CONTRACT_COMPLIANCE_KIND,
|
|
64
|
+
version: CONTRACT_COMPLIANCE_VERSION,
|
|
65
|
+
role,
|
|
66
|
+
compliant: false,
|
|
67
|
+
required: [],
|
|
68
|
+
satisfied: [],
|
|
69
|
+
missing: [],
|
|
70
|
+
violations: warning ? [{ code: "invalid_input", message: warning }] : [],
|
|
71
|
+
nextAction: null,
|
|
72
|
+
summary: warning || "No compliance computed."
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!mutation || typeof mutation !== "object") return empty("mutation missing or malformed");
|
|
76
|
+
|
|
77
|
+
const changedFields = asArray(mutation.changedFields).map(safeString).filter(Boolean);
|
|
78
|
+
const allowed = new Set(asArray(rules.allowedFields).map(safeString));
|
|
79
|
+
const required = [];
|
|
80
|
+
const satisfied = [];
|
|
81
|
+
const missing = [];
|
|
82
|
+
const violations = [];
|
|
83
|
+
|
|
84
|
+
// 1. Allowlist — disallowed fields are a hard violation (rejected by the route).
|
|
85
|
+
for (const field of changedFields) {
|
|
86
|
+
if (!allowed.has(field)) {
|
|
87
|
+
violations.push({
|
|
88
|
+
code: "field_not_allowed",
|
|
89
|
+
field,
|
|
90
|
+
message: `Field "${field}" is outside the PATCH allowlist (${Array.from(allowed).join(", ")}).`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Preflight receipt requirement.
|
|
96
|
+
const receiptGroups = new Set(asArray(rules.requiresReceiptFor).map(safeString));
|
|
97
|
+
if (changedFields.some((f) => receiptGroups.has(f))) {
|
|
98
|
+
const req = { code: "preflight_receipt", message: "A recorded patch-preflight receipt is required." };
|
|
99
|
+
required.push(req);
|
|
100
|
+
(evidence.hasPreflightReceipt ? satisfied : missing).push(req);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Review state requirement.
|
|
104
|
+
const reviewGroups = new Set(asArray(rules.requiresReviewFor).map(safeString));
|
|
105
|
+
if (changedFields.some((f) => reviewGroups.has(f))) {
|
|
106
|
+
const req = { code: "review_state", message: "A human/super-admin review state is required (e.g. approved)." };
|
|
107
|
+
required.push(req);
|
|
108
|
+
const reviewOk = ["approved", "accepted", "merged"].includes(safeString(evidence.reviewState).toLowerCase());
|
|
109
|
+
(reviewOk ? satisfied : missing).push(req);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. Live workflow proof chain.
|
|
113
|
+
if (mutation.touchesLiveWorkflow && rules.liveWorkflowRequiresPublishProof) {
|
|
114
|
+
const req = { code: "publish_proof", message: "Live workflow change requires draft → sandbox-run proof → publish." };
|
|
115
|
+
required.push(req);
|
|
116
|
+
(evidence.hasPublishProof ? satisfied : missing).push(req);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const compliant = violations.length === 0 && missing.length === 0;
|
|
120
|
+
const nextAction = violations.length
|
|
121
|
+
? `Remove disallowed field(s): ${violations.filter((v) => v.code === "field_not_allowed").map((v) => v.field).join(", ") || "see violations"}.`
|
|
122
|
+
: (missing[0]
|
|
123
|
+
? missingNextAction(missing[0])
|
|
124
|
+
: (compliant ? "Compliant — proceed through the governed PATCH/publish lane." : null));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
kind: CONTRACT_COMPLIANCE_KIND,
|
|
128
|
+
version: CONTRACT_COMPLIANCE_VERSION,
|
|
129
|
+
role,
|
|
130
|
+
compliant,
|
|
131
|
+
required,
|
|
132
|
+
satisfied,
|
|
133
|
+
missing,
|
|
134
|
+
violations,
|
|
135
|
+
nextAction,
|
|
136
|
+
summary: summarizeCompliance(role, compliant, required, missing, violations)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function missingNextAction(req) {
|
|
141
|
+
switch (req.code) {
|
|
142
|
+
case "preflight_receipt": return "Run POST /api/workspace/patch/preflight and record the receipt before PATCH.";
|
|
143
|
+
case "review_state": return "Obtain an approved review state before applying.";
|
|
144
|
+
case "publish_proof": return "Save a draft, prove it with sandbox-run, then POST /api/workspace/workflow/publish.";
|
|
145
|
+
default: return `Satisfy: ${req.message}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function summarizeCompliance(role, compliant, required, missing, violations) {
|
|
150
|
+
if (violations.length) {
|
|
151
|
+
return `Non-compliant (${role}): ${violations.length} hard violation(s) — ${violations[0].message}`;
|
|
152
|
+
}
|
|
153
|
+
if (compliant) {
|
|
154
|
+
return required.length
|
|
155
|
+
? `Compliant (${role}): all ${required.length} requirement(s) satisfied.`
|
|
156
|
+
: `Compliant (${role}): no governed requirements triggered.`;
|
|
157
|
+
}
|
|
158
|
+
return `Non-compliant (${role}): ${missing.length} of ${required.length} requirement(s) unmet.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
CONTRACT_COMPLIANCE_KIND,
|
|
163
|
+
CONTRACT_COMPLIANCE_VERSION,
|
|
164
|
+
DEFAULT_ALLOWED_FIELDS,
|
|
165
|
+
defaultContract,
|
|
166
|
+
deriveContractCompliance,
|
|
167
|
+
summarizeCompliance
|
|
168
|
+
};
|