mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(google-meet): keep CLI sessions gateway-owned
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user