@zhin.js/host-http 1.0.10 → 1.0.12

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.
@@ -1,13 +1,27 @@
1
1
  import type { ServerResponse } from 'node:http';
2
+ import type { ConsoleEventEnvelope, ConsoleEventHistoryPage, ConsoleEventHistoryQuery, ConsoleEventPayloadMap, KnownConsoleEventType } from '@zhin.js/console-protocol';
2
3
  /**
3
4
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
4
5
  * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
5
6
  * write 失败或连接断开自动摘除订阅。
6
7
  */
8
+ export interface ConsoleEventSubscriptionOptions {
9
+ readonly runtimeId?: string;
10
+ readonly after?: number;
11
+ }
7
12
  export interface ConsoleEventHub {
8
- publish(type: string, data: unknown): void;
9
- /** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
10
- subscribe(response: ServerResponse): () => void;
13
+ publish<Type extends string>(type: Type, data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown): ConsoleEventEnvelope<Type, Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown>;
14
+ history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
15
+ /** Atomically replay the resumable suffix, then join live fan-out. */
16
+ subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
17
+ readonly runtimeId: string;
11
18
  readonly subscriberCount: number;
12
19
  }
13
- export declare function createConsoleEventHub(): ConsoleEventHub;
20
+ export interface ConsoleEventHubOptions {
21
+ readonly runtimeId?: string;
22
+ readonly historyLimit?: number;
23
+ readonly historyByteLimit?: number;
24
+ readonly historyPageByteLimit?: number;
25
+ }
26
+ export declare function createConsoleEventHub(options?: ConsoleEventHubOptions): ConsoleEventHub;
27
+ export declare function serializeConsoleEvent(event: ConsoleEventEnvelope): string;
@@ -1,33 +1,145 @@
1
- export function createConsoleEventHub() {
1
+ import { randomUUID } from 'node:crypto';
2
+ const DEFAULT_HISTORY_LIMIT = 256;
3
+ const DEFAULT_PAGE_LIMIT = 200;
4
+ const MAX_PAGE_LIMIT = 500;
5
+ const MAX_EVENT_BYTES = 64 * 1024;
6
+ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
7
+ const DEFAULT_HISTORY_PAGE_BYTE_LIMIT = 1024 * 1024;
8
+ export function createConsoleEventHub(options = {}) {
2
9
  let nextId = 0;
10
+ const runtimeId = options.runtimeId ?? randomUUID();
11
+ const historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
12
+ const historyByteLimit = positiveInteger(options.historyByteLimit, DEFAULT_HISTORY_BYTE_LIMIT);
13
+ const historyPageByteLimit = positiveInteger(options.historyPageByteLimit, DEFAULT_HISTORY_PAGE_BYTE_LIMIT);
14
+ const journal = [];
15
+ let journalBytes = 0;
3
16
  const subscribers = new Set();
4
17
  const remove = (response) => {
5
18
  subscribers.delete(response);
6
19
  };
20
+ const write = (response, entry) => {
21
+ let failed = false;
22
+ const accepted = response.write(entry.frame, (error) => {
23
+ if (error) {
24
+ failed = true;
25
+ remove(response);
26
+ }
27
+ });
28
+ if (failed)
29
+ throw new Error('Console event subscriber rejected replay');
30
+ if (!accepted) {
31
+ remove(response);
32
+ response.destroy(new Error('Console event subscriber backpressure limit reached'));
33
+ throw new Error('Console event subscriber backpressure limit reached');
34
+ }
35
+ };
7
36
  return {
37
+ runtimeId,
8
38
  get subscriberCount() {
9
39
  return subscribers.size;
10
40
  },
11
41
  publish(type, data) {
12
- nextId += 1;
13
- const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
42
+ const snapshot = jsonSnapshot(data);
43
+ const event = Object.freeze({
44
+ runtimeId,
45
+ eventId: ++nextId,
46
+ type,
47
+ data: snapshot,
48
+ timestamp: Date.now(),
49
+ });
50
+ const frame = serializeConsoleEvent(event);
51
+ const entry = Object.freeze({ event, frame, bytes: Buffer.byteLength(frame, 'utf8') });
52
+ journal.push(entry);
53
+ journalBytes += entry.bytes;
54
+ while (journal.length > historyLimit || journalBytes > historyByteLimit) {
55
+ journalBytes -= journal.shift()?.bytes ?? 0;
56
+ }
14
57
  for (const response of [...subscribers]) {
15
58
  try {
16
- response.write(frame, (error) => {
17
- if (error)
18
- remove(response);
19
- });
59
+ write(response, entry);
20
60
  }
21
61
  catch {
22
62
  remove(response);
23
63
  }
24
64
  }
65
+ return event;
25
66
  },
26
- subscribe(response) {
27
- subscribers.add(response);
67
+ history(query = {}) {
68
+ const requestedAfter = nonNegativeInteger(query.after, 0);
69
+ const sameRuntime = !query.runtimeId || query.runtimeId === runtimeId;
70
+ const cursorAhead = requestedAfter > nextId;
71
+ const after = sameRuntime && !cursorAhead ? requestedAfter : 0;
72
+ const limit = Math.min(MAX_PAGE_LIMIT, positiveInteger(query.limit, DEFAULT_PAGE_LIMIT));
73
+ const oldestAvailableEventId = journal[0]?.event.eventId ?? null;
74
+ const latestEventId = nextId;
75
+ const gap = !sameRuntime || cursorAhead
76
+ || (oldestAvailableEventId !== null
77
+ ? after < oldestAvailableEventId - 1
78
+ : after < latestEventId);
79
+ const available = journal.filter((entry) => entry.event.eventId > after);
80
+ const selected = [];
81
+ let selectedBytes = 0;
82
+ for (const entry of available) {
83
+ if (selected.length >= limit)
84
+ break;
85
+ if (selected.length > 0 && selectedBytes + entry.bytes > historyPageByteLimit)
86
+ break;
87
+ selected.push(entry.event);
88
+ selectedBytes += entry.bytes;
89
+ }
90
+ const items = Object.freeze(selected);
91
+ const nextAfter = items.at(-1)?.eventId ?? after;
92
+ return Object.freeze({
93
+ runtimeId,
94
+ items,
95
+ oldestAvailableEventId,
96
+ latestEventId,
97
+ nextAfter,
98
+ hasMore: available.length > items.length,
99
+ gap,
100
+ });
101
+ },
102
+ subscribe(response, options = {}) {
103
+ // Replay and subscription are synchronous relative to publish(), closing
104
+ // the race between HTTP recovery and the live stream hand-off.
105
+ const requestedAfter = nonNegativeInteger(options.after, 0);
106
+ const after = options.runtimeId && options.runtimeId !== runtimeId
107
+ || requestedAfter > nextId
108
+ ? 0
109
+ : requestedAfter;
110
+ try {
111
+ for (const entry of journal) {
112
+ if (entry.event.eventId > after)
113
+ write(response, entry);
114
+ }
115
+ subscribers.add(response);
116
+ }
117
+ catch {
118
+ remove(response);
119
+ }
28
120
  response.once('close', () => remove(response));
29
121
  response.once('error', () => remove(response));
30
122
  return () => remove(response);
31
123
  },
32
124
  };
33
125
  }
126
+ export function serializeConsoleEvent(event) {
127
+ return `id: ${event.eventId}\nevent: ${event.type}\nruntime: ${event.runtimeId}\ntimestamp: ${event.timestamp}\ndata: ${JSON.stringify(event.data)}\n\n`;
128
+ }
129
+ function positiveInteger(value, fallback) {
130
+ const parsed = Number(value);
131
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
132
+ }
133
+ function nonNegativeInteger(value, fallback) {
134
+ const parsed = Number(value);
135
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
136
+ }
137
+ function jsonSnapshot(value) {
138
+ const encoded = JSON.stringify(value ?? null);
139
+ if (encoded === undefined)
140
+ throw new TypeError('Console event data must be JSON serializable');
141
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_EVENT_BYTES) {
142
+ throw new RangeError(`Console event data exceeds ${MAX_EVENT_BYTES} bytes`);
143
+ }
144
+ return JSON.parse(encoded);
145
+ }
@@ -30,21 +30,25 @@ export interface ConsoleRestCtx {
30
30
  /** 内省数据门面 — 由 basic/cli 用 agent 包 collectIntrospection* 装配。 */
31
31
  export interface ConsoleAgentIntrospection {
32
32
  commands?(): readonly unknown[];
33
+ middlewares?(): readonly unknown[];
34
+ components?(): readonly unknown[];
33
35
  bindings?(): readonly unknown[];
34
36
  tools?(): readonly unknown[];
35
37
  mcp?(): {
36
38
  rows: readonly unknown[];
37
39
  note?: string;
38
40
  } | readonly unknown[];
41
+ renderComponent?(input: Readonly<{
42
+ requester: string;
43
+ name: string;
44
+ props: unknown;
45
+ signal: AbortSignal;
46
+ }>): Promise<unknown>;
39
47
  }
40
48
  /** Session tree 门面 — 形状对齐 agent 包 SessionTreeRuntimeHandle。 */
41
49
  export interface ConsoleAgentSessionTree {
42
50
  resolveActiveSessionId(sessionKey: string): Promise<string | null>;
43
- agentSessionStore: {
44
- getBySessionId(sessionId: string): Promise<{
45
- active_leaf_message_id?: number | null;
46
- } | null | undefined>;
47
- };
51
+ getActiveLeafMessageId(sessionId: string): Promise<number | null>;
48
52
  listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
49
53
  switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
50
54
  jumpToBranchIndex(sessionId: string, index: number): Promise<{
@@ -66,6 +70,12 @@ export interface ConsoleRestPagesOptions {
66
70
  readonly pluginRegistryUrl?: string;
67
71
  /** 默认 `https://registry.npmmirror.com`(legacy 同源)。 */
68
72
  readonly npmRegistryUrl?: string;
73
+ /** Component preview deadline; default 5 seconds. */
74
+ readonly componentPreviewTimeoutMs?: number;
75
+ /** Maximum serialized Component preview; default 256 KiB. */
76
+ readonly componentPreviewMaxBytes?: number;
77
+ /** Maximum in-flight Component previews per Console route set; default 2. */
78
+ readonly componentPreviewMaxConcurrent?: number;
69
79
  }
70
80
  /**
71
81
  * 注册 Console 页面 REST 路由。返回注销函数(注销全部已注册路由)。
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { HttpBodyError, readJsonBody } from './json-body.js';
4
4
  const INTROSPECTION_PAGE_SIZES = {
5
5
  commands: 25,
6
+ middlewares: 30,
7
+ components: 30,
6
8
  tools: 15,
7
9
  endpoints: 30,
8
10
  bindings: 30,
@@ -26,7 +28,7 @@ export function registerConsoleRestPages(host, ctx, options = {}) {
26
28
  };
27
29
  registerLogsRoutes(route, base, ctx);
28
30
  registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl);
29
- registerIntrospectionRoutes(route, base, ctx);
31
+ registerIntrospectionRoutes(route, base, ctx, options);
30
32
  registerAgentSessionRoutes(route, base, ctx);
31
33
  return () => {
32
34
  for (const dispose of disposers.splice(0))
@@ -378,17 +380,37 @@ async function listInstalledPluginPackages(projectRoot) {
378
380
  // ---------------------------------------------------------------------------
379
381
  // introspection(legacy introspection-rest-api.ts;分页形状对齐 IntrospectionJsonResponse)
380
382
  // ---------------------------------------------------------------------------
381
- function registerIntrospectionRoutes(route, base, ctx) {
383
+ function registerIntrospectionRoutes(route, base, ctx, options) {
382
384
  const collectors = [
383
385
  {
384
386
  kind: 'commands',
385
- collect: () => ({ rows: agentIntrospection(ctx)?.commands?.() ?? [] }),
387
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.commands?.() ?? []) }),
386
388
  fields: [
387
389
  (c) => stringProp(c, 'pattern'),
388
390
  (c) => stringProp(c, 'desc'),
389
391
  (c) => stringProp(c, 'plugin'),
390
392
  ],
391
393
  },
394
+ {
395
+ kind: 'middlewares',
396
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.middlewares?.() ?? []) }),
397
+ fields: [
398
+ (m) => stringProp(m, 'name'),
399
+ (m) => stringProp(m, 'owner'),
400
+ (m) => stringProp(m, 'phase'),
401
+ (m) => stringProp(m, 'target'),
402
+ (m) => stringProp(m, 'source'),
403
+ ],
404
+ },
405
+ {
406
+ kind: 'components',
407
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.components?.() ?? []) }),
408
+ fields: [
409
+ (c) => stringProp(c, 'name'),
410
+ (c) => stringProp(c, 'owner'),
411
+ (c) => stringProp(c, 'source'),
412
+ ],
413
+ },
392
414
  {
393
415
  kind: 'endpoints',
394
416
  collect: () => ({
@@ -403,7 +425,7 @@ function registerIntrospectionRoutes(route, base, ctx) {
403
425
  },
404
426
  {
405
427
  kind: 'bindings',
406
- collect: () => ({ rows: agentIntrospection(ctx)?.bindings?.() ?? [] }),
428
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.bindings?.() ?? []) }),
407
429
  fields: [
408
430
  (a) => stringProp(a, 'name'),
409
431
  (a) => stringProp(a, 'provider'),
@@ -412,7 +434,7 @@ function registerIntrospectionRoutes(route, base, ctx) {
412
434
  },
413
435
  {
414
436
  kind: 'tools',
415
- collect: () => ({ rows: agentIntrospection(ctx)?.tools?.() ?? [] }),
437
+ collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.tools?.() ?? []) }),
416
438
  fields: [
417
439
  (t) => stringProp(t, 'name'),
418
440
  (t) => stringProp(t, 'source'),
@@ -422,7 +444,7 @@ function registerIntrospectionRoutes(route, base, ctx) {
422
444
  {
423
445
  kind: 'mcp',
424
446
  collect: () => {
425
- const result = agentIntrospection(ctx)?.mcp?.();
447
+ const result = withAgentIntrospection(ctx, (value) => value?.mcp?.());
426
448
  if (result == null)
427
449
  return { rows: [] };
428
450
  // readonly 数组不能用 Array.isArray 收窄,按对象形状判断
@@ -444,7 +466,7 @@ function registerIntrospectionRoutes(route, base, ctx) {
444
466
  const slice = paginateItems(filtered, parsePositiveInt(query.get('page'), 1), parsePositiveInt(query.get('pageSize'), INTROSPECTION_PAGE_SIZES[kind]));
445
467
  const missing = kind === 'endpoints'
446
468
  ? !ctx.getEndpoints
447
- : !agentIntrospection(ctx);
469
+ : !hasAgentIntrospection(ctx);
448
470
  const degradedNote = note ?? (missing
449
471
  ? kind === 'endpoints'
450
472
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
@@ -464,18 +486,142 @@ function registerIntrospectionRoutes(route, base, ctx) {
464
486
  }
465
487
  }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
466
488
  }
489
+ let activeComponentPreviews = 0;
490
+ route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
491
+ if (!requireWriteScope(response, ctx, authScope))
492
+ return;
493
+ const body = (await readJsonBody(request)) ?? {};
494
+ const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
495
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
496
+ if (!requester || !name || requester.length > 256 || name.length > 160) {
497
+ writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
498
+ return;
499
+ }
500
+ const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
501
+ if (activeComponentPreviews >= maxConcurrent) {
502
+ writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
503
+ return;
504
+ }
505
+ const lease = ctx.acquireAgentRuntime?.();
506
+ const render = lease?.value.introspection?.renderComponent;
507
+ if (!lease || !render) {
508
+ lease?.release();
509
+ writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
510
+ return;
511
+ }
512
+ const controller = new AbortController();
513
+ const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
514
+ const timeout = setTimeout(() => controller.abort(new ComponentPreviewTimeoutError(`Component preview 超过 ${timeoutMs}ms`)), timeoutMs);
515
+ activeComponentPreviews += 1;
516
+ let released = false;
517
+ const release = () => {
518
+ if (released)
519
+ return;
520
+ released = true;
521
+ activeComponentPreviews -= 1;
522
+ lease.release();
523
+ };
524
+ const renderPromise = Promise.resolve().then(() => render({ requester, name, props: body.props ?? {}, signal: controller.signal }));
525
+ // A timed-out implementation may ignore AbortSignal. Keep its generation
526
+ // lease until the real operation settles; the bounded concurrency gate
527
+ // prevents non-cooperative previews from pinning unbounded generations.
528
+ void renderPromise.then(release, release);
529
+ try {
530
+ const output = await raceWithAbort(renderPromise, controller.signal);
531
+ assertPreviewOutput(output, options.componentPreviewMaxBytes ?? 256 * 1024);
532
+ writeJson(response, 200, { success: true, data: { requester, name, output } });
533
+ }
534
+ catch (error) {
535
+ const status = error instanceof ComponentPreviewTimeoutError
536
+ ? 408
537
+ : error instanceof ComponentPreviewLimitError ? 413 : 422;
538
+ writeJson(response, status, {
539
+ success: false,
540
+ error: error instanceof Error ? error.message : String(error),
541
+ });
542
+ }
543
+ finally {
544
+ clearTimeout(timeout);
545
+ if (!controller.signal.aborted)
546
+ controller.abort();
547
+ }
548
+ }, { summary: 'Render a Component preview', tags: ['console', 'introspection', 'component'] });
549
+ }
550
+ class ComponentPreviewTimeoutError extends Error {
551
+ name = 'ComponentPreviewTimeoutError';
467
552
  }
468
- function agentIntrospection(ctx) {
553
+ class ComponentPreviewLimitError extends Error {
554
+ name = 'ComponentPreviewLimitError';
555
+ }
556
+ function clampPositiveInt(value, fallback, ceiling) {
557
+ if (!Number.isFinite(value) || !value || value < 1)
558
+ return fallback;
559
+ return Math.min(Math.floor(value), ceiling);
560
+ }
561
+ async function raceWithAbort(promise, signal) {
562
+ if (signal.aborted)
563
+ throw abortReason(signal);
564
+ return await new Promise((resolve, reject) => {
565
+ const onAbort = () => reject(abortReason(signal));
566
+ signal.addEventListener('abort', onAbort, { once: true });
567
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
568
+ });
569
+ }
570
+ function abortReason(signal) {
571
+ return signal.reason instanceof Error
572
+ ? signal.reason
573
+ : new DOMException('Component preview aborted', 'AbortError');
574
+ }
575
+ function assertPreviewOutput(output, maxBytes) {
576
+ const byteLimit = clampPositiveInt(maxBytes, 256 * 1024, 1024 * 1024);
577
+ const seen = new WeakSet();
578
+ let nodes = 0;
579
+ const visit = (value, depth) => {
580
+ nodes += 1;
581
+ if (nodes > 10_000)
582
+ throw new ComponentPreviewLimitError('Component preview 节点数超过 10000');
583
+ if (depth > 24)
584
+ throw new ComponentPreviewLimitError('Component preview 嵌套深度超过 24');
585
+ if (!value || typeof value !== 'object')
586
+ return;
587
+ if (seen.has(value))
588
+ throw new ComponentPreviewLimitError('Component preview 不能包含循环引用');
589
+ seen.add(value);
590
+ if (Array.isArray(value)) {
591
+ for (const item of value)
592
+ visit(item, depth + 1);
593
+ }
594
+ else {
595
+ for (const item of Object.values(value))
596
+ visit(item, depth + 1);
597
+ }
598
+ };
599
+ visit(output, 0);
600
+ let serialized;
601
+ try {
602
+ serialized = JSON.stringify(output);
603
+ }
604
+ catch (error) {
605
+ throw new ComponentPreviewLimitError(`Component preview 无法序列化: ${error instanceof Error ? error.message : String(error)}`);
606
+ }
607
+ if (Buffer.byteLength(serialized ?? '') > byteLimit) {
608
+ throw new ComponentPreviewLimitError(`Component preview 超过 ${byteLimit} bytes`);
609
+ }
610
+ }
611
+ function withAgentIntrospection(ctx, operation) {
469
612
  const lease = ctx.acquireAgentRuntime?.();
470
613
  if (!lease)
471
- return undefined;
614
+ return operation(undefined);
472
615
  try {
473
- return lease.value.introspection;
616
+ return operation(lease.value.introspection);
474
617
  }
475
618
  finally {
476
619
  lease.release();
477
620
  }
478
621
  }
622
+ function hasAgentIntrospection(ctx) {
623
+ return withAgentIntrospection(ctx, (value) => value !== undefined);
624
+ }
479
625
  // ---------------------------------------------------------------------------
480
626
  // agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
481
627
  // ---------------------------------------------------------------------------
@@ -518,14 +664,14 @@ function registerAgentSessionRoutes(route, base, ctx) {
518
664
  });
519
665
  return;
520
666
  }
521
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
667
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
522
668
  const points = await runtime.listBranchPoints(sessionId);
523
669
  writeJson(response, 200, {
524
670
  success: true,
525
671
  data: {
526
672
  sessionKey: parsed.sessionKey,
527
673
  sessionId,
528
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
674
+ activeLeafMessageId,
529
675
  points,
530
676
  },
531
677
  });
@@ -594,27 +740,27 @@ function registerAgentSessionRoutes(route, base, ctx) {
594
740
  }
595
741
  if (branchIndex !== undefined) {
596
742
  const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
597
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
743
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
598
744
  writeJson(response, result.ok ? 200 : 400, {
599
745
  success: result.ok,
600
746
  message: result.message,
601
747
  data: {
602
748
  sessionKey: parsed.sessionKey,
603
749
  sessionId,
604
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
750
+ activeLeafMessageId,
605
751
  },
606
752
  });
607
753
  return;
608
754
  }
609
755
  const ok = await runtime.switchActiveLeaf(sessionId, messageId);
610
- const session = await runtime.agentSessionStore.getBySessionId(sessionId);
756
+ const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
611
757
  writeJson(response, ok ? 200 : 400, {
612
758
  success: ok,
613
759
  message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
614
760
  data: {
615
761
  sessionKey: parsed.sessionKey,
616
762
  sessionId,
617
- activeLeafMessageId: session?.active_leaf_message_id ?? null,
763
+ activeLeafMessageId,
618
764
  },
619
765
  });
620
766
  }
@@ -8,7 +8,9 @@
8
8
  * 数据源约定(只读真实数据源,不做内存假数据):
9
9
  * - cron:`ctx.scheduleHost`(Plugin Runtime ScheduleHost,仅 register/list,
10
10
  * 无持久化引擎,故 cron:add/remove/pause/resume 报"未接线")。
11
- * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
11
+ * - request.list:优先 `management.listRequests()`(live endpoint);否则回退
12
+ * unified_inbox_request。审批始终走 approveRequest/rejectRequest。
13
+ * - inbox(历史):`ctx.databaseHost.models`(unified_inbox_message/request/notice,
12
14
  * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
15
  * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
16
  * - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
@@ -28,8 +30,85 @@ export interface ConsoleRpcExtendedCtx {
28
30
  get(name: string): unknown;
29
31
  };
30
32
  };
31
- /** Agent Host 持久化调度引擎(@zhin.js/agent getAssistantRuntime().engine),未 init 时返回 null。 */
33
+ /** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
32
34
  resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
35
+ /** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办)。 */
36
+ loginAssist?: {
37
+ listPending(): readonly {
38
+ id: string;
39
+ adapter: string;
40
+ endpointKey: string;
41
+ type: string;
42
+ payload: Record<string, unknown>;
43
+ createdAt: number;
44
+ expiresAt?: number;
45
+ }[];
46
+ submit(id: string, value: string | Record<string, unknown>): boolean;
47
+ cancel(id: string, reason?: string): boolean;
48
+ };
49
+ /** Authenticated Root adapter; mutation DTOs deliberately contain no principal/decision fields. */
50
+ workroomProfileControl?: ConsoleWorkroomProfileControlPort;
51
+ /** Authenticated HTTP principal; never accepted from the RPC body. */
52
+ authenticatedPrincipal?: Readonly<{
53
+ principalId: string;
54
+ }>;
55
+ workroomKnowledgeControl?: ConsoleWorkroomKnowledgeControlPort;
56
+ }
57
+ export interface ConsoleWorkroomProfileControlPort {
58
+ publishPack(command: Readonly<{
59
+ operationId: string;
60
+ pack: unknown;
61
+ }>, authenticatedPrincipal: Readonly<{
62
+ principalId: string;
63
+ }>): Promise<unknown>;
64
+ publishProfile(command: Readonly<{
65
+ operationId: string;
66
+ projectId: string;
67
+ expectedRegistryRevision: number;
68
+ overlay: unknown;
69
+ activate: boolean;
70
+ }>, authenticatedPrincipal: Readonly<{
71
+ principalId: string;
72
+ }>): Promise<unknown>;
73
+ publishRollback(command: Readonly<{
74
+ operationId: string;
75
+ projectId: string;
76
+ expectedRegistryRevision: number;
77
+ restoredFromRevisionId: string;
78
+ overlay: unknown;
79
+ activate: boolean;
80
+ }>, authenticatedPrincipal: Readonly<{
81
+ principalId: string;
82
+ }>): Promise<unknown>;
83
+ publishPlanningPolicy(command: Readonly<{
84
+ operationId: string;
85
+ projectId: string;
86
+ profileRevisionId: string;
87
+ revision: number;
88
+ expectedPreviousDigest?: string;
89
+ policy: unknown;
90
+ }>, authenticatedPrincipal: Readonly<{
91
+ principalId: string;
92
+ }>): Promise<unknown>;
93
+ }
94
+ export interface ConsoleWorkroomKnowledgeControlPort {
95
+ read(projectId: string): Promise<unknown>;
96
+ publish(command: Readonly<{
97
+ operationId: string;
98
+ projectId: string;
99
+ expectedRevision: number;
100
+ entries: readonly unknown[];
101
+ }>, authenticatedPrincipal: Readonly<{
102
+ principalId: string;
103
+ }>): Promise<unknown>;
104
+ rollback(command: Readonly<{
105
+ operationId: string;
106
+ projectId: string;
107
+ expectedRevision: number;
108
+ restoreRevision: number;
109
+ }>, authenticatedPrincipal: Readonly<{
110
+ principalId: string;
111
+ }>): Promise<unknown>;
33
112
  }
34
113
  export interface ConsoleScheduleEngine {
35
114
  addJob(job: Record<string, unknown>): Promise<Record<string, unknown>>;
@@ -63,6 +142,17 @@ interface EndpointManagementPort {
63
142
  };
64
143
  }[]>;
65
144
  listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
145
+ listRequests?(): Promise<readonly {
146
+ platform_request_id: string;
147
+ type: string;
148
+ scene_type?: string | null;
149
+ scene_id: string;
150
+ sub_type?: string | null;
151
+ actor_id: string;
152
+ actor_name?: string | null;
153
+ comment?: string | null;
154
+ created_at: number;
155
+ }[]>;
66
156
  approveRequest?(requestId: string, remark?: string): Promise<void>;
67
157
  rejectRequest?(requestId: string, reason?: string): Promise<void>;
68
158
  kickGroupMember?(groupId: string, userId: string): Promise<void>;