mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 16:40:46 +08:00
fix: use in-process Harness API for local plugin connections
This commit is contained in:
parent
f4ded46278
commit
f0b6b38b45
40 changed files with 1413 additions and 603 deletions
|
|
@ -98,6 +98,8 @@ dsh plugin --profile web add -w @xmanrui/dsh-im
|
|||
|
||||
Restart `dsh web`, refresh the browser, then open **Settings → IM Bot**. The top-level IM Bot entry uses `order: 21` to follow **Agent Presets**, and the Plugins page no longer retains the old entry. Upgrading preserves existing bots, credentials, workspaces, Agent Presets, and Session bindings.
|
||||
|
||||
Local `dsh web` and DSH Desktop use the current Host's internal `apiProxy` by default, without a loopback HTTP connection. Desktop's compatibility, extended-window, and advanced modes do not require browser access or LAN access to be enabled. An explicit channel `harnessBaseUrl` still uses the existing HTTP/WebSocket connection; failed internal calls never silently switch to another Host.
|
||||
|
||||
To try the latest code before it is published to npm, use the GitHub-source installer instead:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ dsh plugin --profile web add -w @xmanrui/dsh-im
|
|||
|
||||
重启 `dsh web`、刷新浏览器,然后打开「设置 → IM机器人」。IM机器人使用 `order: 21`,尽量排在一级设置菜单的「Agent 预设」之后;插件页面不再保留旧入口。从旧版升级不会改变已有机器人、凭据、工作区、Agent Preset 或会话绑定。
|
||||
|
||||
本机 `dsh web` 和 DSH Desktop 默认通过当前 Host 的内部 `apiProxy` 接口通信,不再绕行本机 HTTP 端口。Desktop 的兼容模式、扩展窗口和增强模式均无需开启“允许在浏览器中打开”或局域网访问。渠道配置中显式设置的 `harnessBaseUrl` 仍使用原有 HTTP/WebSocket 连接;内部调用失败不会自动改连其他 Host。
|
||||
|
||||
如需试用尚未发布到 npm 的最新代码,可以改用 GitHub 源安装器:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
420
lib/index.js
420
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installDingtalkRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-dingtalk-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -17,17 +17,9 @@ import {
|
|||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-dingtalk requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-dingtalk'));
|
||||
|
|
@ -41,7 +33,7 @@ function pluginPaths(config) {
|
|||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-dingtalk requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-dingtalk requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore;
|
||||
const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth;
|
||||
|
|
@ -92,7 +84,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installDiscordRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-discord-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installFeishuRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-feishu-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
function controllerFrom(ctx, config) {
|
||||
if (config?.controller) return config.controller;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as Lark from '@larksuiteoapi/node-sdk';
|
|||
import HttpsProxyAgent from 'https-proxy-agent';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
import { verifyFeishuApp } from '../../../../src/channels/feishu/feishu-app.mjs';
|
||||
import { FeishuRuntime } from '../../../../src/channels/feishu/feishu-runtime.mjs';
|
||||
|
|
@ -39,17 +40,6 @@ export function createFeishuWebSocketAgent(
|
|||
return proxyUrl ? createAgent(proxyUrl) : undefined;
|
||||
}
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-feishu requires an initialized DSH webServer port');
|
||||
}
|
||||
// Even when DSH listens on all interfaces, its own plugin talks through the
|
||||
// loopback authority accepted by Connection's request-trust fence.
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome
|
||||
?? process.env.DSH_HOME
|
||||
|
|
@ -70,7 +60,7 @@ function pluginPaths(config) {
|
|||
*/
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-feishu requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-feishu requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const lark = internals.lark ?? Lark;
|
||||
const Controller = internals.Controller ?? MultiBotDshFeishuController;
|
||||
|
|
@ -139,7 +129,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
// This plugin is already hosted by a running DSH process. Starting a
|
||||
// second DSH would create a competing server and lifecycle.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { OfficeConfigStore } from '../../../../src/channels/office/config-store.
|
|||
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
|
||||
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
|
||||
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
|
||||
import { harnessOrigin } from '../shared/production.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
|
||||
export function officePaths(config = {}) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
|
|
@ -14,6 +14,7 @@ export function officePaths(config = {}) {
|
|||
}
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
const connection = harnessConnection(ctx, config);
|
||||
const Store = internals.ConfigStore ?? OfficeConfigStore;
|
||||
const Controller = internals.Controller ?? OfficeController;
|
||||
const Runtime = internals.Runtime ?? OfficeRuntime;
|
||||
|
|
@ -21,9 +22,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const paths = officePaths(config);
|
||||
const configStore = await new Store(paths.config).load();
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
|
||||
const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
|
||||
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
|
||||
baseUrl: harnessBaseUrl,
|
||||
...connection,
|
||||
workspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installQqRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-qq-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -17,17 +17,9 @@ import {
|
|||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im QQ requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-qq'));
|
||||
|
|
@ -40,7 +32,7 @@ function pluginPaths(config) {
|
|||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im QQ requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im QQ requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ConfigStore = internals.ConfigStore ?? QqConfigStore;
|
||||
const StateStore = internals.StateStore ?? QqStateStore;
|
||||
|
|
@ -83,7 +75,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|||
|
||||
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
|
|
@ -13,15 +14,6 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
|
||||
export function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im token channel requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
export function pluginPaths(config, channel) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', `dsh-${channel}`));
|
||||
|
|
@ -37,7 +29,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
|
||||
} = definitions;
|
||||
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
||||
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore;
|
||||
const ResolvedStateStore = internals.StateStore ?? StateStore;
|
||||
|
|
@ -84,7 +76,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installSlackRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-slack-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installSlackRpc(ctx, config.controller, config.rpcAuthority);
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ import {
|
|||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { harnessOrigin, pluginPaths } from '../shared/production.mjs';
|
||||
import { pluginPaths } from '../shared/production.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im slack requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ResolvedConfigStore = internals.ConfigStore ?? SlackConfigStore;
|
||||
const ResolvedStateStore = internals.StateStore ?? SlackStateStore;
|
||||
|
|
@ -62,7 +63,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installTelegramRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-telegram-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWecomRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-wecom-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -17,17 +17,9 @@ import {
|
|||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im Enterprise WeChat requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-wecom'));
|
||||
|
|
@ -40,7 +32,7 @@ function pluginPaths(config) {
|
|||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im Enterprise WeChat requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
|
||||
const StateStore = internals.StateStore ?? WecomStateStore;
|
||||
|
|
@ -86,7 +78,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWeixinRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-weixin-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'credentials', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -20,17 +20,9 @@ import {
|
|||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-weixin requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-weixin'));
|
||||
|
|
@ -44,7 +36,7 @@ function pluginPaths(config) {
|
|||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-weixin requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
|
||||
const ConfigStore = internals.ConfigStore ?? WeixinConfigStore;
|
||||
const StateStore = internals.StateStore ?? WeixinStateStore;
|
||||
|
|
@ -89,7 +81,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createProductionController } from './production.mjs';
|
|||
import { installWhatsappRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-whatsapp-host';
|
||||
export const inject = ['connection', 'webServer', 'typertGateway'];
|
||||
export const inject = ['connection', 'apiProxy', 'typertGateway'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
|
|
|
|||
|
|
@ -17,19 +17,11 @@ import {
|
|||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||
import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
|
||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||
import { harnessConnection } from '../../harness-connection.mjs';
|
||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||
|
||||
const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im WhatsApp requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-whatsapp'));
|
||||
|
|
@ -47,7 +39,7 @@ function pluginPaths(config) {
|
|||
}
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im WhatsApp requires ctx.webServer');
|
||||
const connection = harnessConnection(ctx, config);
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
|
||||
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
||||
|
|
@ -89,7 +81,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
fileIngressExecutor: internals.fileIngressExecutor,
|
||||
});
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
...connection,
|
||||
workspace: defaultWorkspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
|
|
|
|||
15
plugin-src/host/harness-connection.mjs
Normal file
15
plugin-src/host/harness-connection.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** Connect to this Host directly unless an external Harness URL was configured. */
|
||||
export function harnessConnection(ctx, config = {}) {
|
||||
if (config.harnessBaseUrl !== undefined) {
|
||||
return { baseUrl: new URL(config.harnessBaseUrl) };
|
||||
}
|
||||
if (!ctx?.apiProxy) {
|
||||
throw new TypeError('dsh-im requires the Host apiProxy service; check that DSH has finished loading its Host services');
|
||||
}
|
||||
return {
|
||||
apiProxy: ctx.apiProxy,
|
||||
// Cordis child contexts share one root; different Hosts must not share
|
||||
// ownership of pending questions and approvals.
|
||||
interactionScope: ctx.root ?? ctx,
|
||||
};
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ export const name = 'dsh-im-host';
|
|||
export const inject = [
|
||||
'connection',
|
||||
'credentials',
|
||||
'webServer',
|
||||
'apiProxy',
|
||||
'typertGateway',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import {
|
|||
} from './inbound-file.mjs';
|
||||
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
|
||||
import { t } from './i18n.mjs';
|
||||
import { watchHarnessMux } from './harness-mux.mjs';
|
||||
|
||||
// Every channel plugin runs in the same Host process. Sharing ownership by
|
||||
// Harness origin prevents two channel-specific clients bound to one Session
|
||||
// Host identity (or an explicitly configured HTTP origin) prevents clients
|
||||
// bound to one Session
|
||||
// from claiming or cancelling each other's interactions.
|
||||
const interactionRegistries = new Map();
|
||||
const hostInteractionRegistries = new WeakMap();
|
||||
const MAX_ERROR_CLASSIFICATION_BYTES = 64;
|
||||
|
||||
async function smallResponseText(response) {
|
||||
|
|
@ -74,8 +77,9 @@ async function harnessHttpErrorCode(response, hostname) {
|
|||
return 'harness-http-failed';
|
||||
}
|
||||
|
||||
function interactionRegistry(origin) {
|
||||
let registry = interactionRegistries.get(origin);
|
||||
function interactionRegistry(scope) {
|
||||
const registries = typeof scope === 'string' ? interactionRegistries : hostInteractionRegistries;
|
||||
let registry = registries.get(scope);
|
||||
if (!registry) {
|
||||
registry = {
|
||||
ownerships: new Map(),
|
||||
|
|
@ -83,11 +87,38 @@ function interactionRegistry(origin) {
|
|||
controls: new WeakMap(),
|
||||
nextOrder: 0,
|
||||
};
|
||||
interactionRegistries.set(origin, registry);
|
||||
registries.set(scope, registry);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
// A Host RPC may not accept cancellation itself. Bound the caller's wait
|
||||
// without retrying an operation that the Host may already have accepted.
|
||||
function callWithSignal(call, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener('abort', handleAbort);
|
||||
const handleAbort = () => {
|
||||
cleanup();
|
||||
reject(signal.reason);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
handleAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
Promise.resolve().then(() => {
|
||||
signal.throwIfAborted();
|
||||
return call();
|
||||
}).then((value) => {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeControl(control) {
|
||||
const ownerType = typeof control?.owner;
|
||||
if ((ownerType !== 'object' && ownerType !== 'function')
|
||||
|
|
@ -569,6 +600,7 @@ function harnessTurnSucceeded(reason) {
|
|||
|
||||
export class HarnessClient {
|
||||
#baseUrl;
|
||||
#apiProxy;
|
||||
#workspace;
|
||||
#agentPreset;
|
||||
#autostart;
|
||||
|
|
@ -590,6 +622,8 @@ export class HarnessClient {
|
|||
|
||||
constructor({
|
||||
baseUrl,
|
||||
apiProxy,
|
||||
interactionScope = apiProxy,
|
||||
workspace,
|
||||
agentPreset,
|
||||
autostart = false,
|
||||
|
|
@ -629,11 +663,19 @@ export class HarnessClient {
|
|||
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
||||
throw new TypeError('fileIngressExecutor must be a function');
|
||||
}
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#baseUrl = baseUrl === undefined ? null : new URL(baseUrl);
|
||||
this.#apiProxy = this.#baseUrl ? null : apiProxy;
|
||||
if (!this.#baseUrl && (!this.#apiProxy || typeof this.#apiProxy !== 'object')) {
|
||||
throw new TypeError('HarnessClient requires the current Host apiProxy or an explicit baseUrl');
|
||||
}
|
||||
if (this.#apiProxy && (!interactionScope
|
||||
|| !['object', 'function'].includes(typeof interactionScope))) {
|
||||
throw new TypeError('interactionScope must identify the current Host');
|
||||
}
|
||||
this.#workspace = workspace;
|
||||
// Keep an omitted preset absent so session.create resolves the Host's current default.
|
||||
this.#agentPreset = agentPreset ?? undefined;
|
||||
this.#autostart = autostart;
|
||||
this.#autostart = Boolean(this.#baseUrl && autostart);
|
||||
this.#dshBin = dshBin;
|
||||
this.#fetch = fetchImpl;
|
||||
this.#createWebSocket = createWebSocket;
|
||||
|
|
@ -644,7 +686,7 @@ export class HarnessClient {
|
|||
this.#controlExecutor = controlExecutor;
|
||||
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
|
||||
this.#fileIngressExecutor = fileIngressExecutor;
|
||||
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
||||
this.#interactionRegistry = interactionRegistry(this.#baseUrl?.origin ?? interactionScope);
|
||||
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
||||
this.#interactionClaims = this.#interactionRegistry.claims;
|
||||
this.#controlOwnerships = this.#interactionRegistry.controls;
|
||||
|
|
@ -656,34 +698,46 @@ export class HarnessClient {
|
|||
const signal = options.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
let response;
|
||||
let body;
|
||||
try {
|
||||
response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
||||
signal,
|
||||
});
|
||||
if (this.#apiProxy) {
|
||||
const [domain, action, extra] = method.split('.');
|
||||
const namespace = { host: 'host', workspace: 'workspace', session: 'sessions', llm: 'llm' }[domain];
|
||||
const api = namespace && this.#apiProxy[namespace];
|
||||
if (extra !== undefined || !Object.hasOwn(api ?? {}, action)
|
||||
|| typeof api[action] !== 'function') {
|
||||
throw new HarnessTransportError('harness-api-not-found', method);
|
||||
}
|
||||
const response = await callWithSignal(() => api[action]({ rpcId, payload }, signal), signal);
|
||||
body = { type: 'server-response', ...response };
|
||||
} else {
|
||||
const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
|
||||
throw new HarnessTransportError(code, method, { status: response.status });
|
||||
}
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (error) {
|
||||
throw new HarnessTransportError('harness-response-invalid', method, { cause: error });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Preserve an explicit caller cancellation; it is control flow, not a
|
||||
// Harness availability diagnosis.
|
||||
if (options.signal?.aborted) throw error;
|
||||
if (error instanceof HarnessTransportError) throw error;
|
||||
throw new HarnessTransportError(
|
||||
timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
|
||||
method,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
|
||||
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) {
|
||||
throw new HarnessTransportError('harness-response-invalid', method, {
|
||||
cause: new Error(`Harness returned an invalid response for ${method}`),
|
||||
|
|
@ -876,16 +930,22 @@ export class HarnessClient {
|
|||
const signal = options.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-response', rpcId, result }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
|
||||
const envelope = { type: 'client-response', rpcId, result };
|
||||
let receipt;
|
||||
if (this.#apiProxy) {
|
||||
receipt = await callWithSignal(() => this.#apiProxy.respond(envelope), signal);
|
||||
} else {
|
||||
const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
|
||||
}
|
||||
receipt = await response.json();
|
||||
}
|
||||
const receipt = await response.json();
|
||||
if (receipt?.accepted === true) return receipt;
|
||||
if (receipt?.accepted !== false
|
||||
|| (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) {
|
||||
|
|
@ -921,7 +981,7 @@ export class HarnessClient {
|
|||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await this.#watchInteractionSocket(sessionId, {
|
||||
await this.#watchInteractionStream(sessionId, {
|
||||
signal,
|
||||
onInteraction,
|
||||
onResolved,
|
||||
|
|
@ -1360,191 +1420,135 @@ export class HarnessClient {
|
|||
}
|
||||
}
|
||||
|
||||
#watchInteractionSocket(sessionId, {
|
||||
async #watchInteractionStream(sessionId, {
|
||||
signal,
|
||||
onInteraction,
|
||||
onResolved,
|
||||
onOpen,
|
||||
ownership,
|
||||
}) {
|
||||
const url = new URL('/api/events.mux', this.#baseUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let socket;
|
||||
let settled = false;
|
||||
let callbackFailure = null;
|
||||
let callbackTail = Promise.resolve();
|
||||
let ownershipReady = ownership === undefined || ownership === null;
|
||||
const bufferedEnvelopes = [];
|
||||
let close = () => {};
|
||||
const handleOpen = (closeStream) => {
|
||||
close = closeStream;
|
||||
if (ownership) ownership.reconnect = close;
|
||||
try {
|
||||
socket = this.#createWebSocket(url.toString());
|
||||
onOpen?.();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
|
||||
}
|
||||
if (ownership) {
|
||||
void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
|
||||
if (settled) return;
|
||||
ownershipReady = true;
|
||||
for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
|
||||
}).catch((error) => {
|
||||
callbackFailure ??= error;
|
||||
close();
|
||||
});
|
||||
}
|
||||
};
|
||||
const dispatch = (callback, value) => {
|
||||
if (!callback) return;
|
||||
callbackTail = callbackTail
|
||||
.then(() => callback(value))
|
||||
.catch((error) => {
|
||||
callbackFailure ??= error;
|
||||
close();
|
||||
});
|
||||
};
|
||||
const processEnvelope = (envelope) => {
|
||||
const payload = envelope.payload;
|
||||
if (ownership && payload.type === 'session/event') {
|
||||
this.#consumeInteractionOwnerships(sessionId, [payload.event]);
|
||||
return;
|
||||
}
|
||||
let opened = false;
|
||||
let settled = false;
|
||||
let callbackFailure = null;
|
||||
let callbackTail = Promise.resolve();
|
||||
let ownershipReady = ownership === undefined || ownership === null;
|
||||
const bufferedEnvelopes = [];
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.removeEventListener('open', handleOpen);
|
||||
socket.removeEventListener('message', handleMessage);
|
||||
socket.removeEventListener('close', handleClose);
|
||||
socket.removeEventListener('error', handleError);
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
if (ownership?.reconnect === close) ownership.reconnect = null;
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
void callbackTail.then(() => {
|
||||
const failure = error ?? callbackFailure;
|
||||
if (failure) reject(failure);
|
||||
else resolve();
|
||||
}, reject);
|
||||
};
|
||||
const close = () => {
|
||||
try {
|
||||
if (socket.readyState === 0 || socket.readyState === 1) socket.close();
|
||||
} catch {
|
||||
// Cleanup must still settle the watcher if a WebSocket rejects close while connecting.
|
||||
}
|
||||
};
|
||||
const handleOpen = () => {
|
||||
opened = true;
|
||||
if (ownership) ownership.reconnect = close;
|
||||
try {
|
||||
onOpen?.();
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
|
||||
}
|
||||
if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
|
||||
const kind = payload.type === 'question/requested' ? 'question' : 'approval';
|
||||
const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
|
||||
const claimKey = `${kind}:${interactionId}`;
|
||||
if (ownership) {
|
||||
void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
|
||||
if (settled) return;
|
||||
ownershipReady = true;
|
||||
for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
|
||||
}).catch((error) => {
|
||||
callbackFailure ??= error;
|
||||
close();
|
||||
finish(error);
|
||||
});
|
||||
const claim = this.#interactionOwner(sessionId, claimKey, kind);
|
||||
if (claim?.ownership !== ownership) return;
|
||||
this.#interactionClaims.set(claimKey, claim);
|
||||
}
|
||||
};
|
||||
const dispatch = (callback, value) => {
|
||||
if (!callback) return;
|
||||
callbackTail = callbackTail
|
||||
.then(() => callback(value))
|
||||
.catch((error) => {
|
||||
callbackFailure ??= error;
|
||||
close();
|
||||
finish(callbackFailure);
|
||||
});
|
||||
};
|
||||
const processEnvelope = (envelope) => {
|
||||
const payload = envelope.payload;
|
||||
if (ownership && payload.type === 'session/event') {
|
||||
this.#consumeInteractionOwnerships(sessionId, [payload.event]);
|
||||
return;
|
||||
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
||||
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
||||
: undefined;
|
||||
dispatch(onInteraction, Object.freeze({
|
||||
kind,
|
||||
interactionId,
|
||||
rpcId: envelope.rpcId,
|
||||
sessionId,
|
||||
payload,
|
||||
recovered: ownership
|
||||
? this.#interactionClaims.get(claimKey)?.recovered === true
|
||||
: false,
|
||||
...(toolCall ? { toolCall } : {}),
|
||||
reconnect: close,
|
||||
respond: (result, options = {}) => this.respondInteraction(
|
||||
envelope.rpcId,
|
||||
result,
|
||||
{ ...options, signal: options.signal ?? signal },
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
|
||||
const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
|
||||
const interactionId = kind === 'question'
|
||||
? payload.questionRpcId
|
||||
: payload.approvalId;
|
||||
const claimKey = `${kind}:${interactionId}`;
|
||||
if (ownership) {
|
||||
const claim = this.#interactionClaims.get(claimKey);
|
||||
if (claim?.ownership !== ownership) return;
|
||||
this.#interactionClaims.delete(claimKey);
|
||||
}
|
||||
if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
|
||||
const kind = payload.type === 'question/requested' ? 'question' : 'approval';
|
||||
const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
|
||||
const claimKey = `${kind}:${interactionId}`;
|
||||
if (ownership) {
|
||||
const claim = this.#interactionOwner(sessionId, claimKey, kind);
|
||||
if (claim?.ownership !== ownership) return;
|
||||
this.#interactionClaims.set(claimKey, claim);
|
||||
}
|
||||
const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
|
||||
? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
|
||||
: undefined;
|
||||
dispatch(onInteraction, Object.freeze({
|
||||
kind,
|
||||
interactionId,
|
||||
rpcId: envelope.rpcId,
|
||||
sessionId,
|
||||
payload,
|
||||
recovered: ownership
|
||||
? this.#interactionClaims.get(claimKey)?.recovered === true
|
||||
: false,
|
||||
...(toolCall ? { toolCall } : {}),
|
||||
reconnect: close,
|
||||
respond: (result, options = {}) => this.respondInteraction(
|
||||
envelope.rpcId,
|
||||
result,
|
||||
{ ...options, signal: options.signal ?? signal },
|
||||
),
|
||||
}));
|
||||
return;
|
||||
dispatch(onResolved, Object.freeze({
|
||||
kind,
|
||||
interactionId,
|
||||
sessionId,
|
||||
outcome: payload.outcome,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const handleEnvelope = (envelope) => {
|
||||
try {
|
||||
const payload = envelope?.payload;
|
||||
if (envelope?.type !== 'server-request'
|
||||
|| typeof envelope.rpcId !== 'string'
|
||||
|| !payload || typeof payload !== 'object'
|
||||
|| envelope.method !== payload.type) {
|
||||
throw new Error('invalid server-request envelope');
|
||||
}
|
||||
if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
|
||||
const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
|
||||
const interactionId = kind === 'question'
|
||||
? payload.questionRpcId
|
||||
: payload.approvalId;
|
||||
const claimKey = `${kind}:${interactionId}`;
|
||||
if (ownership) {
|
||||
const claim = this.#interactionClaims.get(claimKey);
|
||||
if (claim?.ownership !== ownership) return;
|
||||
this.#interactionClaims.delete(claimKey);
|
||||
}
|
||||
dispatch(onResolved, Object.freeze({
|
||||
kind,
|
||||
interactionId,
|
||||
sessionId,
|
||||
outcome: payload.outcome,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const handleMessage = (event) => {
|
||||
try {
|
||||
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame');
|
||||
const envelope = JSON.parse(event.data);
|
||||
const payload = envelope?.payload;
|
||||
if (envelope?.type !== 'server-request'
|
||||
|| typeof envelope.rpcId !== 'string'
|
||||
|| !payload || typeof payload !== 'object'
|
||||
|| envelope.method !== payload.type) {
|
||||
throw new Error('invalid server-request envelope');
|
||||
}
|
||||
if (payload.sessionId !== sessionId) return;
|
||||
if (!ownershipReady) bufferedEnvelopes.push(envelope);
|
||||
else processEnvelope(envelope);
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
|
||||
}
|
||||
};
|
||||
const handleClose = () => finish(opened ? null : new Error(
|
||||
'Harness interaction WebSocket closed before opening',
|
||||
));
|
||||
const handleError = () => {
|
||||
finish(new Error(opened
|
||||
? 'Harness interaction WebSocket failed'
|
||||
: 'Harness interaction WebSocket failed before opening'));
|
||||
close();
|
||||
};
|
||||
const handleAbort = () => {
|
||||
close();
|
||||
finish();
|
||||
};
|
||||
|
||||
socket.addEventListener('open', handleOpen);
|
||||
socket.addEventListener('message', handleMessage);
|
||||
socket.addEventListener('close', handleClose, { once: true });
|
||||
socket.addEventListener('error', handleError, { once: true });
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
if (signal.aborted) handleAbort();
|
||||
});
|
||||
if (payload.sessionId !== sessionId) return;
|
||||
if (!ownershipReady) bufferedEnvelopes.push(envelope);
|
||||
else processEnvelope(envelope);
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await this.#watchMux({ signal, onOpen: handleOpen, onEnvelope: handleEnvelope });
|
||||
} finally {
|
||||
settled = true;
|
||||
if (ownership?.reconnect === close) ownership.reconnect = null;
|
||||
if (!signal.aborted) await callbackTail;
|
||||
}
|
||||
if (!signal.aborted && callbackFailure) throw callbackFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the global Harness event mux (all sessions) until `signal`
|
||||
* aborts, reconnecting on drop. The Desktop host serves the mux as a
|
||||
* WebSocket downlink; frames are `server-request` envelopes whose payload
|
||||
* is a `session/event` — only those are forwarded. `onReconnect` (when
|
||||
* provided) fires after every (re)connection so callers can compensate
|
||||
* for events missed while offline.
|
||||
* aborts, reconnecting on drop. Both transports deliver the same envelopes;
|
||||
* only session/event payloads are forwarded. onReconnect fires after every
|
||||
* (re)connection so callers can compensate for events missed while offline.
|
||||
*/
|
||||
async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
|
||||
if (typeof onSessionEvent !== 'function') {
|
||||
|
|
@ -1556,14 +1560,33 @@ export class HarnessClient {
|
|||
if (onReconnect !== undefined && typeof onReconnect !== 'function') {
|
||||
throw new TypeError('onReconnect must be a function');
|
||||
}
|
||||
const url = new URL('/api/events.mux', this.#baseUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await this.#watchHarnessEventSocket(url.toString(), {
|
||||
await this.#watchMux({
|
||||
signal,
|
||||
onSessionEvent,
|
||||
onReconnect,
|
||||
onOpen: () => {
|
||||
try {
|
||||
onReconnect?.();
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
|
||||
}
|
||||
},
|
||||
onEnvelope: (envelope) => {
|
||||
try {
|
||||
const payload = envelope?.payload;
|
||||
if (envelope?.type !== 'server-request'
|
||||
|| !payload
|
||||
|| typeof payload !== 'object'
|
||||
|| envelope.method !== payload.type
|
||||
|| payload.type !== 'session/event'
|
||||
|| typeof payload.sessionId !== 'string'
|
||||
|| !payload.event
|
||||
|| typeof payload.event !== 'object') return;
|
||||
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
|
|
@ -1579,81 +1602,16 @@ export class HarnessClient {
|
|||
}
|
||||
}
|
||||
|
||||
#watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let socket;
|
||||
try {
|
||||
socket = this.#createWebSocket(url);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
let opened = false;
|
||||
let finished = false;
|
||||
const close = () => {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
};
|
||||
const finish = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
socket.removeEventListener('open', handleOpen);
|
||||
socket.removeEventListener('message', handleMessage);
|
||||
socket.removeEventListener('close', handleClose);
|
||||
socket.removeEventListener('error', handleError);
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const handleOpen = () => {
|
||||
opened = true;
|
||||
try {
|
||||
onReconnect?.();
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
|
||||
}
|
||||
};
|
||||
const handleMessage = (event) => {
|
||||
try {
|
||||
if (typeof event.data !== 'string') return;
|
||||
const envelope = JSON.parse(event.data);
|
||||
const payload = envelope?.payload;
|
||||
if (envelope?.type !== 'server-request'
|
||||
|| !payload
|
||||
|| typeof payload !== 'object'
|
||||
|| envelope.method !== payload.type
|
||||
|| payload.type !== 'session/event'
|
||||
|| typeof payload.sessionId !== 'string'
|
||||
|| !payload.event
|
||||
|| typeof payload.event !== 'object') return;
|
||||
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
|
||||
} catch (error) {
|
||||
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
|
||||
}
|
||||
};
|
||||
const handleClose = () => finish(opened ? null : new Error(
|
||||
'Harness event mux WebSocket closed before opening',
|
||||
));
|
||||
const handleError = () => {
|
||||
finish(new Error(opened
|
||||
? 'Harness event mux WebSocket failed'
|
||||
: 'Harness event mux WebSocket failed before opening'));
|
||||
close();
|
||||
};
|
||||
const handleAbort = () => {
|
||||
close();
|
||||
finish();
|
||||
};
|
||||
|
||||
socket.addEventListener('open', handleOpen);
|
||||
socket.addEventListener('message', handleMessage);
|
||||
socket.addEventListener('close', handleClose, { once: true });
|
||||
socket.addEventListener('error', handleError, { once: true });
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
if (signal.aborted) handleAbort();
|
||||
#watchMux(options) {
|
||||
return watchHarnessMux({
|
||||
apiProxy: this.#apiProxy,
|
||||
baseUrl: this.#baseUrl,
|
||||
createWebSocket: this.#createWebSocket,
|
||||
rpcId: `${this.#rpcIdPrefix}-${randomUUID()}`,
|
||||
...options,
|
||||
onMalformed: (error) => {
|
||||
console.warn(`[${this.#logPrefix}] ignored a malformed Harness mux frame:`, error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/channels/shared/harness-mux.mjs
Normal file
123
src/channels/shared/harness-mux.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// The client owns callback ordering and reconnects. Hooks run immediately;
|
||||
// rejected hook promises also close this subscription, without adding a queue.
|
||||
function invoke(callback, value, onError) {
|
||||
try {
|
||||
const result = callback?.(value);
|
||||
if (result && typeof result.then === 'function') Promise.resolve(result).catch(onError);
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope }) {
|
||||
const controller = new AbortController();
|
||||
const close = () => controller.abort(signal.reason);
|
||||
let iterator;
|
||||
let pending;
|
||||
let failure;
|
||||
const fail = (error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
failure = error;
|
||||
close();
|
||||
};
|
||||
signal.addEventListener('abort', close, { once: true });
|
||||
try {
|
||||
iterator = apiProxy.events.mux({ rpcId, payload: {} }, controller.signal)[Symbol.asyncIterator]();
|
||||
// mux registers listeners eagerly, but its generator installs abort/finally
|
||||
// cleanup only on the first next(). Start it even if mux itself aborted us.
|
||||
pending = Promise.resolve(iterator.next());
|
||||
if (!controller.signal.aborted) invoke(onOpen, close, fail);
|
||||
while (!controller.signal.aborted) {
|
||||
const { value, done } = await pending;
|
||||
if (done || controller.signal.aborted) break;
|
||||
invoke(onEnvelope, {
|
||||
type: 'server-request',
|
||||
rpcId: value?.rpcId,
|
||||
method: value?.payload?.type,
|
||||
payload: value?.payload,
|
||||
}, fail);
|
||||
if (!controller.signal.aborted) pending = Promise.resolve(iterator.next());
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
const cancelled = controller.signal.aborted;
|
||||
signal.removeEventListener('abort', close);
|
||||
// return() alone cannot wake an idle generator's pending next(). Abort
|
||||
// first, then drain that read and return (discarding any queued replay).
|
||||
close();
|
||||
await pending?.catch(() => undefined);
|
||||
try {
|
||||
await iterator?.return?.();
|
||||
} catch (error) {
|
||||
if (!cancelled) failure ??= error;
|
||||
}
|
||||
}
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
function watchWebSocketMux({ baseUrl, createWebSocket, signal, onOpen, onEnvelope, onMalformed }) {
|
||||
const url = new URL('/api/events.mux', baseUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createWebSocket(url.toString());
|
||||
let opened = false;
|
||||
let finished = false;
|
||||
const finish = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
socket.removeEventListener('open', handleOpen);
|
||||
socket.removeEventListener('message', handleMessage);
|
||||
socket.removeEventListener('close', handleClose);
|
||||
socket.removeEventListener('error', handleError);
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
try {
|
||||
if (socket.readyState === 0 || socket.readyState === 1) socket.close();
|
||||
} catch {
|
||||
// Some implementations reject closing while still connecting.
|
||||
}
|
||||
if (error && !signal.aborted) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const close = () => finish();
|
||||
const handleOpen = () => {
|
||||
if (finished || opened) return;
|
||||
opened = true;
|
||||
invoke(onOpen, close, finish);
|
||||
};
|
||||
const handleMessage = (event) => {
|
||||
if (finished) return;
|
||||
let envelope;
|
||||
try {
|
||||
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame');
|
||||
envelope = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
invoke(onMalformed, error, () => undefined);
|
||||
return;
|
||||
}
|
||||
invoke(onEnvelope, envelope, finish);
|
||||
};
|
||||
const handleClose = () => finish(opened ? undefined : new Error(
|
||||
'Harness event mux WebSocket closed before opening',
|
||||
));
|
||||
const handleError = () => finish(new Error(opened
|
||||
? 'Harness event mux WebSocket failed'
|
||||
: 'Harness event mux WebSocket failed before opening'));
|
||||
const handleAbort = () => finish();
|
||||
socket.addEventListener('open', handleOpen);
|
||||
socket.addEventListener('message', handleMessage);
|
||||
socket.addEventListener('close', handleClose, { once: true });
|
||||
socket.addEventListener('error', handleError, { once: true });
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
if (signal.aborted) handleAbort();
|
||||
});
|
||||
}
|
||||
|
||||
/** Watch one mux subscription; an explicit URL retains the HTTP/WS transport. */
|
||||
export async function watchHarnessMux(options) {
|
||||
if (options.signal.aborted) return;
|
||||
if (options.baseUrl !== undefined && options.baseUrl !== null) {
|
||||
return watchWebSocketMux(options);
|
||||
}
|
||||
return watchInProcessMux(options);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ function controller() {
|
|||
test('Host exports the DingTalk plugin identity and required services', () => {
|
||||
const plugin = createDingtalkHostPlugin({ controller: controller() });
|
||||
assert.equal(name, 'dsh-dingtalk-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer', 'typertGateway']);
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'apiProxy', 'typertGateway']);
|
||||
assert.equal(plugin.name, name);
|
||||
assert.deepEqual(plugin.inject, inject);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
async close() { seen.supervisorClosed = true; },
|
||||
};
|
||||
const credentials = {};
|
||||
const apiProxy = {};
|
||||
const production = await createProductionController({
|
||||
credentials,
|
||||
webServer: { port: 3080 },
|
||||
apiProxy,
|
||||
logger: () => console,
|
||||
}, { dataDir: directory }, {
|
||||
ConfigStore,
|
||||
|
|
@ -56,7 +57,8 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
});
|
||||
|
||||
assert.equal(seen.controllerOptions.credentials, credentials);
|
||||
assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/');
|
||||
assert.equal(seen.harnessOptions.apiProxy, apiProxy);
|
||||
assert.equal(Object.hasOwn(seen.harnessOptions, 'baseUrl'), false);
|
||||
assert.equal(seen.harnessOptions.autostart, false);
|
||||
assert.equal(Object.hasOwn(seen.harnessOptions, 'agentPreset'), false);
|
||||
const runtime = await seen.controllerOptions.createRuntime({
|
||||
|
|
@ -82,7 +84,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
|
|||
|
||||
const productionWithPreset = await createProductionController({
|
||||
credentials,
|
||||
webServer: { port: 3080 },
|
||||
apiProxy,
|
||||
logger: () => console,
|
||||
}, { dataDir: directory, agentPreset: 'router-standard' }, {
|
||||
ConfigStore,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test('Discord production has no per-bot result-file Gate', async (t) => {
|
|||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
const createRuntime = (botId) => controllerOptions.createRuntime({
|
||||
|
|
|
|||
|
|
@ -1124,7 +1124,7 @@ test('DSH credential adapter stores refs off the browser plane and clears them',
|
|||
assert.equal(await store.configured(), false);
|
||||
});
|
||||
|
||||
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
||||
test('production assembly uses ctx credentials and the active Host apiProxy without a webServer', async () => {
|
||||
const constructed = {};
|
||||
const httpInstance = { request: async () => ({}) };
|
||||
const wsAgent = {
|
||||
|
|
@ -1157,9 +1157,10 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
async close() { constructed.closed = true; }
|
||||
}
|
||||
const credentials = {};
|
||||
const apiProxy = {};
|
||||
const production = await createProductionController({
|
||||
credentials,
|
||||
webServer: { port: 43123, host: '127.0.0.1' },
|
||||
apiProxy,
|
||||
logger: console,
|
||||
}, {
|
||||
dshHome: '/tmp/dsh-feishu-host-test',
|
||||
|
|
@ -1192,7 +1193,8 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
assert.equal(constructed.verifyOptions.httpInstance, httpInstance);
|
||||
assert.equal(constructed.wsAgentCreated, 1);
|
||||
assert.equal(constructed.wsProxyUrl, 'http://proxy.test:8080');
|
||||
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
||||
assert.equal(constructed.harness.apiProxy, apiProxy);
|
||||
assert.equal(Object.hasOwn(constructed.harness, 'baseUrl'), false);
|
||||
assert.equal(constructed.harness.autostart, false);
|
||||
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
||||
|
||||
|
|
@ -1291,7 +1293,7 @@ test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting'
|
|||
async set(ref, value) { secrets.set(ref, value); },
|
||||
async unset(ref) { secrets.delete(ref); },
|
||||
},
|
||||
webServer: { port: 43124 },
|
||||
apiProxy: {},
|
||||
logger: console,
|
||||
}, { dataDir, workspace: dataDir }, {
|
||||
lark: { registerApp: async () => ({}) },
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ test('QQ production has no per-bot result-file Gate', async (t) => {
|
|||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
|
|
|
|||
296
test/channels/shared/harness-apiproxy.test.mjs
Normal file
296
test/channels/shared/harness-apiproxy.test.mjs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
HarnessClient,
|
||||
HarnessHealthError,
|
||||
HarnessInteractionError,
|
||||
HarnessRpcError,
|
||||
HarnessTransportError,
|
||||
} from '../../../src/channels/shared/harness-client.mjs';
|
||||
|
||||
function localClient(apiProxy, options = {}) {
|
||||
return new HarnessClient({
|
||||
apiProxy,
|
||||
workspace: '/tmp/dsh-im-test',
|
||||
interactionReconnectDelayMs: 0,
|
||||
fetchImpl: () => assert.fail('in-process calls must not use HTTP'),
|
||||
createWebSocket: () => assert.fail('in-process events must not use WebSocket'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function eventually(predicate) {
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
if (predicate()) return;
|
||||
await delay(5);
|
||||
}
|
||||
assert.fail('condition was not met');
|
||||
}
|
||||
|
||||
// The real Host registers eagerly, then exposes a signal-controlled iterator.
|
||||
// Reopening replays pending questions; since is deliberately not used.
|
||||
function hostFixture() {
|
||||
const streams = new Set();
|
||||
const pending = new Map();
|
||||
const history = [];
|
||||
const prompts = [];
|
||||
const responses = [];
|
||||
let historyReads = 0;
|
||||
let opened = 0;
|
||||
const host = { streams, pending, history, prompts, responses, onPrompt: () => {} };
|
||||
const success = (rpcId, value) => ({ rpcId, result: { ok: true, value } });
|
||||
host.emit = (frame) => { for (const stream of streams) stream.push(frame); };
|
||||
host.append = (event) => {
|
||||
history.push({ event });
|
||||
host.emit({ rpcId: `event-${event.seq}`, payload: { type: 'session/event', sessionId: 'session', event } });
|
||||
};
|
||||
host.question = () => {
|
||||
const frame = {
|
||||
rpcId: 'question-rpc',
|
||||
payload: { type: 'question/requested', sessionId: 'session', questions: [] },
|
||||
};
|
||||
pending.set(frame.rpcId, frame);
|
||||
host.emit(frame);
|
||||
};
|
||||
host.apiProxy = {
|
||||
host: { describe: ({ rpcId }) => success(rpcId, {}) },
|
||||
sessions: {
|
||||
history: ({ rpcId }) => {
|
||||
historyReads += 1;
|
||||
return success(rpcId, { events: [...history] });
|
||||
},
|
||||
prompt: ({ rpcId, payload }) => {
|
||||
assert.equal(streams.size > 0, true, 'subscribe before submitting a prompt');
|
||||
prompts.push({ rpcId, payload });
|
||||
host.onPrompt(rpcId, payload);
|
||||
return success(rpcId, {});
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux(request, signal) {
|
||||
assert.deepEqual(request.payload, {});
|
||||
opened += 1;
|
||||
const queue = [...pending.values()];
|
||||
let wake;
|
||||
let ended = false;
|
||||
const stream = {
|
||||
push(frame) { queue.push(frame); wake?.(); },
|
||||
end() { ended = true; wake?.(); },
|
||||
};
|
||||
streams.add(stream);
|
||||
return (async function* () {
|
||||
const abort = () => stream.end();
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
while (!ended && !signal.aborted) {
|
||||
if (queue.length) yield queue.shift();
|
||||
else await new Promise((resolve) => { wake = resolve; });
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
streams.delete(stream);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
respond(envelope) {
|
||||
responses.push(envelope);
|
||||
pending.delete(envelope.rpcId);
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
Object.defineProperties(host, {
|
||||
historyReads: { get: () => historyReads },
|
||||
opened: { get: () => opened },
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test('in-process RPC preserves IDs, payloads, namespace receivers and errors', async () => {
|
||||
const calls = [];
|
||||
const apiProxy = {};
|
||||
const methods = ['host.describe', 'workspace.list', 'workspace.create', 'session.list',
|
||||
'session.create', 'session.history', 'session.prompt', 'session.cancel',
|
||||
'session.models', 'session.selectModel', 'llm.models'];
|
||||
for (const method of methods) {
|
||||
const [domain, action] = method.split('.');
|
||||
const namespace = domain === 'session' ? 'sessions' : domain;
|
||||
const api = apiProxy[namespace] ??= {};
|
||||
api[action] = function (request, signal) {
|
||||
assert.equal(this, api);
|
||||
assert.ok(signal instanceof AbortSignal);
|
||||
calls.push({ method, request });
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: method } };
|
||||
};
|
||||
}
|
||||
const client = localClient(apiProxy);
|
||||
const payload = { sessionId: 'session', content: [{ type: 'text', text: 'hello' }] };
|
||||
for (const method of methods) {
|
||||
assert.equal(await client.rpc(method, payload, 1000, { rpcId: `fixed-${method}` }), method);
|
||||
}
|
||||
assert.deepEqual(calls, methods.map((method) => ({
|
||||
method, request: { rpcId: `fixed-${method}`, payload },
|
||||
})));
|
||||
|
||||
apiProxy.sessions.prompt = ({ rpcId }) => ({
|
||||
rpcId, result: { ok: false, error: { code: 'queue-full', message: 'queue full' } },
|
||||
});
|
||||
await assert.rejects(client.rpc('session.prompt'), (error) => (
|
||||
error instanceof HarnessRpcError && error.code === 'queue-full'
|
||||
));
|
||||
apiProxy.host.describe = ({ rpcId }) => ({ rpcId, result: { ok: false, error: { code: 'internal' } } });
|
||||
await assert.rejects(client.health(), HarnessHealthError);
|
||||
for (const response of [null, { rpcId: 'wrong' }, { rpcId: 'fixed', result: {} }]) {
|
||||
apiProxy.host.describe = () => response;
|
||||
await assert.rejects(client.rpc('host.describe', {}, 1000, { rpcId: 'fixed' }), (error) => (
|
||||
error instanceof HarnessTransportError && error.code === 'harness-response-invalid'
|
||||
));
|
||||
}
|
||||
await assert.rejects(client.rpc('session.missing'), { code: 'harness-api-not-found' });
|
||||
});
|
||||
|
||||
test('in-process RPC bounds waits without retrying accepted calls or falling back to HTTP', async () => {
|
||||
let calls = 0;
|
||||
const client = localClient({
|
||||
sessions: {
|
||||
prompt: async ({ rpcId }) => {
|
||||
calls += 1;
|
||||
await delay(40); // Deliberately ignores the caller's AbortSignal.
|
||||
return { rpcId, result: { ok: true, value: {} } };
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(client.rpc('session.prompt', {}, 2), { code: 'harness-timeout' });
|
||||
assert.equal(calls, 1);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('caller cancelled');
|
||||
const pending = client.rpc('session.prompt', {}, 1000, { signal: controller.signal });
|
||||
await eventually(() => calls === 2);
|
||||
controller.abort(reason);
|
||||
await assert.rejects(pending, (error) => error === reason);
|
||||
await assert.rejects(client.rpc('session.prompt', {}, 1000, { signal: controller.signal }), (error) => error === reason);
|
||||
await delay(50);
|
||||
assert.equal(calls, 2, 'late success must not trigger another prompt');
|
||||
});
|
||||
|
||||
test('explicit baseUrl still selects the existing HTTP transport', async () => {
|
||||
let requests = 0;
|
||||
const client = localClient({ get host() { assert.fail('must not call local Host'); } }, {
|
||||
baseUrl: 'http://127.0.0.1:1234',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests += 1;
|
||||
assert.equal(url.href, 'http://127.0.0.1:1234/api/host.describe');
|
||||
return { ok: true, json: async () => ({
|
||||
type: 'server-response', rpcId: JSON.parse(options.body).rpcId, result: { ok: true, value: {} },
|
||||
}) };
|
||||
},
|
||||
});
|
||||
assert.equal(await client.health(), true);
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test('in-process interaction responses preserve the full envelope and rejection receipts', async () => {
|
||||
let received;
|
||||
let receipt = { accepted: true };
|
||||
const client = localClient({ respond: (envelope) => { received = envelope; return receipt; } });
|
||||
const result = { ok: true, value: { sessionId: 'session', answer: { answers: [] } } };
|
||||
assert.deepEqual(await client.respondInteraction('question', result), receipt);
|
||||
assert.deepEqual(received, { type: 'client-response', rpcId: 'question', result });
|
||||
for (const reason of ['not-pending', 'bad-response']) {
|
||||
receipt = { accepted: false, reason };
|
||||
await assert.rejects(client.respondInteraction('question', result), (error) => (
|
||||
error instanceof HarnessInteractionError && error.code === `interaction-${reason}`
|
||||
));
|
||||
}
|
||||
receipt = {};
|
||||
await assert.rejects(client.respondInteraction('question', result), /invalid interaction response receipt/);
|
||||
});
|
||||
|
||||
test('ask uses an initially empty in-process mux and correlates replies with its original prompt ID', async () => {
|
||||
const host = hostFixture();
|
||||
host.onPrompt = (rpcId) => {
|
||||
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
|
||||
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
|
||||
host.append({ type: 'assistant/message', seq: 2, data: {
|
||||
turn: 1, step: 1, message: { content: [{ type: 'text', text: 'answer' }] },
|
||||
} });
|
||||
host.append({ type: 'turn/end', seq: 3, data: { turn: 1, reason: { kind: 'completed' } } });
|
||||
};
|
||||
const client = localClient(host.apiProxy, { rpcIdPrefix: 'local-test' });
|
||||
assert.equal(await client.ask('session', 'hello', { onInteraction: () => {}, timeoutMs: 1000 }), 'answer');
|
||||
assert.equal(host.prompts.length, 1);
|
||||
assert.match(host.prompts[0].rpcId, /^local-test-/);
|
||||
assert.equal(host.prompts[0].payload.mode, 'queue');
|
||||
assert.equal(host.streams.size, 0, 'ask completion must dispose its mux subscription');
|
||||
});
|
||||
|
||||
test('clients on one Host share interaction ownership across context wrappers and reconnect safely', async () => {
|
||||
const host = hostFixture();
|
||||
const scope = {};
|
||||
const abort = new AbortController();
|
||||
const received = [[], []];
|
||||
const clients = [0, 1].map(() => localClient({ ...host.apiProxy }, { interactionScope: scope }));
|
||||
host.onPrompt = (rpcId) => {
|
||||
if (host.prompts.length !== 1) return; // Second queued prompt has not started a Turn.
|
||||
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
|
||||
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
|
||||
};
|
||||
const asks = [];
|
||||
try {
|
||||
for (let index = 0; index < clients.length; index += 1) {
|
||||
const ask = clients[index].ask('session', 'hello', {
|
||||
signal: abort.signal,
|
||||
onInteraction: (interaction) => { received[index].push(interaction); },
|
||||
});
|
||||
asks.push(ask.catch(() => {}));
|
||||
await eventually(() => host.prompts.length === index + 1);
|
||||
}
|
||||
host.question();
|
||||
await eventually(() => received[0].length === 1);
|
||||
assert.equal(received[1].length, 0, 'queued client must not claim the active client question');
|
||||
const historyReads = host.historyReads;
|
||||
received[0][0].reconnect();
|
||||
await eventually(() => received[0].length === 2);
|
||||
assert.ok(host.historyReads > historyReads, 'reconnect must refresh ownership history');
|
||||
assert.equal(received[0][1].rpcId, 'question-rpc', 'replay keeps the pending request ID');
|
||||
assert.equal(received[1].length, 0);
|
||||
await received[0][1].respond({ ok: true, value: { sessionId: 'session', answer: { answers: [] } } });
|
||||
assert.equal(host.responses.length, 1);
|
||||
assert.equal(host.responses[0].rpcId, 'question-rpc');
|
||||
} finally {
|
||||
abort.abort();
|
||||
await Promise.all(asks);
|
||||
}
|
||||
assert.equal(host.streams.size, 0);
|
||||
});
|
||||
|
||||
test('different Hosts never share interaction claims, even with identical session/request IDs', async () => {
|
||||
const hosts = [hostFixture(), hostFixture()];
|
||||
const controllers = hosts.map(() => new AbortController());
|
||||
const received = [[], []];
|
||||
const asks = hosts.map((host, index) => {
|
||||
host.onPrompt = (rpcId) => {
|
||||
host.append({ type: 'turn/start', seq: 0, data: { turn: 1 } });
|
||||
host.append({ type: 'user/message', seq: 1, data: { turn: 1, source: { rpcId } } });
|
||||
};
|
||||
return localClient(host.apiProxy).ask('session', 'hello', {
|
||||
signal: controllers[index].signal,
|
||||
onInteraction: (interaction) => { received[index].push(interaction); },
|
||||
}).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await eventually(() => hosts.every((host) => host.prompts.length === 1));
|
||||
hosts.forEach((host) => host.question());
|
||||
await eventually(() => received.every((items) => items.length === 1));
|
||||
controllers[0].abort();
|
||||
await asks[0];
|
||||
assert.equal(hosts[0].streams.size, 0);
|
||||
assert.equal(hosts[1].streams.size, 1, 'closing one Host client must not stop another');
|
||||
} finally {
|
||||
controllers.forEach((controller) => controller.abort());
|
||||
await Promise.all(asks);
|
||||
}
|
||||
assert.ok(hosts.every((host) => host.streams.size === 0));
|
||||
});
|
||||
338
test/channels/shared/harness-mux.test.mjs
Normal file
338
test/channels/shared/harness-mux.test.mjs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import { watchHarnessMux } from '../../../src/channels/shared/harness-mux.mjs';
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const frame = (id = 'frame-one') => ({
|
||||
rpcId: id,
|
||||
payload: { type: 'session/event', sessionId: 'session-one', event: { type: 'turn/end' } },
|
||||
});
|
||||
|
||||
// Mirrors apiProxy's eager registration and lazy FrameQueue generator cleanup.
|
||||
function hostMux({ initial = [], duringMux } = {}) {
|
||||
const subscriptions = new Set();
|
||||
const requests = [];
|
||||
let started = 0;
|
||||
let cleaned = 0;
|
||||
const apiProxy = {
|
||||
events: {
|
||||
mux(request, signal) {
|
||||
requests.push(request);
|
||||
const subscription = { signal, buffer: [...initial], done: false, wake: undefined };
|
||||
subscriptions.add(subscription);
|
||||
duringMux?.();
|
||||
return (async function* () {
|
||||
started++;
|
||||
const onAbort = () => {
|
||||
subscription.done = true;
|
||||
subscription.wake?.();
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
while (true) {
|
||||
while (subscription.buffer.length) {
|
||||
const value = subscription.buffer.shift();
|
||||
if (value instanceof Error) throw value;
|
||||
yield value;
|
||||
}
|
||||
if (subscription.done || signal.aborted) return;
|
||||
await new Promise((resolve) => { subscription.wake = resolve; });
|
||||
subscription.wake = undefined;
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
subscriptions.delete(subscription);
|
||||
cleaned++;
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
apiProxy,
|
||||
requests,
|
||||
subscriptions,
|
||||
get started() { return started; },
|
||||
get cleaned() { return cleaned; },
|
||||
send(value) {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.buffer.push(value);
|
||||
subscription.wake?.();
|
||||
}
|
||||
},
|
||||
end() {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.done = true;
|
||||
subscription.wake?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
readyState = 0;
|
||||
listeners = new Map();
|
||||
closeCount = 0;
|
||||
throwOnClose = false;
|
||||
|
||||
addEventListener(type, callback) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(callback);
|
||||
}
|
||||
|
||||
removeEventListener(type, callback) {
|
||||
this.listeners.get(type)?.delete(callback);
|
||||
}
|
||||
|
||||
emit(type, data) {
|
||||
if (type === 'open') this.readyState = 1;
|
||||
if (type === 'close') this.readyState = 3;
|
||||
for (const callback of [...(this.listeners.get(type) ?? [])]) callback({ data });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCount++;
|
||||
if (this.throwOnClose) throw new Error('cannot close yet');
|
||||
this.emit('close');
|
||||
}
|
||||
|
||||
assertClean() {
|
||||
for (const listeners of this.listeners.values()) assert.equal(listeners.size, 0);
|
||||
}
|
||||
}
|
||||
|
||||
test('in-process mux is ready with no frames and abort cleans an idle read', { timeout: 1000 }, async () => {
|
||||
const host = hostMux();
|
||||
const controller = new AbortController();
|
||||
let opened = false;
|
||||
const task = watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
rpcId: 'mux-one',
|
||||
signal: controller.signal,
|
||||
onOpen() {
|
||||
assert.equal(host.started, 1);
|
||||
assert.equal(host.subscriptions.size, 1);
|
||||
opened = true;
|
||||
},
|
||||
onEnvelope() { assert.fail('empty mux emitted a frame'); },
|
||||
});
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(host.requests, [{ rpcId: 'mux-one', payload: {} }]);
|
||||
controller.abort();
|
||||
await task;
|
||||
assert.equal(host.cleaned, 1);
|
||||
assert.equal(host.subscriptions.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
});
|
||||
|
||||
test('in-process mux normalizes frames without changing request IDs', async () => {
|
||||
const host = hostMux({ initial: [frame()] });
|
||||
const controller = new AbortController();
|
||||
const envelopes = [];
|
||||
const task = watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: controller.signal,
|
||||
rpcId: 'subscription-id',
|
||||
onEnvelope(envelope) { envelopes.push(envelope); },
|
||||
});
|
||||
await tick();
|
||||
host.end();
|
||||
await task;
|
||||
assert.deepEqual(envelopes, [{
|
||||
type: 'server-request', rpcId: 'frame-one', method: 'session/event', payload: frame().payload,
|
||||
}]);
|
||||
assert.equal(host.cleaned, 1);
|
||||
});
|
||||
|
||||
for (const initial of [[], [frame(), frame('frame-two')]]) {
|
||||
test(`closing in onOpen cleans ${initial.length ? 'queued' : 'empty'} in-process mux`, { timeout: 1000 }, async () => {
|
||||
const host = hostMux({ initial });
|
||||
const controller = new AbortController();
|
||||
await watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: controller.signal,
|
||||
onOpen(close) { close(); },
|
||||
onEnvelope() { assert.fail('forwarded after close'); },
|
||||
});
|
||||
assert.equal(host.started, 1);
|
||||
assert.equal(host.cleaned, 1);
|
||||
assert.equal(host.subscriptions.size, 0);
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('pre-aborted watchers never create either transport', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const host = hostMux();
|
||||
for (const baseUrl of [undefined, 'http://127.0.0.1:3080']) {
|
||||
await watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
baseUrl,
|
||||
signal: controller.signal,
|
||||
createWebSocket() { assert.fail('opened an aborted transport'); },
|
||||
onOpen() { assert.fail('reported aborted transport ready'); },
|
||||
});
|
||||
}
|
||||
assert.equal(host.requests.length, 0);
|
||||
});
|
||||
|
||||
test('abort during eager mux creation still starts the generator to release its listeners', { timeout: 1000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
const host = hostMux({ initial: [frame()], duringMux: () => controller.abort() });
|
||||
await watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: controller.signal,
|
||||
onOpen() { assert.fail('reported readiness after cancellation'); },
|
||||
onEnvelope() { assert.fail('forwarded after cancellation'); },
|
||||
});
|
||||
assert.equal(host.started, 1);
|
||||
assert.equal(host.cleaned, 1);
|
||||
assert.equal(host.subscriptions.size, 0);
|
||||
});
|
||||
|
||||
test('closing one in-process mux does not cancel another subscription on the same host', async () => {
|
||||
const host = hostMux();
|
||||
const controller = new AbortController();
|
||||
let closeFirst;
|
||||
const first = [];
|
||||
const second = [];
|
||||
const firstTask = watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: controller.signal,
|
||||
onOpen(close) { closeFirst = close; },
|
||||
onEnvelope(envelope) { first.push(envelope); },
|
||||
});
|
||||
const secondTask = watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: controller.signal,
|
||||
onEnvelope(envelope) { second.push(envelope); },
|
||||
});
|
||||
closeFirst();
|
||||
await firstTask;
|
||||
assert.equal(host.subscriptions.size, 1);
|
||||
host.send(frame());
|
||||
await tick();
|
||||
controller.abort();
|
||||
await secondTask;
|
||||
assert.equal(first.length, 0);
|
||||
assert.equal(second.length, 1);
|
||||
assert.equal(host.cleaned, 2);
|
||||
});
|
||||
|
||||
test('in-process read failures reject and clean up their subscriptions', async () => {
|
||||
const error = new Error('mux read failed');
|
||||
const host = hostMux({ initial: [error] });
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(watchHarnessMux({ apiProxy: host.apiProxy, signal: controller.signal }), error);
|
||||
assert.equal(host.cleaned, 1);
|
||||
assert.equal(host.subscriptions.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
});
|
||||
|
||||
for (const hook of ['onOpen', 'onEnvelope']) {
|
||||
for (const asynchronous of [false, true]) {
|
||||
test(`${hook} ${asynchronous ? 'async' : 'sync'} failure closes the in-process stream`, { timeout: 1000 }, async () => {
|
||||
const error = new Error('callback failed');
|
||||
const host = hostMux({ initial: [frame()] });
|
||||
await assert.rejects(watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
signal: new AbortController().signal,
|
||||
[hook]: asynchronous ? async () => { throw error; } : () => { throw error; },
|
||||
}), error);
|
||||
assert.equal(host.cleaned, 1);
|
||||
assert.equal(host.subscriptions.size, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('explicit URL uses WebSocket even when apiProxy is available, isolating malformed messages', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const host = hostMux();
|
||||
const controller = new AbortController();
|
||||
const received = [];
|
||||
const malformed = [];
|
||||
let close;
|
||||
const task = watchHarnessMux({
|
||||
apiProxy: host.apiProxy,
|
||||
baseUrl: 'https://harness.example:8443/ignored',
|
||||
signal: controller.signal,
|
||||
createWebSocket(url) {
|
||||
assert.equal(url, 'wss://harness.example:8443/api/events.mux');
|
||||
return socket;
|
||||
},
|
||||
onOpen(value) { close = value; },
|
||||
onEnvelope(value) { received.push(value); },
|
||||
onMalformed(error) { malformed.push(error); },
|
||||
});
|
||||
assert.equal(close, undefined);
|
||||
socket.emit('open');
|
||||
socket.emit('message', '{');
|
||||
socket.emit('message', new Uint8Array([1, 2]));
|
||||
const envelope = { type: 'server-request', method: 'session/event', ...frame() };
|
||||
socket.emit('message', JSON.stringify(envelope));
|
||||
close();
|
||||
socket.emit('message', JSON.stringify(envelope));
|
||||
await task;
|
||||
assert.deepEqual(received, [envelope]);
|
||||
assert.equal(malformed.length, 2);
|
||||
assert.equal(host.requests.length, 0);
|
||||
assert.equal(socket.closeCount, 1);
|
||||
socket.assertClean();
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
});
|
||||
|
||||
test('WebSocket abort settles even if close throws during connection', async () => {
|
||||
const socket = new FakeSocket();
|
||||
socket.throwOnClose = true;
|
||||
const controller = new AbortController();
|
||||
const task = watchHarnessMux({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
signal: controller.signal,
|
||||
createWebSocket: () => socket,
|
||||
onOpen() { assert.fail('opened cancelled socket'); },
|
||||
});
|
||||
controller.abort();
|
||||
await task;
|
||||
socket.emit('open');
|
||||
socket.assertClean();
|
||||
assert.equal(socket.closeCount, 1);
|
||||
});
|
||||
|
||||
for (const opened of [false, true]) {
|
||||
for (const event of ['close', 'error']) {
|
||||
test(`WebSocket ${event} ${opened ? 'after' : 'before'} opening has expected settlement and cleanup`, async () => {
|
||||
const socket = new FakeSocket();
|
||||
const controller = new AbortController();
|
||||
const task = watchHarnessMux({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
signal: controller.signal,
|
||||
createWebSocket: () => socket,
|
||||
});
|
||||
if (opened) socket.emit('open');
|
||||
socket.emit(event);
|
||||
if (opened && event === 'close') await task;
|
||||
else await assert.rejects(task, opened ? /WebSocket failed/ : /before opening/);
|
||||
socket.assertClean();
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('WebSocket callback failures reject and close the connection', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const error = new Error('callback failed');
|
||||
const task = watchHarnessMux({
|
||||
baseUrl: 'http://127.0.0.1:3080',
|
||||
signal: new AbortController().signal,
|
||||
createWebSocket: () => socket,
|
||||
async onEnvelope() { throw error; },
|
||||
});
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify(frame()));
|
||||
await assert.rejects(task, error);
|
||||
socket.assertClean();
|
||||
assert.equal(socket.closeCount, 1);
|
||||
});
|
||||
|
|
@ -37,7 +37,7 @@ test('token-channel production has no per-bot result-file Gate', async (t) => {
|
|||
|
||||
const production = await createTokenProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test('Slack production has no per-bot result-file Gate', async (t) => {
|
|||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ test('Telegram production has no per-bot result-file Gate', async (t) => {
|
|||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ test('Enterprise WeChat production has no per-bot result-file Gate', async (t) =
|
|||
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir }, {
|
||||
ConfigStore,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ test('Weixin production has no per-bot result-file Gate', async (t) => {
|
|||
};
|
||||
const ctx = {
|
||||
credentials: {},
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
|
|||
createConnectionSupervisor,
|
||||
};
|
||||
const ctx = {
|
||||
webServer: { port: 3080 },
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
};
|
||||
|
||||
|
|
|
|||
129
test/host-harness-connection.test.mjs
Normal file
129
test/host-harness-connection.test.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { harnessConnection } from '../plugin-src/host/harness-connection.mjs';
|
||||
import { inject as hostInject } from '../plugin-src/host/index.mjs';
|
||||
|
||||
const IM_CHANNELS = [
|
||||
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
];
|
||||
|
||||
test('Host connections share the current Cordis root without depending on a webServer', () => {
|
||||
const root = {};
|
||||
const apiProxy = {};
|
||||
const first = harnessConnection({ root, apiProxy });
|
||||
const second = harnessConnection({ root, apiProxy });
|
||||
assert.deepEqual(first, { apiProxy, interactionScope: root });
|
||||
assert.equal(first.interactionScope, second.interactionScope);
|
||||
assert.notEqual(first.interactionScope, harnessConnection({ root: {}, apiProxy }).interactionScope);
|
||||
|
||||
const fixtureContext = { apiProxy };
|
||||
assert.equal(harnessConnection(fixtureContext).interactionScope, fixtureContext);
|
||||
});
|
||||
|
||||
test('an explicit Harness URL preserves HTTP transport and never reads the Host apiProxy', () => {
|
||||
const ctx = { get apiProxy() { throw new Error('must not read local apiProxy'); } };
|
||||
const connection = harnessConnection(ctx, { harnessBaseUrl: 'https://harness.example/base/' });
|
||||
assert.equal(connection.baseUrl.href, 'https://harness.example/base/');
|
||||
assert.deepEqual(Object.keys(connection), ['baseUrl']);
|
||||
assert.throws(() => harnessConnection(ctx, { harnessBaseUrl: 'not a URL' }), TypeError);
|
||||
});
|
||||
|
||||
test('a missing Host apiProxy fails clearly instead of silently falling back to localhost', () => {
|
||||
assert.throws(
|
||||
() => harnessConnection({ webServer: { port: 3080 } }),
|
||||
/requires the Host apiProxy service/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Host and all IM channel plugins wait for apiProxy rather than a webServer', async () => {
|
||||
assert.ok(hostInject.includes('apiProxy'));
|
||||
assert.equal(hostInject.includes('webServer'), false);
|
||||
for (const channel of IM_CHANNELS) {
|
||||
const { inject } = await import(`../plugin-src/host/channels/${channel}/index.mjs`);
|
||||
assert.ok(inject.includes('apiProxy'), channel);
|
||||
assert.equal(inject.includes('webServer'), false, channel);
|
||||
}
|
||||
});
|
||||
|
||||
async function assembledHarness(channel, ctx, config = {}) {
|
||||
const { createProductionController } = await import(
|
||||
`../plugin-src/host/channels/${channel}/production.mjs`
|
||||
);
|
||||
const constructed = {};
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return []; }
|
||||
}
|
||||
class Harness {
|
||||
constructor(options) { constructed.harness = options; }
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { constructed.controller = options; }
|
||||
async initialize() {}
|
||||
async close() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { constructed.runtime = options; }
|
||||
}
|
||||
const production = await createProductionController(ctx, {
|
||||
workspace: '/test/workspace',
|
||||
...config,
|
||||
}, {
|
||||
ConfigStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
FeishuRuntime: Runtime,
|
||||
api: {},
|
||||
deviceAuth: {},
|
||||
qrAuth: {},
|
||||
lark: {},
|
||||
proxyEnv: {},
|
||||
workspaces: {
|
||||
async reconcile() {},
|
||||
async ensure() {},
|
||||
decorateStatus(value) { return value; },
|
||||
},
|
||||
createConnectionSupervisor: () => ({
|
||||
ready: Promise.resolve(),
|
||||
start() { return this; },
|
||||
async close() {},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
if (channel === 'office') {
|
||||
constructed.controller.createRuntime({});
|
||||
constructed.runtime.createHarness({ workspace: '/test/workspace' });
|
||||
}
|
||||
return constructed.harness;
|
||||
} finally {
|
||||
await production.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const channel of [...IM_CHANNELS, 'office']) {
|
||||
test(`${channel} production uses its Host apiProxy with no webServer or listening port`, async () => {
|
||||
const apiProxy = {};
|
||||
const root = {};
|
||||
const options = await assembledHarness(channel, { credentials: {}, apiProxy, root });
|
||||
assert.equal(options.apiProxy, apiProxy);
|
||||
assert.equal(options.interactionScope, root);
|
||||
assert.equal(Object.hasOwn(options, 'baseUrl'), false);
|
||||
assert.equal(options.workspace, '/test/workspace');
|
||||
assert.equal(options.autostart, false);
|
||||
});
|
||||
|
||||
test(`${channel} production preserves an explicitly configured Harness URL`, async () => {
|
||||
const options = await assembledHarness(channel, {
|
||||
credentials: {},
|
||||
get apiProxy() { throw new Error('explicit URL must not use local apiProxy'); },
|
||||
}, { harnessBaseUrl: 'http://127.0.0.1:43210/custom/' });
|
||||
assert.equal(options.baseUrl.href, 'http://127.0.0.1:43210/custom/');
|
||||
assert.equal(Object.hasOwn(options, 'apiProxy'), false);
|
||||
assert.equal(Object.hasOwn(options, 'interactionScope'), false);
|
||||
assert.equal(options.autostart, false);
|
||||
});
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ test('Host composes nine IM channels and the AI Office connector inside one plug
|
|||
assert.deepEqual(inject, [
|
||||
'connection',
|
||||
'credentials',
|
||||
'webServer',
|
||||
'apiProxy',
|
||||
'typertGateway',
|
||||
]);
|
||||
assert.deepEqual(calls, [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue