@growthub/cli 0.14.9 → 0.14.11
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/add-ons/[providerId]/callback/route.js +35 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/[productId]/resources/route.js +173 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -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/app/api/workspace/sandbox-run/route.js +3 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +29 -19
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -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-config.js +607 -63
- 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-data-model.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -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/apps/workspace/public/integrations/upstash/provider.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -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
|
@@ -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
|
+
};
|
|
@@ -895,6 +895,27 @@ const OBJECT_TYPE_PRESETS = {
|
|
|
895
895
|
"version",
|
|
896
896
|
"runLocality",
|
|
897
897
|
"schedulerRegistryId",
|
|
898
|
+
"schedulerProviderId",
|
|
899
|
+
"schedulerProductId",
|
|
900
|
+
"schedulerRegion",
|
|
901
|
+
"scheduleId",
|
|
902
|
+
"schedulerCron",
|
|
903
|
+
"schedulerTriggerInput",
|
|
904
|
+
"schedulerDestination",
|
|
905
|
+
"schedulerCallbackUrl",
|
|
906
|
+
"schedulerFailureCallbackUrl",
|
|
907
|
+
"schedulerInstalledAt",
|
|
908
|
+
"schedulerPaused",
|
|
909
|
+
"schedulerPausedAt",
|
|
910
|
+
"schedulerResumedAt",
|
|
911
|
+
"lastScheduledRunStatus",
|
|
912
|
+
"lastScheduledRunMessageId",
|
|
913
|
+
"lastScheduledRunAttemptedAt",
|
|
914
|
+
"lastScheduledRunSucceededAt",
|
|
915
|
+
"lastScheduledRunFailedAt",
|
|
916
|
+
"lastScheduledRunFailureReason",
|
|
917
|
+
"lastScheduledRunBodyPreview",
|
|
918
|
+
"lastScheduledRunRetries",
|
|
898
919
|
"runtime",
|
|
899
920
|
"adapter",
|
|
900
921
|
"agentHost",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation access boundary — single hook for operator/admin authorization on
|
|
3
|
+
* high-impact governed workspace mutations (schedule install/uninstall, provider
|
|
4
|
+
* sync, product sync). These routes can spend provider credentials, create/delete
|
|
5
|
+
* remote infrastructure, and mutate workspace config, so they must sit behind the
|
|
6
|
+
* SAME boundary as any governed write.
|
|
7
|
+
*
|
|
8
|
+
* In the open-source starter kit this is intentionally a NO-OP: the kit is
|
|
9
|
+
* local/dev-only unless the host application wraps these routes. Hosted
|
|
10
|
+
* deployments (e.g. Agency Portal / private) replace this with a real check —
|
|
11
|
+
* session/role, the same middleware that guards `/api/workspace`, or an app-scope
|
|
12
|
+
* gate — WITHOUT changing any route: every mutation route calls this one function.
|
|
13
|
+
*
|
|
14
|
+
* Returns { ok: true } when allowed, or { ok: false, status, error } to reject.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function requireWorkspaceOperator(_request) {
|
|
18
|
+
// Starter kit: no built-in auth. Host app is responsible for protecting these
|
|
19
|
+
// routes (reverse proxy, middleware, or platform auth). Set
|
|
20
|
+
// GROWTHUB_REQUIRE_OPERATOR_AUTH=true with no provider configured to hard-block
|
|
21
|
+
// mutations on an unprotected public deployment.
|
|
22
|
+
if (String(process.env.GROWTHUB_REQUIRE_OPERATOR_AUTH || "").trim() === "true") {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
status: 403,
|
|
26
|
+
error: "operator authorization required but no operator auth provider is configured for this deployment",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { requireWorkspaceOperator };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Growthub Workspace Patch-Impact V1 — the single, authoritative "what does this
|
|
3
|
+
* patch actually change?" deriver, shared by the preflight route and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* A dataModel/dashboards PATCH REPLACES the whole array, so the patch body alone
|
|
6
|
+
* is never "what changed". This diffs the CURRENT config against the MERGED
|
|
7
|
+
* (post-patch) config and reports THREE classes of change, each with downstream
|
|
8
|
+
* impact:
|
|
9
|
+
*
|
|
10
|
+
* - added / modified objects + dashboards → seeded on the MERGED graph
|
|
11
|
+
* (their new dependents).
|
|
12
|
+
* - REMOVED objects + dashboards → seeded on the CURRENT graph, because the
|
|
13
|
+
* deleted node no longer exists in the merged graph; the surfaces that
|
|
14
|
+
* depended on it are the blast radius of deleting it. Without this, deleting
|
|
15
|
+
* a business surface would silently report no impact — the highest-risk
|
|
16
|
+
* false confidence.
|
|
17
|
+
*
|
|
18
|
+
* Pure: composes `deriveStaleSurfaces` (which composes the blast-radius spine),
|
|
19
|
+
* no new traversal, no writes, no secrets.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { deriveStaleSurfaces } from "./workspace-stale-surfaces.js";
|
|
23
|
+
|
|
24
|
+
const PATCH_IMPACT_KIND = "growthub-workspace-patch-impact-v1";
|
|
25
|
+
const PATCH_IMPACT_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
function objectIndex(config) {
|
|
28
|
+
return new Map((config?.dataModel?.objects || []).map((o) => [o && o.id, JSON.stringify(o)]));
|
|
29
|
+
}
|
|
30
|
+
function dashboardIndex(config) {
|
|
31
|
+
return new Map((config?.dashboards || []).map((d) => [d && (d.id || d.name), JSON.stringify(d)]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} currentGraph graph built from the CURRENT config (pre-patch)
|
|
36
|
+
* @param {object} mergedGraph graph built from the MERGED config (post-patch)
|
|
37
|
+
* @param {object} currentConfig
|
|
38
|
+
* @param {object} mergedConfig
|
|
39
|
+
* @returns {object} `{ kind, version, scope, seeds[], total, byType, staleSurfaces[], removed[], summary, warnings }`
|
|
40
|
+
*/
|
|
41
|
+
function derivePatchImpact(currentGraph, mergedGraph, currentConfig, mergedConfig) {
|
|
42
|
+
const empty = (warning) => ({
|
|
43
|
+
kind: PATCH_IMPACT_KIND,
|
|
44
|
+
version: PATCH_IMPACT_VERSION,
|
|
45
|
+
scope: "changed-only",
|
|
46
|
+
seeds: [],
|
|
47
|
+
total: 0,
|
|
48
|
+
byType: {},
|
|
49
|
+
staleSurfaces: [],
|
|
50
|
+
removed: [],
|
|
51
|
+
summary: "No object or dashboard added, modified, or removed.",
|
|
52
|
+
warnings: warning ? [warning] : []
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!mergedGraph || !Array.isArray(mergedGraph.nodes)) return empty("merged graph missing");
|
|
56
|
+
|
|
57
|
+
const curObjects = objectIndex(currentConfig);
|
|
58
|
+
const mergedObjects = objectIndex(mergedConfig);
|
|
59
|
+
const curDashboards = dashboardIndex(currentConfig);
|
|
60
|
+
const mergedDashboards = dashboardIndex(mergedConfig);
|
|
61
|
+
|
|
62
|
+
const changedObjectIds = new Set();
|
|
63
|
+
const removedObjectIds = new Set();
|
|
64
|
+
for (const [id, json] of mergedObjects) if (id && curObjects.get(id) !== json) changedObjectIds.add(id);
|
|
65
|
+
for (const [id] of curObjects) if (id && !mergedObjects.has(id)) removedObjectIds.add(id);
|
|
66
|
+
|
|
67
|
+
const changedDashboardIds = new Set();
|
|
68
|
+
const removedDashboardIds = new Set();
|
|
69
|
+
for (const [id, json] of mergedDashboards) if (id && curDashboards.get(id) !== json) changedDashboardIds.add(id);
|
|
70
|
+
for (const [id] of curDashboards) if (id && !mergedDashboards.has(id)) removedDashboardIds.add(id);
|
|
71
|
+
|
|
72
|
+
// ── added / modified: seed on the MERGED graph ──────────────────────────
|
|
73
|
+
const changedSeeds = [];
|
|
74
|
+
for (const node of mergedGraph.nodes) {
|
|
75
|
+
if (node.type === "dataModelObject" && (changedObjectIds.has(node.summary?.objectId) || changedObjectIds.has(node.metadataId))) {
|
|
76
|
+
changedSeeds.push(node.id);
|
|
77
|
+
} else if (node.type === "dashboard" && (changedDashboardIds.has(node.metadataId) || changedDashboardIds.has(node.label))) {
|
|
78
|
+
changedSeeds.push(node.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const changedStale = changedSeeds.length ? deriveStaleSurfaces(mergedGraph, { seedIds: changedSeeds }) : null;
|
|
82
|
+
|
|
83
|
+
// ── removed: seed on the CURRENT graph (the deleted node lived there) ────
|
|
84
|
+
const removed = [];
|
|
85
|
+
const currentNodes = (currentGraph && Array.isArray(currentGraph.nodes)) ? currentGraph.nodes : [];
|
|
86
|
+
for (const node of currentNodes) {
|
|
87
|
+
const isRemovedObject = node.type === "dataModelObject" && (removedObjectIds.has(node.summary?.objectId) || removedObjectIds.has(node.metadataId));
|
|
88
|
+
const isRemovedDashboard = node.type === "dashboard" && (removedDashboardIds.has(node.metadataId) || removedDashboardIds.has(node.label));
|
|
89
|
+
if (!isRemovedObject && !isRemovedDashboard) continue;
|
|
90
|
+
const downstream = deriveStaleSurfaces(currentGraph, { seedIds: [node.id] });
|
|
91
|
+
removed.push({
|
|
92
|
+
id: node.id,
|
|
93
|
+
type: node.type,
|
|
94
|
+
label: node.label,
|
|
95
|
+
metadataId: node.metadataId,
|
|
96
|
+
affectedTotal: downstream.total,
|
|
97
|
+
affected: downstream.staleSurfaces,
|
|
98
|
+
summary: `Removing "${node.label}" affects ${downstream.total} downstream surface(s) that depend on it.`
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!changedStale && !removed.length) return empty();
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
kind: PATCH_IMPACT_KIND,
|
|
106
|
+
version: PATCH_IMPACT_VERSION,
|
|
107
|
+
scope: "changed-only",
|
|
108
|
+
seeds: changedStale ? changedStale.seeds : [],
|
|
109
|
+
total: changedStale ? changedStale.total : 0,
|
|
110
|
+
byType: changedStale ? changedStale.byType : {},
|
|
111
|
+
staleSurfaces: changedStale ? changedStale.staleSurfaces : [],
|
|
112
|
+
removed,
|
|
113
|
+
summary: summarizePatchImpact(changedStale, removed),
|
|
114
|
+
warnings: removed.length ? [`${removed.length} object/dashboard removal(s) — review affected downstream before applying.`] : []
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function summarizePatchImpact(changedStale, removed) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
if (changedStale && changedStale.total) parts.push(`${changedStale.total} surface(s) stale from add/modify`);
|
|
121
|
+
if (removed.length) {
|
|
122
|
+
const affected = removed.reduce((sum, r) => sum + (r.affectedTotal || 0), 0);
|
|
123
|
+
parts.push(`${removed.length} removal(s) affecting ${affected} downstream surface(s)`);
|
|
124
|
+
}
|
|
125
|
+
return parts.length ? `Patch impact: ${parts.join("; ")}.` : "No downstream impact.";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
PATCH_IMPACT_KIND,
|
|
130
|
+
PATCH_IMPACT_VERSION,
|
|
131
|
+
derivePatchImpact,
|
|
132
|
+
summarizePatchImpact
|
|
133
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Growthub Workspace Provenance-Lineage V1 — bidirectional lineage deriver.
|
|
3
|
+
*
|
|
4
|
+
* The mirror twin of `deriveBlastRadius`. It exposes BOTH transitive directions
|
|
5
|
+
* over the SAME edge taxonomy, named to MATCH the graph's own helper contract
|
|
6
|
+
* (`findDependents` = incoming, `findDependencies` = outgoing) so an agent never
|
|
7
|
+
* mis-reads a consumer as a producer:
|
|
8
|
+
*
|
|
9
|
+
* - dependents — transitive INCOMING closure (generalises `findDependents`):
|
|
10
|
+
* the nodes that DEPEND ON this node — its consumers and the
|
|
11
|
+
* things impacted if it changes (e.g. a widget that binds an
|
|
12
|
+
* object is the object's dependent). "What depends on this?"
|
|
13
|
+
* - dependencies — transitive OUTGOING closure (generalises `findDependencies`):
|
|
14
|
+
* the nodes this one DEPENDS ON — what it is built from /
|
|
15
|
+
* reads (e.g. an object's source record). "What does this
|
|
16
|
+
* depend on?"
|
|
17
|
+
*
|
|
18
|
+
* `ancestors` / `descendants` are kept ONLY as backward-compatible aliases of
|
|
19
|
+
* `dependents` / `dependencies` respectively — they read intuitively for the
|
|
20
|
+
* run→artifact case but mislead for objects/widgets/dashboards, so prefer the
|
|
21
|
+
* canonical names. The `direction` option accepts `dependents` | `dependencies`
|
|
22
|
+
* | `both` (and the legacy `ancestors` | `descendants`).
|
|
23
|
+
*
|
|
24
|
+
* One bounded, cycle-safe, deterministic BFS — the same skeleton the spine uses.
|
|
25
|
+
* No new graph, no new edges, no mutation, no secrets. `selectRunLineage` is the
|
|
26
|
+
* flat single-run ancestor of this module.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { summarizeGraphNode } from "./workspace-metadata-graph.js";
|
|
30
|
+
import { deriveBlastRadius } from "./workspace-metadata-impact.js";
|
|
31
|
+
|
|
32
|
+
const PROVENANCE_KIND = "growthub-workspace-provenance-lineage-v1";
|
|
33
|
+
const PROVENANCE_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MAX_NODES = 500;
|
|
36
|
+
|
|
37
|
+
function safeString(value) {
|
|
38
|
+
if (value == null) return "";
|
|
39
|
+
return typeof value === "string" ? value : String(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the OUTGOING adjacency index once: `Map<fromId, Array<{ to, relation }>>`.
|
|
44
|
+
* (The INCOMING/dependents direction is NOT re-implemented here — it reuses the
|
|
45
|
+
* shipped `deriveBlastRadius` reverse closure, the single source of truth for
|
|
46
|
+
* incoming-edge traversal.)
|
|
47
|
+
*/
|
|
48
|
+
function buildOutgoingIndex(graph) {
|
|
49
|
+
const adjacency = new Map();
|
|
50
|
+
const edges = Array.isArray(graph?.edges) ? graph.edges : [];
|
|
51
|
+
for (const edge of edges) {
|
|
52
|
+
if (!edge || edge.from == null || edge.to == null) continue;
|
|
53
|
+
const key = String(edge.from);
|
|
54
|
+
if (!adjacency.has(key)) adjacency.set(key, []);
|
|
55
|
+
adjacency.get(key).push({ to: String(edge.to), relation: edge.relation });
|
|
56
|
+
}
|
|
57
|
+
return adjacency;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bounded, cycle-safe BFS from `originId` over a pre-built adjacency index.
|
|
62
|
+
* Mirrors the blast-radius walk: FIFO queue → shortest path first, each node
|
|
63
|
+
* visited once, honest truncation past `maxNodes`.
|
|
64
|
+
*/
|
|
65
|
+
function walk(adjacency, nodesById, originId, maxNodes) {
|
|
66
|
+
const visited = new Set([originId]);
|
|
67
|
+
const reached = [];
|
|
68
|
+
let truncated = false;
|
|
69
|
+
let maxDistanceReached = 0;
|
|
70
|
+
const queue = [{ id: originId, distance: 0 }];
|
|
71
|
+
while (queue.length) {
|
|
72
|
+
const current = queue.shift();
|
|
73
|
+
const neighbours = adjacency.get(current.id) || [];
|
|
74
|
+
for (const { to, relation } of neighbours) {
|
|
75
|
+
if (visited.has(to)) continue;
|
|
76
|
+
const node = nodesById.get(to);
|
|
77
|
+
if (!node) continue;
|
|
78
|
+
if (reached.length >= maxNodes) {
|
|
79
|
+
truncated = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
visited.add(to);
|
|
83
|
+
const distance = current.distance + 1;
|
|
84
|
+
maxDistanceReached = Math.max(maxDistanceReached, distance);
|
|
85
|
+
reached.push({
|
|
86
|
+
id: node.id,
|
|
87
|
+
type: node.type,
|
|
88
|
+
label: node.label,
|
|
89
|
+
metadataId: node.metadataId,
|
|
90
|
+
distance,
|
|
91
|
+
viaRelation: relation
|
|
92
|
+
});
|
|
93
|
+
queue.push({ id: to, distance });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
reached.sort((a, b) =>
|
|
97
|
+
a.distance - b.distance ||
|
|
98
|
+
a.type.localeCompare(b.type) ||
|
|
99
|
+
a.id.localeCompare(b.id)
|
|
100
|
+
);
|
|
101
|
+
return { reached, truncated, maxDistanceReached };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object} graph a `buildWorkspaceMetadataGraph` envelope
|
|
106
|
+
* @param {string} originId the metadataId to trace lineage for
|
|
107
|
+
* @param {object} [options]
|
|
108
|
+
* @param {"dependents"|"dependencies"|"both"|"ancestors"|"descendants"} [options.direction="both"]
|
|
109
|
+
* @param {number} [options.maxNodes=500] hard cap PER direction
|
|
110
|
+
* @returns {object} `{ kind, version, origin, direction, dependents[], dependencies[],
|
|
111
|
+
* ancestors[] (alias of dependents), descendants[] (alias of dependencies),
|
|
112
|
+
* byType, truncated, summary, warnings }`
|
|
113
|
+
*/
|
|
114
|
+
function deriveProvenanceLineage(graph, originId, options = {}) {
|
|
115
|
+
const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
|
|
116
|
+
? Math.floor(options.maxNodes)
|
|
117
|
+
: DEFAULT_MAX_NODES;
|
|
118
|
+
// Normalise the requested direction to the canonical names (legacy aliases
|
|
119
|
+
// ancestors→dependents, descendants→dependencies).
|
|
120
|
+
const requested = safeString(options.direction) || "both";
|
|
121
|
+
const direction = requested === "ancestors" ? "dependents"
|
|
122
|
+
: requested === "descendants" ? "dependencies"
|
|
123
|
+
: ["dependents", "dependencies", "both"].includes(requested) ? requested
|
|
124
|
+
: "both";
|
|
125
|
+
|
|
126
|
+
const empty = (warning) => ({
|
|
127
|
+
kind: PROVENANCE_KIND,
|
|
128
|
+
version: PROVENANCE_VERSION,
|
|
129
|
+
origin: null,
|
|
130
|
+
direction,
|
|
131
|
+
dependents: [],
|
|
132
|
+
dependencies: [],
|
|
133
|
+
ancestors: [],
|
|
134
|
+
descendants: [],
|
|
135
|
+
byType: { dependents: {}, dependencies: {} },
|
|
136
|
+
truncated: false,
|
|
137
|
+
summary: "No lineage computed.",
|
|
138
|
+
warnings: warning ? [warning] : []
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
|
|
142
|
+
return empty("graph missing or malformed");
|
|
143
|
+
}
|
|
144
|
+
const id = safeString(originId).trim();
|
|
145
|
+
if (!id) return empty("originId missing");
|
|
146
|
+
|
|
147
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
148
|
+
const originNode = nodesById.get(id);
|
|
149
|
+
if (!originNode) return empty(`origin "${id}" not found in graph`);
|
|
150
|
+
|
|
151
|
+
let dependents = [];
|
|
152
|
+
let dependencies = [];
|
|
153
|
+
let truncated = false;
|
|
154
|
+
|
|
155
|
+
if (direction === "dependents" || direction === "both") {
|
|
156
|
+
// Reuse the spine — `dependents` IS the transitive incoming (reverse) closure
|
|
157
|
+
// that deriveBlastRadius already computes. No second incoming BFS.
|
|
158
|
+
const blast = deriveBlastRadius(graph, id, { maxNodes });
|
|
159
|
+
dependents = blast.impacted;
|
|
160
|
+
truncated = truncated || blast.truncated;
|
|
161
|
+
}
|
|
162
|
+
if (direction === "dependencies" || direction === "both") {
|
|
163
|
+
const res = walk(buildOutgoingIndex(graph), nodesById, id, maxNodes);
|
|
164
|
+
dependencies = res.reached;
|
|
165
|
+
truncated = truncated || res.truncated;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const countByType = (entries) => {
|
|
169
|
+
const out = {};
|
|
170
|
+
for (const entry of entries) out[entry.type] = (out[entry.type] || 0) + 1;
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
kind: PROVENANCE_KIND,
|
|
176
|
+
version: PROVENANCE_VERSION,
|
|
177
|
+
origin: summarizeGraphNode(originNode),
|
|
178
|
+
direction,
|
|
179
|
+
// Canonical names — match findDependents (incoming) / findDependencies (outgoing).
|
|
180
|
+
dependents,
|
|
181
|
+
dependencies,
|
|
182
|
+
// Backward-compatible aliases (deprecated; can mislead for non-run nodes).
|
|
183
|
+
ancestors: dependents,
|
|
184
|
+
descendants: dependencies,
|
|
185
|
+
byType: { dependents: countByType(dependents), dependencies: countByType(dependencies) },
|
|
186
|
+
truncated,
|
|
187
|
+
summary: summarizeLineage(originNode, dependents, dependencies, direction, truncated),
|
|
188
|
+
warnings: []
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function summarizeLineage(originNode, dependents, dependencies, direction, truncated) {
|
|
193
|
+
const label = originNode?.label || originNode?.id || "node";
|
|
194
|
+
const tail = truncated ? " (truncated)" : "";
|
|
195
|
+
if (direction === "dependents") {
|
|
196
|
+
return dependents.length
|
|
197
|
+
? `${dependents.length} node(s) depend on "${label}"${tail}.`
|
|
198
|
+
: `Nothing depends on "${label}".`;
|
|
199
|
+
}
|
|
200
|
+
if (direction === "dependencies") {
|
|
201
|
+
return dependencies.length
|
|
202
|
+
? `"${label}" depends on ${dependencies.length} node(s)${tail}.`
|
|
203
|
+
: `"${label}" depends on nothing.`;
|
|
204
|
+
}
|
|
205
|
+
return `"${label}": ${dependents.length} dependent(s), ${dependencies.length} dependenc(ies)${tail}.`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export {
|
|
209
|
+
PROVENANCE_KIND,
|
|
210
|
+
PROVENANCE_VERSION,
|
|
211
|
+
DEFAULT_MAX_NODES,
|
|
212
|
+
deriveProvenanceLineage,
|
|
213
|
+
summarizeLineage
|
|
214
|
+
};
|