graphlin 0.2.0 → 0.2.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/docs/usage.md +7 -0
- package/docs/visualizer-views.md +41 -0
- package/package.json +5 -1
- package/plugin.json +1 -1
- package/runtime/architecture/analysis.mjs +344 -0
- package/runtime/architecture/controller.mjs +209 -0
- package/runtime/architecture/evidence.mjs +108 -0
- package/runtime/architecture/profile.mjs +56 -0
- package/runtime/daemon/server.mjs +11 -2
- package/runtime/model/project-model.mjs +145 -14
- package/runtime/model/records.mjs +3 -2
- package/runtime/pipeline.mjs +143 -10
- package/runtime/visualizers/c4.mjs +125 -23
- package/runtime/web/app.js +1 -1
- package/runtime/web/index.html +2 -0
- package/runtime/web/platform.js +111 -3
- package/runtime/web/style.css +2 -0
- package/scripts/control.mjs +1 -1
package/runtime/pipeline.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from './core/index.mjs';
|
|
9
9
|
import { safeLabel, safeText, excluded } from './core/privacy.mjs';
|
|
10
10
|
import { createPlatform } from './platform.mjs';
|
|
11
|
+
import { createArchitectureController } from './architecture/controller.mjs';
|
|
12
|
+
import { analyzeArchitecture, ARCHITECTURE_NAMESPACE } from './architecture/analysis.mjs';
|
|
11
13
|
|
|
12
14
|
const MAX_ACTIVITY = 200;
|
|
13
15
|
const MAX_HOOK_EVENTS = 200;
|
|
@@ -93,7 +95,7 @@ function restoreGraph(input, { stale = true } = {}) {
|
|
|
93
95
|
export function createPipeline({
|
|
94
96
|
projectRoot, policy: policyOptions, decisionService, onChange = () => {},
|
|
95
97
|
onDiagnostic = () => {}, restoredState, restoredModel, mode = 'live', clock = Date.now,
|
|
96
|
-
classificationDeadlineMs = DEADLINE_MS,
|
|
98
|
+
classificationDeadlineMs = DEADLINE_MS, missingKey = false,
|
|
97
99
|
} = {}) {
|
|
98
100
|
if (!Number.isSafeInteger(classificationDeadlineMs) ||
|
|
99
101
|
classificationDeadlineMs < DEADLINE_MS || classificationDeadlineMs > 10_000) {
|
|
@@ -152,6 +154,9 @@ export function createPipeline({
|
|
|
152
154
|
let reconciliationTask = null;
|
|
153
155
|
let resumeScheduled = false;
|
|
154
156
|
let lineageId = null;
|
|
157
|
+
let architecture;
|
|
158
|
+
let architectureEpoch = 0;
|
|
159
|
+
const architectureGuards = new WeakMap();
|
|
155
160
|
|
|
156
161
|
const serialized = (fn) => {
|
|
157
162
|
const operation = serial.then(fn);
|
|
@@ -167,9 +172,124 @@ export function createPipeline({
|
|
|
167
172
|
registerArtifacts(await evidence.reconcile({ refs }));
|
|
168
173
|
return evidence.isCurrent(refs);
|
|
169
174
|
}),
|
|
175
|
+
onChange: () => { architecture?.wake(); notify(); },
|
|
176
|
+
});
|
|
177
|
+
architecture = createArchitectureController({
|
|
178
|
+
snapshot: () => platform.snapshot(),
|
|
179
|
+
capture: captureArchitecture,
|
|
180
|
+
commit: commitArchitecture,
|
|
181
|
+
analyze: async input => {
|
|
182
|
+
const guard = {
|
|
183
|
+
epoch: architectureEpoch, signal: input.signal, policyVersion: policy.version,
|
|
184
|
+
lineageId: input.model.coverage.lineage?.id ?? modelProjectId,
|
|
185
|
+
};
|
|
186
|
+
const result = await analyzeArchitecture({ ...input, service: decisionService, policy });
|
|
187
|
+
architectureGuards.set(result, guard);
|
|
188
|
+
return result;
|
|
189
|
+
},
|
|
190
|
+
available: architectureUnavailable,
|
|
191
|
+
ready: () => {
|
|
192
|
+
const { queued, active } = platform.stats();
|
|
193
|
+
return queued === 0 && active === 0;
|
|
194
|
+
},
|
|
170
195
|
onChange: notify,
|
|
196
|
+
onDiagnostic: result => trace(null, 'classification', {
|
|
197
|
+
status: result.status === 'complete' ? 'accepted' : result.status === 'partial' ? 'partial' : 'unavailable',
|
|
198
|
+
reason: result.status === 'complete' ? 'ok' : result.status === 'partial' ? 'unknown' : 'decision_failure',
|
|
199
|
+
diagnostics: { calls: result.providerRequests },
|
|
200
|
+
}),
|
|
201
|
+
now: clock,
|
|
171
202
|
});
|
|
172
203
|
|
|
204
|
+
function architectureUnavailable() {
|
|
205
|
+
if (closed) return 'closed';
|
|
206
|
+
if (!policy.transmitSource) return 'source_consent_required';
|
|
207
|
+
if (missingKey) return 'missing_key';
|
|
208
|
+
if (paused) return 'paused';
|
|
209
|
+
if (mode === 'demo') return 'demo';
|
|
210
|
+
if (typeof decisionService?.analyze !== 'function' || typeof decisionService?.evaluate !== 'function') {
|
|
211
|
+
return 'unsupported_service';
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function captureArchitecture(ids) {
|
|
217
|
+
const artifacts = await serialized(async () => {
|
|
218
|
+
if (architectureUnavailable()) return [];
|
|
219
|
+
const refs = ids.slice(0, 64).filter(id => knownArtifacts.has(id)).map(artifactId => ({ artifactId }));
|
|
220
|
+
const captures = await evidence.reconcile({ refs });
|
|
221
|
+
registerArtifacts(captures);
|
|
222
|
+
return captures;
|
|
223
|
+
});
|
|
224
|
+
// Parser acceptance uses the same serial queue; waiting inside it deadlocks.
|
|
225
|
+
await platform.whenIdle();
|
|
226
|
+
return artifacts;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function commitArchitecture(result, context) {
|
|
230
|
+
return serialized(async () => {
|
|
231
|
+
const guard = architectureGuards.get(result);
|
|
232
|
+
architectureGuards.delete(result);
|
|
233
|
+
const current = () => guard && guard.signal === context.signal && !context.signal.aborted &&
|
|
234
|
+
guard.epoch === architectureEpoch && guard.policyVersion === policy.version &&
|
|
235
|
+
guard.lineageId === (lineageId ?? platform.snapshot().coverage.lineage?.id ?? modelProjectId) &&
|
|
236
|
+
!architectureUnavailable();
|
|
237
|
+
if (!current() || result.sourceRefs.length > 256 || context.artifacts.length > 64) return false;
|
|
238
|
+
const expected = new Map();
|
|
239
|
+
const sameVersion = (a, b) => a && b && a.hash === b.hash &&
|
|
240
|
+
a.generation === b.generation && a.status === b.status;
|
|
241
|
+
for (const value of [...context.artifacts, ...result.sourceRefs.map(ref => ({ ...ref, status: 'present' }))]) {
|
|
242
|
+
if (!knownArtifacts.has(value.artifactId) ||
|
|
243
|
+
expected.has(value.artifactId) && !sameVersion(expected.get(value.artifactId), value)) return false;
|
|
244
|
+
expected.set(value.artifactId, value);
|
|
245
|
+
}
|
|
246
|
+
if (expected.size > 256) return false;
|
|
247
|
+
const observed = new Map();
|
|
248
|
+
const refs = [...expected.keys()].map(artifactId => ({ artifactId }));
|
|
249
|
+
// Re-read all selected versions, including absence, and any extra
|
|
250
|
+
// membership support. Retain metadata only across these bounded reads.
|
|
251
|
+
for (let offset = 0; offset < refs.length; offset += 32) {
|
|
252
|
+
const captures = await evidence.reconcile({ refs: refs.slice(offset, offset + 32) });
|
|
253
|
+
registerArtifacts(captures);
|
|
254
|
+
for (const artifact of captures) observed.set(artifact.id, {
|
|
255
|
+
hash: artifact.hash, generation: artifact.generation, status: artifact.status,
|
|
256
|
+
exists: artifact.exists, complete: artifact.complete,
|
|
257
|
+
});
|
|
258
|
+
if (!current()) return false;
|
|
259
|
+
}
|
|
260
|
+
if ([...expected].some(([id, value]) => !sameVersion(value, observed.get(id)))) return false;
|
|
261
|
+
const missing = new Set(result.coverage.missingArtifactIds ?? []);
|
|
262
|
+
const selected = new Set(context.artifacts.map(value => value.artifactId));
|
|
263
|
+
for (const id of missing) {
|
|
264
|
+
const value = observed.get(id);
|
|
265
|
+
if (!selected.has(id) || value?.status !== 'missing' || value.hash !== null ||
|
|
266
|
+
value.exists !== false || value.complete !== true) return false;
|
|
267
|
+
}
|
|
268
|
+
const withdrawn = new Set(result.coverage.withdrawnEntityIds ?? []);
|
|
269
|
+
const model = platform.snapshot();
|
|
270
|
+
const entities = new Map(model.entities.map(value => [value.id, value]));
|
|
271
|
+
if ([...withdrawn].some(id => !missing.has(entities.get(id)?.artifactId))) return false;
|
|
272
|
+
const affected = result.affectedEntityIds.filter(id => !withdrawn.has(id));
|
|
273
|
+
let omitted = 0;
|
|
274
|
+
if (affected.length || result.interpretations.length) {
|
|
275
|
+
const replacement = platform.model.replaceInterpretations(ARCHITECTURE_NAMESPACE, result.interpretations, {
|
|
276
|
+
affectedEntityIds: affected, sourceRefs: result.sourceRefs,
|
|
277
|
+
});
|
|
278
|
+
if (!replacement.accepted) return false;
|
|
279
|
+
omitted = Math.max(0, result.interpretations.length - replacement.retained);
|
|
280
|
+
}
|
|
281
|
+
if (missing.size) {
|
|
282
|
+
// Present-source guards cannot prove deletion. The serialized reread
|
|
283
|
+
// above authorizes this clear, even when deleted anchors were evicted.
|
|
284
|
+
const cleared = platform.model.replaceInterpretations(ARCHITECTURE_NAMESPACE, [], {
|
|
285
|
+
affectedEntityIds: [...withdrawn], artifactIds: [...missing],
|
|
286
|
+
});
|
|
287
|
+
if (!cleared.accepted) return false;
|
|
288
|
+
}
|
|
289
|
+
return omitted ? { accepted: true, omitted } : true;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
173
293
|
function artifactMetadata(artifact) {
|
|
174
294
|
const relative = artifact.relativePath;
|
|
175
295
|
return {
|
|
@@ -429,6 +549,7 @@ export function createPipeline({
|
|
|
429
549
|
}
|
|
430
550
|
}
|
|
431
551
|
platform.observeArtifacts(artifacts, event);
|
|
552
|
+
if (changed.length) architecture.observe(changed);
|
|
432
553
|
return changed;
|
|
433
554
|
}
|
|
434
555
|
|
|
@@ -1074,6 +1195,7 @@ export function createPipeline({
|
|
|
1074
1195
|
function setPaused(value) {
|
|
1075
1196
|
const wasPaused = paused;
|
|
1076
1197
|
paused = Boolean(value);
|
|
1198
|
+
if (wasPaused !== paused) architectureEpoch++;
|
|
1077
1199
|
if (!wasPaused && paused) {
|
|
1078
1200
|
for (const job of classificationQueue.splice(0)) {
|
|
1079
1201
|
deferClassification(job.event, job.candidates);
|
|
@@ -1086,6 +1208,7 @@ export function createPipeline({
|
|
|
1086
1208
|
serialized(flushDeferred).catch(() => { dropped++; })
|
|
1087
1209
|
.finally(() => { resumeScheduled = false; });
|
|
1088
1210
|
}
|
|
1211
|
+
architecture.wake();
|
|
1089
1212
|
notify();
|
|
1090
1213
|
return getState();
|
|
1091
1214
|
}
|
|
@@ -1095,6 +1218,8 @@ export function createPipeline({
|
|
|
1095
1218
|
if (closed || lineage.id === lineageId) return;
|
|
1096
1219
|
platform.observeLineage(lineage);
|
|
1097
1220
|
lineageId = lineage.id;
|
|
1221
|
+
architectureEpoch++;
|
|
1222
|
+
architecture.invalidate();
|
|
1098
1223
|
completedClassifications.clear();
|
|
1099
1224
|
for (const job of activeClassifications) job.controller.abort();
|
|
1100
1225
|
for (const job of classificationQueue.splice(0)) {
|
|
@@ -1115,21 +1240,26 @@ export function createPipeline({
|
|
|
1115
1240
|
}
|
|
1116
1241
|
|
|
1117
1242
|
async function whenIdle() {
|
|
1118
|
-
|
|
1119
|
-
await platform.whenIdle();
|
|
1120
|
-
await serial;
|
|
1121
|
-
while (tasks.size || classificationQueue.length) {
|
|
1122
|
-
pumpClassifications();
|
|
1123
|
-
await Promise.allSettled([...tasks]);
|
|
1243
|
+
do {
|
|
1124
1244
|
await serial;
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1245
|
+
await platform.whenIdle();
|
|
1246
|
+
await serial;
|
|
1247
|
+
while (tasks.size || classificationQueue.length) {
|
|
1248
|
+
pumpClassifications();
|
|
1249
|
+
await Promise.allSettled([...tasks]);
|
|
1250
|
+
await serial;
|
|
1251
|
+
}
|
|
1252
|
+
await platform.whenIdle();
|
|
1253
|
+
await architecture.whenIdle();
|
|
1254
|
+
await serial;
|
|
1255
|
+
} while (tasks.size || classificationQueue.length || platform.stats().queued || platform.stats().active);
|
|
1128
1256
|
}
|
|
1129
1257
|
|
|
1130
1258
|
async function close() {
|
|
1131
1259
|
if (closed) return;
|
|
1132
1260
|
closed = true;
|
|
1261
|
+
architectureEpoch++;
|
|
1262
|
+
const architectureClosed = architecture.close();
|
|
1133
1263
|
deferredWork.clear();
|
|
1134
1264
|
for (const job of classificationQueue.splice(0)) {
|
|
1135
1265
|
skipJob(job, 'pipeline_closed');
|
|
@@ -1138,11 +1268,14 @@ export function createPipeline({
|
|
|
1138
1268
|
for (const job of activeClassifications) job.controller.abort();
|
|
1139
1269
|
decisionService?.close?.();
|
|
1140
1270
|
await platform.close();
|
|
1271
|
+
await architectureClosed;
|
|
1141
1272
|
await whenIdle();
|
|
1142
1273
|
}
|
|
1143
1274
|
|
|
1144
1275
|
return {
|
|
1145
1276
|
ingest, getState, reconcile, observeLineage, setPaused, selectSession, whenIdle, close,
|
|
1277
|
+
getArchitectureStatus: () => architecture.status(),
|
|
1278
|
+
discoverArchitecture: () => architecture.request(),
|
|
1146
1279
|
getModelState: options => platform.snapshot(options),
|
|
1147
1280
|
createCheckpoint: options => platform.checkpoint(options),
|
|
1148
1281
|
model: platform.model,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { structureScene, aggregateEdges, sceneKind, emptyScene } from './structure.mjs';
|
|
1
|
+
import { structureScene, aggregateEdges, sceneKind, emptyScene, entityIndex } from './structure.mjs';
|
|
2
2
|
|
|
3
3
|
const LEVELS = {
|
|
4
4
|
context: new Set(['system', 'external_system', 'actor', 'person', 'context']),
|
|
@@ -11,40 +11,142 @@ const KINDS = { application: 'service', container: 'service', datastore: 'datast
|
|
|
11
11
|
export function c4Scene(model, settings = {}) {
|
|
12
12
|
if (settings.level === 'code') return structureScene(model, settings, { flat: true });
|
|
13
13
|
const level = LEVELS[settings.level] || LEVELS.applications;
|
|
14
|
-
const byId =
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
const { byId, children: sourceChildrenById } = entityIndex(model);
|
|
15
|
+
const supported = model.interpretations.filter(value =>
|
|
16
|
+
value.validity === 'current' && value.support === 'supported' &&
|
|
17
17
|
value.classification === 'accepted' && value.sourceRefs?.length &&
|
|
18
|
-
value.entityIds?.some(id => byId.has(id)))
|
|
19
|
-
|
|
18
|
+
value.entityIds?.some(id => byId.has(id))).map(value => ({
|
|
19
|
+
...value, members: value.entityIds.filter(id => byId.has(id)), memberSet: new Set(value.entityIds),
|
|
20
|
+
}));
|
|
21
|
+
if (!supported.some(value => level.has(value.kind))) {
|
|
20
22
|
const scene = structureScene(model, { ...settings, depth: 0 });
|
|
21
23
|
scene.coverage.label = 'Source scopes; application and responsibility boundaries unknown';
|
|
22
24
|
scene.groups.forEach(group => { group.style = 'unknown'; });
|
|
23
25
|
scene.nodes.forEach(node => { node.style = 'unknown'; });
|
|
24
26
|
return scene;
|
|
25
27
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const applications = supported.filter(value => LEVELS.applications.has(value.kind));
|
|
29
|
+
const components = supported.filter(value => LEVELS.components.has(value.kind));
|
|
30
|
+
const parents = new Map(), children = new Map();
|
|
31
|
+
const explicitParents = new Map();
|
|
32
|
+
for (const membership of supported.filter(value =>
|
|
33
|
+
value.namespace === 'graphlin.architecture' && value.kind === 'architecture_membership')) {
|
|
34
|
+
const anchors = membership.entityIds;
|
|
35
|
+
if (anchors.length !== 2 || anchors[0] === anchors[1] ||
|
|
36
|
+
anchors.some(id => byId.get(id)?.validity !== 'current')) continue;
|
|
37
|
+
const parent = applications.filter(value => value.kind === 'application' &&
|
|
38
|
+
value.entityIds.length === 1 && anchors.includes(value.entityIds[0]));
|
|
39
|
+
const child = components.filter(value => value.entityIds.length === 1 && anchors.includes(value.entityIds[0]));
|
|
40
|
+
if (parent.length !== 1 || child.length !== 1 || parent[0].entityIds[0] === child[0].entityIds[0]) continue;
|
|
41
|
+
if (!explicitParents.has(child[0].id)) explicitParents.set(child[0].id, new Set());
|
|
42
|
+
explicitParents.get(child[0].id).add(parent[0].id);
|
|
43
|
+
}
|
|
44
|
+
for (const component of components) {
|
|
45
|
+
// Host architecture uses small, source-backed anchor pairs. Other
|
|
46
|
+
// interpretations can still establish containment by a unique strict subset.
|
|
47
|
+
const explicit = explicitParents.get(component.id);
|
|
48
|
+
const candidates = explicit ? applications.filter(value => explicit.has(value.id))
|
|
49
|
+
: component.namespace === 'graphlin.architecture' ? []
|
|
50
|
+
: applications.filter(application => component.memberSet.size < application.memberSet.size &&
|
|
51
|
+
[...component.memberSet].every(id => application.memberSet.has(id)));
|
|
52
|
+
if (candidates.length !== 1) continue;
|
|
53
|
+
const parent = candidates[0];
|
|
54
|
+
parents.set(component.id, parent.id);
|
|
55
|
+
if (!children.has(parent.id)) children.set(parent.id, []);
|
|
56
|
+
children.get(parent.id).push(component);
|
|
57
|
+
}
|
|
58
|
+
const descendants = anchor => {
|
|
59
|
+
const pending = [anchor], visited = new Set();
|
|
60
|
+
for (let index = 0; index < pending.length; index++) {
|
|
61
|
+
const id = pending[index];
|
|
62
|
+
if (visited.has(id)) continue;
|
|
63
|
+
visited.add(id);
|
|
64
|
+
pending.push(...(sourceChildrenById.get(id) || []));
|
|
65
|
+
}
|
|
66
|
+
return [...visited];
|
|
67
|
+
};
|
|
68
|
+
for (const boundary of [...applications, ...components]) {
|
|
69
|
+
if (boundary.namespace === 'graphlin.architecture' && boundary.members.length === 1)
|
|
70
|
+
boundary.members = descendants(boundary.members[0]);
|
|
71
|
+
}
|
|
72
|
+
for (const application of applications) {
|
|
73
|
+
// This is a bounded display projection, not a persisted union of evidence
|
|
74
|
+
// references or a new application-membership claim.
|
|
75
|
+
application.members = [...new Set([...application.members,
|
|
76
|
+
...(children.get(application.id) || []).flatMap(component => component.members)])];
|
|
77
|
+
}
|
|
78
|
+
const roots = level === LEVELS.components
|
|
79
|
+
? [...applications.filter(value => children.has(value.id)), ...components.filter(value => !parents.has(value.id))]
|
|
80
|
+
: supported.filter(value => level.has(value.kind));
|
|
81
|
+
const expanded = new Set(settings.expanded || []), collapsed = new Set(settings.collapsed || []);
|
|
82
|
+
const legacyKeys = new Map();
|
|
83
|
+
for (const boundary of supported.filter(value => value.kind !== 'architecture_membership'))
|
|
84
|
+
legacyKeys.set(boundary.members[0], (legacyKeys.get(boundary.members[0]) || 0) + 1);
|
|
85
|
+
const query = (settings.query || '').toLowerCase(), kinds = settings.kinds ? new Set(settings.kinds) : null;
|
|
86
|
+
const matching = id => {
|
|
87
|
+
const entity = byId.get(id);
|
|
88
|
+
return entity && (!query || `${entity.label} ${entity.qualifiedName || ''}`.toLowerCase().includes(query)) &&
|
|
89
|
+
(!kinds || kinds.has(entity.kind));
|
|
90
|
+
};
|
|
91
|
+
function isExpanded(id, members, defaultOpen = false) {
|
|
92
|
+
if ((query || kinds) && members.some(matching)) return true;
|
|
93
|
+
if (collapsed.has(id)) return false;
|
|
94
|
+
if (expanded.has(id)) return true;
|
|
95
|
+
// Older in-memory settings used a group's first source member. Keep that
|
|
96
|
+
// fallback only when it identifies a single interpretation.
|
|
97
|
+
const legacy = members[0];
|
|
98
|
+
if (legacyKeys.get(legacy) === 1) {
|
|
99
|
+
if (collapsed.has(legacy)) return false;
|
|
100
|
+
if (expanded.has(legacy)) return true;
|
|
101
|
+
}
|
|
102
|
+
return defaultOpen;
|
|
103
|
+
}
|
|
104
|
+
const scene = emptyScene(), represented = new Set(), emitted = new Set(), sourceNodes = new Set();
|
|
105
|
+
let truncated = false;
|
|
106
|
+
function sourceChildren(members, parentId, excluded = new Set()) {
|
|
107
|
+
for (const id of members) {
|
|
108
|
+
if (excluded.has(id) || sourceNodes.has(id)) continue;
|
|
109
|
+
const entity = byId.get(id);
|
|
110
|
+
if (entity.validity === 'retracted') continue;
|
|
111
|
+
if (scene.nodes.length + scene.groups.length >= 255) { truncated = true; break; }
|
|
112
|
+
const style = entity.validity !== 'current' ? 'stale' :
|
|
113
|
+
entity.classification === 'tentative' ? 'tentative' : 'default';
|
|
114
|
+
scene.nodes.push({ id, entityId: id, label: entity.label, kind: sceneKind(entity.kind), parentId, style });
|
|
115
|
+
sourceNodes.add(id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function addBoundary(boundary, parentId) {
|
|
119
|
+
if (emitted.has(boundary.id)) return;
|
|
120
|
+
if (scene.groups.length >= 63 || scene.nodes.length + scene.groups.length >= 255) { truncated = true; return; }
|
|
121
|
+
emitted.add(boundary.id);
|
|
122
|
+
const members = boundary.members.slice(0, 256), id = `c4.${boundary.id}`;
|
|
123
|
+
boundary.members.forEach(member => represented.add(member));
|
|
124
|
+
if (boundary.members.length > members.length) truncated = true;
|
|
125
|
+
const nested = level === LEVELS.context ? [] : children.get(boundary.id) || [];
|
|
126
|
+
const open = isExpanded(id, members, boundary.kind === 'application' || nested.length > 0);
|
|
31
127
|
scene.groups.push({
|
|
32
|
-
id
|
|
128
|
+
id, entityIds: members, label: boundary.label, collapsed: !open,
|
|
33
129
|
kind: sceneKind(KINDS[boundary.kind] || 'group'), membershipId: boundary.id,
|
|
130
|
+
...(parentId ? { parentId } : {}),
|
|
34
131
|
});
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
132
|
+
if (open) {
|
|
133
|
+
for (const child of nested) addBoundary(child, id);
|
|
134
|
+
const nestedMembers = new Set(nested.filter(child => emitted.has(child.id)).flatMap(child => child.members));
|
|
135
|
+
sourceChildren(members, id, nestedMembers);
|
|
39
136
|
}
|
|
40
137
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
138
|
+
for (const boundary of roots) addBoundary(boundary);
|
|
139
|
+
const unknown = model.entities.filter(entity => !represented.has(entity.id));
|
|
140
|
+
if (unknown.length) {
|
|
141
|
+
const members = unknown.slice(0, 256).map(entity => entity.id), open = isExpanded('c4.unknown', members);
|
|
142
|
+
scene.groups.push({
|
|
143
|
+
id: 'c4.unknown', entityIds: members, label: 'Responsibility unknown',
|
|
144
|
+
kind: 'unknown', collapsed: !open, style: 'unknown',
|
|
145
|
+
});
|
|
146
|
+
if (open) sourceChildren(members, 'c4.unknown');
|
|
147
|
+
if (unknown.length > members.length) truncated = true;
|
|
148
|
+
}
|
|
149
|
+
scene.coverage = { shown: scene.groups.length + scene.nodes.length, total: model.entities.length, truncated,
|
|
48
150
|
label: 'Supported interpretations; source does not establish runtime hosting' };
|
|
49
151
|
return aggregateEdges(model, scene);
|
|
50
152
|
}
|
package/runtime/web/app.js
CHANGED
|
@@ -2246,7 +2246,7 @@ export function startViewer() {
|
|
|
2246
2246
|
'aria-expanded': String(!node.collapsed), transform: `translate(${node.width - 33} 10)` });
|
|
2247
2247
|
toggle.append(svgElement('rect', { width: 24, height: 24, rx: 4 }),
|
|
2248
2248
|
svgElement('text', { x: 12, y: 18, 'text-anchor': 'middle' }, node.collapsed ? '+' : '−'));
|
|
2249
|
-
const toggleGroup = event => { event.stopPropagation(); platform.toggle(node.
|
|
2249
|
+
const toggleGroup = event => { event.stopPropagation(); platform.toggle(node.id, node.collapsed); };
|
|
2250
2250
|
toggle.addEventListener('click', toggleGroup);
|
|
2251
2251
|
toggle.addEventListener('keydown', event => {
|
|
2252
2252
|
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleGroup(event); }
|
package/runtime/web/index.html
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
<option value="context">Context</option><option value="applications" selected>Applications / datastores</option>
|
|
132
132
|
<option value="components">Components</option><option value="code">Code</option>
|
|
133
133
|
</select></label>
|
|
134
|
+
<button id="architecture-discover" type="button" aria-describedby="architecture-status" hidden>Discover architecture</button>
|
|
134
135
|
<label id="baseline-label" hidden>Baseline <select id="task-baseline"><option value="">Choose checkpoint</option></select></label>
|
|
135
136
|
<button id="baseline-create" type="button" hidden>Set baseline now</button>
|
|
136
137
|
<label>Position <select id="model-position"><option value="">Live</option></select></label>
|
|
@@ -142,6 +143,7 @@
|
|
|
142
143
|
<button id="analysis-access" type="button">Manage access</button>
|
|
143
144
|
</div>
|
|
144
145
|
</div>
|
|
146
|
+
<p id="architecture-status" class="architecture-status" role="status" aria-live="polite" hidden></p>
|
|
145
147
|
<p class="view-status" id="view-status" role="status" hidden></p>
|
|
146
148
|
<section class="extension-access" id="extension-access" aria-label="Visualizer data access" hidden>
|
|
147
149
|
<p id="extension-access-description"></p>
|
package/runtime/web/platform.js
CHANGED
|
@@ -5,13 +5,38 @@ import { createExtensionFrame } from './extension-frame.js';
|
|
|
5
5
|
import { DATA_FIELDS, extensionId, digest, id as validId } from '../extensions/contracts.mjs';
|
|
6
6
|
import { filterScene } from './scene.js';
|
|
7
7
|
|
|
8
|
+
const DISCOVERY_STATES = ['idle', 'waiting', 'queued', 'running', 'complete', 'partial', 'unavailable'];
|
|
9
|
+
const DISCOVERY_BLOCKED = ['source_consent_required', 'missing_key', 'no_source', 'paused', 'unsupported_service', 'endpoint_unavailable'];
|
|
10
|
+
const DISCOVERY_REASONS = {
|
|
11
|
+
source_consent_required: 'Architecture discovery needs source-transmission consent. Enable source mode for this project in Graphlin setup.',
|
|
12
|
+
missing_key: 'Configure a classification service key in Graphlin setup to discover architecture.',
|
|
13
|
+
no_source: 'No source evidence is available yet. Let Graphlin inspect project files, then try again.',
|
|
14
|
+
paused: 'Classification is paused. Resume classification to discover architecture.',
|
|
15
|
+
analysis_failed: 'Architecture discovery could not finish. Try again; supported boundaries remain available.',
|
|
16
|
+
unsupported_service: 'The configured classification service does not support architecture discovery.',
|
|
17
|
+
source_changed: 'Source changed during discovery. Boundaries will be checked against current evidence.',
|
|
18
|
+
partial_coverage: 'Some evidence is unavailable. The shown boundaries cover only inspected evidence.',
|
|
19
|
+
none_supported: 'No supported application or component boundaries were found. Source scopes remain available.',
|
|
20
|
+
endpoint_unavailable: 'Architecture discovery requires a newer local service.',
|
|
21
|
+
request_failed: 'Could not check architecture discovery. Reconnect to the local service or try again.',
|
|
22
|
+
};
|
|
23
|
+
function discoveryStatus(value) {
|
|
24
|
+
if (!value || !DISCOVERY_STATES.includes(value.status)) throw new Error('invalid_architecture_status');
|
|
25
|
+
const result = { status: value.status, reason: typeof value.reason === 'string' ? value.reason : '' };
|
|
26
|
+
for (const name of ['applications', 'components', 'pending', 'inspected', 'total']) {
|
|
27
|
+
if (Number.isSafeInteger(value[name]) && value[name] >= 0 && value[name] <= 1_000_000) result[name] = value[name];
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export function createViewPlatform({ document, request, onView, onSelect, onFollow = () => {},
|
|
9
|
-
createFrame = createExtensionFrame, grantPollMs = 2000 }) {
|
|
33
|
+
createFrame = createExtensionFrame, grantPollMs = 2000, architecturePollMs = 2000 }) {
|
|
10
34
|
const $ = id => document.getElementById(id);
|
|
11
35
|
let model, active = 'graphlin.code', instance, installed = [], generation = 0, closed = false;
|
|
12
36
|
let projectionController;
|
|
13
37
|
let analysisBusy = false;
|
|
14
38
|
let grantWatch;
|
|
39
|
+
let architectureWatch, architectureState, architectureProject, architectureBusy = false, suspended = false;
|
|
15
40
|
let selection = {}, canonicalSelection = null, follow = true, query = '', kinds = null, focusEntityId = null;
|
|
16
41
|
const settings = new Map(), listeners = [];
|
|
17
42
|
const ownSettings = () => {
|
|
@@ -33,9 +58,82 @@ export function createViewPlatform({ document, request, onView, onSelect, onFoll
|
|
|
33
58
|
$('view-retry').hidden = !message;
|
|
34
59
|
}
|
|
35
60
|
function dispose() {
|
|
61
|
+
stopArchitecture();
|
|
36
62
|
if (grantWatch) { clearTimeout(grantWatch.timer); grantWatch.controller?.abort(); grantWatch = null; }
|
|
37
63
|
instance?.dispose(); instance = null;
|
|
38
64
|
}
|
|
65
|
+
const architectureLive = () => !closed && !suspended && model && active === 'graphlin.c4' && !selection.checkpoint;
|
|
66
|
+
function stopArchitecture() {
|
|
67
|
+
if (architectureWatch) {
|
|
68
|
+
clearTimeout(architectureWatch.timer);
|
|
69
|
+
clearTimeout(architectureWatch.deadline);
|
|
70
|
+
architectureWatch.controller?.abort();
|
|
71
|
+
architectureWatch = null;
|
|
72
|
+
}
|
|
73
|
+
architectureBusy = false;
|
|
74
|
+
}
|
|
75
|
+
function renderArchitecture() {
|
|
76
|
+
const shown = active === 'graphlin.c4';
|
|
77
|
+
$('architecture-discover').hidden = !shown;
|
|
78
|
+
$('architecture-status').hidden = !shown;
|
|
79
|
+
const state = architectureState;
|
|
80
|
+
$('architecture-discover').disabled = !architectureLive() || architectureBusy ||
|
|
81
|
+
['queued', 'running'].includes(state?.status) || DISCOVERY_BLOCKED.includes(state?.reason);
|
|
82
|
+
$('architecture-discover').setAttribute('aria-busy', String(architectureBusy));
|
|
83
|
+
if (!shown) return;
|
|
84
|
+
if (selection.checkpoint) {
|
|
85
|
+
$('architecture-status').textContent = 'Recorded architecture. Return to Live to discover current boundaries.';
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!state) { $('architecture-status').textContent = 'Checking architecture discovery…'; return; }
|
|
89
|
+
const messages = {
|
|
90
|
+
idle: 'Ready to discover application and component boundaries for this project.',
|
|
91
|
+
waiting: 'Waiting for source evidence before architecture discovery can continue.',
|
|
92
|
+
queued: 'Architecture discovery queued.',
|
|
93
|
+
running: 'Discovering architecture…',
|
|
94
|
+
complete: 'Architecture discovery complete.',
|
|
95
|
+
partial: 'Architecture is partly discovered; some boundaries remain unknown.',
|
|
96
|
+
unavailable: 'Architecture discovery is unavailable. Check project source settings and the classification service.',
|
|
97
|
+
};
|
|
98
|
+
const counts = ['applications', 'components'].filter(name => state[name] !== undefined)
|
|
99
|
+
.map(name => `${state[name]} ${name}`);
|
|
100
|
+
if (state.inspected !== undefined && state.total !== undefined) counts.push(`${state.inspected} of ${state.total} inspected`);
|
|
101
|
+
if (state.pending > 0) counts.push(`${state.pending} pending`);
|
|
102
|
+
$('architecture-status').textContent = [
|
|
103
|
+
DISCOVERY_REASONS[state.reason] || messages[state.status], counts.join(' · '),
|
|
104
|
+
].filter(Boolean).join(' ');
|
|
105
|
+
}
|
|
106
|
+
async function refreshArchitecture(watch, manual = false) {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
watch.controller = controller;
|
|
109
|
+
const deadline = watch.deadline = setTimeout(() => controller.abort(), 8000);
|
|
110
|
+
try {
|
|
111
|
+
const result = await request(manual ? '/api/architecture/discover' : '/api/architecture',
|
|
112
|
+
{ signal: controller.signal, ...(manual ? { method: 'POST', body: '{}' } : {}) });
|
|
113
|
+
if (architectureWatch !== watch || !architectureLive()) return;
|
|
114
|
+
architectureState = discoveryStatus(manual && !DISCOVERY_STATES.includes(result?.status) ? { status: 'queued' } : result);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (architectureWatch !== watch || !architectureLive()) return;
|
|
117
|
+
architectureState = { status: 'unavailable', reason: error.status === 404 ? 'endpoint_unavailable' : 'request_failed' };
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(deadline);
|
|
120
|
+
if (architectureWatch === watch && architectureLive()) {
|
|
121
|
+
architectureBusy = false;
|
|
122
|
+
renderArchitecture();
|
|
123
|
+
if (architectureState?.reason !== 'endpoint_unavailable')
|
|
124
|
+
watch.timer = setTimeout(() => refreshArchitecture(watch), architecturePollMs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function syncArchitecture() {
|
|
129
|
+
if (!architectureLive()) stopArchitecture();
|
|
130
|
+
else if (!architectureWatch) {
|
|
131
|
+
if (architectureProject !== model.projectId) { architectureState = null; architectureProject = model.projectId; }
|
|
132
|
+
architectureWatch = {};
|
|
133
|
+
void refreshArchitecture(architectureWatch);
|
|
134
|
+
}
|
|
135
|
+
renderArchitecture();
|
|
136
|
+
}
|
|
39
137
|
function watchGrant(row) {
|
|
40
138
|
if (grantWatch?.instance === instance) return;
|
|
41
139
|
const watch = { instance, id: row.id, digest: row.digest, grant: JSON.stringify(row.grant), timer: null };
|
|
@@ -83,6 +181,7 @@ export function createViewPlatform({ document, request, onView, onSelect, onFoll
|
|
|
83
181
|
for (const option of $('visualizer').children) option.disabled = !model && option.value !== 'graphlin.code';
|
|
84
182
|
$('view-context').hidden = !model;
|
|
85
183
|
$('c4-level-label').hidden = active !== 'graphlin.c4';
|
|
184
|
+
syncArchitecture();
|
|
86
185
|
$('baseline-label').hidden = active !== 'graphlin.changes';
|
|
87
186
|
$('baseline-create').hidden = active !== 'graphlin.changes';
|
|
88
187
|
$('baseline-create').disabled = Boolean(selection.checkpoint);
|
|
@@ -269,6 +368,15 @@ export function createViewPlatform({ document, request, onView, onSelect, onFoll
|
|
|
269
368
|
listen('visualizer', 'change', () => choose($('visualizer').value));
|
|
270
369
|
listen('view-retry', 'click', () => { dispose(); void client.open(selection); });
|
|
271
370
|
listen('c4-level', 'change', () => { ownSettings().level = $('c4-level').value; void project(false, true); });
|
|
371
|
+
listen('architecture-discover', 'click', () => {
|
|
372
|
+
if (!architectureLive() || $('architecture-discover').disabled) return;
|
|
373
|
+
stopArchitecture();
|
|
374
|
+
architectureBusy = true;
|
|
375
|
+
architectureState = { status: 'queued' };
|
|
376
|
+
architectureWatch = {};
|
|
377
|
+
renderArchitecture();
|
|
378
|
+
void refreshArchitecture(architectureWatch, true);
|
|
379
|
+
});
|
|
272
380
|
listen('task-baseline', 'change', () => { ownSettings().baseline = $('task-baseline').value; void project(false, true); });
|
|
273
381
|
listen('baseline-create', 'click', async () => {
|
|
274
382
|
if (selection.checkpoint) return;
|
|
@@ -314,7 +422,7 @@ export function createViewPlatform({ document, request, onView, onSelect, onFoll
|
|
|
314
422
|
});
|
|
315
423
|
controls();
|
|
316
424
|
return {
|
|
317
|
-
async start() { if (await client.open(selection)) await catalogue(); },
|
|
425
|
+
async start() { suspended = false; if (await client.open(selection)) await catalogue(); },
|
|
318
426
|
choose,
|
|
319
427
|
filter(nextQuery, nextKinds) { query = nextQuery; kinds = nextKinds; void project(false, true); },
|
|
320
428
|
selected(id) { canonicalSelection = id; },
|
|
@@ -329,7 +437,7 @@ export function createViewPlatform({ document, request, onView, onSelect, onFoll
|
|
|
329
437
|
void project(false, true);
|
|
330
438
|
},
|
|
331
439
|
close() { closed = true; generation++; projectionController?.abort(); client.close(); dispose(); listeners.forEach(remove => remove()); },
|
|
332
|
-
suspend() { generation++; projectionController?.abort(); client.suspend(); dispose(); },
|
|
440
|
+
suspend() { suspended = true; generation++; projectionController?.abort(); client.suspend(); dispose(); },
|
|
333
441
|
get active() { return active; },
|
|
334
442
|
get model() { return model; },
|
|
335
443
|
get selection() { return selection; },
|
package/runtime/web/style.css
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
.view-context select, .view-context button { max-width: 210px; font-size: 11px; min-height: 28px; padding: 3px 7px; color: var(--ink); background: var(--white); border: 1px solid var(--line); border-radius: 5px; }
|
|
21
21
|
#scope-breadcrumbs { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; }
|
|
22
22
|
.view-status, .view-coverage { padding: 4px 12px; font-size: 11px; color: var(--muted); }
|
|
23
|
+
.architecture-status { padding: 5px 12px; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--line); }
|
|
24
|
+
.view-context #architecture-discover { color: var(--blue); border-color: var(--blue); }
|
|
23
25
|
.extension-access { padding: 12px; border-bottom: 1px solid var(--line); background: var(--paper); font-size: 12px; }
|
|
24
26
|
.extension-access p { max-width: 78ch; margin-bottom: 8px; }
|
|
25
27
|
#extension-fields { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 8px; }
|
package/scripts/control.mjs
CHANGED
|
@@ -39,7 +39,7 @@ async function handle(line) {
|
|
|
39
39
|
const supported = ['2024-11-05', '2025-03-26', '2025-06-18'];
|
|
40
40
|
return result({ protocolVersion: supported.includes(message.params?.protocolVersion) ? message.params.protocolVersion : '2025-06-18',
|
|
41
41
|
capabilities: { tools: { listChanged: false } },
|
|
42
|
-
serverInfo: { name: 'graphlin', version: '0.2.
|
|
42
|
+
serverInfo: { name: 'graphlin', version: '0.2.1' },
|
|
43
43
|
instructions: 'Controls only. Passive host hooks provide observations when separately activated. No drawing calls after each action.' });
|
|
44
44
|
}
|
|
45
45
|
if (message.method === 'ping') return result({});
|