@growthub/cli 0.14.9 → 0.14.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +49 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +38 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-readiness.js +212 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-contract-compliance.js +168 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-impact.js +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-provenance-lineage.js +214 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-stale-surfaces.js +217 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-workflow-impact.js +170 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +3 -1
- package/dist/index.js +3024 -4191
- package/package.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Growthub Workspace Stale-Surfaces V1 — freshness-aware impact deriver.
|
|
3
|
+
*
|
|
4
|
+
* Answers the question the spine (`deriveBlastRadius`) cannot answer on its
|
|
5
|
+
* own: *given the changes the graph already records, which downstream surfaces
|
|
6
|
+
* are stale RIGHT NOW?* — with no change-event argument required.
|
|
7
|
+
*
|
|
8
|
+
* The metadata graph already timestamps the only nodes whose freshness is
|
|
9
|
+
* observable: `run.ranAt`, `sourceRecord.fetchedAt`, and
|
|
10
|
+
* `pipelineHealth.latestRanAt`. When one of those upstream nodes changes, the
|
|
11
|
+
* surfaces that DEPEND on it (its reverse-edge closure) are the surfaces now
|
|
12
|
+
* showing old data. This module seeds the EXISTING `deriveBlastRadius` closure
|
|
13
|
+
* from those freshly-changed nodes and labels the reachable dependents stale.
|
|
14
|
+
*
|
|
15
|
+
* It builds NO new graph and NO second traversal engine — it composes the
|
|
16
|
+
* shipped blast-radius deriver and the timestamps the graph already carries.
|
|
17
|
+
* Pure: no React, no fetch, no fs, no writes, no secrets. Deterministic:
|
|
18
|
+
* results are ordered (distance → type → id) and the union de-duplicates, so
|
|
19
|
+
* the output diffs cleanly between calls.
|
|
20
|
+
*
|
|
21
|
+
* Relationship to `selectStaleMetadataGroups` (single-hop, group-level): that
|
|
22
|
+
* selector answers "given THIS change event, which metadata GROUPS reload?".
|
|
23
|
+
* This deriver answers "given the timestamps already in the graph, which
|
|
24
|
+
* specific NODES are stale, transitively?". They are complementary; this one
|
|
25
|
+
* is node-level and needs no caller-supplied event.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { deriveBlastRadius } from "./workspace-metadata-impact.js";
|
|
29
|
+
|
|
30
|
+
const STALE_SURFACES_KIND = "growthub-workspace-stale-surfaces-v1";
|
|
31
|
+
const STALE_SURFACES_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MAX_NODES = 500;
|
|
34
|
+
|
|
35
|
+
// Summary fields that observably timestamp a node's last refresh. Ordered by
|
|
36
|
+
// the precision of "this node changed at T". The newest of these wins.
|
|
37
|
+
const FRESHNESS_FIELDS = ["ranAt", "fetchedAt", "latestRanAt"];
|
|
38
|
+
|
|
39
|
+
function safeString(value) {
|
|
40
|
+
if (value == null) return "";
|
|
41
|
+
return typeof value === "string" ? value : String(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a node's last-known-fresh timestamp (ms epoch) from its summary, or
|
|
46
|
+
* null when the node carries no observable freshness. Never throws.
|
|
47
|
+
*/
|
|
48
|
+
function nodeFreshAt(node) {
|
|
49
|
+
const summary = node && typeof node === "object" ? node.summary : null;
|
|
50
|
+
if (!summary || typeof summary !== "object") return null;
|
|
51
|
+
let newest = null;
|
|
52
|
+
for (const field of FRESHNESS_FIELDS) {
|
|
53
|
+
const raw = summary[field];
|
|
54
|
+
if (!raw) continue;
|
|
55
|
+
const ms = Date.parse(safeString(raw));
|
|
56
|
+
if (Number.isNaN(ms)) continue;
|
|
57
|
+
if (newest == null || ms > newest) newest = ms;
|
|
58
|
+
}
|
|
59
|
+
return newest;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute the surfaces that are stale given the freshness already recorded in
|
|
64
|
+
* the graph.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} graph a `buildWorkspaceMetadataGraph` envelope
|
|
67
|
+
* @param {object} [options]
|
|
68
|
+
* @param {string|number} [options.since] only treat nodes changed at/after
|
|
69
|
+
* this instant (ISO string or ms epoch) as seeds. Omit to seed from
|
|
70
|
+
* every node that carries a freshness timestamp.
|
|
71
|
+
* @param {string[]} [options.seedIds] explicit seed node ids (e.g. the nodes a
|
|
72
|
+
* just-applied PATCH touched). When given, `since` is ignored and these
|
|
73
|
+
* ids are the change set — this is the preflight/`plan` entry point.
|
|
74
|
+
* @param {number} [options.maxNodes=500] hard cap on stale surfaces.
|
|
75
|
+
* @returns {object} `{ kind, version, since, seeds[], staleSurfaces[], byType, total, truncated, summary, warnings }`
|
|
76
|
+
*/
|
|
77
|
+
function deriveStaleSurfaces(graph, options = {}) {
|
|
78
|
+
const maxNodes = Number.isFinite(options.maxNodes) && options.maxNodes > 0
|
|
79
|
+
? Math.floor(options.maxNodes)
|
|
80
|
+
: DEFAULT_MAX_NODES;
|
|
81
|
+
|
|
82
|
+
const empty = (warning) => ({
|
|
83
|
+
kind: STALE_SURFACES_KIND,
|
|
84
|
+
version: STALE_SURFACES_VERSION,
|
|
85
|
+
since: null,
|
|
86
|
+
seeds: [],
|
|
87
|
+
staleSurfaces: [],
|
|
88
|
+
byType: {},
|
|
89
|
+
total: 0,
|
|
90
|
+
truncated: false,
|
|
91
|
+
summary: "No stale surfaces computed.",
|
|
92
|
+
warnings: warning ? [warning] : []
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!graph || typeof graph !== "object" || !Array.isArray(graph.nodes)) {
|
|
96
|
+
return empty("graph missing or malformed");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
100
|
+
|
|
101
|
+
// ── Resolve the change set (the seeds) ──────────────────────────────────
|
|
102
|
+
let sinceMs = null;
|
|
103
|
+
let seeds = [];
|
|
104
|
+
const explicit = Array.isArray(options.seedIds) ? options.seedIds.map(safeString).filter(Boolean) : null;
|
|
105
|
+
|
|
106
|
+
if (explicit && explicit.length) {
|
|
107
|
+
for (const id of explicit) {
|
|
108
|
+
const node = nodesById.get(id);
|
|
109
|
+
if (node) seeds.push({ node, changedAtMs: nodeFreshAt(node) });
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
if (options.since != null) {
|
|
113
|
+
const raw = options.since;
|
|
114
|
+
sinceMs = typeof raw === "number" ? raw : Date.parse(safeString(raw));
|
|
115
|
+
if (Number.isNaN(sinceMs)) sinceMs = null;
|
|
116
|
+
}
|
|
117
|
+
for (const node of graph.nodes) {
|
|
118
|
+
const changedAtMs = nodeFreshAt(node);
|
|
119
|
+
if (changedAtMs == null) continue;
|
|
120
|
+
if (sinceMs != null && changedAtMs < sinceMs) continue;
|
|
121
|
+
seeds.push({ node, changedAtMs });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!seeds.length) {
|
|
126
|
+
const out = empty();
|
|
127
|
+
out.since = sinceMs;
|
|
128
|
+
out.summary = "No recently-changed nodes — nothing is stale.";
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Union the reverse closures of every seed (reuse the spine) ──────────
|
|
133
|
+
// A downstream node is stale relative to a seed when its own last-fresh
|
|
134
|
+
// timestamp predates the seed's change (or it carries none — it cannot prove
|
|
135
|
+
// freshness, so it is reported stale honestly rather than silently fresh).
|
|
136
|
+
const staleById = new Map();
|
|
137
|
+
for (const { node: seedNode, changedAtMs } of seeds) {
|
|
138
|
+
const blast = deriveBlastRadius(graph, seedNode.id, { maxNodes });
|
|
139
|
+
for (const impacted of blast.impacted) {
|
|
140
|
+
const target = nodesById.get(impacted.id);
|
|
141
|
+
const targetFreshAt = nodeFreshAt(target);
|
|
142
|
+
const isStale = changedAtMs == null
|
|
143
|
+
? true
|
|
144
|
+
: targetFreshAt == null || targetFreshAt < changedAtMs;
|
|
145
|
+
if (!isStale) continue;
|
|
146
|
+
const existing = staleById.get(impacted.id);
|
|
147
|
+
// Keep the nearest / most-recent reason for each stale surface.
|
|
148
|
+
if (!existing || impacted.distance < existing.distance) {
|
|
149
|
+
staleById.set(impacted.id, {
|
|
150
|
+
id: impacted.id,
|
|
151
|
+
type: impacted.type,
|
|
152
|
+
label: impacted.label,
|
|
153
|
+
metadataId: impacted.metadataId,
|
|
154
|
+
distance: impacted.distance,
|
|
155
|
+
viaRelation: impacted.viaRelation,
|
|
156
|
+
staleSinceSeed: seedNode.id,
|
|
157
|
+
lastFreshAt: targetFreshAt != null ? new Date(targetFreshAt).toISOString() : null
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let staleSurfaces = Array.from(staleById.values());
|
|
164
|
+
let truncated = false;
|
|
165
|
+
if (staleSurfaces.length > maxNodes) {
|
|
166
|
+
staleSurfaces = staleSurfaces.slice(0, maxNodes);
|
|
167
|
+
truncated = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
staleSurfaces.sort((a, b) =>
|
|
171
|
+
a.distance - b.distance ||
|
|
172
|
+
a.type.localeCompare(b.type) ||
|
|
173
|
+
a.id.localeCompare(b.id)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const byType = {};
|
|
177
|
+
for (const entry of staleSurfaces) {
|
|
178
|
+
byType[entry.type] = (byType[entry.type] || 0) + 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
kind: STALE_SURFACES_KIND,
|
|
183
|
+
version: STALE_SURFACES_VERSION,
|
|
184
|
+
since: sinceMs,
|
|
185
|
+
seeds: seeds.map(({ node }) => ({ id: node.id, type: node.type, label: node.label, metadataId: node.metadataId })),
|
|
186
|
+
staleSurfaces,
|
|
187
|
+
byType,
|
|
188
|
+
total: staleSurfaces.length,
|
|
189
|
+
truncated,
|
|
190
|
+
summary: summarizeStaleSurfaces(seeds, staleSurfaces, byType, truncated),
|
|
191
|
+
warnings: []
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One human sentence for the inspector chip / preflight line / CLI output.
|
|
197
|
+
* Pure string assembly — never throws.
|
|
198
|
+
*/
|
|
199
|
+
function summarizeStaleSurfaces(seeds, staleSurfaces, byType, truncated) {
|
|
200
|
+
if (!staleSurfaces.length) {
|
|
201
|
+
return `${seeds.length} recent change(s) — no downstream surface is stale.`;
|
|
202
|
+
}
|
|
203
|
+
const parts = Object.keys(byType)
|
|
204
|
+
.sort()
|
|
205
|
+
.map((type) => `${byType[type]} ${type}`);
|
|
206
|
+
const tail = truncated ? " (truncated)" : "";
|
|
207
|
+
return `${seeds.length} recent change(s) leave ${staleSurfaces.length} surface(s) stale: ${parts.join(", ")}${tail}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export {
|
|
211
|
+
STALE_SURFACES_KIND,
|
|
212
|
+
STALE_SURFACES_VERSION,
|
|
213
|
+
DEFAULT_MAX_NODES,
|
|
214
|
+
deriveStaleSurfaces,
|
|
215
|
+
summarizeStaleSurfaces,
|
|
216
|
+
nodeFreshAt
|
|
217
|
+
};
|