@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.
@@ -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 endpointId = String(data.$endpoint ?? '');
546
- if (!adapter || !endpointId) {
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, endpointId);
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 endpointId = String(data.$endpoint ?? '');
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 || !endpointId || !channelId || content === undefined) {
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
- endpointId,
591
+ endpointKey,
592
592
  conversation: wireConversation(channelType, channelId, data.$parent),
593
593
  content,
594
594
  });
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- import { type WebSocket } from 'ws';
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): HttpHost;
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('HttpHost');
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
- if (!listeners || listeners.size === 0) {
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 listener of listeners)
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.map((route) => Object.freeze({
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
- return {
229
- ws(pathname) {
230
- const normalized = normalizePath(pathname);
231
- let listeners = wsRoutes.get(normalized);
232
- if (!listeners) {
233
- listeners = new Set();
234
- wsRoutes.set(normalized, listeners);
235
- }
236
- const owned = new Set();
237
- return {
238
- onConnection(listener) {
239
- listeners.add(listener);
240
- owned.add(listener);
241
- return () => {
242
- listeners.delete(listener);
243
- owned.delete(listener);
244
- };
245
- },
246
- close() {
247
- for (const listener of owned)
248
- listeners.delete(listener);
249
- owned.clear();
250
- },
251
- };
252
- },
253
- route(method, path, handler, meta) {
254
- const normalizedMethod = method.toUpperCase();
255
- const prefix = path.endsWith('/*');
256
- const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
257
- const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
258
- const entry = Object.freeze({
259
- method: normalizedMethod,
260
- path: normalizedPath,
261
- pattern,
262
- prefix,
263
- handler,
264
- meta,
265
- });
266
- httpRoutes.push(entry);
267
- return () => {
268
- const index = httpRoutes.indexOf(entry);
269
- if (index >= 0)
270
- httpRoutes.splice(index, 1);
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(formatCompact({
294
- http: `http://${publicHost}:${listenPort}`,
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
- cors: corsOrigins.join(','),
299
- ws_routes: [...wsRoutes.keys()].join(',') || '(none)',
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 (closed)
308
- return Promise.resolve();
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
- return Promise.resolve();
387
+ closeResult = Promise.resolve();
388
+ return closeResult;
316
389
  }
317
- return new Promise((resolve, reject) => {
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.6",
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.0",
22
- "@zhin.js/host-http-contract": "1.0.0",
23
- "@zhin.js/logger": "1.0.75",
24
- "@zhin.js/plugin-runtime": "1.1.3"
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 getAgentRuntime?: () => unknown;
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.getAgentRuntime()` 返回值的最小结构约定(全部可选,逐项降级)。 */
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 未接线 getAgentRuntime)'
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 runtime = ctx.getAgentRuntime?.() as ConsoleAgentRuntime | null | undefined;
648
- return runtime && typeof runtime === 'object' ? runtime.introspection : undefined;
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 getSessionTree = (): ConsoleAgentSessionTree | null => {
662
- const runtime = ctx.getAgentRuntime?.() as ConsoleAgentRuntime | null | undefined;
663
- const tree = runtime && typeof runtime === 'object' ? runtime.sessionTree : undefined;
664
- if (!tree) return null;
665
- if (typeof tree.resolveActiveSessionId !== 'function') return null;
666
- return tree;
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 需经 getAgentRuntime 装配 sessionTree)',
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 runtime = getSessionTree();
683
- if (!runtime) {
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
- const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
689
- if (!sessionId) {
690
- writeJson(response, 404, {
691
- success: false,
692
- error: `未找到活跃会话:${parsed.sessionKey}`,
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
- return;
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 n = Number(messageIdRaw);
748
- if (!Number.isFinite(n) || n < 1) {
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 = n;
754
+ messageId = value;
753
755
  } else if (indexRaw != null && indexRaw !== '') {
754
- const index = Number(indexRaw);
755
- if (!Number.isFinite(index) || index < 1) {
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
- const result = await runtime.jumpToBranchIndex(sessionId, index);
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, result.ok ? 200 : 400, {
762
- success: result.ok,
763
- message: result.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
- return;
771
- } else {
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