yarramate 0.18.0 → 0.19.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/catalogues/core-enrichment.yaml +52 -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.js +0 -1
- package/dist/adapters/visual/protocol-contract.d.ts +118 -35
- package/dist/adapters/visual/protocol-contract.js +1 -11
- package/dist/adapters/visual/protocol.d.ts +8 -1
- package/dist/adapters/visual/protocol.js +86 -46
- package/dist/adapters/visual/request.d.ts +37 -0
- package/dist/adapters/visual/request.js +131 -0
- package/dist/adapters/visual/session-server.d.ts +12 -14
- package/dist/adapters/visual/session-server.js +641 -283
- package/dist/adapters/visual/session-store.d.ts +28 -28
- package/dist/adapters/visual/session-store.js +32 -93
- package/dist/adapters/visual/wire.d.ts +36 -10
- package/dist/adapters/visual-cli.d.ts +3 -0
- package/dist/adapters/visual-cli.js +66 -1
- package/dist/apply-command.d.ts +10 -0
- package/dist/apply-command.js +399 -247
- package/dist/cli.js +0 -0
- package/dist/compiler.d.ts +13 -0
- package/dist/compiler.js +36 -2
- package/dist/graph-projection.d.ts +62 -0
- package/dist/graph-projection.js +222 -0
- package/dist/interrogate-command.d.ts +2 -0
- package/dist/interrogate-command.js +22 -0
- package/dist/kind-label.d.ts +8 -0
- package/dist/kind-label.js +8 -0
- package/dist/operations.d.ts +139 -0
- package/dist/operations.js +11 -0
- package/dist/projection.d.ts +2 -0
- package/dist/projection.js +8 -2
- package/dist/reconciliation.js +8 -7
- package/dist/source-document.d.ts +10 -0
- package/dist/source-document.js +21 -1
- package/dist/visual-app/assets/index-6oSR9RGM.js +354 -0
- package/dist/visual-app/assets/index-dwA9AOr5.css +1 -0
- package/dist/visual-app/index.html +2 -2
- package/docs/CONSUMING-YARRAMATE.md +25 -19
- package/package.json +6 -1
- package/schema/yarramate-adapter-mapping.schema.json +1 -1
- package/schema/yarramate-apply-result.schema.json +8 -2
- package/schema/yarramate-document.schema.json +1 -1
- package/schema/yarramate-operations.schema.json +203 -1
- package/schema/yarramate-projection.schema.json +12 -3
- package/schema/yarramate-question-catalogue.schema.json +15 -2
- package/schema/yarramate-visual-event.schema.json +143 -0
- package/schema/yarramate-visual-graph.schema.json +272 -0
- package/schema/yarramate-visual-layout.schema.json +41 -0
- package/schema/yarramate-visual-model.schema.json +6 -44
- package/schema/yarramate-visual-response.schema.json +170 -19
- package/schema/yarramate-visual-session-descriptor.schema.json +1 -1
- package/schema/yarramate-visual-session-request.schema.json +0 -70
- package/schema/yarramate-visual-session-started.schema.json +2 -9
- package/schema/yarramate-visual-status.schema.json +3 -2
- package/skills/yarramate-architecture/SKILL.md +20 -13
- package/skills/yarramate-architecture/references/journey-checklists.md +4 -2
- package/skills/yarramate-architecture/references/native-authoring.md +30 -0
- package/skills/yarramate-architecture/references/visual-conversations.md +149 -150
- package/dist/adapters/visual/likec4-compiler.d.ts +0 -55
- package/dist/adapters/visual/likec4-compiler.js +0 -460
- package/dist/visual-app/assets/index-CGvC8e3R.css +0 -1
- package/dist/visual-app/assets/index-CXuW1tOj.js +0 -209
|
@@ -1,27 +1,46 @@
|
|
|
1
|
-
import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
1
|
+
import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
4
|
+
import { createServer, } from "node:http";
|
|
5
|
+
import { basename, extname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Ajv2020Module from "ajv/dist/2020.js";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import { parse, stringify } from "yaml";
|
|
10
|
+
import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, visualBrowserInputType, } from "./protocol.js";
|
|
11
|
+
import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from "./session-store.js";
|
|
12
|
+
import { loadProjection, evaluateProjection, } from "../../projection.js";
|
|
13
|
+
import { compileWorkspaceWithProfileContext, } from "../../compiler.js";
|
|
14
|
+
import { applyOperations } from "../../apply-command.js";
|
|
15
|
+
import { projectGraphForCanvas } from "../../graph-projection.js";
|
|
16
|
+
import { kindLabelOf } from "../../kind-label.js";
|
|
17
|
+
import { loadWorkspaceManifest, } from "../../workspace.js";
|
|
18
|
+
import visualLayoutSchema from "../../../schema/yarramate-visual-layout.schema.json" with { type: "json" };
|
|
19
|
+
import visualProjectionSchema from "../../../schema/yarramate-projection.schema.json" with { type: "json" };
|
|
20
|
+
// The layout sidecar is adapter-owned presentation state (ADR 0023) that
|
|
21
|
+
// protocol.ts's validators never touch — the browser's own `layout.save`
|
|
22
|
+
// payload is already schema-validated by `parseVisualBrowserInput` before it
|
|
23
|
+
// reaches this module — so this file compiles its own single-purpose
|
|
24
|
+
// validator for the sidecar files it reads directly off disk.
|
|
25
|
+
const Ajv2020 = Ajv2020Module.default;
|
|
26
|
+
const layoutAjv = new Ajv2020({ allErrors: true });
|
|
27
|
+
layoutAjv.addSchema(visualProjectionSchema);
|
|
28
|
+
const validateVisualLayout = layoutAjv.compile(visualLayoutSchema);
|
|
10
29
|
/**
|
|
11
30
|
* Document name reported by diagnostics the server itself raises — a refused
|
|
12
31
|
* frame, a frozen queue, or a transport violation that no protocol document
|
|
13
32
|
* owns.
|
|
14
33
|
*/
|
|
15
|
-
export const VISUAL_SERVER_DOCUMENT =
|
|
34
|
+
export const VISUAL_SERVER_DOCUMENT = "visual-session-server";
|
|
16
35
|
/**
|
|
17
36
|
* Returned on every response. The policy admits no external origin at all, so
|
|
18
37
|
* neither a compromised model nor a hostile chat message can reach the network,
|
|
19
38
|
* and the page can be neither framed nor used as a base for relative fetches.
|
|
20
39
|
*/
|
|
21
40
|
export const VISUAL_BROWSER_HEADERS = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
"Referrer-Policy": "no-referrer",
|
|
42
|
+
"X-Content-Type-Options": "nosniff",
|
|
43
|
+
"Cache-Control": "no-store",
|
|
25
44
|
};
|
|
26
45
|
/**
|
|
27
46
|
* This session's content policy. Every source stays `'self'`; the one addition
|
|
@@ -42,36 +61,36 @@ export const VISUAL_SERVER_LIMITS = {
|
|
|
42
61
|
/** Browser sockets one session serves at once. */
|
|
43
62
|
browserConnections: 8,
|
|
44
63
|
};
|
|
45
|
-
export const VISUAL_SOCKET_PATH =
|
|
46
|
-
const COOKIE_NAME =
|
|
64
|
+
export const VISUAL_SOCKET_PATH = "/socket";
|
|
65
|
+
const COOKIE_NAME = "ym_visual";
|
|
47
66
|
/**
|
|
48
67
|
* Assets are addressed by a single flat, hashed file name. Rejecting every
|
|
49
68
|
* separator here means a traversal never reaches path resolution at all.
|
|
50
69
|
*/
|
|
51
70
|
const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
52
71
|
const CONTENT_TYPES = {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
".css": "text/css; charset=utf-8",
|
|
73
|
+
".html": "text/html; charset=utf-8",
|
|
74
|
+
".js": "text/javascript; charset=utf-8",
|
|
75
|
+
".json": "application/json; charset=utf-8",
|
|
76
|
+
".png": "image/png",
|
|
77
|
+
".svg": "image/svg+xml",
|
|
78
|
+
".woff2": "font/woff2",
|
|
60
79
|
};
|
|
61
80
|
/** Response types that finish the agent turn an event opened. */
|
|
62
81
|
const TURN_COMPLETING = {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
"chat.response": true,
|
|
83
|
+
"choice.present": true,
|
|
84
|
+
"handoff.complete": true,
|
|
66
85
|
diagnostic: true,
|
|
67
86
|
};
|
|
68
87
|
const TERMINATION_REASONS = {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
"user-ended": true,
|
|
89
|
+
"child-failed": true,
|
|
90
|
+
"browser-timeout": true,
|
|
91
|
+
"main-cancelled": true,
|
|
92
|
+
"server-failed": true,
|
|
93
|
+
"compiler-failed": true,
|
|
75
94
|
};
|
|
76
95
|
/**
|
|
77
96
|
* Which freezes are a runtime failing rather than a session closing.
|
|
@@ -82,44 +101,34 @@ const TERMINATION_REASONS = {
|
|
|
82
101
|
* the reconnect grace's business, so neither ends a session a second time.
|
|
83
102
|
*/
|
|
84
103
|
const LIMIT_FREEZE = {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
"message-bytes": true,
|
|
105
|
+
"model-bytes": true,
|
|
106
|
+
"transcript-bytes": true,
|
|
107
|
+
"pending-events": true,
|
|
108
|
+
"browser-disconnected": false,
|
|
109
|
+
"terminal-event": false,
|
|
110
|
+
// A landed changeset that leaves the workspace unable to recompile is our
|
|
111
|
+
// own bug, not the reviewer's: the runtime is why the conversation stopped,
|
|
112
|
+
// same as the byte and queue ceilings above.
|
|
113
|
+
"recompile-failed": true,
|
|
91
114
|
};
|
|
92
115
|
/** Built browser application, beside the compiled adapter in `dist`. */
|
|
93
|
-
const DEFAULT_ASSET_ROOT = fileURLToPath(new 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
|
-
});
|
|
116
|
+
const DEFAULT_ASSET_ROOT = fileURLToPath(new URL("../../visual-app/", import.meta.url));
|
|
106
117
|
/**
|
|
107
118
|
* Takes one session terminal, whatever caused it: a reviewer's End, a child
|
|
108
119
|
* that failed, a browser that never came back, a cancelling main agent, or a
|
|
109
120
|
* runtime shutting itself down.
|
|
110
121
|
*
|
|
111
|
-
* The order is the invariant. Input freezes before anything is read;
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* removes the session directory.
|
|
122
|
+
* The order is the invariant. Input freezes before anything is read; work
|
|
123
|
+
* already admitted is still allowed to finish; exactly one terminal event is
|
|
124
|
+
* journaled; and the handoff is recovered without deleting anything, so
|
|
125
|
+
* `stop` — and only `stop` — is what removes the session directory.
|
|
116
126
|
*/
|
|
117
127
|
export const terminateVisualSession = (session, reason) => {
|
|
118
128
|
session.terminating ??= (async () => {
|
|
119
|
-
if (session.lifecycle !==
|
|
120
|
-
session.lifecycle =
|
|
121
|
-
session.freeze(
|
|
122
|
-
session.compilerAbort.abort();
|
|
129
|
+
if (session.lifecycle !== "stopped")
|
|
130
|
+
session.lifecycle = "draining";
|
|
131
|
+
session.freeze("terminal-event");
|
|
123
132
|
await session.quiesce();
|
|
124
133
|
// A terminal event that cannot be journaled must not be why a session
|
|
125
134
|
// stays up: `runVisualStart` waits on this to return, so a transition that
|
|
@@ -143,8 +152,8 @@ const serverError = (code, message) => new Error(`${code}: ${message}`);
|
|
|
143
152
|
* `/`, because these diagnostics are published in `visual-diagnostic-result/v1`
|
|
144
153
|
* documents and read back by the one-shot agent clients.
|
|
145
154
|
*/
|
|
146
|
-
const serverDiagnostic = (code, message, pointer =
|
|
147
|
-
severity:
|
|
155
|
+
const serverDiagnostic = (code, message, pointer = "/") => ({
|
|
156
|
+
severity: "error",
|
|
148
157
|
code,
|
|
149
158
|
message,
|
|
150
159
|
path: VISUAL_SERVER_DOCUMENT,
|
|
@@ -160,14 +169,14 @@ const serverDiagnostic = (code, message, pointer = '/') => ({
|
|
|
160
169
|
const secretEquals = (expected, presented) => {
|
|
161
170
|
if (presented === undefined)
|
|
162
171
|
return false;
|
|
163
|
-
const digest = (value) => createHash(
|
|
172
|
+
const digest = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
164
173
|
return timingSafeEqual(digest(expected), digest(presented));
|
|
165
174
|
};
|
|
166
175
|
const cookieValue = (header, name) => {
|
|
167
176
|
if (header === undefined)
|
|
168
177
|
return undefined;
|
|
169
|
-
for (const part of header.split(
|
|
170
|
-
const split = part.indexOf(
|
|
178
|
+
for (const part of header.split(";")) {
|
|
179
|
+
const split = part.indexOf("=");
|
|
171
180
|
if (split < 0)
|
|
172
181
|
continue;
|
|
173
182
|
if (part.slice(0, split).trim() === name) {
|
|
@@ -186,12 +195,12 @@ const isJsonContent = (header) => header !== undefined && /^application\/json\s*
|
|
|
186
195
|
* instead of a severed connection.
|
|
187
196
|
*/
|
|
188
197
|
const readJsonBody = async (incoming, limit) => {
|
|
189
|
-
if (!isJsonContent(incoming.headers[
|
|
198
|
+
if (!isJsonContent(incoming.headers["content-type"])) {
|
|
190
199
|
incoming.resume();
|
|
191
200
|
return {
|
|
192
201
|
ok: false,
|
|
193
202
|
status: 415,
|
|
194
|
-
message:
|
|
203
|
+
message: "Request body must be application/json",
|
|
195
204
|
};
|
|
196
205
|
}
|
|
197
206
|
const chunks = [];
|
|
@@ -216,11 +225,11 @@ const readJsonBody = async (incoming, limit) => {
|
|
|
216
225
|
try {
|
|
217
226
|
return {
|
|
218
227
|
ok: true,
|
|
219
|
-
value: JSON.parse(Buffer.concat(chunks).toString(
|
|
228
|
+
value: JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
|
220
229
|
};
|
|
221
230
|
}
|
|
222
231
|
catch {
|
|
223
|
-
return { ok: false, status: 400, message:
|
|
232
|
+
return { ok: false, status: 400, message: "Request body is not JSON" };
|
|
224
233
|
}
|
|
225
234
|
};
|
|
226
235
|
/**
|
|
@@ -229,16 +238,47 @@ const readJsonBody = async (incoming, limit) => {
|
|
|
229
238
|
*/
|
|
230
239
|
const eventFrom = (input, envelope) => {
|
|
231
240
|
switch (input.type) {
|
|
232
|
-
case
|
|
241
|
+
case "chat.message":
|
|
242
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
243
|
+
case "choice.selected":
|
|
233
244
|
return { ...envelope, type: input.type, payload: input.payload };
|
|
234
|
-
case
|
|
245
|
+
case "view.navigate":
|
|
235
246
|
return { ...envelope, type: input.type, payload: input.payload };
|
|
236
|
-
case
|
|
247
|
+
case "session.end":
|
|
237
248
|
return { ...envelope, type: input.type, payload: input.payload };
|
|
238
|
-
case
|
|
249
|
+
case "filter.query":
|
|
250
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
251
|
+
case "view.save":
|
|
252
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
253
|
+
case "changeset.commit":
|
|
254
|
+
return { ...envelope, type: input.type, payload: input.payload };
|
|
255
|
+
case "layout.save":
|
|
239
256
|
return { ...envelope, type: input.type, payload: input.payload };
|
|
240
257
|
}
|
|
241
258
|
};
|
|
259
|
+
/**
|
|
260
|
+
* Turns a view title into a schema-valid projection id: lowercase,
|
|
261
|
+
* hyphen-separated, letter-led. A title with no letters or digits degrades
|
|
262
|
+
* to "view" rather than producing an id the schema would reject.
|
|
263
|
+
*/
|
|
264
|
+
const slugify = (title) => {
|
|
265
|
+
const cleaned = title
|
|
266
|
+
.toLowerCase()
|
|
267
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
268
|
+
.replace(/^-+|-+$/g, "");
|
|
269
|
+
if (cleaned.length === 0)
|
|
270
|
+
return "view";
|
|
271
|
+
return /^[a-z]/.test(cleaned) ? cleaned : `view-${cleaned}`;
|
|
272
|
+
};
|
|
273
|
+
/** Appends a numeric suffix only when the base id collides with a known view. */
|
|
274
|
+
const uniqueViewId = (base, taken) => {
|
|
275
|
+
if (!taken.has(base))
|
|
276
|
+
return base;
|
|
277
|
+
let suffix = 2;
|
|
278
|
+
while (taken.has(`${base}-${suffix}`))
|
|
279
|
+
suffix += 1;
|
|
280
|
+
return `${base}-${suffix}`;
|
|
281
|
+
};
|
|
242
282
|
/**
|
|
243
283
|
* Serves one authenticated visual session over loopback. The handle owns the
|
|
244
284
|
* session directory it created: `stop` recovers the handoff before deleting it,
|
|
@@ -253,9 +293,9 @@ export const startVisualServer = async (options) => {
|
|
|
253
293
|
const drawHex = (size) => {
|
|
254
294
|
const drawn = randomBytes(size);
|
|
255
295
|
if (drawn.byteLength < size) {
|
|
256
|
-
throw serverError(
|
|
296
|
+
throw serverError("YMVS301", `Random source returned ${drawn.byteLength} bytes for a ${size}-byte draw`);
|
|
257
297
|
}
|
|
258
|
-
return drawn.subarray(0, size).toString(
|
|
298
|
+
return drawn.subarray(0, size).toString("hex");
|
|
259
299
|
};
|
|
260
300
|
const stamp = () => now().toISOString();
|
|
261
301
|
const session = await createVisualSession(request, {
|
|
@@ -268,47 +308,183 @@ export const startVisualServer = async (options) => {
|
|
|
268
308
|
const cookieSigningKey = drawHex(32);
|
|
269
309
|
// The browser stores only a derived bearer value; the signing key stays in
|
|
270
310
|
// this server process and disappears with the session.
|
|
271
|
-
const browserAuthenticator = createHmac(
|
|
311
|
+
const browserAuthenticator = createHmac("sha256", cookieSigningKey)
|
|
272
312
|
.update(sessionId)
|
|
273
|
-
.digest(
|
|
313
|
+
.digest("hex");
|
|
274
314
|
// Not a credential: it authorises inline style for this page, nothing else.
|
|
275
315
|
const styleNonce = drawHex(16);
|
|
276
316
|
const browserHeaders = {
|
|
277
317
|
...VISUAL_BROWSER_HEADERS,
|
|
278
|
-
|
|
318
|
+
"Content-Security-Policy": visualContentSecurityPolicy(styleNonce),
|
|
279
319
|
};
|
|
280
|
-
/** Cancels every compile this session runs, including one in flight. */
|
|
281
|
-
const compilerAbort = new AbortController();
|
|
282
320
|
/** Set once the loopback listener exists; a failed start has to close it. */
|
|
283
321
|
let closeListener;
|
|
284
322
|
// Past this point a session directory exists on disk; a failed start must not
|
|
285
323
|
// leave one behind for the next run to prune, nor a listener for the process
|
|
286
324
|
// to outlive.
|
|
287
325
|
const abandon = async (cause) => {
|
|
288
|
-
compilerAbort.abort();
|
|
289
326
|
await closeListener?.().catch(() => undefined);
|
|
290
327
|
await removeVisualSession(paths).catch(() => undefined);
|
|
291
328
|
throw cause;
|
|
292
329
|
};
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
330
|
+
const manifestPath = resolve(options.cwd, ".yarramate/workspace.yaml");
|
|
331
|
+
// A session with no resolvable workspace manifest has nothing to compile,
|
|
332
|
+
// filter, or land a `changeset.commit` against, so a missing, unreadable, or
|
|
333
|
+
// invalid manifest refuses the session outright rather than starting one
|
|
334
|
+
// nothing can act on.
|
|
335
|
+
const resolvedWorkspace = await (async () => {
|
|
336
|
+
try {
|
|
337
|
+
const manifestSource = readFileSync(manifestPath, "utf8");
|
|
338
|
+
const loaded = loadWorkspaceManifest({ path: manifestPath, source: manifestSource }, options.cwd);
|
|
339
|
+
if (loaded.ok)
|
|
340
|
+
return loaded.workspace;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Falls through to the same refusal as an invalid manifest below.
|
|
344
|
+
}
|
|
345
|
+
return abandon(serverError("YMVS132", `Session requires a resolvable workspace manifest at ${manifestPath}`));
|
|
346
|
+
})();
|
|
347
|
+
// Saved views join this list as they are written (see `view.save`), because
|
|
348
|
+
// a reconnecting browser is handed it in its snapshot and `layout.save`
|
|
349
|
+
// checks projection ids against it.
|
|
350
|
+
const views = resolvedWorkspace.projections.flatMap((projectionPath) => {
|
|
351
|
+
try {
|
|
352
|
+
const projectionSource = readFileSync(resolve(options.cwd, projectionPath), "utf8");
|
|
353
|
+
const loaded = loadProjection({
|
|
354
|
+
path: projectionPath,
|
|
355
|
+
source: projectionSource,
|
|
356
|
+
});
|
|
357
|
+
if (!loaded.ok)
|
|
358
|
+
return [];
|
|
359
|
+
const { projection } = loaded;
|
|
360
|
+
return [
|
|
361
|
+
{
|
|
362
|
+
id: projection.id,
|
|
363
|
+
title: projection.presentation?.title ?? projection.id,
|
|
364
|
+
description: projection.presentation?.description ?? "",
|
|
365
|
+
query: projection.query,
|
|
366
|
+
presentation: projection.presentation,
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Skipped projection: session continues with remaining views.
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
// Drag positions are adapter-owned presentation state (ADR 0023): never
|
|
376
|
+
// validated by Core. An invalid or unreadable sidecar is skipped exactly
|
|
377
|
+
// like a broken saved view above — presentation state must never fail a
|
|
378
|
+
// session.
|
|
379
|
+
const layoutDir = resolve(options.cwd, ".yarramate/visual-layout");
|
|
380
|
+
const layouts = (() => {
|
|
381
|
+
const built = {};
|
|
382
|
+
let entries;
|
|
383
|
+
try {
|
|
384
|
+
entries = readdirSync(layoutDir);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return built;
|
|
388
|
+
}
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
if (extname(entry) !== ".yaml" && extname(entry) !== ".yml")
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
const source = readFileSync(join(layoutDir, entry), "utf8");
|
|
394
|
+
const parsed = parse(source);
|
|
395
|
+
if (!validateVisualLayout(parsed))
|
|
396
|
+
continue;
|
|
397
|
+
const sidecar = parsed;
|
|
398
|
+
built[sidecar.projectionId] = sidecar.positions;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Skipped sidecar: presentation state must never fail a session.
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return built;
|
|
405
|
+
})();
|
|
406
|
+
// `request.initialModel.graph` is the caller's compile (`buildVisualModelGraph`,
|
|
407
|
+
// before invoking `yarramate-visual start`) and is only the fallback below:
|
|
408
|
+
// `recompileWorkspace` immediately below is the one path that actually fills
|
|
409
|
+
// `documents`/`vocabulary`, reused for both the initial render and every
|
|
410
|
+
// later `changeset.commit`.
|
|
411
|
+
let rendered = {
|
|
412
|
+
authority: request.initialModel.authority,
|
|
413
|
+
initialView: request.initialModel.initialView,
|
|
414
|
+
graph: request.initialModel.graph,
|
|
415
|
+
documents: [],
|
|
416
|
+
vocabulary: { conceptKinds: [], relationshipKinds: [] },
|
|
417
|
+
layouts,
|
|
418
|
+
};
|
|
305
419
|
const capabilities = {
|
|
306
420
|
chat: request.chatEnabled,
|
|
307
421
|
choices: request.chatEnabled,
|
|
308
422
|
navigation: true,
|
|
309
|
-
modelReplacement: true,
|
|
310
423
|
transcript: true,
|
|
311
424
|
};
|
|
425
|
+
let compiledWorkspace;
|
|
426
|
+
/**
|
|
427
|
+
* `filter.query` needs a `SemanticGraph`, not the `CanvasGraph` the rendered
|
|
428
|
+
* model carries — that graph is compiled here, from the same
|
|
429
|
+
* `resolvedWorkspace` profiles and documents `ask-command.ts` compiles from,
|
|
430
|
+
* and reused for every query the session receives. A session whose sources
|
|
431
|
+
* fail to compile has nothing to filter against: `filterMatchedIds`
|
|
432
|
+
* degrades to an empty match set rather than failing the session.
|
|
433
|
+
*
|
|
434
|
+
* This is the single compile path: called once for the initial `rendered`
|
|
435
|
+
* above and again after every landed `changeset.commit`, it re-reads
|
|
436
|
+
* sources from disk, reassigns `compiledWorkspace`, rebuilds the
|
|
437
|
+
* `CanvasGraph`, and reassigns `rendered` — carrying forward `authority`,
|
|
438
|
+
* `initialView`, and `layouts`, which a document recompile never touches.
|
|
439
|
+
* Returns whether it compiled, so a post-write failure can freeze the
|
|
440
|
+
* session instead of serving a stale graph.
|
|
441
|
+
*/
|
|
442
|
+
const recompileWorkspace = () => {
|
|
443
|
+
try {
|
|
444
|
+
const sources = [
|
|
445
|
+
...resolvedWorkspace.profiles,
|
|
446
|
+
...resolvedWorkspace.documents,
|
|
447
|
+
].map((path) => ({
|
|
448
|
+
path,
|
|
449
|
+
source: readFileSync(resolve(options.cwd, path), "utf8"),
|
|
450
|
+
}));
|
|
451
|
+
const compiled = compileWorkspaceWithProfileContext(sources);
|
|
452
|
+
if (!compiled.ok) {
|
|
453
|
+
compiledWorkspace = undefined;
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
compiledWorkspace = {
|
|
457
|
+
graph: compiled.graph,
|
|
458
|
+
profileContext: compiled.profileContext,
|
|
459
|
+
};
|
|
460
|
+
const conceptKinds = [
|
|
461
|
+
...compiled.profileContext.conceptKindLineages.keys(),
|
|
462
|
+
].map((id) => ({ id, label: kindLabelOf(id) }));
|
|
463
|
+
const relationshipKinds = [
|
|
464
|
+
...compiled.profileContext.relationshipKindLineages.keys(),
|
|
465
|
+
].map((id) => ({ id, label: kindLabelOf(id) }));
|
|
466
|
+
rendered = {
|
|
467
|
+
authority: rendered.authority,
|
|
468
|
+
initialView: rendered.initialView,
|
|
469
|
+
graph: projectGraphForCanvas(compiled.graph, compiled.profileContext),
|
|
470
|
+
documents: resolvedWorkspace.documents,
|
|
471
|
+
vocabulary: { conceptKinds, relationshipKinds },
|
|
472
|
+
layouts: rendered.layouts,
|
|
473
|
+
};
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
compiledWorkspace = undefined;
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
recompileWorkspace();
|
|
482
|
+
const filterMatchedIds = (query) => {
|
|
483
|
+
if (compiledWorkspace === undefined)
|
|
484
|
+
return [];
|
|
485
|
+
const result = evaluateProjection(compiledWorkspace.graph, { format: "yarramate/projection/v1", id: "ad-hoc", version: "0", query }, compiledWorkspace.profileContext);
|
|
486
|
+
return result.subjects.map(({ id }) => id);
|
|
487
|
+
};
|
|
312
488
|
let listening = false;
|
|
313
489
|
let bootstrapSpent = false;
|
|
314
490
|
let agentAttached = false;
|
|
@@ -381,7 +557,7 @@ export const startVisualServer = async (options) => {
|
|
|
381
557
|
// `server-failed` is the truth of it: not the reviewer, not the child,
|
|
382
558
|
// but the runtime is why the conversation stopped. Recovery reads that
|
|
383
559
|
// back as a failed handoff carrying everything the session did accept.
|
|
384
|
-
await terminateVisualSession(active,
|
|
560
|
+
await terminateVisualSession(active, "server-failed");
|
|
385
561
|
}
|
|
386
562
|
finally {
|
|
387
563
|
// Whatever the journal managed, the child must not be left holding a
|
|
@@ -407,8 +583,7 @@ export const startVisualServer = async (options) => {
|
|
|
407
583
|
*/
|
|
408
584
|
const active = {
|
|
409
585
|
paths,
|
|
410
|
-
lifecycle:
|
|
411
|
-
compilerAbort,
|
|
586
|
+
lifecycle: "starting",
|
|
412
587
|
freeze,
|
|
413
588
|
quiesce: drainAdmission,
|
|
414
589
|
terminalEvent: { now, randomBytes },
|
|
@@ -429,13 +604,13 @@ export const startVisualServer = async (options) => {
|
|
|
429
604
|
*/
|
|
430
605
|
let cancelGrace;
|
|
431
606
|
const armGrace = () => {
|
|
432
|
-
if (cancelGrace !== undefined || active.lifecycle !==
|
|
607
|
+
if (cancelGrace !== undefined || active.lifecycle !== "running")
|
|
433
608
|
return;
|
|
434
609
|
cancelGrace = schedule(async () => {
|
|
435
610
|
cancelGrace = undefined;
|
|
436
|
-
if (connections.size > 0 || active.lifecycle !==
|
|
611
|
+
if (connections.size > 0 || active.lifecycle !== "running")
|
|
437
612
|
return;
|
|
438
|
-
await stop(
|
|
613
|
+
await stop("browser-timeout");
|
|
439
614
|
}, VISUAL_LIMITS.reconnectMs);
|
|
440
615
|
};
|
|
441
616
|
const disarmGrace = () => {
|
|
@@ -446,20 +621,20 @@ export const startVisualServer = async (options) => {
|
|
|
446
621
|
const recordEvent = (event) => {
|
|
447
622
|
// A session on its way out waits on nobody, and asking past the question
|
|
448
623
|
// is how a reviewer declines to answer it.
|
|
449
|
-
if (event.type ===
|
|
624
|
+
if (event.type === "chat.message" || event.type === "session.end") {
|
|
450
625
|
pendingChoice = null;
|
|
451
626
|
}
|
|
452
|
-
if (event.type ===
|
|
627
|
+
if (event.type === "session.end")
|
|
453
628
|
choicesClosed = true;
|
|
454
|
-
if (event.type ===
|
|
629
|
+
if (event.type === "chat.message") {
|
|
455
630
|
transcript.push({
|
|
456
631
|
id: event.eventId,
|
|
457
|
-
speaker:
|
|
632
|
+
speaker: "reviewer",
|
|
458
633
|
text: event.payload.text,
|
|
459
634
|
});
|
|
460
635
|
return;
|
|
461
636
|
}
|
|
462
|
-
if (event.type !==
|
|
637
|
+
if (event.type !== "choice.selected")
|
|
463
638
|
return;
|
|
464
639
|
if (pendingChoice?.choiceId === event.payload.choiceId)
|
|
465
640
|
pendingChoice = null;
|
|
@@ -470,26 +645,26 @@ export const startVisualServer = async (options) => {
|
|
|
470
645
|
?.get(event.payload.optionId);
|
|
471
646
|
transcript.push({
|
|
472
647
|
id: event.eventId,
|
|
473
|
-
speaker:
|
|
648
|
+
speaker: "reviewer",
|
|
474
649
|
text: label ?? event.payload.optionId,
|
|
475
650
|
});
|
|
476
651
|
};
|
|
477
652
|
/** An accepted agent response, as the line the reviewer sees. */
|
|
478
653
|
const recordResponse = (response) => {
|
|
479
|
-
if (response.type ===
|
|
654
|
+
if (response.type === "choice.present") {
|
|
480
655
|
if (!choicesClosed)
|
|
481
656
|
pendingChoice = response.payload;
|
|
482
657
|
choiceLabels.set(response.payload.choiceId, new Map(response.payload.options.map((option) => [option.id, option.label])));
|
|
483
658
|
return;
|
|
484
659
|
}
|
|
485
|
-
const text = response.type ===
|
|
660
|
+
const text = response.type === "chat.response"
|
|
486
661
|
? response.payload.text
|
|
487
|
-
: response.type ===
|
|
662
|
+
: response.type === "handoff.complete"
|
|
488
663
|
? response.payload.summary
|
|
489
664
|
: undefined;
|
|
490
665
|
if (text === undefined)
|
|
491
666
|
return;
|
|
492
|
-
transcript.push({ id: response.responseId, speaker:
|
|
667
|
+
transcript.push({ id: response.responseId, speaker: "agent", text });
|
|
493
668
|
};
|
|
494
669
|
const snapshot = () => ({
|
|
495
670
|
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
@@ -502,6 +677,7 @@ export const startVisualServer = async (options) => {
|
|
|
502
677
|
webSocketUrl,
|
|
503
678
|
model: rendered,
|
|
504
679
|
transcript: [...transcript],
|
|
680
|
+
views,
|
|
505
681
|
agentTurnOpen: openTurn(),
|
|
506
682
|
pendingChoice,
|
|
507
683
|
styleNonce,
|
|
@@ -510,11 +686,11 @@ export const startVisualServer = async (options) => {
|
|
|
510
686
|
});
|
|
511
687
|
const status = () => {
|
|
512
688
|
const value = {
|
|
513
|
-
format:
|
|
689
|
+
format: "yarramate/visual-status/v1",
|
|
514
690
|
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
515
691
|
sessionId,
|
|
516
692
|
lifecycle: active.lifecycle,
|
|
517
|
-
alreadyStopped: active.lifecycle ===
|
|
693
|
+
alreadyStopped: active.lifecycle === "stopped",
|
|
518
694
|
server: { listening, origin },
|
|
519
695
|
browser: {
|
|
520
696
|
connected: connections.size > 0,
|
|
@@ -546,7 +722,7 @@ export const startVisualServer = async (options) => {
|
|
|
546
722
|
// untrusted input: the agent must never have to parse a broken report.
|
|
547
723
|
const validated = parseVisualStatus(value);
|
|
548
724
|
if (!validated.ok) {
|
|
549
|
-
throw serverError(validated.diagnostics[0]?.code ??
|
|
725
|
+
throw serverError(validated.diagnostics[0]?.code ?? "YMVS108", `Session status is invalid: ${validated.diagnostics[0]?.message ?? "unknown violation"}`);
|
|
550
726
|
}
|
|
551
727
|
return value;
|
|
552
728
|
};
|
|
@@ -602,22 +778,22 @@ export const startVisualServer = async (options) => {
|
|
|
602
778
|
}
|
|
603
779
|
};
|
|
604
780
|
const httpServer = createServer();
|
|
605
|
-
httpServer.on(
|
|
781
|
+
httpServer.on("connection", (socket) => {
|
|
606
782
|
httpSockets.add(socket);
|
|
607
783
|
unclassifiedHttpSockets.add(socket);
|
|
608
|
-
socket.on(
|
|
784
|
+
socket.on("close", () => {
|
|
609
785
|
httpSockets.delete(socket);
|
|
610
786
|
unclassifiedHttpSockets.delete(socket);
|
|
611
787
|
stopResponseSockets.delete(socket);
|
|
612
788
|
});
|
|
613
789
|
});
|
|
614
|
-
httpServer.on(
|
|
790
|
+
httpServer.on("clientError", (_error, socket) => socket.destroy());
|
|
615
791
|
const bound = Promise.withResolvers();
|
|
616
|
-
httpServer.once(
|
|
792
|
+
httpServer.once("error", bound.reject);
|
|
617
793
|
// Loopback only, on a kernel-assigned port: the session is never reachable
|
|
618
794
|
// from another host, and never collides with a well-known service.
|
|
619
|
-
httpServer.listen(0,
|
|
620
|
-
httpServer.removeListener(
|
|
795
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
796
|
+
httpServer.removeListener("error", bound.reject);
|
|
621
797
|
bound.resolve();
|
|
622
798
|
});
|
|
623
799
|
await bound.promise.catch(abandon);
|
|
@@ -645,23 +821,40 @@ export const startVisualServer = async (options) => {
|
|
|
645
821
|
const respond = (server, code, body, contentType) => {
|
|
646
822
|
server.writeHead(code, {
|
|
647
823
|
...browserHeaders,
|
|
648
|
-
|
|
649
|
-
|
|
824
|
+
"Content-Type": contentType,
|
|
825
|
+
"Content-Length": Buffer.byteLength(body),
|
|
650
826
|
// A draining session must not leave a pooled connection behind that
|
|
651
827
|
// outlives its listener.
|
|
652
|
-
...(active.lifecycle ===
|
|
828
|
+
...(active.lifecycle === "running" ? {} : { Connection: "close" }),
|
|
653
829
|
});
|
|
654
830
|
server.end(body);
|
|
655
831
|
};
|
|
656
|
-
const respondJson = (server, code, value) => respond(server, code, JSON.stringify(value),
|
|
657
|
-
const refuse = (server, code, message) => respond(server, code, `${message}\n`,
|
|
832
|
+
const respondJson = (server, code, value) => respond(server, code, JSON.stringify(value), "application/json; charset=utf-8");
|
|
833
|
+
const refuse = (server, code, message) => respond(server, code, `${message}\n`, "text/plain; charset=utf-8");
|
|
658
834
|
// ------------------------------------------------------------ browser input
|
|
835
|
+
/**
|
|
836
|
+
* What a raw frame claims to be, for a refusal that never got a document.
|
|
837
|
+
*
|
|
838
|
+
* Admitting can fail before anything parsed, and the browser is still
|
|
839
|
+
* holding a control open for whatever it sent. Reading the claim back off
|
|
840
|
+
* the bytes costs a parse only on the path where admitting already failed.
|
|
841
|
+
*/
|
|
842
|
+
const claimedBrowserInput = (raw, binary) => {
|
|
843
|
+
if (binary)
|
|
844
|
+
return undefined;
|
|
845
|
+
try {
|
|
846
|
+
return visualBrowserInputType(JSON.parse(raw.toString()));
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
};
|
|
659
852
|
const admitBrowserInput = async (socket, raw, binary) => {
|
|
660
853
|
if (binary) {
|
|
661
854
|
sendFrame(socket, {
|
|
662
|
-
kind:
|
|
855
|
+
kind: "rejected",
|
|
663
856
|
diagnostics: [
|
|
664
|
-
serverDiagnostic(
|
|
857
|
+
serverDiagnostic("YMVS303", "Browser frames must be UTF-8 JSON text"),
|
|
665
858
|
],
|
|
666
859
|
});
|
|
667
860
|
return;
|
|
@@ -672,53 +865,64 @@ export const startVisualServer = async (options) => {
|
|
|
672
865
|
}
|
|
673
866
|
catch {
|
|
674
867
|
sendFrame(socket, {
|
|
675
|
-
kind:
|
|
868
|
+
kind: "rejected",
|
|
676
869
|
diagnostics: [
|
|
677
|
-
serverDiagnostic(
|
|
870
|
+
serverDiagnostic("YMVS109", "Browser frame is not a JSON document"),
|
|
678
871
|
],
|
|
679
872
|
});
|
|
680
873
|
return;
|
|
681
874
|
}
|
|
875
|
+
// What the frame says it is, before anything has agreed that it is well
|
|
876
|
+
// formed. A browser holding a control open for this input is told which
|
|
877
|
+
// one died even when the document that named it is the thing at fault.
|
|
878
|
+
const refused = visualBrowserInputType(parsed);
|
|
682
879
|
const input = parseVisualBrowserInput(parsed);
|
|
683
880
|
if (!input.ok) {
|
|
684
|
-
sendFrame(socket, {
|
|
881
|
+
sendFrame(socket, {
|
|
882
|
+
kind: "rejected",
|
|
883
|
+
...(refused === undefined ? {} : { refused }),
|
|
884
|
+
diagnostics: input.diagnostics,
|
|
885
|
+
});
|
|
685
886
|
return;
|
|
686
887
|
}
|
|
687
888
|
await admit(async () => {
|
|
688
889
|
// A frame can already be on the chain when a stop begins. Recovery must
|
|
689
890
|
// see a journal nothing is still writing to, so anything that has not
|
|
690
891
|
// been journaled yet is refused rather than raced.
|
|
691
|
-
if (active.lifecycle !==
|
|
892
|
+
if (active.lifecycle !== "running") {
|
|
692
893
|
sendFrame(socket, {
|
|
693
|
-
kind:
|
|
894
|
+
kind: "rejected",
|
|
895
|
+
refused: input.value.type,
|
|
694
896
|
diagnostics: [
|
|
695
|
-
serverDiagnostic(
|
|
897
|
+
serverDiagnostic("YMVS306", `Session is ${active.lifecycle} and no longer accepts input`),
|
|
696
898
|
],
|
|
697
899
|
});
|
|
698
900
|
return;
|
|
699
901
|
}
|
|
700
902
|
if (frozen !== undefined) {
|
|
701
903
|
sendFrame(socket, {
|
|
702
|
-
kind:
|
|
904
|
+
kind: "rejected",
|
|
905
|
+
refused: input.value.type,
|
|
703
906
|
frozen,
|
|
704
907
|
diagnostics: [
|
|
705
|
-
serverDiagnostic(
|
|
908
|
+
serverDiagnostic("YMVS304", `Session input is frozen: ${frozen}`),
|
|
706
909
|
],
|
|
707
910
|
});
|
|
708
911
|
return;
|
|
709
912
|
}
|
|
710
913
|
// A session that was started without a conversation has no chat and no
|
|
711
914
|
// choices to make; the diagram, and moving around it, is all it granted.
|
|
712
|
-
const granted = input.value.type ===
|
|
915
|
+
const granted = input.value.type === "chat.message"
|
|
713
916
|
? capabilities.chat
|
|
714
|
-
: input.value.type ===
|
|
917
|
+
: input.value.type === "choice.selected"
|
|
715
918
|
? capabilities.choices
|
|
716
919
|
: true;
|
|
717
920
|
if (!granted) {
|
|
718
921
|
sendFrame(socket, {
|
|
719
|
-
kind:
|
|
922
|
+
kind: "rejected",
|
|
923
|
+
refused: input.value.type,
|
|
720
924
|
diagnostics: [
|
|
721
|
-
serverDiagnostic(
|
|
925
|
+
serverDiagnostic("YMVS309", `Session did not grant the capability "${input.value.type}" needs`, "/type"),
|
|
722
926
|
],
|
|
723
927
|
});
|
|
724
928
|
return;
|
|
@@ -730,15 +934,16 @@ export const startVisualServer = async (options) => {
|
|
|
730
934
|
// journaled behind a sequence its sender never read.
|
|
731
935
|
if (input.value.lastAcknowledgedSequence > lastSequence) {
|
|
732
936
|
sendFrame(socket, {
|
|
733
|
-
kind:
|
|
937
|
+
kind: "rejected",
|
|
938
|
+
refused: input.value.type,
|
|
734
939
|
diagnostics: [
|
|
735
|
-
serverDiagnostic(
|
|
940
|
+
serverDiagnostic("YMVS308", `Browser acknowledged sequence ${input.value.lastAcknowledgedSequence}, past the journal's ${lastSequence}`, "/lastAcknowledgedSequence"),
|
|
736
941
|
],
|
|
737
942
|
});
|
|
738
943
|
return;
|
|
739
944
|
}
|
|
740
945
|
const event = eventFrom(input.value, {
|
|
741
|
-
format:
|
|
946
|
+
format: "yarramate/visual-event/v1",
|
|
742
947
|
sessionId,
|
|
743
948
|
sequence: lastSequence + 1,
|
|
744
949
|
eventId: drawHex(16),
|
|
@@ -746,12 +951,13 @@ export const startVisualServer = async (options) => {
|
|
|
746
951
|
});
|
|
747
952
|
const actionable = isActionableVisualEvent(event);
|
|
748
953
|
if (actionable && pending.length >= VISUAL_LIMITS.pendingEvents) {
|
|
749
|
-
freeze(
|
|
954
|
+
freeze("pending-events");
|
|
750
955
|
sendFrame(socket, {
|
|
751
|
-
kind:
|
|
956
|
+
kind: "rejected",
|
|
957
|
+
refused: input.value.type,
|
|
752
958
|
frozen,
|
|
753
959
|
diagnostics: [
|
|
754
|
-
serverDiagnostic(
|
|
960
|
+
serverDiagnostic("YMVS305", `Queue already holds the ${VISUAL_LIMITS.pendingEvents} event maximum`, "/sequence"),
|
|
755
961
|
],
|
|
756
962
|
});
|
|
757
963
|
return;
|
|
@@ -761,7 +967,8 @@ export const startVisualServer = async (options) => {
|
|
|
761
967
|
if (appended.freeze !== undefined)
|
|
762
968
|
freeze(appended.freeze);
|
|
763
969
|
sendFrame(socket, {
|
|
764
|
-
kind:
|
|
970
|
+
kind: "rejected",
|
|
971
|
+
refused: input.value.type,
|
|
765
972
|
...(frozen === undefined ? {} : { frozen }),
|
|
766
973
|
diagnostics: appended.diagnostics,
|
|
767
974
|
});
|
|
@@ -774,10 +981,168 @@ export const startVisualServer = async (options) => {
|
|
|
774
981
|
pending.push(event);
|
|
775
982
|
// A journaled end is the last input this session takes. The agent still
|
|
776
983
|
// has to see it, so the queue freezes rather than the socket closing.
|
|
777
|
-
if (event.type ===
|
|
778
|
-
freeze(
|
|
984
|
+
if (event.type === "session.end")
|
|
985
|
+
freeze("terminal-event");
|
|
986
|
+
if (event.type === "filter.query") {
|
|
987
|
+
// Filtering is a pure read of the compiled workspace: it never asks
|
|
988
|
+
// the agent anything, so it is answered here directly rather than
|
|
989
|
+
// through the pending queue a poll would drain.
|
|
990
|
+
sendFrame(socket, {
|
|
991
|
+
kind: "filter-result",
|
|
992
|
+
result: {
|
|
993
|
+
query: event.payload.query,
|
|
994
|
+
matchedIds: filterMatchedIds(event.payload.query),
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (event.type === "view.save") {
|
|
1000
|
+
// Saving a view is a pure filesystem write plus schema validation: it
|
|
1001
|
+
// never asks the agent anything, so it is answered here directly
|
|
1002
|
+
// rather than through the pending queue a poll would drain.
|
|
1003
|
+
const existingIds = new Set(views.map((view) => view.id));
|
|
1004
|
+
const id = event.payload.id ??
|
|
1005
|
+
uniqueViewId(slugify(event.payload.title), existingIds);
|
|
1006
|
+
const presentation = {
|
|
1007
|
+
...(event.payload.presentation ?? {}),
|
|
1008
|
+
title: event.payload.title,
|
|
1009
|
+
description: event.payload.description,
|
|
1010
|
+
};
|
|
1011
|
+
const candidate = {
|
|
1012
|
+
format: "yarramate/projection/v1",
|
|
1013
|
+
id,
|
|
1014
|
+
version: "1.0",
|
|
1015
|
+
query: event.payload.query,
|
|
1016
|
+
presentation,
|
|
1017
|
+
};
|
|
1018
|
+
const path = `.yarramate/projections/${id}.yaml`;
|
|
1019
|
+
const source = stringify(candidate);
|
|
1020
|
+
const loaded = loadProjection({ path, source });
|
|
1021
|
+
if (!loaded.ok) {
|
|
1022
|
+
sendFrame(socket, {
|
|
1023
|
+
kind: "view-save-result",
|
|
1024
|
+
result: { ok: false, diagnostics: loaded.diagnostics },
|
|
1025
|
+
});
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
mkdirSync(resolve(options.cwd, ".yarramate/projections"), {
|
|
1029
|
+
recursive: true,
|
|
1030
|
+
});
|
|
1031
|
+
writeFileSync(resolve(options.cwd, path), source, "utf8");
|
|
1032
|
+
// On disk is not enough: the list handed to a reconnecting browser and
|
|
1033
|
+
// checked by `layout.save` lives here, so an overwrite replaces its
|
|
1034
|
+
// summary in place and a new view joins the end.
|
|
1035
|
+
const summary = {
|
|
1036
|
+
id,
|
|
1037
|
+
title: event.payload.title,
|
|
1038
|
+
description: event.payload.description,
|
|
1039
|
+
query: event.payload.query,
|
|
1040
|
+
presentation,
|
|
1041
|
+
};
|
|
1042
|
+
const saved = views.findIndex((view) => view.id === id);
|
|
1043
|
+
if (saved === -1)
|
|
1044
|
+
views.push(summary);
|
|
1045
|
+
else
|
|
1046
|
+
views[saved] = summary;
|
|
1047
|
+
sendFrame(socket, {
|
|
1048
|
+
kind: "view-save-result",
|
|
1049
|
+
result: { ok: true, id, path },
|
|
1050
|
+
});
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (event.type === "changeset.commit") {
|
|
1054
|
+
// Landing a batch is mechanical: Core's `applyOperations` never asks
|
|
1055
|
+
// the agent anything, so it is answered here directly rather than
|
|
1056
|
+
// through the pending queue a poll would drain. This never runs
|
|
1057
|
+
// `git commit` - the user reverts a landed batch with `git revert`.
|
|
1058
|
+
const operationsSource = stringify({
|
|
1059
|
+
format: "yarramate/operations/v1",
|
|
1060
|
+
operations: event.payload.operations,
|
|
1061
|
+
});
|
|
1062
|
+
const outcome = applyOperations({ path: "changeset.yaml", source: operationsSource }, { path: manifestPath, source: readFileSync(manifestPath, "utf8") }, options.cwd);
|
|
1063
|
+
if (!outcome.ok) {
|
|
1064
|
+
// Nothing landed: forward Core's diagnostics verbatim (ADR 0062)
|
|
1065
|
+
// rather than recompiling or broadcasting anything.
|
|
1066
|
+
sendFrame(socket, {
|
|
1067
|
+
kind: "apply-result",
|
|
1068
|
+
result: { ok: false, diagnostics: outcome.diagnostics },
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
sendFrame(socket, {
|
|
1073
|
+
kind: "apply-result",
|
|
1074
|
+
result: { ok: true, result: outcome.result },
|
|
1075
|
+
});
|
|
1076
|
+
if (recompileWorkspace()) {
|
|
1077
|
+
broadcast({ kind: "model", model: rendered });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
// A post-write compile failure is a bug, not a user error: the batch
|
|
1081
|
+
// landed but produced a document Core itself can no longer parse.
|
|
1082
|
+
// The freeze alone only tells the browser input is refused, not why
|
|
1083
|
+
// the graph on screen has gone stale, so a diagnostic response is
|
|
1084
|
+
// journaled alongside it.
|
|
1085
|
+
const diagnosticResponse = {
|
|
1086
|
+
format: "yarramate/visual-response/v1",
|
|
1087
|
+
sessionId,
|
|
1088
|
+
responseId: drawHex(16),
|
|
1089
|
+
eventId: event.eventId,
|
|
1090
|
+
type: "diagnostic",
|
|
1091
|
+
timestamp: stamp(),
|
|
1092
|
+
payload: {
|
|
1093
|
+
diagnostics: [
|
|
1094
|
+
serverDiagnostic("YMVS310", "Workspace failed to recompile after a landed changeset"),
|
|
1095
|
+
],
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
const appendedResponse = await appendVisualResponse(paths, diagnosticResponse);
|
|
1099
|
+
if (appendedResponse.ok) {
|
|
1100
|
+
transcriptBytes = appendedResponse.transcriptBytes;
|
|
1101
|
+
recordResponse(diagnosticResponse);
|
|
1102
|
+
broadcast({ kind: "response", response: diagnosticResponse });
|
|
1103
|
+
}
|
|
1104
|
+
else if (appendedResponse.freeze !== undefined) {
|
|
1105
|
+
freeze(appendedResponse.freeze);
|
|
1106
|
+
}
|
|
1107
|
+
freeze("recompile-failed");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (event.type === "layout.save") {
|
|
1111
|
+
// Saving a drag position is adapter-owned presentation state
|
|
1112
|
+
// (ADR 0023): never routed through `apply`, never validated by Core,
|
|
1113
|
+
// never `git commit`ed. It never asks the agent anything, so it is
|
|
1114
|
+
// answered here directly rather than through the pending queue a
|
|
1115
|
+
// poll would drain.
|
|
1116
|
+
const { projectionId, positions } = event.payload;
|
|
1117
|
+
if (!views.some((view) => view.id === projectionId)) {
|
|
1118
|
+
sendFrame(socket, {
|
|
1119
|
+
kind: "layout-save-result",
|
|
1120
|
+
result: {
|
|
1121
|
+
ok: false,
|
|
1122
|
+
message: `"${projectionId}" is not a known view id`,
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const path = `.yarramate/visual-layout/${projectionId}.yaml`;
|
|
1128
|
+
mkdirSync(layoutDir, { recursive: true });
|
|
1129
|
+
writeFileSync(resolve(options.cwd, path), stringify({
|
|
1130
|
+
format: "yarramate/visual-layout/v1",
|
|
1131
|
+
projectionId,
|
|
1132
|
+
positions,
|
|
1133
|
+
}), "utf8");
|
|
1134
|
+
rendered = {
|
|
1135
|
+
...rendered,
|
|
1136
|
+
layouts: { ...rendered.layouts, [projectionId]: positions },
|
|
1137
|
+
};
|
|
1138
|
+
sendFrame(socket, {
|
|
1139
|
+
kind: "layout-save-result",
|
|
1140
|
+
result: { ok: true, path },
|
|
1141
|
+
});
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
779
1144
|
sendFrame(socket, {
|
|
780
|
-
kind:
|
|
1145
|
+
kind: "accepted",
|
|
781
1146
|
sequence: event.sequence,
|
|
782
1147
|
eventId: event.eventId,
|
|
783
1148
|
});
|
|
@@ -800,20 +1165,24 @@ export const startVisualServer = async (options) => {
|
|
|
800
1165
|
* the process never handles.
|
|
801
1166
|
*/
|
|
802
1167
|
const journalConnection = (connectionId, closedWith, onJournaled) => admit(async () => {
|
|
803
|
-
if (active.lifecycle !==
|
|
1168
|
+
if (active.lifecycle !== "running" || frozen !== undefined)
|
|
804
1169
|
return false;
|
|
805
1170
|
const envelope = {
|
|
806
|
-
format:
|
|
1171
|
+
format: "yarramate/visual-event/v1",
|
|
807
1172
|
sessionId,
|
|
808
1173
|
sequence: lastSequence + 1,
|
|
809
1174
|
eventId: drawHex(16),
|
|
810
1175
|
timestamp: stamp(),
|
|
811
1176
|
};
|
|
812
1177
|
const event = closedWith === undefined
|
|
813
|
-
? {
|
|
1178
|
+
? {
|
|
1179
|
+
...envelope,
|
|
1180
|
+
type: "browser.connected",
|
|
1181
|
+
payload: { connectionId },
|
|
1182
|
+
}
|
|
814
1183
|
: {
|
|
815
1184
|
...envelope,
|
|
816
|
-
type:
|
|
1185
|
+
type: "browser.disconnected",
|
|
817
1186
|
payload: { connectionId, code: closedWith },
|
|
818
1187
|
};
|
|
819
1188
|
const appended = await appendVisualEvent(paths, event);
|
|
@@ -831,16 +1200,18 @@ export const startVisualServer = async (options) => {
|
|
|
831
1200
|
const connectionId = drawHex(16);
|
|
832
1201
|
let registered = false;
|
|
833
1202
|
lastSeenAt = stamp();
|
|
834
|
-
socket.on(
|
|
1203
|
+
socket.on("message", (raw, binary) => {
|
|
835
1204
|
// Nothing awaits this handler, so a rejection here would reach the
|
|
836
1205
|
// process as an unhandled one and take the runtime down with it. The
|
|
837
1206
|
// browser is told its frame failed; the session stays up.
|
|
838
1207
|
admitBrowserInput(socket, raw, binary).catch((cause) => {
|
|
1208
|
+
const refused = claimedBrowserInput(raw, binary);
|
|
839
1209
|
try {
|
|
840
1210
|
sendFrame(socket, {
|
|
841
|
-
kind:
|
|
1211
|
+
kind: "rejected",
|
|
1212
|
+
...(refused === undefined ? {} : { refused }),
|
|
842
1213
|
diagnostics: [
|
|
843
|
-
serverDiagnostic(
|
|
1214
|
+
serverDiagnostic("YMVS307", `Admitting the browser frame failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
844
1215
|
],
|
|
845
1216
|
});
|
|
846
1217
|
}
|
|
@@ -849,17 +1220,17 @@ export const startVisualServer = async (options) => {
|
|
|
849
1220
|
}
|
|
850
1221
|
});
|
|
851
1222
|
});
|
|
852
|
-
socket.on(
|
|
1223
|
+
socket.on("error", (error) => {
|
|
853
1224
|
// ws refuses an oversized frame before buffering it, which is exactly the
|
|
854
1225
|
// byte ceiling the protocol calls a freeze.
|
|
855
|
-
const code = typeof error ===
|
|
1226
|
+
const code = typeof error === "object" && error !== null && "code" in error
|
|
856
1227
|
? error.code
|
|
857
1228
|
: undefined;
|
|
858
|
-
if (code ===
|
|
859
|
-
freeze(
|
|
1229
|
+
if (code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
|
|
1230
|
+
freeze("message-bytes");
|
|
860
1231
|
}
|
|
861
1232
|
});
|
|
862
|
-
socket.on(
|
|
1233
|
+
socket.on("close", (code) => {
|
|
863
1234
|
if (!registered)
|
|
864
1235
|
return;
|
|
865
1236
|
connections.delete(connectionId);
|
|
@@ -883,19 +1254,19 @@ export const startVisualServer = async (options) => {
|
|
|
883
1254
|
socket.close(1011);
|
|
884
1255
|
return;
|
|
885
1256
|
}
|
|
886
|
-
sendFrame(socket, { kind:
|
|
1257
|
+
sendFrame(socket, { kind: "ready", snapshot: snapshot() });
|
|
887
1258
|
});
|
|
888
1259
|
};
|
|
889
1260
|
// ------------------------------------------------------------- agent routes
|
|
890
1261
|
const answerPoll = (server, query) => {
|
|
891
|
-
const raw = query.get(
|
|
1262
|
+
const raw = query.get("after") ?? "0";
|
|
892
1263
|
const after = Number(raw);
|
|
893
1264
|
if (!Number.isSafeInteger(after) || after < 0) {
|
|
894
1265
|
refuse(server, 400, `Query "after" must be a sequence, not "${raw}"`);
|
|
895
1266
|
return;
|
|
896
1267
|
}
|
|
897
1268
|
const immediate = takeDelivery(after);
|
|
898
|
-
if (!immediate.waiting || active.lifecycle !==
|
|
1269
|
+
if (!immediate.waiting || active.lifecycle !== "running") {
|
|
899
1270
|
respondJson(server, 200, immediate);
|
|
900
1271
|
return;
|
|
901
1272
|
}
|
|
@@ -908,51 +1279,11 @@ export const startVisualServer = async (options) => {
|
|
|
908
1279
|
}, agentPollMs),
|
|
909
1280
|
};
|
|
910
1281
|
polls.add(poll);
|
|
911
|
-
server.on(
|
|
1282
|
+
server.on("close", () => {
|
|
912
1283
|
if (polls.delete(poll))
|
|
913
1284
|
clearTimeout(poll.timer);
|
|
914
1285
|
});
|
|
915
1286
|
};
|
|
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
1287
|
/**
|
|
957
1288
|
* Events a turn-completing response has already answered.
|
|
958
1289
|
*
|
|
@@ -1002,7 +1333,7 @@ export const startVisualServer = async (options) => {
|
|
|
1002
1333
|
respondJson(server, 409, {
|
|
1003
1334
|
accepted: false,
|
|
1004
1335
|
diagnostics: [
|
|
1005
|
-
serverDiagnostic(
|
|
1336
|
+
serverDiagnostic("YMVS126", `Response belongs to session "${response.sessionId}", not "${sessionId}"`, "/sessionId"),
|
|
1006
1337
|
],
|
|
1007
1338
|
});
|
|
1008
1339
|
return;
|
|
@@ -1010,30 +1341,61 @@ export const startVisualServer = async (options) => {
|
|
|
1010
1341
|
// The agent cannot hold a conversation a diagram-only session never
|
|
1011
1342
|
// offered; the browser has nowhere to show one.
|
|
1012
1343
|
if (!capabilities.chat &&
|
|
1013
|
-
(response.type ===
|
|
1344
|
+
(response.type === "chat.response" || response.type === "choice.present")) {
|
|
1014
1345
|
respondJson(server, 409, {
|
|
1015
1346
|
accepted: false,
|
|
1016
1347
|
diagnostics: [
|
|
1017
|
-
serverDiagnostic(
|
|
1348
|
+
serverDiagnostic("YMVS309", `Session did not grant the capability "${response.type}" needs`, "/type"),
|
|
1018
1349
|
],
|
|
1019
1350
|
});
|
|
1020
1351
|
return;
|
|
1021
1352
|
}
|
|
1353
|
+
// A chat filter is a query, not a match set (ADR 0090). The agent states
|
|
1354
|
+
// what to filter by; the runtime evaluates it through `filterMatchedIds`
|
|
1355
|
+
// - the same evaluator, over the same compiled graph, that a `filter.query`
|
|
1356
|
+
// event from the panel goes through - so a chat filter and a panel filter
|
|
1357
|
+
// carrying the same query can never highlight different subjects. The
|
|
1358
|
+
// resolution happens here, before the append, so the transcript records
|
|
1359
|
+
// what the browser was actually shown.
|
|
1360
|
+
let delivered = response;
|
|
1361
|
+
if (response.type === "chat.response" &&
|
|
1362
|
+
response.payload.appliedQuery !== undefined) {
|
|
1363
|
+
const applied = response.payload.appliedQuery;
|
|
1364
|
+
if (applied.matchedIds !== undefined) {
|
|
1365
|
+
respondJson(server, 400, {
|
|
1366
|
+
accepted: false,
|
|
1367
|
+
diagnostics: [
|
|
1368
|
+
serverDiagnostic("YMVS311", "A chat filter states a query and the runtime resolves the match set; send appliedQuery.query without matchedIds", "/payload/appliedQuery/matchedIds"),
|
|
1369
|
+
],
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
delivered = {
|
|
1374
|
+
...response,
|
|
1375
|
+
payload: {
|
|
1376
|
+
...response.payload,
|
|
1377
|
+
appliedQuery: {
|
|
1378
|
+
query: applied.query,
|
|
1379
|
+
matchedIds: filterMatchedIds(applied.query),
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1022
1384
|
const result = await admit(async () => {
|
|
1023
1385
|
// Same race as a browser frame: a request already on the chain must not
|
|
1024
1386
|
// append to a session recovery has begun reading.
|
|
1025
|
-
if (active.lifecycle !==
|
|
1387
|
+
if (active.lifecycle !== "running") {
|
|
1026
1388
|
return {
|
|
1027
1389
|
code: 503,
|
|
1028
1390
|
body: {
|
|
1029
1391
|
accepted: false,
|
|
1030
1392
|
diagnostics: [
|
|
1031
|
-
serverDiagnostic(
|
|
1393
|
+
serverDiagnostic("YMVS306", `Session is ${active.lifecycle} and no longer accepts responses`),
|
|
1032
1394
|
],
|
|
1033
1395
|
},
|
|
1034
1396
|
};
|
|
1035
1397
|
}
|
|
1036
|
-
const appended = await appendVisualResponse(paths,
|
|
1398
|
+
const appended = await appendVisualResponse(paths, delivered);
|
|
1037
1399
|
if (!appended.ok) {
|
|
1038
1400
|
if (appended.freeze !== undefined)
|
|
1039
1401
|
freeze(appended.freeze);
|
|
@@ -1056,21 +1418,17 @@ export const startVisualServer = async (options) => {
|
|
|
1056
1418
|
},
|
|
1057
1419
|
};
|
|
1058
1420
|
}
|
|
1059
|
-
recordResponse(
|
|
1060
|
-
markAnswered(
|
|
1061
|
-
broadcast({ kind:
|
|
1062
|
-
|
|
1063
|
-
? await replaceModel(response)
|
|
1064
|
-
: { diagnostics: [] };
|
|
1065
|
-
completeTurn(response);
|
|
1421
|
+
recordResponse(delivered);
|
|
1422
|
+
markAnswered(delivered);
|
|
1423
|
+
broadcast({ kind: "response", response: delivered });
|
|
1424
|
+
completeTurn(delivered);
|
|
1066
1425
|
return {
|
|
1067
1426
|
code: 200,
|
|
1068
1427
|
body: {
|
|
1069
1428
|
accepted: true,
|
|
1070
1429
|
duplicate: false,
|
|
1071
1430
|
lastSequence,
|
|
1072
|
-
|
|
1073
|
-
diagnostics: replaced.diagnostics,
|
|
1431
|
+
diagnostics: [],
|
|
1074
1432
|
},
|
|
1075
1433
|
};
|
|
1076
1434
|
});
|
|
@@ -1083,16 +1441,16 @@ export const startVisualServer = async (options) => {
|
|
|
1083
1441
|
refuse(server, body.status, body.message);
|
|
1084
1442
|
return;
|
|
1085
1443
|
}
|
|
1086
|
-
const asked = typeof body.value ===
|
|
1444
|
+
const asked = typeof body.value === "object" && body.value !== null
|
|
1087
1445
|
? body.value
|
|
1088
1446
|
: {};
|
|
1089
1447
|
const reason = asked.reason;
|
|
1090
|
-
if (typeof reason !==
|
|
1091
|
-
refuse(server, 400,
|
|
1448
|
+
if (typeof reason !== "string" || TERMINATION_REASONS[reason] !== true) {
|
|
1449
|
+
refuse(server, 400, "Stop requires a known termination reason");
|
|
1092
1450
|
return;
|
|
1093
1451
|
}
|
|
1094
1452
|
if (asked.includeTranscript !== undefined &&
|
|
1095
|
-
typeof asked.includeTranscript !==
|
|
1453
|
+
typeof asked.includeTranscript !== "boolean") {
|
|
1096
1454
|
refuse(server, 400, 'Stop takes a boolean "includeTranscript" or none');
|
|
1097
1455
|
return;
|
|
1098
1456
|
}
|
|
@@ -1100,58 +1458,58 @@ export const startVisualServer = async (options) => {
|
|
|
1100
1458
|
// teardown. The response that wins the stop does not get to close its
|
|
1101
1459
|
// concurrent callers before they can observe the same result.
|
|
1102
1460
|
const closed = await stop(reason, asked.includeTranscript);
|
|
1103
|
-
server.on(
|
|
1461
|
+
server.on("finish", () => responseSocket.end());
|
|
1104
1462
|
respondJson(server, 200, closed);
|
|
1105
1463
|
};
|
|
1106
1464
|
const serveAgent = async (incoming, server, pathname, query) => {
|
|
1107
1465
|
// An agent is not a browser. A request carrying an Origin came from a page,
|
|
1108
1466
|
// which must never be able to spend a leaked agent capability.
|
|
1109
1467
|
if (incoming.headers.origin !== undefined) {
|
|
1110
|
-
refuse(server, 403,
|
|
1468
|
+
refuse(server, 403, "Agent routes reject browser-originated requests");
|
|
1111
1469
|
return;
|
|
1112
1470
|
}
|
|
1113
1471
|
if (!secretEquals(session.agentToken, bearerToken(incoming.headers.authorization))) {
|
|
1114
|
-
refuse(server, 401,
|
|
1472
|
+
refuse(server, 401, "Agent capability required");
|
|
1115
1473
|
return;
|
|
1116
1474
|
}
|
|
1117
1475
|
agentAttached = true;
|
|
1118
|
-
if (pathname ===
|
|
1119
|
-
if (incoming.method !==
|
|
1120
|
-
return refuse(server, 405,
|
|
1476
|
+
if (pathname === "/api/agent/events") {
|
|
1477
|
+
if (incoming.method !== "GET")
|
|
1478
|
+
return refuse(server, 405, "Use GET");
|
|
1121
1479
|
answerPoll(server, query);
|
|
1122
1480
|
return;
|
|
1123
1481
|
}
|
|
1124
|
-
if (pathname ===
|
|
1125
|
-
if (incoming.method !==
|
|
1126
|
-
return refuse(server, 405,
|
|
1482
|
+
if (pathname === "/api/agent/status") {
|
|
1483
|
+
if (incoming.method !== "GET")
|
|
1484
|
+
return refuse(server, 405, "Use GET");
|
|
1127
1485
|
respondJson(server, 200, status());
|
|
1128
1486
|
return;
|
|
1129
1487
|
}
|
|
1130
|
-
if (pathname ===
|
|
1131
|
-
if (incoming.method !==
|
|
1132
|
-
return refuse(server, 405,
|
|
1488
|
+
if (pathname === "/api/agent/responses") {
|
|
1489
|
+
if (incoming.method !== "POST")
|
|
1490
|
+
return refuse(server, 405, "Use POST");
|
|
1133
1491
|
await acceptResponse(server, incoming);
|
|
1134
1492
|
return;
|
|
1135
1493
|
}
|
|
1136
|
-
if (pathname ===
|
|
1137
|
-
if (incoming.method !==
|
|
1138
|
-
return refuse(server, 405,
|
|
1494
|
+
if (pathname === "/api/agent/stop") {
|
|
1495
|
+
if (incoming.method !== "POST")
|
|
1496
|
+
return refuse(server, 405, "Use POST");
|
|
1139
1497
|
await acceptStop(server, incoming);
|
|
1140
1498
|
return;
|
|
1141
1499
|
}
|
|
1142
|
-
refuse(server, 404,
|
|
1500
|
+
refuse(server, 404, "No such route");
|
|
1143
1501
|
};
|
|
1144
1502
|
// ----------------------------------------------------------- browser routes
|
|
1145
1503
|
const assetPath = (pathname) => {
|
|
1146
|
-
if (pathname ===
|
|
1147
|
-
return join(assetRoot,
|
|
1148
|
-
const prefix =
|
|
1504
|
+
if (pathname === "/")
|
|
1505
|
+
return join(assetRoot, "index.html");
|
|
1506
|
+
const prefix = "/assets/";
|
|
1149
1507
|
if (!pathname.startsWith(prefix))
|
|
1150
1508
|
return undefined;
|
|
1151
1509
|
const name = pathname.slice(prefix.length);
|
|
1152
1510
|
if (!ASSET_NAME.test(name))
|
|
1153
1511
|
return undefined;
|
|
1154
|
-
const target = resolve(assetRoot,
|
|
1512
|
+
const target = resolve(assetRoot, "assets", name);
|
|
1155
1513
|
// The name cannot carry a separator, so this only confirms what the pattern
|
|
1156
1514
|
// already guaranteed — which is the point of having both.
|
|
1157
1515
|
return target.startsWith(`${assetRoot}${sep}`) ? target : undefined;
|
|
@@ -1160,26 +1518,26 @@ export const startVisualServer = async (options) => {
|
|
|
1160
1518
|
const target = assetPath(pathname);
|
|
1161
1519
|
const contentType = target === undefined ? undefined : CONTENT_TYPES[extname(target)];
|
|
1162
1520
|
if (target === undefined || contentType === undefined) {
|
|
1163
|
-
refuse(server, 404,
|
|
1521
|
+
refuse(server, 404, "No such asset");
|
|
1164
1522
|
return;
|
|
1165
1523
|
}
|
|
1166
1524
|
try {
|
|
1167
1525
|
// lstat, never stat: a symlink inside the asset root is a link out of it.
|
|
1168
1526
|
const entry = await lstat(target);
|
|
1169
1527
|
if (!entry.isFile()) {
|
|
1170
|
-
refuse(server, 404,
|
|
1528
|
+
refuse(server, 404, "No such asset");
|
|
1171
1529
|
return;
|
|
1172
1530
|
}
|
|
1173
1531
|
respond(server, 200, await readFile(target), contentType);
|
|
1174
1532
|
}
|
|
1175
1533
|
catch {
|
|
1176
|
-
refuse(server, 404,
|
|
1534
|
+
refuse(server, 404, "No such asset");
|
|
1177
1535
|
}
|
|
1178
1536
|
};
|
|
1179
1537
|
const serveBootstrap = (server, query) => {
|
|
1180
|
-
const key = query.get(
|
|
1538
|
+
const key = query.get("key") ?? undefined;
|
|
1181
1539
|
if (bootstrapSpent || !secretEquals(session.browserToken, key)) {
|
|
1182
|
-
refuse(server, 403,
|
|
1540
|
+
refuse(server, 403, "Bootstrap capability is not valid");
|
|
1183
1541
|
return;
|
|
1184
1542
|
}
|
|
1185
1543
|
// One-time by construction: the URL capability buys exactly one cookie, and
|
|
@@ -1188,70 +1546,70 @@ export const startVisualServer = async (options) => {
|
|
|
1188
1546
|
bootstrapSpent = true;
|
|
1189
1547
|
server.writeHead(303, {
|
|
1190
1548
|
...browserHeaders,
|
|
1191
|
-
Location:
|
|
1192
|
-
|
|
1193
|
-
|
|
1549
|
+
Location: "/",
|
|
1550
|
+
"Set-Cookie": `${COOKIE_NAME}=${browserAuthenticator}; HttpOnly; SameSite=Strict; Secure; Path=/`,
|
|
1551
|
+
"Content-Length": 0,
|
|
1194
1552
|
});
|
|
1195
1553
|
server.end();
|
|
1196
1554
|
};
|
|
1197
1555
|
const serveBrowser = async (incoming, server, pathname, query) => {
|
|
1198
1556
|
const sent = incoming.headers.origin;
|
|
1199
1557
|
if (sent !== undefined && sent !== origin) {
|
|
1200
|
-
refuse(server, 403,
|
|
1558
|
+
refuse(server, 403, "Origin is not this session");
|
|
1201
1559
|
return;
|
|
1202
1560
|
}
|
|
1203
|
-
if (incoming.method !==
|
|
1204
|
-
refuse(server, 405,
|
|
1561
|
+
if (incoming.method !== "GET") {
|
|
1562
|
+
refuse(server, 405, "Use GET");
|
|
1205
1563
|
return;
|
|
1206
1564
|
}
|
|
1207
|
-
if (pathname ===
|
|
1565
|
+
if (pathname === "/bootstrap") {
|
|
1208
1566
|
serveBootstrap(server, query);
|
|
1209
1567
|
return;
|
|
1210
1568
|
}
|
|
1211
1569
|
if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
|
|
1212
|
-
refuse(server, 401,
|
|
1570
|
+
refuse(server, 401, "Session cookie required");
|
|
1213
1571
|
return;
|
|
1214
1572
|
}
|
|
1215
|
-
if (pathname ===
|
|
1573
|
+
if (pathname === "/api/session") {
|
|
1216
1574
|
respondJson(server, 200, snapshot());
|
|
1217
1575
|
return;
|
|
1218
1576
|
}
|
|
1219
1577
|
await serveAsset(server, pathname);
|
|
1220
1578
|
};
|
|
1221
|
-
httpServer.on(
|
|
1579
|
+
httpServer.on("request", (incoming, server) => {
|
|
1222
1580
|
const responseSocket = incoming.socket;
|
|
1223
1581
|
unclassifiedHttpSockets.delete(responseSocket);
|
|
1224
|
-
if ((incoming.url ??
|
|
1582
|
+
if ((incoming.url ?? "/").split("?")[0] === "/api/agent/stop") {
|
|
1225
1583
|
stopResponseSockets.add(responseSocket);
|
|
1226
1584
|
const releaseResponseSocket = () => {
|
|
1227
1585
|
stopResponseSockets.delete(responseSocket);
|
|
1228
1586
|
};
|
|
1229
|
-
server.once(
|
|
1230
|
-
server.once(
|
|
1587
|
+
server.once("finish", releaseResponseSocket);
|
|
1588
|
+
server.once("close", releaseResponseSocket);
|
|
1231
1589
|
}
|
|
1232
1590
|
void (async () => {
|
|
1233
1591
|
try {
|
|
1234
1592
|
// Rebinding defence: a request that reached this port under any other
|
|
1235
1593
|
// name was routed here by something other than the browser we started.
|
|
1236
1594
|
if (incoming.headers.host !== authority) {
|
|
1237
|
-
refuse(server, 403,
|
|
1595
|
+
refuse(server, 403, "Host is not the bound loopback authority");
|
|
1238
1596
|
return;
|
|
1239
1597
|
}
|
|
1240
|
-
const [rawPath =
|
|
1598
|
+
const [rawPath = "/", rawQuery = ""] = (incoming.url ?? "/").split("?");
|
|
1241
1599
|
let pathname;
|
|
1242
1600
|
try {
|
|
1243
1601
|
pathname = decodeURIComponent(rawPath);
|
|
1244
1602
|
}
|
|
1245
1603
|
catch {
|
|
1246
|
-
refuse(server, 400,
|
|
1604
|
+
refuse(server, 400, "Request target is not a valid URL path");
|
|
1247
1605
|
return;
|
|
1248
1606
|
}
|
|
1249
|
-
if (pathname.includes(
|
|
1250
|
-
refuse(server, 400,
|
|
1607
|
+
if (pathname.includes("\0")) {
|
|
1608
|
+
refuse(server, 400, "Request target is not a valid URL path");
|
|
1251
1609
|
return;
|
|
1252
1610
|
}
|
|
1253
1611
|
const query = new URLSearchParams(rawQuery);
|
|
1254
|
-
if (pathname.startsWith(
|
|
1612
|
+
if (pathname.startsWith("/api/agent/")) {
|
|
1255
1613
|
await serveAgent(incoming, server, pathname, query);
|
|
1256
1614
|
return;
|
|
1257
1615
|
}
|
|
@@ -1261,31 +1619,31 @@ export const startVisualServer = async (options) => {
|
|
|
1261
1619
|
if (server.headersSent)
|
|
1262
1620
|
server.destroy(cause);
|
|
1263
1621
|
else
|
|
1264
|
-
refuse(server, 500,
|
|
1622
|
+
refuse(server, 500, "Session server failed");
|
|
1265
1623
|
}
|
|
1266
1624
|
})();
|
|
1267
1625
|
});
|
|
1268
|
-
httpServer.on(
|
|
1626
|
+
httpServer.on("upgrade", (incoming, socket, head) => {
|
|
1269
1627
|
const deny = (code, message) => {
|
|
1270
1628
|
socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
1271
1629
|
socket.destroy();
|
|
1272
1630
|
};
|
|
1273
|
-
if (active.lifecycle !==
|
|
1274
|
-
return deny(503,
|
|
1631
|
+
if (active.lifecycle !== "running")
|
|
1632
|
+
return deny(503, "Service Unavailable");
|
|
1275
1633
|
if (incoming.headers.host !== authority)
|
|
1276
|
-
return deny(403,
|
|
1634
|
+
return deny(403, "Forbidden");
|
|
1277
1635
|
// An upgrade always carries an Origin, so it is checked without exception.
|
|
1278
1636
|
if (incoming.headers.origin !== origin)
|
|
1279
|
-
return deny(403,
|
|
1280
|
-
const [rawPath =
|
|
1637
|
+
return deny(403, "Forbidden");
|
|
1638
|
+
const [rawPath = "/"] = (incoming.url ?? "/").split("?");
|
|
1281
1639
|
if (rawPath !== VISUAL_SOCKET_PATH)
|
|
1282
|
-
return deny(404,
|
|
1640
|
+
return deny(404, "Not Found");
|
|
1283
1641
|
if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
|
|
1284
|
-
return deny(401,
|
|
1642
|
+
return deny(401, "Unauthorized");
|
|
1285
1643
|
}
|
|
1286
1644
|
if (connections.size + browserConnectionReservations >=
|
|
1287
1645
|
VISUAL_SERVER_LIMITS.browserConnections) {
|
|
1288
|
-
return deny(503,
|
|
1646
|
+
return deny(503, "Service Unavailable");
|
|
1289
1647
|
}
|
|
1290
1648
|
browserConnectionReservations += 1;
|
|
1291
1649
|
try {
|
|
@@ -1310,14 +1668,14 @@ export const startVisualServer = async (options) => {
|
|
|
1310
1668
|
const attempt = (async () => {
|
|
1311
1669
|
// Frozen before anything is torn down: a request already on the wire
|
|
1312
1670
|
// must not be journaled behind the recovery this stop is about to read.
|
|
1313
|
-
active.lifecycle =
|
|
1671
|
+
active.lifecycle = "draining";
|
|
1314
1672
|
listening = false;
|
|
1315
1673
|
disarmGrace();
|
|
1316
1674
|
// Waiting agents are answered before any socket is torn down, so a poll
|
|
1317
1675
|
// in flight during a stop still receives its non-terminal idle result.
|
|
1318
1676
|
settleAllPolls();
|
|
1319
1677
|
for (const socket of connections.values()) {
|
|
1320
|
-
sendFrame(socket, { kind:
|
|
1678
|
+
sendFrame(socket, { kind: "closing", reason });
|
|
1321
1679
|
socket.close(1001);
|
|
1322
1680
|
}
|
|
1323
1681
|
sockets.close();
|
|
@@ -1341,7 +1699,7 @@ export const startVisualServer = async (options) => {
|
|
|
1341
1699
|
// The transcript is read once, here, and only if this stop asked for it;
|
|
1342
1700
|
// the same read is what removes the marked root.
|
|
1343
1701
|
const removed = await removeVisualSession(paths, includeTranscript);
|
|
1344
|
-
active.lifecycle =
|
|
1702
|
+
active.lifecycle = "stopped";
|
|
1345
1703
|
const outcome = {
|
|
1346
1704
|
reason,
|
|
1347
1705
|
alreadyStopped: false,
|
|
@@ -1369,7 +1727,7 @@ export const startVisualServer = async (options) => {
|
|
|
1369
1727
|
let started;
|
|
1370
1728
|
try {
|
|
1371
1729
|
await writeVisualSessionDescriptor(paths, {
|
|
1372
|
-
format:
|
|
1730
|
+
format: "yarramate/visual-session-descriptor/v1",
|
|
1373
1731
|
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
1374
1732
|
sessionId,
|
|
1375
1733
|
origin,
|
|
@@ -1379,7 +1737,7 @@ export const startVisualServer = async (options) => {
|
|
|
1379
1737
|
createdAt: stamp(),
|
|
1380
1738
|
});
|
|
1381
1739
|
started = {
|
|
1382
|
-
format:
|
|
1740
|
+
format: "yarramate/visual-session-started/v1",
|
|
1383
1741
|
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
1384
1742
|
sessionId,
|
|
1385
1743
|
authority: request.authority,
|
|
@@ -1395,12 +1753,12 @@ export const startVisualServer = async (options) => {
|
|
|
1395
1753
|
};
|
|
1396
1754
|
const validated = parseVisualSessionStarted(started);
|
|
1397
1755
|
if (!validated.ok) {
|
|
1398
|
-
throw serverError(validated.diagnostics[0]?.code ??
|
|
1756
|
+
throw serverError(validated.diagnostics[0]?.code ?? "YMVS102", `Started session is invalid: ${validated.diagnostics[0]?.message ?? "unknown violation"}`);
|
|
1399
1757
|
}
|
|
1400
1758
|
}
|
|
1401
1759
|
catch (cause) {
|
|
1402
1760
|
return abandon(cause);
|
|
1403
1761
|
}
|
|
1404
|
-
active.lifecycle =
|
|
1762
|
+
active.lifecycle = "running";
|
|
1405
1763
|
return { started, closed, status, stop: (reason) => stop(reason) };
|
|
1406
1764
|
};
|