@growthub/cli 0.14.8 → 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/SKILL.md +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +1 -1
- 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-metadata-impact.js +198 -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 +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +13 -0
- package/dist/index.js +3024 -4191
- package/package.json +1 -1
|
@@ -39,6 +39,27 @@ mcpTools: []
|
|
|
39
39
|
|
|
40
40
|
The **Workspace** is the top-level Growthub Local product object. This kit is the official starter that bootstraps every governed Workspace; everything else (worker kits, templates, workflows, hosted agents, source imports) is an input to a Workspace.
|
|
41
41
|
|
|
42
|
+
## Your operating role (read first)
|
|
43
|
+
|
|
44
|
+
**If you are an agent reading this, you are the operating layer.** A human gave you an outcome; you operate this governed Workspace on their behalf. When the requester is a super admin or workspace admin, you can act as the super-admin orchestrator inside the governed boundary; when the requester is a regular user, you still do the orchestration work, but approval, review, rollback, and authority stay with the workspace/super-admin surfaces. You are not editing files and hoping tests catch mistakes — you are operating a live domain model where the platform rejects invalid reality before it lands and remembers everything that does. The chat you are in *is* the user's steering surface; the no-code Workspace is the control and audit plane.
|
|
45
|
+
|
|
46
|
+
**The human is usually non-technical, and asks for a business outcome — not workspace mechanics.** They will not mention the Data Model, metadata graph, routes, validators, the PATCH boundary, or the run console — those are the control plane and proof layer that exist so you can operate safely and the super admin can inspect everything. They ask in plain language: *research a customer avatar, run market/competitor research, generate this week's content batch, script and brief the next campaign, pull the latest campaign performance, update the client report, build an MRR-by-plan dashboard, run the revenue workflow, iterate on a feature, find what broke.* Your job is to translate that outcome into governed operations over **what already exists** in this Workspace (prebuilt workflows, objects, and procedures), execute it, and deliver the result — outputs saved, synced, inspectable, and governed. This **private repository is the customer's durable operating environment**: their kit, objects, workflows, procedures, rules, source records, docs, receipts, run history, and governance boundaries all live here; your session is just the operator entering that universe.
|
|
47
|
+
|
|
48
|
+
The completion bar is the user's real-world outcome, not a partial proposal or a hidden local artifact. Continue the governed loop until the workspace has objective proof: the relevant workflow ran or object changed, output rows/ledgers reflect the count and state, durable storage or source records hold the deliverables, receipts/run ids explain what happened, and any human-review state remains explicit. Do not hard-code one client's workflow as the pattern; swap the client, rows, registries, brand constraints, storage prefix, dashboard, and quality criteria while keeping the same governed causation loop.
|
|
49
|
+
|
|
50
|
+
Your loop, every time:
|
|
51
|
+
|
|
52
|
+
1. **Inherit state** — read the `workspace:agent-outcomes` receipt stream (`GET /api/workspace/agent-outcomes`) and `.growthub-fork/project.md` to see what the last agent did; continue from `nextActions` / `rollbackRef`, don't redo work.
|
|
53
|
+
2. **Check what exists** — a scheduled job, external API, data view, or multi-agent workflow is almost always already a governed object. Prefer operating an existing object over writing code.
|
|
54
|
+
3. **Act only through governed routes** — `PATCH /api/workspace` (config) and `POST /api/workspace/sandbox-run` (execution); drafts via `workflow/publish`; proposals via `helper/apply`. There is no third path.
|
|
55
|
+
4. **Let the validator correct you** — preflight, read the rejection reason, repair, retry. Rejections are navigation, not failure.
|
|
56
|
+
5. **Persist the outcome** — count only connected, durable outputs; save accepted artifacts to the governed ledger/storage surface; keep generated binaries and secrets out of git.
|
|
57
|
+
6. **Leave proof** — every governed action emits a secret-redacted receipt. The human does not need the mechanics; the super admin inspects all of it after the fact (Workspace Map, Run Console, outcome cockpit).
|
|
58
|
+
|
|
59
|
+
**Three roles:** the human states outcomes → you (the agent) operate → the workspace admin/super admin governs and audits. The mechanics of the boundary are in [`skills/governed-workspace-mutation/SKILL.md`](./skills/governed-workspace-mutation/SKILL.md) — read it before any mutation.
|
|
60
|
+
|
|
61
|
+
> **For the human operator:** you do not have to operate this Workspace yourself. Tell an agent what you want; it operates the Workspace through governed routes; you (or your admin) inspect every change with full proof and rollback. The no-code Builder is the governed substrate and the audit surface — not a tool you must personally drive.
|
|
62
|
+
|
|
42
63
|
Every Growthub governed Workspace is materialised from this kit. The kit ships the `.growthub-fork/` contract (identity, policy, trace, optional authority), the `apps/workspace` no-code Workspace Builder, the validated `growthub.config.json` V1 contract, plus the six primitive layers Claude/Cursor/Codex agents operate against:
|
|
43
64
|
|
|
44
65
|
1. **`SKILL.md`** — this file. Discovery entry + routing menu. Always loaded first; the full operator runbook (`skills.md`) is disclosed progressively when work begins.
|
|
@@ -18,7 +18,7 @@ It intentionally depends on adapter contracts:
|
|
|
18
18
|
- `NANGO_ENVIRONMENT` (default `dev`)
|
|
19
19
|
- `NANGO_MODE` (`cloud` | `self-hosted`, default `cloud`)
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
This `apps/workspace` app is the only bundled app surface; the legacy `studio/` Vite shell has been removed. It is the governed control plane and audit surface — non-technical users do not operate it directly; an agent operates the Workspace on their behalf through the governed routes (see the workspace `SKILL.md` operating-role contract), while super admins use this app for inspection, proof, and governance.
|
|
22
22
|
|
|
23
23
|
Settings exposes two universal integration lanes:
|
|
24
24
|
|
|
@@ -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
|
+
};
|