@zhin.js/host-http 1.1.17 → 1.2.1
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/README.md +60 -21
- package/lib/access-control.d.ts +127 -0
- package/lib/access-control.js +148 -0
- package/lib/addresses.d.ts +9 -0
- package/lib/addresses.js +9 -0
- package/lib/bridge/bridged-host.d.ts +43 -0
- package/lib/bridge/bridged-host.js +497 -0
- package/lib/bridge/control-channel.d.ts +59 -0
- package/lib/bridge/control-channel.js +131 -0
- package/lib/bridge/flow.d.ts +58 -0
- package/lib/bridge/flow.js +188 -0
- package/lib/bridge/index.d.ts +6 -0
- package/lib/bridge/index.js +6 -0
- package/lib/bridge/instance-transport.d.ts +112 -0
- package/lib/bridge/instance-transport.js +532 -0
- package/lib/bridge/protocol.d.ts +242 -0
- package/lib/bridge/protocol.js +369 -0
- package/lib/console-events.d.ts +5 -3
- package/lib/console-events.js +53 -27
- package/lib/console-plugin-rpc.d.ts +12 -1
- package/lib/console-plugin-rpc.js +16 -66
- package/lib/console-rest-pages-helpers.d.ts +4 -5
- package/lib/console-rest-pages-helpers.js +62 -79
- package/lib/console-rest-pages.js +80 -65
- package/lib/console-rpc-extended/dispatcher.js +1 -6
- package/lib/console-rpc-handlers.d.ts +0 -2
- package/lib/console-rpc-handlers.js +2 -40
- package/lib/console-rpc.d.ts +6 -18
- package/lib/console-rpc.js +6 -6
- package/lib/host-installer.d.ts +6 -3
- package/lib/host-installer.js +10 -40
- package/lib/host-options.d.ts +47 -0
- package/lib/host-options.js +1 -0
- package/lib/http-host.d.ts +124 -48
- package/lib/http-host.js +319 -332
- package/lib/index.d.ts +11 -6
- package/lib/index.js +10 -6
- package/lib/json-body.d.ts +1 -2
- package/lib/json-body.js +1 -2
- package/lib/marketplace-registry.d.ts +17 -0
- package/lib/marketplace-registry.js +52 -0
- package/lib/node/index.d.ts +10 -0
- package/lib/node/index.js +10 -0
- package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
- package/lib/{listener-group.js → node/listener-group.js} +6 -10
- package/lib/node/listener.d.ts +26 -0
- package/lib/node/listener.js +231 -0
- package/lib/node/node-http.d.ts +38 -0
- package/lib/node/node-http.js +272 -0
- package/lib/node/server.d.ts +13 -0
- package/lib/node/server.js +64 -0
- package/lib/protocol-auth.d.ts +24 -0
- package/lib/protocol-auth.js +48 -0
- package/lib/public-api.d.ts +1 -1
- package/lib/token-registry.d.ts +54 -36
- package/lib/token-registry.js +133 -126
- package/lib/trust-proxy.d.ts +22 -0
- package/lib/trust-proxy.js +168 -0
- package/package.json +14 -6
- package/src/access-control.ts +255 -0
- package/src/addresses.ts +16 -0
- package/src/bridge/bridged-host.ts +548 -0
- package/src/bridge/control-channel.ts +173 -0
- package/src/bridge/flow.ts +208 -0
- package/src/bridge/index.ts +31 -0
- package/src/bridge/instance-transport.ts +627 -0
- package/src/bridge/protocol.ts +574 -0
- package/src/console-events.ts +59 -28
- package/src/console-plugin-rpc.ts +17 -77
- package/src/console-rest-pages-helpers.ts +60 -82
- package/src/console-rest-pages.ts +87 -67
- package/src/console-rpc-extended/dispatcher.ts +1 -6
- package/src/console-rpc-handlers.ts +2 -31
- package/src/console-rpc.ts +10 -24
- package/src/host-installer.ts +12 -44
- package/src/host-options.ts +47 -0
- package/src/http-host.ts +467 -421
- package/src/index.ts +56 -14
- package/src/json-body.ts +7 -2
- package/src/marketplace-registry.ts +62 -0
- package/src/node/index.ts +21 -0
- package/src/{listener-group.ts → node/listener-group.ts} +13 -24
- package/src/node/listener.ts +265 -0
- package/src/node/node-http.ts +263 -0
- package/src/node/server.ts +82 -0
- package/src/protocol-auth.ts +52 -0
- package/src/public-api.ts +2 -0
- package/src/token-registry.ts +143 -146
- package/src/trust-proxy.ts +178 -0
package/src/console-events.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import type {
|
|
2
|
+
import type { HttpResponseStream } from '@zhin.js/host-http-contract';
|
|
3
3
|
import type {
|
|
4
4
|
ConsoleEventEnvelope,
|
|
5
5
|
ConsoleEventHistoryPage,
|
|
@@ -11,9 +11,13 @@ import { createToken } from '@zhin.js/plugin-runtime';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
|
|
14
|
-
* 单进程内 fan-out:`publish` 向所有已订阅的 SSE
|
|
14
|
+
* 单进程内 fan-out:`publish` 向所有已订阅的 SSE 响应流写帧;
|
|
15
15
|
* write 失败或连接断开自动摘除订阅。
|
|
16
16
|
*/
|
|
17
|
+
|
|
18
|
+
/** The SSE response body a subscriber reads from (`createResponseStream()`). */
|
|
19
|
+
export type ConsoleEventSink = Pick<HttpResponseStream, 'write' | 'onDrain' | 'onClose' | 'error'>;
|
|
20
|
+
|
|
17
21
|
export interface ConsoleEventSubscriptionOptions {
|
|
18
22
|
readonly runtimeId?: string;
|
|
19
23
|
readonly after?: number;
|
|
@@ -31,7 +35,7 @@ export interface ConsoleEventHub {
|
|
|
31
35
|
/** In-process delivery for trusted Host bridges such as Device Protocol. */
|
|
32
36
|
listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
|
|
33
37
|
/** Atomically replay the resumable suffix, then join live fan-out. */
|
|
34
|
-
subscribe(
|
|
38
|
+
subscribe(sink: ConsoleEventSink, options?: ConsoleEventSubscriptionOptions): () => void;
|
|
35
39
|
readonly runtimeId: string;
|
|
36
40
|
readonly subscriberCount: number;
|
|
37
41
|
}
|
|
@@ -70,29 +74,61 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
70
74
|
);
|
|
71
75
|
const journal: JournalEntry[] = [];
|
|
72
76
|
let journalBytes = 0;
|
|
73
|
-
|
|
77
|
+
/**
|
|
78
|
+
* `null` while a subscriber is live. During replay catch-up it holds the frames
|
|
79
|
+
* (replay suffix, then live events) still waiting for the socket to drain.
|
|
80
|
+
*/
|
|
81
|
+
const subscribers = new Map<ConsoleEventSink, JournalEntry[] | null>();
|
|
74
82
|
const listeners = new Set<(event: ConsoleEventEnvelope) => void>();
|
|
75
83
|
|
|
76
|
-
const remove = (response:
|
|
84
|
+
const remove = (response: ConsoleEventSink): void => {
|
|
77
85
|
subscribers.delete(response);
|
|
78
86
|
};
|
|
79
87
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
const drop = (response: ConsoleEventSink): void => {
|
|
89
|
+
remove(response);
|
|
90
|
+
response.error(new Error('Console event subscriber backpressure limit reached'));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Returns false when the consumer asks the producer to wait for drain. */
|
|
94
|
+
const write = (response: ConsoleEventSink, entry: JournalEntry): boolean =>
|
|
95
|
+
response.write(entry.frame);
|
|
96
|
+
|
|
97
|
+
/** Write queued frames until the socket pushes back again or the queue is empty. */
|
|
98
|
+
const flush = (response: ConsoleEventSink): void => {
|
|
99
|
+
const pending = subscribers.get(response);
|
|
100
|
+
if (!pending) return;
|
|
101
|
+
try {
|
|
102
|
+
while (pending.length > 0) {
|
|
103
|
+
if (!write(response, pending.shift()!)) {
|
|
104
|
+
const stop = response.onDrain(() => {
|
|
105
|
+
stop();
|
|
106
|
+
flush(response);
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
86
110
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!accepted) {
|
|
111
|
+
subscribers.set(response, null);
|
|
112
|
+
} catch {
|
|
90
113
|
remove(response);
|
|
91
|
-
response.destroy(new Error('Console event subscriber backpressure limit reached'));
|
|
92
|
-
throw new Error('Console event subscriber backpressure limit reached');
|
|
93
114
|
}
|
|
94
115
|
};
|
|
95
116
|
|
|
117
|
+
const deliver = (response: ConsoleEventSink, entry: JournalEntry): void => {
|
|
118
|
+
const pending = subscribers.get(response);
|
|
119
|
+
if (pending === undefined) return;
|
|
120
|
+
if (pending === null) {
|
|
121
|
+
// A live subscriber that cannot keep up is disconnected; it resumes from history.
|
|
122
|
+
if (!write(response, entry)) drop(response);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
pending.push(entry);
|
|
126
|
+
// Catch-up may not outgrow what the journal itself is allowed to retain.
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
for (const queued of pending) bytes += queued.bytes;
|
|
129
|
+
if (pending.length > historyLimit || bytes > historyByteLimit) drop(response);
|
|
130
|
+
};
|
|
131
|
+
|
|
96
132
|
return {
|
|
97
133
|
runtimeId,
|
|
98
134
|
get subscriberCount() {
|
|
@@ -120,9 +156,9 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
120
156
|
while (journal.length > historyLimit || journalBytes > historyByteLimit) {
|
|
121
157
|
journalBytes -= journal.shift()?.bytes ?? 0;
|
|
122
158
|
}
|
|
123
|
-
for (const response of [...subscribers]) {
|
|
159
|
+
for (const response of [...subscribers.keys()]) {
|
|
124
160
|
try {
|
|
125
|
-
|
|
161
|
+
deliver(response, entry);
|
|
126
162
|
} catch {
|
|
127
163
|
remove(response);
|
|
128
164
|
}
|
|
@@ -180,16 +216,11 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
|
|
|
180
216
|
|| requestedAfter > nextId
|
|
181
217
|
? 0
|
|
182
218
|
: requestedAfter;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
} catch {
|
|
189
|
-
remove(response);
|
|
190
|
-
}
|
|
191
|
-
response.once('close', () => remove(response));
|
|
192
|
-
response.once('error', () => remove(response));
|
|
219
|
+
// Backpressure during replay is expected for a large suffix: the remaining frames
|
|
220
|
+
// (and any event published meanwhile) wait in order for the socket to drain.
|
|
221
|
+
subscribers.set(response, journal.filter((entry) => entry.event.eventId > after));
|
|
222
|
+
response.onClose(() => remove(response));
|
|
223
|
+
flush(response);
|
|
193
224
|
return () => remove(response);
|
|
194
225
|
},
|
|
195
226
|
};
|
|
@@ -5,7 +5,19 @@ import type {
|
|
|
5
5
|
RuntimeConsoleRpcReply,
|
|
6
6
|
} from './console-rpc.js';
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Refusal of the global plugin switch (`plugin:set-enabled`, `feature.set-enabled` at `global`)
|
|
10
|
+
* by an instance: the switch is a gateway transaction (stop → write `disable_plugins` → start,
|
|
11
|
+
* rollback on failure) and is never written by the instance itself (ADR 0054 §4).
|
|
12
|
+
*/
|
|
13
|
+
export const PLUGIN_SWITCH_REQUIRES_GATEWAY = '插件全局启停由管理网关执行(停止实例 → 写入 disable_plugins → 启动,失败回滚):请在 zhin start 启动的 Console 中操作,或运行 zhin enable / zhin disable <插件>';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
|
|
17
|
+
* installs / updates / uninstalls are not handled in the instance: they are gateway dependency
|
|
18
|
+
* transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first, and so is the
|
|
19
|
+
* global plugin switch (`plugin:set-enabled`, refused here).
|
|
20
|
+
*/
|
|
9
21
|
export async function dispatchPluginConsoleRpc(
|
|
10
22
|
type: string,
|
|
11
23
|
message: RuntimeConsoleRpcMessage,
|
|
@@ -14,32 +26,10 @@ export async function dispatchPluginConsoleRpc(
|
|
|
14
26
|
const requestId = message.requestId as number | string | undefined;
|
|
15
27
|
try {
|
|
16
28
|
switch (type) {
|
|
17
|
-
case PLUGIN_RPC.SET_ENABLED:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return { requestId, error: 'instanceKey and boolean enabled are required' };
|
|
22
|
-
}
|
|
23
|
-
if (!ctx.setPluginEnabled) {
|
|
24
|
-
return { requestId, error: 'Plugin lifecycle management is not configured' };
|
|
25
|
-
}
|
|
26
|
-
const lifecycle = await ctx.setPluginEnabled(instanceKey, enabled);
|
|
27
|
-
ctx.publishEvent?.('plugin:lifecycle-updated', { instanceKey, enabled });
|
|
28
|
-
if (ctx.requestRestart) {
|
|
29
|
-
setTimeout(() => void Promise.resolve(ctx.requestRestart?.()).catch(() => undefined), 500);
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
requestId,
|
|
33
|
-
data: {
|
|
34
|
-
success: true,
|
|
35
|
-
instanceKey,
|
|
36
|
-
enabled,
|
|
37
|
-
disabled: lifecycle.disabled,
|
|
38
|
-
restartRequired: true,
|
|
39
|
-
message: `${instanceKey} 已${enabled ? '启用' : '停用'},Host 正在重启`,
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
29
|
+
case PLUGIN_RPC.SET_ENABLED:
|
|
30
|
+
// Global plugin on / off is a management gateway transaction (ADR 0054 §4): the gateway
|
|
31
|
+
// answers it before forwarding. Reaching the instance means no gateway is in front.
|
|
32
|
+
return { requestId, error: PLUGIN_SWITCH_REQUIRES_GATEWAY };
|
|
43
33
|
case PLUGIN_RPC.INSPECT:
|
|
44
34
|
case PLUGIN_RPC.APPLY:
|
|
45
35
|
case PLUGIN_RPC.PLAN_VERIFICATION:
|
|
@@ -71,20 +61,6 @@ export async function dispatchPluginConsoleRpc(
|
|
|
71
61
|
if (!ctx.pluginManagement) return { requestId, error: 'Plugin management is not configured' };
|
|
72
62
|
return { requestId, data: await ctx.pluginManagement.planInstall(packageName) };
|
|
73
63
|
}
|
|
74
|
-
case PLUGIN_RPC.INSTALL: {
|
|
75
|
-
const packageName = stringField(message, 'packageName');
|
|
76
|
-
if (!packageName) return { requestId, error: 'packageName is required' };
|
|
77
|
-
if (!ctx.pluginManagement?.install) {
|
|
78
|
-
return { requestId, error: 'Plugin installation is not configured' };
|
|
79
|
-
}
|
|
80
|
-
const result = await ctx.pluginManagement.install(
|
|
81
|
-
packageName,
|
|
82
|
-
optionalString(message.expectedRevision),
|
|
83
|
-
optionalString(message.expectedProjectRevision),
|
|
84
|
-
);
|
|
85
|
-
ctx.publishEvent?.('plugin:installed', { packageName, restartRequired: result.restartRequired });
|
|
86
|
-
return { requestId, data: { success: true, ...result } };
|
|
87
|
-
}
|
|
88
64
|
case PLUGIN_RPC.PLAN_UNINSTALL: {
|
|
89
65
|
const packageName = stringField(message, 'packageName');
|
|
90
66
|
if (!packageName) return { requestId, error: 'packageName is required' };
|
|
@@ -93,22 +69,6 @@ export async function dispatchPluginConsoleRpc(
|
|
|
93
69
|
}
|
|
94
70
|
return { requestId, data: await ctx.pluginManagement.planUninstall(packageName) };
|
|
95
71
|
}
|
|
96
|
-
case PLUGIN_RPC.UNINSTALL: {
|
|
97
|
-
const packageName = stringField(message, 'packageName');
|
|
98
|
-
if (!packageName || stringField(message, 'confirmation') !== packageName) {
|
|
99
|
-
return { requestId, error: 'confirmation must exactly match packageName' };
|
|
100
|
-
}
|
|
101
|
-
if (!ctx.pluginManagement?.uninstall) {
|
|
102
|
-
return { requestId, error: 'Plugin uninstall is not configured' };
|
|
103
|
-
}
|
|
104
|
-
const result = await ctx.pluginManagement.uninstall(
|
|
105
|
-
packageName,
|
|
106
|
-
optionalString(message.expectedRevision),
|
|
107
|
-
optionalString(message.expectedProjectRevision),
|
|
108
|
-
);
|
|
109
|
-
ctx.publishEvent?.('plugin:uninstalled', { packageName, restartRequired: result.restartRequired });
|
|
110
|
-
return { requestId, data: { success: true, ...result } };
|
|
111
|
-
}
|
|
112
72
|
case PLUGIN_RPC.PLAN_UPDATE: {
|
|
113
73
|
const packageName = stringField(message, 'packageName');
|
|
114
74
|
const targetVersion = stringField(message, 'targetVersion');
|
|
@@ -120,26 +80,6 @@ export async function dispatchPluginConsoleRpc(
|
|
|
120
80
|
}
|
|
121
81
|
return { requestId, data: await ctx.pluginManagement.planUpdate(packageName, targetVersion) };
|
|
122
82
|
}
|
|
123
|
-
case PLUGIN_RPC.UPDATE: {
|
|
124
|
-
const packageName = stringField(message, 'packageName');
|
|
125
|
-
const targetVersion = stringField(message, 'targetVersion');
|
|
126
|
-
if (!packageName || !targetVersion) {
|
|
127
|
-
return { requestId, error: 'packageName and targetVersion are required' };
|
|
128
|
-
}
|
|
129
|
-
if (!ctx.pluginManagement?.update) return { requestId, error: 'Plugin update is not configured' };
|
|
130
|
-
const result = await ctx.pluginManagement.update(
|
|
131
|
-
packageName,
|
|
132
|
-
targetVersion,
|
|
133
|
-
optionalString(message.expectedRevision),
|
|
134
|
-
optionalString(message.expectedProjectRevision),
|
|
135
|
-
);
|
|
136
|
-
ctx.publishEvent?.('plugin:updated', {
|
|
137
|
-
packageName,
|
|
138
|
-
version: result.installedVersion,
|
|
139
|
-
restartRequired: result.restartRequired,
|
|
140
|
-
});
|
|
141
|
-
return { requestId, data: { success: true, ...result } };
|
|
142
|
-
}
|
|
143
83
|
case PLUGIN_RPC.VALIDATE_CONFIG: {
|
|
144
84
|
const pluginName = stringField(message, 'pluginName');
|
|
145
85
|
if (!pluginName) return { requestId, error: 'pluginName is required' };
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type { ServerResponse } from 'node:http';
|
|
2
1
|
import type { HttpHost } from './http-host.js';
|
|
3
2
|
import { HttpBodyError, readJsonBody } from './json-body.js';
|
|
4
|
-
import
|
|
3
|
+
import { jsonResponse } from '@zhin.js/host-http-contract';
|
|
4
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
5
|
+
|
|
6
|
+
const logger = getLogger('console');
|
|
7
|
+
|
|
8
|
+
/** Logs an unexpected failure server-side; clients only get a generic message. */
|
|
9
|
+
export function logInternalError(op: string, error: unknown): void {
|
|
10
|
+
logger.warn(formatCompact({ op, error: error instanceof Error ? error.message : String(error) }));
|
|
11
|
+
}
|
|
5
12
|
import type {
|
|
6
13
|
ConsoleAgentIntrospection,
|
|
7
14
|
ConsoleAgentSessionTree,
|
|
@@ -125,7 +132,7 @@ export function registerIntrospectionRoutes(
|
|
|
125
132
|
];
|
|
126
133
|
|
|
127
134
|
for (const { kind, collect, fields } of collectors) {
|
|
128
|
-
route('GET', `${base}/introspection/${kind}`, async (_request,
|
|
135
|
+
route('GET', `${base}/introspection/${kind}`, async (_request, { url }) => {
|
|
129
136
|
const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
|
|
130
137
|
try {
|
|
131
138
|
const introspection = lease?.value.introspection;
|
|
@@ -146,15 +153,16 @@ export function registerIntrospectionRoutes(
|
|
|
146
153
|
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
147
154
|
: 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
|
|
148
155
|
: undefined);
|
|
149
|
-
|
|
156
|
+
return jsonResponse(200, {
|
|
150
157
|
success: true,
|
|
151
158
|
data: { ...slice, filter, note: degradedNote },
|
|
152
159
|
});
|
|
153
160
|
} catch (err) {
|
|
154
|
-
|
|
161
|
+
logInternalError(`introspection_${kind}_failed`, err);
|
|
162
|
+
return jsonResponse(503, {
|
|
155
163
|
success: false,
|
|
156
164
|
data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
|
|
157
|
-
error:
|
|
165
|
+
error: 'Introspection unavailable',
|
|
158
166
|
});
|
|
159
167
|
} finally {
|
|
160
168
|
lease?.release();
|
|
@@ -163,27 +171,25 @@ export function registerIntrospectionRoutes(
|
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
let activeComponentPreviews = 0;
|
|
166
|
-
route('POST', `${base}/introspection/components/render`, async (request
|
|
167
|
-
|
|
174
|
+
route('POST', `${base}/introspection/components/render`, async (request) => {
|
|
175
|
+
const denied = requireWriteScope(ctx);
|
|
176
|
+
if (denied) return denied;
|
|
168
177
|
const body = (await readJsonBody<Record<string, unknown>>(request)) ?? {};
|
|
169
178
|
const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
|
|
170
179
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
171
180
|
if (!requester || !name || requester.length > 256 || name.length > 160) {
|
|
172
|
-
|
|
173
|
-
return;
|
|
181
|
+
return jsonResponse(400, { success: false, error: '请提供有效的 requester 与 component name' });
|
|
174
182
|
}
|
|
175
183
|
const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
|
|
176
184
|
if (activeComponentPreviews >= maxConcurrent) {
|
|
177
|
-
|
|
178
|
-
return;
|
|
185
|
+
return jsonResponse(503, { success: false, error: 'Component preview 正忙,请稍后重试' });
|
|
179
186
|
}
|
|
180
187
|
const lease = ctx.acquireAgentRuntime?.();
|
|
181
188
|
const render = lease?.value.introspection?.renderComponent;
|
|
182
189
|
const previewDelivery = lease?.value.introspection?.previewComponentDelivery;
|
|
183
190
|
if (!lease || !render) {
|
|
184
191
|
lease?.release();
|
|
185
|
-
|
|
186
|
-
return;
|
|
192
|
+
return jsonResponse(503, { success: false, error: 'Component Runtime 未就绪' });
|
|
187
193
|
}
|
|
188
194
|
const controller = new AbortController();
|
|
189
195
|
const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
|
|
@@ -214,14 +220,17 @@ export function registerIntrospectionRoutes(
|
|
|
214
220
|
controller.signal,
|
|
215
221
|
);
|
|
216
222
|
assertPreviewOutput(preview, options.componentPreviewMaxBytes ?? 256 * 1024);
|
|
217
|
-
|
|
223
|
+
return jsonResponse(200, { success: true, data: { requester, name, ...preview } });
|
|
218
224
|
} catch (error) {
|
|
219
225
|
const status = error instanceof ComponentPreviewTimeoutError
|
|
220
226
|
? 408
|
|
221
227
|
: error instanceof ComponentPreviewLimitError ? 413 : 422;
|
|
222
|
-
|
|
228
|
+
// Timeout / size limit messages are ours; a component's own failure detail stays in the log.
|
|
229
|
+
const known = error instanceof ComponentPreviewTimeoutError || error instanceof ComponentPreviewLimitError;
|
|
230
|
+
if (!known) logInternalError('component_preview_failed', error);
|
|
231
|
+
return jsonResponse(status, {
|
|
223
232
|
success: false,
|
|
224
|
-
error: error
|
|
233
|
+
error: known ? (error as Error).message : 'Component preview failed',
|
|
225
234
|
});
|
|
226
235
|
} finally {
|
|
227
236
|
clearTimeout(timeout);
|
|
@@ -311,77 +320,68 @@ export function registerAgentSessionRoutes(
|
|
|
311
320
|
}
|
|
312
321
|
return { tree, release: () => lease.release() };
|
|
313
322
|
};
|
|
314
|
-
const unavailable = (
|
|
315
|
-
|
|
323
|
+
const unavailable = (headers?: HeadersInit) => {
|
|
324
|
+
return jsonResponse(503, {
|
|
316
325
|
success: false,
|
|
317
326
|
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
|
|
318
|
-
});
|
|
327
|
+
}, headers);
|
|
319
328
|
};
|
|
320
329
|
|
|
321
330
|
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
|
|
322
|
-
route('GET', `${base}/agent/sessions/*`, async (_request,
|
|
331
|
+
route('GET', `${base}/agent/sessions/*`, async (_request, { url }) => {
|
|
323
332
|
const messageAction = parseSessionAction(url.pathname, prefix, 'message');
|
|
324
|
-
|
|
333
|
+
const headers: HeadersInit | undefined = messageAction ? { 'cache-control': 'no-store' } : undefined;
|
|
334
|
+
const reply = (status: number, body: unknown) => jsonResponse(status, body, headers);
|
|
325
335
|
const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
|
|
326
336
|
if (!parsed) {
|
|
327
|
-
|
|
328
|
-
return;
|
|
337
|
+
return reply(404, { success: false, error: 'Not found' });
|
|
329
338
|
}
|
|
330
339
|
const messageId = Number(url.searchParams.get('messageId'));
|
|
331
340
|
const expectedSessionId = url.searchParams.get('sessionId');
|
|
332
341
|
if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
|
|
333
|
-
|
|
334
|
-
return;
|
|
342
|
+
return reply(400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
|
|
335
343
|
}
|
|
336
344
|
const lease = acquireSessionTree();
|
|
337
345
|
if (!lease) {
|
|
338
|
-
unavailable(
|
|
339
|
-
return;
|
|
346
|
+
return unavailable(headers);
|
|
340
347
|
}
|
|
341
348
|
const runtime = lease.tree;
|
|
342
349
|
try {
|
|
343
350
|
if (messageAction && !runtime.readBranchMessage) {
|
|
344
|
-
|
|
345
|
-
return;
|
|
351
|
+
return reply(501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
|
|
346
352
|
}
|
|
347
353
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
348
354
|
if (messageAction) {
|
|
349
355
|
if (!sessionId) {
|
|
350
|
-
|
|
351
|
-
return;
|
|
356
|
+
return reply(404, { success: false, error: '未找到活跃会话' });
|
|
352
357
|
}
|
|
353
358
|
if (sessionId !== expectedSessionId) {
|
|
354
|
-
|
|
355
|
-
return;
|
|
359
|
+
return reply(409, { success: false, error: '会话已变化,请重新读取会话列表' });
|
|
356
360
|
}
|
|
357
361
|
const text = await runtime.readBranchMessage!(sessionId, messageId);
|
|
358
362
|
if (text === null) {
|
|
359
|
-
|
|
360
|
-
return;
|
|
363
|
+
return reply(404, { success: false, error: '未找到此会话的用户消息' });
|
|
361
364
|
}
|
|
362
|
-
|
|
363
|
-
return;
|
|
365
|
+
return reply(200, { success: true, data: { messageId, text } });
|
|
364
366
|
}
|
|
365
367
|
if (!sessionId) {
|
|
366
368
|
if (!isRuntimeImSessionKey(parsed.sessionKey)) {
|
|
367
|
-
|
|
369
|
+
return reply(404, {
|
|
368
370
|
success: false,
|
|
369
371
|
error: `未找到会话:${parsed.sessionKey}`,
|
|
370
372
|
});
|
|
371
|
-
return;
|
|
372
373
|
}
|
|
373
374
|
const knownConversation = await ctx.isKnownConversationSession?.(parsed.sessionKey);
|
|
374
375
|
if (knownConversation === false) {
|
|
375
|
-
|
|
376
|
+
return reply(404, {
|
|
376
377
|
success: false,
|
|
377
378
|
error: `未找到会话:${parsed.sessionKey}`,
|
|
378
379
|
});
|
|
379
|
-
return;
|
|
380
380
|
}
|
|
381
381
|
// 渠道会话与 Agent 会话的生命周期不同:用户可以先在 Console
|
|
382
382
|
// 选中一个真实渠道会话,而该会话尚未触发过 Agent。树查询是只读操作,
|
|
383
383
|
// 因而将这种正常状态返回为可渲染空态,而不是误报资源不存在。
|
|
384
|
-
|
|
384
|
+
return reply(200, {
|
|
385
385
|
success: true,
|
|
386
386
|
data: {
|
|
387
387
|
state: 'not_started',
|
|
@@ -391,12 +391,11 @@ export function registerAgentSessionRoutes(
|
|
|
391
391
|
points: [],
|
|
392
392
|
},
|
|
393
393
|
});
|
|
394
|
-
return;
|
|
395
394
|
}
|
|
396
395
|
|
|
397
396
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
398
397
|
const points = await runtime.listBranchPoints(sessionId);
|
|
399
|
-
|
|
398
|
+
return reply(200, {
|
|
400
399
|
success: true,
|
|
401
400
|
data: {
|
|
402
401
|
state: 'active',
|
|
@@ -411,20 +410,19 @@ export function registerAgentSessionRoutes(
|
|
|
411
410
|
}
|
|
412
411
|
}, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
|
|
413
412
|
|
|
414
|
-
route('POST', `${base}/agent/sessions/*`, async (request,
|
|
413
|
+
route('POST', `${base}/agent/sessions/*`, async (request, { url }) => {
|
|
415
414
|
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
416
415
|
if (!parsed) {
|
|
417
|
-
|
|
418
|
-
return;
|
|
416
|
+
return jsonResponse(404, { success: false, error: 'Not found' });
|
|
419
417
|
}
|
|
420
|
-
|
|
418
|
+
const denied = requireWriteScope(ctx);
|
|
419
|
+
if (denied) return denied;
|
|
421
420
|
let body: Record<string, unknown>;
|
|
422
421
|
try {
|
|
423
422
|
body = (await readJsonBody(request) ?? {}) as Record<string, unknown>;
|
|
424
423
|
} catch (error) {
|
|
425
424
|
if (error instanceof HttpBodyError) {
|
|
426
|
-
|
|
427
|
-
return;
|
|
425
|
+
return jsonResponse(error.statusCode, { success: false, error: error.message });
|
|
428
426
|
}
|
|
429
427
|
throw error;
|
|
430
428
|
}
|
|
@@ -435,42 +433,37 @@ export function registerAgentSessionRoutes(
|
|
|
435
433
|
if (messageIdRaw != null && messageIdRaw !== '') {
|
|
436
434
|
const value = Number(messageIdRaw);
|
|
437
435
|
if (!Number.isFinite(value) || value < 1) {
|
|
438
|
-
|
|
439
|
-
return;
|
|
436
|
+
return jsonResponse(400, { success: false, error: 'messageId 须为正整数' });
|
|
440
437
|
}
|
|
441
438
|
messageId = value;
|
|
442
439
|
} else if (indexRaw != null && indexRaw !== '') {
|
|
443
440
|
const value = Number(indexRaw);
|
|
444
441
|
if (!Number.isFinite(value) || value < 1) {
|
|
445
|
-
|
|
446
|
-
return;
|
|
442
|
+
return jsonResponse(400, { success: false, error: 'index 须为正整数' });
|
|
447
443
|
}
|
|
448
444
|
branchIndex = value;
|
|
449
445
|
} else {
|
|
450
|
-
|
|
451
|
-
return;
|
|
446
|
+
return jsonResponse(400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
452
447
|
}
|
|
453
448
|
|
|
454
449
|
const lease = acquireSessionTree();
|
|
455
450
|
if (!lease) {
|
|
456
|
-
unavailable(
|
|
457
|
-
return;
|
|
451
|
+
return unavailable();
|
|
458
452
|
}
|
|
459
453
|
const runtime = lease.tree;
|
|
460
454
|
try {
|
|
461
455
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
462
456
|
if (!sessionId) {
|
|
463
|
-
|
|
457
|
+
return jsonResponse(404, {
|
|
464
458
|
success: false,
|
|
465
459
|
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
466
460
|
});
|
|
467
|
-
return;
|
|
468
461
|
}
|
|
469
462
|
|
|
470
463
|
if (branchIndex !== undefined) {
|
|
471
464
|
const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
|
|
472
465
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
473
|
-
|
|
466
|
+
return jsonResponse(result.ok ? 200 : 400, {
|
|
474
467
|
success: result.ok,
|
|
475
468
|
message: result.message,
|
|
476
469
|
data: {
|
|
@@ -479,12 +472,11 @@ export function registerAgentSessionRoutes(
|
|
|
479
472
|
activeLeafMessageId,
|
|
480
473
|
},
|
|
481
474
|
});
|
|
482
|
-
return;
|
|
483
475
|
}
|
|
484
476
|
|
|
485
477
|
const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
|
|
486
478
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
487
|
-
|
|
479
|
+
return jsonResponse(ok ? 200 : 400, {
|
|
488
480
|
success: ok,
|
|
489
481
|
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
490
482
|
data: {
|
|
@@ -576,18 +568,13 @@ export function parsePositiveInt(raw: string | null, fallback: number): number {
|
|
|
576
568
|
// helpers
|
|
577
569
|
// ---------------------------------------------------------------------------
|
|
578
570
|
|
|
579
|
-
/** 写操作门禁:demo 部署(ctx.fullScope=false
|
|
580
|
-
export function requireWriteScope(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
authScope: AuthScope,
|
|
584
|
-
): boolean {
|
|
585
|
-
if (ctx.fullScope && authScope === 'full') return true;
|
|
586
|
-
writeJson(response, 403, {
|
|
571
|
+
/** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
|
|
572
|
+
export function requireWriteScope(ctx: ConsoleRestCtx): Response | undefined {
|
|
573
|
+
if (ctx.fullScope) return undefined;
|
|
574
|
+
return jsonResponse(403, {
|
|
587
575
|
success: false,
|
|
588
576
|
error: 'Demo scope: 只读访问,写操作被拒绝',
|
|
589
577
|
});
|
|
590
|
-
return false;
|
|
591
578
|
}
|
|
592
579
|
|
|
593
580
|
export function decodePathParam(raw: string): string {
|
|
@@ -608,12 +595,3 @@ export function normalizeBase(value: string): string {
|
|
|
608
595
|
if (!value.startsWith('/')) return `/${value}`;
|
|
609
596
|
return trimTrailingSlashes(value) || '/api';
|
|
610
597
|
}
|
|
611
|
-
|
|
612
|
-
export function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
613
|
-
const payload = JSON.stringify(body);
|
|
614
|
-
response.writeHead(status, {
|
|
615
|
-
'content-type': 'application/json; charset=utf-8',
|
|
616
|
-
'content-length': Buffer.byteLength(payload),
|
|
617
|
-
});
|
|
618
|
-
response.end(payload);
|
|
619
|
-
}
|