@zhin.js/host-http 1.1.17 → 1.2.0
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 +5 -1
- package/lib/console-plugin-rpc.js +5 -40
- 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 -14
- 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 +10 -5
- package/lib/index.js +9 -5
- 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 +5 -51
- 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 -19
- 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 +55 -13
- 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/lib/console-events.js
CHANGED
|
@@ -16,27 +16,60 @@ export function createConsoleEventHub(options = {}) {
|
|
|
16
16
|
const historyPageByteLimit = positiveInteger(options.historyPageByteLimit, DEFAULT_HISTORY_PAGE_BYTE_LIMIT);
|
|
17
17
|
const journal = [];
|
|
18
18
|
let journalBytes = 0;
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `null` while a subscriber is live. During replay catch-up it holds the frames
|
|
21
|
+
* (replay suffix, then live events) still waiting for the socket to drain.
|
|
22
|
+
*/
|
|
23
|
+
const subscribers = new Map();
|
|
20
24
|
const listeners = new Set();
|
|
21
25
|
const remove = (response) => {
|
|
22
26
|
subscribers.delete(response);
|
|
23
27
|
};
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const drop = (response) => {
|
|
29
|
+
remove(response);
|
|
30
|
+
response.error(new Error('Console event subscriber backpressure limit reached'));
|
|
31
|
+
};
|
|
32
|
+
/** Returns false when the consumer asks the producer to wait for drain. */
|
|
33
|
+
const write = (response, entry) => response.write(entry.frame);
|
|
34
|
+
/** Write queued frames until the socket pushes back again or the queue is empty. */
|
|
35
|
+
const flush = (response) => {
|
|
36
|
+
const pending = subscribers.get(response);
|
|
37
|
+
if (!pending)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
while (pending.length > 0) {
|
|
41
|
+
if (!write(response, pending.shift())) {
|
|
42
|
+
const stop = response.onDrain(() => {
|
|
43
|
+
stop();
|
|
44
|
+
flush(response);
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!accepted) {
|
|
49
|
+
subscribers.set(response, null);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
35
52
|
remove(response);
|
|
36
|
-
response.destroy(new Error('Console event subscriber backpressure limit reached'));
|
|
37
|
-
throw new Error('Console event subscriber backpressure limit reached');
|
|
38
53
|
}
|
|
39
54
|
};
|
|
55
|
+
const deliver = (response, entry) => {
|
|
56
|
+
const pending = subscribers.get(response);
|
|
57
|
+
if (pending === undefined)
|
|
58
|
+
return;
|
|
59
|
+
if (pending === null) {
|
|
60
|
+
// A live subscriber that cannot keep up is disconnected; it resumes from history.
|
|
61
|
+
if (!write(response, entry))
|
|
62
|
+
drop(response);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
pending.push(entry);
|
|
66
|
+
// Catch-up may not outgrow what the journal itself is allowed to retain.
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
for (const queued of pending)
|
|
69
|
+
bytes += queued.bytes;
|
|
70
|
+
if (pending.length > historyLimit || bytes > historyByteLimit)
|
|
71
|
+
drop(response);
|
|
72
|
+
};
|
|
40
73
|
return {
|
|
41
74
|
runtimeId,
|
|
42
75
|
get subscriberCount() {
|
|
@@ -58,9 +91,9 @@ export function createConsoleEventHub(options = {}) {
|
|
|
58
91
|
while (journal.length > historyLimit || journalBytes > historyByteLimit) {
|
|
59
92
|
journalBytes -= journal.shift()?.bytes ?? 0;
|
|
60
93
|
}
|
|
61
|
-
for (const response of [...subscribers]) {
|
|
94
|
+
for (const response of [...subscribers.keys()]) {
|
|
62
95
|
try {
|
|
63
|
-
|
|
96
|
+
deliver(response, entry);
|
|
64
97
|
}
|
|
65
98
|
catch {
|
|
66
99
|
remove(response);
|
|
@@ -121,18 +154,11 @@ export function createConsoleEventHub(options = {}) {
|
|
|
121
154
|
|| requestedAfter > nextId
|
|
122
155
|
? 0
|
|
123
156
|
: requestedAfter;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
subscribers.add(response);
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
remove(response);
|
|
133
|
-
}
|
|
134
|
-
response.once('close', () => remove(response));
|
|
135
|
-
response.once('error', () => remove(response));
|
|
157
|
+
// Backpressure during replay is expected for a large suffix: the remaining frames
|
|
158
|
+
// (and any event published meanwhile) wait in order for the socket to drain.
|
|
159
|
+
subscribers.set(response, journal.filter((entry) => entry.event.eventId > after));
|
|
160
|
+
response.onClose(() => remove(response));
|
|
161
|
+
flush(response);
|
|
136
162
|
return () => remove(response);
|
|
137
163
|
},
|
|
138
164
|
};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { RuntimeConsoleRpcContext, RuntimeConsoleRpcMessage, RuntimeConsoleRpcReply } from './console-rpc.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
|
|
4
|
+
* installs / updates / uninstalls are not handled in the instance: they are gateway dependency
|
|
5
|
+
* transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
|
|
6
|
+
*/
|
|
3
7
|
export declare function dispatchPluginConsoleRpc(type: string, message: RuntimeConsoleRpcMessage, ctx: RuntimeConsoleRpcContext): Promise<RuntimeConsoleRpcReply | undefined>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { PLUGIN_RPC } from '@zhin.js/console-protocol';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
|
|
4
|
+
* installs / updates / uninstalls are not handled in the instance: they are gateway dependency
|
|
5
|
+
* transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
|
|
6
|
+
*/
|
|
3
7
|
export async function dispatchPluginConsoleRpc(type, message, ctx) {
|
|
4
8
|
const requestId = message.requestId;
|
|
5
9
|
try {
|
|
@@ -71,17 +75,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
|
|
|
71
75
|
return { requestId, error: 'Plugin management is not configured' };
|
|
72
76
|
return { requestId, data: await ctx.pluginManagement.planInstall(packageName) };
|
|
73
77
|
}
|
|
74
|
-
case PLUGIN_RPC.INSTALL: {
|
|
75
|
-
const packageName = stringField(message, 'packageName');
|
|
76
|
-
if (!packageName)
|
|
77
|
-
return { requestId, error: 'packageName is required' };
|
|
78
|
-
if (!ctx.pluginManagement?.install) {
|
|
79
|
-
return { requestId, error: 'Plugin installation is not configured' };
|
|
80
|
-
}
|
|
81
|
-
const result = await ctx.pluginManagement.install(packageName, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
|
|
82
|
-
ctx.publishEvent?.('plugin:installed', { packageName, restartRequired: result.restartRequired });
|
|
83
|
-
return { requestId, data: { success: true, ...result } };
|
|
84
|
-
}
|
|
85
78
|
case PLUGIN_RPC.PLAN_UNINSTALL: {
|
|
86
79
|
const packageName = stringField(message, 'packageName');
|
|
87
80
|
if (!packageName)
|
|
@@ -91,18 +84,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
|
|
|
91
84
|
}
|
|
92
85
|
return { requestId, data: await ctx.pluginManagement.planUninstall(packageName) };
|
|
93
86
|
}
|
|
94
|
-
case PLUGIN_RPC.UNINSTALL: {
|
|
95
|
-
const packageName = stringField(message, 'packageName');
|
|
96
|
-
if (!packageName || stringField(message, 'confirmation') !== packageName) {
|
|
97
|
-
return { requestId, error: 'confirmation must exactly match packageName' };
|
|
98
|
-
}
|
|
99
|
-
if (!ctx.pluginManagement?.uninstall) {
|
|
100
|
-
return { requestId, error: 'Plugin uninstall is not configured' };
|
|
101
|
-
}
|
|
102
|
-
const result = await ctx.pluginManagement.uninstall(packageName, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
|
|
103
|
-
ctx.publishEvent?.('plugin:uninstalled', { packageName, restartRequired: result.restartRequired });
|
|
104
|
-
return { requestId, data: { success: true, ...result } };
|
|
105
|
-
}
|
|
106
87
|
case PLUGIN_RPC.PLAN_UPDATE: {
|
|
107
88
|
const packageName = stringField(message, 'packageName');
|
|
108
89
|
const targetVersion = stringField(message, 'targetVersion');
|
|
@@ -114,22 +95,6 @@ export async function dispatchPluginConsoleRpc(type, message, ctx) {
|
|
|
114
95
|
}
|
|
115
96
|
return { requestId, data: await ctx.pluginManagement.planUpdate(packageName, targetVersion) };
|
|
116
97
|
}
|
|
117
|
-
case PLUGIN_RPC.UPDATE: {
|
|
118
|
-
const packageName = stringField(message, 'packageName');
|
|
119
|
-
const targetVersion = stringField(message, 'targetVersion');
|
|
120
|
-
if (!packageName || !targetVersion) {
|
|
121
|
-
return { requestId, error: 'packageName and targetVersion are required' };
|
|
122
|
-
}
|
|
123
|
-
if (!ctx.pluginManagement?.update)
|
|
124
|
-
return { requestId, error: 'Plugin update is not configured' };
|
|
125
|
-
const result = await ctx.pluginManagement.update(packageName, targetVersion, optionalString(message.expectedRevision), optionalString(message.expectedProjectRevision));
|
|
126
|
-
ctx.publishEvent?.('plugin:updated', {
|
|
127
|
-
packageName,
|
|
128
|
-
version: result.installedVersion,
|
|
129
|
-
restartRequired: result.restartRequired,
|
|
130
|
-
});
|
|
131
|
-
return { requestId, data: { success: true, ...result } };
|
|
132
|
-
}
|
|
133
98
|
case PLUGIN_RPC.VALIDATE_CONFIG: {
|
|
134
99
|
const pluginName = stringField(message, 'pluginName');
|
|
135
100
|
if (!pluginName)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ServerResponse } from 'node:http';
|
|
2
1
|
import type { HttpHost } from './http-host.js';
|
|
3
|
-
|
|
2
|
+
/** Logs an unexpected failure server-side; clients only get a generic message. */
|
|
3
|
+
export declare function logInternalError(op: string, error: unknown): void;
|
|
4
4
|
import type { ConsoleRestCtx, ConsoleRestPagesOptions } from './console-rest-pages.js';
|
|
5
5
|
export declare function registerIntrospectionRoutes(route: HttpHost['route'], base: string, ctx: ConsoleRestCtx, options: ConsoleRestPagesOptions): void;
|
|
6
6
|
export declare function clampPositiveInt(value: number | undefined, fallback: number, ceiling: number): number;
|
|
@@ -15,9 +15,8 @@ export declare function paginateItems<T>(all: T[], page: number, pageSize: numbe
|
|
|
15
15
|
export declare function filterByFields<T>(items: T[], filter: string | undefined, fields: Array<(item: T) => string | undefined>): T[];
|
|
16
16
|
export declare function stringProp(value: unknown, key: string): string | undefined;
|
|
17
17
|
export declare function parsePositiveInt(raw: string | null, fallback: number): number;
|
|
18
|
-
/** 写操作门禁:demo 部署(ctx.fullScope=false
|
|
19
|
-
export declare function requireWriteScope(
|
|
18
|
+
/** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
|
|
19
|
+
export declare function requireWriteScope(ctx: ConsoleRestCtx): Response | undefined;
|
|
20
20
|
export declare function decodePathParam(raw: string): string;
|
|
21
21
|
export declare function trimTrailingSlashes(s: string): string;
|
|
22
22
|
export declare function normalizeBase(value: string): string;
|
|
23
|
-
export declare function writeJson(response: ServerResponse, status: number, body: unknown): void;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { HttpBodyError, readJsonBody } from './json-body.js';
|
|
2
|
+
import { jsonResponse } from '@zhin.js/host-http-contract';
|
|
3
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
|
+
const logger = getLogger('console');
|
|
5
|
+
/** Logs an unexpected failure server-side; clients only get a generic message. */
|
|
6
|
+
export function logInternalError(op, error) {
|
|
7
|
+
logger.warn(formatCompact({ op, error: error instanceof Error ? error.message : String(error) }));
|
|
8
|
+
}
|
|
2
9
|
const INTROSPECTION_PAGE_SIZES = {
|
|
3
10
|
commands: 25,
|
|
4
11
|
middlewares: 30,
|
|
@@ -102,7 +109,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
102
109
|
},
|
|
103
110
|
];
|
|
104
111
|
for (const { kind, collect, fields } of collectors) {
|
|
105
|
-
route('GET', `${base}/introspection/${kind}`, async (_request,
|
|
112
|
+
route('GET', `${base}/introspection/${kind}`, async (_request, { url }) => {
|
|
106
113
|
const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
|
|
107
114
|
try {
|
|
108
115
|
const introspection = lease?.value.introspection;
|
|
@@ -119,16 +126,17 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
119
126
|
? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
|
|
120
127
|
: 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
|
|
121
128
|
: undefined);
|
|
122
|
-
|
|
129
|
+
return jsonResponse(200, {
|
|
123
130
|
success: true,
|
|
124
131
|
data: { ...slice, filter, note: degradedNote },
|
|
125
132
|
});
|
|
126
133
|
}
|
|
127
134
|
catch (err) {
|
|
128
|
-
|
|
135
|
+
logInternalError(`introspection_${kind}_failed`, err);
|
|
136
|
+
return jsonResponse(503, {
|
|
129
137
|
success: false,
|
|
130
138
|
data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
|
|
131
|
-
error:
|
|
139
|
+
error: 'Introspection unavailable',
|
|
132
140
|
});
|
|
133
141
|
}
|
|
134
142
|
finally {
|
|
@@ -137,28 +145,26 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
137
145
|
}, { summary: `Introspection: ${kind}`, tags: ['console', 'introspection'] });
|
|
138
146
|
}
|
|
139
147
|
let activeComponentPreviews = 0;
|
|
140
|
-
route('POST', `${base}/introspection/components/render`, async (request
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
route('POST', `${base}/introspection/components/render`, async (request) => {
|
|
149
|
+
const denied = requireWriteScope(ctx);
|
|
150
|
+
if (denied)
|
|
151
|
+
return denied;
|
|
143
152
|
const body = (await readJsonBody(request)) ?? {};
|
|
144
153
|
const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
|
|
145
154
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
146
155
|
if (!requester || !name || requester.length > 256 || name.length > 160) {
|
|
147
|
-
|
|
148
|
-
return;
|
|
156
|
+
return jsonResponse(400, { success: false, error: '请提供有效的 requester 与 component name' });
|
|
149
157
|
}
|
|
150
158
|
const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
|
|
151
159
|
if (activeComponentPreviews >= maxConcurrent) {
|
|
152
|
-
|
|
153
|
-
return;
|
|
160
|
+
return jsonResponse(503, { success: false, error: 'Component preview 正忙,请稍后重试' });
|
|
154
161
|
}
|
|
155
162
|
const lease = ctx.acquireAgentRuntime?.();
|
|
156
163
|
const render = lease?.value.introspection?.renderComponent;
|
|
157
164
|
const previewDelivery = lease?.value.introspection?.previewComponentDelivery;
|
|
158
165
|
if (!lease || !render) {
|
|
159
166
|
lease?.release();
|
|
160
|
-
|
|
161
|
-
return;
|
|
167
|
+
return jsonResponse(503, { success: false, error: 'Component Runtime 未就绪' });
|
|
162
168
|
}
|
|
163
169
|
const controller = new AbortController();
|
|
164
170
|
const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
|
|
@@ -187,15 +193,19 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
|
|
|
187
193
|
try {
|
|
188
194
|
const preview = await raceWithAbort(renderPromise, controller.signal);
|
|
189
195
|
assertPreviewOutput(preview, options.componentPreviewMaxBytes ?? 256 * 1024);
|
|
190
|
-
|
|
196
|
+
return jsonResponse(200, { success: true, data: { requester, name, ...preview } });
|
|
191
197
|
}
|
|
192
198
|
catch (error) {
|
|
193
199
|
const status = error instanceof ComponentPreviewTimeoutError
|
|
194
200
|
? 408
|
|
195
201
|
: error instanceof ComponentPreviewLimitError ? 413 : 422;
|
|
196
|
-
|
|
202
|
+
// Timeout / size limit messages are ours; a component's own failure detail stays in the log.
|
|
203
|
+
const known = error instanceof ComponentPreviewTimeoutError || error instanceof ComponentPreviewLimitError;
|
|
204
|
+
if (!known)
|
|
205
|
+
logInternalError('component_preview_failed', error);
|
|
206
|
+
return jsonResponse(status, {
|
|
197
207
|
success: false,
|
|
198
|
-
error:
|
|
208
|
+
error: known ? error.message : 'Component preview failed',
|
|
199
209
|
});
|
|
200
210
|
}
|
|
201
211
|
finally {
|
|
@@ -280,77 +290,67 @@ export function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
280
290
|
}
|
|
281
291
|
return { tree, release: () => lease.release() };
|
|
282
292
|
};
|
|
283
|
-
const unavailable = (
|
|
284
|
-
|
|
293
|
+
const unavailable = (headers) => {
|
|
294
|
+
return jsonResponse(503, {
|
|
285
295
|
success: false,
|
|
286
296
|
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
|
|
287
|
-
});
|
|
297
|
+
}, headers);
|
|
288
298
|
};
|
|
289
299
|
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
|
|
290
|
-
route('GET', `${base}/agent/sessions/*`, async (_request,
|
|
300
|
+
route('GET', `${base}/agent/sessions/*`, async (_request, { url }) => {
|
|
291
301
|
const messageAction = parseSessionAction(url.pathname, prefix, 'message');
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
const headers = messageAction ? { 'cache-control': 'no-store' } : undefined;
|
|
303
|
+
const reply = (status, body) => jsonResponse(status, body, headers);
|
|
294
304
|
const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
|
|
295
305
|
if (!parsed) {
|
|
296
|
-
|
|
297
|
-
return;
|
|
306
|
+
return reply(404, { success: false, error: 'Not found' });
|
|
298
307
|
}
|
|
299
308
|
const messageId = Number(url.searchParams.get('messageId'));
|
|
300
309
|
const expectedSessionId = url.searchParams.get('sessionId');
|
|
301
310
|
if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
|
|
302
|
-
|
|
303
|
-
return;
|
|
311
|
+
return reply(400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
|
|
304
312
|
}
|
|
305
313
|
const lease = acquireSessionTree();
|
|
306
314
|
if (!lease) {
|
|
307
|
-
unavailable(
|
|
308
|
-
return;
|
|
315
|
+
return unavailable(headers);
|
|
309
316
|
}
|
|
310
317
|
const runtime = lease.tree;
|
|
311
318
|
try {
|
|
312
319
|
if (messageAction && !runtime.readBranchMessage) {
|
|
313
|
-
|
|
314
|
-
return;
|
|
320
|
+
return reply(501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
|
|
315
321
|
}
|
|
316
322
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
317
323
|
if (messageAction) {
|
|
318
324
|
if (!sessionId) {
|
|
319
|
-
|
|
320
|
-
return;
|
|
325
|
+
return reply(404, { success: false, error: '未找到活跃会话' });
|
|
321
326
|
}
|
|
322
327
|
if (sessionId !== expectedSessionId) {
|
|
323
|
-
|
|
324
|
-
return;
|
|
328
|
+
return reply(409, { success: false, error: '会话已变化,请重新读取会话列表' });
|
|
325
329
|
}
|
|
326
330
|
const text = await runtime.readBranchMessage(sessionId, messageId);
|
|
327
331
|
if (text === null) {
|
|
328
|
-
|
|
329
|
-
return;
|
|
332
|
+
return reply(404, { success: false, error: '未找到此会话的用户消息' });
|
|
330
333
|
}
|
|
331
|
-
|
|
332
|
-
return;
|
|
334
|
+
return reply(200, { success: true, data: { messageId, text } });
|
|
333
335
|
}
|
|
334
336
|
if (!sessionId) {
|
|
335
337
|
if (!isRuntimeImSessionKey(parsed.sessionKey)) {
|
|
336
|
-
|
|
338
|
+
return reply(404, {
|
|
337
339
|
success: false,
|
|
338
340
|
error: `未找到会话:${parsed.sessionKey}`,
|
|
339
341
|
});
|
|
340
|
-
return;
|
|
341
342
|
}
|
|
342
343
|
const knownConversation = await ctx.isKnownConversationSession?.(parsed.sessionKey);
|
|
343
344
|
if (knownConversation === false) {
|
|
344
|
-
|
|
345
|
+
return reply(404, {
|
|
345
346
|
success: false,
|
|
346
347
|
error: `未找到会话:${parsed.sessionKey}`,
|
|
347
348
|
});
|
|
348
|
-
return;
|
|
349
349
|
}
|
|
350
350
|
// 渠道会话与 Agent 会话的生命周期不同:用户可以先在 Console
|
|
351
351
|
// 选中一个真实渠道会话,而该会话尚未触发过 Agent。树查询是只读操作,
|
|
352
352
|
// 因而将这种正常状态返回为可渲染空态,而不是误报资源不存在。
|
|
353
|
-
|
|
353
|
+
return reply(200, {
|
|
354
354
|
success: true,
|
|
355
355
|
data: {
|
|
356
356
|
state: 'not_started',
|
|
@@ -360,11 +360,10 @@ export function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
360
360
|
points: [],
|
|
361
361
|
},
|
|
362
362
|
});
|
|
363
|
-
return;
|
|
364
363
|
}
|
|
365
364
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
366
365
|
const points = await runtime.listBranchPoints(sessionId);
|
|
367
|
-
|
|
366
|
+
return reply(200, {
|
|
368
367
|
success: true,
|
|
369
368
|
data: {
|
|
370
369
|
state: 'active',
|
|
@@ -379,22 +378,21 @@ export function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
379
378
|
lease.release();
|
|
380
379
|
}
|
|
381
380
|
}, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
|
|
382
|
-
route('POST', `${base}/agent/sessions/*`, async (request,
|
|
381
|
+
route('POST', `${base}/agent/sessions/*`, async (request, { url }) => {
|
|
383
382
|
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
384
383
|
if (!parsed) {
|
|
385
|
-
|
|
386
|
-
return;
|
|
384
|
+
return jsonResponse(404, { success: false, error: 'Not found' });
|
|
387
385
|
}
|
|
388
|
-
|
|
389
|
-
|
|
386
|
+
const denied = requireWriteScope(ctx);
|
|
387
|
+
if (denied)
|
|
388
|
+
return denied;
|
|
390
389
|
let body;
|
|
391
390
|
try {
|
|
392
391
|
body = (await readJsonBody(request) ?? {});
|
|
393
392
|
}
|
|
394
393
|
catch (error) {
|
|
395
394
|
if (error instanceof HttpBodyError) {
|
|
396
|
-
|
|
397
|
-
return;
|
|
395
|
+
return jsonResponse(error.statusCode, { success: false, error: error.message });
|
|
398
396
|
}
|
|
399
397
|
throw error;
|
|
400
398
|
}
|
|
@@ -405,42 +403,37 @@ export function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
405
403
|
if (messageIdRaw != null && messageIdRaw !== '') {
|
|
406
404
|
const value = Number(messageIdRaw);
|
|
407
405
|
if (!Number.isFinite(value) || value < 1) {
|
|
408
|
-
|
|
409
|
-
return;
|
|
406
|
+
return jsonResponse(400, { success: false, error: 'messageId 须为正整数' });
|
|
410
407
|
}
|
|
411
408
|
messageId = value;
|
|
412
409
|
}
|
|
413
410
|
else if (indexRaw != null && indexRaw !== '') {
|
|
414
411
|
const value = Number(indexRaw);
|
|
415
412
|
if (!Number.isFinite(value) || value < 1) {
|
|
416
|
-
|
|
417
|
-
return;
|
|
413
|
+
return jsonResponse(400, { success: false, error: 'index 须为正整数' });
|
|
418
414
|
}
|
|
419
415
|
branchIndex = value;
|
|
420
416
|
}
|
|
421
417
|
else {
|
|
422
|
-
|
|
423
|
-
return;
|
|
418
|
+
return jsonResponse(400, { success: false, error: '需要 messageId 或 index 之一' });
|
|
424
419
|
}
|
|
425
420
|
const lease = acquireSessionTree();
|
|
426
421
|
if (!lease) {
|
|
427
|
-
unavailable(
|
|
428
|
-
return;
|
|
422
|
+
return unavailable();
|
|
429
423
|
}
|
|
430
424
|
const runtime = lease.tree;
|
|
431
425
|
try {
|
|
432
426
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
433
427
|
if (!sessionId) {
|
|
434
|
-
|
|
428
|
+
return jsonResponse(404, {
|
|
435
429
|
success: false,
|
|
436
430
|
error: `未找到活跃会话:${parsed.sessionKey}`,
|
|
437
431
|
});
|
|
438
|
-
return;
|
|
439
432
|
}
|
|
440
433
|
if (branchIndex !== undefined) {
|
|
441
434
|
const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
|
|
442
435
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
443
|
-
|
|
436
|
+
return jsonResponse(result.ok ? 200 : 400, {
|
|
444
437
|
success: result.ok,
|
|
445
438
|
message: result.message,
|
|
446
439
|
data: {
|
|
@@ -449,11 +442,10 @@ export function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
449
442
|
activeLeafMessageId,
|
|
450
443
|
},
|
|
451
444
|
});
|
|
452
|
-
return;
|
|
453
445
|
}
|
|
454
446
|
const ok = await runtime.switchActiveLeaf(sessionId, messageId);
|
|
455
447
|
const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
|
|
456
|
-
|
|
448
|
+
return jsonResponse(ok ? 200 : 400, {
|
|
457
449
|
success: ok,
|
|
458
450
|
message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
|
|
459
451
|
data: {
|
|
@@ -531,15 +523,14 @@ export function parsePositiveInt(raw, fallback) {
|
|
|
531
523
|
// ---------------------------------------------------------------------------
|
|
532
524
|
// helpers
|
|
533
525
|
// ---------------------------------------------------------------------------
|
|
534
|
-
/** 写操作门禁:demo 部署(ctx.fullScope=false
|
|
535
|
-
export function requireWriteScope(
|
|
536
|
-
if (ctx.fullScope
|
|
537
|
-
return
|
|
538
|
-
|
|
526
|
+
/** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
|
|
527
|
+
export function requireWriteScope(ctx) {
|
|
528
|
+
if (ctx.fullScope)
|
|
529
|
+
return undefined;
|
|
530
|
+
return jsonResponse(403, {
|
|
539
531
|
success: false,
|
|
540
532
|
error: 'Demo scope: 只读访问,写操作被拒绝',
|
|
541
533
|
});
|
|
542
|
-
return false;
|
|
543
534
|
}
|
|
544
535
|
export function decodePathParam(raw) {
|
|
545
536
|
try {
|
|
@@ -560,11 +551,3 @@ export function normalizeBase(value) {
|
|
|
560
551
|
return `/${value}`;
|
|
561
552
|
return trimTrailingSlashes(value) || '/api';
|
|
562
553
|
}
|
|
563
|
-
export function writeJson(response, status, body) {
|
|
564
|
-
const payload = JSON.stringify(body);
|
|
565
|
-
response.writeHead(status, {
|
|
566
|
-
'content-type': 'application/json; charset=utf-8',
|
|
567
|
-
'content-length': Buffer.byteLength(payload),
|
|
568
|
-
});
|
|
569
|
-
response.end(payload);
|
|
570
|
-
}
|