feat(feishu): persistent session watches and completion pushes

- /watch resolves the target READ-ONLY: sessions are validated against
  registered workspace listings (current or any other) without binding the
  conversation or switching workspaces. /unwatch and /watchlist round out
  the command set; the watchlist card supports button and number-reply
  unwatching.
- Watches persist in the state store (sessionId + title + chatId + lastSeq)
  and resume at runtime start: the bridge starts the global event-mux
  watcher in its constructor when the harness supports it.
- Completion pushes fire on turn/end, deduped by sessionId + turn id, with
  the seq watermark persisted per watch. After a mux reconnect the bridge
  replays each watched session's recent history (session.history) through
  the normal handler, so missed turn/end events are compensated without
  duplicates.
- Bound-session push targets are persisted (chatTargets) instead of living
  only in memory, and refresh on every accepted message.
- Watch failures map to safe user-facing messages.
This commit is contained in:
liuwenbo00 2026-08-21 10:10:36 +08:00 • committed by xmanrui
parent 0579c24b13
commit beb269dc0e
6 changed files with 746 additions and 132 deletions

View file

@ -1223,6 +1223,85 @@ export class HarnessClient {
});
}
/**
* 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.
*/
watchHarnessEvents({ signal, onSessionEvent, onReconnect }) {
if (typeof onSessionEvent !== 'function') {
return Promise.reject(new TypeError('watchHarnessEvents requires onSessionEvent'));
}
const url = new URL('/api/events.mux', this.#baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
let stopped = false;
const connect = () => {
if (stopped || signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const socket = this.#createWebSocket(url.toString());
const settle = () => {
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
signal.removeEventListener('abort', handleAbort);
resolve();
};
const handleOpen = () => {
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') return;
if (payload.type !== 'session/event') return;
if (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 = () => {
settle();
if (!stopped && !signal.aborted) {
setTimeout(() => { void connect(); }, 2000);
}
};
const handleError = () => {
try {
socket.close();
} catch {
// The close event drives reconnection; nothing to do here.
}
};
const handleAbort = () => {
stopped = true;
try {
socket.close();
} catch {
// Already closed.
}
};
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();
});
};
return connect();
}
stopManagedProcess() {
if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
}