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.
@@ -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
- await serial;
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
- await platform.whenIdle();
1127
- await serial;
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 = new Map(model.entities.map(entity => [entity.id, entity]));
15
- const boundaries = model.interpretations.filter(value =>
16
- level.has(value.kind) && value.validity === 'current' && value.support === 'supported' &&
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
- if (!boundaries.length) {
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 scene = emptyScene(), represented = new Set();
27
- for (const boundary of boundaries.slice(0, 63)) {
28
- const members = boundary.entityIds.filter(id => byId.has(id)).slice(0, 256);
29
- members.forEach(id => represented.add(id));
30
- const expanded = settings.expanded?.includes(members[0]);
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: `c4.${boundary.id}`, entityIds: members, label: boundary.label, collapsed: !expanded,
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 (expanded) for (const id of members) {
36
- if (scene.nodes.length + scene.groups.length >= 255 || scene.nodes.some(node => node.entityId === id)) continue;
37
- const entity = byId.get(id);
38
- scene.nodes.push({ id, entityId: id, label: entity.label, kind: sceneKind(entity.kind), parentId: `c4.${boundary.id}` });
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 unknown = model.entities.filter(entity => !represented.has(entity.id)).slice(0, 256);
42
- if (unknown.length) scene.groups.push({
43
- id: 'c4.unknown', entityIds: unknown.map(entity => entity.id), label: 'Responsibility unknown',
44
- kind: 'unknown', collapsed: true, style: 'unknown',
45
- });
46
- scene.coverage = { shown: scene.groups.length, total: model.entities.length,
47
- truncated: represented.size + unknown.length < model.entities.length || boundaries.length > 63,
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
  }
@@ -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.entityId, node.collapsed); };
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); }
@@ -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>
@@ -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; },
@@ -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; }
@@ -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.0' },
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({});