graphlin 0.1.3 → 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.
Files changed (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +472 -0
  8. package/docs/visualizer-views.md +240 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. 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
+ }
@@ -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>
@@ -44,6 +45,10 @@
44
45
 
45
46
  <section class="workspace" aria-label="Session workspace">
46
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>
47
52
  <label class="session-picker" for="session">
48
53
  <span>Session</span>
49
54
  <select id="session" disabled><option value="">Waiting for a session</option></select>
@@ -120,6 +125,35 @@
120
125
  <div id="node-type-filters" role="group" aria-label="Visible component types"></div>
121
126
  </div>
122
127
  </div>
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
+ <button id="architecture-discover" type="button" aria-describedby="architecture-status" hidden>Discover architecture</button>
135
+ <label id="baseline-label" hidden>Baseline <select id="task-baseline"><option value="">Choose checkpoint</option></select></label>
136
+ <button id="baseline-create" type="button" hidden>Set baseline now</button>
137
+ <label>Position <select id="model-position"><option value="">Live</option></select></label>
138
+ <label class="follow-control"><input id="follow-agent" type="checkbox" checked> Follow agent</label>
139
+ <button id="view-retry" type="button" hidden>Retry view</button>
140
+ <div id="analysis-controls" hidden>
141
+ <label>Analysis <select id="analysis-profile" aria-describedby="analysis-note"></select></label>
142
+ <button id="analysis-run" type="button" aria-describedby="analysis-note">Run analysis</button>
143
+ <button id="analysis-access" type="button">Manage access</button>
144
+ </div>
145
+ </div>
146
+ <p id="architecture-status" class="architecture-status" role="status" aria-live="polite" hidden></p>
147
+ <p class="view-status" id="view-status" role="status" hidden></p>
148
+ <section class="extension-access" id="extension-access" aria-label="Visualizer data access" hidden>
149
+ <p id="extension-access-description"></p>
150
+ <div id="extension-fields"></div>
151
+ <label><input type="checkbox" id="extension-history"> Allow retained history</label>
152
+ <div id="extension-profiles"></div>
153
+ <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>
154
+ <button id="extension-approve" type="button">Allow selected data</button>
155
+ <button id="extension-deny" type="button">Deny access</button>
156
+ </section>
123
157
  <div class="diagram-stage" id="diagram-stage">
124
158
  <svg id="architecture" viewBox="0 0 920 510" role="group" tabindex="0" aria-labelledby="diagram-title diagram-desc">
125
159
  <title id="diagram-title">Architecture diagram</title>
@@ -129,11 +163,13 @@
129
163
  <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>
130
164
  <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>
131
165
  </defs>
166
+ <g id="group-layer"></g>
132
167
  <g id="edge-layer"></g>
133
168
  <g id="edge-label-layer"></g>
134
169
  <g id="node-layer"></g>
135
170
  <g id="effects-layer" aria-hidden="true" pointer-events="none"></g>
136
171
  </svg>
172
+ <section id="custom-view" class="custom-view" aria-label="Custom visualizer" hidden></section>
137
173
  <div class="empty-canvas" id="empty-canvas">
138
174
  <div class="empty-map" aria-hidden="true"><i></i><span></span><i></i></div>
139
175
  <h3 id="empty-title">Your architecture starts here.</h3>
@@ -154,6 +190,7 @@
154
190
  </div>
155
191
  <span id="graph-count">No components yet</span>
156
192
  </div>
193
+ <p class="view-coverage" id="view-coverage" role="status" hidden></p>
157
194
  <p class="diagram-note" id="diagram-note">Code classification does not establish runtime connectivity.</p>
158
195
  </section>
159
196
 
@@ -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
+ }