@zhin.js/host-http 1.0.11 → 1.0.13

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
+ }
@@ -34,6 +34,7 @@ export interface ConsoleAgentIntrospection {
34
34
  components?(): readonly unknown[];
35
35
  bindings?(): readonly unknown[];
36
36
  tools?(): readonly unknown[];
37
+ promptSections?(): readonly unknown[];
37
38
  mcp?(): {
38
39
  rows: readonly unknown[];
39
40
  note?: string;
@@ -6,6 +6,7 @@ const INTROSPECTION_PAGE_SIZES = {
6
6
  middlewares: 30,
7
7
  components: 30,
8
8
  tools: 15,
9
+ 'prompt-sections': 20,
9
10
  endpoints: 30,
10
11
  bindings: 30,
11
12
  mcp: 30,
@@ -384,7 +385,7 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
384
385
  const collectors = [
385
386
  {
386
387
  kind: 'commands',
387
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.commands?.() ?? []) }),
388
+ collect: (value) => ({ rows: value?.commands?.() ?? [] }),
388
389
  fields: [
389
390
  (c) => stringProp(c, 'pattern'),
390
391
  (c) => stringProp(c, 'desc'),
@@ -393,7 +394,7 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
393
394
  },
394
395
  {
395
396
  kind: 'middlewares',
396
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.middlewares?.() ?? []) }),
397
+ collect: (value) => ({ rows: value?.middlewares?.() ?? [] }),
397
398
  fields: [
398
399
  (m) => stringProp(m, 'name'),
399
400
  (m) => stringProp(m, 'owner'),
@@ -404,7 +405,7 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
404
405
  },
405
406
  {
406
407
  kind: 'components',
407
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.components?.() ?? []) }),
408
+ collect: (value) => ({ rows: value?.components?.() ?? [] }),
408
409
  fields: [
409
410
  (c) => stringProp(c, 'name'),
410
411
  (c) => stringProp(c, 'owner'),
@@ -425,7 +426,7 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
425
426
  },
426
427
  {
427
428
  kind: 'bindings',
428
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.bindings?.() ?? []) }),
429
+ collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
429
430
  fields: [
430
431
  (a) => stringProp(a, 'name'),
431
432
  (a) => stringProp(a, 'provider'),
@@ -434,17 +435,30 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
434
435
  },
435
436
  {
436
437
  kind: 'tools',
437
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.tools?.() ?? []) }),
438
+ collect: (value) => ({ rows: value?.tools?.() ?? [] }),
438
439
  fields: [
439
440
  (t) => stringProp(t, 'name'),
440
441
  (t) => stringProp(t, 'source'),
441
442
  (t) => stringProp(t, 'description'),
442
443
  ],
443
444
  },
445
+ {
446
+ kind: 'prompt-sections',
447
+ collect: (value) => ({ rows: value?.promptSections?.() ?? [] }),
448
+ fields: [
449
+ (section) => stringProp(section, 'name'),
450
+ (section) => stringProp(section, 'qualifiedName'),
451
+ (section) => stringProp(section, 'title'),
452
+ (section) => stringProp(section, 'owner'),
453
+ (section) => stringProp(section, 'layer'),
454
+ (section) => stringProp(section, 'retention'),
455
+ (section) => stringProp(section, 'source'),
456
+ ],
457
+ },
444
458
  {
445
459
  kind: 'mcp',
446
- collect: () => {
447
- const result = withAgentIntrospection(ctx, (value) => value?.mcp?.());
460
+ collect: (value) => {
461
+ const result = value?.mcp?.();
448
462
  if (result == null)
449
463
  return { rows: [] };
450
464
  // readonly 数组不能用 Array.isArray 收窄,按对象形状判断
@@ -458,15 +472,17 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
458
472
  ];
459
473
  for (const { kind, collect, fields } of collectors) {
460
474
  route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
475
+ const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
461
476
  try {
462
- const { rows, note } = collect();
477
+ const introspection = lease?.value.introspection;
478
+ const { rows, note } = collect(introspection);
463
479
  const query = url.searchParams;
464
480
  const filter = query.get('filter')?.trim() || undefined;
465
481
  const filtered = filterByFields([...rows], filter, fields);
466
482
  const slice = paginateItems(filtered, parsePositiveInt(query.get('page'), 1), parsePositiveInt(query.get('pageSize'), INTROSPECTION_PAGE_SIZES[kind]));
467
483
  const missing = kind === 'endpoints'
468
484
  ? !ctx.getEndpoints
469
- : !hasAgentIntrospection(ctx);
485
+ : !introspection;
470
486
  const degradedNote = note ?? (missing
471
487
  ? kind === 'endpoints'
472
488
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
@@ -484,6 +500,9 @@ function registerIntrospectionRoutes(route, base, ctx, options) {
484
500
  error: err instanceof Error ? err.message : String(err),
485
501
  });
486
502
  }
503
+ finally {
504
+ lease?.release();
505
+ }
487
506
  }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
488
507
  }
489
508
  let activeComponentPreviews = 0;
@@ -608,20 +627,6 @@ function assertPreviewOutput(output, maxBytes) {
608
627
  throw new ComponentPreviewLimitError(`Component preview 超过 ${byteLimit} bytes`);
609
628
  }
610
629
  }
