UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

623 lines (622 loc) 22.7 kB
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 };