fix(google-meet): keep CLI sessions gateway-owned

This commit is contained in:
Peter Steinberger
2026-05-03 17:22:59 +01:00
parent e267e3afa0
commit 7b5a18ae7a
3 changed files with 327 additions and 17 deletions

View File

@@ -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.

View File

@@ -194,6 +194,7 @@ function setupCli(params: {
config?: Parameters<typeof resolveGoogleMeetConfig>[0];
runtime?: Partial<GoogleMeetRuntime>;
ensureRuntime?: () => Promise<GoogleMeetRuntime>;
callGatewayFromCli?: Parameters<typeof registerGoogleMeetCli>[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<Parameters<typeof registerGoogleMeetCli>[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<GoogleMeetRuntime>,
}).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<GoogleMeetRuntime>,
}).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,

View File

@@ -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<string, unknown>;
timeoutMs?: number;
}): Promise<GoogleMeetGatewayCallResult> {
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<GoogleMeetRuntime>;
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 <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<string, unknown>)) {
throw new Error(
@@ -1541,8 +1655,7 @@ export function registerGoogleMeetCli(params: {
.option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
.option("--dtmf-sequence <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>", "Transport: chrome or chrome-node")
.option("--timeout-ms <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<ReturnType<GoogleMeetRuntime["status"]>>;
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("<session-id>", "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("<session-id>", "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<ReturnType<GoogleMeetRuntime["speak"]>>;
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) {