From 7b5a18ae7a7825effa141117487144bca7081d08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 17:22:59 +0100 Subject: [PATCH] fix(google-meet): keep CLI sessions gateway-owned --- CHANGELOG.md | 1 + extensions/google-meet/src/cli.test.ts | 110 ++++++++++++ extensions/google-meet/src/cli.ts | 233 +++++++++++++++++++++++-- 3 files changed, 327 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccc0fdb418..0882323823c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq. - Memory/status: keep plain `openclaw memory status` and `openclaw memory status --json` on the cheap read-only path by reserving vector and embedding provider probes for `--deep` or `--index`. Fixes #76769. Thanks @daruire. - Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc. - Gateway/watch: run `doctor --fix --non-interactive` once and retry when the dev Gateway child exits during startup, so stale local plugin install/config state does not leave the tmux watch session disappearing without a repair attempt. diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index efbdf10391b..e8b008aa0e4 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -194,6 +194,7 @@ function setupCli(params: { config?: Parameters[0]; runtime?: Partial; ensureRuntime?: () => Promise; + callGatewayFromCli?: Parameters[0]["callGatewayFromCli"]; }) { const program = new Command(); registerGoogleMeetCli({ @@ -201,6 +202,11 @@ function setupCli(params: { config: resolveGoogleMeetConfig(params.config ?? {}), ensureRuntime: params.ensureRuntime ?? (async () => (params.runtime ?? {}) as unknown as GoogleMeetRuntime), + callGatewayFromCli: + params.callGatewayFromCli ?? + (vi.fn(async () => { + throw new Error("connect ECONNREFUSED 127.0.0.1:18789"); + }) as NonNullable[0]["callGatewayFromCli"]>), }); return program; } @@ -689,6 +695,110 @@ describe("google-meet CLI", () => { } }); + it("delegates session status to the gateway-owned runtime when available", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + found: true, + sessions: [ + { + id: "meet_gateway", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "realtime", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + ], + })); + const ensureRuntime = vi.fn(async () => { + throw new Error("local runtime should not be loaded"); + }); + const stdout = captureStdout(); + try { + await setupCli({ + callGatewayFromCli, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.status", + { json: true, timeout: "5000" }, + { sessionId: undefined }, + { progress: false }, + ); + expect(ensureRuntime).not.toHaveBeenCalled(); + expect(JSON.parse(stdout.output())).toMatchObject({ + found: true, + sessions: [{ id: "meet_gateway", transport: "chrome-node" }], + }); + } finally { + stdout.restore(); + } + }); + + it("delegates join to the gateway-owned runtime when available", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + session: { + id: "meet_gateway", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "realtime", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + })); + const ensureRuntime = vi.fn(async () => { + throw new Error("local runtime should not be loaded"); + }); + const stdout = captureStdout(); + try { + await setupCli({ + callGatewayFromCli, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync( + [ + "googlemeet", + "join", + "https://meet.google.com/abc-defg-hij", + "--transport", + "chrome-node", + "--mode", + "realtime", + "--message", + "Hello meeting", + ], + { from: "user" }, + ); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.join", + { json: true, timeout: expect.any(String) }, + { + url: "https://meet.google.com/abc-defg-hij", + transport: "chrome-node", + mode: "realtime", + message: "Hello meeting", + dialInNumber: undefined, + pin: undefined, + dtmfSequence: undefined, + }, + { progress: false }, + ); + expect(ensureRuntime).not.toHaveBeenCalled(); + expect(JSON.parse(stdout.output())).toMatchObject({ + id: "meet_gateway", + transport: "chrome-node", + }); + } finally { + stdout.restore(); + } + }); + it("runs a listen-first health probe", async () => { const testListen = vi.fn(async () => ({ createdSession: true, diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 408b7b39c63..dde9d769653 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { format } from "node:util"; import type { Command } from "commander"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; import { buildGoogleMeetCalendarDayWindow, findGoogleMeetCalendarEvent, @@ -136,6 +138,19 @@ type SetupOptions = { transport?: GoogleMeetTransport; }; +type GoogleMeetGatewayMethod = + | "googlemeet.create" + | "googlemeet.join" + | "googlemeet.leave" + | "googlemeet.speak" + | "googlemeet.status" + | "googlemeet.testListen" + | "googlemeet.testSpeech"; + +type GoogleMeetGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown }; + +const GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS = 5000; + type DoctorOptions = { json?: boolean; oauth?: boolean; @@ -178,6 +193,21 @@ function writeStdoutJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } +function isGatewayUnavailableForLocalFallback( + err: unknown, + method: GoogleMeetGatewayMethod, +): boolean { + const message = formatErrorMessage(err); + return ( + message.includes("ECONNREFUSED") || + message.includes("ECONNRESET") || + message.includes("EHOSTUNREACH") || + message.includes("ENOTFOUND") || + message.includes("gateway not connected") || + message.includes(`unknown method: ${method}`) + ); +} + function writeStdoutLine(...values: unknown[]): void { process.stdout.write(`${format(...values)}\n`); } @@ -240,6 +270,42 @@ function parsePositiveNumber(value: string | undefined, label: string): number | return parsed; } +async function callGoogleMeetGateway(params: { + callGateway: typeof callGatewayFromCli; + method: GoogleMeetGatewayMethod; + payload?: Record; + timeoutMs?: number; +}): Promise { + try { + const timeoutMs = + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(1, Math.ceil(params.timeoutMs)) + : GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS; + return { + ok: true, + payload: await params.callGateway( + params.method, + { json: true, timeout: String(timeoutMs) }, + params.payload, + { progress: false }, + ), + }; + } catch (err) { + if (isGatewayUnavailableForLocalFallback(err, params.method)) { + return { ok: false, error: err }; + } + throw err; + } +} + +function resolveGoogleMeetGatewayOperationTimeoutMs(config: GoogleMeetConfig): number { + return Math.max( + 60_000, + config.chrome.joinTimeoutMs + 30_000, + config.voiceCall.requestTimeoutMs + 10_000, + ); +} + function formatDuration(value: number | undefined): string { if (value === undefined) { return "n/a"; @@ -1308,7 +1374,10 @@ export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; ensureRuntime: () => Promise; + callGatewayFromCli?: typeof callGatewayFromCli; }) { + const callGateway = params.callGatewayFromCli ?? callGatewayFromCli; + const operationTimeoutMs = resolveGoogleMeetGatewayOperationTimeoutMs(params.config); const root = params.program .command("googlemeet") .description("Google Meet participant utilities") @@ -1403,6 +1472,51 @@ export function registerGoogleMeetCli(params: { .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") .option("--json", "Print JSON output", false) .action(async (options: CreateOptions) => { + if (options.join !== false) { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.create", + payload: { ...options }, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + const payload = delegated.payload as { + browser?: { nodeId?: string }; + joined?: boolean; + join?: { session?: { id?: string } }; + meetingUri?: string; + source?: string; + space?: { name?: string; meetingCode?: string }; + tokenSource?: string; + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeStdoutLine("meeting uri: %s", payload.meetingUri); + if (payload.space?.name) { + writeStdoutLine("space: %s", payload.space.name); + } + if (payload.space?.meetingCode) { + writeStdoutLine("meeting code: %s", payload.space.meetingCode); + } + if (payload.source) { + writeStdoutLine("source: %s", payload.source); + } + if (payload.browser?.nodeId) { + writeStdoutLine("node: %s", payload.browser.nodeId); + } + if (payload.tokenSource) { + writeStdoutLine("token source: %s", payload.tokenSource); + } + if (payload.joined && payload.join?.session?.id) { + writeStdoutLine("joined: %s", payload.join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", payload.meetingUri); + } + return; + } + } if (!hasCreateOAuth(params.config, options)) { if (hasCreateSpaceConfigInput(options as Record)) { throw new Error( @@ -1541,8 +1655,7 @@ export function registerGoogleMeetCli(params: { .option("--pin ", "Meet phone PIN; # is appended if omitted") .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") .action(async (url: string | undefined, options: JoinOptions) => { - const rt = await params.ensureRuntime(); - const result = await rt.join({ + const payload = { url: resolveMeetingInput(params.config, url), transport: options.transport, mode: options.mode, @@ -1550,7 +1663,20 @@ export function registerGoogleMeetCli(params: { dialInNumber: options.dialInNumber, pin: options.pin, dtmfSequence: options.dtmfSequence, + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.join", + payload, + timeoutMs: operationTimeoutMs, }); + if (delegated.ok) { + const result = delegated.payload as { session?: unknown }; + writeStdoutJson(result.session ?? delegated.payload); + return; + } + const rt = await params.ensureRuntime(); + const result = await rt.join(payload); writeStdoutJson(result.session); }); @@ -1568,15 +1694,24 @@ export function registerGoogleMeetCli(params: { "Say exactly: Google Meet speech test complete.", ) .action(async (url: string | undefined, options: JoinOptions) => { + const payload = { + url: resolveMeetingInput(params.config, url), + transport: options.transport, + mode: options.mode, + message: options.message, + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.testSpeech", + payload, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } const rt = await params.ensureRuntime(); - writeStdoutJson( - await rt.testSpeech({ - url: resolveMeetingInput(params.config, url), - transport: options.transport, - mode: options.mode, - message: options.message, - }), - ); + writeStdoutJson(await rt.testSpeech(payload)); }); root @@ -1585,14 +1720,23 @@ export function registerGoogleMeetCli(params: { .option("--transport ", "Transport: chrome or chrome-node") .option("--timeout-ms ", "How long to wait for fresh captions/transcript movement") .action(async (url: string | undefined, options: JoinOptions) => { + const payload = { + url: resolveMeetingInput(params.config, url), + transport: options.transport, + timeoutMs: parsePositiveNumber(options.timeoutMs, "timeout-ms"), + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.testListen", + payload, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } const rt = await params.ensureRuntime(); - writeStdoutJson( - await rt.testListen({ - url: resolveMeetingInput(params.config, url), - transport: options.transport, - timeoutMs: parsePositiveNumber(options.timeoutMs, "timeout-ms"), - }), - ); + writeStdoutJson(await rt.testListen(payload)); }); root @@ -2035,6 +2179,15 @@ export function registerGoogleMeetCli(params: { .argument("[session-id]", "Meet session ID") .option("--json", "Print JSON output", false) .action(async (sessionId?: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.status", + payload: { sessionId }, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } const rt = await params.ensureRuntime(); writeStdoutJson(await rt.status(sessionId)); }); @@ -2062,6 +2215,20 @@ export function registerGoogleMeetCli(params: { writeOAuthDoctorReport(report); return; } + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.status", + payload: { sessionId }, + }); + if (delegated.ok) { + const status = delegated.payload as Awaited>; + if (options.json) { + writeStdoutJson(status); + return; + } + writeDoctorStatus(status); + return; + } const rt = await params.ensureRuntime(); const status = await rt.status(sessionId); if (options.json) { @@ -2107,6 +2274,19 @@ export function registerGoogleMeetCli(params: { .command("leave") .argument("", "Meet session ID") .action(async (sessionId: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.leave", + payload: { sessionId }, + }); + if (delegated.ok) { + const result = delegated.payload as { found?: boolean }; + if (!result.found) { + throw new Error("session not found"); + } + writeStdoutLine("left %s", sessionId); + return; + } const rt = await params.ensureRuntime(); const result = await rt.leave(sessionId); if (!result.found) { @@ -2120,6 +2300,25 @@ export function registerGoogleMeetCli(params: { .argument("", "Meet session ID") .argument("[message]", "Realtime instructions to speak now") .action(async (sessionId: string, message?: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.speak", + payload: { sessionId, message }, + }); + if (delegated.ok) { + const result = delegated.payload as Awaited>; + if (!result.found) { + throw new Error("session not found"); + } + if (!result.spoken) { + throw new Error( + result.session?.chrome?.health?.speechBlockedMessage ?? + "session has no active realtime audio bridge", + ); + } + writeStdoutLine("speaking on %s", sessionId); + return; + } const rt = await params.ensureRuntime(); const result = await rt.speak(sessionId, message); if (!result.found) {