611
- function withAgentIntrospection(ctx, operation) {
612
- const lease = ctx.acquireAgentRuntime?.();
613
- if (!lease)
614
- return operation(undefined);
615
- try {
616
- return operation(lease.value.introspection);
617
- }
618
- finally {
619
- lease.release();
620
- }
621
- }
622
- function hasAgentIntrospection(ctx) {
623
- return withAgentIntrospection(ctx, (value) => value !== undefined);
624
- }
625
630
  // ---------------------------------------------------------------------------
626
631
  // agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
627
632
  // ---------------------------------------------------------------------------
@@ -21,6 +21,7 @@ import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/
21
21
  const TABLE_MESSAGE = 'unified_inbox_message';
22
22
  const TABLE_REQUEST = 'unified_inbox_request';
23
23
  const TABLE_NOTICE = 'unified_inbox_notice';
24
+ const MAX_INBOX_OFFSET = 10_000;
24
25
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
25
26
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
26
27
  const CONSUMED_NOT_WIRED = '收件箱已读标记未接线:unified_inbox_request/notice 表未注册或 DatabaseHost 未启动';
@@ -330,21 +331,30 @@ function getInboxModel(ctx, table) {
330
331
  async function readInboxRows(ctx, table, where, options) {
331
332
  const model = getInboxModel(ctx, table);
332
333
  if (!model)
333
- return { rows: [], enabled: false };
334
+ return { rows: [], enabled: false, offsetApplied: false };
334
335
  try {
335
336
  let selection = model.select().where(where);
337
+ let orderApplied = options?.orderBy == null;
336
338
  if (options?.orderBy && typeof selection.orderBy === 'function') {
337
339
  selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
340
+ orderApplied = true;
338
341
  }
339
- if (options?.limit != null && typeof selection.limit === 'function') {
340
- selection = selection.limit(options.limit);
342
+ const offset = options?.offset ?? 0;
343
+ const offsetSelection = orderApplied ? selection.offset : undefined;
344
+ const canApplyOffset = typeof offsetSelection === 'function';
345
+ const offsetApplied = offset === 0 || canApplyOffset;
346
+ if (offset > 0 && canApplyOffset) {
347
+ selection = offsetSelection.call(selection, offset);
348
+ }
349
+ if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
350
+ selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
341
351
  }
342
352
  const rows = await selection;
343
- return { rows: Array.isArray(rows) ? rows : [], enabled: true };
353
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
344
354
  }
345
355
  catch {
346
356
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
347
- return { rows: [], enabled: false };
357
+ return { rows: [], enabled: false, offsetApplied: false };
348
358
  }
349
359
  }
350
360
  async function listInbox(d, ctx, table, key, mapRow) {
@@ -352,20 +362,24 @@ async function listInbox(d, ctx, table, key, mapRow) {
352
362
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
353
363
  if (!adapter || !endpointKey)
354
364
  return { error: '$adapter and $endpoint required' };
355
- const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
356
- const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
357
- const { rows, enabled } = await readInboxRows(ctx, table, {
365
+ const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
366
+ const offset = boundedIntegerField(d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset');
367
+ const unreadOnly = table === TABLE_NOTICE
368
+ && boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
369
+ const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
358
370
  adapter,
359
371
  endpoint_id: endpointKey,
372
+ ...(unreadOnly ? { consumed: 0 } : {}),
360
373
  }, {
361
374
  // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
362
375
  orderBy: { field: 'created_at', direction: 'DESC' },
363
- limit: offset + limit,
376
+ limit,
377
+ offset,
364
378
  });
365
379
  const sorted = rows
366
380
  .slice()
367
381
  .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
368
- .slice(offset, offset + limit)
382
+ .slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
369
383
  .map(mapRow);
370
384
  return { data: { [key]: sorted, inboxEnabled: enabled } };
371
385
  }
@@ -422,7 +436,7 @@ async function listInboxMessages(d, ctx) {
422
436
  if (!adapter || !endpointKey || !channelId || !channelType) {
423
437
  return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
424
438
  }
425
- const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
439
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
426
440
  const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
427
441
  const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
428
442
  const parent = normalizeParent(d.$parent ?? d.parent);
@@ -489,16 +503,18 @@ function mapRequestRow(row) {
489
503
  function mapNoticeRow(row) {
490
504
  const actorId = row.actor_id ?? undefined;
491
505
  const actorName = row.actor_name ?? undefined;
492
- const sceneId = row.scene_id;
493
- const sceneType = row.scene_type ?? undefined;
506
+ const sceneId = String(row.scene_id ?? '');
507
+ const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
494
508
  return {
495
509
  id: row.id,
496
510
  platform_notice_id: row.platform_notice_id,
497
511
  type: row.type,
512
+ noticeType: row.type,
498
513
  scene_type: sceneType,
499
514
  scene_id: sceneId,
500
515
  channel_id: sceneId,
501
516
  channel_type: sceneType,
517
+ channel: { id: sceneId, type: sceneType ?? '' },
502
518
  sub_type: row.sub_type ?? undefined,
503
519
  actor_id: actorId,
504
520
  actor_name: actorName,
@@ -510,6 +526,8 @@ function mapNoticeRow(row) {
510
526
  payload: row.payload,
511
527
  created_at: row.created_at,
512
528
  timestamp: row.created_at,
529
+ consumed: row.consumed,
530
+ consumed_at: row.consumed_at ?? undefined,
513
531
  };
514
532
  }
515
533
  // ---------------------------------------------------------------- consumed 标记
@@ -725,6 +743,20 @@ function numField(d, fallback, ...keys) {
725
743
  }
726
744
  return fallback;
727
745
  }
746
+ function boundedIntegerField(d, fallback, minimum, maximum, ...keys) {
747
+ const value = numField(d, fallback, ...keys);
748
+ if (!Number.isFinite(value))
749
+ return fallback;
750
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
751
+ }
752
+ function boolField(d, fallback, ...keys) {
753
+ for (const key of keys) {
754
+ const value = d[key];
755
+ if (typeof value === 'boolean')
756
+ return value;
757
+ }
758
+ return fallback;
759
+ }
728
760
  function optionalNum(d, ...keys) {
729
761
  for (const key of keys) {
730
762
  if (d[key] == null)
@@ -699,6 +699,10 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
699
699
  const m = method.toUpperCase();
700
700
  if (m === 'GET' && pathname === `${normalizedBase}/events`)
701
701
  return true;
702
+ if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
703
+ return true;
704
+ if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length)))
705
+ return true;
702
706
  if (m === 'POST' && pathname === `${normalizedBase}/console/request`)
703
707
  return true;
704
708
  // Read-only dashboard / plugin inspection routes (legacy host-api parity).
@@ -711,3 +715,13 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
711
715
  return true;
712
716
  return false;
713
717
  }
718
+ const DEMO_INTROSPECTION_PATHS = new Set([
719
+ '/introspection/commands',
720
+ '/introspection/middlewares',
721
+ '/introspection/components',
722
+ '/introspection/endpoints',
723
+ '/introspection/bindings',
724
+ '/introspection/tools',
725
+ '/introspection/prompt-sections',
726
+ '/introspection/mcp',
727
+ ]);
@@ -38,16 +38,21 @@ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, u
38
38
  export interface HttpRouteRegistration {
39
39
  (): void;
40
40
  }
41
- export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
41
+ /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
42
+ export interface HttpHost {
42
43
  ws(path: string): WsHandle;
43
44
  route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
44
45
  listRoutes(): readonly ListedRoute[];
45
46
  get address(): HttpHostAddress | undefined;
47
+ /** @internal Console authentication registry owned by the process Host. */
46
48
  get tokenRegistry(): TokenRegistry;
47
49
  }
48
- export interface ProcessHttpHost extends HttpHost {
50
+ /** @internal Process-root lifecycle and generation admission surface. */
51
+ export interface ProcessHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {
49
52
  listen(): Promise<HttpHostAddress>;
50
53
  close(): Promise<void>;
51
54
  }
55
+ /** @public Stable Plugin Runtime HTTP Host token. */
52
56
  export declare const httpHostToken: import("@zhin.js/plugin-runtime").Token<HttpHost>;
57
+ /** @internal CLI composition-root factory. */
53
58
  export declare function createHttpHost(options?: HttpHostOptions): ProcessHttpHost;
package/lib/http-host.js CHANGED
@@ -9,7 +9,9 @@ import { HttpBodyError } from './json-body.js';
9
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
+ /** @public Stable Plugin Runtime HTTP Host token. */
12
13
  export const httpHostToken = createToken('zhin.host.http');
14
+ /** @internal CLI composition-root factory. */
13
15
  export function createHttpHost(options = {}) {
14
16
  const host = options.host ?? '127.0.0.1';
15
17
  const port = options.port ?? 8086;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Plugin-facing HTTP Host contract exposed from `@zhin.js/host-http`.
3
+ * @module @zhin.js/host-http
4
+ */
5
+ export { httpHostToken, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
6
+ export type { AuthenticatedTokenPrincipal, AuthScope } from './token-registry.js';
7
+ export type { ListedRoute, OpenApiParameter, RouteMeta } from './openapi.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Plugin-facing HTTP Host contract exposed from `@zhin.js/host-http`.
3
+ * @module @zhin.js/host-http
4
+ */
5
+ export { httpHostToken, } from './http-host.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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.3",
21
+ "@zhin.js/console-protocol": "1.1.4",
22
22
  "@zhin.js/host-http-contract": "1.0.1",
23
- "@zhin.js/logger": "1.0.76",
24
- "@zhin.js/plugin-runtime": "1.1.7"
23
+ "@zhin.js/logger": "1.0.77",
24
+ "@zhin.js/plugin-runtime": "1.1.8"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -1,47 +1,205 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import type { ServerResponse } from 'node:http';
3
+ import type {
4
+ ConsoleEventEnvelope,
5
+ ConsoleEventHistoryPage,
6
+ ConsoleEventHistoryQuery,
7
+ ConsoleEventPayloadMap,
8
+ KnownConsoleEventType,
9
+ } from '@zhin.js/console-protocol';
2
10
 
3
11
  /**
4
12
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
5
13
  * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
6
14
  * write 失败或连接断开自动摘除订阅。
7
15
  */
16
+ export interface ConsoleEventSubscriptionOptions {
17
+ readonly runtimeId?: string;
18
+ readonly after?: number;
19
+ }
20
+
8
21
  export interface ConsoleEventHub {
9
- publish(type: string, data: unknown): void;
10
- /** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
11
- subscribe(response: ServerResponse): () => void;
22
+ publish<Type extends string>(
23
+ type: Type,
24
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
25
+ ): ConsoleEventEnvelope<
26
+ Type,
27
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
28
+ >;
29
+ history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
30
+ /** Atomically replay the resumable suffix, then join live fan-out. */
31
+ subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
32
+ readonly runtimeId: string;
12
33
  readonly subscriberCount: number;
13
34
  }
14
35
 
15
- export function createConsoleEventHub(): ConsoleEventHub {
36
+ export interface ConsoleEventHubOptions {
37
+ readonly runtimeId?: string;
38
+ readonly historyLimit?: number;
39
+ readonly historyByteLimit?: number;
40
+ readonly historyPageByteLimit?: number;
41
+ }
42
+
43
+ const DEFAULT_HISTORY_LIMIT = 256;
44
+ const DEFAULT_PAGE_LIMIT = 200;
45
+ const MAX_PAGE_LIMIT = 500;
46
+ const MAX_EVENT_BYTES = 64 * 1024;
47
+ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
48
+ const DEFAULT_HISTORY_PAGE_BYTE_LIMIT = 1024 * 1024;
49
+
50
+ interface JournalEntry {
51
+ readonly event: ConsoleEventEnvelope;
52
+ readonly frame: string;
53
+ readonly bytes: number;
54
+ }
55
+
56
+ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): ConsoleEventHub {
16
57
  let nextId = 0;
58
+ const runtimeId = options.runtimeId ?? randomUUID();
59
+ const historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
60
+ const historyByteLimit = positiveInteger(options.historyByteLimit, DEFAULT_HISTORY_BYTE_LIMIT);
61
+ const historyPageByteLimit = positiveInteger(
62
+ options.historyPageByteLimit,
63
+ DEFAULT_HISTORY_PAGE_BYTE_LIMIT,
64
+ );
65
+ const journal: JournalEntry[] = [];
66
+ let journalBytes = 0;
17
67
  const subscribers = new Set<ServerResponse>();
18
68
 
19
69
  const remove = (response: ServerResponse): void => {
20
70
  subscribers.delete(response);
21
71
  };
22
72
 
73
+ const write = (response: ServerResponse, entry: JournalEntry): void => {
74
+ let failed = false;
75
+ const accepted = response.write(entry.frame, (error) => {
76
+ if (error) {
77
+ failed = true;
78
+ remove(response);
79
+ }
80
+ });
81
+ if (failed) throw new Error('Console event subscriber rejected replay');
82
+ if (!accepted) {
83
+ remove(response);
84
+ response.destroy(new Error('Console event subscriber backpressure limit reached'));
85
+ throw new Error('Console event subscriber backpressure limit reached');
86
+ }
87
+ };
88
+
23
89
  return {
90
+ runtimeId,
24
91
  get subscriberCount() {
25
92
  return subscribers.size;
26
93
  },
27
- publish(type, data) {
28
- nextId += 1;
29
- const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
94
+ publish<Type extends string>(
95
+ type: Type,
96
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
97
+ ): ConsoleEventEnvelope<
98
+ Type,
99
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
100
+ > {
101
+ const snapshot = jsonSnapshot(data);
102
+ const event = Object.freeze({
103
+ runtimeId,
104
+ eventId: ++nextId,
105
+ type,
106
+ data: snapshot,
107
+ timestamp: Date.now(),
108
+ });
109
+ const frame = serializeConsoleEvent(event);
110
+ const entry = Object.freeze({ event, frame, bytes: Buffer.byteLength(frame, 'utf8') });
111
+ journal.push(entry);
112
+ journalBytes += entry.bytes;
113
+ while (journal.length > historyLimit || journalBytes > historyByteLimit) {
114
+ journalBytes -= journal.shift()?.bytes ?? 0;
115
+ }
30
116
  for (const response of [...subscribers]) {
31
117
  try {
32
- response.write(frame, (error) => {
33
- if (error) remove(response);
34
- });
118
+ write(response, entry);
35
119
  } catch {
36
120
  remove(response);
37
121
  }
38
122
  }
123
+ return event as ConsoleEventEnvelope<
124
+ Type,
125
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
126
+ >;
39
127
  },
40
- subscribe(response) {
41
- subscribers.add(response);
128
+ history(query = {}): ConsoleEventHistoryPage {
129
+ const requestedAfter = nonNegativeInteger(query.after, 0);
130
+ const sameRuntime = !query.runtimeId || query.runtimeId === runtimeId;
131
+ const cursorAhead = requestedAfter > nextId;
132
+ const after = sameRuntime && !cursorAhead ? requestedAfter : 0;
133
+ const limit = Math.min(MAX_PAGE_LIMIT, positiveInteger(query.limit, DEFAULT_PAGE_LIMIT));
134
+ const oldestAvailableEventId = journal[0]?.event.eventId ?? null;
135
+ const latestEventId = nextId;
136
+ const gap = !sameRuntime || cursorAhead
137
+ || (oldestAvailableEventId !== null
138
+ ? after < oldestAvailableEventId - 1
139
+ : after < latestEventId);
140
+ const available = journal.filter((entry) => entry.event.eventId > after);
141
+ const selected: ConsoleEventEnvelope[] = [];
142
+ let selectedBytes = 0;
143
+ for (const entry of available) {
144
+ if (selected.length >= limit) break;
145
+ if (selected.length > 0 && selectedBytes + entry.bytes > historyPageByteLimit) break;
146
+ selected.push(entry.event);
147
+ selectedBytes += entry.bytes;
148
+ }
149
+ const items = Object.freeze(selected);
150
+ const nextAfter = items.at(-1)?.eventId ?? after;
151
+ return Object.freeze({
152
+ runtimeId,
153
+ items,
154
+ oldestAvailableEventId,
155
+ latestEventId,
156
+ nextAfter,
157
+ hasMore: available.length > items.length,
158
+ gap,
159
+ });
160
+ },
161
+ subscribe(response, options = {}) {
162
+ // Replay and subscription are synchronous relative to publish(), closing
163
+ // the race between HTTP recovery and the live stream hand-off.
164
+ const requestedAfter = nonNegativeInteger(options.after, 0);
165
+ const after = options.runtimeId && options.runtimeId !== runtimeId
166
+ || requestedAfter > nextId
167
+ ? 0
168
+ : requestedAfter;
169
+ try {
170
+ for (const entry of journal) {
171
+ if (entry.event.eventId > after) write(response, entry);
172
+ }
173
+ subscribers.add(response);
174
+ } catch {
175
+ remove(response);
176
+ }
42
177
  response.once('close', () => remove(response));
43
178
  response.once('error', () => remove(response));
44
179
  return () => remove(response);
45
180
  },
46
181
  };
47
182
  }
183
+
184
+ export function serializeConsoleEvent(event: ConsoleEventEnvelope): string {
185
+ return `id: ${event.eventId}\nevent: ${event.type}\nruntime: ${event.runtimeId}\ntimestamp: ${event.timestamp}\ndata: ${JSON.stringify(event.data)}\n\n`;
186
+ }
187
+
188
+ function positiveInteger(value: unknown, fallback: number): number {
189
+ const parsed = Number(value);
190
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
191
+ }
192
+
193
+ function nonNegativeInteger(value: unknown, fallback: number): number {
194
+ const parsed = Number(value);
195
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
196
+ }
197
+
198
+ function jsonSnapshot(value: unknown): unknown {
199
+ const encoded = JSON.stringify(value ?? null);
200
+ if (encoded === undefined) throw new TypeError('Console event data must be JSON serializable');
201
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_EVENT_BYTES) {
202
+ throw new RangeError(`Console event data exceeds ${MAX_EVENT_BYTES} bytes`);
203
+ }
204
+ return JSON.parse(encoded) as unknown;
205
+ }
@@ -51,6 +51,7 @@ export interface ConsoleAgentIntrospection {
51
51
  components?(): readonly unknown[];
52
52
  bindings?(): readonly unknown[];
53
53
  tools?(): readonly unknown[];
54
+ promptSections?(): readonly unknown[];
54
55
  mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
55
56
  renderComponent?(input: Readonly<{
56
57
  requester: string;
@@ -122,6 +123,7 @@ const INTROSPECTION_PAGE_SIZES = {
122
123
  middlewares: 30,
123
124
  components: 30,
124
125
  tools: 15,
126
+ 'prompt-sections': 20,
125
127
  endpoints: 30,
126
128
  bindings: 30,
127
129
  mcp: 30,
@@ -567,12 +569,12 @@ function registerIntrospectionRoutes(
567
569
  ): void {
568
570
  const collectors: Array<{
569
571
  kind: IntrospectionKind;
570
- collect: () => { rows: readonly unknown[]; note?: string };
572
+ collect: (introspection: ConsoleAgentIntrospection | undefined) => { rows: readonly unknown[]; note?: string };
571
573
  fields: Array<(item: unknown) => string | undefined>;
572
574
  }> = [
573
575
  {
574
576
  kind: 'commands',
575
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.commands?.() ?? []) }),
577
+ collect: (value) => ({ rows: value?.commands?.() ?? [] }),
576
578
  fields: [
577
579
  (c) => stringProp(c, 'pattern'),
578
580
  (c) => stringProp(c, 'desc'),
@@ -581,7 +583,7 @@ function registerIntrospectionRoutes(
581
583
  },
582
584
  {
583
585
  kind: 'middlewares',
584
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.middlewares?.() ?? []) }),
586
+ collect: (value) => ({ rows: value?.middlewares?.() ?? [] }),
585
587
  fields: [
586
588
  (m) => stringProp(m, 'name'),
587
589
  (m) => stringProp(m, 'owner'),
@@ -592,7 +594,7 @@ function registerIntrospectionRoutes(
592
594
  },
593
595
  {
594
596
  kind: 'components',
595
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.components?.() ?? []) }),
597
+ collect: (value) => ({ rows: value?.components?.() ?? [] }),
596
598
  fields: [
597
599
  (c) => stringProp(c, 'name'),
598
600
  (c) => stringProp(c, 'owner'),
@@ -613,7 +615,7 @@ function registerIntrospectionRoutes(
613
615
  },
614
616
  {
615
617
  kind: 'bindings',
616
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.bindings?.() ?? []) }),
618
+ collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
617
619
  fields: [
618
620
  (a) => stringProp(a, 'name'),
619
621
  (a) => stringProp(a, 'provider'),
@@ -622,17 +624,30 @@ function registerIntrospectionRoutes(
622
624
  },
623
625
  {
624
626
  kind: 'tools',
625
- collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.tools?.() ?? []) }),
627
+ collect: (value) => ({ rows: value?.tools?.() ?? [] }),
626
628
  fields: [
627
629
  (t) => stringProp(t, 'name'),
628
630
  (t) => stringProp(t, 'source'),
629
631
  (t) => stringProp(t, 'description'),
630
632
  ],
631
633
  },
634
+ {
635
+ kind: 'prompt-sections',
636
+ collect: (value) => ({ rows: value?.promptSections?.() ?? [] }),
637
+ fields: [
638
+ (section) => stringProp(section, 'name'),
639
+ (section) => stringProp(section, 'qualifiedName'),
640
+ (section) => stringProp(section, 'title'),
641
+ (section) => stringProp(section, 'owner'),
642
+ (section) => stringProp(section, 'layer'),
643
+ (section) => stringProp(section, 'retention'),
644
+ (section) => stringProp(section, 'source'),
645
+ ],
646
+ },
632
647
  {
633
648
  kind: 'mcp',
634
- collect: () => {
635
- const result = withAgentIntrospection(ctx, (value) => value?.mcp?.());
649
+ collect: (value) => {
650
+ const result = value?.mcp?.();
636
651
  if (result == null) return { rows: [] as readonly unknown[] };
637
652
  // readonly 数组不能用 Array.isArray 收窄,按对象形状判断
638
653
  if (typeof result === 'object' && 'rows' in result) {
@@ -646,8 +661,10 @@ function registerIntrospectionRoutes(
646
661
 
647
662
  for (const { kind, collect, fields } of collectors) {
648
663
  route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
664
+ const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
649
665
  try {
650
- const { rows, note } = collect();
666
+ const introspection = lease?.value.introspection;
667
+ const { rows, note } = collect(introspection);
651
668
  const query = url.searchParams;
652
669
  const filter = query.get('filter')?.trim() || undefined;
653
670
  const filtered = filterByFields([...rows], filter, fields);
@@ -658,7 +675,7 @@ function registerIntrospectionRoutes(
658
675
  );
659
676
  const missing = kind === 'endpoints'
660
677
  ? !ctx.getEndpoints
661
- : !hasAgentIntrospection(ctx);
678
+ : !introspection;
662
679
  const degradedNote = note ?? (missing
663
680
  ? kind === 'endpoints'
664
681
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
@@ -674,6 +691,8 @@ function registerIntrospectionRoutes(
674
691
  data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
675
692
  error: err instanceof Error ? err.message : String(err),
676
693
  });
694
+ } finally {
695
+ lease?.release();
677
696
  }
678
697
  }, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
679
698
  }
@@ -800,23 +819,6 @@ function assertPreviewOutput(output: unknown, maxBytes: number): void {
800
819
  }
801
820
  }
802
821
 
803
- function withAgentIntrospection<T>(
804
- ctx: ConsoleRestCtx,
805
- operation: (value: ConsoleAgentIntrospection | undefined) => T,
806
- ): T {
807
- const lease = ctx.acquireAgentRuntime?.();
808
- if (!lease) return operation(undefined);
809
- try {
810
- return operation(lease.value.introspection);
811
- } finally {
812
- lease.release();
813
- }
814
- }
815
-
816
- function hasAgentIntrospection(ctx: ConsoleRestCtx): boolean {
817
- return withAgentIntrospection(ctx, (value) => value !== undefined);
818
- }
819
-
820
822
  // ---------------------------------------------------------------------------
821
823
  // agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
822
824
  // ---------------------------------------------------------------------------
@@ -26,6 +26,7 @@ import {
26
26
  const TABLE_MESSAGE = 'unified_inbox_message';
27
27
  const TABLE_REQUEST = 'unified_inbox_request';
28
28
  const TABLE_NOTICE = 'unified_inbox_notice';
29
+ const MAX_INBOX_OFFSET = 10_000;
29
30
 
30
31
  export interface ConsoleRpcExtendedCtx {
31
32
  /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
@@ -131,6 +132,8 @@ interface InboxSelection {
131
132
  orderBy?(field: string, direction?: 'ASC' | 'DESC'): InboxSelection;
132
133
  /** 可选:DB 侧 limit 下推。 */
133
134
  limit?(count: number): InboxSelection;
135
+ /** 可选:DB 侧 offset 下推。 */
136
+ offset?(count: number): InboxSelection;
134
137
  then<TResult1 = Record<string, unknown>[], TResult2 = never>(
135
138
  onfulfilled?: ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>) | null,
136
139
  onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
@@ -509,23 +512,34 @@ async function readInboxRows(
509
512
  orderBy?: { field: string; direction?: 'ASC' | 'DESC' };
510
513
  /** DB 侧 limit 下推(模型不支持时由调用方内存分页兜底)。 */
511
514
  limit?: number;
515
+ /** DB 侧 offset 下推(模型不支持时由调用方内存分页兜底)。 */
516
+ offset?: number;
512
517
  },
513
- ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean }> {
518
+ ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean; offsetApplied: boolean }> {
514
519
  const model = getInboxModel(ctx, table);
515
- if (!model) return { rows: [], enabled: false };
520
+ if (!model) return { rows: [], enabled: false, offsetApplied: false };
516
521
  try {
517
522
  let selection: InboxSelection = model.select().where(where);
523
+ let orderApplied = options?.orderBy == null;
518
524
  if (options?.orderBy && typeof selection.orderBy === 'function') {
519
525
  selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
526
+ orderApplied = true;
520
527
  }
521
- if (options?.limit != null && typeof selection.limit === 'function') {
522
- selection = selection.limit(options.limit);
528
+ const offset = options?.offset ?? 0;
529
+ const offsetSelection = orderApplied ? selection.offset : undefined;
530
+ const canApplyOffset = typeof offsetSelection === 'function';
531
+ const offsetApplied = offset === 0 || canApplyOffset;
532
+ if (offset > 0 && canApplyOffset) {
533
+ selection = offsetSelection.call(selection, offset);
534
+ }
535
+ if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
536
+ selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
523
537
  }
524
538
  const rows = await selection;
525
- return { rows: Array.isArray(rows) ? rows : [], enabled: true };
539
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
526
540
  } catch {
527
541
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
528
- return { rows: [], enabled: false };
542
+ return { rows: [], enabled: false, offsetApplied: false };
529
543
  }
530
544
  }
531
545
 
@@ -539,20 +553,26 @@ async function listInbox(
539
553
  const adapter = strField(d, '$adapter', 'adapter');
540
554
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
541
555
  if (!adapter || !endpointKey) return { error: '$adapter and $endpoint required' };
542
- const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
543
- const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
544
- const { rows, enabled } = await readInboxRows(ctx, table, {
556
+ const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
557
+ const offset = boundedIntegerField(
558
+ d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset',
559
+ );
560
+ const unreadOnly = table === TABLE_NOTICE
561
+ && boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
562
+ const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
545
563
  adapter,
546
564
  endpoint_id: endpointKey,
565
+ ...(unreadOnly ? { consumed: 0 } : {}),
547
566
  }, {
548
567
  // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
549
568
  orderBy: { field: 'created_at', direction: 'DESC' },
550
- limit: offset + limit,
569
+ limit,
570
+ offset,
551
571
  });
552
572
  const sorted = rows
553
573
  .slice()
554
574
  .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
555
- .slice(offset, offset + limit)
575
+ .slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
556
576
  .map(mapRow);
557
577
  return { data: { [key]: sorted, inboxEnabled: enabled } };
558
578
  }
@@ -615,7 +635,7 @@ async function listInboxMessages(
615
635
  if (!adapter || !endpointKey || !channelId || !channelType) {
616
636
  return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
617
637
  }
618
- const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
638
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
619
639
  const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
620
640
  const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
621
641
  const parent = normalizeParent(d.$parent ?? d.parent);
@@ -685,16 +705,18 @@ function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
685
705
  function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
686
706
  const actorId = row.actor_id ?? undefined;
687
707
  const actorName = row.actor_name ?? undefined;
688
- const sceneId = row.scene_id;
689
- const sceneType = row.scene_type ?? undefined;
708
+ const sceneId = String(row.scene_id ?? '');
709
+ const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
690
710
  return {
691
711
  id: row.id,
692
712
  platform_notice_id: row.platform_notice_id,
693
713
  type: row.type,
714
+ noticeType: row.type,
694
715
  scene_type: sceneType,
695
716
  scene_id: sceneId,
696
717
  channel_id: sceneId,
697
718
  channel_type: sceneType,
719
+ channel: { id: sceneId, type: sceneType ?? '' },
698
720
  sub_type: row.sub_type ?? undefined,
699
721
  actor_id: actorId,
700
722
  actor_name: actorName,
@@ -706,6 +728,8 @@ function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
706
728
  payload: row.payload,
707
729
  created_at: row.created_at,
708
730
  timestamp: row.created_at,
731
+ consumed: row.consumed,
732
+ consumed_at: row.consumed_at ?? undefined,
709
733
  };
710
734
  }
711
735
 
@@ -965,6 +989,26 @@ function numField(d: Record<string, unknown>, fallback: number, ...keys: string[
965
989
  return fallback;
966
990
  }
967
991
 
992
+ function boundedIntegerField(
993
+ d: Record<string, unknown>,
994
+ fallback: number,
995
+ minimum: number,
996
+ maximum: number,
997
+ ...keys: string[]
998
+ ): number {
999
+ const value = numField(d, fallback, ...keys);
1000
+ if (!Number.isFinite(value)) return fallback;
1001
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
1002
+ }
1003
+
1004
+ function boolField(d: Record<string, unknown>, fallback: boolean, ...keys: string[]): boolean {
1005
+ for (const key of keys) {
1006
+ const value = d[key];
1007
+ if (typeof value === 'boolean') return value;
1008
+ }
1009
+ return fallback;
1010
+ }
1011
+
968
1012
  function optionalNum(d: Record<string, unknown>, ...keys: string[]): number | undefined {
969
1013
  for (const key of keys) {
970
1014
  if (d[key] == null) continue;
@@ -801,6 +801,8 @@ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: str
801
801
  const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
802
802
  const m = method.toUpperCase();
803
803
  if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
804
+ if (m === 'GET' && pathname === `${normalizedBase}/events/history`) return true;
805
+ if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length))) return true;
804
806
  if (m === 'POST' && pathname === `${normalizedBase}/console/request`) return true;
805
807
  // Read-only dashboard / plugin inspection routes (legacy host-api parity).
806
808
  if (m === 'GET' && pathname === `${normalizedBase}/system/status`) return true;
@@ -809,3 +811,14 @@ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: str
809
811
  || pathname.startsWith(`${normalizedBase}/plugins/`))) return true;
810
812
  return false;
811
813
  }
814
+
815
+ const DEMO_INTROSPECTION_PATHS: ReadonlySet<string> = new Set([
816
+ '/introspection/commands',
817
+ '/introspection/middlewares',
818
+ '/introspection/components',
819
+ '/introspection/endpoints',
820
+ '/introspection/bindings',
821
+ '/introspection/tools',
822
+ '/introspection/prompt-sections',
823
+ '/introspection/mcp',
824
+ ]);
package/src/http-host.ts CHANGED
@@ -81,7 +81,8 @@ export interface HttpRouteRegistration {
81
81
  (): void;
82
82
  }
83
83
 
84
- export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
84
+ /** @public Stable HTTP and WebSocket Host contract resolved through `httpHostToken`. */
85
+ export interface HttpHost {
85
86
  ws(path: string): WsHandle;
86
87
  route(
87
88
  method: string,
@@ -91,10 +92,12 @@ export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
91
92
  ): HttpRouteRegistration;
92
93
  listRoutes(): readonly ListedRoute[];
93
94
  get address(): HttpHostAddress | undefined;
95
+ /** @internal Console authentication registry owned by the process Host. */
94
96
  get tokenRegistry(): TokenRegistry;
95
97
  }
96
98
 
97
- export interface ProcessHttpHost extends HttpHost {
99
+ /** @internal Process-root lifecycle and generation admission surface. */
100
+ export interface ProcessHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {
98
101
  listen(): Promise<HttpHostAddress>;
99
102
  close(): Promise<void>;
100
103
  }
@@ -109,13 +112,17 @@ interface HttpRoute {
109
112
  readonly admission?: GenerationAdmissionGate;
110
113
  }
111
114
 
115
+ interface AdmissionBoundHttpHost extends HttpHost, GenerationAdmissionBindable<HttpHost> {}
116
+
112
117
  interface WsRoute {
113
118
  readonly listener: (connection: WsConnection) => void;
114
119
  readonly admission?: GenerationAdmissionGate;
115
120
  }
116
121
 
122
+ /** @public Stable Plugin Runtime HTTP Host token. */
117
123
  export const httpHostToken = createToken<HttpHost>('zhin.host.http');
118
124
 
125
+ /** @internal CLI composition-root factory. */
119
126
  export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
120
127
  const host = options.host ?? '127.0.0.1';
121
128
  const port = options.port ?? 8086;
@@ -453,8 +460,8 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
453
460
 
454
461
  function createBoundHost(
455
462
  admission: GenerationAdmissionGate,
456
- ): HttpHost {
457
- const bound: HttpHost = {
463
+ ): AdmissionBoundHttpHost {
464
+ const bound: AdmissionBoundHttpHost = {
458
465
  [generationAdmissionBinder]: (next) => createBoundHost(next),
459
466
  ws: (path) => registerWs(path, admission),
460
467
  route: (method, path, handler, meta) => registerRoute(
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Plugin-facing HTTP Host contract exposed from `@zhin.js/host-http`.
3
+ * @module @zhin.js/host-http
4
+ */
5
+ export {
6
+ httpHostToken,
7
+ type HttpHandler,
8
+ type HttpHost,
9
+ type HttpHostAddress,
10
+ type HttpRouteRegistration,
11
+ type WsConnection,
12
+ type WsHandle,
13
+ } from './http-host.js';
14
+ export type { AuthenticatedTokenPrincipal, AuthScope } from './token-registry.js';
15
+ export type { ListedRoute, OpenApiParameter, RouteMeta } from './openapi.js';