openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
623 lines (622 loc) • 22.7 kB
JavaScript
import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js";
import { T as tryResolveConfiguredAgentWorkspaceDir, o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js";
import { C as resolveOAuthDir, w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js";
import { n as resolveDefaultAgentWorkspaceDir } from "./workspace-default-DPT1Dhad.js";
import { r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { o as repairOpenClawStateDatabaseSchemaIfNeeded, r as openExistingOpenClawStateDatabaseReadOnly, s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js";
import { s as listPluginDoctorStateMigrationEntries } from "./doctor-contract-registry-DOa9Ye7j.js";
import { o as resolveSessionStorePathCore } from "./paths-CXdaYWF_.js";
import { n as acquireGatewayLock } from "./gateway-lock-B0QIxQaj.js";
import { _ as withAgentDatabaseMaintenanceLease } from "./openclaw-agent-db-CWtDoRbC.js";
import { _ as pluginStateDeleteEntriesIfUnchanged, g as getPluginStateCapacity, i as importPluginStateEntriesForDoctor, n as createPluginStateKeyedStore, v as pluginStateDoctorEntriesInKeyRange } from "./plugin-state-store-C6hmUuuk.js";
import { r as listChannelIngressQueueAccountIdsReadOnly, t as createChannelIngressQueue } from "./ingress-queue-DrsU80Sd.js";
import { Xt as readSessionIdentityEvidenceBatch, Yt as loadExactSessionEntryReadOnlyResult } from "./session-accessor-YsytfDtG.js";
import { n as withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly-CHjf8FxN.js";
import { l as readExactSessionEntryRowValidated } from "./session-accessor.sqlite-entry-store-BxYl0nro.js";
import { c as resolveSqliteScope, m as toDatabaseOptions } from "./session-accessor.sqlite-scope-2KfMzb44.js";
import { d as dedupeSessionStoreTargetsBySqliteTarget } from "./targets-Cknmo7YZ.js";
import { a as buildAcpDatabaseSessionKey, c as parseAcpDatabaseSessionKeyCandidates, i as acpSessionRowMatchesEntry, n as resolveSessionStorePathForAcp, o as getAcpSessionKysely, u as selectAcpSessionRow } from "./session-meta-store-BkOt1zC9.js";
import { s as rowToAcpSessionMeta } from "./session-meta-dzG4VkYo.js";
import { t as withPluginLifecycleLease } from "./plugin-lifecycle-lease-BtAFH872.js";
import { t as resolveExistingAgentSessionStoreTargetsReadOnlyResult } from "./targets-read-availability-lKmQE2tg.js";
import { t as formatStartupMigrationFailure } from "./state-migrations.messages-oWDQJF4s.js";
import { t as autoMigrateLegacyStateDir } from "./state-migrations.state-dir-BnFhRPt5.js";
import { isDeepStrictEqual } from "node:util";
import os from "node:os";
//#region src/acp/runtime/session-meta-doctor.ts
function isRetiredClaimOwner(config, target) {
const parsed = parseAgentSessionKey(target.sessionKey);
const freeAcp = parsed?.rest.startsWith("acp:") && !parsed.rest.startsWith("acp:binding:");
return !listAgentIds(config).includes(target.agentId) && !freeAcp;
}
function readClaimBinding(scope, target) {
const owner = resolveSessionStorePathForAcp({
cfg: scope.config,
env: scope.env,
...target
});
if (isRetiredClaimOwner(scope.config, target)) throw new Error(`retired ACP owner ${owner.agentId}`);
const resolved = resolveSqliteScope({
...target,
env: scope.env,
storePath: owner.storePath
});
const result = loadExactSessionEntryReadOnlyResult({
...target,
sessionKey: resolved.sessionKey,
env: scope.env,
storePath: owner.storePath
});
if (!result.found || !result.value) throw new Error(`ACP session binding is ${result.found ? "absent" : result.reason}`);
const { sessionId, lifecycleRevision, sessionStartedAt } = result.value.entry;
return {
sessionId,
lifecycleRevision,
sessionStartedAt
};
}
async function inspectAcpSessionClaimsForDoctor(scope) {
const claims = [];
const incomplete = [];
try {
const database = await openExistingOpenClawStateDatabaseReadOnly({ env: scope.env });
if (!database) return {
claims,
incomplete
};
try {
const rows = executeSqliteQuerySync(database.db, getAcpSessionKysely(database.db).selectFrom("acp_sessions").selectAll().where("backend", "=", scope.pluginId)).rows;
for (const row of rows) try {
const target = parseAcpDatabaseSessionKeyCandidates(row.session_key)[0];
if (!target?.agentId || buildAcpDatabaseSessionKey(target.storeSessionKey, target.agentId) !== row.session_key) throw new Error("ACP metadata key is not canonical");
const claimTarget = {
agentId: target.agentId,
sessionKey: target.storeSessionKey
};
const binding = readClaimBinding(scope, claimTarget);
if (row.session_id == null || !acpSessionRowMatchesEntry(row, binding)) throw new Error("ACP metadata binding is absent or stale");
const meta = rowToAcpSessionMeta(row);
if (row.identity_json && !meta.identity || row.runtime_options_json && !meta.runtimeOptions) throw new Error("ACP metadata JSON is unreadable");
claims.push({
...claimTarget,
binding,
meta
});
} catch (error) {
incomplete.push(`${row.session_key}: ${String(error)}`);
}
} finally {
database.walMaintenance.close();
}
} catch (error) {
incomplete.push(String(error));
}
return {
claims,
incomplete
};
}
function updateAcpSessionIdentityForDoctor(scope, authority, input) {
authority.assertCurrent();
const { claim } = input;
if (claim.meta.backend !== scope.pluginId || !claim.meta.identity) throw new Error("ACP identity repair requires a matching backend claim and existing identity");
const key = buildAcpDatabaseSessionKey(claim.sessionKey, claim.agentId);
const owner = resolveSessionStorePathForAcp({
cfg: scope.config,
env: scope.env,
...claim
});
const resolved = resolveSqliteScope({
...claim,
env: scope.env,
storePath: owner.storePath
});
const options = toDatabaseOptions(resolved);
const updated = withOpenClawAgentDatabaseReadOnly((agentDatabase) => {
runOpenClawStateWriteTransaction((database) => {
authority.assertOwnedInTransaction(database.db);
const row = selectAcpSessionRow(database.db, key);
const entry = readExactSessionEntryRowValidated(agentDatabase, resolved.sessionKey)?.entry;
const binding = entry && {
sessionId: entry.sessionId,
lifecycleRevision: entry.lifecycleRevision,
sessionStartedAt: entry.sessionStartedAt
};
if (isRetiredClaimOwner(scope.config, claim) || !row || !isDeepStrictEqual(rowToAcpSessionMeta(row), claim.meta) || !isDeepStrictEqual(binding, claim.binding) || !acpSessionRowMatchesEntry(row, claim.binding)) throw new Error("ACP ownership or metadata changed during Doctor repair; source retained");
executeSqliteQuerySync(database.db, getAcpSessionKysely(database.db).updateTable("acp_sessions").set({
runtime_session_name: input.runtimeSessionName,
identity_json: JSON.stringify({
...claim.meta.identity,
acpxRecordId: input.acpxRecordId
})
}).where("session_key", "=", key));
}, { env: scope.env });
}, options);
if (!updated.found) throw new Error(`ACP owner database became unavailable: ${updated.reason}`);
}
//#endregion
//#region src/infra/state-migrations.plugin-doctor-context.ts
function resolveDoctorSessionIdentityEvidence(params) {
if (params.requests.length > 512) throw new Error("Plugin doctor session evidence batch exceeds the maximum size.");
const probes = [];
for (const [index, request] of params.requests.entries()) {
const agentId = normalizeAgentId(request.agentId);
let targets = params.targetsByAgent.get(agentId);
if (targets === void 0) {
try {
const resolved = resolveExistingAgentSessionStoreTargetsReadOnlyResult(params.config, agentId, {
cache: params.cache,
env: params.env
});
if (!resolved.available) targets = null;
else {
const candidates = resolved.targets.length ? resolved.targets : [{
agentId,
storePath: resolveSessionStorePathCore(params.config.session?.store, {
agentId,
env: params.env
})
}];
targets = dedupeSessionStoreTargetsBySqliteTarget(candidates, {
defaultAgentId: agentId,
env: params.env
});
}
} catch {
targets = null;
}
params.targetsByAgent.set(agentId, targets);
}
for (const target of targets ?? []) probes.push({
...target,
env: params.env,
index,
sessionId: request.sessionId
});
}
const evidence = readSessionIdentityEvidenceBatch(probes);
const observedByRequest = params.requests.map(() => []);
for (const [position, observed] of evidence.entries()) observedByRequest[probes[position].index].push(observed);
return params.requests.map((request, index) => {
const observed = observedByRequest[index];
const current = observed.filter((entry) => entry.status === "current");
if (!observed.length || observed.some((entry) => entry.status === "unknown") || current.length > 1) return {
...request,
state: "unknown"
};
return current[0] ? {
...request,
state: "current",
sessionKey: current[0].sessionKey
} : {
...request,
state: "absent"
};
});
}
/** Re-assert the caller's authority before every write, so a queue handle retained
* past the locked repair section fails instead of mutating durable rows. */
function guardIngressQueueMutations(queue, assertCurrent) {
const guarded = {
...queue,
enqueue: (...args) => {
assertCurrent();
return queue.enqueue(...args);
},
claimNext: (...args) => {
assertCurrent();
return queue.claimNext(...args);
},
claim: (...args) => {
assertCurrent();
return queue.claim(...args);
},
complete: (...args) => {
assertCurrent();
return queue.complete(...args);
},
release: (...args) => {
assertCurrent();
return queue.release(...args);
},
fail: (...args) => {
assertCurrent();
return queue.fail(...args);
},
delete: (...args) => {
assertCurrent();
return queue.delete(...args);
},
recoverStaleClaims: (recoverOptions) => {
assertCurrent();
if (!recoverOptions) return queue.recoverStaleClaims();
const { shouldRecover, shouldRecoverCorrupt, ...rest } = recoverOptions;
const guardedRecovery = { ...rest };
if (shouldRecover) guardedRecovery.shouldRecover = async (claim) => {
const decision = await shouldRecover(claim);
assertCurrent();
return decision;
};
if (shouldRecoverCorrupt) guardedRecovery.shouldRecoverCorrupt = async (claim) => {
const decision = await shouldRecoverCorrupt(claim);
assertCurrent();
return decision;
};
return queue.recoverStaleClaims(guardedRecovery);
},
prune: (...args) => {
assertCurrent();
return queue.prune(...args);
}
};
const refreshClaim = queue.refreshClaim?.bind(queue);
if (refreshClaim) guarded.refreshClaim = (...args) => {
assertCurrent();
return refreshClaim(...args);
};
const resubmit = queue.resubmit?.bind(queue);
if (resubmit) guarded.resubmit = (...args) => {
assertCurrent();
return resubmit(...args);
};
return guarded;
}
/** Build a genuinely read-only object rather than a narrowed view of the queue.
* A `Pick<...>` return type would still hand the caller every mutating method at
* runtime, so the boundary has to exist in the value, not only in the type. */
function projectIngressQueueForInspection(queue) {
const listFailed = queue.listFailed?.bind(queue);
const projection = {
listPending: (...args) => queue.listPending(...args),
listClaims: () => queue.listClaims()
};
if (listFailed) projection.listFailed = (...args) => listFailed(...args);
return projection;
}
function buildChannelIngressQueueAccess(options) {
const { channelIds, stateDir, mutation } = options;
return channelIds.map((channelId) => {
const open = (openOptions, access) => createChannelIngressQueue({
channelId,
...openOptions?.accountId === void 0 ? {} : { accountId: openOptions.accountId },
stateDir,
access
});
const access = {
channelId,
openChannelIngressQueueForInspection: (openOptions) => projectIngressQueueForInspection(open(openOptions, "read-only")),
listChannelIngressQueueAccountIds: () => listChannelIngressQueueAccountIdsReadOnly({
channelId,
stateDir
})
};
if (mutation) {
const assertCurrent = () => mutation.assertCurrent();
access.openChannelIngressQueue = (openOptions) => {
assertCurrent();
return guardIngressQueueMutations(open(openOptions, "read-write"), assertCurrent);
};
}
return access;
});
}
function createPluginDoctorStateMigrationContext(params) {
const { pluginId, env } = params;
const cache = /* @__PURE__ */ new Map();
const targetsByAgent = /* @__PURE__ */ new Map();
const context = {
inspectAcpSessionClaims: async () => {
params.repairAuthority?.assertCurrent();
const evidence = await inspectAcpSessionClaimsForDoctor(params);
params.repairAuthority?.assertCurrent();
return evidence;
},
getPluginStateCapacity: () => getPluginStateCapacity(pluginId, env),
importPluginStateEntries(options, entries) {
importPluginStateEntriesForDoctor(pluginId, {
...options,
env: options.env ?? env
}, entries);
},
openPluginStateKeyedStore(options) {
return createPluginStateKeyedStore(pluginId, {
...options,
env: options.env ?? env
});
},
readPluginStateEntriesInKeyRange(namespace, range) {
params.repairAuthority?.assertCurrent();
return pluginStateDoctorEntriesInKeyRange({
pluginId,
namespace,
...range,
env
});
},
async readSessionIdentityEvidenceBatch(requests) {
params.repairAuthority?.assertCurrent();
const evidence = resolveDoctorSessionIdentityEvidence({
cache,
config: params.config,
env,
requests,
targetsByAgent
});
params.repairAuthority?.assertCurrent();
return evidence;
}
};
if (params.channelIngress) context.channelIngressQueues = buildChannelIngressQueueAccess(params.channelIngress);
if (params.repairAuthority) {
const authority = params.repairAuthority;
context.updateAcpSessionIdentity = (input) => updateAcpSessionIdentityForDoctor(params, authority, input);
context.deletePluginStateEntriesIfUnchanged = (namespace, entries) => {
authority.assertCurrent();
return pluginStateDeleteEntriesIfUnchanged({
pluginId,
namespace,
entries,
env,
assertOwnedInTransaction: (database) => authority.assertOwnedInTransaction(database)
});
};
}
return context;
}
//#endregion
//#region src/infra/state-migrations.plugin-doctor.ts
const PLUGIN_DOCTOR_MIGRATION_LOCK_TIMEOUT_MS = 250;
const PLUGIN_DOCTOR_MIGRATION_LOCK_POLL_INTERVAL_MS = 25;
async function collectPluginDoctorStateMigrationPlans(input, params) {
const plans = [];
const { config, env } = input;
for (const entry of listPluginDoctorStateMigrationEntries({
config,
env
})) {
if (entry.migration.phase !== params.phase || entry.migration.doctorOnly === true && params.includeDoctorOnly !== true) continue;
let detected;
try {
detected = await entry.migration.detectLegacyState({
...input,
serviceWorkspaceDir: tryResolveConfiguredAgentWorkspaceDir(config, env) ?? resolveDefaultAgentWorkspaceDir(env),
context: createPluginDoctorStateMigrationContext({
pluginId: entry.pluginId,
env,
config,
repairAuthority: params.repairAuthority,
...entry.trustedForDurableStores ?? true ? { channelIngress: {
channelIds: entry.channelIds ?? [],
stateDir: input.stateDir
} } : {}
})
});
} catch (err) {
params.warnings?.push(`Failed detecting ${entry.migration.label}: ${String(err)}`);
continue;
}
if (detected?.preview.length) plans.push({
pluginId: entry.pluginId,
channelIds: entry.channelIds,
trustedForDurableStores: entry.trustedForDurableStores,
migration: entry.migration,
preview: detected.preview
});
}
return plans;
}
async function runPluginDoctorStateMigrationPlans(params) {
const input = {
config: params.config,
env: params.env,
stateDir: params.detected.stateDir,
oauthDir: params.detected.oauthDir
};
const warnings = [];
const refreshedPlans = await collectPluginDoctorStateMigrationPlans(input, {
includeDoctorOnly: params.detected.doctorOnlyStateMigrations,
warnings
});
const hasDetectorFailure = warnings.length > 0;
const migrated = await migratePluginDoctorStatePlans(input, refreshedPlans.length > 0 || hasDetectorFailure ? refreshedPlans : params.detected.pluginPlans?.plans ?? []);
return {
...migrated,
warnings: [...warnings, ...migrated.warnings]
};
}
async function migratePluginDoctorStatePlans(input, plans, repairAuthority) {
const changes = [];
const warnings = [];
const notices = [];
if (plans.length === 0) return {
changes,
warnings
};
let ingressMutationActive = false;
const assertIngressMutationCurrent = () => {
if (!ingressMutationActive) throw new Error("Plugin Doctor ingress queue access has expired.");
repairAuthority?.assertCurrent();
};
const migrate = async () => {
ingressMutationActive = true;
try {
return await migrateWithIngressAuthority();
} finally {
ingressMutationActive = false;
}
};
const migrateWithIngressAuthority = async () => {
for (const plan of plans) try {
repairAuthority?.assertCurrent();
const result = await plan.migration.migrateLegacyState({
...input,
serviceWorkspaceDir: tryResolveConfiguredAgentWorkspaceDir(input.config, input.env) ?? resolveDefaultAgentWorkspaceDir(input.env),
context: createPluginDoctorStateMigrationContext({
pluginId: plan.pluginId,
env: input.env,
config: input.config,
repairAuthority,
...plan.trustedForDurableStores ?? true ? { channelIngress: {
channelIds: plan.channelIds ?? [],
stateDir: input.stateDir,
mutation: { assertCurrent: assertIngressMutationCurrent }
} } : {}
})
});
repairAuthority?.assertCurrent();
changes.push(...result.changes);
warnings.push(...result.warnings);
notices.push(...result.notices ?? []);
} catch (err) {
warnings.push(`Failed migrating ${plan.migration.label}: ${String(err)}`);
}
return notices.length > 0 ? {
changes,
warnings,
notices
} : {
changes,
warnings
};
};
if (repairAuthority) return migrate();
let lock;
try {
lock = await acquireGatewayLock({
allowInTests: true,
env: {
...input.env,
OPENCLAW_STATE_DIR: input.stateDir
},
pollIntervalMs: PLUGIN_DOCTOR_MIGRATION_LOCK_POLL_INTERVAL_MS,
role: "sqlite-maintenance",
timeoutMs: PLUGIN_DOCTOR_MIGRATION_LOCK_TIMEOUT_MS
});
} catch (error) {
return {
changes,
warnings: [`Skipped plugin doctor state migrations because exclusive state ownership is unavailable: ${String(error)}`]
};
}
if (!lock) return {
changes,
warnings: ["Skipped plugin doctor state migrations because exclusive state ownership is unavailable"]
};
try {
return await migrate();
} finally {
await lock.release();
}
}
/** Detect after canonical inspection; destructive repair also requires offline maintenance ownership. */
async function runPostSessionPluginDoctorStateRepairs(params) {
const stateDir = resolveStateDir(params.env);
const input = {
config: params.config,
env: params.env,
stateDir,
oauthDir: resolveOAuthDir(params.env, stateDir)
};
const run = async (repairAuthority) => {
const warnings = [];
repairAuthority?.assertCurrent();
const plans = await collectPluginDoctorStateMigrationPlans(input, {
includeDoctorOnly: true,
phase: "after-session-repair",
repairAuthority,
warnings
});
if (!repairAuthority) return {
changes: [],
warnings: [
...warnings,
...plans.flatMap((plan) => plan.preview),
...plans.length ? ["Run \"openclaw doctor --fix\" to repair plugin session ownership."] : []
]
};
const result = await migratePluginDoctorStatePlans(input, plans, repairAuthority);
return {
...result,
warnings: [...warnings, ...result.warnings]
};
};
const maintenance = params.maintenanceAuthority;
if (!maintenance) return run();
maintenance.assertCurrent();
try {
return await withAgentDatabaseMaintenanceLease({ env: params.env }, async (agentLease) => withPluginLifecycleLease({
env: params.env,
waitMs: 5e3
}, async (pluginLease) => {
let active = true;
const assertCurrent = () => {
if (!active) throw new Error("Plugin Doctor repair authority has expired.");
maintenance.assertCurrent();
};
const authority = {
assertCurrent() {
assertCurrent();
agentLease.assertOwned();
pluginLease.assertOwned();
},
assertOwnedInTransaction(database) {
assertCurrent();
agentLease.assertOwnedInTransaction(database);
pluginLease.assertOwnedInTransaction(database);
}
};
try {
return await run(authority);
} finally {
active = false;
}
}));
} catch (error) {
return {
changes: [],
warnings: [`Skipped plugin session repair: ${String(error)}. Stop active agents and run openclaw doctor --fix again.`]
};
}
}
async function autoMigrateLegacyPluginDoctorState(params) {
const env = params.env ?? process.env;
const stateDirResult = await autoMigrateLegacyStateDir({
env,
homedir: params.homedir,
log: params.log
});
const stateDir = resolveStateDir(env, params.homedir ?? os.homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const stateSchema = repairOpenClawStateDatabaseSchemaIfNeeded({ env: {
...env,
OPENCLAW_STATE_DIR: stateDir
} });
const changes = [...stateDirResult.changes, ...stateSchema.changes];
const warnings = [...stateDirResult.warnings, ...stateSchema.warnings];
const notices = [...stateDirResult.notices ?? []];
if (stateSchema.warnings.length > 0 && params.doctorOnlyStateMigrations !== true) throw new Error(formatStartupMigrationFailure(stateSchema.warnings));
const input = {
config: params.config,
env,
stateDir,
oauthDir
};
const plans = stateSchema.warnings.length > 0 ? [] : await collectPluginDoctorStateMigrationPlans(input, {
includeDoctorOnly: params.doctorOnlyStateMigrations === true,
warnings
});
const migrated = await migratePluginDoctorStatePlans(input, plans);
changes.push(...migrated.changes);
warnings.push(...migrated.warnings);
notices.push(...migrated.notices ?? []);
return {
migrated: stateDirResult.migrated || stateSchema.changes.length > 0 || plans.length > 0,
skipped: false,
changes,
warnings,
...notices.length > 0 ? { notices } : {}
};
}
//#endregion
export { runPostSessionPluginDoctorStateRepairs as i, collectPluginDoctorStateMigrationPlans as n, runPluginDoctorStateMigrationPlans as r, autoMigrateLegacyPluginDoctorState as t };