@zhin.js/host-http 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/console-events.d.ts +18 -4
- package/lib/console-events.js +121 -9
- package/lib/console-rest-pages.d.ts +15 -5
- package/lib/console-rest-pages.js +162 -16
- package/lib/console-rpc-extended.d.ts +92 -2
- package/lib/console-rpc-extended.js +243 -16
- package/lib/console-rpc.d.ts +10 -0
- package/lib/console-rpc.js +48 -0
- package/lib/http-host.d.ts +2 -2
- package/lib/http-host.js +4 -3
- package/lib/index.d.ts +2 -2
- package/lib/token-registry.d.ts +7 -0
- package/lib/token-registry.js +29 -8
- package/package.json +3 -3
- package/src/console-events.ts +170 -12
- package/src/console-rest-pages.ts +181 -20
- package/src/console-rpc-extended.ts +332 -18
- package/src/console-rpc.ts +55 -0
- package/src/http-host.ts +7 -4
- package/src/index.ts +3 -0
- package/src/token-registry.ts +36 -8
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
|
+
}
|
|
@@ -47,19 +47,23 @@ export interface ConsoleRestCtx {
|
|
|
47
47
|
/** 内省数据门面 — 由 basic/cli 用 agent 包 collectIntrospection* 装配。 */
|
|
48
48
|
export interface ConsoleAgentIntrospection {
|
|
49
49
|
commands?(): readonly unknown[];
|
|
50
|
+
middlewares?(): readonly unknown[];
|
|
51
|
+
components?(): readonly unknown[];
|
|
50
52
|
bindings?(): readonly unknown[];
|
|
51
53
|
tools?(): readonly unknown[];
|
|
52
54
|
mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
|
|
55
|
+
renderComponent?(input: Readonly<{
|
|
56
|
+
requester: string;
|
|
57
|
+
name: string;
|
|
58
|
+
props: unknown;
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
}>): Promise<unknown>;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** Session tree 门面 — 形状对齐 agent 包 SessionTreeRuntimeHandle。 */
|
|
56
64
|
export interface ConsoleAgentSessionTree {
|
|
57
65
|
resolveActiveSessionId(sessionKey: string): Promise<string | null>;
|
|
58
|
-
|
|
59
|
-
getBySessionId(sessionId: string): Promise<
|
|
60
|
-
{ active_leaf_message_id?: number | null } | null | undefined
|
|
61
|
-
>;
|
|
62
|
-
};
|
|
66
|
+
getActiveLeafMessageId(sessionId: string): Promise<number | null>;
|
|
63
67
|
listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
|
|
64
68
|
switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
|
|
65
69
|
jumpToBranchIndex(
|
|
@@ -83,6 +87,12 @@ export interface ConsoleRestPagesOptions {
|
|
|
83
87
|
readonly pluginRegistryUrl?: string;
|
|
84
88
|
/** 默认 `https://registry.npmmirror.com`(legacy 同源)。 */
|
|
85
89
|
readonly npmRegistryUrl?: string;
|
|
90
|
+
/** Component preview deadline; default 5 seconds. */
|
|
91
|
+
readonly componentPreviewTimeoutMs?: number;
|
|
92
|
+
/** Maximum serialized Component preview; default 256 KiB. */
|
|
93
|
+
readonly componentPreviewMaxBytes?: number;
|
|
94
|
+
/** Maximum in-flight Component previews per Console route set; default 2. */
|
|
95
|
+
readonly componentPreviewMaxConcurrent?: number;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
type LogModelLike = {
|
|
@@ -109,6 +119,8 @@ type LogRow = {
|
|
|
109
119
|
|
|
110
120
|
const INTROSPECTION_PAGE_SIZES = {
|
|
111
121
|
commands: 25,
|
|
122
|
+
middlewares: 30,
|
|
123
|
+
components: 30,
|
|
112
124
|
tools: 15,
|
|
113
125
|
endpoints: 30,
|
|
114
126
|
bindings: 30,
|
|
@@ -143,7 +155,7 @@ export function registerConsoleRestPages(
|
|
|
143
155
|
|
|
144
156
|
registerLogsRoutes(route, base, ctx);
|
|
145
157
|
registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl, npmRegistryUrl);
|
|
146
|
-
registerIntrospectionRoutes(route, base, ctx);
|
|
158
|
+
registerIntrospectionRoutes(route, base, ctx, options);
|
|
147
159
|
registerAgentSessionRoutes(route, base, ctx);
|
|
148
160
|
|
|
149
161
|
return () => {
|
|
@@ -551,6 +563,7 @@ function registerIntrospectionRoutes(
|
|
|
551
563
|
route: HttpHost['route'],
|
|
552
564
|
base: string,
|
|
553
565
|
ctx: ConsoleRestCtx,
|
|
566
|
+
options: ConsoleRestPagesOptions,
|
|
554
567
|
): void {
|
|
555
568
|
const collectors: Array<{
|
|
556
569
|
kind: IntrospectionKind;
|
|
@@ -559,13 +572,33 @@ function registerIntrospectionRoutes(
|
|
|
559
572
|
}> = [
|
|
560
573
|
{
|
|
561
574
|
kind: 'commands',
|
|
562
|
-
collect: () => ({ rows:
|
|
575
|
+
collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.commands?.() ?? []) }),
|
|
563
576
|
fields: [
|
|
564
577
|
(c) => stringProp(c, 'pattern'),
|
|
565
578
|
(c) => stringProp(c, 'desc'),
|
|
566
579
|
(c) => stringProp(c, 'plugin'),
|
|
567
580
|
],
|
|
568
581
|
},
|
|
582
|
+
{
|
|
583
|
+
kind: 'middlewares',
|
|
584
|
+
collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.middlewares?.() ?? []) }),
|
|
585
|
+
fields: [
|
|
586
|
+
(m) => stringProp(m, 'name'),
|
|
587
|
+
(m) => stringProp(m, 'owner'),
|
|
588
|
+
(m) => stringProp(m, 'phase'),
|
|
589
|
+
(m) => stringProp(m, 'target'),
|
|
590
|
+
(m) => stringProp(m, 'source'),
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
kind: 'components',
|
|
595
|
+
collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.components?.() ?? []) }),
|
|
596
|
+
fields: [
|
|
597
|
+
(c) => stringProp(c, 'name'),
|
|
598
|
+
(c) => stringProp(c, 'owner'),
|
|
599
|
+
(c) => stringProp(c, 'source'),
|
|
600
|
+
],
|
|
601
|
+
},
|
|
569
602
|
{
|
|
570
603
|
kind: 'endpoints',
|
|
571
604
|
collect: () => ({
|
|
@@ -580,7 +613,7 @@ function registerIntrospectionRoutes(
|
|
|
580
613
|
},
|
|
581
614
|
{
|
|
582
615
|
kind: 'bindings',
|
|
583
|
-
collect: () => ({ rows:
|
|
616
|
+
collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.bindings?.() ?? []) }),
|
|
584
617
|
fields: [
|
|
585
618
|
(a) => stringProp(a, 'name'),
|
|
586
619
|
(a) => stringProp(a, 'provider'),
|
|
@@ -589,7 +622,7 @@ function registerIntrospectionRoutes(
|
|
|
589
622
|
},
|
|
590
623
|
{
|
|
591
624
|
kind: 'tools',
|
|
592
|
-
collect: () => ({ rows:
|
|
625
|
+
collect: () => ({ rows: withAgentIntrospection(ctx, (value) => value?.tools?.() ?? []) }),
|
|
593
626
|
fields: [
|
|
594
627
|
(t) => stringProp(t, 'name'),
|
|
595
628
|
(t) => stringProp(t, 'source'),
|
|
@@ -599,7 +632,7 @@ function registerIntrospectionRoutes(
|
|
|
599
632
|
{
|
|
600
633
|
kind: 'mcp',
|
|
601
634
|
collect: () => {
|
|
602
|
-
const result =
|
|
635
|
+
const result = withAgentIntrospection(ctx, (value) => value?.mcp?.());
|
|
603
636
|
if (result == null) return { rows: [] as readonly unknown[] };
|
|
604
637
|
// readonly 数组不能用 Array.isArray 收窄,按对象形状判断
|
|
605
638
|
if (typeof result === 'object' && 'rows' in result) {
|
|
@@ -625,7 +658,7 @@ function registerIntrospectionRoutes(
|
|
|
625
658
|
);
|
|
626
659
|
const missing = kind === 'endpoints'
|
|
627
660
|
? !ctx.getEndpoints
|
|
628
|
-
: !
|
|
661
|
+
: !hasAgentIntrospection(ctx);
|
|
629
662
|
const degradedNote = note ?? (missing
|
|
630
663
|
? kind === 'endpoints'
|
|
631
664
|
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
@@ -644,18 +677,146 @@ function registerIntrospectionRoutes(
|
|
|
644
677
|
}
|
|
645
678
|
}, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
|
|
646
679
|
}
|
|
680
|
+
|
|
681
|
+
let activeComponentPreviews = 0;
|
|
682
|
+
route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
|
|
683
|
+
if (!requireWriteScope(response, ctx, authScope)) return;
|
|
684
|
+
const body = (await readJsonBody<Record<string, unknown>>(request)) ?? {};
|
|
685
|
+
const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
|
|
686
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
687
|
+
if (!requester || !name || requester.length > 256 || name.length > 160) {
|
|
688
|
+
writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
|
|
692
|
+
if (activeComponentPreviews >= maxConcurrent) {
|
|
693
|
+
writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const lease = ctx.acquireAgentRuntime?.();
|
|
697
|
+
const render = lease?.value.introspection?.renderComponent;
|
|
698
|
+
if (!lease || !render) {
|
|
699
|
+
lease?.release();
|
|
700
|
+
writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const controller = new AbortController();
|
|
704
|
+
const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
|
|
705
|
+
const timeout = setTimeout(() => controller.abort(
|
|
706
|
+
new ComponentPreviewTimeoutError(`Component preview 超过 ${timeoutMs}ms`),
|
|
707
|
+
), timeoutMs);
|
|
708
|
+
activeComponentPreviews += 1;
|
|
709
|
+
let released = false;
|
|
710
|
+
const release = () => {
|
|
711
|
+
if (released) return;
|
|
712
|
+
released = true;
|
|
713
|
+
activeComponentPreviews -= 1;
|
|
714
|
+
lease.release();
|
|
715
|
+
};
|
|
716
|
+
const renderPromise = Promise.resolve().then(() =>
|
|
717
|
+
render({ requester, name, props: body.props ?? {}, signal: controller.signal }));
|
|
718
|
+
// A timed-out implementation may ignore AbortSignal. Keep its generation
|
|
719
|
+
// lease until the real operation settles; the bounded concurrency gate
|
|
720
|
+
// prevents non-cooperative previews from pinning unbounded generations.
|
|
721
|
+
void renderPromise.then(release, release);
|
|
722
|
+
try {
|
|
723
|
+
const output = await raceWithAbort(
|
|
724
|
+
renderPromise,
|
|
725
|
+
controller.signal,
|
|
726
|
+
);
|
|
727
|
+
assertPreviewOutput(output, options.componentPreviewMaxBytes ?? 256 * 1024);
|
|
728
|
+
writeJson(response, 200, { success: true, data: { requester, name, output } });
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const status = error instanceof ComponentPreviewTimeoutError
|
|
731
|
+
? 408
|
|
732
|
+
: error instanceof ComponentPreviewLimitError ? 413 : 422;
|
|
733
|
+
writeJson(response, status, {
|
|
734
|
+
success: false,
|
|
735
|
+
error: error instanceof Error ? error.message : String(error),
|
|
736
|
+
});
|
|
737
|
+
} finally {
|
|
738
|
+
clearTimeout(timeout);
|
|
739
|
+
if (!controller.signal.aborted) controller.abort();
|
|
740
|
+
}
|
|
741
|
+
}, { summary: 'Render a Component preview', tags: ['console', 'introspection', 'component'] });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
class ComponentPreviewTimeoutError extends Error {
|
|
745
|
+
override readonly name = 'ComponentPreviewTimeoutError';
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
class ComponentPreviewLimitError extends Error {
|
|
749
|
+
override readonly name = 'ComponentPreviewLimitError';
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function clampPositiveInt(value: number | undefined, fallback: number, ceiling: number): number {
|
|
753
|
+
if (!Number.isFinite(value) || !value || value < 1) return fallback;
|
|
754
|
+
return Math.min(Math.floor(value), ceiling);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
758
|
+
if (signal.aborted) throw abortReason(signal);
|
|
759
|
+
return await new Promise<T>((resolve, reject) => {
|
|
760
|
+
const onAbort = () => reject(abortReason(signal));
|
|
761
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
762
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function abortReason(signal: AbortSignal): Error {
|
|
767
|
+
return signal.reason instanceof Error
|
|
768
|
+
? signal.reason
|
|
769
|
+
: new DOMException('Component preview aborted', 'AbortError');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function assertPreviewOutput(output: unknown, maxBytes: number): void {
|
|
773
|
+
const byteLimit = clampPositiveInt(maxBytes, 256 * 1024, 1024 * 1024);
|
|
774
|
+
const seen = new WeakSet<object>();
|
|
775
|
+
let nodes = 0;
|
|
776
|
+
const visit = (value: unknown, depth: number): void => {
|
|
777
|
+
nodes += 1;
|
|
778
|
+
if (nodes > 10_000) throw new ComponentPreviewLimitError('Component preview 节点数超过 10000');
|
|
779
|
+
if (depth > 24) throw new ComponentPreviewLimitError('Component preview 嵌套深度超过 24');
|
|
780
|
+
if (!value || typeof value !== 'object') return;
|
|
781
|
+
if (seen.has(value)) throw new ComponentPreviewLimitError('Component preview 不能包含循环引用');
|
|
782
|
+
seen.add(value);
|
|
783
|
+
if (Array.isArray(value)) {
|
|
784
|
+
for (const item of value) visit(item, depth + 1);
|
|
785
|
+
} else {
|
|
786
|
+
for (const item of Object.values(value as Record<string, unknown>)) visit(item, depth + 1);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
visit(output, 0);
|
|
790
|
+
let serialized: string;
|
|
791
|
+
try {
|
|
792
|
+
serialized = JSON.stringify(output);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
throw new ComponentPreviewLimitError(
|
|
795
|
+
`Component preview 无法序列化: ${error instanceof Error ? error.message : String(error)}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (Buffer.byteLength(serialized ?? '') > byteLimit) {
|
|
799
|
+
throw new ComponentPreviewLimitError(`Component preview 超过 ${byteLimit} bytes`);
|
|
800
|
+
}
|
|
647
801
|
}
|
|
648
802
|
|
|
649
|
-
function
|
|
803
|
+
function withAgentIntrospection<T>(
|
|
804
|
+
ctx: ConsoleRestCtx,
|
|
805
|
+
operation: (value: ConsoleAgentIntrospection | undefined) => T,
|
|
806
|
+
): T {
|
|
650
807
|
const lease = ctx.acquireAgentRuntime?.();
|
|
651
|
-
if (!lease) return undefined;
|
|
808
|
+
if (!lease) return operation(undefined);
|
|
652
809
|
try {
|
|
653
|
-
return lease.value.introspection;
|
|
810
|
+
return operation(lease.value.introspection);
|
|
654
811
|
} finally {
|
|
655
812
|
lease.release();
|
|
656
813
|
}
|
|
657
814
|
}
|
|
658
815
|
|
|
816
|
+
function hasAgentIntrospection(ctx: ConsoleRestCtx): boolean {
|
|
817
|
+
return withAgentIntrospection(ctx, (value) => value !== undefined);
|
|
818
|
+
}
|
|
819
|
+
|
|
659
820
|
// ---------------------------------------------------------------------------
|
|
660
821
|
// agent sessions(legacy agent-sessions-rest-api.ts;ADR 0010 D3)
|
|
661
822
|
// ---------------------------------------------------------------------------
|
|
@@ -708,14 +869,14 @@ function registerAgentSessionRoutes(
|
|
|
708
869
|
return;
|
|
709
870
|
}
|
|
710
871
|
|
|
711
|
-
const
|
|
872
|
+
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
712
873
|
const points = await runtime.listBranchPoints(sessionId);
|
|
713
874
|
writeJson(response, 200, {
|
|
714
875
|
success: true,
|
|
715
876
|
data: {
|
|
716
877
|
sessionKey: parsed.sessionKey,
|
|
717
878
|
sessionId,
|
|
718
|
-
activeLeafMessageId
|
|
879
|
+
activeLeafMessageId,
|
|
719
880
|
points,
|
|
720
881
|
},
|
|
721
882
|
});
|
|
@@ -782,28 +943,28 @@ function registerAgentSessionRoutes(
|
|
|
782
943
|
|
|
783
944
|
if (branchIndex !== undefined) {
|
|
784
945
|
const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
|
|
785
|
-
const
|
|
946
|
+
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
786
947
|
writeJson(response, result.ok ? 200 : 400, {
|
|
787
948
|
success: result.ok,
|
|
788
949
|
message: result.message,
|
|
789
950
|
data: {
|
|
790
951
|
sessionKey: parsed.sessionKey,
|
|
791
952
|
sessionId,
|
|
792
|
-
activeLeafMessageId
|
|
953
|
+
activeLeafMessageId,
|
|
793
954
|
},
|
|
794
955
|
});
|
|
795
956
|
return;
|
|
796
957
|
}
|
|
797
958
|
|
|
798
959
|
const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
|
|
799
|
-
const
|
|
960
|
+
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
800
961
|
writeJson(response, ok ? 200 : 400, {
|
|
801
962
|
success: ok,
|
|
802
963
|
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
803
964
|
data: {
|
|
804
965
|
sessionKey: parsed.sessionKey,
|
|
805
966
|
sessionId,
|
|
806
|
-
activeLeafMessageId
|
|
967
|
+
activeLeafMessageId,
|
|
807
968
|
},
|
|
808
969
|
});
|
|
809
970
|
} finally {
|