graphlin 0.1.2 → 0.2.0
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/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +465 -0
- package/docs/visualizer-views.md +199 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +74 -9
- package/plugin.json +4 -2
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +70 -12
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +889 -0
- package/runtime/model/records.mjs +239 -0
- package/runtime/pipeline.mjs +127 -48
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +52 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +369 -86
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +123 -80
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +337 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +152 -142
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { validateScene } from '../extensions/scene.mjs';
|
|
2
|
+
import { jsonBytes, id, integer, EXTENSION_LIMITS } from '../extensions/contracts.mjs';
|
|
3
|
+
import { validateMessage } from '../extensions/sdk.mjs';
|
|
4
|
+
|
|
5
|
+
const CONTEXT = ['instanceId', 'projectId', 'revision', 'viewEpoch', 'requestId'];
|
|
6
|
+
const nonceValue = () => {
|
|
7
|
+
const bytes = new Uint8Array(24);
|
|
8
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
9
|
+
return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Only the host's projected-data route supplies `model`. A frame never receives
|
|
13
|
+
* fetch, cookies, a daemon token, or the host's unprojected snapshot.
|
|
14
|
+
*/
|
|
15
|
+
export function createExtensionFrame({
|
|
16
|
+
root, extension, onSelect = () => {}, onFailure = () => {},
|
|
17
|
+
Channel = globalThis.MessageChannel, nonce = nonceValue(), timeout = 5000,
|
|
18
|
+
}) {
|
|
19
|
+
const frame = root.ownerDocument.createElement('iframe');
|
|
20
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
21
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
22
|
+
frame.setAttribute('title', extension.manifest.name || extension.id);
|
|
23
|
+
frame.setAttribute('src', `/api/extensions/frame/${encodeURIComponent(extension.id)}?nonce=${encodeURIComponent(nonce)}`);
|
|
24
|
+
const channel = new Channel(), port = channel.port1;
|
|
25
|
+
let stopped = false, loaded = false, ready = false, requestNumber = 0, pending, latest;
|
|
26
|
+
const granted = model => extension.grant?.approved === true &&
|
|
27
|
+
extension.grant.projectId === model?.projectId && extension.grant.extensionId === extension.id &&
|
|
28
|
+
extension.grant.digest === extension.digest;
|
|
29
|
+
let readyResolve, readyReject;
|
|
30
|
+
const readyPromise = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
|
|
31
|
+
// A frame can fail before update attaches its await handler.
|
|
32
|
+
readyPromise.catch(() => {});
|
|
33
|
+
const readyTimer = setTimeout(() => fail(new Error('extension_ready_timeout')), timeout);
|
|
34
|
+
function rejectPending(error) {
|
|
35
|
+
if (pending) { clearTimeout(pending.timer); pending.reject(error); pending = null; }
|
|
36
|
+
}
|
|
37
|
+
function stop(error = new Error('extension_disposed')) {
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
stopped = true;
|
|
40
|
+
clearTimeout(readyTimer);
|
|
41
|
+
readyReject(error); rejectPending(error);
|
|
42
|
+
port.onmessage = null; port.close(); channel.port2.close();
|
|
43
|
+
frame.removeEventListener('load', onLoad); frame.remove();
|
|
44
|
+
latest = null;
|
|
45
|
+
}
|
|
46
|
+
function fail(error) { stop(error); onFailure(); }
|
|
47
|
+
function onLoad() {
|
|
48
|
+
if (stopped) return;
|
|
49
|
+
if (loaded) { fail(new Error('extension_navigated')); return; }
|
|
50
|
+
loaded = true;
|
|
51
|
+
// Opaque-origin sandbox recipients require "*"; the exact frame WindowProxy,
|
|
52
|
+
// a fresh nonce, and the one-use port bind this bootstrap to its document.
|
|
53
|
+
frame.contentWindow.postMessage({ type: 'graphlin:bootstrap', apiVersion: 1, nonce }, '*', [channel.port2]);
|
|
54
|
+
}
|
|
55
|
+
let windowStart = Date.now(), messages = 0;
|
|
56
|
+
port.onmessage = event => {
|
|
57
|
+
if (stopped) return;
|
|
58
|
+
try {
|
|
59
|
+
if (Date.now() - windowStart > 1000) { windowStart = Date.now(); messages = 0; }
|
|
60
|
+
if (++messages > 120) throw new Error('extension_message_rate');
|
|
61
|
+
const message = event.data;
|
|
62
|
+
jsonBytes(message, EXTENSION_LIMITS.projectionBytes, 'extension_message_limit');
|
|
63
|
+
if (!ready) {
|
|
64
|
+
if (!loaded || message?.type !== 'graphlin:ready' || message.apiVersion !== 1 || message.nonce !== nonce)
|
|
65
|
+
throw new Error('invalid_extension_ready');
|
|
66
|
+
ready = true; clearTimeout(readyTimer); readyResolve(); return;
|
|
67
|
+
}
|
|
68
|
+
if (!message || message.apiVersion !== 1 || !latest ||
|
|
69
|
+
!CONTEXT.every(key => message[key] === latest.context[key])) return;
|
|
70
|
+
validateMessage(message, { model: latest.model, context: { apiVersion: 1, ...latest.context } });
|
|
71
|
+
if (message.type === 'graphlin:select') {
|
|
72
|
+
const capabilities = extension.manifest.capabilities || [];
|
|
73
|
+
const field = message.selection?.entityId ? 'entities' : message.selection?.relationId ? 'relations' : 'activity';
|
|
74
|
+
if (!granted(latest.model) || !capabilities.some(value => ['selection.request', 'inspection.request'].includes(value)) ||
|
|
75
|
+
!extension.grant.fields?.includes(field) || (field === 'relations' && !extension.grant.fields.includes('entities'))) return;
|
|
76
|
+
const selection = message.selection;
|
|
77
|
+
if (selection?.entityId && latest.model.entities.some(entity => entity.id === selection.entityId))
|
|
78
|
+
onSelect({ entityId: selection.entityId });
|
|
79
|
+
else if (selection?.relationId && latest.model.relations.some(relation => relation.id === selection.relationId))
|
|
80
|
+
onSelect({ relationId: selection.relationId });
|
|
81
|
+
else if (selection?.activityId && latest.model.activity.some(activity => activity.id === selection.activityId))
|
|
82
|
+
onSelect({ activityId: selection.activityId });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!pending) return;
|
|
86
|
+
if (message.type === 'graphlin:status' && message.status === 'busy') return;
|
|
87
|
+
if (message.type === 'graphlin:status' && message.status === 'error') {
|
|
88
|
+
rejectPending(new Error('extension_projection_failed')); return;
|
|
89
|
+
}
|
|
90
|
+
let result;
|
|
91
|
+
if (message.type === 'graphlin:scene' && extension.manifest.renderer.kind === 'graphlin-scene')
|
|
92
|
+
result = { kind: 'scene', scene: validateScene(message.scene, { model: latest.model }) };
|
|
93
|
+
else if (message.type === 'graphlin:status' && extension.manifest.renderer.kind === 'custom' &&
|
|
94
|
+
['ready', 'empty'].includes(message.status) && integer(message.itemCount, 20000))
|
|
95
|
+
result = { kind: 'custom', status: message.status, itemCount: message.itemCount };
|
|
96
|
+
else if (message.type === 'graphlin:error') { rejectPending(new Error('extension_projection_failed')); return; }
|
|
97
|
+
else throw new Error('invalid_extension_response');
|
|
98
|
+
clearTimeout(pending.timer); const resolve = pending.resolve; pending = null; resolve(result);
|
|
99
|
+
} catch (error) { fail(error); }
|
|
100
|
+
};
|
|
101
|
+
port.start();
|
|
102
|
+
frame.addEventListener('load', onLoad);
|
|
103
|
+
root.replaceChildren(frame);
|
|
104
|
+
return {
|
|
105
|
+
async update({ model, settings = {}, selection = null, viewEpoch = 0 }) {
|
|
106
|
+
if (!granted(model)) throw new Error('extension_grant_denied');
|
|
107
|
+
await readyPromise;
|
|
108
|
+
if (stopped) throw new Error('extension_disposed');
|
|
109
|
+
rejectPending(new Error('extension_superseded'));
|
|
110
|
+
const context = {
|
|
111
|
+
instanceId: `frame.${nonce}`, projectId: model.projectId, revision: model.revision,
|
|
112
|
+
viewEpoch, requestId: `request.${++requestNumber}`,
|
|
113
|
+
};
|
|
114
|
+
if (!id(context.projectId) || !integer(context.revision)) throw new Error('invalid_extension_context');
|
|
115
|
+
const message = { type: 'graphlin:project', apiVersion: 1, ...context, model, settings, selection };
|
|
116
|
+
jsonBytes(message, EXTENSION_LIMITS.projectionBytes);
|
|
117
|
+
latest = { context, model };
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
pending = { resolve, reject, timer: setTimeout(() => fail(new Error('extension_projection_timeout')), timeout) };
|
|
120
|
+
port.postMessage(message);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
dispose() {
|
|
124
|
+
if (!stopped && ready && latest) port.postMessage({ type: 'graphlin:dispose', apiVersion: 1, ...latest.context });
|
|
125
|
+
stop();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/runtime/web/index.html
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
<meta name="color-scheme" content="light">
|
|
7
7
|
<meta name="darkreader-lock">
|
|
8
8
|
<meta name="referrer" content="no-referrer">
|
|
9
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
|
|
9
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
|
|
10
10
|
<title>Graphlin — Live architecture</title>
|
|
11
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg' viewBox='0 0 32 32'%3E%3Crect x='4' y='3' width='20' height='24' rx='4' fill='white' stroke='%233d60bc' stroke-width='2'/%3E%3Crect x='18' y='18' width='11' height='11' rx='3' fill='%23399783'/%3E%3C/svg%3E">
|
|
11
12
|
<link rel="stylesheet" href="/style.css">
|
|
12
13
|
<script type="module" src="/app.js"></script>
|
|
13
14
|
</head>
|
|
@@ -16,6 +17,15 @@
|
|
|
16
17
|
<div class="page">
|
|
17
18
|
<header class="masthead">
|
|
18
19
|
<a class="brand" href="/" aria-label="Graphlin live viewer"><span class="brand-mark" aria-hidden="true"></span>graphlin</a>
|
|
20
|
+
<section class="project-details" aria-label="Project and Graphlin version">
|
|
21
|
+
<p id="project-label" class="project-label">Local project</p>
|
|
22
|
+
<dl>
|
|
23
|
+
<div><dt>Branch</dt><dd id="project-branch">Checking…</dd></div>
|
|
24
|
+
<div class="project-path"><dt>Path</dt><dd id="project-path">Checking…</dd></div>
|
|
25
|
+
<div><dt>Graphlin</dt><dd id="graphlin-version">Checking…</dd></div>
|
|
26
|
+
</dl>
|
|
27
|
+
</section>
|
|
28
|
+
<button class="update-indicator" id="version-update-indicator" type="button" aria-controls="version-update-guide" hidden>Update available</button>
|
|
19
29
|
<div class="connection" id="connection" data-state="connecting">
|
|
20
30
|
<span class="connection-dot" aria-hidden="true"></span>
|
|
21
31
|
<span id="connection-label" role="status">Connecting to local service</span>
|
|
@@ -24,29 +34,7 @@
|
|
|
24
34
|
</header>
|
|
25
35
|
|
|
26
36
|
<main id="main">
|
|
27
|
-
<
|
|
28
|
-
<div>
|
|
29
|
-
<h1>Architecture, as it happens.</h1>
|
|
30
|
-
<p>Follow the work. Inspect the evidence behind each connection.</p>
|
|
31
|
-
</div>
|
|
32
|
-
<p id="project-label" class="project-label">Local project</p>
|
|
33
|
-
</div>
|
|
34
|
-
|
|
35
|
-
<section class="project-details" aria-label="Project and Graphlin version">
|
|
36
|
-
<dl>
|
|
37
|
-
<div><dt>Branch</dt><dd id="project-branch">Checking…</dd></div>
|
|
38
|
-
<div class="project-path"><dt>Path</dt><dd id="project-path">Checking…</dd></div>
|
|
39
|
-
<div><dt>Graphlin</dt><dd id="graphlin-version">Checking…</dd></div>
|
|
40
|
-
</dl>
|
|
41
|
-
<p id="version-update-status" role="status">Checking for updates…</p>
|
|
42
|
-
<details id="version-update-guide" hidden>
|
|
43
|
-
<summary>How to update</summary>
|
|
44
|
-
<p id="version-update-steps">Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.</p>
|
|
45
|
-
<pre id="version-update-command" tabindex="0"></pre>
|
|
46
|
-
<button id="version-update-copy" type="button">Copy update command</button>
|
|
47
|
-
<span id="version-update-copy-status" role="status"></span>
|
|
48
|
-
</details>
|
|
49
|
-
</section>
|
|
37
|
+
<h1 class="screen-reader">Live architecture workspace</h1>
|
|
50
38
|
|
|
51
39
|
<div id="demo-banner" class="notice demo-notice" role="status" hidden>
|
|
52
40
|
<strong>Demo session</strong>
|
|
@@ -57,34 +45,65 @@
|
|
|
57
45
|
|
|
58
46
|
<section class="workspace" aria-label="Session workspace">
|
|
59
47
|
<div class="workspace-toolbar">
|
|
48
|
+
<label class="session-picker" for="visualizer">
|
|
49
|
+
<span>View</span>
|
|
50
|
+
<select id="visualizer" aria-label="Visualizer"><option value="graphlin.code">Code</option></select>
|
|
51
|
+
</label>
|
|
60
52
|
<label class="session-picker" for="session">
|
|
61
53
|
<span>Session</span>
|
|
62
54
|
<select id="session" disabled><option value="">Waiting for a session</option></select>
|
|
63
55
|
</label>
|
|
56
|
+
<div class="panel-controls" role="group" aria-label="Workspace panels">
|
|
57
|
+
<button type="button" id="details-toggle" aria-expanded="false" aria-controls="live-sidebar">Details</button>
|
|
58
|
+
<button type="button" id="history-toggle" aria-expanded="false" aria-controls="history-panel">History</button>
|
|
59
|
+
<button type="button" id="activity-toggle" aria-expanded="false" aria-controls="activity-panel">Activity</button>
|
|
60
|
+
</div>
|
|
64
61
|
<div class="session-actions">
|
|
65
62
|
<button type="button" id="classification-log" aria-haspopup="dialog" aria-controls="diagnostics-dialog">Classification log</button>
|
|
66
|
-
<button type="button" id="how-to-connect" aria-haspopup="dialog" aria-controls="connection-dialog">
|
|
63
|
+
<button type="button" id="how-to-connect" aria-haspopup="dialog" aria-controls="connection-dialog">Connect</button>
|
|
67
64
|
<button type="button" id="pause" aria-describedby="capture-note" disabled>Pause classification</button>
|
|
68
65
|
<button type="button" id="export" disabled>Export JSON</button>
|
|
69
66
|
</div>
|
|
70
67
|
</div>
|
|
71
68
|
|
|
72
|
-
<div class="
|
|
73
|
-
<div class="classification-status">
|
|
74
|
-
<span class="status-dot" id="classifier-dot" data-state="waiting" aria-hidden="true"></span>
|
|
75
|
-
<strong id="classifier-label">Waiting for local service</strong>
|
|
76
|
-
<span id="capture-note">Capture status will appear with the first snapshot.</span>
|
|
77
|
-
</div>
|
|
78
|
-
<div class="coverage-status"><span id="coverage-label">Coverage not reported</span><span id="queue-label">No snapshot yet</span></div>
|
|
79
|
-
</div>
|
|
80
|
-
|
|
81
|
-
<div class="workspace-body">
|
|
69
|
+
<div class="workspace-body" id="workspace-body" data-details-open="false">
|
|
82
70
|
<section class="drawing" id="drawing" data-theme="sketchbook" aria-labelledby="canvas-title">
|
|
83
71
|
<div class="drawing-toolbar">
|
|
84
72
|
<div class="drawing-heading">
|
|
85
73
|
<h2 id="canvas-title">Live architecture</h2>
|
|
86
74
|
<span id="revision">Revision —</span>
|
|
87
75
|
</div>
|
|
76
|
+
<div class="layout-toolbar" role="group" aria-label="Diagram arrangement">
|
|
77
|
+
<label for="layout">Layout
|
|
78
|
+
<select id="layout" aria-describedby="layout-note" disabled>
|
|
79
|
+
<option value="hierarchy">Hierarchy top-down</option>
|
|
80
|
+
<option value="dependency">Dependency left-right</option>
|
|
81
|
+
<option value="grouped">Group by type</option>
|
|
82
|
+
<option value="circular">Circular</option>
|
|
83
|
+
<option value="grid">Grid</option>
|
|
84
|
+
<option value="original">Original</option>
|
|
85
|
+
</select>
|
|
86
|
+
</label>
|
|
87
|
+
<button type="button" id="arrange" disabled>Arrange</button>
|
|
88
|
+
<label class="auto-arrange" for="auto-arrange"><input id="auto-arrange" type="checkbox" checked disabled> Auto-arrange</label>
|
|
89
|
+
<div class="theme-picker">
|
|
90
|
+
<label for="theme">Theme
|
|
91
|
+
<select id="theme" aria-describedby="theme-note" disabled>
|
|
92
|
+
<option value="sketchbook">Sketchbook</option>
|
|
93
|
+
<option value="ocean">Ocean</option>
|
|
94
|
+
<option value="forest">Forest</option>
|
|
95
|
+
<option value="sunset">Sunset</option>
|
|
96
|
+
<option value="berry">Berry</option>
|
|
97
|
+
<option value="sepia">Sepia</option>
|
|
98
|
+
<option value="blueprint">Blueprint dark</option>
|
|
99
|
+
<option value="midnight">Midnight dark</option>
|
|
100
|
+
</select>
|
|
101
|
+
</label>
|
|
102
|
+
<span class="theme-preview" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
103
|
+
</div>
|
|
104
|
+
<span class="screen-reader" id="theme-note">Colors follow component types. Themes change appearance only.</span>
|
|
105
|
+
<span class="screen-reader" id="layout-note">Arranges when components or connections change.</span>
|
|
106
|
+
</div>
|
|
88
107
|
<div class="zoom-controls" role="group" aria-label="Diagram zoom">
|
|
89
108
|
<button type="button" id="zoom-out" aria-label="Zoom out" disabled>−</button>
|
|
90
109
|
<output id="zoom-level" aria-label="Zoom level">100%</output>
|
|
@@ -92,44 +111,47 @@
|
|
|
92
111
|
<button type="button" id="fit" disabled>Fit</button>
|
|
93
112
|
</div>
|
|
94
113
|
</div>
|
|
95
|
-
<div class="
|
|
96
|
-
<
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
</
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<label class="auto-arrange" for="auto-arrange"><input id="auto-arrange" type="checkbox" checked disabled> Auto-arrange</label>
|
|
108
|
-
<div class="theme-picker">
|
|
109
|
-
<label for="theme">Theme
|
|
110
|
-
<select id="theme" aria-describedby="theme-note" disabled>
|
|
111
|
-
<option value="sketchbook">Sketchbook</option>
|
|
112
|
-
<option value="ocean">Ocean</option>
|
|
113
|
-
<option value="forest">Forest</option>
|
|
114
|
-
<option value="sunset">Sunset</option>
|
|
115
|
-
<option value="berry">Berry</option>
|
|
116
|
-
<option value="sepia">Sepia</option>
|
|
117
|
-
<option value="blueprint">Blueprint dark</option>
|
|
118
|
-
<option value="midnight">Midnight dark</option>
|
|
119
|
-
</select>
|
|
120
|
-
</label>
|
|
121
|
-
<span class="theme-preview" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
114
|
+
<div class="filter-toolbar">
|
|
115
|
+
<div class="diagram-search-bar" role="search" aria-label="Diagram search">
|
|
116
|
+
<label class="screen-reader" for="diagram-search">Find components</label>
|
|
117
|
+
<input id="diagram-search" type="search" maxlength="200" autocomplete="off" spellcheck="false" placeholder="Find components…" aria-controls="architecture" aria-describedby="diagram-search-hint" aria-keyshortcuts="/">
|
|
118
|
+
<button id="diagram-search-clear" type="button" hidden>Clear search</button>
|
|
119
|
+
<span class="screen-reader" id="diagram-search-hint">/ to search · Esc to clear</span>
|
|
120
|
+
<span id="diagram-search-status" role="status" aria-live="polite" aria-atomic="true"></span>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="node-type-toolbar" role="group" aria-label="Component type filters">
|
|
123
|
+
<button type="button" id="node-types-all">All types</button>
|
|
124
|
+
<button type="button" id="node-types-none">Clear all</button>
|
|
125
|
+
<div id="node-type-filters" role="group" aria-label="Visible component types"></div>
|
|
122
126
|
</div>
|
|
123
|
-
<span class="screen-reader" id="theme-note">Colors follow component types. Themes change appearance only.</span>
|
|
124
|
-
<span id="layout-note">Arranges when components or connections change.</span>
|
|
125
127
|
</div>
|
|
126
|
-
<div class="
|
|
127
|
-
<
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
<div class="view-context" id="view-context" hidden>
|
|
129
|
+
<nav id="scope-breadcrumbs" aria-label="Source scope"></nav>
|
|
130
|
+
<label id="c4-level-label" hidden>Level <select id="c4-level">
|
|
131
|
+
<option value="context">Context</option><option value="applications" selected>Applications / datastores</option>
|
|
132
|
+
<option value="components">Components</option><option value="code">Code</option>
|
|
133
|
+
</select></label>
|
|
134
|
+
<label id="baseline-label" hidden>Baseline <select id="task-baseline"><option value="">Choose checkpoint</option></select></label>
|
|
135
|
+
<button id="baseline-create" type="button" hidden>Set baseline now</button>
|
|
136
|
+
<label>Position <select id="model-position"><option value="">Live</option></select></label>
|
|
137
|
+
<label class="follow-control"><input id="follow-agent" type="checkbox" checked> Follow agent</label>
|
|
138
|
+
<button id="view-retry" type="button" hidden>Retry view</button>
|
|
139
|
+
<div id="analysis-controls" hidden>
|
|
140
|
+
<label>Analysis <select id="analysis-profile" aria-describedby="analysis-note"></select></label>
|
|
141
|
+
<button id="analysis-run" type="button" aria-describedby="analysis-note">Run analysis</button>
|
|
142
|
+
<button id="analysis-access" type="button">Manage access</button>
|
|
143
|
+
</div>
|
|
132
144
|
</div>
|
|
145
|
+
<p class="view-status" id="view-status" role="status" hidden></p>
|
|
146
|
+
<section class="extension-access" id="extension-access" aria-label="Visualizer data access" hidden>
|
|
147
|
+
<p id="extension-access-description"></p>
|
|
148
|
+
<div id="extension-fields"></div>
|
|
149
|
+
<label><input type="checkbox" id="extension-history"> Allow retained history</label>
|
|
150
|
+
<div id="extension-profiles"></div>
|
|
151
|
+
<p id="analysis-note" hidden>Running an approved profile can send locally filtered source evidence to the configured classification service. This project must already allow source transmission. Approving a visualizer does not enable source transmission. Analysis runs only when you choose Run analysis, using the selected entity or up to 256 entities in the current scope.</p>
|
|
152
|
+
<button id="extension-approve" type="button">Allow selected data</button>
|
|
153
|
+
<button id="extension-deny" type="button">Deny access</button>
|
|
154
|
+
</section>
|
|
133
155
|
<div class="diagram-stage" id="diagram-stage">
|
|
134
156
|
<svg id="architecture" viewBox="0 0 920 510" role="group" tabindex="0" aria-labelledby="diagram-title diagram-desc">
|
|
135
157
|
<title id="diagram-title">Architecture diagram</title>
|
|
@@ -139,11 +161,13 @@
|
|
|
139
161
|
<marker id="arrow-proposed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" class="arrow-proposed"></path></marker>
|
|
140
162
|
<marker id="arrow-stale" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 Z" class="arrow-stale"></path></marker>
|
|
141
163
|
</defs>
|
|
164
|
+
<g id="group-layer"></g>
|
|
142
165
|
<g id="edge-layer"></g>
|
|
143
166
|
<g id="edge-label-layer"></g>
|
|
144
167
|
<g id="node-layer"></g>
|
|
145
168
|
<g id="effects-layer" aria-hidden="true" pointer-events="none"></g>
|
|
146
169
|
</svg>
|
|
170
|
+
<section id="custom-view" class="custom-view" aria-label="Custom visualizer" hidden></section>
|
|
147
171
|
<div class="empty-canvas" id="empty-canvas">
|
|
148
172
|
<div class="empty-map" aria-hidden="true"><i></i><span></span><i></i></div>
|
|
149
173
|
<h3 id="empty-title">Your architecture starts here.</h3>
|
|
@@ -164,15 +188,30 @@
|
|
|
164
188
|
</div>
|
|
165
189
|
<span id="graph-count">No components yet</span>
|
|
166
190
|
</div>
|
|
167
|
-
<
|
|
168
|
-
<summary>Shape key</summary>
|
|
169
|
-
<div id="shape-key-items"></div>
|
|
170
|
-
<p>Shapes describe component types. Display overrides change appearance only.</p>
|
|
171
|
-
</details>
|
|
191
|
+
<p class="view-coverage" id="view-coverage" role="status" hidden></p>
|
|
172
192
|
<p class="diagram-note" id="diagram-note">Code classification does not establish runtime connectivity.</p>
|
|
173
193
|
</section>
|
|
174
194
|
|
|
175
|
-
<aside class="live-sidebar" id="live-sidebar" aria-label="Live activity and evidence">
|
|
195
|
+
<aside class="live-sidebar" id="live-sidebar" aria-label="Live activity and evidence" hidden>
|
|
196
|
+
<div class="details-heading" id="details-heading"><h2>Details</h2><button type="button" id="details-close" aria-label="Close details">Close</button></div>
|
|
197
|
+
<div class="status-strip" aria-label="Capture and classification status">
|
|
198
|
+
<div class="classification-status">
|
|
199
|
+
<span class="status-dot" id="classifier-dot" data-state="waiting" aria-hidden="true"></span>
|
|
200
|
+
<strong id="classifier-label">Waiting for local service</strong>
|
|
201
|
+
<span id="capture-note">Capture status will appear with the first snapshot.</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="coverage-status"><span id="coverage-label">Coverage not reported</span><span id="queue-label">No snapshot yet</span></div>
|
|
204
|
+
</div>
|
|
205
|
+
<section class="version-details" aria-label="Graphlin updates">
|
|
206
|
+
<p id="version-update-status" role="status">Checking for updates…</p>
|
|
207
|
+
<details id="version-update-guide" hidden>
|
|
208
|
+
<summary>How to update</summary>
|
|
209
|
+
<p id="version-update-steps">Press Ctrl+C in the viewer terminal, then run this command. Start a new Claude Code or Codex session after setup finishes.</p>
|
|
210
|
+
<pre id="version-update-command" tabindex="0"></pre>
|
|
211
|
+
<button id="version-update-copy" type="button">Copy update command</button>
|
|
212
|
+
<span id="version-update-copy-status" role="status"></span>
|
|
213
|
+
</details>
|
|
214
|
+
</section>
|
|
176
215
|
<section class="onboarding" aria-labelledby="onboarding-title">
|
|
177
216
|
<h2 id="onboarding-title">Getting your first shape</h2>
|
|
178
217
|
<ol class="onboarding-progress" aria-label="Observed setup progress">
|
|
@@ -193,6 +232,11 @@
|
|
|
193
232
|
<ol id="sidebar-hook-list" class="hook-receipts" aria-label="Received hooks across this project, newest first"></ol>
|
|
194
233
|
</details>
|
|
195
234
|
<section class="sidebar-history sidebar-theme" id="sidebar-history" data-theme="sketchbook" aria-labelledby="sidebar-change-heading">
|
|
235
|
+
<details class="shape-key">
|
|
236
|
+
<summary>Shape key</summary>
|
|
237
|
+
<div id="shape-key-items"></div>
|
|
238
|
+
<p>Shapes describe component types. Display overrides change appearance only.</p>
|
|
239
|
+
</details>
|
|
196
240
|
<div class="sidebar-heading"><h2 id="sidebar-change-heading">Diagram changes</h2><span class="sidebar-count" id="sidebar-change-count">0</span></div>
|
|
197
241
|
<p class="sidebar-note" id="sidebar-change-note">Newest changes first. Select a shape to inspect it, or a revision to replay.</p>
|
|
198
242
|
<p class="sidebar-empty" id="sidebar-change-empty">Shape changes will stack here as this session discovers or changes the architecture.</p>
|
|
@@ -212,7 +256,7 @@
|
|
|
212
256
|
</aside>
|
|
213
257
|
</div>
|
|
214
258
|
|
|
215
|
-
<div class="playback">
|
|
259
|
+
<div class="playback" id="history-panel" role="region" aria-label="Architecture history" hidden>
|
|
216
260
|
<div class="mode-buttons" role="group" aria-label="Architecture view">
|
|
217
261
|
<button id="live" type="button" aria-pressed="true">Live</button>
|
|
218
262
|
<button id="replay" type="button" aria-pressed="false" disabled>Replay</button>
|
|
@@ -226,13 +270,12 @@
|
|
|
226
270
|
</div>
|
|
227
271
|
</section>
|
|
228
272
|
|
|
229
|
-
<section class="activity-section" aria-labelledby="activity-heading">
|
|
273
|
+
<section class="activity-section" id="activity-panel" aria-labelledby="activity-heading" hidden>
|
|
230
274
|
<div class="activity-heading">
|
|
231
275
|
<div><h2 id="activity-heading">Activity stream</h2><p id="activity-note">Observable work, independent of classification.</p></div>
|
|
232
276
|
<span id="activity-count">0 events</span>
|
|
233
|
-
<button id="activity-toggle" type="button" aria-expanded="true" aria-controls="activity-content">Hide activity log</button>
|
|
234
277
|
</div>
|
|
235
|
-
<div id="activity-content">
|
|
278
|
+
<div id="activity-content" hidden>
|
|
236
279
|
<p class="activity-empty" id="activity-empty">No activity received. Start work in a connected agent session; its captured events will appear here.</p>
|
|
237
280
|
<ol id="activity-list" class="activity-list" aria-label="Recent activity, newest first"></ol>
|
|
238
281
|
</div>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { id, integer, jsonBytes } from '../extensions/contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const COLLECTIONS = ['entities', 'relations', 'interpretations', 'activity', 'sessions', 'checkpoints'];
|
|
4
|
+
export const VIEW_MODEL_LIMITS = Object.freeze({
|
|
5
|
+
entities: 2048, relations: 4096, interpretations: 512, activity: 2048, sessions: 100, checkpoints: 100,
|
|
6
|
+
});
|
|
7
|
+
const stalePage = () => Object.assign(new Error('inconsistent_model_page'), { status: 409 });
|
|
8
|
+
|
|
9
|
+
export function readModel(value) {
|
|
10
|
+
if (value?.schemaVersion !== 2 || !id(value.projectId) ||
|
|
11
|
+
!integer(value.revision) || !integer(value.sequence)) throw new Error('invalid_model');
|
|
12
|
+
for (const [key, max] of Object.entries({
|
|
13
|
+
entities: 20000, relations: 40000, interpretations: 512, activity: 2048, sessions: 100, checkpoints: 100,
|
|
14
|
+
})) if (!Array.isArray(value[key]) || value[key].length > max) throw new Error('invalid_model');
|
|
15
|
+
jsonBytes(value, 8 * 1024 * 1024);
|
|
16
|
+
for (const entity of value.entities) if (!id(entity.id) || typeof entity.label !== 'string' ||
|
|
17
|
+
!Array.isArray(entity.sourceRefs)) throw new Error('invalid_model_entity');
|
|
18
|
+
if (new Set(value.entities.map(entity => entity.id)).size !== value.entities.length) throw new Error('duplicate_model_entity');
|
|
19
|
+
if (value.transport && (typeof value.transport.epoch !== 'string' ||
|
|
20
|
+
!integer(value.transport.sequence) || typeof value.transport.eventId !== 'string')) throw new Error('invalid_model_transport');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function modelQuery(selection = {}) {
|
|
25
|
+
const params = new URLSearchParams();
|
|
26
|
+
for (const key of ['scope', 'session', 'checkpoint']) if (selection[key]) params.set(key, selection[key]);
|
|
27
|
+
return params.size ? `?${params}` : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hydrates one scope, not an unlimited project index. Every cursor is bound by
|
|
31
|
+
* the server to the same revision, model sequence, policy fingerprint and epoch.
|
|
32
|
+
*/
|
|
33
|
+
export async function hydrateModel(raw, { request, selection = {}, signal,
|
|
34
|
+
limits = VIEW_MODEL_LIMITS, maxBytes = 6 * 1024 * 1024, fetchPages = true } = {}) {
|
|
35
|
+
readModel(raw);
|
|
36
|
+
const result = { ...raw, coverage: { ...raw.coverage }, pages: { ...raw.pages } };
|
|
37
|
+
let usedBytes = new TextEncoder().encode(JSON.stringify(raw)).length;
|
|
38
|
+
let incomplete = false;
|
|
39
|
+
const totals = {}, retained = {};
|
|
40
|
+
for (const kind of COLLECTIONS) {
|
|
41
|
+
const pageInfo = raw.pages?.[kind];
|
|
42
|
+
if (pageInfo && (!integer(pageInfo.total) || pageInfo.total < raw[kind].length ||
|
|
43
|
+
pageInfo.returned !== raw[kind].length)) throw new Error('invalid_model_pages');
|
|
44
|
+
const values = raw[kind].slice(0, limits[kind]), seenIds = new Set(values.map(value => value.id));
|
|
45
|
+
if (seenIds.size !== values.length) throw new Error('duplicate_model_record');
|
|
46
|
+
totals[kind] = raw.pages?.[kind]?.total ?? raw[kind].length;
|
|
47
|
+
let cursor = raw.pages?.[kind]?.nextCursor;
|
|
48
|
+
const seenCursors = new Set();
|
|
49
|
+
while (fetchPages && cursor && values.length < limits[kind] && usedBytes < maxBytes) {
|
|
50
|
+
if (typeof cursor !== 'string' || cursor.length > 2048 || seenCursors.has(cursor)) throw new Error('invalid_model_cursor');
|
|
51
|
+
seenCursors.add(cursor);
|
|
52
|
+
const params = new URLSearchParams(modelQuery(selection).slice(1));
|
|
53
|
+
params.set('cursor', cursor); params.set('limit', String(Math.min(200, limits[kind] - values.length)));
|
|
54
|
+
const page = await request(`/api/model/v1/${kind === 'checkpoints' ? 'history' : kind}?${params}`, { signal });
|
|
55
|
+
if (signal?.aborted) throw new Error('model_request_cancelled');
|
|
56
|
+
if (page?.projectId !== raw.projectId || page.revision !== raw.revision || page.sequence !== raw.sequence ||
|
|
57
|
+
page.transport?.epoch !== raw.transport?.epoch ||
|
|
58
|
+
JSON.stringify(page.selection) !== JSON.stringify(raw.selection)) throw stalePage();
|
|
59
|
+
if (page.kind !== kind || !Array.isArray(page.items) || page.items.length > 200 ||
|
|
60
|
+
page.page?.offset !== values.length || page.page.total !== totals[kind] ||
|
|
61
|
+
page.page.returned !== page.items.length || (!page.items.length && page.page.nextCursor))
|
|
62
|
+
throw new Error('invalid_model_page');
|
|
63
|
+
const bytes = new TextEncoder().encode(jsonBytes(page, 512 * 1024)).length;
|
|
64
|
+
if (usedBytes + bytes > maxBytes) { incomplete = true; break; }
|
|
65
|
+
usedBytes += bytes;
|
|
66
|
+
for (const value of page.items) {
|
|
67
|
+
if (!id(value?.id) || seenIds.has(value.id)) throw new Error('duplicate_model_record');
|
|
68
|
+
seenIds.add(value.id); values.push(value);
|
|
69
|
+
}
|
|
70
|
+
cursor = page.page.nextCursor;
|
|
71
|
+
result.pages[kind] = { total: totals[kind], returned: values.length, nextCursor: cursor };
|
|
72
|
+
}
|
|
73
|
+
result[kind] = values;
|
|
74
|
+
retained[kind] = values.length;
|
|
75
|
+
if (values.length < totals[kind]) incomplete = true;
|
|
76
|
+
}
|
|
77
|
+
const ids = new Set(result.entities.map(entity => entity.id));
|
|
78
|
+
const relations = result.relations.filter(relation => ids.has(relation.source) && ids.has(relation.target));
|
|
79
|
+
const interpretations = result.interpretations.filter(value => value.entityIds?.every(id => ids.has(id)));
|
|
80
|
+
if (relations.length !== result.relations.length || interpretations.length !== result.interpretations.length) incomplete = true;
|
|
81
|
+
result.relations = relations; result.interpretations = interpretations;
|
|
82
|
+
retained.relations = relations.length; retained.interpretations = interpretations.length;
|
|
83
|
+
result.partial = incomplete;
|
|
84
|
+
result.coverage.client = { truncated: incomplete, totals, retained };
|
|
85
|
+
if (incomplete) result.coverage.truncated = true;
|
|
86
|
+
return readModel(result);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createModelClient({ request, onSnapshot, onError = () => {}, Stream = globalThis.EventSource }) {
|
|
90
|
+
let epoch = 0, update = 0, stream, controller, hydration, closed = false, selection = {}, latest = null, received = null;
|
|
91
|
+
function stop() { update++; controller?.abort(); hydration?.abort(); stream?.close(); stream = null; }
|
|
92
|
+
function duplicate(raw) {
|
|
93
|
+
const previous = received || latest;
|
|
94
|
+
if (!previous || previous.projectId !== raw.projectId) return false;
|
|
95
|
+
if (raw.transport || previous.transport) return raw.transport?.epoch === previous.transport?.epoch &&
|
|
96
|
+
raw.transport.sequence <= previous.transport.sequence;
|
|
97
|
+
return raw.sequence <= previous.sequence;
|
|
98
|
+
}
|
|
99
|
+
async function accept(raw, streamed = false) {
|
|
100
|
+
readModel(raw);
|
|
101
|
+
if (streamed && duplicate(raw)) return;
|
|
102
|
+
received = raw;
|
|
103
|
+
hydration?.abort();
|
|
104
|
+
const active = new AbortController(), mine = ++update;
|
|
105
|
+
hydration = active;
|
|
106
|
+
let model, current = raw;
|
|
107
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
108
|
+
try {
|
|
109
|
+
model = await hydrateModel(current, { request, selection, signal: active.signal });
|
|
110
|
+
break;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (active.signal.aborted || mine !== update || closed) return;
|
|
113
|
+
if (error.status !== 409) throw error;
|
|
114
|
+
current = await request(`/api/model/v1/snapshot${modelQuery(selection)}`, { signal: active.signal });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// A continuously changing scope can invalidate all page cursors. Its fresh
|
|
118
|
+
// first page is still consistent; retain it with explicit partial coverage.
|
|
119
|
+
if (!model) model = await hydrateModel(current, { fetchPages: false });
|
|
120
|
+
if (active.signal.aborted || mine !== update || closed) return;
|
|
121
|
+
const sameEpoch = !latest?.transport || latest.transport.epoch === model.transport?.epoch;
|
|
122
|
+
latest = model;
|
|
123
|
+
received = model;
|
|
124
|
+
onSnapshot(model, streamed && sameEpoch);
|
|
125
|
+
}
|
|
126
|
+
async function open(next = selection) {
|
|
127
|
+
selection = { ...next };
|
|
128
|
+
stop();
|
|
129
|
+
const mine = ++epoch;
|
|
130
|
+
controller = new AbortController();
|
|
131
|
+
try {
|
|
132
|
+
const model = await request(`/api/model/v1/snapshot${modelQuery(selection)}`, { signal: controller.signal });
|
|
133
|
+
if (closed || mine !== epoch) return false;
|
|
134
|
+
await accept(model);
|
|
135
|
+
if (closed || mine !== epoch) return false;
|
|
136
|
+
if (!selection.checkpoint && Stream) {
|
|
137
|
+
const active = new Stream(`/api/model/v1/events${modelQuery(selection)}`, { withCredentials: true });
|
|
138
|
+
stream = active;
|
|
139
|
+
active.addEventListener('snapshot', event => {
|
|
140
|
+
if (closed || stream !== active) return;
|
|
141
|
+
try {
|
|
142
|
+
if (event.data.length > 8 * 1024 * 1024) throw new Error('model_too_large');
|
|
143
|
+
const raw = JSON.parse(event.data);
|
|
144
|
+
if (event.lastEventId && raw.transport?.eventId !== event.lastEventId) throw new Error('invalid_model_event');
|
|
145
|
+
void accept(raw, true).catch(() => onError('An invalid model update was ignored. Reconnect to restore the view.'));
|
|
146
|
+
} catch { onError('An invalid model update was ignored. Reconnect to restore the view.'); }
|
|
147
|
+
});
|
|
148
|
+
active.addEventListener('reset', () => { if (stream === active) void open(); });
|
|
149
|
+
for (const type of ['unavailable', 'closed', 'revoked', 'expired'])
|
|
150
|
+
active.addEventListener(type, () => { if (stream === active) void open(); });
|
|
151
|
+
active.addEventListener('error', () => {
|
|
152
|
+
if (stream === active) onError('Model connection interrupted. The last received view is retained.');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!closed && mine === epoch && ![404, 405].includes(error.status)) onError('Model views unavailable. The code map remains available.');
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { open, suspend() { epoch++; stop(); }, close() { closed = true; epoch++; stop(); } };
|
|
162
|
+
}
|