mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
964c8c84c1
* refactor: consolidate coercion ownership Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries. The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries. * fix: guard integer option ownership Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation. * fix: keep integer helpers on numeric facade Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract. * fix: point numeric coercion to number runtime Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
595 lines
20 KiB
TypeScript
595 lines
20 KiB
TypeScript
// Voice Call plugin entrypoint registers its OpenClaw integration.
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
|
import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
|
|
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
import {
|
|
asNonArrayRecord as asParamRecord,
|
|
asOptionalRecord,
|
|
normalizeOptionalString,
|
|
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
|
|
import { Type } from "typebox";
|
|
import {
|
|
definePluginEntry,
|
|
type GatewayRequestHandlerOptions,
|
|
type OpenClawPluginApi,
|
|
} from "./api.js";
|
|
import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
|
|
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
|
|
import { registerVoiceCallCli } from "./src/cli.js";
|
|
import {
|
|
createVoiceCallCommandService,
|
|
VoiceCallCommandInputError,
|
|
} from "./src/command-service.js";
|
|
import {
|
|
VoiceCallConfigSchema,
|
|
resolveVoiceCallConfig,
|
|
validateProviderConfig,
|
|
type VoiceCallConfig,
|
|
} from "./src/config.js";
|
|
import type { CoreConfig } from "./src/core-bridge.js";
|
|
import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js";
|
|
|
|
const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" as const };
|
|
const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const };
|
|
|
|
const voiceCallConfigSchema = {
|
|
parse(value: unknown): VoiceCallConfig {
|
|
const config = asOptionalRecord(value) ?? {};
|
|
const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
|
|
return VoiceCallConfigSchema.parse({
|
|
...config,
|
|
enabled,
|
|
provider: config.provider ?? (enabled ? "mock" : undefined),
|
|
});
|
|
},
|
|
};
|
|
|
|
const VoiceCallToolSchema = Type.Union([
|
|
Type.Object({
|
|
action: Type.Literal("initiate_call"),
|
|
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
message: Type.String({ description: "Intro message" }),
|
|
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
|
|
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
|
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("continue_call"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
message: Type.String({ description: "Follow-up message" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("speak_to_user"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
message: Type.String({ description: "Message to speak" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("send_dtmf"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
digits: Type.String({ description: "DTMF digits to send" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("end_call"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("get_status"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
}),
|
|
Type.Object({
|
|
mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
|
|
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
|
message: Type.Optional(Type.String({ description: "Optional intro message" })),
|
|
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
|
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
|
|
}),
|
|
]);
|
|
|
|
function isCliOnlyProcess(): boolean {
|
|
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
|
|
}
|
|
|
|
const VOICE_CALL_RUNTIME_COORDINATOR_KEY = Symbol.for("openclaw.voice-call.runtimeCoordinator");
|
|
|
|
type VoiceCallRuntimeGeneration = {
|
|
epoch: number;
|
|
retired: boolean;
|
|
stopPromise?: Promise<void>;
|
|
};
|
|
|
|
type VoiceCallRuntimeSlot =
|
|
| {
|
|
state: "starting";
|
|
owner: VoiceCallRuntimeGeneration;
|
|
promise: Promise<VoiceCallRuntime>;
|
|
}
|
|
| {
|
|
state: "running";
|
|
owner: VoiceCallRuntimeGeneration;
|
|
runtime: VoiceCallRuntime;
|
|
}
|
|
| {
|
|
state: "stopping";
|
|
owner: VoiceCallRuntimeGeneration;
|
|
promise: Promise<void>;
|
|
};
|
|
|
|
type VoiceCallRuntimeCoordinator = {
|
|
current?: VoiceCallRuntimeGeneration;
|
|
// Activation advances this fence; construction alone stays rollback-safe.
|
|
epochCounter: number;
|
|
registeredEpoch: number;
|
|
slot?: VoiceCallRuntimeSlot;
|
|
};
|
|
|
|
class VoiceCallRuntimeLifecycleError extends Error {}
|
|
|
|
function getVoiceCallRuntimeCoordinator(): VoiceCallRuntimeCoordinator {
|
|
return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({
|
|
epochCounter: 0,
|
|
registeredEpoch: 0,
|
|
}));
|
|
}
|
|
|
|
function activateVoiceCallRuntimeGeneration(
|
|
coordinator: VoiceCallRuntimeCoordinator,
|
|
generation: VoiceCallRuntimeGeneration,
|
|
): void {
|
|
if (generation.epoch < coordinator.registeredEpoch) {
|
|
throw new VoiceCallRuntimeLifecycleError(
|
|
"Voice call runtime generation was superseded; use the current plugin registration",
|
|
);
|
|
}
|
|
if (generation.retired) {
|
|
throw new VoiceCallRuntimeLifecycleError(
|
|
"Voice call runtime generation is retired; use the current plugin registration",
|
|
);
|
|
}
|
|
if (coordinator.current !== generation) {
|
|
if (coordinator.current) {
|
|
coordinator.current.retired = true;
|
|
}
|
|
coordinator.current = generation;
|
|
coordinator.registeredEpoch = generation.epoch;
|
|
}
|
|
}
|
|
|
|
export default definePluginEntry({
|
|
id: "voice-call",
|
|
name: "Voice Call",
|
|
description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
|
|
configSchema: voiceCallConfigSchema,
|
|
register(api: OpenClawPluginApi) {
|
|
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
|
|
const validation = validateProviderConfig(config);
|
|
|
|
const runtimeCoordinator = getVoiceCallRuntimeCoordinator();
|
|
const runtimeGeneration: VoiceCallRuntimeGeneration =
|
|
api.registrationMode !== "full" && runtimeCoordinator.current
|
|
? runtimeCoordinator.current
|
|
: {
|
|
epoch: ++runtimeCoordinator.epochCounter,
|
|
retired: false,
|
|
};
|
|
const continueOperationStore = createVoiceCallContinueOperationStore({
|
|
config,
|
|
coreConfig: api.config as CoreConfig,
|
|
});
|
|
|
|
const ensureRuntime = async (): Promise<VoiceCallRuntime> => {
|
|
activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
|
|
if (!config.enabled) {
|
|
throw new Error("Voice call disabled in plugin config");
|
|
}
|
|
if (!validation.valid) {
|
|
throw new Error(validation.errors.join("; "));
|
|
}
|
|
|
|
while (true) {
|
|
activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
|
|
const slot = runtimeCoordinator.slot;
|
|
if (slot) {
|
|
if (slot.owner !== runtimeGeneration) {
|
|
if (slot.state === "stopping") {
|
|
await slot.promise;
|
|
continue;
|
|
}
|
|
throw new VoiceCallRuntimeLifecycleError(
|
|
"A previous voice call runtime generation is still active; retry after it stops",
|
|
);
|
|
}
|
|
|
|
if (slot.state === "running") {
|
|
return slot.runtime;
|
|
}
|
|
if (slot.state === "stopping") {
|
|
await slot.promise;
|
|
continue;
|
|
}
|
|
|
|
let createdRuntime: VoiceCallRuntime;
|
|
try {
|
|
createdRuntime = await slot.promise;
|
|
} catch (err) {
|
|
if (runtimeCoordinator.slot === slot) {
|
|
runtimeCoordinator.slot = undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
|
|
if (runtimeCoordinator.slot !== slot) {
|
|
continue;
|
|
}
|
|
runtimeCoordinator.slot = {
|
|
state: "running",
|
|
owner: runtimeGeneration,
|
|
runtime: createdRuntime,
|
|
};
|
|
return createdRuntime;
|
|
}
|
|
|
|
const runtimePromise = createVoiceCallRuntime({
|
|
config,
|
|
coreConfig: api.config as CoreConfig,
|
|
fullConfig: api.config,
|
|
agentRuntime: api.runtime.agent,
|
|
stateRuntime: api.runtime.state,
|
|
ttsRuntime: api.runtime.tts,
|
|
logger: api.logger,
|
|
});
|
|
const startingSlot: VoiceCallRuntimeSlot = {
|
|
state: "starting",
|
|
owner: runtimeGeneration,
|
|
promise: runtimePromise,
|
|
};
|
|
runtimeCoordinator.slot = startingSlot;
|
|
}
|
|
};
|
|
|
|
const commands = createVoiceCallCommandService(ensureRuntime);
|
|
const registerGatewayCommand = (
|
|
method: string,
|
|
handler: (options: GatewayRequestHandlerOptions) => unknown,
|
|
scope: typeof VOICE_CALL_WRITE_METHOD_SCOPE | typeof VOICE_CALL_READ_METHOD_SCOPE,
|
|
) => {
|
|
api.registerGatewayMethod(
|
|
method,
|
|
async (options: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
options.respond(true, await handler(options));
|
|
} catch (err) {
|
|
const code =
|
|
err instanceof VoiceCallCommandInputError
|
|
? ErrorCodes.INVALID_REQUEST
|
|
: ErrorCodes.UNAVAILABLE;
|
|
options.respond(false, undefined, errorShape(code, formatErrorMessage(err)));
|
|
}
|
|
},
|
|
scope,
|
|
);
|
|
};
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.initiate",
|
|
async ({ params }) => {
|
|
const message = normalizeOptionalString(params?.message);
|
|
if (!message) {
|
|
throw new VoiceCallCommandInputError("message required");
|
|
}
|
|
return await commands.initiate({
|
|
to: normalizeOptionalString(params?.to),
|
|
message,
|
|
mode:
|
|
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined,
|
|
sessionKey: normalizeOptionalString(params?.sessionKey),
|
|
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
|
|
});
|
|
},
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.continue",
|
|
({ params }) =>
|
|
commands.continueCall(
|
|
normalizeOptionalString(params?.callId),
|
|
normalizeOptionalString(params?.message),
|
|
),
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.continue.start",
|
|
async ({ params }) =>
|
|
continueOperationStore.start(
|
|
await commands.prepareContinue(
|
|
normalizeOptionalString(params?.callId),
|
|
normalizeOptionalString(params?.message),
|
|
),
|
|
),
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.continue.result",
|
|
({ params }) => {
|
|
const operationId = normalizeOptionalString(params?.operationId);
|
|
if (!operationId) {
|
|
throw new VoiceCallCommandInputError("operationId required");
|
|
}
|
|
const operation = continueOperationStore.read(operationId);
|
|
if (!operation.ok) {
|
|
throw new VoiceCallCommandInputError(operation.error);
|
|
}
|
|
return operation.payload;
|
|
},
|
|
VOICE_CALL_READ_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.speak",
|
|
({ params }) =>
|
|
commands.speak({
|
|
callId: normalizeOptionalString(params?.callId),
|
|
message: normalizeOptionalString(params?.message),
|
|
allowTwimlFallback: params?.allowTwimlFallback !== false,
|
|
}),
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.dtmf",
|
|
({ params }) =>
|
|
commands.sendDtmf(
|
|
normalizeOptionalString(params?.callId),
|
|
normalizeOptionalString(params?.digits),
|
|
),
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.end",
|
|
({ params }) => commands.endCall(normalizeOptionalString(params?.callId)),
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.status",
|
|
({ params }) =>
|
|
commands.status(
|
|
normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid),
|
|
),
|
|
VOICE_CALL_READ_METHOD_SCOPE,
|
|
);
|
|
|
|
registerGatewayCommand(
|
|
"voicecall.start",
|
|
async ({ params, client }) => {
|
|
const to = normalizeOptionalString(params?.to);
|
|
const requestedAgentId = normalizeOptionalString(params?.agentId);
|
|
const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : undefined;
|
|
const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
|
|
if (
|
|
requestedAgentId &&
|
|
(!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())
|
|
) {
|
|
throw new VoiceCallCommandInputError(
|
|
"agentId requires a trusted plugin caller and a valid agent id",
|
|
);
|
|
}
|
|
if (!to) {
|
|
throw new VoiceCallCommandInputError("to required");
|
|
}
|
|
return await commands.initiate({
|
|
to,
|
|
message: normalizeOptionalString(params?.message),
|
|
mode:
|
|
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined,
|
|
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
|
sessionKey: normalizeOptionalString(params?.sessionKey),
|
|
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
|
|
agentId: normalizedAgentId,
|
|
});
|
|
},
|
|
VOICE_CALL_WRITE_METHOD_SCOPE,
|
|
);
|
|
|
|
api.registerTool((toolContext) => ({
|
|
name: "voice_call",
|
|
label: "Voice Call",
|
|
description: "Make phone calls and have voice conversations via the voice-call plugin.",
|
|
parameters: VoiceCallToolSchema,
|
|
async execute(_toolCallId, params) {
|
|
const rawParams = asParamRecord(params);
|
|
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
|
|
// Agent ownership and requester lineage come from trusted tool context.
|
|
// Some harnesses omit agentId but retain its canonical session key.
|
|
const contextAgentId =
|
|
normalizeOptionalString(toolContext.agentId) ??
|
|
parseAgentSessionKey(requesterSessionKey)?.agentId;
|
|
const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : undefined;
|
|
try {
|
|
// Preserve tool error precedence: runtime availability is checked before model input.
|
|
await ensureRuntime();
|
|
if (typeof rawParams.action === "string") {
|
|
switch (rawParams.action) {
|
|
case "initiate_call": {
|
|
const message = normalizeOptionalString(rawParams.message);
|
|
if (!message) {
|
|
throw new VoiceCallCommandInputError("message required");
|
|
}
|
|
return json(
|
|
await commands.initiate({
|
|
to: normalizeOptionalString(rawParams.to),
|
|
message,
|
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
mode:
|
|
rawParams.mode === "notify" || rawParams.mode === "conversation"
|
|
? rawParams.mode
|
|
: undefined,
|
|
sessionKey: normalizeOptionalString(rawParams.sessionKey),
|
|
agentId,
|
|
requesterSessionKey,
|
|
}),
|
|
);
|
|
}
|
|
case "continue_call":
|
|
return json(
|
|
await commands.continueCall(
|
|
normalizeOptionalString(rawParams.callId),
|
|
normalizeOptionalString(rawParams.message),
|
|
),
|
|
);
|
|
case "speak_to_user":
|
|
return json(
|
|
await commands.speak({
|
|
callId: normalizeOptionalString(rawParams.callId),
|
|
message: normalizeOptionalString(rawParams.message),
|
|
}),
|
|
);
|
|
case "send_dtmf":
|
|
return json(
|
|
await commands.sendDtmf(
|
|
normalizeOptionalString(rawParams.callId),
|
|
normalizeOptionalString(rawParams.digits),
|
|
),
|
|
);
|
|
case "end_call":
|
|
return json(await commands.endCall(normalizeOptionalString(rawParams.callId)));
|
|
case "get_status": {
|
|
const callId = normalizeOptionalString(rawParams.callId);
|
|
if (!callId) {
|
|
throw new VoiceCallCommandInputError("callId required");
|
|
}
|
|
return json(await commands.status(callId));
|
|
}
|
|
}
|
|
}
|
|
|
|
const mode = rawParams.mode ?? "call";
|
|
if (mode === "status") {
|
|
const sid = normalizeOptionalString(rawParams.sid) ?? "";
|
|
if (!sid) {
|
|
throw new Error("sid required for status");
|
|
}
|
|
return json(await commands.status(sid));
|
|
}
|
|
|
|
return json(
|
|
await commands.initiate(
|
|
{
|
|
to: normalizeOptionalString(rawParams.to),
|
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
message: normalizeOptionalString(rawParams.message),
|
|
sessionKey: normalizeOptionalString(rawParams.sessionKey),
|
|
agentId,
|
|
requesterSessionKey,
|
|
},
|
|
"to required for call",
|
|
),
|
|
);
|
|
} catch (err) {
|
|
return json({
|
|
error: formatErrorMessage(err),
|
|
});
|
|
}
|
|
},
|
|
}));
|
|
|
|
api.registerCli(
|
|
({ program }) =>
|
|
registerVoiceCallCli({
|
|
program,
|
|
config,
|
|
ensureRuntime,
|
|
stateRuntime: api.runtime.state,
|
|
logger: api.logger,
|
|
}),
|
|
{ commands: ["voicecall"], descriptors: [VOICE_CALL_CLI_DESCRIPTOR] },
|
|
);
|
|
|
|
api.registerService({
|
|
id: "voicecall",
|
|
start: () => {
|
|
if (isCliOnlyProcess()) {
|
|
return;
|
|
}
|
|
try {
|
|
activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
|
|
} catch (err) {
|
|
if (err instanceof VoiceCallRuntimeLifecycleError) {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!config.enabled) {
|
|
return;
|
|
}
|
|
if (!validation.valid) {
|
|
api.logger.warn(
|
|
`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`,
|
|
);
|
|
return;
|
|
}
|
|
void ensureRuntime().catch((err: unknown) => {
|
|
const staleGeneration =
|
|
runtimeGeneration.retired ||
|
|
runtimeGeneration.epoch < runtimeCoordinator.registeredEpoch;
|
|
if (err instanceof VoiceCallRuntimeLifecycleError && staleGeneration) {
|
|
return;
|
|
}
|
|
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
|
|
});
|
|
},
|
|
stop: async () => {
|
|
if (runtimeGeneration.stopPromise) {
|
|
return await runtimeGeneration.stopPromise;
|
|
}
|
|
runtimeGeneration.retired = true;
|
|
const ownedSlot =
|
|
runtimeCoordinator.slot?.owner === runtimeGeneration
|
|
? runtimeCoordinator.slot
|
|
: undefined;
|
|
let stoppingSlot: VoiceCallRuntimeSlot | undefined;
|
|
const stopPromise =
|
|
ownedSlot?.state === "stopping"
|
|
? ownedSlot.promise
|
|
: Promise.resolve().then(async () => {
|
|
if (!ownedSlot) {
|
|
return;
|
|
}
|
|
const runtime =
|
|
ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise;
|
|
await runtime.stop();
|
|
});
|
|
runtimeGeneration.stopPromise = stopPromise;
|
|
if (ownedSlot && ownedSlot.state !== "stopping") {
|
|
stoppingSlot = {
|
|
state: "stopping",
|
|
owner: runtimeGeneration,
|
|
promise: stopPromise,
|
|
};
|
|
if (runtimeCoordinator.slot === ownedSlot) {
|
|
runtimeCoordinator.slot = stoppingSlot;
|
|
}
|
|
} else if (ownedSlot) {
|
|
stoppingSlot = ownedSlot;
|
|
}
|
|
try {
|
|
await stopPromise;
|
|
} finally {
|
|
if (stoppingSlot && runtimeCoordinator.slot === stoppingSlot) {
|
|
runtimeCoordinator.slot = undefined;
|
|
}
|
|
if (runtimeCoordinator.current === runtimeGeneration) {
|
|
runtimeCoordinator.current = undefined;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
},
|
|
});
|