@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.
- package/lib/console-events.d.ts +18 -4
- package/lib/console-events.js +121 -9
- package/lib/console-rest-pages.d.ts +1 -0
- package/lib/console-rest-pages.js +28 -23
- package/lib/console-rpc-extended.js +45 -13
- package/lib/console-rpc.js +14 -0
- package/lib/http-host.d.ts +7 -2
- package/lib/http-host.js +2 -0
- package/lib/public-api.d.ts +7 -0
- package/lib/public-api.js +5 -0
- package/package.json +4 -4
- package/src/console-events.ts +170 -12
- package/src/console-rest-pages.ts +29 -27
- package/src/console-rpc-extended.ts +58 -14
- package/src/console-rpc.ts +13 -0
- package/src/http-host.ts +11 -4
- package/src/public-api.ts +15 -0
package/lib/console-events.d.ts
CHANGED
|
@@ -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:
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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;
|
package/lib/console-events.js
CHANGED
|
@@ -1,33 +1,145 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
13
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
: !
|
|
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
|
-
|
|
340
|
-
|
|
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 =
|
|
356
|
-
const offset =
|
|
357
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
package/lib/console-rpc.js
CHANGED
|
@@ -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
|
+
]);
|
package/lib/http-host.d.ts
CHANGED
|
@@ -38,16 +38,21 @@ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, u
|
|
|
38
38
|
export interface HttpRouteRegistration {
|
|
39
39
|
(): void;
|
|
40
40
|
}
|
|
41
|
-
|
|
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
|
-
|
|
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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.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.
|
|
21
|
+
"@zhin.js/console-protocol": "1.1.4",
|
|
22
22
|
"@zhin.js/host-http-contract": "1.0.1",
|
|
23
|
-
"@zhin.js/logger": "1.0.
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
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",
|
package/src/console-events.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
: !
|
|
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
|
-
|
|
522
|
-
|
|
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 =
|
|
543
|
-
const offset =
|
|
544
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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;
|
package/src/console-rpc.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
457
|
-
const bound:
|
|
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';
|