@zhin.js/host-http 1.0.6 → 1.0.9
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 +5 -0
- package/lib/console-rest-pages.d.ts +5 -2
- package/lib/console-rest-pages.js +90 -67
- package/lib/console-rpc-extended.d.ts +4 -6
- package/lib/console-rpc-extended.js +73 -124
- package/lib/console-rpc.d.ts +2 -2
- package/lib/console-rpc.js +6 -6
- package/lib/http-host.d.ts +8 -5
- package/lib/http-host.js +141 -64
- package/lib/index.d.ts +1 -1
- package/package.json +5 -5
- package/src/console-rest-pages.ts +98 -73
- package/src/console-rpc-extended.ts +78 -125
- package/src/console-rpc.ts +8 -8
- package/src/http-host.ts +174 -72
- package/src/index.ts +1 -0
package/lib/console-rpc.js
CHANGED
|
@@ -542,8 +542,8 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
542
542
|
try {
|
|
543
543
|
const data = message;
|
|
544
544
|
const adapter = String(data.$adapter ?? '');
|
|
545
|
-
const
|
|
546
|
-
if (!adapter || !
|
|
545
|
+
const endpointKey = String(data.$endpoint ?? '');
|
|
546
|
+
if (!adapter || !endpointKey) {
|
|
547
547
|
emit({ requestId, error: '$adapter and $endpoint required' });
|
|
548
548
|
return payloads;
|
|
549
549
|
}
|
|
@@ -551,7 +551,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
551
551
|
emit({ requestId, error: 'Endpoint registry is not configured' });
|
|
552
552
|
return payloads;
|
|
553
553
|
}
|
|
554
|
-
const endpoint = await ctx.getEndpoint(adapter,
|
|
554
|
+
const endpoint = await ctx.getEndpoint(adapter, endpointKey);
|
|
555
555
|
if (!endpoint) {
|
|
556
556
|
emit({ requestId, error: 'endpoint not found' });
|
|
557
557
|
return payloads;
|
|
@@ -570,12 +570,12 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
570
570
|
try {
|
|
571
571
|
const data = message;
|
|
572
572
|
const adapter = String(data.$adapter ?? '');
|
|
573
|
-
const
|
|
573
|
+
const endpointKey = String(data.$endpoint ?? '');
|
|
574
574
|
const channelId = String(data.$channel_id ?? '');
|
|
575
575
|
const channelType = String(data.$channel_type ?? '');
|
|
576
576
|
const content = data.$content;
|
|
577
577
|
// channelType 可省:wireConversation 归一为 private(与旧 RPC 行为对齐)。
|
|
578
|
-
if (!adapter || !
|
|
578
|
+
if (!adapter || !endpointKey || !channelId || content === undefined) {
|
|
579
579
|
emit({
|
|
580
580
|
requestId,
|
|
581
581
|
error: '$adapter, $endpoint, $channel_id, $content required',
|
|
@@ -588,7 +588,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
588
588
|
}
|
|
589
589
|
const result = await ctx.sendEndpointMessage({
|
|
590
590
|
adapter,
|
|
591
|
-
|
|
591
|
+
endpointKey,
|
|
592
592
|
conversation: wireConversation(channelType, channelId, data.$parent),
|
|
593
593
|
content,
|
|
594
594
|
});
|
package/lib/http-host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import {
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
|
|
3
4
|
import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
|
|
4
5
|
import { type ListedRoute, type RouteMeta } from './openapi.js';
|
|
5
6
|
export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
|
|
@@ -37,14 +38,16 @@ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, u
|
|
|
37
38
|
export interface HttpRouteRegistration {
|
|
38
39
|
(): void;
|
|
39
40
|
}
|
|
40
|
-
export interface HttpHost {
|
|
41
|
+
export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
|
|
41
42
|
ws(path: string): WsHandle;
|
|
42
43
|
route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
|
|
43
44
|
listRoutes(): readonly ListedRoute[];
|
|
44
|
-
listen(): Promise<HttpHostAddress>;
|
|
45
|
-
close(): Promise<void>;
|
|
46
45
|
get address(): HttpHostAddress | undefined;
|
|
47
46
|
get tokenRegistry(): TokenRegistry;
|
|
48
47
|
}
|
|
48
|
+
export interface ProcessHttpHost extends HttpHost {
|
|
49
|
+
listen(): Promise<HttpHostAddress>;
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
}
|
|
49
52
|
export declare const httpHostToken: import("@zhin.js/plugin-runtime").Token<HttpHost>;
|
|
50
|
-
export declare function createHttpHost(options?: HttpHostOptions):
|
|
53
|
+
export declare function createHttpHost(options?: HttpHostOptions): ProcessHttpHost;
|
package/lib/http-host.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createServer, } from 'node:http';
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
|
-
import { createToken } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
|
|
5
5
|
import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
6
6
|
import { buildOpenApiDocument, } from './openapi.js';
|
|
7
7
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
8
8
|
import { HttpBodyError } from './json-body.js';
|
|
9
|
-
const logger = getLogger('
|
|
9
|
+
const logger = getLogger('http');
|
|
10
10
|
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
11
11
|
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
12
12
|
export const httpHostToken = createToken('zhin.host.http');
|
|
@@ -26,6 +26,7 @@ export function createHttpHost(options = {}) {
|
|
|
26
26
|
const httpRoutes = [];
|
|
27
27
|
let address;
|
|
28
28
|
let closed = false;
|
|
29
|
+
let closeResult;
|
|
29
30
|
const server = createServer((request, response) => {
|
|
30
31
|
void dispatchHttp(request, response);
|
|
31
32
|
});
|
|
@@ -78,7 +79,13 @@ export function createHttpHost(options = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
const pathname = upgradePath(request.url);
|
|
80
81
|
const listeners = wsRoutes.get(pathname);
|
|
81
|
-
|
|
82
|
+
const admitted = listeners ? [...listeners].flatMap((entry) => {
|
|
83
|
+
const release = entry.admission?.acquire();
|
|
84
|
+
if (entry.admission && !release)
|
|
85
|
+
return [];
|
|
86
|
+
return [{ entry, release }];
|
|
87
|
+
}) : [];
|
|
88
|
+
if (admitted.length === 0) {
|
|
82
89
|
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
83
90
|
socket.destroy();
|
|
84
91
|
return;
|
|
@@ -87,20 +94,46 @@ export function createHttpHost(options = {}) {
|
|
|
87
94
|
if (!auth.ok) {
|
|
88
95
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
89
96
|
socket.destroy();
|
|
97
|
+
for (const item of admitted)
|
|
98
|
+
item.release?.();
|
|
90
99
|
return;
|
|
91
100
|
}
|
|
92
101
|
try {
|
|
93
102
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
103
|
+
let settled = false;
|
|
104
|
+
const retireSubscriptions = [];
|
|
105
|
+
const releaseAdmissions = () => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
settled = true;
|
|
109
|
+
for (const dispose of retireSubscriptions)
|
|
110
|
+
dispose();
|
|
111
|
+
for (const item of admitted)
|
|
112
|
+
item.release?.();
|
|
113
|
+
};
|
|
114
|
+
ws.once('close', releaseAdmissions);
|
|
115
|
+
ws.once('error', releaseAdmissions);
|
|
116
|
+
for (const item of admitted) {
|
|
117
|
+
if (item.entry.admission) {
|
|
118
|
+
retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
|
|
119
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
120
|
+
ws.close(1012, 'Generation retired');
|
|
121
|
+
}
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
94
125
|
const connection = Object.freeze({
|
|
95
126
|
socket: ws,
|
|
96
127
|
request,
|
|
97
128
|
authScope: auth.scope,
|
|
98
129
|
});
|
|
99
|
-
for (const
|
|
100
|
-
listener(connection);
|
|
130
|
+
for (const item of admitted)
|
|
131
|
+
item.entry.listener(connection);
|
|
101
132
|
});
|
|
102
133
|
}
|
|
103
134
|
catch (err) {
|
|
135
|
+
for (const item of admitted)
|
|
136
|
+
item.release?.();
|
|
104
137
|
logger.warn(formatCompact({
|
|
105
138
|
op: 'ws_upgrade_failed',
|
|
106
139
|
path: pathname,
|
|
@@ -178,7 +211,30 @@ export function createHttpHost(options = {}) {
|
|
|
178
211
|
applyCors(response, origin, corsOrigins);
|
|
179
212
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
180
213
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
214
|
+
const release = route.admission?.acquire();
|
|
215
|
+
if (route.admission && !release) {
|
|
216
|
+
response.writeHead(503);
|
|
217
|
+
response.end();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!release) {
|
|
221
|
+
await route.handler(request, response, url, auth.scope);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let released = false;
|
|
225
|
+
const releaseResponse = () => {
|
|
226
|
+
if (released)
|
|
227
|
+
return;
|
|
228
|
+
released = true;
|
|
229
|
+
response.off('finish', releaseResponse);
|
|
230
|
+
response.off('close', releaseResponse);
|
|
231
|
+
release();
|
|
232
|
+
};
|
|
233
|
+
response.once('finish', releaseResponse);
|
|
234
|
+
response.once('close', releaseResponse);
|
|
181
235
|
await route.handler(request, response, url, auth.scope);
|
|
236
|
+
if (response.writableFinished || response.destroyed)
|
|
237
|
+
releaseResponse();
|
|
182
238
|
}
|
|
183
239
|
catch (err) {
|
|
184
240
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
@@ -219,57 +275,75 @@ export function createHttpHost(options = {}) {
|
|
|
219
275
|
return { ok: true, scope };
|
|
220
276
|
}
|
|
221
277
|
function listListedRoutes() {
|
|
222
|
-
return Object.freeze(httpRoutes
|
|
278
|
+
return Object.freeze(httpRoutes
|
|
279
|
+
.filter((route) => !route.admission || route.admission.active)
|
|
280
|
+
.map((route) => Object.freeze({
|
|
223
281
|
method: route.method,
|
|
224
282
|
pattern: route.pattern,
|
|
225
283
|
meta: route.meta,
|
|
226
284
|
})));
|
|
227
285
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
286
|
+
function registerWs(pathname, admission) {
|
|
287
|
+
const normalized = normalizePath(pathname);
|
|
288
|
+
let listeners = wsRoutes.get(normalized);
|
|
289
|
+
if (!listeners) {
|
|
290
|
+
listeners = new Set();
|
|
291
|
+
wsRoutes.set(normalized, listeners);
|
|
292
|
+
}
|
|
293
|
+
const owned = new Set();
|
|
294
|
+
return {
|
|
295
|
+
onConnection(listener) {
|
|
296
|
+
const entry = Object.freeze({ listener, admission });
|
|
297
|
+
listeners.add(entry);
|
|
298
|
+
owned.add(entry);
|
|
299
|
+
return () => {
|
|
300
|
+
listeners.delete(entry);
|
|
301
|
+
owned.delete(entry);
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
close() {
|
|
305
|
+
for (const entry of owned)
|
|
306
|
+
listeners.delete(entry);
|
|
307
|
+
owned.clear();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function registerRoute(method, path, handler, meta, admission) {
|
|
312
|
+
const normalizedMethod = method.toUpperCase();
|
|
313
|
+
const prefix = path.endsWith('/*');
|
|
314
|
+
const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
|
|
315
|
+
const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
|
|
316
|
+
const entry = Object.freeze({
|
|
317
|
+
method: normalizedMethod,
|
|
318
|
+
path: normalizedPath,
|
|
319
|
+
pattern,
|
|
320
|
+
prefix,
|
|
321
|
+
handler,
|
|
322
|
+
meta,
|
|
323
|
+
admission,
|
|
324
|
+
});
|
|
325
|
+
httpRoutes.push(entry);
|
|
326
|
+
return () => {
|
|
327
|
+
const index = httpRoutes.indexOf(entry);
|
|
328
|
+
if (index >= 0)
|
|
329
|
+
httpRoutes.splice(index, 1);
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function createBoundHost(admission) {
|
|
333
|
+
const bound = {
|
|
334
|
+
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
335
|
+
ws: (path) => registerWs(path, admission),
|
|
336
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
|
|
337
|
+
listRoutes: () => listListedRoutes(),
|
|
338
|
+
get address() { return runtime.address; },
|
|
339
|
+
get tokenRegistry() { return tokenRegistry; },
|
|
340
|
+
};
|
|
341
|
+
return Object.freeze(bound);
|
|
342
|
+
}
|
|
343
|
+
const runtime = {
|
|
344
|
+
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
345
|
+
ws: (pathname) => registerWs(pathname),
|
|
346
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
273
347
|
listRoutes() {
|
|
274
348
|
return listListedRoutes();
|
|
275
349
|
},
|
|
@@ -290,31 +364,30 @@ export function createHttpHost(options = {}) {
|
|
|
290
364
|
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
291
365
|
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
292
366
|
address = Object.freeze({ host: publicHost, port: listenPort });
|
|
293
|
-
logger.info(
|
|
294
|
-
|
|
295
|
-
token: tokenRegistry.hasAnyToken()
|
|
367
|
+
logger.info(`listening http://${publicHost}:${listenPort}`
|
|
368
|
+
+ ` | routes: ${httpRoutes.length}`
|
|
369
|
+
+ ` | token: ${tokenRegistry.hasAnyToken()
|
|
296
370
|
? `${tokenRegistry.primaryTokenPrefixForLog()}…`
|
|
297
|
-
: '(none)'
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
http_routes: httpRoutes.length,
|
|
301
|
-
}));
|
|
371
|
+
: '(none)'}`);
|
|
372
|
+
logger.debug(`listen detail | cors: ${corsOrigins.join(',')}`
|
|
373
|
+
+ ` | ws: ${[...wsRoutes.keys()].join(',') || '(none)'}`);
|
|
302
374
|
resolve(address);
|
|
303
375
|
});
|
|
304
376
|
});
|
|
305
377
|
},
|
|
306
378
|
close() {
|
|
307
|
-
if (
|
|
308
|
-
return
|
|
379
|
+
if (closeResult)
|
|
380
|
+
return closeResult;
|
|
309
381
|
closed = true;
|
|
310
382
|
wsRoutes.clear();
|
|
311
383
|
httpRoutes.length = 0;
|
|
312
384
|
// Rollback paths dispose a host whose generation never reached listen().
|
|
313
385
|
if (!server.listening) {
|
|
314
386
|
address = undefined;
|
|
315
|
-
|
|
387
|
+
closeResult = Promise.resolve();
|
|
388
|
+
return closeResult;
|
|
316
389
|
}
|
|
317
|
-
|
|
390
|
+
closeResult = new Promise((resolve, reject) => {
|
|
318
391
|
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
319
392
|
// destroy tracked sockets and terminate WS clients before close(),
|
|
320
393
|
// otherwise the close callbacks would never fire.
|
|
@@ -339,8 +412,10 @@ export function createHttpHost(options = {}) {
|
|
|
339
412
|
});
|
|
340
413
|
});
|
|
341
414
|
});
|
|
415
|
+
return closeResult;
|
|
342
416
|
},
|
|
343
417
|
};
|
|
418
|
+
return runtime;
|
|
344
419
|
}
|
|
345
420
|
function requiresHttpAuth(pathname, apiBase, authExempt) {
|
|
346
421
|
if (pathname === '/pub' || pathname.startsWith('/pub/'))
|
|
@@ -380,6 +455,8 @@ function matchHttpRoute(routes, method, pathname) {
|
|
|
380
455
|
let best;
|
|
381
456
|
let bestLength = -1;
|
|
382
457
|
for (const route of routes) {
|
|
458
|
+
if (route.admission && !route.admission.active)
|
|
459
|
+
continue;
|
|
383
460
|
if (route.method !== method && route.method !== 'ALL')
|
|
384
461
|
continue;
|
|
385
462
|
if (route.prefix) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
1
|
+
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
2
2
|
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
3
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
4
|
export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"ws": "^8.21.1",
|
|
21
|
-
"@zhin.js/console-protocol": "1.1.
|
|
22
|
-
"@zhin.js/host-http-contract": "1.0.
|
|
23
|
-
"@zhin.js/logger": "1.0.
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
21
|
+
"@zhin.js/console-protocol": "1.1.1",
|
|
22
|
+
"@zhin.js/host-http-contract": "1.0.1",
|
|
23
|
+
"@zhin.js/logger": "1.0.76",
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.6"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
@@ -32,7 +32,10 @@ export interface ConsoleRestCtx {
|
|
|
32
32
|
* 可选:返回新 Runtime 的 agent 门面(见 {@link ConsoleAgentRuntime})。
|
|
33
33
|
* 未接线时 introspection 降级为空列表 + note,session tree 返回 503。
|
|
34
34
|
*/
|
|
35
|
-
readonly
|
|
35
|
+
readonly acquireAgentRuntime?: () => {
|
|
36
|
+
readonly value: ConsoleAgentRuntime;
|
|
37
|
+
release(): void;
|
|
38
|
+
} | null;
|
|
36
39
|
/** Database host(logs 页数据源:SystemLog 模型)。 */
|
|
37
40
|
readonly databaseHost?: {
|
|
38
41
|
readonly dialect: string;
|
|
@@ -65,7 +68,7 @@ export interface ConsoleAgentSessionTree {
|
|
|
65
68
|
): Promise<{ ok: boolean; message: string }>;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
/** `ctx.
|
|
71
|
+
/** `ctx.acquireAgentRuntime()` 返回值的最小结构约定(全部可选,逐项降级)。 */
|
|
69
72
|
export interface ConsoleAgentRuntime {
|
|
70
73
|
readonly introspection?: ConsoleAgentIntrospection;
|
|
71
74
|
readonly sessionTree?: ConsoleAgentSessionTree;
|
|
@@ -626,7 +629,7 @@ function registerIntrospectionRoutes(
|
|
|
626
629
|
const degradedNote = note ?? (missing
|
|
627
630
|
? kind === 'endpoints'
|
|
628
631
|
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
629
|
-
: 'Agent runtime 未装配(basic/cli 未接线
|
|
632
|
+
: 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
|
|
630
633
|
: undefined);
|
|
631
634
|
writeJson(response, 200, {
|
|
632
635
|
success: true,
|
|
@@ -644,8 +647,13 @@ function registerIntrospectionRoutes(
|
|
|
644
647
|
}
|
|
645
648
|
|
|
646
649
|
function agentIntrospection(ctx: ConsoleRestCtx): ConsoleAgentIntrospection | undefined {
|
|
647
|
-
const
|
|
648
|
-
|
|
650
|
+
const lease = ctx.acquireAgentRuntime?.();
|
|
651
|
+
if (!lease) return undefined;
|
|
652
|
+
try {
|
|
653
|
+
return lease.value.introspection;
|
|
654
|
+
} finally {
|
|
655
|
+
lease.release();
|
|
656
|
+
}
|
|
649
657
|
}
|
|
650
658
|
|
|
651
659
|
// ---------------------------------------------------------------------------
|
|
@@ -658,17 +666,22 @@ function registerAgentSessionRoutes(
|
|
|
658
666
|
ctx: ConsoleRestCtx,
|
|
659
667
|
): void {
|
|
660
668
|
const prefix = `${base}/agent/sessions/`;
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
669
|
+
const acquireSessionTree = (): {
|
|
670
|
+
readonly tree: ConsoleAgentSessionTree;
|
|
671
|
+
release(): void;
|
|
672
|
+
} | null => {
|
|
673
|
+
const lease = ctx.acquireAgentRuntime?.();
|
|
674
|
+
const tree = lease?.value.sessionTree;
|
|
675
|
+
if (!lease || !tree || typeof tree.resolveActiveSessionId !== 'function') {
|
|
676
|
+
lease?.release();
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
return { tree, release: () => lease.release() };
|
|
667
680
|
};
|
|
668
681
|
const unavailable = (response: ServerResponse) => {
|
|
669
682
|
writeJson(response, 503, {
|
|
670
683
|
success: false,
|
|
671
|
-
error: 'Agent session tree runtime 未就绪(新 Runtime 需经
|
|
684
|
+
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
|
|
672
685
|
});
|
|
673
686
|
};
|
|
674
687
|
|
|
@@ -679,32 +692,36 @@ function registerAgentSessionRoutes(
|
|
|
679
692
|
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
680
693
|
return;
|
|
681
694
|
}
|
|
682
|
-
const
|
|
683
|
-
if (!
|
|
695
|
+
const lease = acquireSessionTree();
|
|
696
|
+
if (!lease) {
|
|
684
697
|
unavailable(response);
|
|
685
698
|
return;
|
|
686
699
|
}
|
|
700
|
+
const runtime = lease.tree;
|
|
701
|
+
try {
|
|
702
|
+
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
703
|
+
if (!sessionId) {
|
|
704
|
+
writeJson(response, 404, {
|
|
705
|
+
success: false,
|
|
706
|
+
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
687
710
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
writeJson(response,
|
|
691
|
-
success:
|
|
692
|
-
|
|
711
|
+
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
712
|
+
const points = await runtime.listBranchPoints(sessionId);
|
|
713
|
+
writeJson(response, 200, {
|
|
714
|
+
success: true,
|
|
715
|
+
data: {
|
|
716
|
+
sessionKey: parsed.sessionKey,
|
|
717
|
+
sessionId,
|
|
718
|
+
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
719
|
+
points,
|
|
720
|
+
},
|
|
693
721
|
});
|
|
694
|
-
|
|
722
|
+
} finally {
|
|
723
|
+
lease.release();
|
|
695
724
|
}
|
|
696
|
-
|
|
697
|
-
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
698
|
-
const points = await runtime.listBranchPoints(sessionId);
|
|
699
|
-
writeJson(response, 200, {
|
|
700
|
-
success: true,
|
|
701
|
-
data: {
|
|
702
|
-
sessionKey: parsed.sessionKey,
|
|
703
|
-
sessionId,
|
|
704
|
-
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
705
|
-
points,
|
|
706
|
-
},
|
|
707
|
-
});
|
|
708
725
|
}, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
|
|
709
726
|
|
|
710
727
|
route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
|
|
@@ -714,21 +731,6 @@ function registerAgentSessionRoutes(
|
|
|
714
731
|
return;
|
|
715
732
|
}
|
|
716
733
|
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
717
|
-
const runtime = getSessionTree();
|
|
718
|
-
if (!runtime) {
|
|
719
|
-
unavailable(response);
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
724
|
-
if (!sessionId) {
|
|
725
|
-
writeJson(response, 404, {
|
|
726
|
-
success: false,
|
|
727
|
-
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
728
|
-
});
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
734
|
let body: Record<string, unknown>;
|
|
733
735
|
try {
|
|
734
736
|
body = (await readJsonBody(request) ?? {}) as Record<string, unknown>;
|
|
@@ -741,49 +743,72 @@ function registerAgentSessionRoutes(
|
|
|
741
743
|
}
|
|
742
744
|
const messageIdRaw = body.messageId;
|
|
743
745
|
const indexRaw = body.index;
|
|
744
|
-
|
|
745
746
|
let messageId: number | undefined;
|
|
747
|
+
let branchIndex: number | undefined;
|
|
746
748
|
if (messageIdRaw != null && messageIdRaw !== '') {
|
|
747
|
-
const
|
|
748
|
-
if (!Number.isFinite(
|
|
749
|
+
const value = Number(messageIdRaw);
|
|
750
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
749
751
|
writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
|
|
750
752
|
return;
|
|
751
753
|
}
|
|
752
|
-
messageId =
|
|
754
|
+
messageId = value;
|
|
753
755
|
} else if (indexRaw != null && indexRaw !== '') {
|
|
754
|
-
const
|
|
755
|
-
if (!Number.isFinite(
|
|
756
|
+
const value = Number(indexRaw);
|
|
757
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
756
758
|
writeJson(response, 400, { success: false, error: 'index 须为正整数' });
|
|
757
759
|
return;
|
|
758
760
|
}
|
|
759
|
-
|
|
761
|
+
branchIndex = value;
|
|
762
|
+
} else {
|
|
763
|
+
writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const lease = acquireSessionTree();
|
|
768
|
+
if (!lease) {
|
|
769
|
+
unavailable(response);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const runtime = lease.tree;
|
|
773
|
+
try {
|
|
774
|
+
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
775
|
+
if (!sessionId) {
|
|
776
|
+
writeJson(response, 404, {
|
|
777
|
+
success: false,
|
|
778
|
+
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
779
|
+
});
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (branchIndex !== undefined) {
|
|
784
|
+
const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
|
|
785
|
+
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
786
|
+
writeJson(response, result.ok ? 200 : 400, {
|
|
787
|
+
success: result.ok,
|
|
788
|
+
message: result.message,
|
|
789
|
+
data: {
|
|
790
|
+
sessionKey: parsed.sessionKey,
|
|
791
|
+
sessionId,
|
|
792
|
+
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
|
|
760
799
|
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
761
|
-
writeJson(response,
|
|
762
|
-
success:
|
|
763
|
-
message:
|
|
800
|
+
writeJson(response, ok ? 200 : 400, {
|
|
801
|
+
success: ok,
|
|
802
|
+
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
764
803
|
data: {
|
|
765
804
|
sessionKey: parsed.sessionKey,
|
|
766
805
|
sessionId,
|
|
767
806
|
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
768
807
|
},
|
|
769
808
|
});
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
773
|
-
return;
|
|
809
|
+
} finally {
|
|
810
|
+
lease.release();
|
|
774
811
|
}
|
|
775
|
-
|
|
776
|
-
const ok = await runtime.switchActiveLeaf(sessionId, messageId);
|
|
777
|
-
const session = await runtime.agentSessionStore.getBySessionId(sessionId);
|
|
778
|
-
writeJson(response, ok ? 200 : 400, {
|
|
779
|
-
success: ok,
|
|
780
|
-
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
781
|
-
data: {
|
|
782
|
-
sessionKey: parsed.sessionKey,
|
|
783
|
-
sessionId,
|
|
784
|
-
activeLeafMessageId: session?.active_leaf_message_id ?? null,
|
|
785
|
-
},
|
|
786
|
-
});
|
|
787
812
|
}, { summary: 'Switch agent session active leaf', tags: ['console', 'agent'] });
|
|
788
813
|
}
|
|
789
814
|
|