@zhin.js/gateway 1.2.0 → 1.3.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 +1 -1
- package/lib/config/configuration.d.ts +16 -0
- package/lib/config/configuration.js +32 -7
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/validation-messages.d.ts +13 -0
- package/lib/config/validation-messages.js +59 -0
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.js +6 -5
- package/lib/http/gateway-http-server.js +39 -1
- package/lib/http/plugin-switch-rpc.js +6 -5
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +22 -4
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +8 -2
- package/lib/plugin-management/workflow.js +28 -10
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +6 -6
package/lib/http/console-rpc.js
CHANGED
|
@@ -5,31 +5,58 @@
|
|
|
5
5
|
* error, requestId, errors?, missingEnv? }`, see `GatewayRpcHttpErrorReply`), every other
|
|
6
6
|
* type is forwarded to the instance by the HTTP server.
|
|
7
7
|
*/
|
|
8
|
-
import { auditActor, jsonResponse, } from '@zhin.js/host-http';
|
|
9
|
-
import { GATEWAY_RPC } from '@zhin.js/console-protocol';
|
|
8
|
+
import { auditActor, jsonResponse, unknownRpcLog, } from '@zhin.js/host-http';
|
|
9
|
+
import { CONSOLE_DENIAL_REASONS, GATEWAY_RPC, GATEWAY_RPC_PERMISSIONS, } from '@zhin.js/console-protocol';
|
|
10
|
+
import { isGatewayRpcType } from '../packages/rpc.js';
|
|
11
|
+
import { gatewayPrincipalAllows } from '../packages/authorization.js';
|
|
12
|
+
import { LOCAL_GATEWAY_RPC } from '../local/protocol.js';
|
|
10
13
|
/** RPC types with this prefix belong to the gateway (unknown ones are refused as unmapped). */
|
|
11
14
|
export const GATEWAY_RPC_PREFIX = 'gateway.';
|
|
15
|
+
/**
|
|
16
|
+
* Socket-only RPC types ({@link LOCAL_GATEWAY_RPC}): they exist, but never over HTTP. Refused
|
|
17
|
+
* with 403 (`forbidden`, audit reason `socket_only`) rather than `unknown_rpc`, so an attempt to
|
|
18
|
+
* reach Console administration over HTTP stays visible in the audit log.
|
|
19
|
+
*/
|
|
20
|
+
const SOCKET_ONLY_TYPES = new Set(Object.values(LOCAL_GATEWAY_RPC));
|
|
12
21
|
const READ_ONLY_TYPES = new Set([
|
|
13
22
|
GATEWAY_RPC.LIST,
|
|
14
23
|
GATEWAY_RPC.PLAN,
|
|
15
24
|
GATEWAY_RPC.STATUS,
|
|
16
25
|
GATEWAY_RPC.LOGS,
|
|
17
26
|
]);
|
|
27
|
+
/** Why the gateway dispatcher refused `type` (`forbidden`). */
|
|
28
|
+
function gatewayDenialReason(type, principal) {
|
|
29
|
+
if (!principal)
|
|
30
|
+
return CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED;
|
|
31
|
+
if (isGatewayRpcType(type) && !gatewayPrincipalAllows(principal, GATEWAY_RPC_PERMISSIONS[type])) {
|
|
32
|
+
return principal.kind === 'bootstrap' ? CONSOLE_DENIAL_REASONS.SETUP_REQUIRED : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
|
|
33
|
+
}
|
|
34
|
+
// The permission map admitted it; the service's own rule refused (e.g. bootstrap package limits).
|
|
35
|
+
return CONSOLE_DENIAL_REASONS.HANDLER_DENIED;
|
|
36
|
+
}
|
|
18
37
|
export function createGatewayConsoleRpc(dispatch) {
|
|
19
38
|
return Object.freeze({
|
|
20
39
|
async handle(message, context, tokenRegistry) {
|
|
21
40
|
const type = typeof message.type === 'string' ? message.type : '';
|
|
22
41
|
const requestId = message.requestId;
|
|
23
42
|
const principal = context.principal;
|
|
24
|
-
const
|
|
43
|
+
const socketOnly = SOCKET_ONLY_TYPES.has(type);
|
|
44
|
+
const result = socketOnly
|
|
45
|
+
? { ok: false, error: { code: 'forbidden', status: 403, message: `RPC "${type.slice(0, 64)}" 只能通过本机网关套接字调用(zhin console …)` } }
|
|
46
|
+
: await dispatch({ type, data: message.data }, principal);
|
|
25
47
|
const write = !READ_ONLY_TYPES.has(type);
|
|
48
|
+
const unknown = !result.ok && result.error.code === 'unknown_rpc';
|
|
26
49
|
const outcome = result.ok ? 'ok' : result.error.code === 'forbidden' ? 'denied' : 'error';
|
|
27
|
-
|
|
50
|
+
// zhinjs/zhin#1250: an unknown `gateway.*` type ran nothing and is not a refusal: not audited.
|
|
51
|
+
if (unknown)
|
|
52
|
+
unknownRpcLog.note(type);
|
|
53
|
+
else if (write || outcome === 'denied') {
|
|
28
54
|
try {
|
|
29
55
|
tokenRegistry.accessProvider?.audit?.({
|
|
30
56
|
actor: auditActor(principal),
|
|
31
57
|
action: `rpc:${type}`.slice(0, 256),
|
|
32
58
|
outcome,
|
|
59
|
+
...(outcome === 'denied' ? { detail: { type: type.slice(0, 256), reason: socketOnly ? CONSOLE_DENIAL_REASONS.SOCKET_ONLY : gatewayDenialReason(type, principal) } } : {}),
|
|
33
60
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
34
61
|
});
|
|
35
62
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
15
|
-
import { ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
15
|
+
import { CONSOLE_DENIAL_REASONS, IAM_RPC, ONBOARDING_RPC } from '@zhin.js/console-protocol';
|
|
16
16
|
import { DEFAULT_ROOT_CONFIG_FILE_NAME } from '@zhin.js/plugin-runtime';
|
|
17
17
|
import { createConfigDocument } from '@zhin.js/runtime';
|
|
18
18
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -330,6 +330,7 @@ const ONBOARDING_STEPS = new Set(['ai', 'adapters', 'finish']);
|
|
|
330
330
|
export function createGatewayAccountRpc(options) {
|
|
331
331
|
return Object.freeze({
|
|
332
332
|
owns: (type) => isIamRpc(type) || type === ONBOARDING_RPC.UPDATE,
|
|
333
|
+
types: () => [...Object.values(IAM_RPC), ONBOARDING_RPC.UPDATE],
|
|
333
334
|
async handle(message, context, registry) {
|
|
334
335
|
const noStore = { 'cache-control': 'no-store' };
|
|
335
336
|
const requestId = message.requestId;
|
|
@@ -338,21 +339,21 @@ export function createGatewayAccountRpc(options) {
|
|
|
338
339
|
return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
|
|
339
340
|
}
|
|
340
341
|
const decision = authorizeConsoleRpc(principal, message);
|
|
341
|
-
const audit = (outcome) => {
|
|
342
|
+
const audit = (outcome, reason) => {
|
|
342
343
|
try {
|
|
343
344
|
registry.accessProvider?.audit?.({
|
|
344
345
|
actor: auditActor(principal),
|
|
345
346
|
action: `rpc:${String(message.type ?? '')}`.slice(0, 256),
|
|
346
347
|
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
347
348
|
outcome,
|
|
348
|
-
detail: rpcAuditDetail(message),
|
|
349
|
+
detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
|
|
349
350
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
350
351
|
});
|
|
351
352
|
}
|
|
352
353
|
catch { /* audit never breaks a request */ }
|
|
353
354
|
};
|
|
354
355
|
if (!decision.allowed) {
|
|
355
|
-
audit('denied');
|
|
356
|
+
audit('denied', decision.reason);
|
|
356
357
|
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
357
358
|
}
|
|
358
359
|
const result = isIamRpc(message.type)
|
|
@@ -361,7 +362,7 @@ export function createGatewayAccountRpc(options) {
|
|
|
361
362
|
if (decision.write)
|
|
362
363
|
audit(result.status < 400 && result.body.success !== false ? 'ok' : 'error');
|
|
363
364
|
else if (result.status === 403)
|
|
364
|
-
audit('denied');
|
|
365
|
+
audit('denied', CONSOLE_DENIAL_REASONS.HANDLER_DENIED);
|
|
365
366
|
return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
|
|
366
367
|
},
|
|
367
368
|
});
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* answer in time gives `504 { code: 'instance_timeout' }`, any other bridge failure
|
|
15
15
|
* `502 { code: 'instance_error' }` (mapped from {@link InstanceTransportError}).
|
|
16
16
|
*/
|
|
17
|
-
import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, } from '@zhin.js/host-http';
|
|
17
|
+
import { authorizeConsoleWebSocket, auditActor, createHttpRouter, extractBearerToken, jsonResponse, normalizePath, requestMeta, TokenRegistry, webSocketDenialReason, } from '@zhin.js/host-http';
|
|
18
18
|
import { InstanceTransportError, } from '@zhin.js/host-http/bridge';
|
|
19
19
|
import { createNodeHttpListener } from '@zhin.js/host-http/node';
|
|
20
|
+
import { CONSOLE_CAPABILITIES_PATH, CONSOLE_FEATURES, GATEWAY_RPC, createConsoleCapabilities, mergeConsoleCapabilities, parseConsoleCapabilities, } from '@zhin.js/console-protocol';
|
|
20
21
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
21
22
|
import { databaseBusyResponse, isDatabaseBusyError } from '../database/project-database.js';
|
|
22
23
|
import { GATEWAY_RPC_PREFIX } from './console-rpc.js';
|
|
@@ -55,6 +56,8 @@ export class GatewayHttpServer {
|
|
|
55
56
|
#retryAfter;
|
|
56
57
|
#maxBody;
|
|
57
58
|
#consoleRpc;
|
|
59
|
+
/** The instance connection (and route table) that has no capability route: asked once. */
|
|
60
|
+
#capabilitiesMissing;
|
|
58
61
|
#rpcHandlers = new Set();
|
|
59
62
|
#closed = false;
|
|
60
63
|
#closeResult;
|
|
@@ -84,6 +87,10 @@ export class GatewayHttpServer {
|
|
|
84
87
|
summary: 'Console RPC (gateway-owned types answered by the gateway, the rest forwarded)',
|
|
85
88
|
tags: ['console'],
|
|
86
89
|
});
|
|
90
|
+
router.route('GET', `${this.apiBase}${CONSOLE_CAPABILITIES_PATH}`, (request, context) => this.#capabilities(request, context), {
|
|
91
|
+
summary: 'Console capabilities (gateway-owned RPC types merged with the instance\'s)',
|
|
92
|
+
tags: ['console'],
|
|
93
|
+
});
|
|
87
94
|
// Lowest precedence: every path no gateway route claims goes to the instance.
|
|
88
95
|
router.route('ALL', '/*', (request, context) => this.#forward(request, context));
|
|
89
96
|
}
|
|
@@ -243,6 +250,36 @@ export class GatewayHttpServer {
|
|
|
243
250
|
}));
|
|
244
251
|
return this.#instanceError(502, 'instance_error', 'unknown', 'The instance failed to answer', false);
|
|
245
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* `GET {base}/console/capabilities` (zhinjs/zhin#1250): the RPC types the gateway answers itself,
|
|
255
|
+
* merged with the instance's reply. While the instance is not answering (or predates the route)
|
|
256
|
+
* the reply says `complete: false`, so the Console reads it again later.
|
|
257
|
+
*/
|
|
258
|
+
async #capabilities(request, context) {
|
|
259
|
+
const own = [...this.#rpcHandlers].flatMap(handler => handler.types?.() ?? []);
|
|
260
|
+
const gateway = createConsoleCapabilities({
|
|
261
|
+
rpc: [...(this.#consoleRpc ? Object.values(GATEWAY_RPC) : []), ...own],
|
|
262
|
+
features: [CONSOLE_FEATURES.UNKNOWN_RPC_CODE, CONSOLE_FEATURES.DENIAL_REASONS, CONSOLE_FEATURES.GATEWAY],
|
|
263
|
+
});
|
|
264
|
+
let instance = createConsoleCapabilities({ rpc: [], complete: false });
|
|
265
|
+
const transport = this.#readyTransport();
|
|
266
|
+
// An instance that predates the route refuses it (and audits that): ask it once per route table.
|
|
267
|
+
const revision = transport?.routes().revision;
|
|
268
|
+
const missing = this.#capabilitiesMissing;
|
|
269
|
+
if (transport && !(missing?.transport === transport && missing.revision === revision)) {
|
|
270
|
+
try {
|
|
271
|
+
const response = await this.#forward(request, context);
|
|
272
|
+
const body = response.ok ? await response.json().catch(() => null) : null;
|
|
273
|
+
const parsed = parseConsoleCapabilities(body?.data);
|
|
274
|
+
if (parsed)
|
|
275
|
+
instance = parsed;
|
|
276
|
+
else if (response.status === 403 || response.status === 404)
|
|
277
|
+
this.#capabilitiesMissing = { transport, revision };
|
|
278
|
+
}
|
|
279
|
+
catch { /* the instance part stays unknown */ }
|
|
280
|
+
}
|
|
281
|
+
return jsonResponse(200, { success: true, data: mergeConsoleCapabilities(gateway, instance) }, { 'cache-control': 'no-store' });
|
|
282
|
+
}
|
|
246
283
|
/**
|
|
247
284
|
* `POST {base}/console/request`: the router authenticated and authorized the route; `gateway.*`
|
|
248
285
|
* types are dispatched here (the dispatcher checks the per-RPC permission), the rest are
|
|
@@ -392,6 +429,7 @@ export class GatewayHttpServer {
|
|
|
392
429
|
actor: auditActor(principal),
|
|
393
430
|
action: `ws:${pathname}`,
|
|
394
431
|
outcome: 'denied',
|
|
432
|
+
detail: { reason: webSocketDenialReason(principal, pathname) },
|
|
395
433
|
...(request.remoteAddress ? { ip: request.remoteAddress } : {}),
|
|
396
434
|
});
|
|
397
435
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
|
|
20
|
-
import { FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
|
|
20
|
+
import { CONSOLE_DENIAL_REASONS, FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
|
|
21
21
|
import { DEFAULT_ROOT_CONFIG_FILE_NAME, FEATURE_OVERRIDES_KEY } from '@zhin.js/plugin-runtime';
|
|
22
22
|
import { collectFeatureOverrideWarnings, createConfigDocument } from '@zhin.js/runtime';
|
|
23
23
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -39,6 +39,7 @@ export function createGatewayPluginSwitchRpc(options) {
|
|
|
39
39
|
return Object.freeze({
|
|
40
40
|
owns: (type) => type === PLUGIN_RPC.SET_ENABLED || type === FEATURE_RPC.SET_ENABLED,
|
|
41
41
|
claims: isGatewayPluginSwitch,
|
|
42
|
+
types: () => [PLUGIN_RPC.SET_ENABLED, FEATURE_RPC.SET_ENABLED],
|
|
42
43
|
async handle(message, context, registry) {
|
|
43
44
|
const noStore = { 'cache-control': 'no-store' };
|
|
44
45
|
const type = String(message.type ?? '');
|
|
@@ -49,27 +50,27 @@ export function createGatewayPluginSwitchRpc(options) {
|
|
|
49
50
|
}
|
|
50
51
|
// plugin.manage: `plugin:set-enabled` is mapped to it, `feature.set-enabled` is refined by level.
|
|
51
52
|
const decision = authorizeConsoleRpc(principal, message);
|
|
52
|
-
const audit = (outcome) => {
|
|
53
|
+
const audit = (outcome, reason) => {
|
|
53
54
|
try {
|
|
54
55
|
registry.accessProvider?.audit?.({
|
|
55
56
|
actor: auditActor(principal),
|
|
56
57
|
action: `rpc:${type}`.slice(0, 256),
|
|
57
58
|
...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
|
|
58
59
|
outcome,
|
|
59
|
-
detail: rpcAuditDetail(message),
|
|
60
|
+
detail: reason ? { ...rpcAuditDetail(message), reason } : rpcAuditDetail(message),
|
|
60
61
|
...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
catch { /* audit never breaks a request */ }
|
|
64
65
|
};
|
|
65
66
|
if (!decision.allowed) {
|
|
66
|
-
audit('denied');
|
|
67
|
+
audit('denied', decision.reason);
|
|
67
68
|
return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
|
|
68
69
|
}
|
|
69
70
|
const reply = type === PLUGIN_RPC.SET_ENABLED
|
|
70
71
|
? await setPluginEnabled(message, principal, options, readConfig)
|
|
71
72
|
: await setFeatureGlobal(message, principal, options, readConfig);
|
|
72
|
-
audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error');
|
|
73
|
+
audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error', reply.status === 403 ? CONSOLE_DENIAL_REASONS.HANDLER_DENIED : undefined);
|
|
73
74
|
return jsonResponse(reply.status, { ...reply.body, requestId }, noStore);
|
|
74
75
|
},
|
|
75
76
|
});
|
package/lib/packages/errors.js
CHANGED
package/lib/packages/rpc.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `gateway.instance.*`, ADR 0054 §4 / §5). The HTTP server maps envelopes onto it; the permission
|
|
4
4
|
* map in `@zhin.js/console-protocol` is checked here, then each service applies its own rules.
|
|
5
5
|
*/
|
|
6
|
-
import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType } from '@zhin.js/console-protocol';
|
|
6
|
+
import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType, type ConsoleProcessSample } from '@zhin.js/console-protocol';
|
|
7
7
|
import type { InstanceStatus, InstanceStopOptions } from '../supervisor/instance-supervisor.js';
|
|
8
8
|
import type { InstanceLogLine } from '../supervisor/instance-logs.js';
|
|
9
9
|
import { type GatewayPrincipal } from './authorization.js';
|
|
@@ -28,3 +28,8 @@ export declare function createGatewayRpcDispatcher(dependencies: GatewayRpcDepen
|
|
|
28
28
|
/** Dispatches one gateway RPC; never throws (errors become `{ ok: false }`). */
|
|
29
29
|
export declare function dispatchGatewayRpc(request: GatewayRpcRequest, principal: GatewayPrincipal | undefined, dependencies: GatewayRpcDependencies): Promise<GatewayRpcResponse>;
|
|
30
30
|
export declare function instanceStatus(status: InstanceStatus): GatewayInstanceStatus;
|
|
31
|
+
/**
|
|
32
|
+
* The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
|
|
33
|
+
* `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sampleGatewayProcess(): ConsoleProcessSample | undefined;
|
package/lib/packages/rpc.js
CHANGED
|
@@ -20,8 +20,9 @@ export function createGatewayRpcDispatcher(dependencies) {
|
|
|
20
20
|
export async function dispatchGatewayRpc(request, principal, dependencies) {
|
|
21
21
|
try {
|
|
22
22
|
const type = request?.type;
|
|
23
|
+
// zhinjs/zhin#1250: a `gateway.*` type this gateway does not have is not a refusal (not audited).
|
|
23
24
|
if (!isGatewayRpcType(type))
|
|
24
|
-
throw new GatewayError('
|
|
25
|
+
throw new GatewayError('unknown_rpc', `RPC "${String(type ?? '').slice(0, 64)}" 不存在`);
|
|
25
26
|
if (!principal)
|
|
26
27
|
throw new GatewayError('forbidden', '未登录');
|
|
27
28
|
const packageMutation = type === GATEWAY_RPC.PLAN || type === GATEWAY_RPC.INSTALL;
|
|
@@ -69,7 +70,10 @@ async function handle(type, data, principal, deps) {
|
|
|
69
70
|
return packagesOf(deps).setEnabled({ plugin: data.plugin }, type === 'gateway.plugins.enable', {
|
|
70
71
|
principal, ...optionalBoolean(data, 'startInstance'),
|
|
71
72
|
});
|
|
72
|
-
case 'gateway.instance.status':
|
|
73
|
+
case 'gateway.instance.status': {
|
|
74
|
+
const gateway = sampleGatewayProcess();
|
|
75
|
+
return Object.freeze({ ...instanceStatus(instanceOf(deps).status()), ...(gateway ? { gateway } : {}) });
|
|
76
|
+
}
|
|
73
77
|
case 'gateway.instance.logs': {
|
|
74
78
|
const lines = data.lines === undefined ? DEFAULT_LOG_LINES : data.lines;
|
|
75
79
|
if (typeof lines !== 'number' || !Number.isInteger(lines) || lines < 1 || lines > MAX_LOG_LINES) {
|
|
@@ -126,6 +130,28 @@ export function instanceStatus(status) {
|
|
|
126
130
|
recentRestarts: status.recentRestarts,
|
|
127
131
|
});
|
|
128
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
|
|
135
|
+
* `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
|
|
136
|
+
*/
|
|
137
|
+
export function sampleGatewayProcess() {
|
|
138
|
+
try {
|
|
139
|
+
const memory = process.memoryUsage();
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
role: 'gateway',
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
uptime: process.uptime(),
|
|
144
|
+
memory: Object.freeze({
|
|
145
|
+
rss: memory.rss, heapUsed: memory.heapUsed, heapTotal: memory.heapTotal,
|
|
146
|
+
external: memory.external, arrayBuffers: memory.arrayBuffers,
|
|
147
|
+
}),
|
|
148
|
+
sampledAt: Date.now(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
129
155
|
function record(value) {
|
|
130
156
|
if (value === undefined || value === null)
|
|
131
157
|
return {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GatewayAuthorizedPackage, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
|
|
1
|
+
import type { GatewayAuthorizedPackage, GatewayInstalledPackageKind, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
|
|
2
2
|
import { type InstallAllowlist, type MarketCatalogEntry } from '../onboarding/allowlist.js';
|
|
3
3
|
import { type PackageManagerOutput } from '../onboarding/package-manager.js';
|
|
4
4
|
import { type PluginManagementOptions } from '../plugin-management/workflow.js';
|
|
@@ -137,3 +137,5 @@ export declare class GatewayPackageService {
|
|
|
137
137
|
readonly result: Promise<GatewayPackageTransactionResult>;
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
|
+
/** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
|
|
141
|
+
export declare function installedPackageKind(zhin: unknown): GatewayInstalledPackageKind;
|
package/lib/packages/service.js
CHANGED
|
@@ -90,7 +90,12 @@ export class GatewayPackageService {
|
|
|
90
90
|
if (typeof range !== 'string')
|
|
91
91
|
continue;
|
|
92
92
|
const catalog = allowlist.get(name)?.kind;
|
|
93
|
-
|
|
93
|
+
const installed = await this.#installedManifest(name);
|
|
94
|
+
packages.push(Object.freeze({
|
|
95
|
+
name, range, dependencyType, installedVersion: installed?.version ?? null,
|
|
96
|
+
...(installed ? { kind: installed.kind } : {}),
|
|
97
|
+
...(catalog ? { catalog } : {}),
|
|
98
|
+
}));
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -416,13 +421,19 @@ export class GatewayPackageService {
|
|
|
416
421
|
#instanceState() {
|
|
417
422
|
return this.#options.instance?.state ?? 'unmanaged';
|
|
418
423
|
}
|
|
419
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Installed version plus what the package is to Zhin (#1250): `package.json#zhin.type` `plugin`
|
|
426
|
+
* → `plugin`, `feature` → `feature`, no `zhin` manifest → `library`. Undefined when not installed.
|
|
427
|
+
*/
|
|
428
|
+
async #installedManifest(name) {
|
|
420
429
|
try {
|
|
421
430
|
const manifest = JSON.parse(await readFile(join(this.#root, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'));
|
|
422
|
-
|
|
431
|
+
if (manifest.name !== name || typeof manifest.version !== 'string')
|
|
432
|
+
return undefined;
|
|
433
|
+
return { version: manifest.version, kind: installedPackageKind(manifest.zhin) };
|
|
423
434
|
}
|
|
424
435
|
catch {
|
|
425
|
-
return
|
|
436
|
+
return undefined;
|
|
426
437
|
}
|
|
427
438
|
}
|
|
428
439
|
/** Cross-process exclusion (CLI and gateway); separate from the workflow's own lock file. */
|
|
@@ -502,3 +513,10 @@ function failureMessage(error) {
|
|
|
502
513
|
const cause = error instanceof PluginManagementOperationError ? error.cause : error;
|
|
503
514
|
return cause instanceof Error ? cause.message : String(cause);
|
|
504
515
|
}
|
|
516
|
+
/** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
|
|
517
|
+
export function installedPackageKind(zhin) {
|
|
518
|
+
if (!zhin || typeof zhin !== 'object' || Array.isArray(zhin))
|
|
519
|
+
return 'library';
|
|
520
|
+
const type = zhin.type;
|
|
521
|
+
return type === 'plugin' ? 'plugin' : type === 'feature' ? 'feature' : 'library';
|
|
522
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export const pluginUseCases = Object.freeze([
|
|
2
2
|
{
|
|
3
|
-
id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '
|
|
3
|
+
id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '在 Console「运维 → 定时任务 → 管理员任务」选择精确目标,创建独立管理员 RSS 订阅。',
|
|
4
4
|
packageName: '@zhin.js/plugin-rss', mode: 'administrator-task',
|
|
5
5
|
steps: ['通过现有安装计划安装 RSS 插件,配置持久 Database Host、Schedule Host 与支持精确目标的 Outbound Host,保存并应用。',
|
|
6
|
-
'在 Console
|
|
6
|
+
'在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的 RSS 实例,核对账号、会话、父容器和线程。',
|
|
7
7
|
'填写 HTTP(S) RSS / Atom 地址,阅读抓取和发送影响后创建;保存不会抓取源站或发送测试消息。',
|
|
8
|
-
'
|
|
8
|
+
'等待实例轮询首次成功抓取建立基线;后续新文章才推送,可在「运维 → 定时任务 → 管理员任务」暂停、编辑、恢复或取消。'],
|
|
9
9
|
limitations: ['首次和暂停恢复后的首次成功抓取只建基线,不补发历史;每轮只检查源前 20 条,最多展示 maxItems 条,其余提示查看源站,不续轮补发。',
|
|
10
10
|
'管理员订阅与聊天订阅独立;聊天 rss-list、rss-remove 和 rss-check 不管理或触发管理员任务。',
|
|
11
11
|
'投递未确认时不自动重试或恢复,只能取消;先核实目标会话,确需继续时再新建。'],
|
|
@@ -14,7 +14,7 @@ export const pluginUseCases = Object.freeze([
|
|
|
14
14
|
id: 'reminder', title: '创建定时提醒', summary: '指定账号、会话与时间,创建可查看、暂停和取消的管理员提醒。',
|
|
15
15
|
packageName: '@zhin.js/plugin-reminder', mode: 'administrator-task',
|
|
16
16
|
steps: ['通过现有安装计划安装持久提醒插件,配置 Database Host 并应用。',
|
|
17
|
-
'在 Console
|
|
17
|
+
'在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的提醒实例,检查当前版本与可用表单。',
|
|
18
18
|
'选择精确投递账号及会话,填写含时区的时间和内容,确认未来发送的效果后创建。',
|
|
19
19
|
'查看下次执行时间与实际结果;投递不确定时先核对目标会话,不盲目重建。'],
|
|
20
20
|
limitations: ['Console 只管理管理员任务;聊天创建的提醒仍归原发送者。',
|
|
@@ -54,11 +54,11 @@ export const pluginUseCases = Object.freeze([
|
|
|
54
54
|
].map(item => Object.freeze({ ...item, steps: Object.freeze(item.steps), limitations: Object.freeze(item.limitations) })));
|
|
55
55
|
const englishUseCases = {
|
|
56
56
|
'rss-subscription': {
|
|
57
|
-
title: 'Subscribe to RSS updates', summary: '
|
|
57
|
+
title: 'Subscribe to RSS updates', summary: 'In Operations → Scheduled tasks → Administrator tasks, choose an exact delivery target and create a separate administrator RSS subscription.',
|
|
58
58
|
steps: ['Install the RSS plugin through the existing installation plan. Configure persistent Database, Schedule and Outbound Hosts with exact-target support, then save and apply.',
|
|
59
|
-
'
|
|
59
|
+
'In Console, open Operations → Scheduled tasks → Administrator tasks and select a loaded RSS instance. Check its account, conversation, parent container and thread.',
|
|
60
60
|
'Enter an HTTP(S) RSS or Atom URL, review fetching and delivery effects, then create. Saving does not fetch the feed or send a test message.',
|
|
61
|
-
'Wait for the first successful poll to establish a baseline. Only later articles are delivered.
|
|
61
|
+
'Wait for the first successful poll to establish a baseline. Only later articles are delivered. Pause, edit, resume or cancel it under Operations → Scheduled tasks → Administrator tasks.'],
|
|
62
62
|
limitations: ['The first successful fetch after creation or resuming only establishes a baseline; it does not replay history. Each poll examines the first 20 feed entries and displays at most maxItems, with a link to the feed for the remainder; there is no later catch-up.',
|
|
63
63
|
'Administrator subscriptions and chat subscriptions are separate. Chat rss-list, rss-remove and rss-check do not manage or trigger administrator tasks.',
|
|
64
64
|
'An unconfirmed delivery is not automatically retried or resumed. Cancel it, check the destination, and create a new task only if needed.'],
|
|
@@ -66,7 +66,7 @@ const englishUseCases = {
|
|
|
66
66
|
reminder: {
|
|
67
67
|
title: 'Create a scheduled reminder', summary: 'Choose an account, conversation and time, then manage an administrator reminder.',
|
|
68
68
|
steps: ['Install the persistent reminder plugin, configure a Database Host and apply.',
|
|
69
|
-
'
|
|
69
|
+
'In Console, open Operations → Scheduled tasks → Administrator tasks, select a loaded reminder instance and check its current version and form.',
|
|
70
70
|
'Choose the exact account and conversation, enter a time with an offset and the content, and confirm the future delivery effect.',
|
|
71
71
|
'Check the next execution time and actual result. If delivery is uncertain, inspect the destination before creating another task.'],
|
|
72
72
|
limitations: ['Console manages administrator tasks only. Chat reminders still belong to the actual sender.',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SnapshotReader } from '@zhin.js/plugin-runtime';
|
|
2
|
-
import type { PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
|
|
2
|
+
import type { PluginDependencyCheck, PluginInspectOptions, PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
|
|
3
3
|
import type { ConsoleConfigExplanation, GatewayPluginConfigurationNeed } from '@zhin.js/console-protocol';
|
|
4
4
|
import type { PluginManagementActivity } from './activity.js';
|
|
5
5
|
export interface PluginManagementOptions {
|
|
@@ -67,7 +67,13 @@ export interface PluginEnablementPlan {
|
|
|
67
67
|
export declare class PluginManagementWorkflow implements PluginManagementPort {
|
|
68
68
|
#private;
|
|
69
69
|
constructor(projectRoot: string, options?: PluginManagementOptions);
|
|
70
|
-
inspect(request: string): Promise<PluginManagementState>;
|
|
70
|
+
inspect(request: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
|
|
71
|
+
/**
|
|
72
|
+
* The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
|
|
73
|
+
* Callers inspecting many plugins (Console attention) run it once here and pass
|
|
74
|
+
* `{ dependencies: 'skip' }` to `inspect`.
|
|
75
|
+
*/
|
|
76
|
+
checkDependencies(): Promise<PluginDependencyCheck>;
|
|
71
77
|
/** Full-scope, metadata-only projection over the same file and generation observation as inspect. */
|
|
72
78
|
explainConfiguration(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
|
|
73
79
|
planInstall(request: string, options?: Pick<PluginInstallOptions, 'enable'>): Promise<PluginInstallPlan>;
|
|
@@ -209,16 +209,32 @@ export class PluginManagementWorkflow {
|
|
|
209
209
|
const revision = config ? (await config.document.read()).revision : null;
|
|
210
210
|
return { revision, projectRevision: createHash('sha256').update(JSON.stringify([source.revision, revision])).digest('hex') };
|
|
211
211
|
}
|
|
212
|
-
async inspect(request) {
|
|
212
|
+
async inspect(request, options = {}) {
|
|
213
213
|
const lease = this.#options.snapshots?.acquire();
|
|
214
214
|
try {
|
|
215
|
-
return await this.#inspect(request, lease?.value);
|
|
215
|
+
return await this.#inspect(request, lease?.value, options);
|
|
216
216
|
}
|
|
217
217
|
finally {
|
|
218
218
|
lease?.release();
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
|
|
223
|
+
* Callers inspecting many plugins (Console attention) run it once here and pass
|
|
224
|
+
* `{ dependencies: 'skip' }` to `inspect`.
|
|
225
|
+
*/
|
|
226
|
+
async checkDependencies() {
|
|
227
|
+
const configuration = this.#configuration();
|
|
228
|
+
const document = configuration ? (await configuration.document.read()).document : {};
|
|
229
|
+
try {
|
|
230
|
+
await this.#checkDependencies(undefined, document);
|
|
231
|
+
return Object.freeze({ status: 'passed' });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return Object.freeze({ status: 'failed', error: errorMessage(error) });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async #inspect(request, snapshot, options = {}) {
|
|
222
238
|
const { configuration, source, document, declaration, projectRevision } = await this.#configurationObservation(snapshot);
|
|
223
239
|
const entries = declaration.instances;
|
|
224
240
|
const target = await this.#resolveTarget(request, entries);
|
|
@@ -236,13 +252,15 @@ export class PluginManagementWorkflow {
|
|
|
236
252
|
throw new Error('Legacy plugin lifecycle state detected; run zhin migrate instances');
|
|
237
253
|
const disabled = declaration.disabled.includes(target.instanceKey);
|
|
238
254
|
if (installed.plugin && declared && !disabled) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
if (options.dependencies !== 'skip') {
|
|
256
|
+
try {
|
|
257
|
+
await this.#checkDependencies(undefined, document);
|
|
258
|
+
dependencies = 'passed';
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
dependencies = 'failed';
|
|
262
|
+
issues.push(errorMessage(error));
|
|
263
|
+
}
|
|
246
264
|
}
|
|
247
265
|
validation = await configuration.store.validatePluginConfig(target.instanceKey, config[target.instanceKey] ?? {}, document);
|
|
248
266
|
}
|
|
@@ -17,7 +17,10 @@ export interface InstanceStatus {
|
|
|
17
17
|
readonly pid?: number;
|
|
18
18
|
/** Epoch ms of the current child's `ready`. */
|
|
19
19
|
readonly startedAt?: number;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Committed Runtime generation of the current instance: reported in `ready`, then advanced by
|
|
22
|
+
* every route table the instance publishes after a hot reload commits (#1250).
|
|
23
|
+
*/
|
|
21
24
|
readonly generation?: number;
|
|
22
25
|
/** Protocol version reported by the instance in `ready`. */
|
|
23
26
|
readonly protocolVersion?: number;
|
|
@@ -55,6 +55,8 @@ export class InstanceSupervisor extends EventEmitter {
|
|
|
55
55
|
#backoffTimer;
|
|
56
56
|
#restartTimes = [];
|
|
57
57
|
#ready;
|
|
58
|
+
/** Latest committed generation of the current child (`ready`, then each route table). */
|
|
59
|
+
#generation;
|
|
58
60
|
#startedAt;
|
|
59
61
|
#lastExit;
|
|
60
62
|
#lastFailure;
|
|
@@ -76,7 +78,7 @@ export class InstanceSupervisor extends EventEmitter {
|
|
|
76
78
|
state: this.#state,
|
|
77
79
|
...(child?.process.pid !== undefined ? { pid: child.process.pid } : {}),
|
|
78
80
|
...(this.#startedAt !== undefined && this.#state === 'running' ? { startedAt: this.#startedAt } : {}),
|
|
79
|
-
...(this.#
|
|
81
|
+
...(this.#generation !== undefined ? { generation: this.#generation } : {}),
|
|
80
82
|
...(this.#ready ? { protocolVersion: this.#ready.version } : {}),
|
|
81
83
|
lastExitCode: this.#lastExit?.code ?? null,
|
|
82
84
|
lastExitSignal: this.#lastExit?.signal ?? null,
|
|
@@ -153,6 +155,7 @@ export class InstanceSupervisor extends EventEmitter {
|
|
|
153
155
|
const { spawn: spec } = this.#options;
|
|
154
156
|
const output = this.#options.output ?? 'pipe';
|
|
155
157
|
this.#ready = undefined;
|
|
158
|
+
this.#generation = undefined;
|
|
156
159
|
this.#startedAt = undefined;
|
|
157
160
|
this.#setState('starting');
|
|
158
161
|
let processHandle;
|
|
@@ -250,12 +253,19 @@ export class InstanceSupervisor extends EventEmitter {
|
|
|
250
253
|
}
|
|
251
254
|
child.ready = true;
|
|
252
255
|
this.#ready = message.payload;
|
|
256
|
+
this.#noteGeneration(message.payload.generation);
|
|
253
257
|
this.#startedAt = Date.now();
|
|
254
258
|
this.#setState('running');
|
|
255
259
|
this.emit('ready', message.payload);
|
|
256
260
|
child.settleStart?.();
|
|
257
261
|
return;
|
|
258
262
|
}
|
|
263
|
+
if (message.type === 'routes') {
|
|
264
|
+
// The instance republishes its route table on every generation commit (hot reload,
|
|
265
|
+
// configuration apply); the table carries that generation (#1250).
|
|
266
|
+
this.#noteGeneration(message.payload.table.generation);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
259
269
|
if (message.type !== 'restart-request')
|
|
260
270
|
return;
|
|
261
271
|
this.emit('restart-request', message.payload);
|
|
@@ -273,6 +283,13 @@ export class InstanceSupervisor extends EventEmitter {
|
|
|
273
283
|
this.#setState('stopping');
|
|
274
284
|
void this.#terminate(child, `restart:${message.payload.reason}`);
|
|
275
285
|
}
|
|
286
|
+
/** Generations only move forward; a late table of an older generation never rolls status back. */
|
|
287
|
+
#noteGeneration(generation) {
|
|
288
|
+
if (generation === undefined)
|
|
289
|
+
return;
|
|
290
|
+
if (this.#generation === undefined || generation > this.#generation)
|
|
291
|
+
this.#generation = generation;
|
|
292
|
+
}
|
|
276
293
|
#onExit(child, code, signal) {
|
|
277
294
|
if (this.#child !== child)
|
|
278
295
|
return;
|
|
@@ -85,3 +85,15 @@ export declare function createModuleCache(loaders?: TerminalModuleLoaders): {
|
|
|
85
85
|
pty(): Promise<ModuleLoadResult<PtyModule>>;
|
|
86
86
|
ssh(): Promise<ModuleLoadResult<SshModule>>;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Whether `error` says the package itself cannot be resolved (not installed), as opposed to the
|
|
90
|
+
* package loading but failing inside (e.g. node-pty's `pty.node` missing because pnpm skipped its
|
|
91
|
+
* build script, or an addon built for another Node.js ABI).
|
|
92
|
+
*/
|
|
93
|
+
export declare function isPackageNotInstalledError(name: string, error: unknown): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Operator hint for an optional terminal module that failed to load (#1250). Distinguishes "not
|
|
96
|
+
* installed" from "installed but its native addon was not built" — the usual cause with pnpm, which
|
|
97
|
+
* skips dependency build scripts unless `allowBuilds` lets them run. Matches `zhin doctor`'s fix text.
|
|
98
|
+
*/
|
|
99
|
+
export declare function moduleLoadFailureHint(name: string, error: unknown): string;
|