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
package/README.md
CHANGED
|
@@ -183,7 +183,8 @@ projections, evidence, and architecture states; and exposes a stable CLI for
|
|
|
183
183
|
people, CI, skills, and agent harnesses.
|
|
184
184
|
|
|
185
185
|
Optional adapters provide LikeC4 visualization from semantic projections,
|
|
186
|
-
Graphify observations as evidence overlays,
|
|
186
|
+
Graphify observations as evidence overlays, loopback-only visual
|
|
187
|
+
conversations over a published protocol (beta), and separately governed
|
|
187
188
|
compatibility profiles for external languages. Core depends on none of them.
|
|
188
189
|
|
|
189
190
|
YarraMate is not affiliated with or certified by The Open Group. ArchiMate®
|
|
File without changes
|
|
File without changes
|
package/dist/adapters/mcp-cli.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type ParseResult, type VisualDiagnostic, type VisualHandoff, type VisualResponse, type VisualSessionDescriptor, type VisualStatus } from './protocol.js';
|
|
2
|
+
import { type VisualEventDelivery, type VisualResponseAcceptance } from './session-server.js';
|
|
3
|
+
/**
|
|
4
|
+
* Document name reported by diagnostics the client itself raises — an
|
|
5
|
+
* unreadable descriptor, an unreachable session server, or an answer that is
|
|
6
|
+
* not the document the route promises.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VISUAL_CLIENT_DOCUMENT = "visual-session-client";
|
|
9
|
+
export declare const VISUAL_CLIENT_LIMITS: {
|
|
10
|
+
/**
|
|
11
|
+
* Ceiling on one agent request. It has to outlast the server's long poll,
|
|
12
|
+
* which answers "still idle" only once its own window closes, or every idle
|
|
13
|
+
* wait would be reported as a transport failure.
|
|
14
|
+
*/
|
|
15
|
+
readonly requestTimeoutMs: number;
|
|
16
|
+
/** How much of a refusal the diagnostic explaining it quotes back. */
|
|
17
|
+
readonly refusalBytes: 512;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* `pointer` is an RFC 6901 pointer into the document being refused; a refusal
|
|
21
|
+
* the client raised about the transport itself is rooted at `/`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const visualClientDiagnostic: (code: string, message: string, pointer?: string) => VisualDiagnostic;
|
|
24
|
+
export declare const visualFailureDiagnostics: (cause: unknown) => readonly VisualDiagnostic[];
|
|
25
|
+
/**
|
|
26
|
+
* Whether an unreadable descriptor belongs to a session this client already
|
|
27
|
+
* removed. Absence alone is not evidence: the stopped marker is written before
|
|
28
|
+
* teardown and names the vanished session directory.
|
|
29
|
+
*/
|
|
30
|
+
export declare const visualSessionAlreadyStopped: (path: string, cwd?: string) => Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* The agent's entry point into one live session, read the way a hostile file
|
|
33
|
+
* has to be read. The descriptor is the only file carrying the agent
|
|
34
|
+
* capability, so a redirected or planted one must never be spent.
|
|
35
|
+
*/
|
|
36
|
+
export declare const readVisualSessionDescriptor: (path: string, cwd?: string) => Promise<ParseResult<VisualSessionDescriptor>>;
|
|
37
|
+
/** One JSON document the agent hands to a command, read from the filesystem. */
|
|
38
|
+
export declare const readVisualJsonDocument: (path: string, cwd?: string) => Promise<ParseResult<unknown>>;
|
|
39
|
+
/**
|
|
40
|
+
* Long-polls for the next actionable event past `after`. The server holds the
|
|
41
|
+
* request for its whole poll window before answering that the session is still
|
|
42
|
+
* idle, which is why this is the client's longest-running call.
|
|
43
|
+
*/
|
|
44
|
+
export declare const waitForVisualEvent: (descriptor: VisualSessionDescriptor, after: number) => Promise<ParseResult<VisualEventDelivery>>;
|
|
45
|
+
/**
|
|
46
|
+
* Delivers one agent response. A response the runtime already journaled is
|
|
47
|
+
* accepted again as a duplicate, so a retried delivery is never a second turn.
|
|
48
|
+
*/
|
|
49
|
+
export declare const sendVisualResponse: (descriptor: VisualSessionDescriptor, response: VisualResponse) => Promise<ParseResult<Extract<VisualResponseAcceptance, {
|
|
50
|
+
accepted: true;
|
|
51
|
+
}>>>;
|
|
52
|
+
/**
|
|
53
|
+
* Asks the runtime for its status, falling back to the local one when nothing
|
|
54
|
+
* is listening: a session whose server died still has a status, and it is
|
|
55
|
+
* stopped. A server that answers and refuses does not fall back — that is a
|
|
56
|
+
* capability fault, not a dead session.
|
|
57
|
+
*/
|
|
58
|
+
export declare const fetchVisualStatus: (descriptor: VisualSessionDescriptor) => Promise<ParseResult<VisualStatus>>;
|
|
59
|
+
/**
|
|
60
|
+
* Recovers the handoff from the journal. Recovery never asks the server: the
|
|
61
|
+
* journal is the record, and it is written to outlive the runtime that kept it.
|
|
62
|
+
*/
|
|
63
|
+
export declare const recoverVisualSessionClient: (descriptor: VisualSessionDescriptor, includeTranscript?: boolean) => Promise<ParseResult<VisualHandoff>>;
|
|
64
|
+
/**
|
|
65
|
+
* Converges one session on stopped, whatever is left of it. Recovery runs
|
|
66
|
+
* first, so no later step can be the one that loses confirmed state; the stop
|
|
67
|
+
* request only returns once the runtime has journaled its terminal event,
|
|
68
|
+
* drained, and removed the session it owns; and the local removal converges
|
|
69
|
+
* the case where nothing was listening.
|
|
70
|
+
*
|
|
71
|
+
* `undefined` means the session was already gone, which is what makes a
|
|
72
|
+
* repeated stop idempotent.
|
|
73
|
+
*/
|
|
74
|
+
export declare const stopVisualSessionClient: (descriptor: VisualSessionDescriptor, includeTranscript?: boolean) => Promise<ParseResult<VisualHandoff | undefined>>;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { VISUAL_PROTOCOL_VERSION, parseVisualDiagnosticResult, parseVisualEvent, parseVisualHandoff, parseVisualSessionDescriptor, parseVisualStatus, } from './protocol.js';
|
|
5
|
+
import { VISUAL_SERVER_LIMITS, } from './session-server.js';
|
|
6
|
+
import { recoverVisualSession, removeVisualSession, visualSessionPaths, } from './session-store.js';
|
|
7
|
+
/**
|
|
8
|
+
* Document name reported by diagnostics the client itself raises — an
|
|
9
|
+
* unreadable descriptor, an unreachable session server, or an answer that is
|
|
10
|
+
* not the document the route promises.
|
|
11
|
+
*/
|
|
12
|
+
export const VISUAL_CLIENT_DOCUMENT = 'visual-session-client';
|
|
13
|
+
export const VISUAL_CLIENT_LIMITS = {
|
|
14
|
+
/**
|
|
15
|
+
* Ceiling on one agent request. It has to outlast the server's long poll,
|
|
16
|
+
* which answers "still idle" only once its own window closes, or every idle
|
|
17
|
+
* wait would be reported as a transport failure.
|
|
18
|
+
*/
|
|
19
|
+
requestTimeoutMs: VISUAL_SERVER_LIMITS.agentPollMs + 5_000,
|
|
20
|
+
/** How much of a refusal the diagnostic explaining it quotes back. */
|
|
21
|
+
refusalBytes: 512,
|
|
22
|
+
};
|
|
23
|
+
/** Termination reason a one-shot `stop` asks the runtime to close under. */
|
|
24
|
+
const STOP_REASON = 'main-cancelled';
|
|
25
|
+
const AGENT_EVENTS = '/api/agent/events';
|
|
26
|
+
const AGENT_STATUS = '/api/agent/status';
|
|
27
|
+
const AGENT_RESPONSES = '/api/agent/responses';
|
|
28
|
+
const AGENT_STOP = '/api/agent/stop';
|
|
29
|
+
/**
|
|
30
|
+
* Session store failures already carry the protocol code that explains them, so
|
|
31
|
+
* they are surfaced under that code rather than flattened into one client code.
|
|
32
|
+
*/
|
|
33
|
+
const STORE_FAILURE = /^(YMVS[0-9]{3}): ([\s\S]+)$/;
|
|
34
|
+
/**
|
|
35
|
+
* `pointer` is an RFC 6901 pointer into the document being refused; a refusal
|
|
36
|
+
* the client raised about the transport itself is rooted at `/`.
|
|
37
|
+
*/
|
|
38
|
+
export const visualClientDiagnostic = (code, message, pointer = '/') => ({
|
|
39
|
+
severity: 'error',
|
|
40
|
+
code,
|
|
41
|
+
message,
|
|
42
|
+
path: VISUAL_CLIENT_DOCUMENT,
|
|
43
|
+
pointer,
|
|
44
|
+
line: 1,
|
|
45
|
+
column: 1,
|
|
46
|
+
});
|
|
47
|
+
const refused = (diagnostics) => ({ ok: false, diagnostics });
|
|
48
|
+
export const visualFailureDiagnostics = (cause) => {
|
|
49
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
50
|
+
const named = STORE_FAILURE.exec(message);
|
|
51
|
+
const code = named?.[1];
|
|
52
|
+
const detail = named?.[2];
|
|
53
|
+
return code === undefined || detail === undefined
|
|
54
|
+
? [visualClientDiagnostic('YMVS408', message)]
|
|
55
|
+
: [visualClientDiagnostic(code, detail)];
|
|
56
|
+
};
|
|
57
|
+
const summarise = (value) => {
|
|
58
|
+
const text = value instanceof Error ? value.message : String(value);
|
|
59
|
+
const trimmed = text.trim().split('\n')[0] ?? '';
|
|
60
|
+
return trimmed.length > VISUAL_CLIENT_LIMITS.refusalBytes
|
|
61
|
+
? `${trimmed.slice(0, VISUAL_CLIENT_LIMITS.refusalBytes)}…`
|
|
62
|
+
: trimmed;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Reads an untrusted answer's own fields. Anything that is not a plain object
|
|
66
|
+
* simply has no fields, so the field checks report the violation.
|
|
67
|
+
*/
|
|
68
|
+
const documentFields = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
69
|
+
? value
|
|
70
|
+
: {};
|
|
71
|
+
const exists = (path) => lstat(path).then(() => true, () => false);
|
|
72
|
+
const writeStoppedMarker = async (sessionRoot, sessionId) => {
|
|
73
|
+
const handle = await open(join(dirname(sessionRoot), `.stopped-${basename(sessionRoot)}.json`), constants.O_WRONLY |
|
|
74
|
+
constants.O_CREAT |
|
|
75
|
+
constants.O_TRUNC |
|
|
76
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
77
|
+
try {
|
|
78
|
+
await handle.writeFile(JSON.stringify({
|
|
79
|
+
format: 'yarramate/visual-session-stopped/v1',
|
|
80
|
+
sessionId,
|
|
81
|
+
}), 'utf8');
|
|
82
|
+
await handle.sync();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const readsStoppedMarker = async (sessionRoot, sessionId) => {
|
|
89
|
+
const handle = await open(join(dirname(sessionRoot), `.stopped-${basename(sessionRoot)}.json`), constants.O_RDONLY |
|
|
90
|
+
constants.O_NONBLOCK |
|
|
91
|
+
(constants.O_NOFOLLOW ?? 0)).catch(() => undefined);
|
|
92
|
+
if (handle === undefined)
|
|
93
|
+
return false;
|
|
94
|
+
try {
|
|
95
|
+
const entry = await handle.stat();
|
|
96
|
+
if (!entry.isFile())
|
|
97
|
+
return false;
|
|
98
|
+
const document = documentFields(JSON.parse(await handle.readFile('utf8')));
|
|
99
|
+
return (document.format === 'yarramate/visual-session-stopped/v1' &&
|
|
100
|
+
document.sessionId === sessionId);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Whether an unreadable descriptor belongs to a session this client already
|
|
111
|
+
* removed. Absence alone is not evidence: the stopped marker is written before
|
|
112
|
+
* teardown and names the vanished session directory.
|
|
113
|
+
*/
|
|
114
|
+
export const visualSessionAlreadyStopped = async (path, cwd = process.cwd()) => {
|
|
115
|
+
const target = resolve(cwd, path);
|
|
116
|
+
const sessionRoot = dirname(target);
|
|
117
|
+
const sessionId = basename(sessionRoot);
|
|
118
|
+
return (basename(target) === 'descriptor.json' &&
|
|
119
|
+
!(await exists(target)) &&
|
|
120
|
+
!(await exists(sessionRoot)) &&
|
|
121
|
+
(await readsStoppedMarker(sessionRoot, sessionId)));
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* The agent's entry point into one live session, read the way a hostile file
|
|
125
|
+
* has to be read. The descriptor is the only file carrying the agent
|
|
126
|
+
* capability, so a redirected or planted one must never be spent.
|
|
127
|
+
*/
|
|
128
|
+
export const readVisualSessionDescriptor = async (path, cwd = process.cwd()) => {
|
|
129
|
+
const target = resolve(cwd, path);
|
|
130
|
+
let raw;
|
|
131
|
+
// One handle, opened once: the descriptor is the only file carrying the agent
|
|
132
|
+
// capability, so the thing that is checked has to be the thing that is read.
|
|
133
|
+
// `O_NOFOLLOW` refuses a symlinked capability in the open itself,
|
|
134
|
+
// `O_NONBLOCK` prevents a planted FIFO from hanging the open, and the file
|
|
135
|
+
// kind is taken from the open handle, leaving no window between the check
|
|
136
|
+
// and the read for the path to be swapped.
|
|
137
|
+
const handle = await open(target, constants.O_RDONLY |
|
|
138
|
+
constants.O_NONBLOCK |
|
|
139
|
+
(constants.O_NOFOLLOW ?? 0)).catch(() => undefined);
|
|
140
|
+
if (handle === undefined) {
|
|
141
|
+
return refused([
|
|
142
|
+
visualClientDiagnostic('YMVS401', `Session descriptor "${target}" cannot be read`),
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const entry = await handle.stat();
|
|
147
|
+
if (!entry.isFile()) {
|
|
148
|
+
return refused([
|
|
149
|
+
visualClientDiagnostic('YMVS401', `Session descriptor "${target}" is not a regular file`),
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
raw = await handle.readFile('utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return refused([
|
|
156
|
+
visualClientDiagnostic('YMVS401', `Session descriptor "${target}" cannot be read`),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
await handle.close();
|
|
161
|
+
}
|
|
162
|
+
let document;
|
|
163
|
+
try {
|
|
164
|
+
document = JSON.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return refused([
|
|
168
|
+
visualClientDiagnostic('YMVS402', `Session descriptor "${target}" is not JSON`),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
const parsed = parseVisualSessionDescriptor(document);
|
|
172
|
+
if (!parsed.ok)
|
|
173
|
+
return parsed;
|
|
174
|
+
const paths = visualSessionPaths(parsed.value.sessionRoot);
|
|
175
|
+
// A descriptor authorises work on the session it lives in and no other: this
|
|
176
|
+
// is the same invariant the runtime enforced when it published the file, so a
|
|
177
|
+
// copied or planted descriptor cannot direct a stop at another directory.
|
|
178
|
+
if (paths.descriptor !== target || paths.journal !== parsed.value.journalPath) {
|
|
179
|
+
return refused([
|
|
180
|
+
visualClientDiagnostic('YMVS403', `Session descriptor "${target}" names session artefacts outside its own directory`),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
return parsed;
|
|
184
|
+
};
|
|
185
|
+
/** One JSON document the agent hands to a command, read from the filesystem. */
|
|
186
|
+
export const readVisualJsonDocument = async (path, cwd = process.cwd()) => {
|
|
187
|
+
const target = resolve(cwd, path);
|
|
188
|
+
try {
|
|
189
|
+
return { ok: true, value: JSON.parse(await readFile(target, 'utf8')) };
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return refused([
|
|
193
|
+
visualClientDiagnostic('YMVS407', `Document "${target}" is not a readable JSON document`),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* One bearer request to the loopback origin the descriptor names. The
|
|
199
|
+
* descriptor schema confines that origin to 127.0.0.1, so the capability never
|
|
200
|
+
* travels off the host that minted it.
|
|
201
|
+
*/
|
|
202
|
+
const agentRequest = async (descriptor, route, body) => {
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(`${descriptor.origin}${route}`, {
|
|
205
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
Authorization: `Bearer ${descriptor.agentCapability}`,
|
|
208
|
+
...(body === undefined
|
|
209
|
+
? {}
|
|
210
|
+
: { 'Content-Type': 'application/json; charset=utf-8' }),
|
|
211
|
+
},
|
|
212
|
+
body,
|
|
213
|
+
signal: AbortSignal.timeout(VISUAL_CLIENT_LIMITS.requestTimeoutMs),
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
value: {
|
|
218
|
+
status: response.status,
|
|
219
|
+
contentType: response.headers.get('content-type') ?? '',
|
|
220
|
+
body: await response.text(),
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
catch (cause) {
|
|
225
|
+
return refused([
|
|
226
|
+
visualClientDiagnostic('YMVS404', `Session server at ${descriptor.origin} did not answer ${route}: ${summarise(cause)}`),
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Diagnostics a refusal carried as one of the server's own JSON documents. They
|
|
232
|
+
* explain the refusal better than its status line ever could, but they arrive
|
|
233
|
+
* over a trust boundary: anything that is not already a conforming
|
|
234
|
+
* `visual-diagnostic-result/v1` payload is dropped rather than repaired, so a
|
|
235
|
+
* protocol violation upstream is reported as the transport failure it is
|
|
236
|
+
* instead of being laundered into this client's own output.
|
|
237
|
+
*/
|
|
238
|
+
const carriedDiagnostics = (answer) => {
|
|
239
|
+
if (!answer.contentType.startsWith('application/json'))
|
|
240
|
+
return undefined;
|
|
241
|
+
let document;
|
|
242
|
+
try {
|
|
243
|
+
document = JSON.parse(answer.body);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const parsed = parseVisualDiagnosticResult({
|
|
249
|
+
format: 'yarramate/visual-diagnostic-result/v1',
|
|
250
|
+
diagnostics: documentFields(document).diagnostics,
|
|
251
|
+
});
|
|
252
|
+
return parsed.ok && parsed.value.diagnostics.length > 0
|
|
253
|
+
? parsed.value.diagnostics
|
|
254
|
+
: undefined;
|
|
255
|
+
};
|
|
256
|
+
const refusalDiagnostics = (answer, route) => carriedDiagnostics(answer) ?? [
|
|
257
|
+
visualClientDiagnostic('YMVS405', `Session server refused ${route} with status ${answer.status}: ${summarise(answer.body)}`),
|
|
258
|
+
];
|
|
259
|
+
const malformed = (route, detail) => refused([
|
|
260
|
+
visualClientDiagnostic('YMVS406', `Session server answered ${route} with ${detail}`),
|
|
261
|
+
]);
|
|
262
|
+
/** The 200 body of an agent route, decoded; any other status is a refusal. */
|
|
263
|
+
const decoded = (answer, route) => {
|
|
264
|
+
if (answer.status !== 200)
|
|
265
|
+
return refused(refusalDiagnostics(answer, route));
|
|
266
|
+
try {
|
|
267
|
+
return { ok: true, value: JSON.parse(answer.body) };
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return malformed(route, 'a body that is not JSON');
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Long-polls for the next actionable event past `after`. The server holds the
|
|
275
|
+
* request for its whole poll window before answering that the session is still
|
|
276
|
+
* idle, which is why this is the client's longest-running call.
|
|
277
|
+
*/
|
|
278
|
+
export const waitForVisualEvent = async (descriptor, after) => {
|
|
279
|
+
const answered = await agentRequest(descriptor, `${AGENT_EVENTS}?after=${after}`);
|
|
280
|
+
if (!answered.ok)
|
|
281
|
+
return answered;
|
|
282
|
+
const body = decoded(answered.value, AGENT_EVENTS);
|
|
283
|
+
if (!body.ok)
|
|
284
|
+
return body;
|
|
285
|
+
const fields = documentFields(body.value);
|
|
286
|
+
const { lastSequence, pendingEvents } = fields;
|
|
287
|
+
if (typeof lastSequence !== 'number' || typeof pendingEvents !== 'number') {
|
|
288
|
+
return malformed(AGENT_EVENTS, 'a document that is not an event delivery');
|
|
289
|
+
}
|
|
290
|
+
if (fields.waiting === true) {
|
|
291
|
+
return { ok: true, value: { waiting: true, lastSequence, pendingEvents } };
|
|
292
|
+
}
|
|
293
|
+
if (fields.waiting !== false) {
|
|
294
|
+
return malformed(AGENT_EVENTS, 'a document that is not an event delivery');
|
|
295
|
+
}
|
|
296
|
+
const event = parseVisualEvent(fields.event);
|
|
297
|
+
if (!event.ok)
|
|
298
|
+
return event;
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
value: { waiting: false, event: event.value, lastSequence, pendingEvents },
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* Delivers one agent response. A response the runtime already journaled is
|
|
306
|
+
* accepted again as a duplicate, so a retried delivery is never a second turn.
|
|
307
|
+
*/
|
|
308
|
+
export const sendVisualResponse = async (descriptor, response) => {
|
|
309
|
+
const answered = await agentRequest(descriptor, AGENT_RESPONSES, JSON.stringify(response));
|
|
310
|
+
if (!answered.ok)
|
|
311
|
+
return answered;
|
|
312
|
+
const body = decoded(answered.value, AGENT_RESPONSES);
|
|
313
|
+
if (!body.ok)
|
|
314
|
+
return body;
|
|
315
|
+
const fields = documentFields(body.value);
|
|
316
|
+
if (fields.accepted !== true ||
|
|
317
|
+
typeof fields.duplicate !== 'boolean' ||
|
|
318
|
+
typeof fields.lastSequence !== 'number' ||
|
|
319
|
+
!Array.isArray(fields.diagnostics)) {
|
|
320
|
+
return malformed(AGENT_RESPONSES, 'a document that is not a response acceptance');
|
|
321
|
+
}
|
|
322
|
+
// Every discriminating field is checked above; `model` and `diagnostics` are
|
|
323
|
+
// carried through unread from the runtime's own acceptance document.
|
|
324
|
+
const acceptance = body.value;
|
|
325
|
+
return { ok: true, value: acceptance };
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* The status of a session whose runtime is gone: stopped, and reporting the
|
|
329
|
+
* journal that outlived it. A marker that is absent went with the runtime; a
|
|
330
|
+
* marker that is present and unusable is a fault the caller has to see.
|
|
331
|
+
*/
|
|
332
|
+
const localVisualStatus = async (descriptor) => {
|
|
333
|
+
const paths = visualSessionPaths(descriptor.sessionRoot);
|
|
334
|
+
let lastSequence = 0;
|
|
335
|
+
let transcriptBytes = 0;
|
|
336
|
+
try {
|
|
337
|
+
lastSequence = (await recoverVisualSession(paths)).lastSequence;
|
|
338
|
+
transcriptBytes = (await stat(paths.journal)).size;
|
|
339
|
+
}
|
|
340
|
+
catch (cause) {
|
|
341
|
+
if (await exists(paths.marker)) {
|
|
342
|
+
return refused(visualFailureDiagnostics(cause));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
value: {
|
|
348
|
+
format: 'yarramate/visual-status/v1',
|
|
349
|
+
protocolVersion: VISUAL_PROTOCOL_VERSION,
|
|
350
|
+
sessionId: descriptor.sessionId,
|
|
351
|
+
lifecycle: 'stopped',
|
|
352
|
+
alreadyStopped: true,
|
|
353
|
+
server: { listening: false, origin: descriptor.origin },
|
|
354
|
+
browser: { connected: false, connections: 0 },
|
|
355
|
+
agent: { attached: false, inFlightEventId: null },
|
|
356
|
+
queue: { pendingEvents: 0, lastSequence, frozen: false },
|
|
357
|
+
// Every capability is the runtime's, and the runtime is gone.
|
|
358
|
+
capabilities: {
|
|
359
|
+
chat: false,
|
|
360
|
+
choices: false,
|
|
361
|
+
navigation: false,
|
|
362
|
+
modelReplacement: false,
|
|
363
|
+
transcript: false,
|
|
364
|
+
},
|
|
365
|
+
transcriptBytes,
|
|
366
|
+
updatedAt: new Date().toISOString(),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
};
|
|
370
|
+
/**
|
|
371
|
+
* Asks the runtime for its status, falling back to the local one when nothing
|
|
372
|
+
* is listening: a session whose server died still has a status, and it is
|
|
373
|
+
* stopped. A server that answers and refuses does not fall back — that is a
|
|
374
|
+
* capability fault, not a dead session.
|
|
375
|
+
*/
|
|
376
|
+
export const fetchVisualStatus = async (descriptor) => {
|
|
377
|
+
const answered = await agentRequest(descriptor, AGENT_STATUS);
|
|
378
|
+
if (!answered.ok)
|
|
379
|
+
return localVisualStatus(descriptor);
|
|
380
|
+
const body = decoded(answered.value, AGENT_STATUS);
|
|
381
|
+
if (!body.ok)
|
|
382
|
+
return body;
|
|
383
|
+
return parseVisualStatus(body.value);
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Recovers the handoff from the journal. Recovery never asks the server: the
|
|
387
|
+
* journal is the record, and it is written to outlive the runtime that kept it.
|
|
388
|
+
*/
|
|
389
|
+
export const recoverVisualSessionClient = async (descriptor, includeTranscript = false) => {
|
|
390
|
+
try {
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
value: await recoverVisualSession(visualSessionPaths(descriptor.sessionRoot), includeTranscript),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
catch (cause) {
|
|
397
|
+
return refused(visualFailureDiagnostics(cause));
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* The handoff a runtime answered its own stop with. It is the terminal one —
|
|
402
|
+
* journaled terminal event and all — so it supersedes what this client read
|
|
403
|
+
* on the way in. Anything that is not a conforming handoff is dropped rather
|
|
404
|
+
* than repaired: the pre-stop recovery is already a sound answer.
|
|
405
|
+
*/
|
|
406
|
+
const closedHandoff = (answer) => {
|
|
407
|
+
let document;
|
|
408
|
+
try {
|
|
409
|
+
document = JSON.parse(answer.body);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
const handoff = parseVisualHandoff(documentFields(document).handoff);
|
|
415
|
+
return handoff.ok ? handoff.value : undefined;
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* Converges one session on stopped, whatever is left of it. Recovery runs
|
|
419
|
+
* first, so no later step can be the one that loses confirmed state; the stop
|
|
420
|
+
* request only returns once the runtime has journaled its terminal event,
|
|
421
|
+
* drained, and removed the session it owns; and the local removal converges
|
|
422
|
+
* the case where nothing was listening.
|
|
423
|
+
*
|
|
424
|
+
* `undefined` means the session was already gone, which is what makes a
|
|
425
|
+
* repeated stop idempotent.
|
|
426
|
+
*/
|
|
427
|
+
export const stopVisualSessionClient = async (descriptor, includeTranscript = false) => {
|
|
428
|
+
const paths = visualSessionPaths(descriptor.sessionRoot);
|
|
429
|
+
if (!(await exists(paths.root)))
|
|
430
|
+
return { ok: true, value: undefined };
|
|
431
|
+
const recovered = await recoverVisualSessionClient(descriptor, includeTranscript);
|
|
432
|
+
if (!recovered.ok)
|
|
433
|
+
return recovered;
|
|
434
|
+
const answered = await agentRequest(descriptor, AGENT_STOP, JSON.stringify({ reason: STOP_REASON, includeTranscript }));
|
|
435
|
+
// A server that answered and refused is not this caller's to tear down.
|
|
436
|
+
if (answered.ok && answered.value.status !== 200) {
|
|
437
|
+
return refused(refusalDiagnostics(answered.value, AGENT_STOP));
|
|
438
|
+
}
|
|
439
|
+
const terminal = answered.ok ? closedHandoff(answered.value) : undefined;
|
|
440
|
+
try {
|
|
441
|
+
await writeStoppedMarker(paths.root, descriptor.sessionId);
|
|
442
|
+
await removeVisualSession(paths, includeTranscript);
|
|
443
|
+
}
|
|
444
|
+
catch (cause) {
|
|
445
|
+
return refused(visualFailureDiagnostics(cause));
|
|
446
|
+
}
|
|
447
|
+
return { ok: true, value: terminal ?? recovered.value };
|
|
448
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { VisualAuthority, VisualCompilerCommand, VisualDiagnostic, VisualModel } from './protocol.js';
|
|
2
|
+
import { type VisualSessionPaths } from './session-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Document name reported by diagnostics that no staged source file owns — a
|
|
5
|
+
* compiler that could not be executed, ran past its budget, or answered with
|
|
6
|
+
* something other than the report its command promised.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VISUAL_COMPILER_DOCUMENT = "likec4-compiler";
|
|
9
|
+
/**
|
|
10
|
+
* Name of the exported document inside the candidate root. The leading dot
|
|
11
|
+
* cannot collide with a staged model file, because model paths reject
|
|
12
|
+
* dot-prefixed segments.
|
|
13
|
+
*/
|
|
14
|
+
export declare const VISUAL_COMPILER_EXPORT_FILE = ".likec4-export.json";
|
|
15
|
+
export declare const VISUAL_COMPILER_LIMITS: {
|
|
16
|
+
readonly timeoutMs: 30000;
|
|
17
|
+
readonly outputBytes: number;
|
|
18
|
+
readonly exportBytes: number;
|
|
19
|
+
/** Grace between the abort's SIGTERM and an unconditional SIGKILL. */
|
|
20
|
+
readonly killGraceMs: 2000;
|
|
21
|
+
};
|
|
22
|
+
export interface CompileVisualModelOptions {
|
|
23
|
+
readonly model: VisualModel;
|
|
24
|
+
/** Trusted preflight vector. Never assembled from browser input. */
|
|
25
|
+
readonly command: VisualCompilerCommand;
|
|
26
|
+
readonly paths: VisualSessionPaths;
|
|
27
|
+
readonly now?: () => Date;
|
|
28
|
+
readonly signal?: AbortSignal;
|
|
29
|
+
readonly timeoutMs?: number;
|
|
30
|
+
readonly maxOutputBytes?: number;
|
|
31
|
+
readonly maxExportBytes?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CompiledVisualModel {
|
|
34
|
+
readonly candidate: string;
|
|
35
|
+
readonly candidateDir: string;
|
|
36
|
+
readonly authority: VisualAuthority;
|
|
37
|
+
readonly initialView: string;
|
|
38
|
+
readonly views: readonly string[];
|
|
39
|
+
readonly exportPath: string;
|
|
40
|
+
}
|
|
41
|
+
export type LikeC4CompilationResult = {
|
|
42
|
+
readonly ok: true;
|
|
43
|
+
readonly compiled: CompiledVisualModel;
|
|
44
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
45
|
+
} | {
|
|
46
|
+
readonly ok: false;
|
|
47
|
+
readonly diagnostics: readonly VisualDiagnostic[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Validates and exports one candidate model with the trusted LikeC4 command,
|
|
51
|
+
* promoting it only once a complete export naming the requested initial view
|
|
52
|
+
* has been parsed. Every process, parse, and diagnostic failure becomes a
|
|
53
|
+
* `VisualDiagnostic` and leaves the last good rendering in place.
|
|
54
|
+
*/
|
|
55
|
+
export declare const compileVisualModel: (options: CompileVisualModelOptions) => Promise<LikeC4CompilationResult>;
|