mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 15:43:13 +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
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue