mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 02:23:20 +08:00
fix(weixin): classify Harness health failures
This commit is contained in:
parent
8eed60bbc9
commit
eb21b14f60
9 changed files with 387 additions and 171 deletions
|
|
@ -495,7 +495,14 @@ var EN = Object.freeze({
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u767B\u5F55\u51ED\u636E\u65E0\u6CD5\u5199\u5165 DSH \u51ED\u636E\u5B58\u50A8\u3002\u8BF7\u68C0\u67E5\u51ED\u636E\u5B58\u50A8\u662F\u5426\u53EF\u5199\u3002": "WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u767B\u5F55\u51ED\u636E\u65E0\u6CD5\u5199\u5165 DSH \u51ED\u636E\u5B58\u50A8\u3002\u8BF7\u68C0\u67E5\u51ED\u636E\u5B58\u50A8\u662F\u5426\u53EF\u5199\u3002": "WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u8D26\u53F7\u914D\u7F6E\u65E0\u6CD5\u5199\u5165\u672C\u673A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u76EE\u5F55\u6743\u9650\u3002": "WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u8D26\u53F7\u914D\u7F6E\u65E0\u6CD5\u5199\u5165\u672C\u673A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u76EE\u5F55\u6743\u9650\u3002": "WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u521D\u59CB\u5316\u8D26\u53F7\u72B6\u6001\u6216\u5DE5\u4F5C\u533A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u548C\u5DE5\u4F5C\u533A\u76EE\u5F55\u3002": "WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u521D\u59CB\u5316\u8D26\u53F7\u72B6\u6001\u6216\u5DE5\u4F5C\u533A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u548C\u5DE5\u4F5C\u533A\u76EE\u5F55\u3002": "WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u63D2\u4EF6\u65E0\u6CD5\u8FDE\u63A5\u672C\u673A Harness\u3002\u8BF7\u786E\u8BA4 dsh web \u5DF2\u6B63\u5E38\u542F\u52A8\u3002": "WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u63D2\u4EF6\u65E0\u6CD5\u8FDE\u63A5\u672C\u673A Harness\u3002\u8BF7\u68C0\u67E5 dsh web \u5730\u5740\u548C\u7AEF\u53E3\u3002": "WeChat was authorized, but the plugin could not connect to the local Harness. Check the dsh web address and port.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u5065\u5EB7\u68C0\u67E5\u8D85\u65F6\u3002\u8BF7\u786E\u8BA4 dsh web \u672A\u963B\u585E\u3002": "WeChat was authorized, but the Harness health check timed out. Confirm that dsh web is not blocked.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u62D2\u7EDD\u4E86\u672C\u673A\u5065\u5EB7\u68C0\u67E5\u3002\u8BF7\u68C0\u67E5 Host \u4FE1\u4EFB\u914D\u7F6E\u3002": "WeChat was authorized, but Harness denied the local health check. Check the Host trust configuration.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u627E\u4E0D\u5230 Harness \u5065\u5EB7\u68C0\u67E5\u63A5\u53E3\u3002\u8BF7\u786E\u8BA4 Harness \u4E0E\u63D2\u4EF6\u7248\u672C\u517C\u5BB9\u3002": "WeChat was authorized, but the Harness health endpoint was not found. Confirm that Harness and the plugin are compatible.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u5065\u5EB7\u68C0\u67E5\u8FD4\u56DE\u670D\u52A1\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but the Harness health check returned a service error. Check the dsh web logs.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94\u3002\u8BF7\u786E\u8BA4 Harness \u4E0E\u63D2\u4EF6\u7248\u672C\u517C\u5BB9\u3002": "WeChat was authorized, but Harness returned an unrecognized response. Confirm that Harness and the plugin are compatible.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u62D2\u7EDD\u4E86\u5065\u5EB7\u68C0\u67E5\u8BF7\u6C42\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but Harness rejected the health-check request. Check the dsh web logs.",
|
||||||
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46 Harness \u5065\u5EB7\u68C0\u67E5\u53D1\u751F\u672A\u77E5\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but the Harness health check failed unexpectedly. Check the dsh web logs.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u521D\u59CB\u5316\u5931\u8D25\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u540E\u91CD\u8BD5\u3002": "WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u521D\u59CB\u5316\u5931\u8D25\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u540E\u91CD\u8BD5\u3002": "WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6FC0\u6D3B\u8FC7\u7A0B\u4E2D\u53D1\u751F\u672A\u77E5\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.",
|
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6FC0\u6D3B\u8FC7\u7A0B\u4E2D\u53D1\u751F\u672A\u77E5\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.",
|
||||||
"\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\uFF0C\u53EF\u4EE5\u5F00\u59CB\u5411\u5DF2\u7ED1\u5B9A\u7684\u673A\u5668\u4EBA\u53D1\u6D88\u606F\u3002": "WeChat is connected and ready for messages.",
|
"\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\uFF0C\u53EF\u4EE5\u5F00\u59CB\u5411\u5DF2\u7ED1\u5B9A\u7684\u673A\u5668\u4EBA\u53D1\u6D88\u606F\u3002": "WeChat is connected and ready for messages.",
|
||||||
|
|
|
||||||
300
lib/index.js
300
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -305,7 +305,14 @@ const EN = Object.freeze({
|
||||||
'微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
|
'微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
|
||||||
'微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
|
'微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
|
||||||
'微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
|
'微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
|
||||||
'微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。': 'WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.',
|
'微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。': 'WeChat was authorized, but the plugin could not connect to the local Harness. Check the dsh web address and port.',
|
||||||
|
'微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。': 'WeChat was authorized, but the Harness health check timed out. Confirm that dsh web is not blocked.',
|
||||||
|
'微信已授权,但 Harness 拒绝了本机健康检查。请检查 Host 信任配置。': 'WeChat was authorized, but Harness denied the local health check. Check the Host trust configuration.',
|
||||||
|
'微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。': 'WeChat was authorized, but the Harness health endpoint was not found. Confirm that Harness and the plugin are compatible.',
|
||||||
|
'微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。': 'WeChat was authorized, but the Harness health check returned a service error. Check the dsh web logs.',
|
||||||
|
'微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。': 'WeChat was authorized, but Harness returned an unrecognized response. Confirm that Harness and the plugin are compatible.',
|
||||||
|
'微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。': 'WeChat was authorized, but Harness rejected the health-check request. Check the dsh web logs.',
|
||||||
|
'微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but the Harness health check failed unexpectedly. Check the dsh web logs.',
|
||||||
'微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
|
'微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
|
||||||
'微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
|
'微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
|
||||||
'微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
|
'微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,26 @@ export class HarnessRpcError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class HarnessTransportError extends Error {
|
||||||
|
constructor(code, method, { cause, status } = {}) {
|
||||||
|
const statusDetail = Number.isInteger(status) ? `, HTTP ${status}` : '';
|
||||||
|
super(`Harness ${method} transport failed (${code}${statusDetail})`, { cause });
|
||||||
|
this.name = 'HarnessTransportError';
|
||||||
|
this.code = code;
|
||||||
|
this.method = method;
|
||||||
|
if (Number.isInteger(status)) this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HarnessHealthError extends Error {
|
||||||
|
constructor(cause) {
|
||||||
|
super('Harness health RPC was rejected', { cause });
|
||||||
|
this.name = 'HarnessHealthError';
|
||||||
|
this.code = 'harness-rpc-rejected';
|
||||||
|
this.method = 'host.describe';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class HarnessInteractionError extends Error {
|
export class HarnessInteractionError extends Error {
|
||||||
constructor(code, message) {
|
constructor(code, message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -455,24 +475,60 @@ export class HarnessClient {
|
||||||
const signal = options.signal
|
const signal = options.signal
|
||||||
? AbortSignal.any([options.signal, timeoutSignal])
|
? AbortSignal.any([options.signal, timeoutSignal])
|
||||||
: timeoutSignal;
|
: timeoutSignal;
|
||||||
const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
let response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'content-type': 'application/json' },
|
response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
||||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
method: 'POST',
|
||||||
signal,
|
headers: { 'content-type': 'application/json' },
|
||||||
});
|
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
||||||
if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`);
|
signal,
|
||||||
const body = await response.json();
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Preserve an explicit caller cancellation; it is control flow, not a
|
||||||
|
// Harness availability diagnosis.
|
||||||
|
if (options.signal?.aborted) throw error;
|
||||||
|
throw new HarnessTransportError(
|
||||||
|
timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
|
||||||
|
method,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const code = response.status === 401 || response.status === 403
|
||||||
|
? 'harness-access-denied'
|
||||||
|
: response.status === 404
|
||||||
|
? 'harness-api-not-found'
|
||||||
|
: 'harness-http-failed';
|
||||||
|
throw new HarnessTransportError(code, method, { status: response.status });
|
||||||
|
}
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new HarnessTransportError('harness-response-invalid', method, { cause: error });
|
||||||
|
}
|
||||||
if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
|
if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
|
||||||
throw new Error(`Harness returned an invalid response for ${method}`);
|
throw new HarnessTransportError('harness-response-invalid', method, {
|
||||||
|
cause: new Error(`Harness returned an invalid response for ${method}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!body.result || typeof body.result !== 'object' || typeof body.result.ok !== 'boolean') {
|
||||||
|
throw new HarnessTransportError('harness-response-invalid', method, {
|
||||||
|
cause: new Error(`Harness returned an invalid result for ${method}`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
|
if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
|
||||||
return body.result.value;
|
return body.result.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async health(options = {}) {
|
async health(options = {}) {
|
||||||
await this.rpc('host.describe', {}, 5_000, options);
|
try {
|
||||||
return true;
|
await this.rpc('host.describe', {}, 5_000, options);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessRpcError) throw new HarnessHealthError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureRunning(options = {}) {
|
async ensureRunning(options = {}) {
|
||||||
|
|
@ -506,7 +562,8 @@ export class HarnessClient {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
if (lastError) throw lastError;
|
||||||
|
throw new HarnessTransportError('harness-timeout', 'host.describe');
|
||||||
}
|
}
|
||||||
|
|
||||||
async listWorkspaces(options = {}) {
|
async listWorkspaces(options = {}) {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,14 @@ const ACTIVATION_ERROR_MESSAGES = Object.freeze({
|
||||||
'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
|
'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
|
||||||
'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
|
'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
|
||||||
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
||||||
'harness-unreachable': '微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。',
|
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
||||||
|
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
||||||
|
'harness-access-denied': '微信已授权,但 Harness 拒绝了本机健康检查。请检查 Host 信任配置。',
|
||||||
|
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
||||||
|
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
||||||
|
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
||||||
|
'harness-rpc-rejected': '微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。',
|
||||||
|
'harness-check-unknown-failed': '微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。',
|
||||||
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
|
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ import {
|
||||||
} from '../shared/connection-test.mjs';
|
} from '../shared/connection-test.mjs';
|
||||||
|
|
||||||
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
||||||
|
const HARNESS_HEALTH_ERROR_CODES = new Set([
|
||||||
|
'harness-connect-failed',
|
||||||
|
'harness-timeout',
|
||||||
|
'harness-access-denied',
|
||||||
|
'harness-api-not-found',
|
||||||
|
'harness-http-failed',
|
||||||
|
'harness-response-invalid',
|
||||||
|
'harness-rpc-rejected',
|
||||||
|
]);
|
||||||
|
|
||||||
function startRetryDelays(value) {
|
function startRetryDelays(value) {
|
||||||
if (value === undefined) return [...DEFAULT_START_RETRY_DELAYS_MS];
|
if (value === undefined) return [...DEFAULT_START_RETRY_DELAYS_MS];
|
||||||
|
|
@ -32,6 +41,13 @@ function runtimeStartError(code, cause) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function harnessHealthError(cause) {
|
||||||
|
const code = HARNESS_HEALTH_ERROR_CODES.has(cause?.code)
|
||||||
|
? cause.code
|
||||||
|
: 'harness-check-unknown-failed';
|
||||||
|
return runtimeStartError(code, cause);
|
||||||
|
}
|
||||||
|
|
||||||
function delay(ms, signal) {
|
function delay(ms, signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|
@ -127,7 +143,7 @@ export class WeixinRuntime {
|
||||||
try {
|
try {
|
||||||
await this.#harness.ensureRunning();
|
await this.#harness.ensureRunning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw runtimeStartError('harness-unreachable', error);
|
throw harnessHealthError(error);
|
||||||
}
|
}
|
||||||
this.#status.harnessReachable = true;
|
this.#status.harnessReachable = true;
|
||||||
await this.#notifyStart();
|
await this.#notifyStart();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import { QqHarnessClient } from '../../../src/channels/qq/harness-client.mjs';
|
||||||
import { SlackHarnessClient } from '../../../src/channels/slack/harness-client.mjs';
|
import { SlackHarnessClient } from '../../../src/channels/slack/harness-client.mjs';
|
||||||
import { TelegramHarnessClient } from '../../../src/channels/telegram/harness-client.mjs';
|
import { TelegramHarnessClient } from '../../../src/channels/telegram/harness-client.mjs';
|
||||||
import { WecomHarnessClient } from '../../../src/channels/wecom/harness-client.mjs';
|
import { WecomHarnessClient } from '../../../src/channels/wecom/harness-client.mjs';
|
||||||
|
import {
|
||||||
|
HarnessHealthError,
|
||||||
|
HarnessTransportError,
|
||||||
|
} from '../../../src/channels/shared/harness-client.mjs';
|
||||||
import { HarnessClient, HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
import { HarnessClient, HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
||||||
import { WhatsappHarnessClient } from '../../../src/channels/whatsapp/harness-client.mjs';
|
import { WhatsappHarnessClient } from '../../../src/channels/whatsapp/harness-client.mjs';
|
||||||
|
|
||||||
|
|
@ -45,6 +49,91 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shared Harness health checks expose precise safe availability codes', async () => {
|
||||||
|
const clientWithFetch = (fetchImpl) => new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/default-workspace',
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateConnectionError = new Error('ECONNREFUSED at private loopback port');
|
||||||
|
await assert.rejects(
|
||||||
|
clientWithFetch(async () => { throw privateConnectionError; }).health(),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof HarnessTransportError);
|
||||||
|
assert.equal(error.code, 'harness-connect-failed');
|
||||||
|
assert.equal(error.cause, privateConnectionError);
|
||||||
|
assert.doesNotMatch(error.message, /private loopback port/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeoutClient = clientWithFetch((_url, { signal }) => new Promise((_resolve, reject) => {
|
||||||
|
const rejectTimeout = () => reject(signal.reason);
|
||||||
|
if (signal.aborted) rejectTimeout();
|
||||||
|
else signal.addEventListener('abort', rejectTimeout, { once: true });
|
||||||
|
}));
|
||||||
|
await assert.rejects(timeoutClient.rpc('host.describe', {}, 1), (error) => {
|
||||||
|
assert.ok(error instanceof HarnessTransportError);
|
||||||
|
assert.equal(error.code, 'harness-timeout');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [status, expectedCode] of [
|
||||||
|
[401, 'harness-access-denied'],
|
||||||
|
[403, 'harness-access-denied'],
|
||||||
|
[404, 'harness-api-not-found'],
|
||||||
|
[500, 'harness-http-failed'],
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
clientWithFetch(async () => ({ ok: false, status })).health(),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof HarnessTransportError);
|
||||||
|
assert.equal(error.code, expectedCode);
|
||||||
|
assert.equal(error.status, status);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
clientWithFetch(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => { throw new SyntaxError('private malformed response body'); },
|
||||||
|
})).health(),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof HarnessTransportError);
|
||||||
|
assert.equal(error.code, 'harness-response-invalid');
|
||||||
|
assert.doesNotMatch(error.message, /private malformed response body/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
clientWithFetch(async (_url, options) => {
|
||||||
|
const { rpcId } = JSON.parse(options.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
type: 'server-response',
|
||||||
|
rpcId,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'private-host-code', message: 'private Host RPC detail' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}).health(),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof HarnessHealthError);
|
||||||
|
assert.equal(error.code, 'harness-rpc-rejected');
|
||||||
|
assert.match(error.cause?.message ?? '', /private Host RPC detail/);
|
||||||
|
assert.doesNotMatch(error.message, /private-host-code|private Host RPC detail/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('HarnessClient lets the Host resolve an omitted agent preset and forwards an explicit override', async () => {
|
test('HarnessClient lets the Host resolve an omitted agent preset and forwards an explicit override', async () => {
|
||||||
const createPayload = async (options = {}) => {
|
const createPayload = async (options = {}) => {
|
||||||
const client = new HarnessClient({
|
const client = new HarnessClient({
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,7 @@ test('known runtime activation codes cross the provisioning boundary unchanged',
|
||||||
const configs = configFixture();
|
const configs = configFixture();
|
||||||
const runtimes = runtimeFactory({
|
const runtimes = runtimeFactory({
|
||||||
startError: Object.assign(new Error('loopback transport host-only detail'), {
|
startError: Object.assign(new Error('loopback transport host-only detail'), {
|
||||||
code: 'harness-unreachable',
|
code: 'harness-api-not-found',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const controller = new WeixinController({
|
const controller = new WeixinController({
|
||||||
|
|
@ -377,8 +377,9 @@ test('known runtime activation codes cross the provisioning boundary unchanged',
|
||||||
(value) => value.status === 'failed',
|
(value) => value.status === 'failed',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(failed.error.code, 'harness-unreachable');
|
assert.equal(failed.error.code, 'harness-api-not-found');
|
||||||
assert.match(failed.error.message, /无法连接本机 Harness/);
|
assert.notEqual(failed.error.code, 'harness-unreachable');
|
||||||
|
assert.match(failed.error.message, /找不到 Harness 健康检查接口/);
|
||||||
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
|
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
|
||||||
await controller.close();
|
await controller.close();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ test('runtime refuses to report ready when notifyStart rejects the stored token'
|
||||||
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime identifies a local Harness health failure without exposing its detail', async () => {
|
test('runtime uses an explicit unknown code for an unclassified Harness health failure', async () => {
|
||||||
const runtime = new WeixinRuntime({
|
const runtime = new WeixinRuntime({
|
||||||
api: {
|
api: {
|
||||||
notifyStart: async () => assert.fail('notifyStart must not run while Harness is unavailable'),
|
notifyStart: async () => assert.fail('notifyStart must not run while Harness is unavailable'),
|
||||||
|
|
@ -323,7 +323,8 @@ test('runtime identifies a local Harness health failure without exposing its det
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(runtime.start(), (error) => {
|
await assert.rejects(runtime.start(), (error) => {
|
||||||
assert.equal(error.code, 'harness-unreachable');
|
assert.equal(error.code, 'harness-check-unknown-failed');
|
||||||
|
assert.notEqual(error.code, 'harness-unreachable');
|
||||||
assert.doesNotMatch(error.message, /private loopback transport detail/);
|
assert.doesNotMatch(error.message, /private loopback transport detail/);
|
||||||
assert.match(error.cause?.message ?? '', /private loopback transport detail/);
|
assert.match(error.cause?.message ?? '', /private loopback transport detail/);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -332,6 +333,37 @@ test('runtime identifies a local Harness health failure without exposing its det
|
||||||
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runtime preserves classified Harness health codes without exposing their causes', async () => {
|
||||||
|
for (const code of [
|
||||||
|
'harness-connect-failed',
|
||||||
|
'harness-timeout',
|
||||||
|
'harness-access-denied',
|
||||||
|
'harness-api-not-found',
|
||||||
|
'harness-http-failed',
|
||||||
|
'harness-response-invalid',
|
||||||
|
'harness-rpc-rejected',
|
||||||
|
]) {
|
||||||
|
const healthError = Object.assign(new Error(`private detail for ${code}`), { code });
|
||||||
|
const runtime = new WeixinRuntime({
|
||||||
|
api: {
|
||||||
|
notifyStart: async () => assert.fail('notifyStart must not run after a failed health check'),
|
||||||
|
notifyStop: async () => {},
|
||||||
|
},
|
||||||
|
config: { botId: `wx_${code}`, baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
|
||||||
|
token: 'bot-token',
|
||||||
|
harness: { ensureRunning: async () => { throw healthError; } },
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), (error) => {
|
||||||
|
assert.equal(error.code, code);
|
||||||
|
assert.equal(error.cause, healthError);
|
||||||
|
assert.doesNotMatch(error.message, /private detail/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
|
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
|
||||||
let startCalls = 0;
|
let startCalls = 0;
|
||||||
const runtime = new WeixinRuntime({
|
const runtime = new WeixinRuntime({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue