openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
484 lines (483 loc) • 19.7 kB
JavaScript
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs";
import { n as sanitizeTerminalText } from "./safe-text-SY_CvCi3.mjs";
import "./utils-DgZn2dLu.mjs";
import { t as formatErrorMessage } from "./errors-O33D01XW.mjs";
import { n as awaitWithinDeadline, t as ABSOLUTE_DEADLINE_EXPIRED } from "./absolute-deadline-DoGHpXtD.mjs";
import { d as runPluginCleanup } from "./plugin-instance-scope-DEzoxqBj.mjs";
import { i as normalizeCapabilityProviderId } from "./provider-registry-shared-Cov0065J.mjs";
import { n as activeSessions, o as retainTranscriptStartRetry, t as TranscriptStartError } from "./capture-startup-YlmBfTMP.mjs";
import { u as transcriptSessionSelector } from "./store-export-state-CqLuFbIn.mjs";
import { n as sanitizeTranscriptSourceLocator, r as sourceFromParams } from "./source-locator-7M-To08_.mjs";
import "./store-DZ0R_KU6.mjs";
import { a as createTranscriptSessionId, f as resolveSourceProvider, m as startTranscripts, p as resolveTranscriptSourceOwnership, r as stopTranscriptCapture, t as createTranscriptsStore, u as isTranscriptSessionStarting } from "./capture-operations-BgzRQocr.mjs";
import { t as resolveTranscriptsConfig } from "./config-B42acf6V.mjs";
import { r as hasSameTranscriptCaptureIntent, t as beginConfiguredTranscriptStarts } from "./configured-start-status-_Hn89S5E.mjs";
//#region src/transcripts/auto-start.ts
const AUTO_START_RETRY_ATTEMPTS = 12;
const AUTO_START_RETRY_MS = 5e3;
const AUTO_START_STOP_TIMEOUT_MS = 5e3;
const AUTO_START_PROVIDER_READY_TIMEOUT_MS = 3e4;
const AUTO_START_OCCUPANCY_EMPTY_GRACE_MS = 3e4;
const AUTO_START_OCCUPANCY_REOPEN_WINDOW_MS = 6e5;
function formatAutoStopDiagnostic(value) {
return JSON.stringify(truncateUtf16Safe(sanitizeTerminalText(formatErrorMessage(value)), 300));
}
function matchesConfiguredSource(owner, config, index) {
return hasSameTranscriptCaptureIntent({ autoStart: owner.config?.transcripts?.autoStart?.slice(owner.index, owner.index + 1) }, { autoStart: config?.transcripts?.autoStart?.slice(index, index + 1) });
}
/** Own configured captures independently of the room's provider connection. */
function createTranscriptsAutoStartService(ctx, getConfig) {
const entries = /* @__PURE__ */ new Set();
const guildOwners = /* @__PURE__ */ new Map();
const consumedSessions = /* @__PURE__ */ new Map();
let stopped = false;
let diagnostics;
return {
start(config = ctx.config) {
if (stopped) return { settled: Promise.resolve() };
diagnostics = beginConfiguredTranscriptStarts(config?.transcripts);
const resolved = resolveTranscriptsConfig(config?.transcripts);
if (!resolved.enabled) return { settled: Promise.resolve() };
const retained = new Set(entries);
for (const [index, entry] of resolved.autoStart.entries()) {
const current = [...retained].find((candidate) => matchesConfiguredSource(candidate.owner, config, index));
if (current) {
current.owner.index = index;
current.owner.config = config;
if (current.owner.diagnostic) {
current.owner.diagnostic[0] = index;
diagnostics.record(...current.owner.diagnostic);
}
retained.delete(current);
continue;
}
const providerId = normalizeCapabilityProviderId(entry.providerId);
const owner = {
index,
config
};
entries.add({
owner,
providerId,
...startTranscriptsAutoStartEntry({
...ctx,
config
}, entry, owner, guildOwners, consumedSessions, (lifecycleToken, diagnostic) => {
owner.diagnostic = [
owner.index,
lifecycleToken,
diagnostic
];
diagnostics?.record(...owner.diagnostic);
}, getConfig ?? (() => owner.config))
});
}
return { settled: Promise.allSettled([...entries].flatMap((entry) => Array.from(entry.pendingStarts))).then(() => void 0) };
},
async stop(providerIds, nextConfig) {
stopped ||= providerIds === void 0;
if (stopped) diagnostics?.clear();
const next = resolveTranscriptsConfig(nextConfig?.transcripts);
const retained = new Set(next.enabled ? next.autoStart.map((_, index) => index) : []);
const selected = [...entries].filter((entry) => {
if (!providerIds || entry.providerId && providerIds.has(entry.providerId)) return true;
if (!nextConfig) return false;
const index = [...retained].find((candidate) => matchesConfiguredSource(entry.owner, nextConfig, candidate));
if (index === void 0) return true;
retained.delete(index);
return false;
});
const stopping = Promise.allSettled(selected.map(async (entry) => {
entry.stopping ??= entry.stop(providerIds !== void 0).finally(() => {
entry.stopping = void 0;
});
if (!await entry.stopping) {
if (providerIds) throw new Error("Transcript auto-start capture cleanup remains pending");
return;
}
entries.delete(entry);
for (const [key, owner] of guildOwners) if (owner === entry.owner) guildOwners.delete(key);
}));
if (await awaitWithinDeadline(() => Promise.allSettled(selected.flatMap((entry) => Array.from(entry.pendingStarts))), Date.now() + AUTO_START_STOP_TIMEOUT_MS) === ABSOLUTE_DEADLINE_EXPIRED) {
stopping.then((results) => {
for (const result of results) if (result.status === "rejected") ctx.logger.warn(`transcripts autoStart cleanup failed: ${formatAutoStopDiagnostic(result.reason)}`);
});
if (providerIds) throw new Error("Transcript auto-start stop timed out waiting for pending starts; retry after the provider finishes stopping");
ctx.logger.warn(`transcripts autoStart stop timed out waiting for pending starts`);
return;
}
const errors = (await stopping).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (errors.length) throw new AggregateError(errors, "Transcript auto-start cleanup failed");
}
};
}
function startTranscriptsAutoStartEntry(ctx, entry, entryOwner, guildOwners, consumedSessions, recordDiagnostic, getConfig) {
let stopped = false;
const store = createTranscriptsStore(ctx);
const timers = /* @__PURE__ */ new Set();
let watcher;
let watchRegistration;
const startedSessions = /* @__PURE__ */ new Map();
const controllers = /* @__PURE__ */ new Set();
const pendingStarts = /* @__PURE__ */ new Set();
let stopping;
let startRetry;
const clearRetry = () => {
startRetry?.release();
startRetry = void 0;
};
const futureTitle = () => {
const latest = getConfig();
const config = hasSameTranscriptCaptureIntent(entryOwner.config?.transcripts, latest?.transcripts) ? latest : entryOwner.config;
return resolveTranscriptsConfig(config?.transcripts).autoStart[entryOwner.index]?.title;
};
const terminalDiagnostic = (error) => error instanceof TranscriptStartError && !error.retry ? error.code : void 0;
const schedule = (run, delay) => {
const timer = setTimeout(() => {
timers.delete(timer);
run();
}, delay);
timer.unref();
timers.add(timer);
return timer;
};
const cancel = (timer) => {
if (timer) {
clearTimeout(timer);
timers.delete(timer);
}
};
const runPending = (run) => {
const controller = new AbortController();
controllers.add(controller);
const task = run(controller).finally(() => {
controllers.delete(controller);
pendingStarts.delete(task);
});
pendingStarts.add(task);
return task;
};
const ownsCapture = (capture) => activeSessions.get(capture.sessionId)?.lifecycleToken === capture.lifecycleToken;
const forgetCapture = (capture) => {
if (!ownsCapture(capture) && startedSessions.get(capture.sessionId) === capture.lifecycleToken) {
startedSessions.delete(capture.sessionId);
if (consumedSessions.get(capture.sessionId) === capture.lifecycleToken) consumedSessions.delete(capture.sessionId);
}
};
const stopCapture = async (capture, requireProviderStop = false) => {
const warnings = [];
try {
const active = activeSessions.get(capture.sessionId);
if (!active || active.lifecycleToken !== capture.lifecycleToken) {
forgetCapture(capture);
return;
}
const details = await stopTranscriptCapture({
ctx,
store,
selection: {
session: active.session,
selector: transcriptSessionSelector(active.session),
activeCandidate: active,
selectedActive: active,
historicalRevision: void 0
}
});
if (details.status === "skipped") {
if (requireProviderStop && details.reason !== "inactive") throw new Error(`Transcripts session stop still in progress: ${capture.sessionId}`);
forgetCapture(capture);
return;
}
if (typeof details.summaryExportError === "string") warnings.push(`summary saved; export failed intendedSummaryPath=${formatAutoStopDiagnostic(details.intendedSummaryPath)}: ${formatAutoStopDiagnostic(details.summaryExportError)}. Correct the export destination, then run openclaw transcripts path <session> or openclaw transcripts show <session>.`);
if (typeof details.providerStopError === "string") warnings.push(`provider stop failed: ${formatAutoStopDiagnostic(details.providerStopError)}. Check the provider capture status and connection.`);
} catch (error) {
if (requireProviderStop) throw error;
warnings.push(`stop failed: ${formatAutoStopDiagnostic(error)}`);
}
for (const warning of warnings) ctx.logger.warn(`transcripts autoStart session=${formatAutoStopDiagnostic(capture.sessionId)}: ${warning}`);
forgetCapture(capture);
};
const startCapture = async (capture, params) => {
recordDiagnostic(capture.lifecycleToken, "starting");
try {
const retry = startRetry;
const result = await startTranscripts({
...params,
existingSession: retry?.session ?? params.existingSession,
existingSessionCondition: retry ? {
expectedInputRevision: retry.revision,
assertCurrent: retry.assertCurrent
} : params.existingSessionCondition,
ctx,
store,
startupWaitMs: AUTO_START_PROVIDER_READY_TIMEOUT_MS,
configuredLifecycle: true,
lifecycleToken: capture.lifecycleToken,
rawParams: {
...params.rawParams,
sessionId: capture.sessionId
}
});
clearRetry();
if (!stopped) recordDiagnostic(capture.lifecycleToken, result.status === "ended" ? "ended" : void 0);
return result;
} catch (error) {
if (error instanceof TranscriptStartError) {
clearRetry();
if (!stopped && error.retry) startRetry = retainTranscriptStartRetry(ctx.stateDir, error.retry);
}
throw error;
} finally {
if (ownsCapture(capture)) {
startedSessions.set(capture.sessionId, capture.lifecycleToken);
consumedSessions.set(capture.sessionId, capture.lifecycleToken);
}
}
};
const startContinuous = (attempt) => {
if (stopped) return;
const capture = {
sessionId: startRetry?.session.sessionId ?? entry.sessionId ?? createTranscriptSessionId(),
lifecycleToken: Symbol(entry.sessionId)
};
runPending(async (controller) => {
try {
if (consumedSessions.has(entry.sessionId ?? "")) throw new TranscriptStartError("id-conflict", /* @__PURE__ */ new Error("transcripts session already started by this service"));
await startCapture(capture, {
sessionIdOrigin: entry.sessionId ? "supplied" : "generated",
abortSignal: controller.signal,
rawParams: {
...entry,
title: futureTitle()
}
});
} catch (error) {
if (stopped) return;
const terminal = terminalDiagnostic(error);
const cleanupPending = ownsCapture(capture);
if (terminal || cleanupPending || attempt >= AUTO_START_RETRY_ATTEMPTS) {
clearRetry();
const diagnostic = terminal ?? "start-failed";
recordDiagnostic(capture.lifecycleToken, diagnostic);
ctx.logger.warn(`transcripts autoStart source ${entryOwner.index + 1}: ${diagnostic}. Check Meeting capture health in Settings.`);
} else {
recordDiagnostic(capture.lifecycleToken, "retrying");
schedule(() => startContinuous(attempt + 1), AUTO_START_RETRY_MS);
}
}
});
};
const watchEntry = async () => {
let occupied = false;
let ready = false;
let capture;
let starting;
let startController;
let emptyTimer;
let retryTimer;
let source;
let diagnosticToken = Symbol(`transcripts occupancy ${entryOwner.index}`);
const label = () => `transcripts autoStart[${entryOwner.index}] provider=${entry.providerId}`;
const retry = (run, attempt, error, phase) => {
if (stopped) return;
const terminal = terminalDiagnostic(error);
if (terminal || attempt >= AUTO_START_RETRY_ATTEMPTS) {
clearRetry();
recordDiagnostic(diagnosticToken, terminal ?? "start-failed");
ctx.logger.warn(`${label()} failed: ${formatAutoStopDiagnostic(error)}; check the entry and provider connection. ${phase === "watch" ? "Reload the provider plugin to retry occupancy watching." : "Waiting for the next occupancy transition."}`);
return;
}
recordDiagnostic(diagnosticToken, "retrying");
cancel(retryTimer);
retryTimer = schedule(run, AUTO_START_RETRY_MS);
};
const begin = (attempt) => {
if (stopped || !ready || !occupied || starting || stopping) return;
if (capture && ownsCapture(capture) && activeSessions.get(capture.sessionId)?.phase === "active") return;
starting = runPending(async (controller) => {
startController = controller;
try {
if (capture) {
await stopCapture(capture);
if (ownsCapture(capture)) throw new Error("previous capture still awaits finalization");
}
if (stopped || !occupied || controller.signal.aborted) return;
const now = Date.now();
const retained = startRetry;
const recent = retained ? {
session: retained.session,
inputRevision: retained.revision
} : await store.readRecentStoppedSession(sanitizeTranscriptSourceLocator(source), (/* @__PURE__ */ new Date(now - AUTO_START_OCCUPANCY_REOPEN_WINDOW_MS)).toISOString(), new Date(now).toISOString());
if (stopped || !occupied || controller.signal.aborted) return;
const candidate = recent && recent.session.metadata?.sessionIdOrigin === "generated" && (!(source.agentId ?? ctx.agentId) || (recent.session.metadata?.agentId ?? "main") === (source.agentId ?? ctx.agentId)) && !activeSessions.has(recent.session.sessionId) && !isTranscriptSessionStarting(recent.session.sessionId) && !consumedSessions.has(recent.session.sessionId) ? recent : void 0;
const owned = {
sessionId: candidate?.session.sessionId ?? createTranscriptSessionId(),
lifecycleToken: Symbol(label())
};
diagnosticToken = owned.lifecycleToken;
capture = owned;
if ((await startCapture(owned, {
sessionIdOrigin: "generated",
abortSignal: controller.signal,
existingSession: candidate?.session,
existingSessionCondition: candidate ? {
expectedInputRevision: candidate.inputRevision,
assertCurrent: () => {
if (stopped || !occupied || controller.signal.aborted || capture !== owned) throw new TranscriptStartError("id-conflict", /* @__PURE__ */ new Error("transcript occupancy changed before reopening"));
}
} : void 0,
rawParams: {
...entry,
...source,
title: futureTitle()
},
onCaptureEnded: () => {
if (capture !== owned || stopped || !occupied) return;
forgetCapture(owned);
cancel(retryTimer);
retryTimer = schedule(() => begin(1), AUTO_START_RETRY_MS);
}
})).status === "ended") throw new Error("capture ended during startup");
} catch (error) {
if (capture && !ownsCapture(capture)) capture = void 0;
if (occupied && !controller.signal.aborted) retry(() => begin(attempt + 1), attempt, error, "capture");
} finally {
startController = void 0;
}
}).finally(() => {
starting = void 0;
});
};
const end = () => {
if (stopping) return;
stopping = (async () => {
startController?.abort();
await starting;
clearRetry();
if (capture) {
await stopCapture(capture);
if (!ownsCapture(capture)) capture = void 0;
}
if (!capture) recordDiagnostic(diagnosticToken);
})().finally(() => {
stopping = void 0;
if (occupied && !stopped) begin(1);
});
};
const arm = (attempt) => {
if (stopped) return;
watchRegistration = runPending(async (controller) => {
try {
const provider = resolveSourceProvider(entry.providerId, ctx);
if (!provider) throw new Error("provider is not available");
if (!provider.watchOccupancy) {
recordDiagnostic(diagnosticToken, "start-failed");
ctx.logger.warn(`${label()} cannot report occupancy; remove whenOccupied or select a provider that supports occupancy watching.`);
return;
}
clearRetry();
source = resolveTranscriptSourceOwnership({
ctx,
operation: "start",
provider,
source: {
...sourceFromParams(entry),
providerId: provider.id
},
configuredLifecycle: true
});
if (source.guildId) {
const key = JSON.stringify([
provider.id,
source.accountId,
source.guildId
]);
const owner = guildOwners.get(key);
if (owner !== void 0 && owner !== entryOwner) {
recordDiagnostic(diagnosticToken, "start-failed");
ctx.logger.warn(`${label()} skipped: autoStart[${owner.index}] already owns this provider account and guild; configure only one whenOccupied entry per account and guild.`);
return;
}
guildOwners.set(key, entryOwner);
}
const result = await provider.watchOccupancy({
cfg: ctx.config,
source,
abortSignal: controller.signal,
startupWaitMs: AUTO_START_PROVIDER_READY_TIMEOUT_MS,
onOccupied: () => {
if (stopped || controller.signal.aborted || occupied) return;
occupied = true;
cancel(emptyTimer);
cancel(retryTimer);
clearRetry();
begin(1);
},
onEmpty: () => {
if (stopped || controller.signal.aborted || !occupied) return;
occupied = false;
cancel(retryTimer);
cancel(emptyTimer);
emptyTimer = schedule(end, AUTO_START_OCCUPANCY_EMPTY_GRACE_MS);
}
});
if (!result.ok) throw new Error(result.error);
watcher = result.value;
if (stopped) return;
ready = true;
recordDiagnostic(diagnosticToken);
begin(1);
} catch (error) {
controller.abort();
occupied = false;
cancel(emptyTimer);
retry(() => arm(attempt + 1), attempt, error, "watch");
}
});
};
arm(1);
await watchRegistration;
await starting;
};
if (entry.whenOccupied) {
const startup = watchEntry().finally(() => pendingStarts.delete(startup));
pendingStarts.add(startup);
} else startContinuous(1);
const stop = async (strict) => {
stopped = true;
clearRetry();
for (const timer of timers) clearTimeout(timer);
timers.clear();
for (const controller of controllers) controller.abort();
await watchRegistration;
const failures = [];
if (watcher) {
const current = watcher;
try {
await runPluginCleanup(current.stop, () => current.stop());
watcher = void 0;
} catch (error) {
failures.push(error);
}
}
await Promise.allSettled(pendingStarts);
await Promise.allSettled(stopping ? [stopping] : []);
for (const [sessionId, lifecycleToken] of startedSessions) try {
await stopCapture({
sessionId,
lifecycleToken
}, strict);
} catch (error) {
failures.push(error);
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, "Transcript entry cleanup failed");
return startedSessions.size === 0;
};
return {
stop,
pendingStarts
};
}
//#endregion
export { createTranscriptsAutoStartService };