@ggui-ai/protocol-reference-server 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +45 -0
- package/dist/action-router.d.ts +40 -0
- package/dist/action-router.d.ts.map +1 -0
- package/dist/action-router.js +283 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +110 -0
- package/dist/conformance-host.d.ts +44 -0
- package/dist/conformance-host.d.ts.map +1 -0
- package/dist/conformance-host.js +185 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/server.d.ts +59 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +224 -0
- package/dist/session.d.ts +142 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +134 -0
- package/dist/tool-registry.d.ts +63 -0
- package/dist/tool-registry.d.ts.map +1 -0
- package/dist/tool-registry.js +99 -0
- package/package.json +64 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ReferenceServer` — the minimal WS live-channel server this package
|
|
3
|
+
* exports. Honest scope: SPEC §12.2 wire, version handshake,
|
|
4
|
+
* wired-action dispatch. That's it.
|
|
5
|
+
*
|
|
6
|
+
* No auth (accepts any bearer). No persistence. No bundle loading.
|
|
7
|
+
* The whole point is to be narrow enough that the vendor-neutral
|
|
8
|
+
* separation claim (Protocol #6) is empirically grounded — if this
|
|
9
|
+
* server passes `@ggui-ai/protocol-conformance`, the protocol has
|
|
10
|
+
* no implicit `@ggui-ai/mcp-server` coupling.
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { PROTOCOL_SCHEMA_VERSION } from '@ggui-ai/protocol';
|
|
14
|
+
import { WebSocketServer } from 'ws';
|
|
15
|
+
import { dispatchAction, isActionFrame } from './action-router.js';
|
|
16
|
+
import { SessionStore } from './session.js';
|
|
17
|
+
import { ToolRegistry } from './tool-registry.js';
|
|
18
|
+
export class ReferenceServer {
|
|
19
|
+
sessions = new SessionStore();
|
|
20
|
+
tools = new ToolRegistry();
|
|
21
|
+
options;
|
|
22
|
+
http = null;
|
|
23
|
+
wss = null;
|
|
24
|
+
boundPort = null;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = {
|
|
27
|
+
port: options.port,
|
|
28
|
+
host: options.host ?? '127.0.0.1',
|
|
29
|
+
strictVersionPolicy: options.strictVersionPolicy ?? true,
|
|
30
|
+
versionOverride: options.versionOverride ?? PROTOCOL_SCHEMA_VERSION,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Protocol schema version the server advertises in subscribe ack
|
|
35
|
+
* frames + UPGRADE_REQUIRED error frames. Defaults to
|
|
36
|
+
* `PROTOCOL_SCHEMA_VERSION`; overridable via
|
|
37
|
+
* {@link ReferenceServerOptions.versionOverride} for conformance
|
|
38
|
+
* fault injection.
|
|
39
|
+
*/
|
|
40
|
+
get advertisedVersion() {
|
|
41
|
+
return this.options.versionOverride;
|
|
42
|
+
}
|
|
43
|
+
/** Resolved port (valid after `start()` resolves). */
|
|
44
|
+
get port() {
|
|
45
|
+
if (this.boundPort === null) {
|
|
46
|
+
throw new Error('reference-server: port not resolved — did you await start()?');
|
|
47
|
+
}
|
|
48
|
+
return this.boundPort;
|
|
49
|
+
}
|
|
50
|
+
/** Base URL for the kit's `runConformance({serverUrl})`. */
|
|
51
|
+
get baseUrl() {
|
|
52
|
+
return `http://${this.options.host}:${this.port}`;
|
|
53
|
+
}
|
|
54
|
+
async start() {
|
|
55
|
+
const http = createServer();
|
|
56
|
+
const wss = new WebSocketServer({ server: http, path: '/ws' });
|
|
57
|
+
wss.on('connection', (socket) => {
|
|
58
|
+
this.handleConnection(socket);
|
|
59
|
+
});
|
|
60
|
+
await new Promise((done, fail) => {
|
|
61
|
+
const onError = (err) => {
|
|
62
|
+
http.off('listening', onListening);
|
|
63
|
+
fail(err);
|
|
64
|
+
};
|
|
65
|
+
const onListening = () => {
|
|
66
|
+
http.off('error', onError);
|
|
67
|
+
done();
|
|
68
|
+
};
|
|
69
|
+
http.once('error', onError);
|
|
70
|
+
http.once('listening', onListening);
|
|
71
|
+
http.listen(this.options.port, this.options.host);
|
|
72
|
+
});
|
|
73
|
+
const address = http.address();
|
|
74
|
+
if (address === null || typeof address === 'string') {
|
|
75
|
+
throw new Error('reference-server: failed to resolve bound port');
|
|
76
|
+
}
|
|
77
|
+
this.boundPort = address.port;
|
|
78
|
+
this.http = http;
|
|
79
|
+
this.wss = wss;
|
|
80
|
+
}
|
|
81
|
+
async stop() {
|
|
82
|
+
const wss = this.wss;
|
|
83
|
+
const http = this.http;
|
|
84
|
+
if (wss !== null) {
|
|
85
|
+
await new Promise((done) => wss.close(() => done()));
|
|
86
|
+
this.wss = null;
|
|
87
|
+
}
|
|
88
|
+
if (http !== null) {
|
|
89
|
+
await new Promise((done) => http.close(() => done()));
|
|
90
|
+
this.http = null;
|
|
91
|
+
}
|
|
92
|
+
this.boundPort = null;
|
|
93
|
+
}
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
// Connection handling
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
handleConnection(socket) {
|
|
98
|
+
// Subscribe state is per-connection — one WS may subscribe to
|
|
99
|
+
// one session at a time. Re-subscribe overwrites.
|
|
100
|
+
let subscribedSessionId = null;
|
|
101
|
+
const subscriber = {
|
|
102
|
+
send: (frame) => {
|
|
103
|
+
try {
|
|
104
|
+
socket.send(JSON.stringify(frame));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Socket lifecycle issues are the caller's problem.
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
socket.on('message', (raw) => {
|
|
112
|
+
void this.handleMessage(raw.toString('utf8'), {
|
|
113
|
+
socket,
|
|
114
|
+
subscriber,
|
|
115
|
+
onSubscribed: (sessionId) => {
|
|
116
|
+
// If previously subscribed to a different session, unsub
|
|
117
|
+
// from it first.
|
|
118
|
+
if (subscribedSessionId !== null && subscribedSessionId !== sessionId) {
|
|
119
|
+
this.sessions.removeSubscriber(subscribedSessionId, subscriber);
|
|
120
|
+
}
|
|
121
|
+
subscribedSessionId = sessionId;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
socket.on('close', () => {
|
|
126
|
+
if (subscribedSessionId !== null) {
|
|
127
|
+
this.sessions.removeSubscriber(subscribedSessionId, subscriber);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async handleMessage(text, ctx) {
|
|
132
|
+
let frame;
|
|
133
|
+
try {
|
|
134
|
+
frame = JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Malformed JSON — silently drop per SPEC non-promise "no flow
|
|
138
|
+
// control / no retries". Real servers would log; the reference
|
|
139
|
+
// server keeps it silent so `no-op` fixtures pass.
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (frame === null || typeof frame !== 'object')
|
|
143
|
+
return;
|
|
144
|
+
const f = frame;
|
|
145
|
+
if (f['type'] === 'subscribe') {
|
|
146
|
+
this.handleSubscribe(f, {
|
|
147
|
+
subscriber: ctx.subscriber,
|
|
148
|
+
onSubscribed: ctx.onSubscribed,
|
|
149
|
+
socket: ctx.socket,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (f['type'] === 'action' && isActionFrame(frame)) {
|
|
154
|
+
const session = this.sessions.get(frame.sessionId);
|
|
155
|
+
if (session === undefined)
|
|
156
|
+
return; // drop actions for unknown sessions
|
|
157
|
+
await dispatchAction(frame, { session, tools: this.tools });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Unrecognized type — silently drop (extensibly-closed; third
|
|
161
|
+
// parties may send frame types we don't know about).
|
|
162
|
+
}
|
|
163
|
+
handleSubscribe(frame, ctx) {
|
|
164
|
+
const payload = frame['payload'];
|
|
165
|
+
if (payload === null || typeof payload !== 'object')
|
|
166
|
+
return;
|
|
167
|
+
const p = payload;
|
|
168
|
+
const sessionId = p['sessionId'];
|
|
169
|
+
if (typeof sessionId !== 'string')
|
|
170
|
+
return;
|
|
171
|
+
const appId = typeof p['appId'] === 'string' ? p['appId'] : 'conformance';
|
|
172
|
+
const requestId = typeof frame['requestId'] === 'string' ? frame['requestId'] : undefined;
|
|
173
|
+
const supportedVersions = Array.isArray(p['supportedVersions'])
|
|
174
|
+
? p['supportedVersions'].filter((v) => typeof v === 'string')
|
|
175
|
+
: undefined;
|
|
176
|
+
// Version handshake — if the client declared `supportedVersions`
|
|
177
|
+
// AND our current schema-version is not in the list, emit
|
|
178
|
+
// UPGRADE_REQUIRED per SPEC §12.2.2.
|
|
179
|
+
//
|
|
180
|
+
// - `strictVersionPolicy: true` (default): emit + close the
|
|
181
|
+
// WebSocket so the caller cannot proceed.
|
|
182
|
+
// - `strictVersionPolicy: false` (advisory opt-out): emit +
|
|
183
|
+
// keep the connection open.
|
|
184
|
+
//
|
|
185
|
+
// Per-session override precedence: if the `server-version-override`
|
|
186
|
+
// directive set a `versionOverride` on this session BEFORE the
|
|
187
|
+
// subscribe landed, advertise that value instead of the instance-
|
|
188
|
+
// level default. Lets parallel kit fixtures share one server while
|
|
189
|
+
// mismatching version on exactly one session.
|
|
190
|
+
const existingSession = this.sessions.get(sessionId);
|
|
191
|
+
const advertised = existingSession?.versionOverride ?? this.options.versionOverride;
|
|
192
|
+
if (supportedVersions !== undefined && !supportedVersions.includes(advertised)) {
|
|
193
|
+
ctx.subscriber.send({
|
|
194
|
+
type: 'error',
|
|
195
|
+
payload: {
|
|
196
|
+
code: 'UPGRADE_REQUIRED',
|
|
197
|
+
message: `server advertises '${advertised}'; client supports [${supportedVersions.join(', ')}]`,
|
|
198
|
+
serverVersion: advertised,
|
|
199
|
+
},
|
|
200
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
201
|
+
});
|
|
202
|
+
if (this.options.strictVersionPolicy) {
|
|
203
|
+
try {
|
|
204
|
+
ctx.socket.close();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// best-effort — socket may already be closing
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Add the subscriber + emit ack.
|
|
213
|
+
this.sessions.addSubscriber(sessionId, ctx.subscriber);
|
|
214
|
+
// Preserve appId on first subscribe — create() is no-op if the
|
|
215
|
+
// session already exists from an earlier directive.
|
|
216
|
+
this.sessions.create(sessionId, appId);
|
|
217
|
+
ctx.onSubscribed(sessionId);
|
|
218
|
+
ctx.subscriber.send({
|
|
219
|
+
type: 'ack',
|
|
220
|
+
payload: { serverVersion: advertised },
|
|
221
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory session store for the reference server.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are ephemeral and process-local — this is the whole
|
|
5
|
+
* point of the reference server. Persistence is explicitly out of
|
|
6
|
+
* scope. Restart drops state; that's documented behavior, not a TODO.
|
|
7
|
+
*
|
|
8
|
+
* Each session carries an actionSpec map that the `register-actionspec`
|
|
9
|
+
* ConformanceHost directive populates. The action router
|
|
10
|
+
* (`./action-router.ts`) consults this map at dispatch time to
|
|
11
|
+
* resolve action-name → tool-name → handler.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Actionspec entry maps an action name (the value wired into DOM
|
|
15
|
+
* `data-ggui-action` attributes on real UIs; here sent verbatim on
|
|
16
|
+
* the fixture's inputEnvelope) to the registered tool name the
|
|
17
|
+
* router should dispatch to.
|
|
18
|
+
*/
|
|
19
|
+
export interface ActionSpecEntry {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly tool: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Streamspec entry binds a stream channel to a "refresh tool" — the
|
|
25
|
+
* tool the action-router invokes after a successful wired-action
|
|
26
|
+
* dispatch to produce the channel's next snapshot. Mirrors the real
|
|
27
|
+
* `streamSpec[channel].tool` shape declared by ggui blueprints (SPEC
|
|
28
|
+
* §2.3 StreamSpec refresh triggers): a wired action mutates state,
|
|
29
|
+
* the refresh tool reads fresh state, the stream-update wraps the
|
|
30
|
+
* read into an envelope on the named channel.
|
|
31
|
+
*
|
|
32
|
+
* Reference-server scope: refresh-tool invocation is unconditional
|
|
33
|
+
* — every successful wired-action dispatch fans out through every
|
|
34
|
+
* registered streamSpec for the session. Real ggui servers may
|
|
35
|
+
* filter by which actions touch which channels; the reference
|
|
36
|
+
* server's narrower contract is "any successful action triggers all
|
|
37
|
+
* declared refreshes", which is sufficient for the kit's
|
|
38
|
+
* `stream-refresh-success` proof and stays under the package's
|
|
39
|
+
* 20–50 LOC budget for refresh-stream support.
|
|
40
|
+
*/
|
|
41
|
+
export interface StreamSpecEntry {
|
|
42
|
+
readonly channel: string;
|
|
43
|
+
readonly tool: string;
|
|
44
|
+
}
|
|
45
|
+
export interface Session {
|
|
46
|
+
readonly sessionId: string;
|
|
47
|
+
readonly appId: string;
|
|
48
|
+
readonly actionSpecs: Map<string, ActionSpecEntry>;
|
|
49
|
+
readonly streamSpecs: Map<string, StreamSpecEntry>;
|
|
50
|
+
readonly subscribers: Set<Subscriber>;
|
|
51
|
+
/**
|
|
52
|
+
* Per-session protocol-version override. When set, the WS subscribe
|
|
53
|
+
* + UPGRADE_REQUIRED paths advertise this value in place of the
|
|
54
|
+
* server-instance-level `versionOverride`.
|
|
55
|
+
*
|
|
56
|
+
* Discipline (mirrors `ReferenceServerOptions.versionOverride`):
|
|
57
|
+
* conformance fault-injection ONLY. Populated exclusively by the
|
|
58
|
+
* `server-version-override` setup directive in the conformance host
|
|
59
|
+
* adapter — production code paths leave this `undefined`.
|
|
60
|
+
*
|
|
61
|
+
* Why per-session, not per-instance: parallel kit fixtures share
|
|
62
|
+
* one `ReferenceServer`. Mutating the instance-level override would
|
|
63
|
+
* leak across sessions; the per-session field scopes the mismatch
|
|
64
|
+
* to the one fixture that asked for it.
|
|
65
|
+
*/
|
|
66
|
+
versionOverride?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Minimal subscriber handle — the action router calls `send()` to
|
|
70
|
+
* emit stream frames (including `_ggui:contract-error`) back to the
|
|
71
|
+
* subscribed WebSocket.
|
|
72
|
+
*/
|
|
73
|
+
export interface Subscriber {
|
|
74
|
+
send(frame: unknown): void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* In-memory session store. Wraps a `Map<sessionId, Session>` with
|
|
78
|
+
* the operations the ConformanceHost adapter + WS subscribe handler
|
|
79
|
+
* need. No locking — JS single-threaded; all calls originate from
|
|
80
|
+
* the event loop.
|
|
81
|
+
*/
|
|
82
|
+
export declare class SessionStore {
|
|
83
|
+
private readonly sessions;
|
|
84
|
+
private lastCreated;
|
|
85
|
+
create(sessionId: string, appId: string): Session;
|
|
86
|
+
/**
|
|
87
|
+
* The sessionId most recently passed to `create()`. Used by the
|
|
88
|
+
* ConformanceHost's `register-actionspec` dispatcher — that
|
|
89
|
+
* directive doesn't carry a sessionId in its JSON shape, so the
|
|
90
|
+
* adapter needs the "most recently created" scope to bind the
|
|
91
|
+
* actionspec to. This matches the fixture-authoring convention that
|
|
92
|
+
* create-session always precedes register-actionspec.
|
|
93
|
+
*/
|
|
94
|
+
lastCreatedSessionId(): string | undefined;
|
|
95
|
+
get(sessionId: string): Session | undefined;
|
|
96
|
+
close(sessionId: string): boolean;
|
|
97
|
+
addSubscriber(sessionId: string, subscriber: Subscriber): Session;
|
|
98
|
+
removeSubscriber(sessionId: string, subscriber: Subscriber): void;
|
|
99
|
+
registerActionSpec(sessionId: string, entry: ActionSpecEntry): void;
|
|
100
|
+
/**
|
|
101
|
+
* Register a stream channel ↔ refresh-tool binding on the named
|
|
102
|
+
* session. Same "create-if-missing" semantics as
|
|
103
|
+
* {@link registerActionSpec} so the ConformanceHost adapter can
|
|
104
|
+
* dispatch this directive before subscribe lands. Keyed by
|
|
105
|
+
* `entry.channel` — registering the same channel twice replaces
|
|
106
|
+
* the prior binding, matching the action-spec map's behavior.
|
|
107
|
+
*/
|
|
108
|
+
registerStreamSpec(sessionId: string, entry: StreamSpecEntry): void;
|
|
109
|
+
/**
|
|
110
|
+
* Set the per-session protocol-version override. Used by the
|
|
111
|
+
* `server-version-override` ConformanceHost directive — populates
|
|
112
|
+
* {@link Session.versionOverride} so the WS subscribe handler
|
|
113
|
+
* advertises this value (and emits UPGRADE_REQUIRED keyed off it)
|
|
114
|
+
* for THIS session only, leaving parallel sessions on the instance-
|
|
115
|
+
* level default.
|
|
116
|
+
*
|
|
117
|
+
* Same "create-if-missing" semantics as the other register* setters
|
|
118
|
+
* so directive ordering relative to subscribe doesn't matter.
|
|
119
|
+
*/
|
|
120
|
+
setVersionOverride(sessionId: string, version: string): void;
|
|
121
|
+
/**
|
|
122
|
+
* Fan out a frame to every subscriber on the named session. Used by
|
|
123
|
+
* the `emit-envelope` ConformanceHost directive — kit fixtures use
|
|
124
|
+
* it to inject WS-observable side-effects (envelopes the server
|
|
125
|
+
* would not normally emit on its own) so the kit can assert
|
|
126
|
+
* downstream consequences (sequencing, fan-out, observability).
|
|
127
|
+
*
|
|
128
|
+
* Returns `true` if the session existed and at least one subscriber
|
|
129
|
+
* received the frame; `false` if the session is unknown OR has no
|
|
130
|
+
* subscribers attached. Caller may use the boolean to log a warning
|
|
131
|
+
* when a fixture's directive-injection lands before any subscribe
|
|
132
|
+
* — the directive then has no observable effect, which is usually
|
|
133
|
+
* a fixture-authoring bug worth surfacing.
|
|
134
|
+
*
|
|
135
|
+
* Subscriber-level send failures (closed socket, etc.) are
|
|
136
|
+
* swallowed per the same convention as the action router's
|
|
137
|
+
* `broadcast()` — one bad subscriber must not block fan-out to the
|
|
138
|
+
* rest.
|
|
139
|
+
*/
|
|
140
|
+
injectFrame(sessionId: string, frame: unknown): boolean;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACtC;;;;;;;;;;;;;;OAcG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;GAKG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;IACvD,OAAO,CAAC,WAAW,CAAqB;IAExC,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAkBjD;;;;;;;OAOG;IACH,oBAAoB,IAAI,MAAM,GAAG,SAAS;IAI1C,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS;IAI3C,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAIjC,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,OAAO;IAMjE,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI;IAMjE,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI;IAKnE;;;;;;;OAOG;IACH,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,IAAI;IAKnE;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAK5D;;;;;;;;;;;;;;;;;;OAkBG;IACH,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO;CAcxD"}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory session store for the reference server.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are ephemeral and process-local — this is the whole
|
|
5
|
+
* point of the reference server. Persistence is explicitly out of
|
|
6
|
+
* scope. Restart drops state; that's documented behavior, not a TODO.
|
|
7
|
+
*
|
|
8
|
+
* Each session carries an actionSpec map that the `register-actionspec`
|
|
9
|
+
* ConformanceHost directive populates. The action router
|
|
10
|
+
* (`./action-router.ts`) consults this map at dispatch time to
|
|
11
|
+
* resolve action-name → tool-name → handler.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* In-memory session store. Wraps a `Map<sessionId, Session>` with
|
|
15
|
+
* the operations the ConformanceHost adapter + WS subscribe handler
|
|
16
|
+
* need. No locking — JS single-threaded; all calls originate from
|
|
17
|
+
* the event loop.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionStore {
|
|
20
|
+
sessions = new Map();
|
|
21
|
+
lastCreated;
|
|
22
|
+
create(sessionId, appId) {
|
|
23
|
+
const existing = this.sessions.get(sessionId);
|
|
24
|
+
if (existing !== undefined) {
|
|
25
|
+
this.lastCreated = sessionId;
|
|
26
|
+
return existing;
|
|
27
|
+
}
|
|
28
|
+
const session = {
|
|
29
|
+
sessionId,
|
|
30
|
+
appId,
|
|
31
|
+
actionSpecs: new Map(),
|
|
32
|
+
streamSpecs: new Map(),
|
|
33
|
+
subscribers: new Set(),
|
|
34
|
+
};
|
|
35
|
+
this.sessions.set(sessionId, session);
|
|
36
|
+
this.lastCreated = sessionId;
|
|
37
|
+
return session;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The sessionId most recently passed to `create()`. Used by the
|
|
41
|
+
* ConformanceHost's `register-actionspec` dispatcher — that
|
|
42
|
+
* directive doesn't carry a sessionId in its JSON shape, so the
|
|
43
|
+
* adapter needs the "most recently created" scope to bind the
|
|
44
|
+
* actionspec to. This matches the fixture-authoring convention that
|
|
45
|
+
* create-session always precedes register-actionspec.
|
|
46
|
+
*/
|
|
47
|
+
lastCreatedSessionId() {
|
|
48
|
+
return this.lastCreated;
|
|
49
|
+
}
|
|
50
|
+
get(sessionId) {
|
|
51
|
+
return this.sessions.get(sessionId);
|
|
52
|
+
}
|
|
53
|
+
close(sessionId) {
|
|
54
|
+
return this.sessions.delete(sessionId);
|
|
55
|
+
}
|
|
56
|
+
addSubscriber(sessionId, subscriber) {
|
|
57
|
+
const session = this.create(sessionId, 'conformance');
|
|
58
|
+
session.subscribers.add(subscriber);
|
|
59
|
+
return session;
|
|
60
|
+
}
|
|
61
|
+
removeSubscriber(sessionId, subscriber) {
|
|
62
|
+
const session = this.sessions.get(sessionId);
|
|
63
|
+
if (session === undefined)
|
|
64
|
+
return;
|
|
65
|
+
session.subscribers.delete(subscriber);
|
|
66
|
+
}
|
|
67
|
+
registerActionSpec(sessionId, entry) {
|
|
68
|
+
const session = this.create(sessionId, 'conformance');
|
|
69
|
+
session.actionSpecs.set(entry.name, entry);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Register a stream channel ↔ refresh-tool binding on the named
|
|
73
|
+
* session. Same "create-if-missing" semantics as
|
|
74
|
+
* {@link registerActionSpec} so the ConformanceHost adapter can
|
|
75
|
+
* dispatch this directive before subscribe lands. Keyed by
|
|
76
|
+
* `entry.channel` — registering the same channel twice replaces
|
|
77
|
+
* the prior binding, matching the action-spec map's behavior.
|
|
78
|
+
*/
|
|
79
|
+
registerStreamSpec(sessionId, entry) {
|
|
80
|
+
const session = this.create(sessionId, 'conformance');
|
|
81
|
+
session.streamSpecs.set(entry.channel, entry);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Set the per-session protocol-version override. Used by the
|
|
85
|
+
* `server-version-override` ConformanceHost directive — populates
|
|
86
|
+
* {@link Session.versionOverride} so the WS subscribe handler
|
|
87
|
+
* advertises this value (and emits UPGRADE_REQUIRED keyed off it)
|
|
88
|
+
* for THIS session only, leaving parallel sessions on the instance-
|
|
89
|
+
* level default.
|
|
90
|
+
*
|
|
91
|
+
* Same "create-if-missing" semantics as the other register* setters
|
|
92
|
+
* so directive ordering relative to subscribe doesn't matter.
|
|
93
|
+
*/
|
|
94
|
+
setVersionOverride(sessionId, version) {
|
|
95
|
+
const session = this.create(sessionId, 'conformance');
|
|
96
|
+
session.versionOverride = version;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Fan out a frame to every subscriber on the named session. Used by
|
|
100
|
+
* the `emit-envelope` ConformanceHost directive — kit fixtures use
|
|
101
|
+
* it to inject WS-observable side-effects (envelopes the server
|
|
102
|
+
* would not normally emit on its own) so the kit can assert
|
|
103
|
+
* downstream consequences (sequencing, fan-out, observability).
|
|
104
|
+
*
|
|
105
|
+
* Returns `true` if the session existed and at least one subscriber
|
|
106
|
+
* received the frame; `false` if the session is unknown OR has no
|
|
107
|
+
* subscribers attached. Caller may use the boolean to log a warning
|
|
108
|
+
* when a fixture's directive-injection lands before any subscribe
|
|
109
|
+
* — the directive then has no observable effect, which is usually
|
|
110
|
+
* a fixture-authoring bug worth surfacing.
|
|
111
|
+
*
|
|
112
|
+
* Subscriber-level send failures (closed socket, etc.) are
|
|
113
|
+
* swallowed per the same convention as the action router's
|
|
114
|
+
* `broadcast()` — one bad subscriber must not block fan-out to the
|
|
115
|
+
* rest.
|
|
116
|
+
*/
|
|
117
|
+
injectFrame(sessionId, frame) {
|
|
118
|
+
const session = this.sessions.get(sessionId);
|
|
119
|
+
if (session === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
if (session.subscribers.size === 0)
|
|
122
|
+
return false;
|
|
123
|
+
for (const subscriber of session.subscribers) {
|
|
124
|
+
try {
|
|
125
|
+
subscriber.send(frame);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Subscriber lifecycle issues (closed socket, etc.) are the
|
|
129
|
+
// subscriber's problem — the store keeps fanning out.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry — the 4 handler kinds the reference server wires
|
|
3
|
+
* through wired-action dispatch. Names match the `handler` enumeration
|
|
4
|
+
* in `packages/protocol-conformance/src/conformance-host.ts`'s
|
|
5
|
+
* `RegisterToolSetup` so the conformance kit's `register-tool`
|
|
6
|
+
* directive drops through cleanly.
|
|
7
|
+
*
|
|
8
|
+
* The four handler kinds:
|
|
9
|
+
*
|
|
10
|
+
* - `echo` — returns `{received: args}`.
|
|
11
|
+
* - `throw` — rejects with `Error('tool_threw_for_fixture')`.
|
|
12
|
+
* - `timeout` — never resolves; router enforces a 500ms timeout.
|
|
13
|
+
* - `malformed`— returns `{wrong: 'shape'}` to exercise
|
|
14
|
+
* SCHEMA_VIOLATION.
|
|
15
|
+
*
|
|
16
|
+
* `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
|
|
17
|
+
* to an action whose tool is NOT in the registry; no handler needed.
|
|
18
|
+
*
|
|
19
|
+
* Handlers are declarative: they return `{status: 'resolved', value}`
|
|
20
|
+
* or throw. The router consults the return shape against the
|
|
21
|
+
* declared channel schema (minimal — currently only `malformed` is
|
|
22
|
+
* flagged) and maps unsupported cases to `_ggui:contract-error` with
|
|
23
|
+
* the matching ContractErrorCode.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* One tool's executable behavior. Async so `timeout` can return a
|
|
27
|
+
* never-resolving promise the router bounds with a timer.
|
|
28
|
+
*/
|
|
29
|
+
export type ToolHandler = (args: unknown) => Promise<unknown>;
|
|
30
|
+
export type ToolHandlerKind = 'echo' | 'throw' | 'timeout' | 'malformed' | 'malformed-stream' | 'list-snapshot' | (string & {});
|
|
31
|
+
/**
|
|
32
|
+
* Registered tool — the handler + its declared behavior kind so
|
|
33
|
+
* the router can match against fixture expectations.
|
|
34
|
+
*/
|
|
35
|
+
export interface RegisteredTool {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly kind: ToolHandlerKind;
|
|
38
|
+
readonly handler: ToolHandler;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build one of the four canonical handlers by kind. Unknown kinds
|
|
42
|
+
* throw — the caller (ConformanceHost adapter or setup-step
|
|
43
|
+
* dispatcher) MUST surface this as an honest "handler not implemented"
|
|
44
|
+
* error so the kit records a SKIP with the error message as reason.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildHandler(kind: ToolHandlerKind): ToolHandler;
|
|
47
|
+
/**
|
|
48
|
+
* In-memory tool registry. Scoped to a session via the action router
|
|
49
|
+
* (the plan's register-tool directive wires a handler under the
|
|
50
|
+
* session's tool namespace). No-persistence by design.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ToolRegistry {
|
|
53
|
+
private readonly tools;
|
|
54
|
+
register(name: string, kind: ToolHandlerKind): void;
|
|
55
|
+
unregister(name: string): boolean;
|
|
56
|
+
get(name: string): RegisteredTool | undefined;
|
|
57
|
+
has(name: string): boolean;
|
|
58
|
+
/** Name of the first tool registered on this registry, or
|
|
59
|
+
* `undefined` if none. Used by the action-router as a fallback
|
|
60
|
+
* when no explicit action→tool binding exists. */
|
|
61
|
+
firstRegistered(): string | undefined;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=tool-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE9D,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,OAAO,GACP,SAAS,GACT,WAAW,GACX,kBAAkB,GAClB,eAAe,GACf,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAYD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAkC/D;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI;IAInD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIjC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAI7C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAI1B;;uDAEmD;IACnD,eAAe,IAAI,MAAM,GAAG,SAAS;CAItC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry — the 4 handler kinds the reference server wires
|
|
3
|
+
* through wired-action dispatch. Names match the `handler` enumeration
|
|
4
|
+
* in `packages/protocol-conformance/src/conformance-host.ts`'s
|
|
5
|
+
* `RegisterToolSetup` so the conformance kit's `register-tool`
|
|
6
|
+
* directive drops through cleanly.
|
|
7
|
+
*
|
|
8
|
+
* The four handler kinds:
|
|
9
|
+
*
|
|
10
|
+
* - `echo` — returns `{received: args}`.
|
|
11
|
+
* - `throw` — rejects with `Error('tool_threw_for_fixture')`.
|
|
12
|
+
* - `timeout` — never resolves; router enforces a 500ms timeout.
|
|
13
|
+
* - `malformed`— returns `{wrong: 'shape'}` to exercise
|
|
14
|
+
* SCHEMA_VIOLATION.
|
|
15
|
+
*
|
|
16
|
+
* `TOOL_NOT_FOUND` is the 5th failure path — exercised by dispatching
|
|
17
|
+
* to an action whose tool is NOT in the registry; no handler needed.
|
|
18
|
+
*
|
|
19
|
+
* Handlers are declarative: they return `{status: 'resolved', value}`
|
|
20
|
+
* or throw. The router consults the return shape against the
|
|
21
|
+
* declared channel schema (minimal — currently only `malformed` is
|
|
22
|
+
* flagged) and maps unsupported cases to `_ggui:contract-error` with
|
|
23
|
+
* the matching ContractErrorCode.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Returns a never-resolving promise. The router pairs it with a
|
|
27
|
+
* timer to emit `TOOL_TIMEOUT` contract-error after N ms.
|
|
28
|
+
*/
|
|
29
|
+
function neverResolve() {
|
|
30
|
+
return new Promise(() => {
|
|
31
|
+
/* deliberate: never settles */
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build one of the four canonical handlers by kind. Unknown kinds
|
|
36
|
+
* throw — the caller (ConformanceHost adapter or setup-step
|
|
37
|
+
* dispatcher) MUST surface this as an honest "handler not implemented"
|
|
38
|
+
* error so the kit records a SKIP with the error message as reason.
|
|
39
|
+
*/
|
|
40
|
+
export function buildHandler(kind) {
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case 'echo':
|
|
43
|
+
return async (args) => ({ received: args });
|
|
44
|
+
case 'throw':
|
|
45
|
+
return async () => {
|
|
46
|
+
throw new Error('tool_threw_for_fixture');
|
|
47
|
+
};
|
|
48
|
+
case 'timeout':
|
|
49
|
+
return () => neverResolve();
|
|
50
|
+
case 'malformed':
|
|
51
|
+
case 'malformed-stream':
|
|
52
|
+
// Both kinds return a shape that does not match the declared
|
|
53
|
+
// channel schema — router maps to SCHEMA_VIOLATION.
|
|
54
|
+
// `malformed-stream` is the fixture-authored alias used by
|
|
55
|
+
// `stream-schema-violation` in the kit.
|
|
56
|
+
return async () => ({ wrong: 'shape' });
|
|
57
|
+
case 'list-snapshot':
|
|
58
|
+
// Refresh-tool kind: returns a deterministic empty list snapshot
|
|
59
|
+
// (`{ items: [] }`). Models the "list-fresh-state" role of a
|
|
60
|
+
// streamSpec refresh tool (real ggui blueprints' `tasks_list` /
|
|
61
|
+
// `notes_list` etc.). The reference server runs handlers
|
|
62
|
+
// statelessly — the snapshot intentionally does NOT reflect
|
|
63
|
+
// prior `tasks_create` calls, since modeling stateful in-memory
|
|
64
|
+
// stores is out of scope for the smallest conformant impl. The
|
|
65
|
+
// kit's `stream-refresh-success` matcher only asserts the
|
|
66
|
+
// channel-update arrived with the declared shape, not that the
|
|
67
|
+
// payload reflects mutation history.
|
|
68
|
+
return async () => ({ items: [] });
|
|
69
|
+
default:
|
|
70
|
+
throw new Error(`reference-server: tool handler kind '${String(kind)}' is not recognized — supported: echo, throw, timeout, malformed, malformed-stream, list-snapshot`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* In-memory tool registry. Scoped to a session via the action router
|
|
75
|
+
* (the plan's register-tool directive wires a handler under the
|
|
76
|
+
* session's tool namespace). No-persistence by design.
|
|
77
|
+
*/
|
|
78
|
+
export class ToolRegistry {
|
|
79
|
+
tools = new Map();
|
|
80
|
+
register(name, kind) {
|
|
81
|
+
this.tools.set(name, { name, kind, handler: buildHandler(kind) });
|
|
82
|
+
}
|
|
83
|
+
unregister(name) {
|
|
84
|
+
return this.tools.delete(name);
|
|
85
|
+
}
|
|
86
|
+
get(name) {
|
|
87
|
+
return this.tools.get(name);
|
|
88
|
+
}
|
|
89
|
+
has(name) {
|
|
90
|
+
return this.tools.has(name);
|
|
91
|
+
}
|
|
92
|
+
/** Name of the first tool registered on this registry, or
|
|
93
|
+
* `undefined` if none. Used by the action-router as a fallback
|
|
94
|
+
* when no explicit action→tool binding exists. */
|
|
95
|
+
firstRegistered() {
|
|
96
|
+
const it = this.tools.keys().next();
|
|
97
|
+
return it.done === true ? undefined : it.value;
|
|
98
|
+
}
|
|
99
|
+
}
|