@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.
Files changed (61) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/callback/route.js +35 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
  6. 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
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +3 -20
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +29 -19
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +607 -63
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +21 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
  51. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
  52. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/provider.png +0 -0
  53. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
  54. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
  55. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
  56. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
  57. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -0
  58. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +6 -0
  59. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +3 -1
  60. package/dist/index.js +3024 -4191
  61. 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
+ };