yarramate 0.16.0 → 0.18.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/README.md +2 -1
- package/dist/adapters/graphify-cli.js +0 -0
- package/dist/adapters/likec4-cli.js +0 -0
- package/dist/adapters/mcp-cli.js +0 -0
- package/dist/adapters/visual/client.d.ts +74 -0
- package/dist/adapters/visual/client.js +448 -0
- package/dist/adapters/visual/likec4-compiler.d.ts +55 -0
- package/dist/adapters/visual/likec4-compiler.js +460 -0
- package/dist/adapters/visual/protocol-contract.d.ts +246 -0
- package/dist/adapters/visual/protocol-contract.js +19 -0
- package/dist/adapters/visual/protocol.d.ts +13 -0
- package/dist/adapters/visual/protocol.js +258 -0
- package/dist/adapters/visual/session-server.d.ts +130 -0
- package/dist/adapters/visual/session-server.js +1406 -0
- package/dist/adapters/visual/session-store.d.ts +126 -0
- package/dist/adapters/visual/session-store.js +755 -0
- package/dist/adapters/visual/wire.d.ts +93 -0
- package/dist/adapters/visual/wire.js +1 -0
- package/dist/adapters/visual-cli.d.ts +42 -0
- package/dist/adapters/visual-cli.js +274 -0
- package/dist/attestation-staleness.js +8 -1
- package/dist/cli.js +0 -0
- package/dist/compiler.d.ts +12 -0
- package/dist/compiler.js +46 -2
- package/dist/reconciliation.d.ts +17 -1
- package/dist/reconciliation.js +48 -1
- package/dist/rtm.d.ts +1 -0
- package/dist/rtm.js +9 -7
- package/dist/visual-app/assets/index-CGvC8e3R.css +1 -0
- package/dist/visual-app/assets/index-CXuW1tOj.js +209 -0
- package/dist/visual-app/index.html +18 -0
- package/docs/CONSUMING-YARRAMATE.md +49 -0
- package/package.json +25 -5
- package/schema/yarramate-ask-result.schema.json +4 -0
- package/schema/yarramate-document.schema.json +5 -0
- package/schema/yarramate-operations.schema.json +7 -1
- package/schema/yarramate-reconciliation-report.schema.json +36 -1
- package/schema/yarramate-visual-diagnostic-result.schema.json +67 -0
- package/schema/yarramate-visual-event.schema.json +255 -0
- package/schema/yarramate-visual-handoff.schema.json +120 -0
- package/schema/yarramate-visual-model.schema.json +111 -0
- package/schema/yarramate-visual-response.schema.json +230 -0
- package/schema/yarramate-visual-session-descriptor.schema.json +45 -0
- package/schema/yarramate-visual-session-request.schema.json +110 -0
- package/schema/yarramate-visual-session-started.schema.json +121 -0
- package/schema/yarramate-visual-status.schema.json +123 -0
- package/skills/yarramate-architecture/SKILL.md +28 -1
- package/skills/yarramate-architecture/references/native-authoring.md +15 -3
- package/skills/yarramate-architecture/references/visual-conversations.md +263 -0
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer, } from 'node:http';
|
|
4
|
+
import { basename, extname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { compileVisualModel, } from './likec4-compiler.js';
|
|
8
|
+
import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, } from './protocol.js';
|
|
9
|
+
import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from './session-store.js';
|
|
10
|
+
/**
|
|
11
|
+
* Document name reported by diagnostics the server itself raises — a refused
|
|
12
|
+
* frame, a frozen queue, or a transport violation that no protocol document
|
|
13
|
+
* owns.
|
|
14
|
+
*/
|
|
15
|
+
export const VISUAL_SERVER_DOCUMENT = 'visual-session-server';
|
|
16
|
+
/**
|
|
17
|
+
* Returned on every response. The policy admits no external origin at all, so
|
|
18
|
+
* neither a compromised model nor a hostile chat message can reach the network,
|
|
19
|
+
* and the page can be neither framed nor used as a base for relative fetches.
|
|
20
|
+
*/
|
|
21
|
+
export const VISUAL_BROWSER_HEADERS = {
|
|
22
|
+
'Referrer-Policy': 'no-referrer',
|
|
23
|
+
'X-Content-Type-Options': 'nosniff',
|
|
24
|
+
'Cache-Control': 'no-store',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* This session's content policy. Every source stays `'self'`; the one addition
|
|
28
|
+
* is a per-session nonce for inline style, because the diagram renderer injects
|
|
29
|
+
* its own stylesheets at runtime. A nonce admits exactly the styles this
|
|
30
|
+
* application emits, where `'unsafe-inline'` would admit anyone's.
|
|
31
|
+
*/
|
|
32
|
+
export const visualContentSecurityPolicy = (styleNonce) => `default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'nonce-${styleNonce}'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`;
|
|
33
|
+
export const VISUAL_SERVER_LIMITS = {
|
|
34
|
+
/** WebSocket frame ceiling: one 64 KiB chat message plus its envelope. */
|
|
35
|
+
browserFrameBytes: VISUAL_LIMITS.messageBytes + 1024,
|
|
36
|
+
/** Agent request body ceiling: one 5 MiB candidate model plus its envelope. */
|
|
37
|
+
agentBodyBytes: VISUAL_LIMITS.modelBytes + 64 * 1024,
|
|
38
|
+
/** Agent stop requests carry a termination reason and nothing else. */
|
|
39
|
+
agentControlBytes: 4096,
|
|
40
|
+
/** How long one agent long poll waits before answering "still idle". */
|
|
41
|
+
agentPollMs: 30_000,
|
|
42
|
+
/** Browser sockets one session serves at once. */
|
|
43
|
+
browserConnections: 8,
|
|
44
|
+
};
|
|
45
|
+
export const VISUAL_SOCKET_PATH = '/socket';
|
|
46
|
+
const COOKIE_NAME = 'ym_visual';
|
|
47
|
+
/**
|
|
48
|
+
* Assets are addressed by a single flat, hashed file name. Rejecting every
|
|
49
|
+
* separator here means a traversal never reaches path resolution at all.
|
|
50
|
+
*/
|
|
51
|
+
const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
52
|
+
const CONTENT_TYPES = {
|
|
53
|
+
'.css': 'text/css; charset=utf-8',
|
|
54
|
+
'.html': 'text/html; charset=utf-8',
|
|
55
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
56
|
+
'.json': 'application/json; charset=utf-8',
|
|
57
|
+
'.png': 'image/png',
|
|
58
|
+
'.svg': 'image/svg+xml',
|
|
59
|
+
'.woff2': 'font/woff2',
|
|
60
|
+
};
|
|
61
|
+
/** Response types that finish the agent turn an event opened. */
|
|
62
|
+
const TURN_COMPLETING = {
|
|
63
|
+
'chat.response': true,
|
|
64
|
+
'choice.present': true,
|
|
65
|
+
'handoff.complete': true,
|
|
66
|
+
diagnostic: true,
|
|
67
|
+
};
|
|
68
|
+
const TERMINATION_REASONS = {
|
|
69
|
+
'user-ended': true,
|
|
70
|
+
'child-failed': true,
|
|
71
|
+
'browser-timeout': true,
|
|
72
|
+
'main-cancelled': true,
|
|
73
|
+
'server-failed': true,
|
|
74
|
+
'compiler-failed': true,
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Which freezes are a runtime failing rather than a session closing.
|
|
78
|
+
*
|
|
79
|
+
* A ceiling the runtime enforced for itself means nothing more can be
|
|
80
|
+
* journaled, so the conversation is over whether or not anyone asks it to
|
|
81
|
+
* stop. `terminal-event` is already that ending, and a disconnected browser is
|
|
82
|
+
* the reconnect grace's business, so neither ends a session a second time.
|
|
83
|
+
*/
|
|
84
|
+
const LIMIT_FREEZE = {
|
|
85
|
+
'message-bytes': true,
|
|
86
|
+
'model-bytes': true,
|
|
87
|
+
'transcript-bytes': true,
|
|
88
|
+
'pending-events': true,
|
|
89
|
+
'browser-disconnected': false,
|
|
90
|
+
'terminal-event': false,
|
|
91
|
+
};
|
|
92
|
+
/** Built browser application, beside the compiled adapter in `dist`. */
|
|
93
|
+
const DEFAULT_ASSET_ROOT = fileURLToPath(new URL('../../visual-app/', import.meta.url));
|
|
94
|
+
/**
|
|
95
|
+
* One promoted candidate as the browser receives it. The export is read back
|
|
96
|
+
* from the candidate directory rather than re-derived, so what the browser
|
|
97
|
+
* draws is exactly the document the trusted compiler wrote.
|
|
98
|
+
*/
|
|
99
|
+
const renderCompiled = async (compiled) => ({
|
|
100
|
+
candidate: compiled.candidate,
|
|
101
|
+
authority: compiled.authority,
|
|
102
|
+
initialView: compiled.initialView,
|
|
103
|
+
views: compiled.views,
|
|
104
|
+
compiled: JSON.parse(await readFile(compiled.exportPath, 'utf8')),
|
|
105
|
+
});
|
|
106
|
+
/**
|
|
107
|
+
* Takes one session terminal, whatever caused it: a reviewer's End, a child
|
|
108
|
+
* that failed, a browser that never came back, a cancelling main agent, or a
|
|
109
|
+
* runtime shutting itself down.
|
|
110
|
+
*
|
|
111
|
+
* The order is the invariant. Input freezes before anything is read; the
|
|
112
|
+
* compiler is cancelled rather than waited out; work already admitted is still
|
|
113
|
+
* allowed to finish; exactly one terminal event is journaled; and the handoff
|
|
114
|
+
* is recovered without deleting anything, so `stop` — and only `stop` — is what
|
|
115
|
+
* removes the session directory.
|
|
116
|
+
*/
|
|
117
|
+
export const terminateVisualSession = (session, reason) => {
|
|
118
|
+
session.terminating ??= (async () => {
|
|
119
|
+
if (session.lifecycle !== 'stopped')
|
|
120
|
+
session.lifecycle = 'draining';
|
|
121
|
+
session.freeze('terminal-event');
|
|
122
|
+
session.compilerAbort.abort();
|
|
123
|
+
await session.quiesce();
|
|
124
|
+
// A terminal event that cannot be journaled must not be why a session
|
|
125
|
+
// stays up: `runVisualStart` waits on this to return, so a transition that
|
|
126
|
+
// refuses to settle hangs the command and strands the directory. The
|
|
127
|
+
// journal is then a runtime that died without saying why, and recovery
|
|
128
|
+
// reports precisely that.
|
|
129
|
+
await appendTerminalEvent(session.paths, reason, session.terminalEvent).catch(() => undefined);
|
|
130
|
+
session.handoff = await recoverVisualSession(session.paths, false);
|
|
131
|
+
return session.handoff;
|
|
132
|
+
})().catch((cause) => {
|
|
133
|
+
// A transition that failed is not this session's outcome: the next
|
|
134
|
+
// terminal cause has to be able to try it again.
|
|
135
|
+
session.terminating = undefined;
|
|
136
|
+
throw cause;
|
|
137
|
+
});
|
|
138
|
+
return session.terminating;
|
|
139
|
+
};
|
|
140
|
+
const serverError = (code, message) => new Error(`${code}: ${message}`);
|
|
141
|
+
/**
|
|
142
|
+
* `pointer` is an RFC 6901 pointer into the document being refused, rooted at
|
|
143
|
+
* `/`, because these diagnostics are published in `visual-diagnostic-result/v1`
|
|
144
|
+
* documents and read back by the one-shot agent clients.
|
|
145
|
+
*/
|
|
146
|
+
const serverDiagnostic = (code, message, pointer = '/') => ({
|
|
147
|
+
severity: 'error',
|
|
148
|
+
code,
|
|
149
|
+
message,
|
|
150
|
+
path: VISUAL_SERVER_DOCUMENT,
|
|
151
|
+
pointer,
|
|
152
|
+
line: 1,
|
|
153
|
+
column: 1,
|
|
154
|
+
});
|
|
155
|
+
/**
|
|
156
|
+
* Compares two capabilities without leaking where they differ, or how long the
|
|
157
|
+
* presented one is: both sides are reduced to a fixed-width digest first, so
|
|
158
|
+
* the comparison is over equal-length buffers by construction.
|
|
159
|
+
*/
|
|
160
|
+
const secretEquals = (expected, presented) => {
|
|
161
|
+
if (presented === undefined)
|
|
162
|
+
return false;
|
|
163
|
+
const digest = (value) => createHash('sha256').update(value, 'utf8').digest();
|
|
164
|
+
return timingSafeEqual(digest(expected), digest(presented));
|
|
165
|
+
};
|
|
166
|
+
const cookieValue = (header, name) => {
|
|
167
|
+
if (header === undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
for (const part of header.split(';')) {
|
|
170
|
+
const split = part.indexOf('=');
|
|
171
|
+
if (split < 0)
|
|
172
|
+
continue;
|
|
173
|
+
if (part.slice(0, split).trim() === name) {
|
|
174
|
+
return part.slice(split + 1).trim();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
};
|
|
179
|
+
const bearerToken = (header) => header === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: /^Bearer +([\x21-\x7e]+)$/.exec(header)?.[1];
|
|
182
|
+
const isJsonContent = (header) => header !== undefined && /^application\/json\s*(;.*)?$/i.test(header.trim());
|
|
183
|
+
/**
|
|
184
|
+
* Buffers at most `limit` bytes while always draining the request, so an
|
|
185
|
+
* oversized body costs bounded memory and still gets a clean HTTP answer
|
|
186
|
+
* instead of a severed connection.
|
|
187
|
+
*/
|
|
188
|
+
const readJsonBody = async (incoming, limit) => {
|
|
189
|
+
if (!isJsonContent(incoming.headers['content-type'])) {
|
|
190
|
+
incoming.resume();
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
status: 415,
|
|
194
|
+
message: 'Request body must be application/json',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const chunks = [];
|
|
198
|
+
let bytes = 0;
|
|
199
|
+
let overflowed = false;
|
|
200
|
+
for await (const chunk of incoming) {
|
|
201
|
+
const part = chunk;
|
|
202
|
+
bytes += part.byteLength;
|
|
203
|
+
if (bytes > limit) {
|
|
204
|
+
overflowed = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
chunks.push(part);
|
|
208
|
+
}
|
|
209
|
+
if (overflowed) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
status: 413,
|
|
213
|
+
message: `Request body of ${bytes} bytes exceeds the ${limit} byte ceiling`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
value: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { ok: false, status: 400, message: 'Request body is not JSON' };
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Widens one validated browser input into the journaled event. The runtime owns
|
|
228
|
+
* every field the browser is not allowed to choose.
|
|
229
|
+
*/
|
|
230
|
+
const eventFrom = (input, envelope) => {
|
|
231
|
+
switch (input.type) {
|
|
232
|
+
case 'chat.message':
|
|
233
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
234
|
+
case 'choice.selected':
|
|
235
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
236
|
+
case 'view.navigate':
|
|
237
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
238
|
+
case 'session.end':
|
|
239
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Serves one authenticated visual session over loopback. The handle owns the
|
|
244
|
+
* session directory it created: `stop` recovers the handoff before deleting it,
|
|
245
|
+
* and answers every later call with that same outcome.
|
|
246
|
+
*/
|
|
247
|
+
export const startVisualServer = async (options) => {
|
|
248
|
+
const now = options.now ?? (() => new Date());
|
|
249
|
+
const randomBytes = options.randomBytes ?? randomSource;
|
|
250
|
+
const agentPollMs = options.agentPollMs ?? VISUAL_SERVER_LIMITS.agentPollMs;
|
|
251
|
+
const assetRoot = resolve(options.assetRoot ?? DEFAULT_ASSET_ROOT);
|
|
252
|
+
const request = options.request;
|
|
253
|
+
const drawHex = (size) => {
|
|
254
|
+
const drawn = randomBytes(size);
|
|
255
|
+
if (drawn.byteLength < size) {
|
|
256
|
+
throw serverError('YMVS301', `Random source returned ${drawn.byteLength} bytes for a ${size}-byte draw`);
|
|
257
|
+
}
|
|
258
|
+
return drawn.subarray(0, size).toString('hex');
|
|
259
|
+
};
|
|
260
|
+
const stamp = () => now().toISOString();
|
|
261
|
+
const session = await createVisualSession(request, {
|
|
262
|
+
baseDir: options.baseDir,
|
|
263
|
+
now,
|
|
264
|
+
randomBytes,
|
|
265
|
+
});
|
|
266
|
+
const paths = session.paths;
|
|
267
|
+
const sessionId = basename(paths.root);
|
|
268
|
+
const cookieSigningKey = drawHex(32);
|
|
269
|
+
// The browser stores only a derived bearer value; the signing key stays in
|
|
270
|
+
// this server process and disappears with the session.
|
|
271
|
+
const browserAuthenticator = createHmac('sha256', cookieSigningKey)
|
|
272
|
+
.update(sessionId)
|
|
273
|
+
.digest('hex');
|
|
274
|
+
// Not a credential: it authorises inline style for this page, nothing else.
|
|
275
|
+
const styleNonce = drawHex(16);
|
|
276
|
+
const browserHeaders = {
|
|
277
|
+
...VISUAL_BROWSER_HEADERS,
|
|
278
|
+
'Content-Security-Policy': visualContentSecurityPolicy(styleNonce),
|
|
279
|
+
};
|
|
280
|
+
/** Cancels every compile this session runs, including one in flight. */
|
|
281
|
+
const compilerAbort = new AbortController();
|
|
282
|
+
/** Set once the loopback listener exists; a failed start has to close it. */
|
|
283
|
+
let closeListener;
|
|
284
|
+
// Past this point a session directory exists on disk; a failed start must not
|
|
285
|
+
// leave one behind for the next run to prune, nor a listener for the process
|
|
286
|
+
// to outlive.
|
|
287
|
+
const abandon = async (cause) => {
|
|
288
|
+
compilerAbort.abort();
|
|
289
|
+
await closeListener?.().catch(() => undefined);
|
|
290
|
+
await removeVisualSession(paths).catch(() => undefined);
|
|
291
|
+
throw cause;
|
|
292
|
+
};
|
|
293
|
+
const compilation = await compileVisualModel({
|
|
294
|
+
model: request.initialModel,
|
|
295
|
+
command: request.compiler,
|
|
296
|
+
paths,
|
|
297
|
+
now,
|
|
298
|
+
signal: compilerAbort.signal,
|
|
299
|
+
}).catch(abandon);
|
|
300
|
+
if (!compilation.ok) {
|
|
301
|
+
const first = compilation.diagnostics[0];
|
|
302
|
+
return abandon(serverError(first?.code ?? 'YMVS302', `Initial visual model did not compile: ${first?.message ?? 'unknown violation'}`));
|
|
303
|
+
}
|
|
304
|
+
let rendered = await renderCompiled(compilation.compiled).catch(abandon);
|
|
305
|
+
const capabilities = {
|
|
306
|
+
chat: request.chatEnabled,
|
|
307
|
+
choices: request.chatEnabled,
|
|
308
|
+
navigation: true,
|
|
309
|
+
modelReplacement: true,
|
|
310
|
+
transcript: true,
|
|
311
|
+
};
|
|
312
|
+
let listening = false;
|
|
313
|
+
let bootstrapSpent = false;
|
|
314
|
+
let agentAttached = false;
|
|
315
|
+
let lastSequence = 0;
|
|
316
|
+
let transcriptBytes = 0;
|
|
317
|
+
let frozen;
|
|
318
|
+
let inFlight;
|
|
319
|
+
let lastSeenAt;
|
|
320
|
+
let pending = [];
|
|
321
|
+
/**
|
|
322
|
+
* What the browser has seen said, in order. It is derived from the same
|
|
323
|
+
* events and responses the journal takes, so a browser that reloads inside
|
|
324
|
+
* the grace is handed back the conversation it left rather than a blank one.
|
|
325
|
+
*/
|
|
326
|
+
const transcript = [];
|
|
327
|
+
/** Option labels the agent presented, so a selection restores as its label. */
|
|
328
|
+
const choiceLabels = new Map();
|
|
329
|
+
/**
|
|
330
|
+
* The choice the agent presented and nobody has answered. It is session
|
|
331
|
+
* state, not transcript: the reviewer's browser can only render the buttons
|
|
332
|
+
* again after a reload if the session hands the question back.
|
|
333
|
+
*/
|
|
334
|
+
let pendingChoice = null;
|
|
335
|
+
/**
|
|
336
|
+
* A journaled End is the last thing this session takes from the reviewer, so
|
|
337
|
+
* no later question can ever be answered. The lock outlives the clearing
|
|
338
|
+
* below: an agent response still in flight when the End landed must not hand
|
|
339
|
+
* a reloading browser buttons that lead nowhere.
|
|
340
|
+
*/
|
|
341
|
+
let choicesClosed = false;
|
|
342
|
+
const connections = new Map();
|
|
343
|
+
let browserConnectionReservations = 0;
|
|
344
|
+
const polls = new Set();
|
|
345
|
+
const httpSockets = new Set();
|
|
346
|
+
const unclassifiedHttpSockets = new Set();
|
|
347
|
+
const stopResponseSockets = new Set();
|
|
348
|
+
/** Every sequence assignment and queue mutation runs in call order. */
|
|
349
|
+
let admission = Promise.resolve();
|
|
350
|
+
const admit = (action) => {
|
|
351
|
+
const next = admission.then(action);
|
|
352
|
+
admission = next.then(() => undefined, () => undefined);
|
|
353
|
+
return next;
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Waits until nothing is left on the admission chain. `admission` absorbs its
|
|
357
|
+
* own rejections, and work can still enqueue while draining (it refuses
|
|
358
|
+
* itself), so the tail is re-read until it stops moving.
|
|
359
|
+
*/
|
|
360
|
+
const drainAdmission = async () => {
|
|
361
|
+
for (let tail = admission;; tail = admission) {
|
|
362
|
+
await tail;
|
|
363
|
+
if (tail === admission)
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Ends a session that reached one of its own ceilings, exactly once.
|
|
369
|
+
*
|
|
370
|
+
* `freeze` is reached from inside the admission chain and the transition
|
|
371
|
+
* drains that same chain, so the entry that detected the limit is allowed to
|
|
372
|
+
* answer its sender first: starting the transition inline would be a session
|
|
373
|
+
* waiting on itself. Nothing is truncated — every record already accepted
|
|
374
|
+
* stands, and only the closing one is added.
|
|
375
|
+
*/
|
|
376
|
+
let limitFailure;
|
|
377
|
+
const failOnLimit = () => {
|
|
378
|
+
limitFailure ??= (async () => {
|
|
379
|
+
await drainAdmission();
|
|
380
|
+
try {
|
|
381
|
+
// `server-failed` is the truth of it: not the reviewer, not the child,
|
|
382
|
+
// but the runtime is why the conversation stopped. Recovery reads that
|
|
383
|
+
// back as a failed handoff carrying everything the session did accept.
|
|
384
|
+
await terminateVisualSession(active, 'server-failed');
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
// Whatever the journal managed, the child must not be left holding a
|
|
388
|
+
// turn on a session that has stopped answering.
|
|
389
|
+
settleAllPolls();
|
|
390
|
+
}
|
|
391
|
+
})().catch(() => undefined);
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* The first limit a session reaches is the one it reports for good, and a
|
|
395
|
+
* limit the runtime detected for itself also ends it.
|
|
396
|
+
*/
|
|
397
|
+
const freeze = (reason) => {
|
|
398
|
+
if (frozen !== undefined)
|
|
399
|
+
return;
|
|
400
|
+
frozen = reason;
|
|
401
|
+
if (LIMIT_FREEZE[reason])
|
|
402
|
+
failOnLimit();
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Everything a terminal cause touches, in one place. The transition below is
|
|
406
|
+
* the only thing that moves a live session off `running`.
|
|
407
|
+
*/
|
|
408
|
+
const active = {
|
|
409
|
+
paths,
|
|
410
|
+
lifecycle: 'starting',
|
|
411
|
+
compilerAbort,
|
|
412
|
+
freeze,
|
|
413
|
+
quiesce: drainAdmission,
|
|
414
|
+
terminalEvent: { now, randomBytes },
|
|
415
|
+
terminating: undefined,
|
|
416
|
+
handoff: undefined,
|
|
417
|
+
};
|
|
418
|
+
const schedule = options.schedule ??
|
|
419
|
+
((task, ms) => {
|
|
420
|
+
const timer = setTimeout(() => void task().catch(() => undefined), ms);
|
|
421
|
+
// A grace nobody is waiting on must never be why a process stays alive.
|
|
422
|
+
timer.unref();
|
|
423
|
+
return () => clearTimeout(timer);
|
|
424
|
+
});
|
|
425
|
+
/**
|
|
426
|
+
* The reviewer's browser is gone and the session is holding the conversation
|
|
427
|
+
* open for it. Reconnecting inside the window resumes exactly where it left
|
|
428
|
+
* off; reaching the end of it is a terminal cause like any other.
|
|
429
|
+
*/
|
|
430
|
+
let cancelGrace;
|
|
431
|
+
const armGrace = () => {
|
|
432
|
+
if (cancelGrace !== undefined || active.lifecycle !== 'running')
|
|
433
|
+
return;
|
|
434
|
+
cancelGrace = schedule(async () => {
|
|
435
|
+
cancelGrace = undefined;
|
|
436
|
+
if (connections.size > 0 || active.lifecycle !== 'running')
|
|
437
|
+
return;
|
|
438
|
+
await stop('browser-timeout');
|
|
439
|
+
}, VISUAL_LIMITS.reconnectMs);
|
|
440
|
+
};
|
|
441
|
+
const disarmGrace = () => {
|
|
442
|
+
cancelGrace?.();
|
|
443
|
+
cancelGrace = undefined;
|
|
444
|
+
};
|
|
445
|
+
/** A journaled browser event, as the line the reviewer sees. */
|
|
446
|
+
const recordEvent = (event) => {
|
|
447
|
+
// A session on its way out waits on nobody, and asking past the question
|
|
448
|
+
// is how a reviewer declines to answer it.
|
|
449
|
+
if (event.type === 'chat.message' || event.type === 'session.end') {
|
|
450
|
+
pendingChoice = null;
|
|
451
|
+
}
|
|
452
|
+
if (event.type === 'session.end')
|
|
453
|
+
choicesClosed = true;
|
|
454
|
+
if (event.type === 'chat.message') {
|
|
455
|
+
transcript.push({
|
|
456
|
+
id: event.eventId,
|
|
457
|
+
speaker: 'reviewer',
|
|
458
|
+
text: event.payload.text,
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (event.type !== 'choice.selected')
|
|
463
|
+
return;
|
|
464
|
+
if (pendingChoice?.choiceId === event.payload.choiceId)
|
|
465
|
+
pendingChoice = null;
|
|
466
|
+
// The reviewer chose a label, not an identifier, so that is what the
|
|
467
|
+
// restored conversation says they chose.
|
|
468
|
+
const label = choiceLabels
|
|
469
|
+
.get(event.payload.choiceId)
|
|
470
|
+
?.get(event.payload.optionId);
|
|
471
|
+
transcript.push({
|
|
472
|
+
id: event.eventId,
|
|
473
|
+
speaker: 'reviewer',
|
|
474
|
+
text: label ?? event.payload.optionId,
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
/** An accepted agent response, as the line the reviewer sees. */
|
|
478
|
+
const recordResponse = (response) => {
|
|
479
|
+
if (response.type === 'choice.present') {
|
|
480
|
+
if (!choicesClosed)
|
|
481
|
+
pendingChoice = response.payload;
|
|
482
|
+
choiceLabels.set(response.payload.choiceId, new Map(response.payload.options.map((option) => [option.id, option.label])));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const text = response.type === 'chat.response'
|
|
486
|
+
? response.payload.text
|
|
487
|
+
: response.type === 'handoff.complete'
|
|
488
|
+
? response.payload.summary
|
|
489
|
+
: undefined;
|
|
490
|
+
if (text === undefined)
|
|
491
|
+
return;
|
|
492
|
+
transcript.push({ id: response.responseId, speaker: 'agent', text });
|
|
493
|
+
};
|
|
494
|
+
const snapshot = () => ({
|
|
495
|
+
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
496
|
+
sessionId,
|
|
497
|
+
authority: request.authority,
|
|
498
|
+
title: request.title,
|
|
499
|
+
description: request.description,
|
|
500
|
+
chatEnabled: request.chatEnabled,
|
|
501
|
+
capabilities,
|
|
502
|
+
webSocketUrl,
|
|
503
|
+
model: rendered,
|
|
504
|
+
transcript: [...transcript],
|
|
505
|
+
agentTurnOpen: openTurn(),
|
|
506
|
+
pendingChoice,
|
|
507
|
+
styleNonce,
|
|
508
|
+
lastSequence,
|
|
509
|
+
frozen: frozen !== undefined,
|
|
510
|
+
});
|
|
511
|
+
const status = () => {
|
|
512
|
+
const value = {
|
|
513
|
+
format: 'yarramate/visual-status/v1',
|
|
514
|
+
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
515
|
+
sessionId,
|
|
516
|
+
lifecycle: active.lifecycle,
|
|
517
|
+
alreadyStopped: active.lifecycle === 'stopped',
|
|
518
|
+
server: { listening, origin },
|
|
519
|
+
browser: {
|
|
520
|
+
connected: connections.size > 0,
|
|
521
|
+
connections: connections.size,
|
|
522
|
+
...(lastSeenAt === undefined ? {} : { lastSeenAt }),
|
|
523
|
+
// Only while one is actually armed: a session whose window already
|
|
524
|
+
// elapsed is not still waiting for that browser, and must not say so.
|
|
525
|
+
...(cancelGrace !== undefined && lastSeenAt !== undefined
|
|
526
|
+
? {
|
|
527
|
+
graceExpiresAt: new Date(Date.parse(lastSeenAt) + VISUAL_LIMITS.reconnectMs).toISOString(),
|
|
528
|
+
}
|
|
529
|
+
: {}),
|
|
530
|
+
},
|
|
531
|
+
agent: {
|
|
532
|
+
attached: agentAttached,
|
|
533
|
+
inFlightEventId: inFlight?.eventId ?? null,
|
|
534
|
+
},
|
|
535
|
+
queue: {
|
|
536
|
+
pendingEvents: pending.length,
|
|
537
|
+
lastSequence,
|
|
538
|
+
frozen: frozen !== undefined,
|
|
539
|
+
...(frozen === undefined ? {} : { frozenReason: frozen }),
|
|
540
|
+
},
|
|
541
|
+
capabilities,
|
|
542
|
+
transcriptBytes,
|
|
543
|
+
updatedAt: stamp(),
|
|
544
|
+
};
|
|
545
|
+
// A status document that does not validate is a runtime defect, not
|
|
546
|
+
// untrusted input: the agent must never have to parse a broken report.
|
|
547
|
+
const validated = parseVisualStatus(value);
|
|
548
|
+
if (!validated.ok) {
|
|
549
|
+
throw serverError(validated.diagnostics[0]?.code ?? 'YMVS108', `Session status is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
|
|
550
|
+
}
|
|
551
|
+
return value;
|
|
552
|
+
};
|
|
553
|
+
const sendFrame = (socket, frame) => {
|
|
554
|
+
if (socket.readyState === socket.OPEN)
|
|
555
|
+
socket.send(JSON.stringify(frame));
|
|
556
|
+
};
|
|
557
|
+
const broadcast = (frame) => {
|
|
558
|
+
for (const socket of connections.values())
|
|
559
|
+
sendFrame(socket, frame);
|
|
560
|
+
};
|
|
561
|
+
const idleDelivery = () => ({
|
|
562
|
+
waiting: true,
|
|
563
|
+
lastSequence,
|
|
564
|
+
pendingEvents: pending.length,
|
|
565
|
+
});
|
|
566
|
+
/**
|
|
567
|
+
* While a turn is outstanding the only event the agent may see is the one it
|
|
568
|
+
* already owns, so a repeated poll replays it and a later chat message waits
|
|
569
|
+
* for the current response.
|
|
570
|
+
*/
|
|
571
|
+
const deliverable = (after) => inFlight === undefined
|
|
572
|
+
? pending.find((event) => event.sequence > after)
|
|
573
|
+
: inFlight.sequence > after
|
|
574
|
+
? inFlight
|
|
575
|
+
: undefined;
|
|
576
|
+
const takeDelivery = (after) => {
|
|
577
|
+
const event = deliverable(after);
|
|
578
|
+
if (event === undefined)
|
|
579
|
+
return idleDelivery();
|
|
580
|
+
inFlight = event;
|
|
581
|
+
return {
|
|
582
|
+
waiting: false,
|
|
583
|
+
event,
|
|
584
|
+
lastSequence,
|
|
585
|
+
pendingEvents: pending.length,
|
|
586
|
+
};
|
|
587
|
+
};
|
|
588
|
+
const wakePolls = () => {
|
|
589
|
+
for (const poll of [...polls]) {
|
|
590
|
+
if (deliverable(poll.after) === undefined)
|
|
591
|
+
continue;
|
|
592
|
+
polls.delete(poll);
|
|
593
|
+
clearTimeout(poll.timer);
|
|
594
|
+
poll.settle(takeDelivery(poll.after));
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const settleAllPolls = () => {
|
|
598
|
+
for (const poll of [...polls]) {
|
|
599
|
+
polls.delete(poll);
|
|
600
|
+
clearTimeout(poll.timer);
|
|
601
|
+
poll.settle(idleDelivery());
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
const httpServer = createServer();
|
|
605
|
+
httpServer.on('connection', (socket) => {
|
|
606
|
+
httpSockets.add(socket);
|
|
607
|
+
unclassifiedHttpSockets.add(socket);
|
|
608
|
+
socket.on('close', () => {
|
|
609
|
+
httpSockets.delete(socket);
|
|
610
|
+
unclassifiedHttpSockets.delete(socket);
|
|
611
|
+
stopResponseSockets.delete(socket);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
httpServer.on('clientError', (_error, socket) => socket.destroy());
|
|
615
|
+
const bound = Promise.withResolvers();
|
|
616
|
+
httpServer.once('error', bound.reject);
|
|
617
|
+
// Loopback only, on a kernel-assigned port: the session is never reachable
|
|
618
|
+
// from another host, and never collides with a well-known service.
|
|
619
|
+
httpServer.listen(0, '127.0.0.1', () => {
|
|
620
|
+
httpServer.removeListener('error', bound.reject);
|
|
621
|
+
bound.resolve();
|
|
622
|
+
});
|
|
623
|
+
await bound.promise.catch(abandon);
|
|
624
|
+
const address = httpServer.address();
|
|
625
|
+
const origin = `http://127.0.0.1:${address.port}`;
|
|
626
|
+
const authority = `127.0.0.1:${address.port}`;
|
|
627
|
+
const webSocketUrl = `ws://${authority}${VISUAL_SOCKET_PATH}`;
|
|
628
|
+
listening = true;
|
|
629
|
+
const sockets = new WebSocketServer({
|
|
630
|
+
noServer: true,
|
|
631
|
+
maxPayload: VISUAL_SERVER_LIMITS.browserFrameBytes,
|
|
632
|
+
});
|
|
633
|
+
// A start that fails after the port is bound has to give it back; nothing
|
|
634
|
+
// has published this origin yet, so there is nothing to drain first.
|
|
635
|
+
closeListener = async () => {
|
|
636
|
+
listening = false;
|
|
637
|
+
sockets.close();
|
|
638
|
+
for (const socket of httpSockets)
|
|
639
|
+
socket.destroy();
|
|
640
|
+
const shut = Promise.withResolvers();
|
|
641
|
+
httpServer.close(() => shut.resolve());
|
|
642
|
+
await shut.promise;
|
|
643
|
+
};
|
|
644
|
+
// ---------------------------------------------------------------- responses
|
|
645
|
+
const respond = (server, code, body, contentType) => {
|
|
646
|
+
server.writeHead(code, {
|
|
647
|
+
...browserHeaders,
|
|
648
|
+
'Content-Type': contentType,
|
|
649
|
+
'Content-Length': Buffer.byteLength(body),
|
|
650
|
+
// A draining session must not leave a pooled connection behind that
|
|
651
|
+
// outlives its listener.
|
|
652
|
+
...(active.lifecycle === 'running' ? {} : { Connection: 'close' }),
|
|
653
|
+
});
|
|
654
|
+
server.end(body);
|
|
655
|
+
};
|
|
656
|
+
const respondJson = (server, code, value) => respond(server, code, JSON.stringify(value), 'application/json; charset=utf-8');
|
|
657
|
+
const refuse = (server, code, message) => respond(server, code, `${message}\n`, 'text/plain; charset=utf-8');
|
|
658
|
+
// ------------------------------------------------------------ browser input
|
|
659
|
+
const admitBrowserInput = async (socket, raw, binary) => {
|
|
660
|
+
if (binary) {
|
|
661
|
+
sendFrame(socket, {
|
|
662
|
+
kind: 'rejected',
|
|
663
|
+
diagnostics: [
|
|
664
|
+
serverDiagnostic('YMVS303', 'Browser frames must be UTF-8 JSON text'),
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
let parsed;
|
|
670
|
+
try {
|
|
671
|
+
parsed = JSON.parse(raw.toString());
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
sendFrame(socket, {
|
|
675
|
+
kind: 'rejected',
|
|
676
|
+
diagnostics: [
|
|
677
|
+
serverDiagnostic('YMVS109', 'Browser frame is not a JSON document'),
|
|
678
|
+
],
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const input = parseVisualBrowserInput(parsed);
|
|
683
|
+
if (!input.ok) {
|
|
684
|
+
sendFrame(socket, { kind: 'rejected', diagnostics: input.diagnostics });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
await admit(async () => {
|
|
688
|
+
// A frame can already be on the chain when a stop begins. Recovery must
|
|
689
|
+
// see a journal nothing is still writing to, so anything that has not
|
|
690
|
+
// been journaled yet is refused rather than raced.
|
|
691
|
+
if (active.lifecycle !== 'running') {
|
|
692
|
+
sendFrame(socket, {
|
|
693
|
+
kind: 'rejected',
|
|
694
|
+
diagnostics: [
|
|
695
|
+
serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts input`),
|
|
696
|
+
],
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (frozen !== undefined) {
|
|
701
|
+
sendFrame(socket, {
|
|
702
|
+
kind: 'rejected',
|
|
703
|
+
frozen,
|
|
704
|
+
diagnostics: [
|
|
705
|
+
serverDiagnostic('YMVS304', `Session input is frozen: ${frozen}`),
|
|
706
|
+
],
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// A session that was started without a conversation has no chat and no
|
|
711
|
+
// choices to make; the diagram, and moving around it, is all it granted.
|
|
712
|
+
const granted = input.value.type === 'chat.message'
|
|
713
|
+
? capabilities.chat
|
|
714
|
+
: input.value.type === 'choice.selected'
|
|
715
|
+
? capabilities.choices
|
|
716
|
+
: true;
|
|
717
|
+
if (!granted) {
|
|
718
|
+
sendFrame(socket, {
|
|
719
|
+
kind: 'rejected',
|
|
720
|
+
diagnostics: [
|
|
721
|
+
serverDiagnostic('YMVS309', `Session did not grant the capability "${input.value.type}" needs`, '/type'),
|
|
722
|
+
],
|
|
723
|
+
});
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
// A browser cannot have seen an acknowledgement the journal never issued.
|
|
727
|
+
// A stale claim is ordinary — a reconnect mid-turn looks exactly like one
|
|
728
|
+
// — but a claim ahead of the journal means this frame was composed against
|
|
729
|
+
// a session state that does not exist, so it is refused rather than
|
|
730
|
+
// journaled behind a sequence its sender never read.
|
|
731
|
+
if (input.value.lastAcknowledgedSequence > lastSequence) {
|
|
732
|
+
sendFrame(socket, {
|
|
733
|
+
kind: 'rejected',
|
|
734
|
+
diagnostics: [
|
|
735
|
+
serverDiagnostic('YMVS308', `Browser acknowledged sequence ${input.value.lastAcknowledgedSequence}, past the journal's ${lastSequence}`, '/lastAcknowledgedSequence'),
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const event = eventFrom(input.value, {
|
|
741
|
+
format: 'yarramate/visual-event/v1',
|
|
742
|
+
sessionId,
|
|
743
|
+
sequence: lastSequence + 1,
|
|
744
|
+
eventId: drawHex(16),
|
|
745
|
+
timestamp: stamp(),
|
|
746
|
+
});
|
|
747
|
+
const actionable = isActionableVisualEvent(event);
|
|
748
|
+
if (actionable && pending.length >= VISUAL_LIMITS.pendingEvents) {
|
|
749
|
+
freeze('pending-events');
|
|
750
|
+
sendFrame(socket, {
|
|
751
|
+
kind: 'rejected',
|
|
752
|
+
frozen,
|
|
753
|
+
diagnostics: [
|
|
754
|
+
serverDiagnostic('YMVS305', `Queue already holds the ${VISUAL_LIMITS.pendingEvents} event maximum`, '/sequence'),
|
|
755
|
+
],
|
|
756
|
+
});
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const appended = await appendVisualEvent(paths, event);
|
|
760
|
+
if (!appended.ok) {
|
|
761
|
+
if (appended.freeze !== undefined)
|
|
762
|
+
freeze(appended.freeze);
|
|
763
|
+
sendFrame(socket, {
|
|
764
|
+
kind: 'rejected',
|
|
765
|
+
...(frozen === undefined ? {} : { frozen }),
|
|
766
|
+
diagnostics: appended.diagnostics,
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
lastSequence = appended.lastSequence;
|
|
771
|
+
transcriptBytes = appended.transcriptBytes;
|
|
772
|
+
recordEvent(event);
|
|
773
|
+
if (actionable)
|
|
774
|
+
pending.push(event);
|
|
775
|
+
// A journaled end is the last input this session takes. The agent still
|
|
776
|
+
// has to see it, so the queue freezes rather than the socket closing.
|
|
777
|
+
if (event.type === 'session.end')
|
|
778
|
+
freeze('terminal-event');
|
|
779
|
+
sendFrame(socket, {
|
|
780
|
+
kind: 'accepted',
|
|
781
|
+
sequence: event.sequence,
|
|
782
|
+
eventId: event.eventId,
|
|
783
|
+
});
|
|
784
|
+
wakePolls();
|
|
785
|
+
});
|
|
786
|
+
};
|
|
787
|
+
/**
|
|
788
|
+
* Journals one transport record the runtime owns outright: a browser was
|
|
789
|
+
* admitted, or the socket it was admitted on went away. `closedWith` is the
|
|
790
|
+
* close code for the second and absent for the first.
|
|
791
|
+
*
|
|
792
|
+
* The browser neither sends these nor waits on them. They are non-actionable,
|
|
793
|
+
* so they open no agent turn and never join the pending queue; they take
|
|
794
|
+
* their sequence, identifier, and timestamp from the same admission chain
|
|
795
|
+
* every other record does, so a connection is always journaled ahead of the
|
|
796
|
+
* frames it goes on to send. A session that is no longer taking input skips
|
|
797
|
+
* them, because a shutdown closing its own sockets is not the reviewer
|
|
798
|
+
* leaving. The append is deliberately answered to nobody: a socket callback
|
|
799
|
+
* awaits nothing, so a failure here becomes a freeze rather than a rejection
|
|
800
|
+
* the process never handles.
|
|
801
|
+
*/
|
|
802
|
+
const journalConnection = (connectionId, closedWith, onJournaled) => admit(async () => {
|
|
803
|
+
if (active.lifecycle !== 'running' || frozen !== undefined)
|
|
804
|
+
return false;
|
|
805
|
+
const envelope = {
|
|
806
|
+
format: 'yarramate/visual-event/v1',
|
|
807
|
+
sessionId,
|
|
808
|
+
sequence: lastSequence + 1,
|
|
809
|
+
eventId: drawHex(16),
|
|
810
|
+
timestamp: stamp(),
|
|
811
|
+
};
|
|
812
|
+
const event = closedWith === undefined
|
|
813
|
+
? { ...envelope, type: 'browser.connected', payload: { connectionId } }
|
|
814
|
+
: {
|
|
815
|
+
...envelope,
|
|
816
|
+
type: 'browser.disconnected',
|
|
817
|
+
payload: { connectionId, code: closedWith },
|
|
818
|
+
};
|
|
819
|
+
const appended = await appendVisualEvent(paths, event);
|
|
820
|
+
if (!appended.ok) {
|
|
821
|
+
if (appended.freeze !== undefined)
|
|
822
|
+
freeze(appended.freeze);
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
lastSequence = appended.lastSequence;
|
|
826
|
+
transcriptBytes = appended.transcriptBytes;
|
|
827
|
+
onJournaled?.();
|
|
828
|
+
return true;
|
|
829
|
+
}).catch(() => false);
|
|
830
|
+
const attachBrowser = (socket, releaseReservation) => {
|
|
831
|
+
const connectionId = drawHex(16);
|
|
832
|
+
let registered = false;
|
|
833
|
+
lastSeenAt = stamp();
|
|
834
|
+
socket.on('message', (raw, binary) => {
|
|
835
|
+
// Nothing awaits this handler, so a rejection here would reach the
|
|
836
|
+
// process as an unhandled one and take the runtime down with it. The
|
|
837
|
+
// browser is told its frame failed; the session stays up.
|
|
838
|
+
admitBrowserInput(socket, raw, binary).catch((cause) => {
|
|
839
|
+
try {
|
|
840
|
+
sendFrame(socket, {
|
|
841
|
+
kind: 'rejected',
|
|
842
|
+
diagnostics: [
|
|
843
|
+
serverDiagnostic('YMVS307', `Admitting the browser frame failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
844
|
+
],
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
socket.close(1011);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
socket.on('error', (error) => {
|
|
853
|
+
// ws refuses an oversized frame before buffering it, which is exactly the
|
|
854
|
+
// byte ceiling the protocol calls a freeze.
|
|
855
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
856
|
+
? error.code
|
|
857
|
+
: undefined;
|
|
858
|
+
if (code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
|
|
859
|
+
freeze('message-bytes');
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
socket.on('close', (code) => {
|
|
863
|
+
if (!registered)
|
|
864
|
+
return;
|
|
865
|
+
connections.delete(connectionId);
|
|
866
|
+
lastSeenAt = stamp();
|
|
867
|
+
// The code the transport reported, held inside the range the journal
|
|
868
|
+
// accepts: a socket that died without one still has to be recorded as
|
|
869
|
+
// having gone, and 1006 is what that abnormal closure is called.
|
|
870
|
+
void journalConnection(connectionId, Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1006);
|
|
871
|
+
if (connections.size === 0)
|
|
872
|
+
armGrace();
|
|
873
|
+
});
|
|
874
|
+
// Admission is one serialized operation: the connection is visible only
|
|
875
|
+
// after its event is durable, so no frame can be accepted against a
|
|
876
|
+
void journalConnection(connectionId, undefined, () => {
|
|
877
|
+
registered = true;
|
|
878
|
+
connections.set(connectionId, socket);
|
|
879
|
+
disarmGrace();
|
|
880
|
+
}).then((journaled) => {
|
|
881
|
+
releaseReservation();
|
|
882
|
+
if (!journaled || socket.readyState !== socket.OPEN) {
|
|
883
|
+
socket.close(1011);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
sendFrame(socket, { kind: 'ready', snapshot: snapshot() });
|
|
887
|
+
});
|
|
888
|
+
};
|
|
889
|
+
// ------------------------------------------------------------- agent routes
|
|
890
|
+
const answerPoll = (server, query) => {
|
|
891
|
+
const raw = query.get('after') ?? '0';
|
|
892
|
+
const after = Number(raw);
|
|
893
|
+
if (!Number.isSafeInteger(after) || after < 0) {
|
|
894
|
+
refuse(server, 400, `Query "after" must be a sequence, not "${raw}"`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const immediate = takeDelivery(after);
|
|
898
|
+
if (!immediate.waiting || active.lifecycle !== 'running') {
|
|
899
|
+
respondJson(server, 200, immediate);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const poll = {
|
|
903
|
+
after,
|
|
904
|
+
settle: (result) => respondJson(server, 200, result),
|
|
905
|
+
timer: setTimeout(() => {
|
|
906
|
+
polls.delete(poll);
|
|
907
|
+
respondJson(server, 200, idleDelivery());
|
|
908
|
+
}, agentPollMs),
|
|
909
|
+
};
|
|
910
|
+
polls.add(poll);
|
|
911
|
+
server.on('close', () => {
|
|
912
|
+
if (polls.delete(poll))
|
|
913
|
+
clearTimeout(poll.timer);
|
|
914
|
+
});
|
|
915
|
+
};
|
|
916
|
+
const replaceModel = async (response) => {
|
|
917
|
+
const compiled = await compileVisualModel({
|
|
918
|
+
model: response.payload.model,
|
|
919
|
+
command: request.compiler,
|
|
920
|
+
paths,
|
|
921
|
+
now,
|
|
922
|
+
signal: compilerAbort.signal,
|
|
923
|
+
});
|
|
924
|
+
if (compiled.ok) {
|
|
925
|
+
rendered = await renderCompiled(compiled.compiled);
|
|
926
|
+
broadcast({ kind: 'model', model: rendered });
|
|
927
|
+
return { model: rendered, diagnostics: [] };
|
|
928
|
+
}
|
|
929
|
+
// The candidate is discarded and the last good rendering stays live; the
|
|
930
|
+
// browser learns why from a journaled diagnostic response.
|
|
931
|
+
const diagnostic = {
|
|
932
|
+
format: 'yarramate/visual-response/v1',
|
|
933
|
+
sessionId,
|
|
934
|
+
responseId: drawHex(16),
|
|
935
|
+
eventId: response.eventId,
|
|
936
|
+
type: 'diagnostic',
|
|
937
|
+
timestamp: stamp(),
|
|
938
|
+
payload: { diagnostics: compiled.diagnostics },
|
|
939
|
+
};
|
|
940
|
+
const appended = await appendVisualResponse(paths, diagnostic);
|
|
941
|
+
if (appended.ok)
|
|
942
|
+
transcriptBytes = appended.transcriptBytes;
|
|
943
|
+
// A journal that refused the explanation is still a journal that reached a
|
|
944
|
+
// limit, and the limit outlives this response.
|
|
945
|
+
else if (appended.freeze !== undefined)
|
|
946
|
+
freeze(appended.freeze);
|
|
947
|
+
broadcast({ kind: 'response', response: diagnostic });
|
|
948
|
+
// The diagnostic is the answer to the event the replacement was answering,
|
|
949
|
+
// and it is the only one that event will get. It retires the turn exactly
|
|
950
|
+
// as an agent-posted diagnostic would, so the reviewer's next message is
|
|
951
|
+
// deliverable instead of waiting behind a turn nobody will ever close.
|
|
952
|
+
markAnswered(diagnostic);
|
|
953
|
+
completeTurn(diagnostic);
|
|
954
|
+
return { diagnostics: compiled.diagnostics };
|
|
955
|
+
};
|
|
956
|
+
/**
|
|
957
|
+
* Events a turn-completing response has already answered.
|
|
958
|
+
*
|
|
959
|
+
* `completeTurn` only retires an event the agent actually polled for, so an
|
|
960
|
+
* answer that arrives before the poll leaves the event queued. The browser
|
|
961
|
+
* still has to be told its turn is over, and it is pruned back to the queue
|
|
962
|
+
* it describes so it cannot outgrow it.
|
|
963
|
+
*/
|
|
964
|
+
const answered = new Set();
|
|
965
|
+
const openTurn = () => (inFlight === undefined ? pending : [inFlight, ...pending]).some((event) => !answered.has(event.eventId));
|
|
966
|
+
const markAnswered = (response) => {
|
|
967
|
+
if (TURN_COMPLETING[response.type] !== true)
|
|
968
|
+
return;
|
|
969
|
+
answered.add(response.eventId);
|
|
970
|
+
for (const eventId of answered) {
|
|
971
|
+
const queued = inFlight?.eventId === eventId ||
|
|
972
|
+
pending.some((event) => event.eventId === eventId);
|
|
973
|
+
if (!queued)
|
|
974
|
+
answered.delete(eventId);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
const completeTurn = (response) => {
|
|
978
|
+
if (TURN_COMPLETING[response.type] !== true)
|
|
979
|
+
return;
|
|
980
|
+
if (inFlight?.eventId !== response.eventId)
|
|
981
|
+
return;
|
|
982
|
+
pending = pending.filter((event) => event.eventId !== response.eventId);
|
|
983
|
+
inFlight = undefined;
|
|
984
|
+
wakePolls();
|
|
985
|
+
};
|
|
986
|
+
const acceptResponse = async (server, incoming) => {
|
|
987
|
+
const body = await readJsonBody(incoming, VISUAL_SERVER_LIMITS.agentBodyBytes);
|
|
988
|
+
if (!body.ok) {
|
|
989
|
+
refuse(server, body.status, body.message);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const parsed = parseVisualResponse(body.value);
|
|
993
|
+
if (!parsed.ok) {
|
|
994
|
+
respondJson(server, 400, {
|
|
995
|
+
accepted: false,
|
|
996
|
+
diagnostics: parsed.diagnostics,
|
|
997
|
+
});
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const response = parsed.value;
|
|
1001
|
+
if (response.sessionId !== sessionId) {
|
|
1002
|
+
respondJson(server, 409, {
|
|
1003
|
+
accepted: false,
|
|
1004
|
+
diagnostics: [
|
|
1005
|
+
serverDiagnostic('YMVS126', `Response belongs to session "${response.sessionId}", not "${sessionId}"`, '/sessionId'),
|
|
1006
|
+
],
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
// The agent cannot hold a conversation a diagram-only session never
|
|
1011
|
+
// offered; the browser has nowhere to show one.
|
|
1012
|
+
if (!capabilities.chat &&
|
|
1013
|
+
(response.type === 'chat.response' || response.type === 'choice.present')) {
|
|
1014
|
+
respondJson(server, 409, {
|
|
1015
|
+
accepted: false,
|
|
1016
|
+
diagnostics: [
|
|
1017
|
+
serverDiagnostic('YMVS309', `Session did not grant the capability "${response.type}" needs`, '/type'),
|
|
1018
|
+
],
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const result = await admit(async () => {
|
|
1023
|
+
// Same race as a browser frame: a request already on the chain must not
|
|
1024
|
+
// append to a session recovery has begun reading.
|
|
1025
|
+
if (active.lifecycle !== 'running') {
|
|
1026
|
+
return {
|
|
1027
|
+
code: 503,
|
|
1028
|
+
body: {
|
|
1029
|
+
accepted: false,
|
|
1030
|
+
diagnostics: [
|
|
1031
|
+
serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts responses`),
|
|
1032
|
+
],
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
const appended = await appendVisualResponse(paths, response);
|
|
1037
|
+
if (!appended.ok) {
|
|
1038
|
+
if (appended.freeze !== undefined)
|
|
1039
|
+
freeze(appended.freeze);
|
|
1040
|
+
return {
|
|
1041
|
+
code: 409,
|
|
1042
|
+
body: { accepted: false, diagnostics: appended.diagnostics },
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
transcriptBytes = appended.transcriptBytes;
|
|
1046
|
+
// A response the runtime already journaled must not be broadcast or
|
|
1047
|
+
// compiled twice: a retried delivery is not a second turn.
|
|
1048
|
+
if (appended.duplicate) {
|
|
1049
|
+
return {
|
|
1050
|
+
code: 200,
|
|
1051
|
+
body: {
|
|
1052
|
+
accepted: true,
|
|
1053
|
+
duplicate: true,
|
|
1054
|
+
lastSequence: appended.lastSequence,
|
|
1055
|
+
diagnostics: [],
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
recordResponse(response);
|
|
1060
|
+
markAnswered(response);
|
|
1061
|
+
broadcast({ kind: 'response', response });
|
|
1062
|
+
const replaced = response.type === 'model.replace'
|
|
1063
|
+
? await replaceModel(response)
|
|
1064
|
+
: { diagnostics: [] };
|
|
1065
|
+
completeTurn(response);
|
|
1066
|
+
return {
|
|
1067
|
+
code: 200,
|
|
1068
|
+
body: {
|
|
1069
|
+
accepted: true,
|
|
1070
|
+
duplicate: false,
|
|
1071
|
+
lastSequence,
|
|
1072
|
+
...(replaced.model === undefined ? {} : { model: replaced.model }),
|
|
1073
|
+
diagnostics: replaced.diagnostics,
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
});
|
|
1077
|
+
respondJson(server, result.code, result.body);
|
|
1078
|
+
};
|
|
1079
|
+
const acceptStop = async (server, incoming) => {
|
|
1080
|
+
const responseSocket = incoming.socket;
|
|
1081
|
+
const body = await readJsonBody(incoming, VISUAL_SERVER_LIMITS.agentControlBytes);
|
|
1082
|
+
if (!body.ok) {
|
|
1083
|
+
refuse(server, body.status, body.message);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const asked = typeof body.value === 'object' && body.value !== null
|
|
1087
|
+
? body.value
|
|
1088
|
+
: {};
|
|
1089
|
+
const reason = asked.reason;
|
|
1090
|
+
if (typeof reason !== 'string' || TERMINATION_REASONS[reason] !== true) {
|
|
1091
|
+
refuse(server, 400, 'Stop requires a known termination reason');
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (asked.includeTranscript !== undefined &&
|
|
1095
|
+
typeof asked.includeTranscript !== 'boolean') {
|
|
1096
|
+
refuse(server, 400, 'Stop takes a boolean "includeTranscript" or none');
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
// Every stop response already in flight is protected from the shared
|
|
1100
|
+
// teardown. The response that wins the stop does not get to close its
|
|
1101
|
+
// concurrent callers before they can observe the same result.
|
|
1102
|
+
const closed = await stop(reason, asked.includeTranscript);
|
|
1103
|
+
server.on('finish', () => responseSocket.end());
|
|
1104
|
+
respondJson(server, 200, closed);
|
|
1105
|
+
};
|
|
1106
|
+
const serveAgent = async (incoming, server, pathname, query) => {
|
|
1107
|
+
// An agent is not a browser. A request carrying an Origin came from a page,
|
|
1108
|
+
// which must never be able to spend a leaked agent capability.
|
|
1109
|
+
if (incoming.headers.origin !== undefined) {
|
|
1110
|
+
refuse(server, 403, 'Agent routes reject browser-originated requests');
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (!secretEquals(session.agentToken, bearerToken(incoming.headers.authorization))) {
|
|
1114
|
+
refuse(server, 401, 'Agent capability required');
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
agentAttached = true;
|
|
1118
|
+
if (pathname === '/api/agent/events') {
|
|
1119
|
+
if (incoming.method !== 'GET')
|
|
1120
|
+
return refuse(server, 405, 'Use GET');
|
|
1121
|
+
answerPoll(server, query);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (pathname === '/api/agent/status') {
|
|
1125
|
+
if (incoming.method !== 'GET')
|
|
1126
|
+
return refuse(server, 405, 'Use GET');
|
|
1127
|
+
respondJson(server, 200, status());
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (pathname === '/api/agent/responses') {
|
|
1131
|
+
if (incoming.method !== 'POST')
|
|
1132
|
+
return refuse(server, 405, 'Use POST');
|
|
1133
|
+
await acceptResponse(server, incoming);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (pathname === '/api/agent/stop') {
|
|
1137
|
+
if (incoming.method !== 'POST')
|
|
1138
|
+
return refuse(server, 405, 'Use POST');
|
|
1139
|
+
await acceptStop(server, incoming);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
refuse(server, 404, 'No such route');
|
|
1143
|
+
};
|
|
1144
|
+
// ----------------------------------------------------------- browser routes
|
|
1145
|
+
const assetPath = (pathname) => {
|
|
1146
|
+
if (pathname === '/')
|
|
1147
|
+
return join(assetRoot, 'index.html');
|
|
1148
|
+
const prefix = '/assets/';
|
|
1149
|
+
if (!pathname.startsWith(prefix))
|
|
1150
|
+
return undefined;
|
|
1151
|
+
const name = pathname.slice(prefix.length);
|
|
1152
|
+
if (!ASSET_NAME.test(name))
|
|
1153
|
+
return undefined;
|
|
1154
|
+
const target = resolve(assetRoot, 'assets', name);
|
|
1155
|
+
// The name cannot carry a separator, so this only confirms what the pattern
|
|
1156
|
+
// already guaranteed — which is the point of having both.
|
|
1157
|
+
return target.startsWith(`${assetRoot}${sep}`) ? target : undefined;
|
|
1158
|
+
};
|
|
1159
|
+
const serveAsset = async (server, pathname) => {
|
|
1160
|
+
const target = assetPath(pathname);
|
|
1161
|
+
const contentType = target === undefined ? undefined : CONTENT_TYPES[extname(target)];
|
|
1162
|
+
if (target === undefined || contentType === undefined) {
|
|
1163
|
+
refuse(server, 404, 'No such asset');
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
// lstat, never stat: a symlink inside the asset root is a link out of it.
|
|
1168
|
+
const entry = await lstat(target);
|
|
1169
|
+
if (!entry.isFile()) {
|
|
1170
|
+
refuse(server, 404, 'No such asset');
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
respond(server, 200, await readFile(target), contentType);
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
refuse(server, 404, 'No such asset');
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
const serveBootstrap = (server, query) => {
|
|
1180
|
+
const key = query.get('key') ?? undefined;
|
|
1181
|
+
if (bootstrapSpent || !secretEquals(session.browserToken, key)) {
|
|
1182
|
+
refuse(server, 403, 'Bootstrap capability is not valid');
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// One-time by construction: the URL capability buys exactly one cookie, and
|
|
1186
|
+
// the cookie carries a different secret, so a leaked URL cannot be replayed
|
|
1187
|
+
// and a leaked cookie cannot be pasted into an address bar.
|
|
1188
|
+
bootstrapSpent = true;
|
|
1189
|
+
server.writeHead(303, {
|
|
1190
|
+
...browserHeaders,
|
|
1191
|
+
Location: '/',
|
|
1192
|
+
'Set-Cookie': `${COOKIE_NAME}=${browserAuthenticator}; HttpOnly; SameSite=Strict; Secure; Path=/`,
|
|
1193
|
+
'Content-Length': 0,
|
|
1194
|
+
});
|
|
1195
|
+
server.end();
|
|
1196
|
+
};
|
|
1197
|
+
const serveBrowser = async (incoming, server, pathname, query) => {
|
|
1198
|
+
const sent = incoming.headers.origin;
|
|
1199
|
+
if (sent !== undefined && sent !== origin) {
|
|
1200
|
+
refuse(server, 403, 'Origin is not this session');
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (incoming.method !== 'GET') {
|
|
1204
|
+
refuse(server, 405, 'Use GET');
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (pathname === '/bootstrap') {
|
|
1208
|
+
serveBootstrap(server, query);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
|
|
1212
|
+
refuse(server, 401, 'Session cookie required');
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (pathname === '/api/session') {
|
|
1216
|
+
respondJson(server, 200, snapshot());
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
await serveAsset(server, pathname);
|
|
1220
|
+
};
|
|
1221
|
+
httpServer.on('request', (incoming, server) => {
|
|
1222
|
+
const responseSocket = incoming.socket;
|
|
1223
|
+
unclassifiedHttpSockets.delete(responseSocket);
|
|
1224
|
+
if ((incoming.url ?? '/').split('?')[0] === '/api/agent/stop') {
|
|
1225
|
+
stopResponseSockets.add(responseSocket);
|
|
1226
|
+
const releaseResponseSocket = () => {
|
|
1227
|
+
stopResponseSockets.delete(responseSocket);
|
|
1228
|
+
};
|
|
1229
|
+
server.once('finish', releaseResponseSocket);
|
|
1230
|
+
server.once('close', releaseResponseSocket);
|
|
1231
|
+
}
|
|
1232
|
+
void (async () => {
|
|
1233
|
+
try {
|
|
1234
|
+
// Rebinding defence: a request that reached this port under any other
|
|
1235
|
+
// name was routed here by something other than the browser we started.
|
|
1236
|
+
if (incoming.headers.host !== authority) {
|
|
1237
|
+
refuse(server, 403, 'Host is not the bound loopback authority');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const [rawPath = '/', rawQuery = ''] = (incoming.url ?? '/').split('?');
|
|
1241
|
+
let pathname;
|
|
1242
|
+
try {
|
|
1243
|
+
pathname = decodeURIComponent(rawPath);
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
refuse(server, 400, 'Request target is not a valid URL path');
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (pathname.includes('\0')) {
|
|
1250
|
+
refuse(server, 400, 'Request target is not a valid URL path');
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const query = new URLSearchParams(rawQuery);
|
|
1254
|
+
if (pathname.startsWith('/api/agent/')) {
|
|
1255
|
+
await serveAgent(incoming, server, pathname, query);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
await serveBrowser(incoming, server, pathname, query);
|
|
1259
|
+
}
|
|
1260
|
+
catch (cause) {
|
|
1261
|
+
if (server.headersSent)
|
|
1262
|
+
server.destroy(cause);
|
|
1263
|
+
else
|
|
1264
|
+
refuse(server, 500, 'Session server failed');
|
|
1265
|
+
}
|
|
1266
|
+
})();
|
|
1267
|
+
});
|
|
1268
|
+
httpServer.on('upgrade', (incoming, socket, head) => {
|
|
1269
|
+
const deny = (code, message) => {
|
|
1270
|
+
socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
1271
|
+
socket.destroy();
|
|
1272
|
+
};
|
|
1273
|
+
if (active.lifecycle !== 'running')
|
|
1274
|
+
return deny(503, 'Service Unavailable');
|
|
1275
|
+
if (incoming.headers.host !== authority)
|
|
1276
|
+
return deny(403, 'Forbidden');
|
|
1277
|
+
// An upgrade always carries an Origin, so it is checked without exception.
|
|
1278
|
+
if (incoming.headers.origin !== origin)
|
|
1279
|
+
return deny(403, 'Forbidden');
|
|
1280
|
+
const [rawPath = '/'] = (incoming.url ?? '/').split('?');
|
|
1281
|
+
if (rawPath !== VISUAL_SOCKET_PATH)
|
|
1282
|
+
return deny(404, 'Not Found');
|
|
1283
|
+
if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
|
|
1284
|
+
return deny(401, 'Unauthorized');
|
|
1285
|
+
}
|
|
1286
|
+
if (connections.size + browserConnectionReservations >=
|
|
1287
|
+
VISUAL_SERVER_LIMITS.browserConnections) {
|
|
1288
|
+
return deny(503, 'Service Unavailable');
|
|
1289
|
+
}
|
|
1290
|
+
browserConnectionReservations += 1;
|
|
1291
|
+
try {
|
|
1292
|
+
sockets.handleUpgrade(incoming, socket, head, (browser) => {
|
|
1293
|
+
attachBrowser(browser, () => {
|
|
1294
|
+
browserConnectionReservations -= 1;
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
catch (cause) {
|
|
1299
|
+
browserConnectionReservations -= 1;
|
|
1300
|
+
throw cause;
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
// ----------------------------------------------------------------- shutdown
|
|
1304
|
+
const { promise: closed, resolve: announceClosed } = Promise.withResolvers();
|
|
1305
|
+
let stopping;
|
|
1306
|
+
const stop = async (reason, includeTranscript = options.includeTranscript ?? false) => {
|
|
1307
|
+
if (stopping !== undefined) {
|
|
1308
|
+
return { ...(await stopping), alreadyStopped: true };
|
|
1309
|
+
}
|
|
1310
|
+
const attempt = (async () => {
|
|
1311
|
+
// Frozen before anything is torn down: a request already on the wire
|
|
1312
|
+
// must not be journaled behind the recovery this stop is about to read.
|
|
1313
|
+
active.lifecycle = 'draining';
|
|
1314
|
+
listening = false;
|
|
1315
|
+
disarmGrace();
|
|
1316
|
+
// Waiting agents are answered before any socket is torn down, so a poll
|
|
1317
|
+
// in flight during a stop still receives its non-terminal idle result.
|
|
1318
|
+
settleAllPolls();
|
|
1319
|
+
for (const socket of connections.values()) {
|
|
1320
|
+
sendFrame(socket, { kind: 'closing', reason });
|
|
1321
|
+
socket.close(1001);
|
|
1322
|
+
}
|
|
1323
|
+
sockets.close();
|
|
1324
|
+
// The one transition: freeze, cancel the compiler, let admitted work
|
|
1325
|
+
// finish, journal the terminal event, and recover — all without deleting
|
|
1326
|
+
// anything, so shutdown can never be the step that loses confirmed state.
|
|
1327
|
+
// A session that already ended answers with the handoff it ended with.
|
|
1328
|
+
const terminal = await terminateVisualSession(active, reason);
|
|
1329
|
+
// Keep the listener alive while stop requests already reaching the port
|
|
1330
|
+
// join the shared attempt. Closing it earlier resets accepted idle
|
|
1331
|
+
// sockets before Node has emitted their request events.
|
|
1332
|
+
httpServer.close();
|
|
1333
|
+
// Stop requests already on the wire, including accepted sockets whose
|
|
1334
|
+
// request event has not fired yet, must survive long enough to answer.
|
|
1335
|
+
for (const socket of httpSockets) {
|
|
1336
|
+
if (!stopResponseSockets.has(socket) &&
|
|
1337
|
+
!unclassifiedHttpSockets.has(socket)) {
|
|
1338
|
+
socket.end();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// The transcript is read once, here, and only if this stop asked for it;
|
|
1342
|
+
// the same read is what removes the marked root.
|
|
1343
|
+
const removed = await removeVisualSession(paths, includeTranscript);
|
|
1344
|
+
active.lifecycle = 'stopped';
|
|
1345
|
+
const outcome = {
|
|
1346
|
+
reason,
|
|
1347
|
+
alreadyStopped: false,
|
|
1348
|
+
handoff: removed ?? terminal,
|
|
1349
|
+
};
|
|
1350
|
+
announceClosed(outcome);
|
|
1351
|
+
return outcome;
|
|
1352
|
+
})();
|
|
1353
|
+
// Every step of a teardown is idempotent, and one that throws has left the
|
|
1354
|
+
// session directory behind for someone to finish. Forgetting the failed
|
|
1355
|
+
// attempt is what makes the next stop that fresh run rather than a replay
|
|
1356
|
+
// of the same rejection; observing it here is what keeps the cached promise
|
|
1357
|
+
// from being a rejection nothing ever handled.
|
|
1358
|
+
stopping = attempt;
|
|
1359
|
+
attempt.catch(() => {
|
|
1360
|
+
if (stopping === attempt)
|
|
1361
|
+
stopping = undefined;
|
|
1362
|
+
});
|
|
1363
|
+
return attempt;
|
|
1364
|
+
};
|
|
1365
|
+
// Everything from here is publication, and every way it can fail — a
|
|
1366
|
+
// descriptor that will not write, a document that does not validate, a clock
|
|
1367
|
+
// that answers with nonsense — leaves a bound port and a session directory
|
|
1368
|
+
// behind unless it goes through `abandon`.
|
|
1369
|
+
let started;
|
|
1370
|
+
try {
|
|
1371
|
+
await writeVisualSessionDescriptor(paths, {
|
|
1372
|
+
format: 'yarramate/visual-session-descriptor/v1',
|
|
1373
|
+
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
1374
|
+
sessionId,
|
|
1375
|
+
origin,
|
|
1376
|
+
agentCapability: session.agentToken,
|
|
1377
|
+
sessionRoot: paths.root,
|
|
1378
|
+
journalPath: paths.journal,
|
|
1379
|
+
createdAt: stamp(),
|
|
1380
|
+
});
|
|
1381
|
+
started = {
|
|
1382
|
+
format: 'yarramate/visual-session-started/v1',
|
|
1383
|
+
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
1384
|
+
sessionId,
|
|
1385
|
+
authority: request.authority,
|
|
1386
|
+
title: request.title,
|
|
1387
|
+
chatEnabled: request.chatEnabled,
|
|
1388
|
+
browserUrl: `${origin}/bootstrap?key=${session.browserToken}`,
|
|
1389
|
+
webSocketUrl,
|
|
1390
|
+
origin,
|
|
1391
|
+
descriptorPath: paths.descriptor,
|
|
1392
|
+
sessionRoot: paths.root,
|
|
1393
|
+
capabilities,
|
|
1394
|
+
startedAt: stamp(),
|
|
1395
|
+
};
|
|
1396
|
+
const validated = parseVisualSessionStarted(started);
|
|
1397
|
+
if (!validated.ok) {
|
|
1398
|
+
throw serverError(validated.diagnostics[0]?.code ?? 'YMVS102', `Started session is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (cause) {
|
|
1402
|
+
return abandon(cause);
|
|
1403
|
+
}
|
|
1404
|
+
active.lifecycle = 'running';
|
|
1405
|
+
return { started, closed, status, stop: (reason) => stop(reason) };
|
|
1406
|
+
};
|