From 0b409a0348c68acf5f5a308ed73a406cf5b2a0f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 10:41:36 -0700 Subject: [PATCH] refactor(gateway): split session handlers (#107677) * refactor(gateway): split session handlers * chore(gateway): drop obsolete max-lines suppression * test(gateway): preserve session boundary ratchets * test(gateway): update session boundary fixtures * style(auto-reply): restore formatter compliance --- config/max-lines-baseline.txt | 1 - scripts/check-session-accessor-boundary.mjs | 26 +- ...eck-session-transcript-reader-boundary.mjs | 20 +- .../reply/reply-config-runtime-mode.ts | 2 +- src/gateway/server-methods/sessions-abort.ts | 299 ++ .../server-methods/sessions-compact.ts | 457 ++ .../sessions-compaction-checkpoints.ts | 435 ++ .../sessions-compaction-queries.ts | 107 + .../sessions-compaction-runner.ts | 75 + src/gateway/server-methods/sessions-create.ts | 456 ++ src/gateway/server-methods/sessions-delete.ts | 436 ++ .../server-methods/sessions-dispatch.ts | 165 + src/gateway/server-methods/sessions-groups.ts | 85 + .../server-methods/sessions-messaging.ts | 428 ++ .../server-methods/sessions-mutations.ts | 426 ++ src/gateway/server-methods/sessions-read.ts | 437 ++ src/gateway/server-methods/sessions-shared.ts | 309 ++ .../server-methods/sessions-subscriptions.ts | 176 + src/gateway/server-methods/sessions.ts | 4007 +---------------- .../check-session-accessor-boundary.test.ts | 30 +- ...session-transcript-reader-boundary.test.ts | 14 + 21 files changed, 4403 insertions(+), 3988 deletions(-) create mode 100644 src/gateway/server-methods/sessions-abort.ts create mode 100644 src/gateway/server-methods/sessions-compact.ts create mode 100644 src/gateway/server-methods/sessions-compaction-checkpoints.ts create mode 100644 src/gateway/server-methods/sessions-compaction-queries.ts create mode 100644 src/gateway/server-methods/sessions-compaction-runner.ts create mode 100644 src/gateway/server-methods/sessions-create.ts create mode 100644 src/gateway/server-methods/sessions-delete.ts create mode 100644 src/gateway/server-methods/sessions-dispatch.ts create mode 100644 src/gateway/server-methods/sessions-groups.ts create mode 100644 src/gateway/server-methods/sessions-messaging.ts create mode 100644 src/gateway/server-methods/sessions-mutations.ts create mode 100644 src/gateway/server-methods/sessions-read.ts create mode 100644 src/gateway/server-methods/sessions-shared.ts create mode 100644 src/gateway/server-methods/sessions-subscriptions.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6041517c31bd..bb6eee0817e5 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -910,7 +910,6 @@ src/gateway/server-methods/send.test.ts src/gateway/server-methods/send.ts src/gateway/server-methods/server-methods.test.ts src/gateway/server-methods/sessions-files.ts -src/gateway/server-methods/sessions.ts src/gateway/server-methods/skills.ts src/gateway/server-methods/talk.test.ts src/gateway/server-methods/talk.ts diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index a36008dd232f..2a530b2f7bd5 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -76,6 +76,24 @@ export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([ "updateSessionStore", ]); +const gatewaySessionServerMethodFiles = [ + "src/gateway/server-methods/sessions-abort.ts", + "src/gateway/server-methods/sessions-compact.ts", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + "src/gateway/server-methods/sessions-compaction-queries.ts", + "src/gateway/server-methods/sessions-compaction-runner.ts", + "src/gateway/server-methods/sessions-create.ts", + "src/gateway/server-methods/sessions-delete.ts", + "src/gateway/server-methods/sessions-dispatch.ts", + "src/gateway/server-methods/sessions-groups.ts", + "src/gateway/server-methods/sessions-messaging.ts", + "src/gateway/server-methods/sessions-mutations.ts", + "src/gateway/server-methods/sessions-read.ts", + "src/gateway/server-methods/sessions-shared.ts", + "src/gateway/server-methods/sessions-subscriptions.ts", + "src/gateway/server-methods/sessions.ts", +]; + export const migratedSessionAccessorFiles = new Set([ "packages/memory-host-sdk/src/host/session-files.ts", "src/acp/control-plane/manager.background-task.ts", @@ -120,7 +138,7 @@ export const migratedSessionAccessorFiles = new Set([ "src/gateway/server-methods/chat.ts", "src/gateway/sessions-resolve.ts", "src/gateway/server-methods/sessions-files.ts", - "src/gateway/server-methods/sessions.ts", + ...gatewaySessionServerMethodFiles, "src/gateway/server-session-events.ts", "src/gateway/session-reset-service.ts", "src/infra/outbound/message-action-tts.ts", @@ -197,7 +215,7 @@ export const migratedSessionAccessorWriteFiles = new Set([ "src/config/sessions/goals.ts", "src/gateway/boot.ts", "src/gateway/server-methods/chat.ts", - "src/gateway/server-methods/sessions.ts", + ...gatewaySessionServerMethodFiles, "src/gateway/server-node-events.ts", "src/gateway/session-compaction-checkpoints.ts", "src/infra/outbound/outbound-session.ts", @@ -218,7 +236,7 @@ export const migratedTranscriptWriterFiles = new Set([ ]); export const migratedSessionCompactManualTrimFiles = new Set([ - "src/gateway/server-methods/sessions.ts", + "src/gateway/server-methods/sessions-compact.ts", ]); export const migratedSessionLifecycleCleanupFiles = new Set([ @@ -863,7 +881,7 @@ export async function main() { sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]), skipFile: (filePath) => normalizeRelativePath(path.relative(repoRoot, filePath)) !== - "src/gateway/server-methods/sessions.ts", + "src/gateway/server-methods/sessions-create.ts", findViolations: findGatewaySessionCreateLifecycleViolations, }); const manualCompactTrimViolations = await collectFileViolations({ diff --git a/scripts/check-session-transcript-reader-boundary.mjs b/scripts/check-session-transcript-reader-boundary.mjs index bd87dd8d7317..2da5a55bcf2b 100644 --- a/scripts/check-session-transcript-reader-boundary.mjs +++ b/scripts/check-session-transcript-reader-boundary.mjs @@ -51,6 +51,24 @@ const transcriptReaderNames = new Set([ const storageSpecificTranscriptReaderAliasNames = new Set(["readSessionMessagesFromFileAsync"]); +const gatewaySessionServerMethodFiles = [ + "src/gateway/server-methods/sessions-abort.ts", + "src/gateway/server-methods/sessions-compact.ts", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + "src/gateway/server-methods/sessions-compaction-queries.ts", + "src/gateway/server-methods/sessions-compaction-runner.ts", + "src/gateway/server-methods/sessions-create.ts", + "src/gateway/server-methods/sessions-delete.ts", + "src/gateway/server-methods/sessions-dispatch.ts", + "src/gateway/server-methods/sessions-groups.ts", + "src/gateway/server-methods/sessions-messaging.ts", + "src/gateway/server-methods/sessions-mutations.ts", + "src/gateway/server-methods/sessions-read.ts", + "src/gateway/server-methods/sessions-shared.ts", + "src/gateway/server-methods/sessions-subscriptions.ts", + "src/gateway/server-methods/sessions.ts", +]; + export const migratedSessionTranscriptReaderFiles = new Set([ "src/agents/main-session-restart-recovery.ts", "src/agents/subagent-announce-output.test.ts", @@ -73,7 +91,7 @@ export const migratedSessionTranscriptReaderFiles = new Set([ "src/gateway/server-methods/chat.ts", "src/gateway/server-methods/sessions-files.test.ts", "src/gateway/server-methods/sessions-files.ts", - "src/gateway/server-methods/sessions.ts", + ...gatewaySessionServerMethodFiles, "src/gateway/server-session-events.ts", "src/gateway/session-history-state.test.ts", "src/gateway/session-history-state.ts", diff --git a/src/auto-reply/reply/reply-config-runtime-mode.ts b/src/auto-reply/reply/reply-config-runtime-mode.ts index 5ba9e0f6f8d6..9198f2821189 100644 --- a/src/auto-reply/reply/reply-config-runtime-mode.ts +++ b/src/auto-reply/reply/reply-config-runtime-mode.ts @@ -22,6 +22,6 @@ export function usesFullReplyRuntime(config: unknown): boolean { return Boolean( config && typeof config === "object" && - replyConfigRuntimeModes.get(config as OpenClawConfig) === "full" + replyConfigRuntimeModes.get(config as OpenClawConfig) === "full", ); } diff --git a/src/gateway/server-methods/sessions-abort.ts b/src/gateway/server-methods/sessions-abort.ts new file mode 100644 index 000000000000..b6cbdde2a999 --- /dev/null +++ b/src/gateway/server-methods/sessions-abort.ts @@ -0,0 +1,299 @@ +// Session active-run cancellation and agent-scope resolution. +import { expectDefined } from "@openclaw/normalization-core"; +import { + normalizeOptionalString, + readStringValue, +} from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsAbortParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveSessionKeyForRun } from "../server-session-key.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { + resolveSessionStoreAgentId, + resolveSessionStoreKey, + resolveStoredSessionKeyForAgentStore, + resolveStoredSessionOwnerAgentId, +} from "../session-store-key.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; +import { resolveWorkerSessionTarget } from "../worker-environments/session-target.js"; +import { setGatewayDedupeEntry } from "./agent-job.js"; +import { chatHandlers } from "./chat.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { requireSessionKey } from "./sessions-shared.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export function resolveAbortSessionKey(params: { + context: Pick; + requestedKey: string; + canonicalKey: string; + activeRunSessionKey?: string; + aliasKeys?: string[]; +}): string { + if (params.activeRunSessionKey) { + return params.activeRunSessionKey; + } + const candidates = [params.canonicalKey, params.requestedKey, ...(params.aliasKeys ?? [])]; + for (const active of params.context.chatAbortControllers.values()) { + if (active.controlUiVisible === false) { + continue; + } + for (const candidate of candidates) { + if (active.sessionKey === candidate) { + return candidate; + } + } + } + return params.requestedKey; +} + +function resolveSessionKeyAgentId( + sessionKey: string | undefined, + cfg: OpenClawConfig, +): string | undefined { + const key = normalizeOptionalString(sessionKey); + if (!key) { + return undefined; + } + if (!parseAgentSessionKey(key) && key.toLowerCase().startsWith("agent:")) { + return undefined; + } + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); + return resolveSessionStoreAgentId(cfg, canonicalKey); +} + +function sessionKeyBelongsToAgent( + sessionKey: string | undefined, + agentId: string, + cfg: OpenClawConfig, +): boolean { + const key = normalizeOptionalString(sessionKey); + if (cfg.session?.scope === "global" && key?.toLowerCase() === "global") { + return true; + } + const sessionAgentId = resolveSessionKeyAgentId(sessionKey, cfg); + return Boolean(sessionAgentId && sessionAgentId === normalizeAgentId(agentId)); +} + +function resolveScopedAbortKey(params: { + cfg: OpenClawConfig; + key: string | undefined; + agentId: string | undefined; +}): string | undefined { + const key = normalizeOptionalString(params.key); + if (!key) { + return undefined; + } + const requestedAgentId = normalizeOptionalString(params.agentId); + if (!requestedAgentId) { + return key; + } + const scopedAgentId = normalizeAgentId(requestedAgentId); + const ownerAgentId = resolveStoredSessionOwnerAgentId({ + cfg: params.cfg, + agentId: scopedAgentId, + sessionKey: key, + }); + if (ownerAgentId && ownerAgentId !== scopedAgentId) { + return undefined; + } + return resolveStoredSessionKeyForAgentStore({ + cfg: params.cfg, + agentId: scopedAgentId, + sessionKey: key, + }); +} + +export const sessionAbortHandlers: GatewayRequestHandlers = { + "sessions.abort": async ({ req, params, respond, context, client, isWebchatConnect }) => { + if (!assertValidParams(params, validateSessionsAbortParams, "sessions.abort", respond)) { + return; + } + const p = params; + const cfg = context.getRuntimeConfig(); + const requestedRunId = readStringValue(p.runId); + const requestedKey = normalizeOptionalString(p.key); + const requestedParamAgentId = normalizeOptionalString(p.agentId); + const workerRunSessionId = requestedRunId + ? asWorkerInferenceControl(context.workerEnvironmentService)?.resolveInferenceSessionForRunId( + requestedRunId, + ) + : undefined; + const workerRunTarget = workerRunSessionId + ? resolveWorkerSessionTarget(cfg, workerRunSessionId) + : undefined; + const scopedRequestedKey = resolveScopedAbortKey({ + cfg, + key: requestedKey, + agentId: requestedParamAgentId, + }); + if (requestedKey && requestedParamAgentId && !scopedRequestedKey) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), + ); + return; + } + const requestedKeyAgentId = scopedRequestedKey + ? resolveSessionKeyAgentId(scopedRequestedKey, cfg) + : undefined; + const activeRun = requestedRunId ? context.chatAbortControllers.get(requestedRunId) : undefined; + const activeRunSessionKey = activeRun?.sessionKey; + const activeRunAgentId = normalizeOptionalString(activeRun?.agentId); + const inferredRunAgentId = + requestedParamAgentId ?? + (requestedRunId && scopedRequestedKey?.toLowerCase() === "global" + ? activeRunAgentId + : undefined) ?? + requestedKeyAgentId ?? + workerRunTarget?.agentId ?? + (requestedRunId && !activeRunSessionKey ? resolveDefaultAgentId(cfg) : undefined); + const requestedRunAgentId = requestedRunId + ? inferredRunAgentId + ? normalizeAgentId(inferredRunAgentId) + : undefined + : undefined; + const scopedActiveRunSessionKey = activeRunSessionKey + ? requestedRunAgentId + ? sessionKeyBelongsToAgent(activeRunSessionKey, requestedRunAgentId, cfg) + ? activeRunSessionKey + : undefined + : activeRunSessionKey + : undefined; + const keyCandidate = + scopedRequestedKey ?? + scopedActiveRunSessionKey ?? + (requestedRunId + ? resolveSessionKeyForRun(requestedRunId, { + agentId: requestedRunAgentId ?? resolveDefaultAgentId(cfg), + }) + : undefined) ?? + workerRunTarget?.sessionKey; + if (!keyCandidate && requestedRunId) { + respond(true, { ok: true, abortedRunId: null, status: "no-active-run" }); + return; + } + const key = requireSessionKey(keyCandidate, respond); + if (!key) { + return; + } + const requestedGlobalAgent = resolveRequestedGlobalAgentId( + cfg, + key, + requestedParamAgentId ?? requestedRunAgentId, + ); + if (!requestedGlobalAgent.ok) { + respond(false, undefined, requestedGlobalAgent.error); + return; + } + const requestedGlobalAgentId = requestedGlobalAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedGlobalAgentId }); + const requestedKeyAliases = + requestedKey && + requestedKey !== key && + (!requestedParamAgentId || sessionKeyBelongsToAgent(requestedKey, requestedParamAgentId, cfg)) + ? [requestedKey] + : undefined; + const resolvedAbortSessionKey = resolveAbortSessionKey({ + context, + requestedKey: key, + canonicalKey, + activeRunSessionKey: scopedActiveRunSessionKey, + aliasKeys: requestedKeyAliases, + }); + const abortSessionKey = + canonicalKey === "global" && requestedGlobalAgentId ? "global" : resolvedAbortSessionKey; + const abortAgentId = + abortSessionKey === "global" ? (requestedGlobalAgentId ?? activeRunAgentId) : undefined; + // Capture run kinds before the abort because abortChatRunById deletes entries + // from chatAbortControllers synchronously. We use this snapshot to choose the + // correct dedupe namespace: agent-kind runs use "agent:" (their runId equals + // their idempotency key), while chat-send runs use "chat:" so the abort + // snapshot does not collide with the agent RPC dedupe cache. + const preAbortRunKinds = new Map(); + if (requestedRunId) { + preAbortRunKinds.set(requestedRunId, activeRun?.kind); + } else { + for (const [rid, entry] of context.chatAbortControllers) { + preAbortRunKinds.set(rid, entry.kind); + } + } + let abortedRunId: string | null = null; + await expectDefined( + chatHandlers["chat.abort"], + "chat.abort handler", + )({ + req, + params: { + sessionKey: abortSessionKey, + runId: requestedRunId, + ...(abortAgentId ? { agentId: abortAgentId } : {}), + }, + respond: (ok, payload, error, meta) => { + if (!ok) { + respond(ok, payload, error, meta); + return; + } + const runIds = + payload && + typeof payload === "object" && + Array.isArray((payload as { runIds?: unknown[] }).runIds) + ? (payload as { runIds: unknown[] }).runIds.filter((value): value is string => + Boolean(normalizeOptionalString(value)), + ) + : []; + const firstAbortedRunId = runIds[0] ?? null; + abortedRunId = firstAbortedRunId; + const workerOnly = Boolean(workerRunSessionId && !activeRun); + if (firstAbortedRunId && !workerOnly) { + const endedAt = Date.now(); + const runKind = preAbortRunKinds.get(firstAbortedRunId); + const dedupePrefix = runKind === "agent" ? "agent" : "chat"; + setGatewayDedupeEntry({ + dedupe: context.dedupe, + key: `${dedupePrefix}:${firstAbortedRunId}`, + entry: { + ts: endedAt, + ok: true, + payload: { + status: "timeout", + runId: firstAbortedRunId, + ...(abortAgentId ? { agentId: abortAgentId } : {}), + stopReason: "rpc", + endedAt, + }, + }, + }); + } + respond( + true, + { + ok: true, + abortedRunId, + status: abortedRunId ? "aborted" : "no-active-run", + }, + undefined, + meta, + ); + }, + context, + client, + isWebchatConnect, + }); + if (abortedRunId) { + emitSessionsChanged(context, { + sessionKey: canonicalKey, + ...(canonicalKey === "global" && abortAgentId ? { agentId: abortAgentId } : {}), + reason: "abort", + }); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-compact.ts b/src/gateway/server-methods/sessions-compact.ts new file mode 100644 index 000000000000..f94a5d8ceef5 --- /dev/null +++ b/src/gateway/server-methods/sessions-compact.ts @@ -0,0 +1,457 @@ +// Manual transcript trimming and model-backed session compaction. +import { randomUUID } from "node:crypto"; +import { + ErrorCodes, + errorShape, + validateSessionsCompactParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; +import { + resolveSessionWorkStartError, + SESSION_LIFECYCLE_CHANGED_ERROR_REASON, +} from "../../config/sessions.js"; +import { + applySessionPatchProjection, + loadTranscriptEvents, + preflightSessionTranscriptForManualCompact, + trimSessionTranscriptForManualCompact, +} from "../../config/sessions/session-accessor.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { + interruptSessionWorkAdmissions, + runExclusiveSessionLifecycleMutation, + SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, +} from "../../sessions/session-lifecycle-admission.js"; +import { recordSessionCompacted } from "../../sessions/session-state-events.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { migrateAndPruneGatewaySessionStoreKey } from "../session-utils.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { runGatewaySessionCompaction } from "./sessions-compaction-runner.js"; +import { interruptSessionRunIfActive } from "./sessions-messaging.js"; +import { + emitSessionOperation, + loadAccessorSessionEntryForGatewayTarget, + rejectWebchatSessionMutation, + requireSessionKey, + resolveGatewaySessionTargetFromKey, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionCompactHandlers: GatewayRequestHandlers = { + "sessions.compact": async ({ req, params, respond, context, client, isWebchatConnect }) => { + if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "compact", client, isWebchatConnect, respond })) { + return; + } + + const maxLines = + typeof p.maxLines === "number" && Number.isFinite(p.maxLines) + ? Math.max(1, Math.floor(p.maxLines)) + : undefined; + + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); + // Lock + read in a short critical section; transcript work happens outside. + // The projection resolver re-runs gateway key migration on the writer + // snapshot so alias promotion/pruning persists through the accessor. + let compactPrimaryKey = target.canonicalKey; + const compactRead = await applySessionPatchProjection({ + agentId: target.agentId, + storePath, + resolveTarget: ({ entries }) => { + const snapshot = Object.fromEntries( + entries.map(({ sessionKey, entry }) => [sessionKey, entry]), + ); + const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key, + store: snapshot, + agentId: requestedAgentId, + }); + compactPrimaryKey = primaryKey; + return { primaryKey, candidateKeys: migratedTarget.storeKeys }; + }, + // Read-only projection: persist the resolved row unchanged so the alias + // migration above is saved even when compaction bails out below. + project: ({ existingEntry }) => + existingEntry ? { ok: true, entry: existingEntry } : { ok: false }, + }); + const compactTarget = { + entry: compactRead.ok ? compactRead.entry : undefined, + primaryKey: compactPrimaryKey, + }; + const entry = compactTarget.entry; + const sessionId = entry?.sessionId; + if (!sessionId) { + respond( + true, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no sessionId", + }, + undefined, + ); + return; + } + + if (maxLines !== undefined) { + const trimPreflight = await preflightSessionTranscriptForManualCompact( + { + sessionId, + storePath, + sessionKey: compactTarget.primaryKey, + agentId: target.agentId, + }, + { maxLines, sessionFile: entry.sessionFile }, + ); + if (!trimPreflight.compacted) { + respond( + true, + { + ok: true, + key: target.canonicalKey, + compacted: false, + ...("kept" in trimPreflight + ? { kept: trimPreflight.kept } + : { reason: "no transcript" }), + }, + undefined, + ); + return; + } + } else { + const transcriptEvents = await loadTranscriptEvents({ + agentId: target.agentId, + sessionId, + sessionKey: compactTarget.primaryKey, + storePath, + }).catch(() => []); + if (transcriptEvents.length === 0) { + respond( + true, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no transcript", + }, + undefined, + ); + return; + } + } + + const lifecycleRevision = entry.lifecycleRevision; + const lifecycleIdentities = [ + key, + target.canonicalKey, + compactTarget.primaryKey, + sessionId, + lifecycleRevision, + ]; + let sessionStillCurrent = true; + let admittedWorkReleased = true; + try { + await runExclusiveSessionLifecycleMutation({ + scope: storePath, + identities: lifecycleIdentities, + kind: "compaction", + prepare: async () => { + const latestEntry = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgentId, + }).entry; + sessionStillCurrent = Boolean( + latestEntry && + latestEntry.sessionId === sessionId && + latestEntry.lifecycleRevision === lifecycleRevision && + !resolveSessionWorkStartError(target.canonicalKey, latestEntry), + ); + if (!sessionStillCurrent) { + return; + } + // Drop work queued against the pre-compaction transcript before its + // active admission drains and no longer exposes queue cleanup. + clearSessionQueues([key, target.canonicalKey, compactTarget.primaryKey, sessionId]); + admittedWorkReleased = await interruptSessionWorkAdmissions({ + scope: storePath, + identities: lifecycleIdentities, + timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, + }); + }, + run: async () => { + if (!sessionStillCurrent) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} changed before compaction. Retry.`, + { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, + ), + ); + return; + } + if (!admittedWorkReleased) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), + ); + return; + } + + const latestEntry = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgentId, + }).entry; + if ( + !latestEntry || + latestEntry.sessionId !== sessionId || + latestEntry.lifecycleRevision !== lifecycleRevision || + resolveSessionWorkStartError(target.canonicalKey, latestEntry) + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} changed before compaction. Retry.`, + { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, + ), + ); + return; + } + + const interruptResult = await interruptSessionRunIfActive({ + req, + context, + client, + isWebchatConnect, + requestedKey: key, + canonicalKey: target.canonicalKey, + agentId: requestedAgentId, + sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const operationId = randomUUID(); + if (maxLines !== undefined) { + const trimResult = await trimSessionTranscriptForManualCompact( + { + sessionId, + storePath, + sessionKey: compactTarget.primaryKey, + agentId: target.agentId, + }, + { maxLines, sessionFile: latestEntry.sessionFile }, + ); + respond( + true, + { + ok: true, + key: target.canonicalKey, + compacted: trimResult.compacted, + ...(trimResult.compacted + ? { archived: trimResult.archived, kept: trimResult.kept } + : "kept" in trimResult + ? { kept: trimResult.kept } + : { reason: "no transcript" }), + }, + undefined, + ); + if (trimResult.compacted) { + recordSessionCompacted({ + sessionKey: target.canonicalKey, + operationId, + sessionId, + agentId: target.agentId ?? requestedAgentId, + }); + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), + reason: "compact", + compacted: true, + }); + } + return; + } + + const transcriptEvents = await loadTranscriptEvents({ + agentId: target.agentId, + sessionId, + sessionKey: compactTarget.primaryKey, + storePath, + }).catch(() => []); + if (transcriptEvents.length === 0) { + respond( + true, + { + ok: true, + key: target.canonicalKey, + compacted: false, + reason: "no transcript", + }, + undefined, + ); + return; + } + emitSessionOperation(context, { + operationId, + operation: "compact", + phase: "start", + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), + }); + const emitCompactionEnd = (completed: boolean, reason?: string) => + emitSessionOperation(context, { + operationId, + operation: "compact", + phase: "end", + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), + completed, + reason, + }); + let result: Awaited>; + try { + result = await runGatewaySessionCompaction({ + cfg, + entry: latestEntry, + agentId: target.agentId, + sessionId, + sessionKey: target.canonicalKey, + sessionStoreKey: compactTarget.primaryKey, + storePath, + }); + } catch (err) { + emitCompactionEnd(false, formatErrorMessage(err)); + throw err; + } + if (result.ok && result.compacted) { + let persisted: boolean; + try { + // Guarded terminal persist: skip when session ownership rotated + // while compaction ran (sessionId/lifecycleRevision/work-start). + const persistProjection = await applySessionPatchProjection({ + agentId: target.agentId, + storePath, + resolveTarget: () => ({ primaryKey: compactTarget.primaryKey }), + project: ({ existingEntry }) => { + if ( + !existingEntry || + existingEntry.sessionId !== sessionId || + existingEntry.lifecycleRevision !== lifecycleRevision || + resolveSessionWorkStartError(target.canonicalKey, existingEntry) + ) { + return { ok: false }; + } + const entryToUpdate = existingEntry; + entryToUpdate.updatedAt = Date.now(); + entryToUpdate.compactionCount = + Math.max(0, entryToUpdate.compactionCount ?? 0) + 1; + if ( + result.result?.sessionId && + result.result.sessionId !== entryToUpdate.sessionId + ) { + entryToUpdate.sessionId = result.result.sessionId; + } + delete entryToUpdate.inputTokens; + delete entryToUpdate.outputTokens; + delete entryToUpdate.contextBudgetStatus; + if ( + typeof result.result?.tokensAfter === "number" && + Number.isFinite(result.result.tokensAfter) + ) { + entryToUpdate.totalTokens = result.result.tokensAfter; + entryToUpdate.totalTokensFresh = true; + } else { + delete entryToUpdate.totalTokens; + delete entryToUpdate.totalTokensFresh; + } + return { ok: true, entry: entryToUpdate }; + }, + }); + persisted = persistProjection.ok; + } catch (err) { + emitCompactionEnd(false, formatErrorMessage(err)); + throw err; + } + if (!persisted) { + const reason = `Session ${key} changed before compaction completed. Retry.`; + emitCompactionEnd(false, reason); + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, reason, { + details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON }, + }), + ); + return; + } + recordSessionCompacted({ + sessionKey: target.canonicalKey, + operationId, + sessionId: result.result?.sessionId ?? sessionId, + agentId: target.agentId ?? requestedAgentId, + }); + } + + emitCompactionEnd(result.ok && result.compacted, result.reason); + respond( + true, + { + ok: result.ok, + key: target.canonicalKey, + compacted: result.compacted, + reason: result.reason, + result: result.result, + }, + undefined, + ); + if (result.ok) { + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && target.agentId + ? { agentId: target.agentId } + : {}), + reason: "compact", + compacted: result.compacted, + }); + } + }, + }); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(err))); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-compaction-checkpoints.ts b/src/gateway/server-methods/sessions-compaction-checkpoints.ts new file mode 100644 index 000000000000..9d3cc02304d6 --- /dev/null +++ b/src/gateway/server-methods/sessions-compaction-checkpoints.ts @@ -0,0 +1,435 @@ +// Compaction checkpoint branching and restore operations. +import { + ErrorCodes, + errorShape, + validateSessionsCompactionBranchParams, + validateSessionsCompactionRestoreParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; +import { SESSION_LIFECYCLE_CHANGED_ERROR_REASON } from "../../config/sessions.js"; +import { + interruptSessionWorkAdmissions, + runExclusiveSessionLifecycleMutation, + SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, +} from "../../sessions/session-lifecycle-admission.js"; +import { + createFileBackedCompactionCheckpointStore, + getSessionCompactionCheckpoint, +} from "../session-compaction-checkpoints.js"; +import { + buildDashboardSessionKey, + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, +} from "../session-create-service.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { interruptSessionRunIfActive } from "./sessions-messaging.js"; +import { + loadAccessorSessionEntryForGatewayTarget, + rejectWebchatSessionMutation, + requireSessionKey, + resolveSessionWorkerPlacementMutationError, + respondSessionWorkerPlacementMutationError, + type SessionWorkerPlacementMutationError, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +const compactionCheckpointStore = createFileBackedCompactionCheckpointStore(); +const MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE = + "Checkpoint branch and restore are unavailable while model selection is locked."; + +export const sessionCheckpointHandlers: GatewayRequestHandlers = { + "sessions.compaction.branch": async ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionBranchParams, + "sessions.compaction.branch", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey, sessionStoreKey, target, storePath } = + loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + const nextKey = buildDashboardSessionKey(target.agentId); + const branchedSession = await compactionCheckpointStore.branchCheckpointSession({ + agentId: target.agentId, + storePath, + sourceKey: canonicalKey, + sourceStoreKey: sessionStoreKey, + nextKey, + checkpointId, + }); + if ( + branchedSession.status === "missing-checkpoint" || + branchedSession.status === "missing-boundary" + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (branchedSession.status === "missing-session") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + if (branchedSession.status === "model-selection-locked") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } + if (branchedSession.status === "failed") { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to create checkpoint branch transcript"), + ); + return; + } + + respond( + true, + { + ok: true, + sourceKey: canonicalKey, + key: branchedSession.key, + sessionId: branchedSession.entry.sessionId, + checkpoint: branchedSession.checkpoint, + entry: branchedSession.entry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgent.agentId + ? { agentId: requestedAgent.agentId } + : {}), + reason: "checkpoint-branch", + }); + emitSessionsChanged(context, { + sessionKey: branchedSession.key, + reason: "checkpoint-branch", + }); + }, + "sessions.compaction.restore": async ({ + req, + params, + respond, + context, + client, + isWebchatConnect, + }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionRestoreParams, + "sessions.compaction.restore", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "restore", client, isWebchatConnect, respond })) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey, sessionStoreKey, storePath } = + loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + const initialPlacementError = resolveSessionWorkerPlacementMutationError({ + action: "restore", + context, + key, + sessionId: entry.sessionId, + }); + if (initialPlacementError) { + respondSessionWorkerPlacementMutationError(initialPlacementError, respond); + return; + } + const lifecycleIdentities = [ + key, + canonicalKey, + sessionStoreKey, + entry.sessionId, + entry.lifecycleRevision, + ]; + const restoreLockIdentities = [entry.sessionId, entry.lifecycleRevision]; + let admittedWorkReleased = true; + let restoreTargetStillCurrent = true; + let restoreBlockedByModelLock = false; + let restorePlacementError: SessionWorkerPlacementMutationError | undefined; + // Restore replaces the active transcript identity. Hold the same lifecycle fence as + // compaction so neither operation can publish state from the other's obsolete session. + await runExclusiveSessionLifecycleMutation({ + scope: storePath, + identities: restoreLockIdentities, + prepare: async () => { + const current = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + const currentCheckpoint = current.entry + ? getSessionCompactionCheckpoint({ entry: current.entry, checkpointId }) + : undefined; + restoreTargetStillCurrent = + current.entry?.sessionId === entry.sessionId && + current.entry.lifecycleRevision === entry.lifecycleRevision && + currentCheckpoint !== undefined; + if (!restoreTargetStillCurrent) { + return; + } + restoreBlockedByModelLock = current.entry?.modelSelectionLocked === true; + if (restoreBlockedByModelLock) { + return; + } + restorePlacementError = resolveSessionWorkerPlacementMutationError({ + action: "restore", + context, + key, + sessionId: current.entry?.sessionId, + }); + if (restorePlacementError) { + return; + } + clearSessionQueues([ + key, + current.canonicalKey, + current.sessionStoreKey, + current.entry?.sessionId, + ]); + admittedWorkReleased = await interruptSessionWorkAdmissions({ + scope: storePath, + identities: lifecycleIdentities, + timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, + }); + }, + run: async () => { + if (!restoreTargetStillCurrent) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} changed before checkpoint restore. Retry.`, + { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, + ), + ); + return; + } + if (restoreBlockedByModelLock) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } + if (restorePlacementError) { + respondSessionWorkerPlacementMutationError(restorePlacementError, respond); + return; + } + if (!admittedWorkReleased) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), + ); + return; + } + const current = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + if (!current.entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + if (current.entry.modelSelectionLocked === true) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } + const currentCheckpoint = getSessionCompactionCheckpoint({ + entry: current.entry, + checkpointId, + }); + if (!currentCheckpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + const interruptResult = await interruptSessionRunIfActive({ + req, + context, + client, + isWebchatConnect, + requestedKey: key, + canonicalKey: current.canonicalKey, + agentId: requestedAgent.agentId, + sessionId: current.entry.sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const restoredSession = await compactionCheckpointStore.restoreCheckpointSession({ + agentId: requestedAgent.agentId, + storePath, + sessionKey: current.canonicalKey, + sessionStoreKey: current.sessionStoreKey, + checkpointId, + }); + if ( + restoredSession.status === "missing-checkpoint" || + restoredSession.status === "missing-boundary" + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (restoredSession.status === "missing-session") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + if (restoredSession.status === "model-selection-locked") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), + ); + return; + } + if (restoredSession.status === "failed") { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to restore checkpoint transcript"), + ); + return; + } + + respond( + true, + { + ok: true, + key: restoredSession.key, + sessionId: restoredSession.entry.sessionId, + checkpoint: restoredSession.checkpoint, + entry: restoredSession.entry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: current.canonicalKey, + ...(current.canonicalKey === "global" && requestedAgent.agentId + ? { agentId: requestedAgent.agentId } + : {}), + reason: "checkpoint-restore", + }); + }, + }); + }, +}; diff --git a/src/gateway/server-methods/sessions-compaction-queries.ts b/src/gateway/server-methods/sessions-compaction-queries.ts new file mode 100644 index 000000000000..41b9b6299095 --- /dev/null +++ b/src/gateway/server-methods/sessions-compaction-queries.ts @@ -0,0 +1,107 @@ +// Read-only compaction checkpoint queries. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsCompactionGetParams, + validateSessionsCompactionListParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + getSessionCompactionCheckpoint, + listSessionCompactionCheckpoints, +} from "../session-compaction-checkpoints.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { loadAccessorSessionEntryForGatewayTarget, requireSessionKey } from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionCheckpointQueryHandlers: GatewayRequestHandlers = { + "sessions.compaction.list": ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionListParams, + "sessions.compaction.list", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey } = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoints: listSessionCompactionCheckpoints(entry), + }, + undefined, + ); + }, + "sessions.compaction.get": ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionGetParams, + "sessions.compaction.get", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = normalizeOptionalString(p.checkpointId) ?? ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { entry, canonicalKey } = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoint, + }, + undefined, + ); + }, +}; diff --git a/src/gateway/server-methods/sessions-compaction-runner.ts b/src/gateway/server-methods/sessions-compaction-runner.ts new file mode 100644 index 000000000000..9cac98e6cac4 --- /dev/null +++ b/src/gateway/server-methods/sessions-compaction-runner.ts @@ -0,0 +1,75 @@ +// Model-backed compaction request construction. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js"; +import { resolvePersistedSessionRuntimeId } from "../../agents/session-runtime-compat.js"; +import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; +import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { resolveSessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveSessionModelRef } from "../session-utils.js"; + +export async function runGatewaySessionCompaction(params: { + agentId: string; + cfg: OpenClawConfig; + entry: SessionEntry; + sessionId: string; + sessionKey: string; + sessionStoreKey: string; + storePath: string; +}): Promise>> { + const transcriptTarget = await resolveSessionTranscriptRuntimeTarget({ + agentId: params.agentId, + sessionId: params.sessionId, + sessionKey: params.sessionStoreKey, + storePath: params.storePath, + }); + const resolvedModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId); + const workspaceDir = + resolveIngressWorkspaceOverrideForSessionRun({ + spawnedBy: params.entry.spawnedBy, + workspaceDir: params.entry.spawnedWorkspaceDir, + cwd: params.entry.spawnedCwd, + }) ?? resolveAgentWorkspaceDir(params.cfg, params.agentId); + + return await compactEmbeddedAgentSession({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + sessionTarget: { + agentId: params.agentId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }, + allowGatewaySubagentBinding: true, + sessionFile: transcriptTarget.sessionFile, + workspaceDir, + cwd: normalizeOptionalString(params.entry.spawnedCwd), + config: params.cfg, + provider: resolvedModel.provider, + model: resolvedModel.model, + authProfileId: params.entry.authProfileOverride, + authProfileIdSource: + params.entry.authProfileOverrideSource ?? + (params.entry.authProfileOverride + ? typeof params.entry.authProfileOverrideCompactionCount === "number" + ? "auto" + : "user" + : undefined), + agentHarnessId: + params.entry.modelSelectionLocked === true + ? resolvePersistedSessionRuntimeId(params.entry) + : params.entry.agentHarnessId, + modelSelectionLocked: params.entry.modelSelectionLocked === true, + thinkLevel: normalizeThinkLevel(params.entry.thinkingLevel), + reasoningLevel: normalizeReasoningLevel(params.entry.reasoningLevel), + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + trigger: "manual", + }); +} diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts new file mode 100644 index 000000000000..caf6c67c20e0 --- /dev/null +++ b/src/gateway/server-methods/sessions-create.ts @@ -0,0 +1,456 @@ +// Session creation, initial turns, and managed-worktree provisioning. +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { expectDefined } from "@openclaw/normalization-core"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsCreateParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { insideGitCheckout } from "../../agents/worktrees/git.js"; +import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; +import { + buildDashboardSessionKey, + createGatewaySession, + resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, +} from "../session-create-service.js"; +import { resolveSessionStoreAgentId } from "../session-store-key.js"; +import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; +import { loadSessionEntry, resolveGatewaySessionStoreTarget } from "../session-utils.js"; +import { chatHandlers } from "./chat.js"; +import { resolveSessionCatalogCreateTarget } from "./session-catalog.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { + resolveSessionCreateInitialTurn, + shouldAttachPendingMessageSeq, +} from "./session-create-initial-turn.js"; +import { sessionLog } from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionCreateHandlers: GatewayRequestHandlers = { + "sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => { + if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) { + return; + } + const p = params; + const cfg = context.getRuntimeConfig(); + const catalogId = normalizeOptionalString(p.catalogId); + if (catalogId && p.model) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create catalogId cannot include model"), + ); + return; + } + if (catalogId && p.key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create catalogId cannot include key"), + ); + return; + } + const catalogRequestedKey = normalizeOptionalString(p.key) ?? "global"; + const catalogAgentId = catalogId + ? normalizeAgentId( + normalizeOptionalString(p.agentId) ?? + parseAgentSessionKey(catalogRequestedKey)?.agentId ?? + resolveDefaultAgentId(cfg), + ) + : undefined; + const catalogRequestedAgent = catalogAgentId + ? resolveRequestedGlobalAgentId(cfg, catalogRequestedKey, catalogAgentId) + : undefined; + if (catalogRequestedAgent && !catalogRequestedAgent.ok) { + respond(false, undefined, catalogRequestedAgent.error); + return; + } + const catalogTarget = + catalogId && catalogAgentId + ? resolveSessionCatalogCreateTarget(catalogId, catalogAgentId) + : undefined; + if (catalogTarget && !catalogTarget.ok) { + respond( + false, + undefined, + errorShape( + catalogTarget.unknownCatalog ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, + catalogTarget.message, + ), + ); + return; + } + const initialTurn = resolveSessionCreateInitialTurn(p); + if (!initialTurn) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.create attachments require usable content", + ), + ); + return; + } + const { + attachments: initialAttachments, + hasInitialTurn, + message: initialMessage, + } = initialTurn; + const requestedCwd = normalizeOptionalString(p.cwd); + const requestedExecNode = normalizeOptionalString(p.execNode); + if (requestedCwd && p.worktree !== true && !requestedExecNode) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.create cwd requires worktree=true or execNode", + ), + ); + return; + } + const cwdIsAbsolute = + !requestedCwd || + path.isAbsolute(requestedCwd) || + Boolean(requestedExecNode && path.win32.isAbsolute(requestedCwd)); + if (!cwdIsAbsolute) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd must be absolute"), + ); + return; + } + if (requestedExecNode && p.worktree === true) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create worktree cannot target execNode"), + ); + return; + } + const requestedWorktreeBaseRef = normalizeOptionalString(p.worktreeBaseRef); + const requestedWorktreeName = normalizeOptionalString(p.worktreeName); + if ((requestedWorktreeBaseRef || requestedWorktreeName) && p.worktree !== true) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.create worktreeBaseRef/worktreeName require worktree=true", + ), + ); + return; + } + let sessionKey = p.key; + let sessionAgentId = catalogAgentId ?? p.agentId; + let sessionWorktree: Awaited> | undefined; + const sessionExecCwd = requestedExecNode ? requestedCwd : undefined; + let sessionCwd: string | undefined; + let sessionSourceRoot: string | undefined; + let provisionedSessionWorktree = false; + if (p.worktree === true) { + // The normal path stays at operator.write and checks out the configured agent workspace. + // An explicit cwd can target another host checkout, so method-scopes requires admin. + const explicitKey = normalizeOptionalString(p.key); + const requestedKey = explicitKey ?? "global"; + const requestedAgent = resolveRequestedGlobalAgentId(cfg, requestedKey, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const agentId = normalizeAgentId( + requestedAgent.agentId ?? + normalizeOptionalString(p.agentId) ?? + parseAgentSessionKey(requestedKey)?.agentId ?? + resolveDefaultAgentId(cfg), + ); + let targetKey = explicitKey; + let preservesUnspecifiedKey = false; + const parentSessionKey = normalizeOptionalString(p.parentSessionKey); + if ( + !targetKey && + parentSessionKey && + p.emitCommandHooks === true && + !hasInitialTurn && + cfg.session?.dmScope === "main" + ) { + const parent = loadSessionEntry( + parentSessionKey, + requestedAgent.agentId ? { agentId: requestedAgent.agentId } : undefined, + ); + const parentAgentId = normalizeAgentId( + requestedAgent.agentId ?? resolveSessionStoreAgentId(cfg, parent.canonicalKey), + ); + if ( + parent.entry?.sessionId && + parent.canonicalKey === resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }) + ) { + targetKey = parent.canonicalKey; + preservesUnspecifiedKey = true; + } + } + targetKey ??= buildDashboardSessionKey(agentId); + const target = resolveGatewaySessionStoreTarget({ cfg, key: targetKey, agentId }); + sessionKey = preservesUnspecifiedKey ? undefined : targetKey; + sessionAgentId = target.agentId; + const workspace = requestedCwd ?? resolveAgentWorkspaceDir(cfg, target.agentId); + // Subdirectory workspaces are valid: the worktree service resolves the repo root + // via git discovery, so the preflight must accept ancestor .git entries too. + if (!insideGitCheckout(workspace)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "agent workspace is not a git checkout"), + ); + return; + } + try { + const requestedRepository = await managedWorktrees.resolveRepositoryPaths(workspace); + sessionSourceRoot = requestedRepository.sourceRoot; + const existing = managedWorktrees.findLiveByOwner("session", target.canonicalKey); + let existingDirectory = false; + if (existing) { + try { + existingDirectory = fs.lstatSync(existing.path).isDirectory(); + } catch { + // Missing registry targets are replaced; periodic GC retires their stale rows. + } + } + if (existing && existingDirectory) { + if (existing.repoRoot !== requestedRepository.canonicalRoot) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "session worktree belongs to a different repository", + ), + ); + return; + } + // Adopting an existing checkout cannot honor a different name or a + // new base; fail loudly instead of silently ignoring the request. + if ( + (requestedWorktreeName && existing.name !== requestedWorktreeName) || + requestedWorktreeBaseRef + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `session is already bound to worktree ${existing.name} (${existing.branch})`, + ), + ); + return; + } + sessionWorktree = existing; + } else { + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + sessionWorktree = await managedWorktrees.create({ + repoRoot: workspace, + ownerKind: "session", + ownerId: target.canonicalKey, + name: requestedWorktreeName, + baseRef: requestedWorktreeBaseRef, + // Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them + // admin-only so this write-scoped path cannot execute gated repo scripts. + runSetupScript: scopes.includes(ADMIN_SCOPE), + }); + provisionedSessionWorktree = true; + } + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + return; + } + // Nested workspaces run from the matching subdirectory inside the worktree, mirroring + // how the session would have run in the source checkout; the worktree root would + // silently change tool/file scope for subdirectory-configured agents. + sessionCwd = sessionWorktree.path; + try { + const relative = path.relative( + sessionSourceRoot ?? fs.realpathSync(sessionWorktree.repoRoot), + fs.realpathSync(workspace), + ); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + sessionCwd = path.join(sessionWorktree.path, relative); + fs.mkdirSync(sessionCwd, { recursive: true }); + } + } catch { + sessionCwd = sessionWorktree.path; + } + } + let runPayload: Record | undefined; + let runError: unknown; + let runMeta: Record | undefined; + let messageSeq: number | undefined; + const created = await createGatewaySession({ + cfg, + key: sessionKey, + agentId: sessionAgentId, + label: p.label, + ...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }), + parentSessionKey: p.parentSessionKey, + spawnedCwd: sessionCwd, + worktree: sessionWorktree + ? { + id: sessionWorktree.id, + branch: sessionWorktree.branch, + repoRoot: sessionWorktree.repoRoot, + } + : undefined, + execNode: requestedExecNode, + execCwd: sessionExecCwd, + clearExecBinding: !requestedExecNode, + // A plain New Chat that resets an existing session must not inherit its prior worktree cwd. + clearSpawnedCwd: p.worktree !== true, + fork: p.fork, + emitCommandHooks: p.emitCommandHooks, + resetMainWhenUnspecified: !hasInitialTurn, + commandSource: "webchat", + loadGatewayModelCatalog: context.loadGatewayModelCatalog, + afterCreate: hasInitialTurn + ? async ({ key, agentId, entry, storePath }) => { + messageSeq = + (await readSessionMessageCountAsync({ + agentId, + sessionEntry: entry, + sessionId: entry.sessionId, + sessionKey: key, + storePath, + })) + 1; + await expectDefined( + chatHandlers["chat.send"], + "chat.send handler", + )({ + req, + params: { + sessionKey: key, + ...(key === "global" ? { agentId } : {}), + message: initialMessage ?? "", + idempotencyKey: randomUUID(), + ...(initialAttachments ? { attachments: initialAttachments } : {}), + }, + respond: (ok, payload, error, meta) => { + if (ok && payload && typeof payload === "object") { + runPayload = payload as Record; + } else { + runError = error; + } + runMeta = meta; + }, + context, + client, + isWebchatConnect, + }); + } + : undefined, + }); + if (!created.ok) { + if (sessionWorktree && provisionedSessionWorktree) { + try { + await managedWorktrees.remove({ + id: sessionWorktree.id, + reason: "session-create-failed", + force: true, + }); + } catch (error) { + sessionLog.warn( + `failed to clean up worktree after session creation failed: ${formatErrorMessage(error)}`, + ); + } + } + respond(false, undefined, created.error); + return; + } + // Leaving an isolated checkout via a plain New Chat detaches the session from its + // worktree; remove it when lossless so the reset does not orphan a protected worktree. + if (p.worktree !== true) { + try { + const owned = managedWorktrees.findLiveByOwner("session", created.key); + if (owned) { + await managedWorktrees.removeIfLossless(owned.id); + } + } catch (error) { + sessionLog.warn( + `failed to release worktree for reset session ${created.key}: ${formatErrorMessage(error)}`, + ); + } + } + const createdWorktree = sessionWorktree + ? { + id: sessionWorktree.id, + path: sessionWorktree.path, + branch: sessionWorktree.branch, + } + : undefined; + if (created.resetExisting) { + respond( + true, + { + ok: true, + key: created.key, + sessionId: created.entry.sessionId, + entry: created.entry, + resolved: created.resolved, + runStarted: false, + ...(createdWorktree ? { worktree: createdWorktree } : {}), + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: created.key, + ...(created.key === "global" ? { agentId: created.agentId } : {}), + reason: "new", + }); + return; + } + + const runStarted = + runPayload !== undefined && + shouldAttachPendingMessageSeq({ + payload: runPayload, + cached: runMeta?.cached === true, + }); + + respond( + true, + { + ok: true, + key: created.key, + sessionId: created.entry.sessionId, + entry: created.entry, + runStarted, + ...(runPayload ? runPayload : {}), + ...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}), + ...(runError ? { runError } : {}), + resolved: created.resolved, + ...(createdWorktree ? { worktree: createdWorktree } : {}), + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: created.key, + ...(created.key === "global" ? { agentId: created.agentId } : {}), + reason: "create", + }); + if (runStarted) { + emitSessionsChanged(context, { + sessionKey: created.key, + ...(created.key === "global" ? { agentId: created.agentId } : {}), + reason: "send", + }); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-delete.ts b/src/gateway/server-methods/sessions-delete.ts new file mode 100644 index 000000000000..1a9fbb376cf4 --- /dev/null +++ b/src/gateway/server-methods/sessions-delete.ts @@ -0,0 +1,436 @@ +// Destructive session deletion and lifecycle cleanup. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsDeleteParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { + deleteSessionEntryLifecycle, + resolveMainSessionKey, + SESSION_LIFECYCLE_CHANGED_ERROR_REASON, + type SessionEntry, +} from "../../config/sessions.js"; +import { rollbackPluginOwnedSessionEntryLifecycle } from "../../config/sessions/session-accessor.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { isAgentHarnessSessionKey } from "../../sessions/agent-harness-session-key.js"; +import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; +import { + interruptSessionWorkAdmissions, + runExclusiveSessionLifecycleMutation, + SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, +} from "../../sessions/session-lifecycle-admission.js"; +import { handleSessionStateSessionDeleted } from "../../sessions/session-state-events.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { resolveSessionStoreAgentId } from "../session-store-key.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { chatHandlers } from "./chat.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { + loadAccessorSessionEntryForGatewayTarget, + loadSessionsRuntimeModule, + rejectPluginRuntimeDeleteMismatch, + rejectWebchatSessionMutation, + requireSessionKey, + resolveGatewaySessionTargetFromKey, + resolveSessionWorkerPlacementMutationError, + respondSessionWorkerPlacementMutationError, + sessionLog, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionDeleteHandlers: GatewayRequestHandlers = { + "sessions.delete": async ({ req, params, respond, client, isWebchatConnect, context }) => { + if (!assertValidParams(params, validateSessionsDeleteParams, "sessions.delete", respond)) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "delete", client, isWebchatConnect, respond })) { + return; + } + + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); + const mainKey = resolveMainSessionKey(cfg); + const isSelectedNonDefaultGlobal = + target.canonicalKey === "global" && + requestedAgentId !== undefined && + requestedAgentId !== resolveDefaultAgentId(cfg); + if (target.canonicalKey === mainKey && !isSelectedNonDefaultGlobal) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`), + ); + return; + } + + const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; + const { + cleanupSessionBeforeMutation, + emitGatewaySessionEndPluginHook, + emitSessionUnboundLifecycleEvent, + } = await loadSessionsRuntimeModule(); + + const initialDeleteEntry = loadSessionEntry(key, { + agentId: requestedAgentId, + }).entry; + const rejectModelSelectionLockedDelete = ( + entry: SessionEntry | undefined, + sessionKey: string, + ): boolean => { + if (!isModelSelectionLocked(entry)) { + return false; + } + const deletablePluginOwnedSession = + normalizeOptionalString(entry?.pluginOwnerId) !== undefined && + entry?.agentHarnessId === undefined && + !isAgentHarnessSessionKey(sessionKey); + if (deletablePluginOwnedSession) { + return false; + } + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "This session cannot be deleted while model selection is locked.", + ), + ); + return true; + }; + if (rejectModelSelectionLockedDelete(initialDeleteEntry, target.canonicalKey)) { + return; + } + // archivedOnly is the archive-then-delete contract: the dispatcher grants + // it to write-scope operators, so the target must actually be archived. + if (p.archivedOnly === true && initialDeleteEntry?.archivedAt === undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} is not archived. Archive it first, then delete it.`, + ), + ); + return; + } + const expectedSessionId = p.expectedSessionId?.trim(); + const expectedLifecycleRevision = p.expectedLifecycleRevision?.trim(); + const expectedSessionUpdatedAt = p.expectedSessionUpdatedAt; + const expectedLifecycleRevisionMatches = (entry: SessionEntry | undefined): boolean => + !expectedLifecycleRevision || entry?.lifecycleRevision === expectedLifecycleRevision; + const expectedSessionIdMatches = (entry: SessionEntry | undefined): boolean => { + if (!expectedSessionId || entry?.sessionId === expectedSessionId) { + return true; + } + return false; + }; + const respondSessionChanged = () => { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Session ${key} changed before deletion. Retry.`, { + details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON }, + }), + ); + }; + const rejectExpectedSessionMismatch = (entry: SessionEntry | undefined): boolean => { + const updatedAtMatches = + expectedSessionUpdatedAt === undefined || entry?.updatedAt === expectedSessionUpdatedAt; + if ( + expectedLifecycleRevisionMatches(entry) && + expectedSessionIdMatches(entry) && + updatedAtMatches + ) { + return false; + } + respondSessionChanged(); + return true; + }; + if (rejectExpectedSessionMismatch(initialDeleteEntry)) { + return; + } + const initialPlacementError = resolveSessionWorkerPlacementMutationError({ + action: "delete", + context, + key, + sessionId: normalizeOptionalString(initialDeleteEntry?.sessionId), + }); + if (initialPlacementError) { + respondSessionWorkerPlacementMutationError(initialPlacementError, respond); + return; + } + if ( + rejectPluginRuntimeDeleteMismatch({ + client, + key: target.canonicalKey ?? key, + entry: initialDeleteEntry, + respond, + }) + ) { + return; + } + let abortResult: + | { + ok: boolean; + error?: ReturnType; + } + | undefined; + const abortSessionKey = target.canonicalKey ?? key; + const chatAbort = chatHandlers["chat.abort"]; + if (!chatAbort) { + throw new Error("chat.abort handler is not registered"); + } + await chatAbort({ + req, + params: { + sessionKey: abortSessionKey, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }, + respond: (ok, _payload, error) => { + abortResult = { ok, ...(error ? { error } : {}) }; + }, + context, + client, + isWebchatConnect, + }); + if (abortResult?.ok === false) { + respond(false, undefined, abortResult.error); + return; + } + const deleteLifecycleIdentities = [ + target.canonicalKey, + key, + initialDeleteEntry?.sessionId, + expectedSessionId, + ]; + let admittedWorkReleased = true; + let expectedSessionStillCurrent = true; + let deleteBlockedByModelLock = false; + let deleteBlockedByWorkerPlacement = false; + const deletion = await runExclusiveSessionLifecycleMutation({ + scope: storePath, + identities: deleteLifecycleIdentities, + prepare: async () => { + const preparedEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; + deleteBlockedByModelLock = rejectModelSelectionLockedDelete( + preparedEntry, + target.canonicalKey, + ); + if (deleteBlockedByModelLock) { + return; + } + expectedSessionStillCurrent = !rejectExpectedSessionMismatch(preparedEntry); + if (!expectedSessionStillCurrent) { + return; + } + const placementError = resolveSessionWorkerPlacementMutationError({ + action: "delete", + context, + key, + sessionId: normalizeOptionalString(preparedEntry?.sessionId), + }); + if (placementError) { + deleteBlockedByWorkerPlacement = true; + respondSessionWorkerPlacementMutationError(placementError, respond); + return; + } + admittedWorkReleased = await interruptSessionWorkAdmissions({ + scope: storePath, + identities: deleteLifecycleIdentities, + timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, + }); + }, + run: async () => { + if ( + deleteBlockedByModelLock || + deleteBlockedByWorkerPlacement || + !expectedSessionStillCurrent + ) { + return undefined; + } + if (!admittedWorkReleased) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), + ); + return undefined; + } + const { entry, legacyKey, canonicalKey } = loadSessionEntry(key, { + agentId: requestedAgentId, + }); + if (rejectModelSelectionLockedDelete(entry, canonicalKey ?? target.canonicalKey)) { + return undefined; + } + if (rejectExpectedSessionMismatch(entry)) { + return undefined; + } + // Recheck under the lifecycle lock: an unarchive racing the pre-lock + // check must not let an archive-gated delete remove an active session. + if (p.archivedOnly === true && entry?.archivedAt === undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} is not archived. Archive it first, then delete it.`, + ), + ); + return undefined; + } + if ( + rejectPluginRuntimeDeleteMismatch({ + client, + key: canonicalKey ?? key, + entry, + respond, + }) + ) { + return undefined; + } + const mutationCleanupError = await cleanupSessionBeforeMutation({ + cfg, + key, + target, + entry, + legacyKey, + canonicalKey, + reason: "session-delete", + }); + if (mutationCleanupError) { + respond(false, undefined, mutationCleanupError); + return undefined; + } + const postCleanupTarget = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }); + const postCleanupEntry = postCleanupTarget.entry; + if ( + !expectedLifecycleRevisionMatches(postCleanupEntry) || + !expectedSessionIdMatches(postCleanupEntry) + ) { + respondSessionChanged(); + return undefined; + } + const pluginOwnerId = normalizeOptionalString(postCleanupEntry?.pluginOwnerId); + const deletionParams = { + agentId: target.agentId, + archiveTranscript: deleteTranscript, + expectedEntry: postCleanupEntry, + expectedLifecycleRevision, + expectedSessionId, + expectedUpdatedAt: postCleanupEntry?.updatedAt, + storePath, + target: { + canonicalKey: target.canonicalKey, + storeKeys: target.storeKeys, + }, + }; + // Catalog and other plugin-owned sessions keep model selection locked, + // so deletion must use the exact-row owner-validated lifecycle seam. + const result = + postCleanupEntry && pluginOwnerId && isModelSelectionLocked(postCleanupEntry) + ? await rollbackPluginOwnedSessionEntryLifecycle({ + ...deletionParams, + expectedEntry: postCleanupEntry, + expectedPluginOwnerId: pluginOwnerId, + target: { + canonicalKey: postCleanupTarget.target.canonicalKey, + storeKeys: postCleanupTarget.target.storeKeys, + }, + }) + : await deleteSessionEntryLifecycle(deletionParams); + if (result.expectedEntryMismatch) { + respondSessionChanged(); + return undefined; + } + if (result.deleted) { + emitGatewaySessionEndPluginHook({ + cfg, + sessionKey: target.canonicalKey ?? key, + sessionId: result.deletedSessionId, + storePath, + sessionFile: result.deletedSessionFile, + agentId: target.agentId, + reason: "deleted", + archivedTranscripts: result.archivedTranscripts, + }); + await emitSessionUnboundLifecycleEvent({ + targetSessionKey: target.canonicalKey ?? key, + reason: "session-delete", + emitHooks: p.emitLifecycleHooks !== false, + }); + } + return result; + }, + }); + if (!deletion) { + return; + } + const deleted = deletion.deleted; + const archivedTranscripts = deletion.archivedTranscripts; + const archived = archivedTranscripts.map((entryLocal) => entryLocal.archivedPath); + + // Dirty or unpushed worktrees survive session deletion; tell the caller so + // operator UIs can point at the preserved checkout instead of orphaning it. + let worktreePreserved: { id: string; branch: string; path: string } | undefined; + if (deleted) { + // requestedAgentId wins: "global" canonical keys resolve to the default store + // agent, which would purge the wrong agent's rows for explicit-agent deletes. + handleSessionStateSessionDeleted( + target.canonicalKey ?? key, + requestedAgentId ?? resolveSessionStoreAgentId(cfg, target.canonicalKey ?? key), + ); + try { + const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey); + if (worktree && !(await managedWorktrees.removeIfLossless(worktree.id))) { + worktreePreserved = { id: worktree.id, branch: worktree.branch, path: worktree.path }; + } + } catch (error) { + sessionLog.warn( + `failed to clean up worktree for deleted session ${target.canonicalKey}: ${formatErrorMessage(error)}`, + ); + } + } + + respond( + true, + { + ok: true, + key: target.canonicalKey, + deleted, + archived, + ...(worktreePreserved ? { worktreePreserved } : {}), + }, + undefined, + ); + if (deleted) { + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && requestedAgentId + ? { agentId: requestedAgentId } + : {}), + reason: "delete", + }); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-dispatch.ts b/src/gateway/server-methods/sessions-dispatch.ts new file mode 100644 index 000000000000..27fc577ed8a3 --- /dev/null +++ b/src/gateway/server-methods/sessions-dispatch.ts @@ -0,0 +1,165 @@ +// Cloud-worker dispatch for managed-worktree sessions. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsDispatchParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { managedWorktrees } from "../../agents/worktrees/service.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { projectWorkerSessionPlacement } from "../worker-environments/placement-projector.js"; +import { + isWorkerPlacementSessionRuntimeSupported, + resolveWorkerPlacementSessionRuntime, +} from "../worker-environments/placement-session-runtime.js"; +import { + isWorkerDispatchInputError, + loadAccessorSessionEntryForGatewayTarget, + rejectWebchatSessionMutation, + requireSessionKey, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionDispatchHandlers: GatewayRequestHandlers = { + "sessions.dispatch": async ({ params, respond, context, client, isWebchatConnect }) => { + if (!assertValidParams(params, validateSessionsDispatchParams, "sessions.dispatch", respond)) { + return; + } + const key = requireSessionKey(params.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "dispatch", client, isWebchatConnect, respond })) { + return; + } + const dispatchService = context.workerPlacementDispatchService; + const placementReader = context.workerSessionPlacementService; + if (!dispatchService || !placementReader) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker dispatch is not configured"), + ); + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + if (!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `cloud worker profile is not configured: ${params.profileId}`, + ), + ); + return; + } + const target = loadAccessorSessionEntryForGatewayTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + const entry = target.entry; + const sessionId = normalizeOptionalString(entry?.sessionId); + if (!entry || !sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + if (entry.archivedAt !== undefined) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "cannot dispatch an archived session"), + ); + return; + } + const sessionRuntime = resolveWorkerPlacementSessionRuntime({ + cfg, + entry, + agentId: target.target.agentId, + sessionKey: target.canonicalKey, + }); + if (!isWorkerPlacementSessionRuntimeSupported(sessionRuntime)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`, + ), + ); + return; + } + const existingPlacement = placementReader.getMany([sessionId]).get(sessionId); + if ( + existingPlacement && + existingPlacement.state !== "local" && + existingPlacement.state !== "reclaimed" + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `session cannot dispatch from placement ${existingPlacement.state}`, + ), + ); + return; + } + const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey); + if ( + !target.entry?.worktree?.id || + !worktree || + worktree.id !== target.entry.worktree.id || + worktree.ownerId !== target.canonicalKey + ) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.dispatch requires a session-owned managed worktree", + ), + ); + return; + } + try { + const placement = await dispatchService.dispatch({ + sessionId, + sessionKey: target.canonicalKey, + agentId: target.target.agentId, + profileId: params.profileId, + }); + respond( + true, + { + ok: true, + key: target.canonicalKey, + sessionId, + placement: projectWorkerSessionPlacement(placement), + }, + undefined, + ); + } catch (error) { + respond( + false, + undefined, + errorShape( + isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, + formatErrorMessage(error), + ), + ); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-groups.ts b/src/gateway/server-methods/sessions-groups.ts new file mode 100644 index 000000000000..d5bf0ffb2be1 --- /dev/null +++ b/src/gateway/server-methods/sessions-groups.ts @@ -0,0 +1,85 @@ +// Session group catalog mutations. +import { + ErrorCodes, + errorShape, + validateSessionsGroupsDeleteParams, + validateSessionsGroupsListParams, + validateSessionsGroupsPutParams, + validateSessionsGroupsRenameParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { + deleteSessionGroup, + listSessionGroups, + putSessionGroups, + renameSessionGroup, +} from "../session-groups.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionGroupHandlers: GatewayRequestHandlers = { + "sessions.groups.list": async ({ params, respond }) => { + if ( + !assertValidParams(params, validateSessionsGroupsListParams, "sessions.groups.list", respond) + ) { + return; + } + respond(true, { groups: listSessionGroups() }, undefined); + }, + "sessions.groups.put": async ({ params, respond, context }) => { + if ( + !assertValidParams(params, validateSessionsGroupsPutParams, "sessions.groups.put", respond) + ) { + return; + } + respond(true, { ok: true, groups: putSessionGroups(params.names) }, undefined); + // Catalog-only changes still need to reach other open clients. + emitSessionsChanged(context, { reason: "groups" }); + }, + "sessions.groups.rename": async ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsGroupsRenameParams, + "sessions.groups.rename", + respond, + ) + ) { + return; + } + try { + const result = await renameSessionGroup({ + cfg: context.getRuntimeConfig(), + name: params.name, + to: params.to, + }); + respond(true, { ok: true, ...result }, undefined); + emitSessionsChanged(context, { reason: "groups" }); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, + "sessions.groups.delete": async ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsGroupsDeleteParams, + "sessions.groups.delete", + respond, + ) + ) { + return; + } + try { + const result = await deleteSessionGroup({ + cfg: context.getRuntimeConfig(), + name: params.name, + }); + respond(true, { ok: true, ...result }, undefined); + emitSessionsChanged(context, { reason: "groups" }); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, +}; diff --git a/src/gateway/server-methods/sessions-messaging.ts b/src/gateway/server-methods/sessions-messaging.ts new file mode 100644 index 000000000000..df2c32741470 --- /dev/null +++ b/src/gateway/server-methods/sessions-messaging.ts @@ -0,0 +1,428 @@ +// Session message dispatch, steering, and active-run cancellation. +import { randomUUID } from "node:crypto"; +import { expectDefined } from "@openclaw/normalization-core"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsSendParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + abortEmbeddedAgentRun, + isEmbeddedAgentRunActive, + waitForEmbeddedAgentRunEnd, +} from "../../agents/embedded-agent-runner/runs.js"; +import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; +import { resolveSessionWorkStartError, type SessionEntry } from "../../config/sessions.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; +import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; +import { loadSessionEntry, resolveDeletedAgentIdFromSessionKey } from "../session-utils.js"; +import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; +import { chatHandlers } from "./chat.js"; +import { hasTrackedActiveSessionRun } from "./session-active-runs.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { shouldAttachPendingMessageSeq } from "./session-create-initial-turn.js"; +import { resolveAbortSessionKey } from "./sessions-abort.js"; +import { sessionCreateHandlers } from "./sessions-create.js"; +import { isAgentMainSessionKey, requireSessionKey } from "./sessions-shared.js"; +import type { + GatewayClient, + GatewayRequestContext, + GatewayRequestHandlerOptions, + GatewayRequestHandlers, + RespondFn, +} from "./types.js"; +import { assertValidParams } from "./validation.js"; + +async function createAgentMainSessionForSend(params: { + req: GatewayRequestHandlerOptions["req"]; + canonicalKey: string; + context: GatewayRequestContext; + client: GatewayClient | null; + isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; +}): Promise< + | { + ok: true; + entry: SessionEntry; + canonicalKey: string; + storePath: string; + } + | { ok: false; error: ReturnType } +> { + const agentId = parseAgentSessionKey(params.canonicalKey)?.agentId; + if (!agentId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${params.canonicalKey}`), + }; + } + + let createResult: + | { ok: boolean; payload?: { key?: string }; error?: ReturnType } + | undefined; + await expectDefined( + sessionCreateHandlers["sessions.create"], + "sessions.create handler", + )({ + req: params.req, + params: { + key: params.canonicalKey, + agentId, + }, + respond: (ok, payload, error) => { + createResult = { + ok, + payload: payload && typeof payload === "object" ? (payload as { key?: string }) : undefined, + error, + }; + }, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + }); + + if (!createResult) { + return { + ok: false, + error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"), + }; + } + if (!createResult.ok) { + return { + ok: false, + error: createResult.error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create session"), + }; + } + + const createdKey = normalizeOptionalString(createResult.payload?.key) ?? params.canonicalKey; + const loaded = loadSessionEntry(createdKey); + if (!loaded.entry?.sessionId) { + return { + ok: false, + error: errorShape(ErrorCodes.UNAVAILABLE, `session not created: ${createdKey}`), + }; + } + return { + ok: true, + entry: loaded.entry, + canonicalKey: loaded.canonicalKey, + storePath: loaded.storePath, + }; +} + +export async function interruptSessionRunIfActive(params: { + req: GatewayRequestHandlerOptions["req"]; + context: GatewayRequestContext; + client: GatewayClient | null; + isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; + requestedKey: string; + canonicalKey: string; + agentId?: string; + sessionId?: string; +}): Promise<{ interrupted: boolean; error?: ReturnType }> { + const cfg = params.context.getRuntimeConfig(); + const hasTrackedRun = hasTrackedActiveSessionRun({ + context: params.context, + requestedKey: params.requestedKey, + canonicalKey: params.canonicalKey, + agentId: params.agentId, + defaultAgentId: resolveDefaultAgentId(cfg), + }); + const hasEmbeddedRun = + typeof params.sessionId === "string" && params.sessionId + ? isEmbeddedAgentRunActive(params.sessionId) + : false; + const hasWorkerRun = + typeof params.sessionId === "string" && params.sessionId + ? (asWorkerInferenceControl(params.context.workerEnvironmentService)?.hasInferenceForSession( + params.sessionId, + ) ?? false) + : false; + + if (!hasTrackedRun && !hasEmbeddedRun && !hasWorkerRun) { + return { interrupted: false }; + } + + if (hasTrackedRun || hasWorkerRun) { + let abortOk = true; + let abortError: ReturnType | undefined; + const abortSessionKey = resolveAbortSessionKey({ + context: params.context, + requestedKey: params.requestedKey, + canonicalKey: params.canonicalKey, + }); + + await expectDefined( + chatHandlers["chat.abort"], + "chat.abort handler", + )({ + req: params.req, + params: { + sessionKey: abortSessionKey, + ...(params.canonicalKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + }, + respond: (ok, _payload, error) => { + abortOk = ok; + abortError = error; + }, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + }); + + if (!abortOk) { + return { + interrupted: true, + error: + abortError ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to interrupt active session"), + }; + } + } + + if (hasEmbeddedRun && params.sessionId) { + abortEmbeddedAgentRun(params.sessionId); + } + + // Clear queued follow-up work for both requested aliases and the canonical session id. + clearSessionQueues([params.requestedKey, params.canonicalKey, params.sessionId]); + + if (hasEmbeddedRun && params.sessionId) { + const ended = await waitForEmbeddedAgentRunEnd(params.sessionId, 15_000); + if (!ended) { + return { + interrupted: true, + error: errorShape( + ErrorCodes.UNAVAILABLE, + `Session ${params.requestedKey} is still active; try again in a moment.`, + ), + }; + } + } + + return { interrupted: true }; +} + +async function handleSessionSend(params: { + method: "sessions.send" | "sessions.steer"; + req: GatewayRequestHandlerOptions["req"]; + params: Record; + respond: RespondFn; + context: GatewayRequestContext; + client: GatewayClient | null; + isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; + interruptIfActive: boolean; +}) { + if ( + !assertValidParams(params.params, validateSessionsSendParams, params.method, params.respond) + ) { + return; + } + const p = params.params; + const key = requireSessionKey((p as { key?: unknown }).key, params.respond); + if (!key) { + return; + } + const cfg = params.context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId( + cfg, + key, + (p as { agentId?: string }).agentId, + ); + if (!requestedAgent.ok) { + params.respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const loaded = loadSessionEntry(key, { agentId: requestedAgentId }); + const { legacyKey } = loaded; + let { entry, canonicalKey, storePath } = loaded; + // Reject sends/steers targeting sessions whose owning agent was deleted (#65524). + const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey, entry, { + acpMetadataSessionKey: legacyKey ?? canonicalKey, + }); + if (deletedAgentId !== null) { + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Agent "${deletedAgentId}" no longer exists in configuration`, + ), + ); + return; + } + const rawIdempotencyKey = (p as { idempotencyKey?: string }).idempotencyKey; + const explicitIdempotencyKey = + typeof rawIdempotencyKey === "string" && rawIdempotencyKey.trim() + ? rawIdempotencyKey.trim() + : undefined; + const idempotencyKey = explicitIdempotencyKey ?? randomUUID(); + const dispatchChatSend = async (respond: RespondFn) => { + await expectDefined( + chatHandlers["chat.send"], + "chat.send handler", + )({ + req: params.req, + params: { + sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), + message: (p as { message: string }).message, + thinking: (p as { thinking?: string }).thinking, + attachments: (p as { attachments?: unknown[] }).attachments, + timeoutMs: (p as { timeoutMs?: number }).timeoutMs, + idempotencyKey, + }, + respond, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + }); + }; + const archivedSessionError = resolveSessionWorkStartError(canonicalKey, entry); + if (archivedSessionError) { + // An explicit retry may already have a terminal chat.send result. Let the + // owning handler replay that result before it applies the archive guard. + if (explicitIdempotencyKey) { + await dispatchChatSend(params.respond); + return; + } + params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, archivedSessionError)); + return; + } + if (!entry?.sessionId && !params.interruptIfActive && isAgentMainSessionKey(cfg, canonicalKey)) { + // Sending to an empty agent main session should create it; steering still requires an active row. + const created = await createAgentMainSessionForSend({ + req: params.req, + canonicalKey, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + }); + if (!created.ok) { + params.respond(false, undefined, created.error); + return; + } + entry = created.entry; + canonicalKey = created.canonicalKey; + storePath = created.storePath; + } + if (!entry?.sessionId) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + + let interruptedActiveRun = false; + if (params.interruptIfActive) { + const interruptResult = await interruptSessionRunIfActive({ + req: params.req, + context: params.context, + client: params.client, + isWebchatConnect: params.isWebchatConnect, + requestedKey: key, + canonicalKey, + agentId: requestedAgentId, + sessionId: entry.sessionId, + }); + if (interruptResult.error) { + params.respond(false, undefined, interruptResult.error); + return; + } + interruptedActiveRun = interruptResult.interrupted; + } + + const messageSeq = + (await readSessionMessageCountAsync({ + agentId: requestedAgentId, + sessionEntry: entry, + sessionId: entry.sessionId, + sessionKey: canonicalKey, + storePath, + })) + 1; + let sendAcked = false; + let sendPayload: unknown; + let sendCached = false; + let startedRunId: string | undefined; + await dispatchChatSend((ok, payload, error, meta) => { + sendAcked = ok; + sendPayload = payload; + sendCached = meta?.cached === true; + startedRunId = + payload && + typeof payload === "object" && + typeof (payload as { runId?: unknown }).runId === "string" + ? (payload as { runId: string }).runId + : undefined; + if (ok && shouldAttachPendingMessageSeq({ payload, cached: meta?.cached === true })) { + params.respond( + true, + { + ...(payload && typeof payload === "object" ? payload : {}), + messageSeq, + ...(interruptedActiveRun ? { interruptedActiveRun: true } : {}), + }, + undefined, + meta, + ); + return; + } + params.respond( + ok, + ok && payload && typeof payload === "object" + ? { + ...payload, + ...(interruptedActiveRun ? { interruptedActiveRun: true } : {}), + } + : payload, + error, + meta, + ); + }); + if (sendAcked) { + if (shouldAttachPendingMessageSeq({ payload: sendPayload, cached: sendCached })) { + await reactivateCompletedSubagentSession({ + sessionKey: canonicalKey, + runId: startedRunId, + task: (p as { message: string }).message, + }); + } + emitSessionsChanged(params.context, { + sessionKey: canonicalKey, + ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), + reason: interruptedActiveRun ? "steer" : "send", + }); + } +} + +export const sessionMessagingHandlers: GatewayRequestHandlers = { + "sessions.send": async ({ req, params, respond, context, client, isWebchatConnect }) => { + await handleSessionSend({ + method: "sessions.send", + req, + params, + respond, + context, + client, + isWebchatConnect, + interruptIfActive: false, + }); + }, + "sessions.steer": async ({ req, params, respond, context, client, isWebchatConnect }) => { + await handleSessionSend({ + method: "sessions.steer", + req, + params, + respond, + context, + client, + isWebchatConnect, + interruptIfActive: true, + }); + }, +}; diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts new file mode 100644 index 000000000000..b0e705dee565 --- /dev/null +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -0,0 +1,426 @@ +// Session metadata mutations, plugin state, and reset routing. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsPatchParams, + validateSessionsPluginPatchParams, + validateSessionsResetParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { replyRunRegistry } from "../../auto-reply/reply/reply-run-registry.js"; +import { applySessionPatchProjection } from "../../config/sessions/session-accessor.js"; +import { disableCronJobsBoundToSession } from "../../cron/job-session-bindings.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js"; +import { isPluginJsonValue } from "../../plugins/host-hooks.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; +import { + isSessionLifecycleMutationActive, + isSessionWorkAdmissionActive, + runExclusiveSessionLifecycleMutation, +} from "../../sessions/session-lifecycle-admission.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { ensureSessionGroupRegistered } from "../session-groups.js"; +import { triggerSessionPatchHook } from "../session-patch-hooks.js"; +import { + loadSessionEntry, + migrateAndPruneGatewaySessionStoreKey, + resolveGatewaySessionThinkingProjection, + resolveSessionDisplayModelIdentityRef, + resolveSessionModelRef, + type SessionsPatchResult, +} from "../session-utils.js"; +import { projectSessionsPatchEntry } from "../sessions-patch.js"; +import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { + isAgentMainSessionKey, + loadSessionsRuntimeModule, + rejectWebchatSessionMutation, + requireSessionKey, + resolveGatewaySessionTargetFromKey, + resolveSessionWorkerPlacementPatchError, + sessionLog, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionMutationHandlers: GatewayRequestHandlers = { + "sessions.patch": async ({ params, respond, context, client, isWebchatConnect }) => { + if (!assertValidParams(params, validateSessionsPatchParams, "sessions.patch", respond)) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { + return; + } + + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { + agentId: requestedAgentId, + }); + const canonicalKey = target.canonicalKey ?? key; + const lifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; + const missingHarnessSessionError = resolveMissingAgentHarnessSessionError( + canonicalKey, + lifecycleEntry, + ); + if (missingHarnessSessionError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError)); + return; + } + const initialPlacementPatchError = resolveSessionWorkerPlacementPatchError({ + agentId: target.agentId, + cfg, + context, + entry: lifecycleEntry, + key, + patch: p, + sessionKey: canonicalKey, + validateModelRuntime: false, + }); + if (initialPlacementPatchError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, initialPlacementPatchError)); + return; + } + const lifecycleIdentities = [canonicalKey, key, lifecycleEntry?.sessionId]; + if (p.archived === true && isSessionLifecycleMutationActive(storePath, lifecycleIdentities)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive a session with an active run."), + ); + return; + } + let patchModelCatalog: Awaited> | undefined; + const loadPatchModelCatalog = async () => { + const catalog = await context.loadGatewayModelCatalog(); + patchModelCatalog = catalog; + return catalog; + }; + const applyPatch = async () => { + const currentLifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; + // A reset queued ahead of archive can rotate the row before this mutation starts. + // Never apply stale destructive intent to the replacement session identity. + const lifecycleEntryRemoved = + lifecycleEntry !== undefined && currentLifecycleEntry === undefined; + const archiveTargetChanged = + p.archived === true && + (lifecycleEntry === undefined + ? currentLifecycleEntry !== undefined + : currentLifecycleEntry !== undefined && + (currentLifecycleEntry.sessionId !== lifecycleEntry.sessionId || + currentLifecycleEntry.lifecycleRevision !== lifecycleEntry.lifecycleRevision)); + if (lifecycleEntryRemoved || archiveTargetChanged) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Session ${key} changed before patch. Retry.`), + ); + return null; + } + if (p.archived === true) { + if (canonicalKey === "global" || isAgentMainSessionKey(cfg, canonicalKey)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive an agent's main session."), + ); + return null; + } + const { entry } = loadSessionEntry(key, { agentId: requestedAgentId }); + const activeIdentities = [canonicalKey, key, entry?.sessionId]; + if ( + isSessionWorkAdmissionActive(storePath, activeIdentities) || + replyRunRegistry.isActive(canonicalKey) || + replyRunRegistry.isActive(key) || + hasVisibleActiveSessionRun({ + context, + requestedKey: key, + canonicalKey, + sessionId: entry?.sessionId, + defaultAgentId: resolveDefaultAgentId(cfg), + }) + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive a session with an active run."), + ); + return null; + } + } + return await applySessionPatchProjection({ + agentId: target.agentId, + storePath, + resolveTarget: ({ entries }) => { + const store = Object.fromEntries( + entries.map(({ sessionKey, entry }) => [sessionKey, entry]), + ); + const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key, + store, + agentId: requestedAgentId, + }); + return { primaryKey, candidateKeys: migratedTarget.storeKeys }; + }, + project: async ({ primaryKey, existingEntry, entries }) => { + const projected = await projectSessionsPatchEntry({ + cfg, + entries, + existingEntry, + storeKey: primaryKey, + agentId: requestedAgentId, + patch: p, + loadGatewayModelCatalog: loadPatchModelCatalog, + }); + if (!projected.ok) { + return projected; + } + const placementPatchError = resolveSessionWorkerPlacementPatchError({ + agentId: target.agentId, + cfg, + context, + entry: projected.entry, + key, + patch: p, + sessionKey: canonicalKey, + validateModelRuntime: true, + }); + return placementPatchError + ? { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, placementPatchError), + } + : projected; + }, + }); + }; + const applied = await runExclusiveSessionLifecycleMutation({ + scope: storePath, + identities: lifecycleIdentities, + run: applyPatch, + }); + if (!applied) { + return; + } + if (!applied.ok) { + respond(false, undefined, applied.error); + return; + } + + triggerSessionPatchHook({ + cfg, + sessionEntry: applied.entry, + sessionKey: target.canonicalKey ?? key, + patch: p, + }); + + // Cron mutations are operator.admin surface while archive is write-scoped; + // only cascade for internal callers (client == null) or admin operators so + // write-scoped archiving cannot flip admin-managed schedules. + const callerScopes = client?.connect ? (client.connect.scopes ?? []) : null; + const callerCanManageCron = callerScopes === null || callerScopes.includes(ADMIN_SCOPE); + if (p.archived === true && callerCanManageCron) { + // Archived sessions reject new work, so schedules bound to them would + // only accumulate failing runs; disable them with the archive. + try { + const disabledJobIds = await disableCronJobsBoundToSession({ + cron: context.cron, + cfg, + sessionKey: target.canonicalKey ?? key, + }); + if (disabledJobIds.length > 0) { + sessionLog.info( + `sessions.patch: disabled cron jobs bound to archived session ${target.canonicalKey ?? key}: ${disabledJobIds.join(", ")}`, + ); + } + } catch (error) { + // Best-effort by design: archive is the primary action and must not + // fail or roll back on cron-store errors. Any job left enabled fails + // closed at run start because archived sessions reject new work. + sessionLog.warn( + `sessions.patch: failed to disable cron jobs for archived session ${target.canonicalKey ?? key}: ${formatErrorMessage(error)}`, + ); + } + } + + // Absorb ad-hoc categories into the gateway group catalog so ordering + // covers every group an operator UI can observe. + if (typeof p.category === "string" && p.category.trim()) { + ensureSessionGroupRegistered(p.category); + } + + const parsed = parseAgentSessionKey(target.canonicalKey ?? key); + const agentId = normalizeAgentId( + target.canonicalKey === "global" + ? target.agentId + : (parsed?.agentId ?? resolveDefaultAgentId(cfg)), + ); + const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); + const resolvedDisplayModel = resolveSessionDisplayModelIdentityRef({ + cfg, + agentId, + provider: resolved.provider, + model: resolved.model, + }); + const thinkingProjection = resolveGatewaySessionThinkingProjection({ + cfg, + agentId, + provider: resolvedDisplayModel.provider ?? resolved.provider, + model: resolvedDisplayModel.model ?? resolved.model, + sessionKey: target.canonicalKey ?? key, + entry: applied.entry, + modelCatalog: patchModelCatalog, + }); + const resolvedThinkingMetadata = + patchModelCatalog === undefined + ? {} + : { + thinkingLevel: thinkingProjection.effectiveThinkingLevel, + thinkingLevels: thinkingProjection.thinkingLevels, + }; + const result: SessionsPatchResult = { + ok: true, + path: storePath, + key: target.canonicalKey, + entry: applied.entry, + resolved: { + modelProvider: resolvedDisplayModel.provider, + model: resolvedDisplayModel.model, + agentRuntime: thinkingProjection.agentRuntime, + ...resolvedThinkingMetadata, + }, + }; + respond(true, result, undefined); + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + ...(target.canonicalKey === "global" && requestedAgentId + ? { agentId: requestedAgentId } + : {}), + reason: "patch", + }); + }, + "sessions.pluginPatch": async ({ params, respond, context, client, isWebchatConnect }) => { + if ( + !assertValidParams(params, validateSessionsPluginPatchParams, "sessions.pluginPatch", respond) + ) { + return; + } + const key = requireSessionKey(params.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { + return; + } + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + if (!scopes.includes(ADMIN_SCOPE)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `sessions.pluginPatch requires gateway scope: ${ADMIN_SCOPE}`, + ), + ); + return; + } + const pluginId = normalizeOptionalString(params.pluginId); + const namespace = normalizeOptionalString(params.namespace); + if (!pluginId || !namespace) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "pluginId and namespace are required"), + ); + return; + } + if (params.unset === true && params.value !== undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.pluginPatch cannot specify both unset and value", + ), + ); + return; + } + if (params.value !== undefined && !isPluginJsonValue(params.value)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.pluginPatch value must be JSON-compatible", + ), + ); + return; + } + const patched = await patchPluginSessionExtension({ + cfg: context.getRuntimeConfig(), + sessionKey: key, + pluginId, + namespace, + value: params.value, + unset: params.unset === true, + }); + if (!patched.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, patched.error)); + return; + } + respond(true, { ok: true, key: patched.key, value: patched.value }, undefined); + emitSessionsChanged(context, { + sessionKey: patched.key, + reason: "plugin-patch", + }); + }, + "sessions.reset": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsResetParams, "sessions.reset", respond)) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + + const reason = p.reason === "new" ? "new" : "reset"; + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); + const result = await performGatewaySessionReset({ + key, + ...(p.agentId ? { agentId: p.agentId } : {}), + reason, + commandSource: "gateway:sessions.reset", + }); + if (!result.ok) { + respond(false, undefined, result.error); + return; + } + respond( + true, + { ok: true, key: result.key, entry: result.entry, resolved: result.resolved }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: result.key, + ...(result.key === "global" ? { agentId: result.agentId } : {}), + reason, + }); + }, +}; diff --git a/src/gateway/server-methods/sessions-read.ts b/src/gateway/server-methods/sessions-read.ts new file mode 100644 index 000000000000..b16b8c2d1c11 --- /dev/null +++ b/src/gateway/server-methods/sessions-read.ts @@ -0,0 +1,437 @@ +// Read-only session queries. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + validateSessionsCleanupParams, + validateSessionsDescribeParams, + validateSessionsListParams, + validateSessionsPreviewParams, + validateSessionsResolveParams, + validateSessionsSearchParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + runSessionsCleanup, + serializeSessionCleanupResult, + type SessionEntry, +} from "../../config/sessions.js"; +import { searchSessionTranscripts } from "../../config/sessions/session-transcript-search.js"; +import { + measureDiagnosticsTimelineSpan, + measureDiagnosticsTimelineSpanSync, +} from "../../infra/diagnostics-timeline.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { + resolveSessionStoreAgentId, + resolveSessionStoreKey, + resolveStoredSessionKeyForAgentStore, +} from "../session-store-key.js"; +import { + readRecentSessionMessagesWithStatsAsync, + readSessionPreviewItemsFromTranscript, +} from "../session-transcript-readers.js"; +import { + buildGatewaySessionRow, + listSessionsFromStoreAsync, + loadCombinedSessionStoreForGateway, + resolveFreshestSessionEntryFromStoreKeys, + resolveGatewaySessionStoreTarget, + resolveGatewaySessionStoreTargetWithStore, + type SessionsPreviewEntry, + type SessionsPreviewResult, +} from "../session-utils.js"; +import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; +import { projectWorkerSessionPlacement } from "../worker-environments/placement-projector.js"; +import { loadOptionalServerMethodModelCatalog } from "./optional-model-catalog.js"; +import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js"; +import { emitSessionsChanged } from "./session-change-event.js"; +import { + filterSessionStoreToConfiguredAgents, + loadSessionEntriesForTarget, + requireSessionKey, +} from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionReadHandlers: GatewayRequestHandlers = { + "sessions.search": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsSearchParams, "sessions.search", respond)) { + return; + } + const query = params.query.trim(); + if (!query) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "query must not be empty")); + return; + } + const cfg = context.getRuntimeConfig(); + if (params.agentId && !params.sessionKeys) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "agentId requires sessionKeys"), + ); + return; + } + const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + const sessionKeys = params.sessionKeys?.map((sessionKey) => + requestedAgentId + ? resolveStoredSessionKeyForAgentStore({ cfg, agentId: requestedAgentId, sessionKey }) + : resolveSessionStoreKey({ cfg, sessionKey }), + ); + const agentIds = new Set( + sessionKeys?.map((sessionKey) => + requestedAgentId && (sessionKey === "global" || sessionKey === "unknown") + ? requestedAgentId + : resolveSessionStoreAgentId(cfg, sessionKey), + ), + ); + if ( + agentIds.size > 1 || + (requestedAgentId && [...agentIds].some((agentId) => agentId !== requestedAgentId)) + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "sessions.search supports one agent per call"), + ); + return; + } + const agentId = + requestedAgentId ?? agentIds.values().next().value ?? resolveDefaultAgentId(cfg); + try { + const result = searchSessionTranscripts({ + agentId, + query, + limit: params.limit, + ...(sessionKeys ? { sessionKeys } : {}), + }); + respond(true, { + results: result.hits, + ...(result.indexing ? { indexing: true } : {}), + ...(result.truncated ? { truncated: true } : {}), + }); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, + "sessions.list": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) { + return; + } + const p = params; + const cfg = context.getRuntimeConfig(); + const configuredAgentsOnly = p.configuredAgentsOnly === true; + const payload = await measureDiagnosticsTimelineSpan( + "gateway.sessions.list", + async () => { + const { storePath, store } = measureDiagnosticsTimelineSpanSync( + "gateway.sessions.list.store_load", + () => + loadCombinedSessionStoreForGateway(cfg, { + agentId: p.agentId, + }), + { + config: cfg, + phase: "sessions.list", + attributes: { + agentId: p.agentId ?? null, + configuredAgentsOnly, + }, + }, + ); + const listStore = configuredAgentsOnly + ? filterSessionStoreToConfiguredAgents(cfg, store) + : store; + const modelCatalog = await measureDiagnosticsTimelineSpan( + "gateway.sessions.list.model_catalog", + () => loadOptionalServerMethodModelCatalog(context, "sessions.list"), + { + config: cfg, + phase: "sessions.list", + }, + ); + const result = await measureDiagnosticsTimelineSpan( + "gateway.sessions.list.rows", + () => + listSessionsFromStoreAsync({ + cfg, + storePath, + store: listStore, + modelCatalog, + opts: p, + }), + { + config: cfg, + phase: "sessions.list", + attributes: { + storeEntries: Object.keys(listStore).length, + }, + }, + ); + const placementsBySessionId = context.workerSessionPlacementService?.getMany( + result.sessions.flatMap((session) => (session.sessionId ? [session.sessionId] : [])), + ); + const sessions = measureDiagnosticsTimelineSpanSync( + "gateway.sessions.list.active_run_flags", + () => { + return result.sessions.map((session) => { + const placementRecord = session.sessionId + ? placementsBySessionId?.get(session.sessionId) + : undefined; + const activeRunState = resolveVisibleActiveSessionRunState({ + context, + requestedKey: session.key, + canonicalKey: session.key, + sessionId: session.sessionId, + ...(session.key === "global" && p.agentId ? { agentId: p.agentId } : {}), + defaultAgentId: resolveDefaultAgentId(cfg), + }); + return Object.assign({}, session, { + hasActiveRun: activeRunState.active, + ...(placementRecord + ? { placement: projectWorkerSessionPlacement(placementRecord) } + : {}), + ...(activeRunState.runIds.length > 0 + ? { activeRunIds: activeRunState.runIds } + : {}), + }); + }); + }, + { + config: cfg, + phase: "sessions.list", + attributes: { + sessions: result.sessions.length, + }, + }, + ); + return { + ...result, + sessions, + }; + }, + { + config: cfg, + phase: "sessions.list", + attributes: { + agentId: p.agentId ?? null, + configuredAgentsOnly, + }, + }, + ); + respond(true, payload, undefined); + }, + "sessions.cleanup": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsCleanupParams, "sessions.cleanup", respond)) { + return; + } + const p = params; + try { + const { mode, appliedSummaries } = await runSessionsCleanup({ + cfg: context.getRuntimeConfig(), + opts: { + agent: p.agent, + allAgents: p.allAgents, + enforce: p.enforce, + activeKey: p.activeKey, + fixMissing: p.fixMissing, + fixDmScope: p.fixDmScope, + }, + }); + const result = serializeSessionCleanupResult({ + mode, + dryRun: false, + summaries: appliedSummaries, + }); + respond(true, result, undefined); + for (const summary of appliedSummaries) { + emitSessionsChanged(context, { + reason: "cleanup", + sessionKey: undefined, + }); + if (summary.wouldMutate) { + context.logGateway.debug( + `sessions.cleanup applied ${summary.storePath}: ${summary.beforeCount} -> ${summary.afterCount}`, + ); + } + } + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(error))); + } + }, + "sessions.preview": ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsPreviewParams, "sessions.preview", respond)) { + return; + } + const p = params; + const keysRaw = Array.isArray(p.keys) ? p.keys : []; + const keys = keysRaw + .map((key) => normalizeOptionalString(key ?? "")) + .filter((key): key is string => Boolean(key)) + .slice(0, 64); + const limit = + typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12; + const maxChars = + typeof p.maxChars === "number" && Number.isFinite(p.maxChars) + ? Math.max(20, p.maxChars) + : 240; + + if (keys.length === 0) { + respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined); + return; + } + + const cfg = context.getRuntimeConfig(); + const storeCache = new Map>(); + const previews: SessionsPreviewEntry[] = []; + + for (const key of keys) { + try { + const cachedStoreTarget = resolveGatewaySessionStoreTargetWithStore({ + cfg, + key, + }); + const store = storeCache.get(cachedStoreTarget.storePath) ?? cachedStoreTarget.store; + storeCache.set(cachedStoreTarget.storePath, store); + const target = resolveGatewaySessionStoreTarget({ + cfg, + key, + store, + }); + const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); + if (!entry?.sessionId) { + previews.push({ key, status: "missing", items: [] }); + continue; + } + const items = readSessionPreviewItemsFromTranscript( + { + agentId: target.agentId, + sessionEntry: entry, + sessionId: entry.sessionId, + sessionKey: target.canonicalKey, + storePath: target.storePath, + }, + limit, + maxChars, + ); + previews.push({ + key, + status: items.length > 0 ? "ok" : "empty", + items, + }); + } catch { + previews.push({ key, status: "error", items: [] }); + } + } + + respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined); + }, + "sessions.describe": ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsDescribeParams, "sessions.describe", respond)) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const cfg = context.getRuntimeConfig(); + const { target, storePath, store, entry } = loadSessionEntriesForTarget({ key, cfg }); + if (!entry) { + respond(true, { session: null }, undefined); + return; + } + const row = buildGatewaySessionRow({ + cfg, + storePath, + store, + key: target.canonicalKey, + entry, + includeDerivedTitles: p.includeDerivedTitles, + includeLastMessage: p.includeLastMessage, + transcriptUsageMaxBytes: 64 * 1024, + }); + const placement = row.sessionId + ? context.workerSessionPlacementService?.getMany([row.sessionId]).get(row.sessionId) + : undefined; + respond( + true, + { + session: placement ? { ...row, placement: projectWorkerSessionPlacement(placement) } : row, + }, + undefined, + ); + }, + "sessions.resolve": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) { + return; + } + const p = params; + const cfg = context.getRuntimeConfig(); + + const resolved = await resolveSessionKeyFromResolveParams({ cfg, p }); + if (!resolved.ok) { + respond(false, undefined, resolved.error); + return; + } + if ("missing" in resolved) { + respond(true, { ok: false }, undefined); + return; + } + respond(true, { ok: true, key: resolved.key }, undefined); + }, + "sessions.get": async ({ params, respond, context }) => { + const p = params as { + key?: unknown; + sessionKey?: unknown; + limit?: unknown; + agentId?: unknown; + }; + const key = requireSessionKey(p.key ?? p.sessionKey, respond); + if (!key) { + return; + } + const limit = + typeof p.limit === "number" && Number.isFinite(p.limit) + ? Math.max(1, Math.floor(p.limit)) + : 200; + + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId( + cfg, + key, + normalizeOptionalString(p.agentId), + ); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const { storePath, entry } = loadSessionEntriesForTarget({ + key, + cfg, + agentId: requestedAgent.agentId, + }); + if (!entry?.sessionId) { + respond(true, { messages: [] }, undefined); + return; + } + const { messages } = await readRecentSessionMessagesWithStatsAsync( + { + agentId: requestedAgent.agentId, + sessionEntry: entry, + sessionId: entry.sessionId, + sessionKey: key, + storePath, + }, + { + maxMessages: limit, + maxLines: limit * 20 + 20, + allowResetArchiveFallback: true, + }, + ); + respond(true, { messages }, undefined); + }, +}; diff --git a/src/gateway/server-methods/sessions-shared.ts b/src/gateway/server-methods/sessions-shared.ts new file mode 100644 index 000000000000..293ea864537f --- /dev/null +++ b/src/gateway/server-methods/sessions-shared.ts @@ -0,0 +1,309 @@ +// Shared session-handler target resolution and mutation guards. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; +import { + ErrorCodes, + errorShape, + type SessionOperationEvent, + type SessionPlacement, + type SessionsPatchParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { listConfiguredSessionStoreAgentIds, type SessionEntry } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; +import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "../session-store-key.js"; +import { + resolveFreshestSessionEntryFromStoreKeys, + resolveGatewaySessionStoreTarget, + resolveGatewaySessionStoreTargetWithStore, +} from "../session-utils.js"; +import { + isWorkerPlacementSessionRuntimeSupported, + resolveWorkerPlacementSessionRuntime, +} from "../worker-environments/placement-session-runtime.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +export const sessionLog = createSubsystemLogger("gateway/sessions"); + +export class SessionWorkerPlacementMutationError extends Error { + constructor( + readonly placementState: SessionPlacement["state"], + action: "delete" | "reset" | "restore", + key: string, + ) { + super(`Session ${key} cannot ${action} while cloud worker placement is ${placementState}.`); + } +} + +export function resolveSessionWorkerPlacementMutationError(params: { + action: "delete" | "reset" | "restore"; + context: GatewayRequestContext; + key: string; + sessionId: string | undefined; +}): SessionWorkerPlacementMutationError | undefined { + if (!params.sessionId) { + return undefined; + } + const placement = params.context.workerSessionPlacementService + ?.getMany([params.sessionId]) + .get(params.sessionId); + if ( + !placement || + placement.state === "local" || + (params.action === "delete" && placement.state === "reclaimed") + ) { + return undefined; + } + return new SessionWorkerPlacementMutationError(placement.state, params.action, params.key); +} + +export function respondSessionWorkerPlacementMutationError( + error: SessionWorkerPlacementMutationError, + respond: RespondFn, +): void { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); +} + +export function resolveSessionWorkerPlacementPatchError(params: { + agentId: string; + cfg: OpenClawConfig; + context: GatewayRequestContext; + entry: SessionEntry | undefined; + key: string; + patch: SessionsPatchParams; + sessionKey: string; + validateModelRuntime: boolean; +}): string | undefined { + const placement = params.entry?.sessionId + ? params.context.workerSessionPlacementService + ?.getMany([params.entry.sessionId]) + .get(params.entry.sessionId) + : undefined; + if (!placement || placement.state === "local") { + return undefined; + } + if (params.patch.archived !== undefined) { + return `Session ${params.key} cannot change archive state while cloud worker placement is ${placement.state}.`; + } + if (!params.validateModelRuntime || params.patch.model === undefined || !params.entry) { + return undefined; + } + const runtime = resolveWorkerPlacementSessionRuntime({ + cfg: params.cfg, + entry: params.entry, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + if (isWorkerPlacementSessionRuntimeSupported(runtime)) { + return undefined; + } + return `Session ${params.key} cannot select the ${runtime} runtime while cloud worker placement is ${placement.state}.`; +} + +export function filterSessionStoreToConfiguredAgents( + cfg: OpenClawConfig, + store: Record, +): Record { + const configuredAgentIds = new Set(listConfiguredSessionStoreAgentIds(cfg)); + const isConfiguredSessionKey = (key: string | undefined) => { + const normalizedKey = normalizeOptionalString(key); + if (!normalizedKey) { + return false; + } + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: normalizedKey }); + const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); + return configuredAgentIds.has(normalizeAgentId(agentId)); + }; + return Object.fromEntries( + Object.entries(store).filter(([key, entry]) => { + if (key === "global" || key === "unknown") { + return true; + } + if (isConfiguredSessionKey(key)) { + return true; + } + // Keep spawned child sessions visible when their parent belongs to a configured agent. + return ( + isConfiguredSessionKey(entry?.spawnedBy) || isConfiguredSessionKey(entry?.parentSessionKey) + ); + }), + ); +} + +export const loadSessionsRuntimeModule = createLazyRuntimeModule( + () => import("./sessions.runtime.js"), +); + +export function requireSessionKey(key: unknown, respond: RespondFn): string | null { + const raw = + typeof key === "string" + ? key + : typeof key === "number" + ? String(key) + : typeof key === "bigint" + ? String(key) + : ""; + const normalized = normalizeOptionalString(raw) ?? ""; + if (!normalized) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); + return null; + } + return normalized; +} + +export function rejectPluginRuntimeDeleteMismatch(params: { + client: GatewayClient | null; + key: string; + entry: SessionEntry | undefined; + respond: RespondFn; +}): boolean { + const pluginOwnerId = normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId); + if (!pluginOwnerId || !params.entry) { + return false; + } + if (normalizeOptionalString(params.entry.pluginOwnerId) === pluginOwnerId) { + return false; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Plugin "${pluginOwnerId}" cannot delete session "${params.key}" because it did not create it.`, + ), + ); + return true; +} + +export function resolveGatewaySessionTargetFromKey( + key: string, + cfg: OpenClawConfig, + opts?: { agentId?: string }, +) { + const target = resolveGatewaySessionStoreTarget({ + cfg, + key, + ...(opts?.agentId ? { agentId: opts.agentId } : {}), + }); + return { cfg, target, storePath: target.storePath }; +} + +export function loadAccessorSessionEntryForGatewayTarget(params: { + key: string; + cfg: OpenClawConfig; + agentId?: string; +}) { + const target = resolveGatewaySessionStoreTargetWithStore({ + cfg: params.cfg, + key: params.key, + clone: false, + ...(params.agentId ? { agentId: params.agentId } : {}), + }); + let best: + | { + entry: SessionEntry; + sessionStoreKey: string; + } + | undefined; + for (const sessionStoreKey of target.storeKeys) { + const entry = target.store[sessionStoreKey]; + if (entry) { + if (!best || (entry.updatedAt ?? 0) > (best.entry.updatedAt ?? 0)) { + best = { entry, sessionStoreKey }; + } + } + } + if (best) { + return { + target, + storePath: target.storePath, + entry: best.entry, + canonicalKey: target.canonicalKey, + sessionStoreKey: best.sessionStoreKey, + }; + } + return { + target, + storePath: target.storePath, + entry: undefined, + canonicalKey: target.canonicalKey, + sessionStoreKey: target.canonicalKey, + }; +} + +export function loadSessionEntriesForTarget(params: { + key: string; + cfg: OpenClawConfig; + agentId?: string; +}) { + const target = resolveGatewaySessionStoreTargetWithStore({ + cfg: params.cfg, + key: params.key, + clone: false, + ...(params.agentId ? { agentId: params.agentId } : {}), + }); + const store = target.store; + const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); + return { target, storePath: target.storePath, store, entry }; +} + +export function emitSessionOperation( + context: Pick, + payload: Omit, +) { + const connIds = context.getSessionEventSubscriberConnIds(); + if (connIds.size === 0) { + return; + } + context.broadcastToConnIds( + "session.operation", + { + ...payload, + ts: Date.now(), + } satisfies SessionOperationEvent, + connIds, + { dropIfSlow: true }, + ); +} + +export function rejectWebchatSessionMutation(params: { + action: "patch" | "delete" | "compact" | "restore" | "dispatch"; + client: GatewayClient | null; + isWebchatConnect: (params: GatewayClient["connect"] | null | undefined) => boolean; + respond: RespondFn; +}): boolean { + if (!params.client?.connect || !params.isWebchatConnect(params.client.connect)) { + return false; + } + if (params.client.connect.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI) { + return false; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `webchat clients cannot ${params.action} sessions; use chat.send for session-scoped updates`, + ), + ); + return true; +} + +export function isWorkerDispatchInputError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + const code = error.code; + return code === "invalid_profile" || code === "profile_not_found" || code === "invalid_state"; +} + +export function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return false; + } + return sessionKey === resolveAgentMainSessionKey({ cfg, agentId: parsed.agentId }); +} diff --git a/src/gateway/server-methods/sessions-subscriptions.ts b/src/gateway/server-methods/sessions-subscriptions.ts new file mode 100644 index 000000000000..9c6a0833d986 --- /dev/null +++ b/src/gateway/server-methods/sessions-subscriptions.ts @@ -0,0 +1,176 @@ +// Session and transcript event subscription handlers. +import { + ErrorCodes, + errorShape, + validateSessionsMessagesSubscribeParams, + validateSessionsMessagesUnsubscribeParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { canReviewOperatorApproval } from "../operator-approval-authorization.js"; +import { APPROVALS_SCOPE } from "../operator-scopes.js"; +import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-create-service.js"; +import { loadSessionEntry } from "../session-utils.js"; +import { requireSessionKey } from "./sessions-shared.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +function resolveSessionMessageSubscriptionKey(params: { + canonicalKey: string; + agentId?: string; + defaultAgentId?: string; +}): string { + const agentId = params.agentId + ? normalizeAgentId(params.agentId) + : params.canonicalKey === "global" && params.defaultAgentId + ? normalizeAgentId(params.defaultAgentId) + : undefined; + // Global session message subscriptions need per-agent channels to avoid cross-agent fanout. + return params.canonicalKey === "global" && agentId + ? `agent:${agentId}:global` + : params.canonicalKey; +} + +export const sessionSubscriptionHandlers: GatewayRequestHandlers = { + "sessions.subscribe": ({ client, context, respond }) => { + const connId = client?.connId?.trim(); + if (connId) { + context.subscribeSessionEvents(connId); + } + respond(true, { subscribed: Boolean(connId) }, undefined); + }, + "sessions.unsubscribe": ({ client, context, respond }) => { + const connId = client?.connId?.trim(); + if (connId) { + context.unsubscribeSessionEvents(connId); + } + respond(true, { subscribed: false }, undefined); + }, + "sessions.messages.subscribe": ({ params, client, context, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsMessagesSubscribeParams, + "sessions.messages.subscribe", + respond, + ) + ) { + return; + } + const connId = client?.connId?.trim(); + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + if (p.includeApprovals === true && !canReviewOperatorApproval(client)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `sessions.messages.subscribe includeApprovals requires a paired device and gateway scope: ${APPROVALS_SCOPE}`, + ), + ); + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); + const subscriptionKey = resolveSessionMessageSubscriptionKey({ + canonicalKey, + agentId: requestedAgentId, + defaultAgentId: resolveDefaultAgentId(cfg), + }); + if (connId) { + let approvalReplay; + if (p.includeApprovals === true) { + // Subscribe before the authoritative snapshot so a transition cannot + // land between replay and live delivery. Clients reconcile by id. + const rollbackSubscription = context.subscribeSessionMessageEvents( + connId, + subscriptionKey, + { includeApprovals: true }, + ); + try { + approvalReplay = context.listSessionPendingApprovals?.(subscriptionKey, client); + } catch (error) { + rollbackSubscription?.(); + context.logGateway.error(`session approval replay failed: ${String(error)}`); + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "session approval replay unavailable"), + ); + return; + } + if (!approvalReplay) { + rollbackSubscription?.(); + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "session approval replay unavailable"), + ); + return; + } + } else { + context.subscribeSessionMessageEvents(connId, subscriptionKey); + } + respond( + true, + { + subscribed: true, + key: canonicalKey, + ...(p.includeApprovals === true + ? { + approvalReplay, + } + : {}), + }, + undefined, + ); + return; + } + respond(true, { subscribed: false, key: canonicalKey }, undefined); + }, + "sessions.messages.unsubscribe": ({ params, client, context, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsMessagesUnsubscribeParams, + "sessions.messages.unsubscribe", + respond, + ) + ) { + return; + } + const connId = client?.connId?.trim(); + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const cfg = context.getRuntimeConfig(); + const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); + if (!requestedAgent.ok) { + respond(false, undefined, requestedAgent.error); + return; + } + const requestedAgentId = requestedAgent.agentId; + const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); + const subscriptionKey = resolveSessionMessageSubscriptionKey({ + canonicalKey, + agentId: requestedAgentId, + defaultAgentId: resolveDefaultAgentId(cfg), + }); + if (connId) { + context.unsubscribeSessionMessageEvents(connId, subscriptionKey); + } + respond(true, { subscribed: false, key: canonicalKey }, undefined); + }, +}; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 19fed22a31e3..d3e0dcab749d 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1,3983 +1,30 @@ -// Sessions gateway methods implement list/create/patch/delete/reset/compact/ -// restore/preview/send flows over session stores, transcripts, and active runs. -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { - normalizeOptionalString, - readStringValue, -} from "@openclaw/normalization-core/string-coerce"; -import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; -import { - ErrorCodes, - errorShape, - type SessionPlacement, - type SessionOperationEvent, - type SessionsPatchParams, - validateSessionsAbortParams, - validateSessionsCleanupParams, - validateSessionsCompactParams, - validateSessionsCompactionBranchParams, - validateSessionsCompactionGetParams, - validateSessionsCompactionListParams, - validateSessionsCompactionRestoreParams, - validateSessionsCreateParams, - validateSessionsDeleteParams, - validateSessionsDescribeParams, - validateSessionsDispatchParams, - validateSessionsGroupsDeleteParams, - validateSessionsGroupsListParams, - validateSessionsGroupsPutParams, - validateSessionsGroupsRenameParams, - validateSessionsListParams, - validateSessionsMessagesSubscribeParams, - validateSessionsMessagesUnsubscribeParams, - validateSessionsPatchParams, - validateSessionsPluginPatchParams, - validateSessionsPreviewParams, - validateSessionsResetParams, - validateSessionsResolveParams, - validateSessionsSearchParams, - validateSessionsSendParams, -} from "../../../packages/gateway-protocol/src/index.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { - abortEmbeddedAgentRun, - isEmbeddedAgentRunActive, - waitForEmbeddedAgentRunEnd, -} from "../../agents/embedded-agent-runner/runs.js"; -import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js"; -import { resolvePersistedSessionRuntimeId } from "../../agents/session-runtime-compat.js"; -import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js"; -import { insideGitCheckout } from "../../agents/worktrees/git.js"; -import { managedWorktrees } from "../../agents/worktrees/service.js"; -import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; -import { replyRunRegistry } from "../../auto-reply/reply/reply-run-registry.js"; -import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js"; -import { - runSessionsCleanup, - serializeSessionCleanupResult, - resolveMainSessionKey, - resolveSessionWorkStartError, - SESSION_LIFECYCLE_CHANGED_ERROR_REASON, - listConfiguredSessionStoreAgentIds, - deleteSessionEntryLifecycle, - type SessionEntry, -} from "../../config/sessions.js"; -import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; -import { - applySessionPatchProjection, - loadTranscriptEvents, - preflightSessionTranscriptForManualCompact, - resolveSessionTranscriptRuntimeTarget, - rollbackPluginOwnedSessionEntryLifecycle, - trimSessionTranscriptForManualCompact, -} from "../../config/sessions/session-accessor.js"; -import { searchSessionTranscripts } from "../../config/sessions/session-transcript-search.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { disableCronJobsBoundToSession } from "../../cron/job-session-bindings.js"; -import { - measureDiagnosticsTimelineSpan, - measureDiagnosticsTimelineSpanSync, -} from "../../infra/diagnostics-timeline.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js"; -import { isPluginJsonValue } from "../../plugins/host-hooks.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; -import { - isAgentHarnessSessionKey, - resolveMissingAgentHarnessSessionError, -} from "../../sessions/agent-harness-session-key.js"; -import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; -import { - interruptSessionWorkAdmissions, - isSessionLifecycleMutationActive, - isSessionWorkAdmissionActive, - runExclusiveSessionLifecycleMutation, - SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, -} from "../../sessions/session-lifecycle-admission.js"; -import { - handleSessionStateSessionDeleted, - recordSessionCompacted, -} from "../../sessions/session-state-events.js"; -import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; -import { canReviewOperatorApproval } from "../operator-approval-authorization.js"; -import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../operator-scopes.js"; -import { resolveSessionKeyForRun } from "../server-session-key.js"; -import { - createFileBackedCompactionCheckpointStore, - getSessionCompactionCheckpoint, - listSessionCompactionCheckpoints, -} from "../session-compaction-checkpoints.js"; -import { - buildDashboardSessionKey, - createGatewaySession, - resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId, -} from "../session-create-service.js"; -import { - deleteSessionGroup, - ensureSessionGroupRegistered, - listSessionGroups, - putSessionGroups, - renameSessionGroup, -} from "../session-groups.js"; -import { triggerSessionPatchHook } from "../session-patch-hooks.js"; -import { - resolveSessionStoreAgentId, - resolveSessionStoreKey, - resolveStoredSessionKeyForAgentStore, - resolveStoredSessionOwnerAgentId, -} from "../session-store-key.js"; -import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; -import { - readRecentSessionMessagesWithStatsAsync, - readSessionMessageCountAsync, - readSessionPreviewItemsFromTranscript, -} from "../session-transcript-readers.js"; -import { - buildGatewaySessionRow, - listSessionsFromStoreAsync, - loadCombinedSessionStoreForGateway, - loadSessionEntry, - migrateAndPruneGatewaySessionStoreKey, - resolveDeletedAgentIdFromSessionKey, - resolveFreshestSessionEntryFromStoreKeys, - resolveGatewaySessionStoreTarget, - resolveGatewaySessionStoreTargetWithStore, - resolveGatewaySessionThinkingProjection, - resolveSessionDisplayModelIdentityRef, - resolveSessionModelRef, - type SessionsPatchResult, - type SessionsPreviewEntry, - type SessionsPreviewResult, -} from "../session-utils.js"; -import { projectSessionsPatchEntry } from "../sessions-patch.js"; -import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; -import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; -import { projectWorkerSessionPlacement } from "../worker-environments/placement-projector.js"; -import { - isWorkerPlacementSessionRuntimeSupported, - resolveWorkerPlacementSessionRuntime, -} from "../worker-environments/placement-session-runtime.js"; -import { resolveWorkerSessionTarget } from "../worker-environments/session-target.js"; -import { setGatewayDedupeEntry } from "./agent-job.js"; -import { chatHandlers } from "./chat.js"; -import { loadOptionalServerMethodModelCatalog } from "./optional-model-catalog.js"; -import { - hasTrackedActiveSessionRun, - hasVisibleActiveSessionRun, - resolveVisibleActiveSessionRunState, -} from "./session-active-runs.js"; -import { resolveSessionCatalogCreateTarget } from "./session-catalog.js"; -import { emitSessionsChanged } from "./session-change-event.js"; -import { - resolveSessionCreateInitialTurn, - shouldAttachPendingMessageSeq, -} from "./session-create-initial-turn.js"; -import type { - GatewayClient, - GatewayRequestContext, - GatewayRequestHandlerOptions, - GatewayRequestHandlers, - RespondFn, -} from "./types.js"; -import { assertValidParams } from "./validation.js"; +// Sessions gateway methods are grouped by lifecycle responsibility so the +// public registration remains small while each owner module stays reviewable. +import { sessionAbortHandlers } from "./sessions-abort.js"; +import { sessionCompactHandlers } from "./sessions-compact.js"; +import { sessionCheckpointHandlers } from "./sessions-compaction-checkpoints.js"; +import { sessionCheckpointQueryHandlers } from "./sessions-compaction-queries.js"; +import { sessionCreateHandlers } from "./sessions-create.js"; +import { sessionDeleteHandlers } from "./sessions-delete.js"; +import { sessionDispatchHandlers } from "./sessions-dispatch.js"; +import { sessionGroupHandlers } from "./sessions-groups.js"; +import { sessionMessagingHandlers } from "./sessions-messaging.js"; +import { sessionMutationHandlers } from "./sessions-mutations.js"; +import { sessionReadHandlers } from "./sessions-read.js"; +import { sessionSubscriptionHandlers } from "./sessions-subscriptions.js"; +import type { GatewayRequestHandlers } from "./types.js"; -const log = createSubsystemLogger("gateway/sessions"); - -const compactionCheckpointStore = createFileBackedCompactionCheckpointStore(); -const MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE = - "Checkpoint branch and restore are unavailable while model selection is locked."; - -class SessionWorkerPlacementMutationError extends Error { - constructor( - readonly placementState: SessionPlacement["state"], - action: "delete" | "reset" | "restore", - key: string, - ) { - super(`Session ${key} cannot ${action} while cloud worker placement is ${placementState}.`); - } -} - -function resolveSessionWorkerPlacementMutationError(params: { - action: "delete" | "reset" | "restore"; - context: GatewayRequestContext; - key: string; - sessionId: string | undefined; -}): SessionWorkerPlacementMutationError | undefined { - if (!params.sessionId) { - return undefined; - } - const placement = params.context.workerSessionPlacementService - ?.getMany([params.sessionId]) - .get(params.sessionId); - if ( - !placement || - placement.state === "local" || - (params.action === "delete" && placement.state === "reclaimed") - ) { - return undefined; - } - return new SessionWorkerPlacementMutationError(placement.state, params.action, params.key); -} - -function respondSessionWorkerPlacementMutationError( - error: SessionWorkerPlacementMutationError, - respond: RespondFn, -): void { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); -} - -function resolveSessionWorkerPlacementPatchError(params: { - agentId: string; - cfg: OpenClawConfig; - context: GatewayRequestContext; - entry: SessionEntry | undefined; - key: string; - patch: SessionsPatchParams; - sessionKey: string; - validateModelRuntime: boolean; -}): string | undefined { - const placement = params.entry?.sessionId - ? params.context.workerSessionPlacementService - ?.getMany([params.entry.sessionId]) - .get(params.entry.sessionId) - : undefined; - if (!placement || placement.state === "local") { - return undefined; - } - if (params.patch.archived !== undefined) { - return `Session ${params.key} cannot change archive state while cloud worker placement is ${placement.state}.`; - } - if (!params.validateModelRuntime || params.patch.model === undefined || !params.entry) { - return undefined; - } - const runtime = resolveWorkerPlacementSessionRuntime({ - cfg: params.cfg, - entry: params.entry, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); - if (isWorkerPlacementSessionRuntimeSupported(runtime)) { - return undefined; - } - return `Session ${params.key} cannot select the ${runtime} runtime while cloud worker placement is ${placement.state}.`; -} - -function filterSessionStoreToConfiguredAgents( - cfg: OpenClawConfig, - store: Record, -): Record { - const configuredAgentIds = new Set(listConfiguredSessionStoreAgentIds(cfg)); - const isConfiguredSessionKey = (key: string | undefined) => { - const normalizedKey = normalizeOptionalString(key); - if (!normalizedKey) { - return false; - } - const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: normalizedKey }); - const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); - return configuredAgentIds.has(normalizeAgentId(agentId)); - }; - return Object.fromEntries( - Object.entries(store).filter(([key, entry]) => { - if (key === "global" || key === "unknown") { - return true; - } - if (isConfiguredSessionKey(key)) { - return true; - } - // Keep spawned child sessions visible when their parent belongs to a configured agent. - return ( - isConfiguredSessionKey(entry?.spawnedBy) || isConfiguredSessionKey(entry?.parentSessionKey) - ); - }), - ); -} - -const loadSessionsRuntimeModule = createLazyRuntimeModule(() => import("./sessions.runtime.js")); - -function requireSessionKey(key: unknown, respond: RespondFn): string | null { - const raw = - typeof key === "string" - ? key - : typeof key === "number" - ? String(key) - : typeof key === "bigint" - ? String(key) - : ""; - const normalized = normalizeOptionalString(raw) ?? ""; - if (!normalized) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required")); - return null; - } - return normalized; -} - -function rejectPluginRuntimeDeleteMismatch(params: { - client: GatewayClient | null; - key: string; - entry: SessionEntry | undefined; - respond: RespondFn; -}): boolean { - const pluginOwnerId = normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId); - if (!pluginOwnerId || !params.entry) { - return false; - } - if (normalizeOptionalString(params.entry.pluginOwnerId) === pluginOwnerId) { - return false; - } - params.respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Plugin "${pluginOwnerId}" cannot delete session "${params.key}" because it did not create it.`, - ), - ); - return true; -} - -function resolveGatewaySessionTargetFromKey( - key: string, - cfg: OpenClawConfig, - opts?: { agentId?: string }, -) { - const target = resolveGatewaySessionStoreTarget({ - cfg, - key, - ...(opts?.agentId ? { agentId: opts.agentId } : {}), - }); - return { cfg, target, storePath: target.storePath }; -} - -function loadAccessorSessionEntryForGatewayTarget(params: { - key: string; - cfg: OpenClawConfig; - agentId?: string; -}) { - const target = resolveGatewaySessionStoreTargetWithStore({ - cfg: params.cfg, - key: params.key, - clone: false, - ...(params.agentId ? { agentId: params.agentId } : {}), - }); - let best: - | { - entry: SessionEntry; - sessionStoreKey: string; - } - | undefined; - for (const sessionStoreKey of target.storeKeys) { - const entry = target.store[sessionStoreKey]; - if (entry) { - if (!best || (entry.updatedAt ?? 0) > (best.entry.updatedAt ?? 0)) { - best = { entry, sessionStoreKey }; - } - } - } - if (best) { - return { - target, - storePath: target.storePath, - entry: best.entry, - canonicalKey: target.canonicalKey, - sessionStoreKey: best.sessionStoreKey, - }; - } - return { - target, - storePath: target.storePath, - entry: undefined, - canonicalKey: target.canonicalKey, - sessionStoreKey: target.canonicalKey, - }; -} - -function loadSessionEntriesForTarget(params: { - key: string; - cfg: OpenClawConfig; - agentId?: string; -}) { - const target = resolveGatewaySessionStoreTargetWithStore({ - cfg: params.cfg, - key: params.key, - clone: false, - ...(params.agentId ? { agentId: params.agentId } : {}), - }); - const store = target.store; - const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); - return { target, storePath: target.storePath, store, entry }; -} - -function emitSessionOperation( - context: Pick, - payload: Omit, -) { - const connIds = context.getSessionEventSubscriberConnIds(); - if (connIds.size === 0) { - return; - } - context.broadcastToConnIds( - "session.operation", - { - ...payload, - ts: Date.now(), - } satisfies SessionOperationEvent, - connIds, - { dropIfSlow: true }, - ); -} - -function rejectWebchatSessionMutation(params: { - action: "patch" | "delete" | "compact" | "restore" | "dispatch"; - client: GatewayClient | null; - isWebchatConnect: (params: GatewayClient["connect"] | null | undefined) => boolean; - respond: RespondFn; -}): boolean { - if (!params.client?.connect || !params.isWebchatConnect(params.client.connect)) { - return false; - } - if (params.client.connect.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI) { - return false; - } - params.respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `webchat clients cannot ${params.action} sessions; use chat.send for session-scoped updates`, - ), - ); - return true; -} - -function isWorkerDispatchInputError(error: unknown): boolean { - if (typeof error !== "object" || error === null || !("code" in error)) { - return false; - } - const code = error.code; - return code === "invalid_profile" || code === "profile_not_found" || code === "invalid_state"; -} - -function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean { - const parsed = parseAgentSessionKey(sessionKey); - if (!parsed) { - return false; - } - return sessionKey === resolveAgentMainSessionKey({ cfg, agentId: parsed.agentId }); -} - -async function createAgentMainSessionForSend(params: { - req: GatewayRequestHandlerOptions["req"]; - canonicalKey: string; - context: GatewayRequestContext; - client: GatewayClient | null; - isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; -}): Promise< - | { - ok: true; - entry: SessionEntry; - canonicalKey: string; - storePath: string; - } - | { ok: false; error: ReturnType } -> { - const agentId = parseAgentSessionKey(params.canonicalKey)?.agentId; - if (!agentId) { - return { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${params.canonicalKey}`), - }; - } - - let createResult: - | { ok: boolean; payload?: { key?: string }; error?: ReturnType } - | undefined; - await expectDefined( - sessionsHandlers["sessions.create"], - "sessions.create handler", - )({ - req: params.req, - params: { - key: params.canonicalKey, - agentId, - }, - respond: (ok, payload, error) => { - createResult = { - ok, - payload: payload && typeof payload === "object" ? (payload as { key?: string }) : undefined, - error, - }; - }, - context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, - }); - - if (!createResult) { - return { - ok: false, - error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"), - }; - } - if (!createResult.ok) { - return { - ok: false, - error: createResult.error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create session"), - }; - } - - const createdKey = normalizeOptionalString(createResult.payload?.key) ?? params.canonicalKey; - const loaded = loadSessionEntry(createdKey); - if (!loaded.entry?.sessionId) { - return { - ok: false, - error: errorShape(ErrorCodes.UNAVAILABLE, `session not created: ${createdKey}`), - }; - } - return { - ok: true, - entry: loaded.entry, - canonicalKey: loaded.canonicalKey, - storePath: loaded.storePath, - }; -} - -function resolveAbortSessionKey(params: { - context: Pick; - requestedKey: string; - canonicalKey: string; - activeRunSessionKey?: string; - aliasKeys?: string[]; -}): string { - if (params.activeRunSessionKey) { - return params.activeRunSessionKey; - } - const candidates = [params.canonicalKey, params.requestedKey, ...(params.aliasKeys ?? [])]; - for (const active of params.context.chatAbortControllers.values()) { - if (active.controlUiVisible === false) { - continue; - } - for (const candidate of candidates) { - if (active.sessionKey === candidate) { - return candidate; - } - } - } - return params.requestedKey; -} - -function resolveSessionKeyAgentId( - sessionKey: string | undefined, - cfg: OpenClawConfig, -): string | undefined { - const key = normalizeOptionalString(sessionKey); - if (!key) { - return undefined; - } - if (!parseAgentSessionKey(key) && key.toLowerCase().startsWith("agent:")) { - return undefined; - } - const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); - return resolveSessionStoreAgentId(cfg, canonicalKey); -} - -function sessionKeyBelongsToAgent( - sessionKey: string | undefined, - agentId: string, - cfg: OpenClawConfig, -): boolean { - const key = normalizeOptionalString(sessionKey); - if (cfg.session?.scope === "global" && key?.toLowerCase() === "global") { - return true; - } - const sessionAgentId = resolveSessionKeyAgentId(sessionKey, cfg); - return Boolean(sessionAgentId && sessionAgentId === normalizeAgentId(agentId)); -} - -function resolveScopedAbortKey(params: { - cfg: OpenClawConfig; - key: string | undefined; - agentId: string | undefined; -}): string | undefined { - const key = normalizeOptionalString(params.key); - if (!key) { - return undefined; - } - const requestedAgentId = normalizeOptionalString(params.agentId); - if (!requestedAgentId) { - return key; - } - const scopedAgentId = normalizeAgentId(requestedAgentId); - const ownerAgentId = resolveStoredSessionOwnerAgentId({ - cfg: params.cfg, - agentId: scopedAgentId, - sessionKey: key, - }); - if (ownerAgentId && ownerAgentId !== scopedAgentId) { - return undefined; - } - return resolveStoredSessionKeyForAgentStore({ - cfg: params.cfg, - agentId: scopedAgentId, - sessionKey: key, - }); -} - -function resolveSessionMessageSubscriptionKey(params: { - canonicalKey: string; - agentId?: string; - defaultAgentId?: string; -}): string { - const agentId = params.agentId - ? normalizeAgentId(params.agentId) - : params.canonicalKey === "global" && params.defaultAgentId - ? normalizeAgentId(params.defaultAgentId) - : undefined; - // Global session message subscriptions need per-agent channels to avoid cross-agent fanout. - return params.canonicalKey === "global" && agentId - ? `agent:${agentId}:global` - : params.canonicalKey; -} - -async function interruptSessionRunIfActive(params: { - req: GatewayRequestHandlerOptions["req"]; - context: GatewayRequestContext; - client: GatewayClient | null; - isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; - requestedKey: string; - canonicalKey: string; - agentId?: string; - sessionId?: string; -}): Promise<{ interrupted: boolean; error?: ReturnType }> { - const cfg = params.context.getRuntimeConfig(); - const hasTrackedRun = hasTrackedActiveSessionRun({ - context: params.context, - requestedKey: params.requestedKey, - canonicalKey: params.canonicalKey, - agentId: params.agentId, - defaultAgentId: resolveDefaultAgentId(cfg), - }); - const hasEmbeddedRun = - typeof params.sessionId === "string" && params.sessionId - ? isEmbeddedAgentRunActive(params.sessionId) - : false; - const hasWorkerRun = - typeof params.sessionId === "string" && params.sessionId - ? (asWorkerInferenceControl(params.context.workerEnvironmentService)?.hasInferenceForSession( - params.sessionId, - ) ?? false) - : false; - - if (!hasTrackedRun && !hasEmbeddedRun && !hasWorkerRun) { - return { interrupted: false }; - } - - if (hasTrackedRun || hasWorkerRun) { - let abortOk = true; - let abortError: ReturnType | undefined; - const abortSessionKey = resolveAbortSessionKey({ - context: params.context, - requestedKey: params.requestedKey, - canonicalKey: params.canonicalKey, - }); - - await expectDefined( - chatHandlers["chat.abort"], - "chat.abort handler", - )({ - req: params.req, - params: { - sessionKey: abortSessionKey, - ...(params.canonicalKey === "global" && params.agentId ? { agentId: params.agentId } : {}), - }, - respond: (ok, _payload, error) => { - abortOk = ok; - abortError = error; - }, - context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, - }); - - if (!abortOk) { - return { - interrupted: true, - error: - abortError ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to interrupt active session"), - }; - } - } - - if (hasEmbeddedRun && params.sessionId) { - abortEmbeddedAgentRun(params.sessionId); - } - - // Clear queued follow-up work for both requested aliases and the canonical session id. - clearSessionQueues([params.requestedKey, params.canonicalKey, params.sessionId]); - - if (hasEmbeddedRun && params.sessionId) { - const ended = await waitForEmbeddedAgentRunEnd(params.sessionId, 15_000); - if (!ended) { - return { - interrupted: true, - error: errorShape( - ErrorCodes.UNAVAILABLE, - `Session ${params.requestedKey} is still active; try again in a moment.`, - ), - }; - } - } - - return { interrupted: true }; -} - -async function handleSessionSend(params: { - method: "sessions.send" | "sessions.steer"; - req: GatewayRequestHandlerOptions["req"]; - params: Record; - respond: RespondFn; - context: GatewayRequestContext; - client: GatewayClient | null; - isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"]; - interruptIfActive: boolean; -}) { - if ( - !assertValidParams(params.params, validateSessionsSendParams, params.method, params.respond) - ) { - return; - } - const p = params.params; - const key = requireSessionKey((p as { key?: unknown }).key, params.respond); - if (!key) { - return; - } - const cfg = params.context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId( - cfg, - key, - (p as { agentId?: string }).agentId, - ); - if (!requestedAgent.ok) { - params.respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const loaded = loadSessionEntry(key, { agentId: requestedAgentId }); - const { legacyKey } = loaded; - let { entry, canonicalKey, storePath } = loaded; - // Reject sends/steers targeting sessions whose owning agent was deleted (#65524). - const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey, entry, { - acpMetadataSessionKey: legacyKey ?? canonicalKey, - }); - if (deletedAgentId !== null) { - params.respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Agent "${deletedAgentId}" no longer exists in configuration`, - ), - ); - return; - } - const rawIdempotencyKey = (p as { idempotencyKey?: string }).idempotencyKey; - const explicitIdempotencyKey = - typeof rawIdempotencyKey === "string" && rawIdempotencyKey.trim() - ? rawIdempotencyKey.trim() - : undefined; - const idempotencyKey = explicitIdempotencyKey ?? randomUUID(); - const dispatchChatSend = async (respond: RespondFn) => { - await expectDefined( - chatHandlers["chat.send"], - "chat.send handler", - )({ - req: params.req, - params: { - sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), - message: (p as { message: string }).message, - thinking: (p as { thinking?: string }).thinking, - attachments: (p as { attachments?: unknown[] }).attachments, - timeoutMs: (p as { timeoutMs?: number }).timeoutMs, - idempotencyKey, - }, - respond, - context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, - }); - }; - const archivedSessionError = resolveSessionWorkStartError(canonicalKey, entry); - if (archivedSessionError) { - // An explicit retry may already have a terminal chat.send result. Let the - // owning handler replay that result before it applies the archive guard. - if (explicitIdempotencyKey) { - await dispatchChatSend(params.respond); - return; - } - params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, archivedSessionError)); - return; - } - if (!entry?.sessionId && !params.interruptIfActive && isAgentMainSessionKey(cfg, canonicalKey)) { - // Sending to an empty agent main session should create it; steering still requires an active row. - const created = await createAgentMainSessionForSend({ - req: params.req, - canonicalKey, - context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, - }); - if (!created.ok) { - params.respond(false, undefined, created.error); - return; - } - entry = created.entry; - canonicalKey = created.canonicalKey; - storePath = created.storePath; - } - if (!entry?.sessionId) { - params.respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - - let interruptedActiveRun = false; - if (params.interruptIfActive) { - const interruptResult = await interruptSessionRunIfActive({ - req: params.req, - context: params.context, - client: params.client, - isWebchatConnect: params.isWebchatConnect, - requestedKey: key, - canonicalKey, - agentId: requestedAgentId, - sessionId: entry.sessionId, - }); - if (interruptResult.error) { - params.respond(false, undefined, interruptResult.error); - return; - } - interruptedActiveRun = interruptResult.interrupted; - } - - const messageSeq = - (await readSessionMessageCountAsync({ - agentId: requestedAgentId, - sessionEntry: entry, - sessionId: entry.sessionId, - sessionKey: canonicalKey, - storePath, - })) + 1; - let sendAcked = false; - let sendPayload: unknown; - let sendCached = false; - let startedRunId: string | undefined; - await dispatchChatSend((ok, payload, error, meta) => { - sendAcked = ok; - sendPayload = payload; - sendCached = meta?.cached === true; - startedRunId = - payload && - typeof payload === "object" && - typeof (payload as { runId?: unknown }).runId === "string" - ? (payload as { runId: string }).runId - : undefined; - if (ok && shouldAttachPendingMessageSeq({ payload, cached: meta?.cached === true })) { - params.respond( - true, - { - ...(payload && typeof payload === "object" ? payload : {}), - messageSeq, - ...(interruptedActiveRun ? { interruptedActiveRun: true } : {}), - }, - undefined, - meta, - ); - return; - } - params.respond( - ok, - ok && payload && typeof payload === "object" - ? { - ...payload, - ...(interruptedActiveRun ? { interruptedActiveRun: true } : {}), - } - : payload, - error, - meta, - ); - }); - if (sendAcked) { - if (shouldAttachPendingMessageSeq({ payload: sendPayload, cached: sendCached })) { - await reactivateCompletedSubagentSession({ - sessionKey: canonicalKey, - runId: startedRunId, - task: (p as { message: string }).message, - }); - } - emitSessionsChanged(params.context, { - sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgentId ? { agentId: requestedAgentId } : {}), - reason: interruptedActiveRun ? "steer" : "send", - }); - } -} export const sessionsHandlers: GatewayRequestHandlers = { - "sessions.search": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsSearchParams, "sessions.search", respond)) { - return; - } - const query = params.query.trim(); - if (!query) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "query must not be empty")); - return; - } - const cfg = context.getRuntimeConfig(); - if (params.agentId && !params.sessionKeys) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "agentId requires sessionKeys"), - ); - return; - } - const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - const sessionKeys = params.sessionKeys?.map((sessionKey) => - requestedAgentId - ? resolveStoredSessionKeyForAgentStore({ cfg, agentId: requestedAgentId, sessionKey }) - : resolveSessionStoreKey({ cfg, sessionKey }), - ); - const agentIds = new Set( - sessionKeys?.map((sessionKey) => - requestedAgentId && (sessionKey === "global" || sessionKey === "unknown") - ? requestedAgentId - : resolveSessionStoreAgentId(cfg, sessionKey), - ), - ); - if ( - agentIds.size > 1 || - (requestedAgentId && [...agentIds].some((agentId) => agentId !== requestedAgentId)) - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.search supports one agent per call"), - ); - return; - } - const agentId = - requestedAgentId ?? agentIds.values().next().value ?? resolveDefaultAgentId(cfg); - try { - const result = searchSessionTranscripts({ - agentId, - query, - limit: params.limit, - ...(sessionKeys ? { sessionKeys } : {}), - }); - respond(true, { - results: result.hits, - ...(result.indexing ? { indexing: true } : {}), - ...(result.truncated ? { truncated: true } : {}), - }); - } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - } - }, - "sessions.list": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) { - return; - } - const p = params; - const cfg = context.getRuntimeConfig(); - const configuredAgentsOnly = p.configuredAgentsOnly === true; - const payload = await measureDiagnosticsTimelineSpan( - "gateway.sessions.list", - async () => { - const { storePath, store } = measureDiagnosticsTimelineSpanSync( - "gateway.sessions.list.store_load", - () => - loadCombinedSessionStoreForGateway(cfg, { - agentId: p.agentId, - }), - { - config: cfg, - phase: "sessions.list", - attributes: { - agentId: p.agentId ?? null, - configuredAgentsOnly, - }, - }, - ); - const listStore = configuredAgentsOnly - ? filterSessionStoreToConfiguredAgents(cfg, store) - : store; - const modelCatalog = await measureDiagnosticsTimelineSpan( - "gateway.sessions.list.model_catalog", - () => loadOptionalServerMethodModelCatalog(context, "sessions.list"), - { - config: cfg, - phase: "sessions.list", - }, - ); - const result = await measureDiagnosticsTimelineSpan( - "gateway.sessions.list.rows", - () => - listSessionsFromStoreAsync({ - cfg, - storePath, - store: listStore, - modelCatalog, - opts: p, - }), - { - config: cfg, - phase: "sessions.list", - attributes: { - storeEntries: Object.keys(listStore).length, - }, - }, - ); - const placementsBySessionId = context.workerSessionPlacementService?.getMany( - result.sessions.flatMap((session) => (session.sessionId ? [session.sessionId] : [])), - ); - const sessions = measureDiagnosticsTimelineSpanSync( - "gateway.sessions.list.active_run_flags", - () => { - return result.sessions.map((session) => { - const placementRecord = session.sessionId - ? placementsBySessionId?.get(session.sessionId) - : undefined; - const activeRunState = resolveVisibleActiveSessionRunState({ - context, - requestedKey: session.key, - canonicalKey: session.key, - sessionId: session.sessionId, - ...(session.key === "global" && p.agentId ? { agentId: p.agentId } : {}), - defaultAgentId: resolveDefaultAgentId(cfg), - }); - return Object.assign({}, session, { - hasActiveRun: activeRunState.active, - ...(placementRecord - ? { placement: projectWorkerSessionPlacement(placementRecord) } - : {}), - ...(activeRunState.runIds.length > 0 - ? { activeRunIds: activeRunState.runIds } - : {}), - }); - }); - }, - { - config: cfg, - phase: "sessions.list", - attributes: { - sessions: result.sessions.length, - }, - }, - ); - return { - ...result, - sessions, - }; - }, - { - config: cfg, - phase: "sessions.list", - attributes: { - agentId: p.agentId ?? null, - configuredAgentsOnly, - }, - }, - ); - respond(true, payload, undefined); - }, - "sessions.cleanup": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsCleanupParams, "sessions.cleanup", respond)) { - return; - } - const p = params; - try { - const { mode, appliedSummaries } = await runSessionsCleanup({ - cfg: context.getRuntimeConfig(), - opts: { - agent: p.agent, - allAgents: p.allAgents, - enforce: p.enforce, - activeKey: p.activeKey, - fixMissing: p.fixMissing, - fixDmScope: p.fixDmScope, - }, - }); - const result = serializeSessionCleanupResult({ - mode, - dryRun: false, - summaries: appliedSummaries, - }); - respond(true, result, undefined); - for (const summary of appliedSummaries) { - emitSessionsChanged(context, { - reason: "cleanup", - sessionKey: undefined, - }); - if (summary.wouldMutate) { - context.logGateway.debug( - `sessions.cleanup applied ${summary.storePath}: ${summary.beforeCount} -> ${summary.afterCount}`, - ); - } - } - } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(error))); - } - }, - "sessions.subscribe": ({ client, context, respond }) => { - const connId = client?.connId?.trim(); - if (connId) { - context.subscribeSessionEvents(connId); - } - respond(true, { subscribed: Boolean(connId) }, undefined); - }, - "sessions.unsubscribe": ({ client, context, respond }) => { - const connId = client?.connId?.trim(); - if (connId) { - context.unsubscribeSessionEvents(connId); - } - respond(true, { subscribed: false }, undefined); - }, - "sessions.messages.subscribe": ({ params, client, context, respond }) => { - if ( - !assertValidParams( - params, - validateSessionsMessagesSubscribeParams, - "sessions.messages.subscribe", - respond, - ) - ) { - return; - } - const connId = client?.connId?.trim(); - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - if (p.includeApprovals === true && !canReviewOperatorApproval(client)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `sessions.messages.subscribe includeApprovals requires a paired device and gateway scope: ${APPROVALS_SCOPE}`, - ), - ); - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); - const subscriptionKey = resolveSessionMessageSubscriptionKey({ - canonicalKey, - agentId: requestedAgentId, - defaultAgentId: resolveDefaultAgentId(cfg), - }); - if (connId) { - let approvalReplay; - if (p.includeApprovals === true) { - // Subscribe before the authoritative snapshot so a transition cannot - // land between replay and live delivery. Clients reconcile by id. - const rollbackSubscription = context.subscribeSessionMessageEvents( - connId, - subscriptionKey, - { includeApprovals: true }, - ); - try { - approvalReplay = context.listSessionPendingApprovals?.(subscriptionKey, client); - } catch (error) { - rollbackSubscription?.(); - context.logGateway.error(`session approval replay failed: ${String(error)}`); - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "session approval replay unavailable"), - ); - return; - } - if (!approvalReplay) { - rollbackSubscription?.(); - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "session approval replay unavailable"), - ); - return; - } - } else { - context.subscribeSessionMessageEvents(connId, subscriptionKey); - } - respond( - true, - { - subscribed: true, - key: canonicalKey, - ...(p.includeApprovals === true - ? { - approvalReplay, - } - : {}), - }, - undefined, - ); - return; - } - respond(true, { subscribed: false, key: canonicalKey }, undefined); - }, - "sessions.messages.unsubscribe": ({ params, client, context, respond }) => { - if ( - !assertValidParams( - params, - validateSessionsMessagesUnsubscribeParams, - "sessions.messages.unsubscribe", - respond, - ) - ) { - return; - } - const connId = client?.connId?.trim(); - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const { canonicalKey } = loadSessionEntry(key, { agentId: requestedAgentId }); - const subscriptionKey = resolveSessionMessageSubscriptionKey({ - canonicalKey, - agentId: requestedAgentId, - defaultAgentId: resolveDefaultAgentId(cfg), - }); - if (connId) { - context.unsubscribeSessionMessageEvents(connId, subscriptionKey); - } - respond(true, { subscribed: false, key: canonicalKey }, undefined); - }, - "sessions.preview": ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsPreviewParams, "sessions.preview", respond)) { - return; - } - const p = params; - const keysRaw = Array.isArray(p.keys) ? p.keys : []; - const keys = keysRaw - .map((key) => normalizeOptionalString(key ?? "")) - .filter((key): key is string => Boolean(key)) - .slice(0, 64); - const limit = - typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12; - const maxChars = - typeof p.maxChars === "number" && Number.isFinite(p.maxChars) - ? Math.max(20, p.maxChars) - : 240; - - if (keys.length === 0) { - respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined); - return; - } - - const cfg = context.getRuntimeConfig(); - const storeCache = new Map>(); - const previews: SessionsPreviewEntry[] = []; - - for (const key of keys) { - try { - const cachedStoreTarget = resolveGatewaySessionStoreTargetWithStore({ - cfg, - key, - }); - const store = storeCache.get(cachedStoreTarget.storePath) ?? cachedStoreTarget.store; - storeCache.set(cachedStoreTarget.storePath, store); - const target = resolveGatewaySessionStoreTarget({ - cfg, - key, - store, - }); - const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); - if (!entry?.sessionId) { - previews.push({ key, status: "missing", items: [] }); - continue; - } - const items = readSessionPreviewItemsFromTranscript( - { - agentId: target.agentId, - sessionEntry: entry, - sessionId: entry.sessionId, - sessionKey: target.canonicalKey, - storePath: target.storePath, - }, - limit, - maxChars, - ); - previews.push({ - key, - status: items.length > 0 ? "ok" : "empty", - items, - }); - } catch { - previews.push({ key, status: "error", items: [] }); - } - } - - respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined); - }, - "sessions.describe": ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsDescribeParams, "sessions.describe", respond)) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - const cfg = context.getRuntimeConfig(); - const { target, storePath, store, entry } = loadSessionEntriesForTarget({ key, cfg }); - if (!entry) { - respond(true, { session: null }, undefined); - return; - } - const row = buildGatewaySessionRow({ - cfg, - storePath, - store, - key: target.canonicalKey, - entry, - includeDerivedTitles: p.includeDerivedTitles, - includeLastMessage: p.includeLastMessage, - transcriptUsageMaxBytes: 64 * 1024, - }); - const placement = row.sessionId - ? context.workerSessionPlacementService?.getMany([row.sessionId]).get(row.sessionId) - : undefined; - respond( - true, - { - session: placement ? { ...row, placement: projectWorkerSessionPlacement(placement) } : row, - }, - undefined, - ); - }, - "sessions.resolve": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) { - return; - } - const p = params; - const cfg = context.getRuntimeConfig(); - - const resolved = await resolveSessionKeyFromResolveParams({ cfg, p }); - if (!resolved.ok) { - respond(false, undefined, resolved.error); - return; - } - if ("missing" in resolved) { - respond(true, { ok: false }, undefined); - return; - } - respond(true, { ok: true, key: resolved.key }, undefined); - }, - "sessions.compaction.list": ({ params, respond, context }) => { - if ( - !assertValidParams( - params, - validateSessionsCompactionListParams, - "sessions.compaction.list", - respond, - ) - ) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const { entry, canonicalKey } = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - respond( - true, - { - ok: true, - key: canonicalKey, - checkpoints: listSessionCompactionCheckpoints(entry), - }, - undefined, - ); - }, - "sessions.compaction.get": ({ params, respond, context }) => { - if ( - !assertValidParams( - params, - validateSessionsCompactionGetParams, - "sessions.compaction.get", - respond, - ) - ) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - const checkpointId = normalizeOptionalString(p.checkpointId) ?? ""; - if (!checkpointId) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const { entry, canonicalKey } = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); - if (!checkpoint) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - respond( - true, - { - ok: true, - key: canonicalKey, - checkpoint, - }, - undefined, - ); - }, - "sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => { - if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) { - return; - } - const p = params; - const cfg = context.getRuntimeConfig(); - const catalogId = normalizeOptionalString(p.catalogId); - if (catalogId && p.model) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create catalogId cannot include model"), - ); - return; - } - if (catalogId && p.key) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create catalogId cannot include key"), - ); - return; - } - const catalogRequestedKey = normalizeOptionalString(p.key) ?? "global"; - const catalogAgentId = catalogId - ? normalizeAgentId( - normalizeOptionalString(p.agentId) ?? - parseAgentSessionKey(catalogRequestedKey)?.agentId ?? - resolveDefaultAgentId(cfg), - ) - : undefined; - const catalogRequestedAgent = catalogAgentId - ? resolveRequestedGlobalAgentId(cfg, catalogRequestedKey, catalogAgentId) - : undefined; - if (catalogRequestedAgent && !catalogRequestedAgent.ok) { - respond(false, undefined, catalogRequestedAgent.error); - return; - } - const catalogTarget = - catalogId && catalogAgentId - ? resolveSessionCatalogCreateTarget(catalogId, catalogAgentId) - : undefined; - if (catalogTarget && !catalogTarget.ok) { - respond( - false, - undefined, - errorShape( - catalogTarget.unknownCatalog ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - catalogTarget.message, - ), - ); - return; - } - const initialTurn = resolveSessionCreateInitialTurn(p); - if (!initialTurn) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.create attachments require usable content", - ), - ); - return; - } - const { - attachments: initialAttachments, - hasInitialTurn, - message: initialMessage, - } = initialTurn; - const requestedCwd = normalizeOptionalString(p.cwd); - const requestedExecNode = normalizeOptionalString(p.execNode); - if (requestedCwd && p.worktree !== true && !requestedExecNode) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.create cwd requires worktree=true or execNode", - ), - ); - return; - } - const cwdIsAbsolute = - !requestedCwd || - path.isAbsolute(requestedCwd) || - Boolean(requestedExecNode && path.win32.isAbsolute(requestedCwd)); - if (!cwdIsAbsolute) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd must be absolute"), - ); - return; - } - if (requestedExecNode && p.worktree === true) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create worktree cannot target execNode"), - ); - return; - } - const requestedWorktreeBaseRef = normalizeOptionalString(p.worktreeBaseRef); - const requestedWorktreeName = normalizeOptionalString(p.worktreeName); - if ((requestedWorktreeBaseRef || requestedWorktreeName) && p.worktree !== true) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.create worktreeBaseRef/worktreeName require worktree=true", - ), - ); - return; - } - let sessionKey = p.key; - let sessionAgentId = catalogAgentId ?? p.agentId; - let sessionWorktree: Awaited> | undefined; - const sessionExecCwd = requestedExecNode ? requestedCwd : undefined; - let sessionCwd: string | undefined; - let sessionSourceRoot: string | undefined; - let provisionedSessionWorktree = false; - if (p.worktree === true) { - // The normal path stays at operator.write and checks out the configured agent workspace. - // An explicit cwd can target another host checkout, so method-scopes requires admin. - const explicitKey = normalizeOptionalString(p.key); - const requestedKey = explicitKey ?? "global"; - const requestedAgent = resolveRequestedGlobalAgentId(cfg, requestedKey, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const agentId = normalizeAgentId( - requestedAgent.agentId ?? - normalizeOptionalString(p.agentId) ?? - parseAgentSessionKey(requestedKey)?.agentId ?? - resolveDefaultAgentId(cfg), - ); - let targetKey = explicitKey; - let preservesUnspecifiedKey = false; - const parentSessionKey = normalizeOptionalString(p.parentSessionKey); - if ( - !targetKey && - parentSessionKey && - p.emitCommandHooks === true && - !hasInitialTurn && - cfg.session?.dmScope === "main" - ) { - const parent = loadSessionEntry( - parentSessionKey, - requestedAgent.agentId ? { agentId: requestedAgent.agentId } : undefined, - ); - const parentAgentId = normalizeAgentId( - requestedAgent.agentId ?? resolveSessionStoreAgentId(cfg, parent.canonicalKey), - ); - if ( - parent.entry?.sessionId && - parent.canonicalKey === resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }) - ) { - targetKey = parent.canonicalKey; - preservesUnspecifiedKey = true; - } - } - targetKey ??= buildDashboardSessionKey(agentId); - const target = resolveGatewaySessionStoreTarget({ cfg, key: targetKey, agentId }); - sessionKey = preservesUnspecifiedKey ? undefined : targetKey; - sessionAgentId = target.agentId; - const workspace = requestedCwd ?? resolveAgentWorkspaceDir(cfg, target.agentId); - // Subdirectory workspaces are valid: the worktree service resolves the repo root - // via git discovery, so the preflight must accept ancestor .git entries too. - if (!insideGitCheckout(workspace)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "agent workspace is not a git checkout"), - ); - return; - } - try { - const requestedRepository = await managedWorktrees.resolveRepositoryPaths(workspace); - sessionSourceRoot = requestedRepository.sourceRoot; - const existing = managedWorktrees.findLiveByOwner("session", target.canonicalKey); - let existingDirectory = false; - if (existing) { - try { - existingDirectory = fs.lstatSync(existing.path).isDirectory(); - } catch { - // Missing registry targets are replaced; periodic GC retires their stale rows. - } - } - if (existing && existingDirectory) { - if (existing.repoRoot !== requestedRepository.canonicalRoot) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "session worktree belongs to a different repository", - ), - ); - return; - } - // Adopting an existing checkout cannot honor a different name or a - // new base; fail loudly instead of silently ignoring the request. - if ( - (requestedWorktreeName && existing.name !== requestedWorktreeName) || - requestedWorktreeBaseRef - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `session is already bound to worktree ${existing.name} (${existing.branch})`, - ), - ); - return; - } - sessionWorktree = existing; - } else { - const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; - sessionWorktree = await managedWorktrees.create({ - repoRoot: workspace, - ownerKind: "session", - ownerId: target.canonicalKey, - name: requestedWorktreeName, - baseRef: requestedWorktreeBaseRef, - // Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them - // admin-only so this write-scoped path cannot execute gated repo scripts. - runSetupScript: scopes.includes(ADMIN_SCOPE), - }); - provisionedSessionWorktree = true; - } - } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - return; - } - // Nested workspaces run from the matching subdirectory inside the worktree, mirroring - // how the session would have run in the source checkout; the worktree root would - // silently change tool/file scope for subdirectory-configured agents. - sessionCwd = sessionWorktree.path; - try { - const relative = path.relative( - sessionSourceRoot ?? fs.realpathSync(sessionWorktree.repoRoot), - fs.realpathSync(workspace), - ); - if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { - sessionCwd = path.join(sessionWorktree.path, relative); - fs.mkdirSync(sessionCwd, { recursive: true }); - } - } catch { - sessionCwd = sessionWorktree.path; - } - } - let runPayload: Record | undefined; - let runError: unknown; - let runMeta: Record | undefined; - let messageSeq: number | undefined; - const created = await createGatewaySession({ - cfg, - key: sessionKey, - agentId: sessionAgentId, - label: p.label, - ...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }), - parentSessionKey: p.parentSessionKey, - spawnedCwd: sessionCwd, - worktree: sessionWorktree - ? { - id: sessionWorktree.id, - branch: sessionWorktree.branch, - repoRoot: sessionWorktree.repoRoot, - } - : undefined, - execNode: requestedExecNode, - execCwd: sessionExecCwd, - clearExecBinding: !requestedExecNode, - // A plain New Chat that resets an existing session must not inherit its prior worktree cwd. - clearSpawnedCwd: p.worktree !== true, - fork: p.fork, - emitCommandHooks: p.emitCommandHooks, - resetMainWhenUnspecified: !hasInitialTurn, - commandSource: "webchat", - loadGatewayModelCatalog: context.loadGatewayModelCatalog, - afterCreate: hasInitialTurn - ? async ({ key, agentId, entry, storePath }) => { - messageSeq = - (await readSessionMessageCountAsync({ - agentId, - sessionEntry: entry, - sessionId: entry.sessionId, - sessionKey: key, - storePath, - })) + 1; - await expectDefined( - chatHandlers["chat.send"], - "chat.send handler", - )({ - req, - params: { - sessionKey: key, - ...(key === "global" ? { agentId } : {}), - message: initialMessage ?? "", - idempotencyKey: randomUUID(), - ...(initialAttachments ? { attachments: initialAttachments } : {}), - }, - respond: (ok, payload, error, meta) => { - if (ok && payload && typeof payload === "object") { - runPayload = payload as Record; - } else { - runError = error; - } - runMeta = meta; - }, - context, - client, - isWebchatConnect, - }); - } - : undefined, - }); - if (!created.ok) { - if (sessionWorktree && provisionedSessionWorktree) { - try { - await managedWorktrees.remove({ - id: sessionWorktree.id, - reason: "session-create-failed", - force: true, - }); - } catch (error) { - log.warn( - `failed to clean up worktree after session creation failed: ${formatErrorMessage(error)}`, - ); - } - } - respond(false, undefined, created.error); - return; - } - // Leaving an isolated checkout via a plain New Chat detaches the session from its - // worktree; remove it when lossless so the reset does not orphan a protected worktree. - if (p.worktree !== true) { - try { - const owned = managedWorktrees.findLiveByOwner("session", created.key); - if (owned) { - await managedWorktrees.removeIfLossless(owned.id); - } - } catch (error) { - log.warn( - `failed to release worktree for reset session ${created.key}: ${formatErrorMessage(error)}`, - ); - } - } - const createdWorktree = sessionWorktree - ? { - id: sessionWorktree.id, - path: sessionWorktree.path, - branch: sessionWorktree.branch, - } - : undefined; - if (created.resetExisting) { - respond( - true, - { - ok: true, - key: created.key, - sessionId: created.entry.sessionId, - entry: created.entry, - resolved: created.resolved, - runStarted: false, - ...(createdWorktree ? { worktree: createdWorktree } : {}), - }, - undefined, - ); - emitSessionsChanged(context, { - sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), - reason: "new", - }); - return; - } - - const runStarted = - runPayload !== undefined && - shouldAttachPendingMessageSeq({ - payload: runPayload, - cached: runMeta?.cached === true, - }); - - respond( - true, - { - ok: true, - key: created.key, - sessionId: created.entry.sessionId, - entry: created.entry, - runStarted, - ...(runPayload ? runPayload : {}), - ...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}), - ...(runError ? { runError } : {}), - resolved: created.resolved, - ...(createdWorktree ? { worktree: createdWorktree } : {}), - }, - undefined, - ); - emitSessionsChanged(context, { - sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), - reason: "create", - }); - if (runStarted) { - emitSessionsChanged(context, { - sessionKey: created.key, - ...(created.key === "global" ? { agentId: created.agentId } : {}), - reason: "send", - }); - } - }, - "sessions.compaction.branch": async ({ params, respond, context }) => { - if ( - !assertValidParams( - params, - validateSessionsCompactionBranchParams, - "sessions.compaction.branch", - respond, - ) - ) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - const checkpointId = - typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; - if (!checkpointId) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const { entry, canonicalKey, sessionStoreKey, target, storePath } = - loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - if (!entry?.sessionId) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); - if (!checkpoint) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - const nextKey = buildDashboardSessionKey(target.agentId); - const branchedSession = await compactionCheckpointStore.branchCheckpointSession({ - agentId: target.agentId, - storePath, - sourceKey: canonicalKey, - sourceStoreKey: sessionStoreKey, - nextKey, - checkpointId, - }); - if ( - branchedSession.status === "missing-checkpoint" || - branchedSession.status === "missing-boundary" - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - if (branchedSession.status === "missing-session") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - if (branchedSession.status === "model-selection-locked") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), - ); - return; - } - if (branchedSession.status === "failed") { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "failed to create checkpoint branch transcript"), - ); - return; - } - - respond( - true, - { - ok: true, - sourceKey: canonicalKey, - key: branchedSession.key, - sessionId: branchedSession.entry.sessionId, - checkpoint: branchedSession.checkpoint, - entry: branchedSession.entry, - }, - undefined, - ); - emitSessionsChanged(context, { - sessionKey: canonicalKey, - ...(canonicalKey === "global" && requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), - reason: "checkpoint-branch", - }); - emitSessionsChanged(context, { - sessionKey: branchedSession.key, - reason: "checkpoint-branch", - }); - }, - "sessions.compaction.restore": async ({ - req, - params, - respond, - context, - client, - isWebchatConnect, - }) => { - if ( - !assertValidParams( - params, - validateSessionsCompactionRestoreParams, - "sessions.compaction.restore", - respond, - ) - ) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "restore", client, isWebchatConnect, respond })) { - return; - } - const checkpointId = - typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; - if (!checkpointId) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const { entry, canonicalKey, sessionStoreKey, storePath } = - loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - if (!entry?.sessionId) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); - if (!checkpoint) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - const initialPlacementError = resolveSessionWorkerPlacementMutationError({ - action: "restore", - context, - key, - sessionId: entry.sessionId, - }); - if (initialPlacementError) { - respondSessionWorkerPlacementMutationError(initialPlacementError, respond); - return; - } - const lifecycleIdentities = [ - key, - canonicalKey, - sessionStoreKey, - entry.sessionId, - entry.lifecycleRevision, - ]; - const restoreLockIdentities = [entry.sessionId, entry.lifecycleRevision]; - let admittedWorkReleased = true; - let restoreTargetStillCurrent = true; - let restoreBlockedByModelLock = false; - let restorePlacementError: SessionWorkerPlacementMutationError | undefined; - // Restore replaces the active transcript identity. Hold the same lifecycle fence as - // compaction so neither operation can publish state from the other's obsolete session. - await runExclusiveSessionLifecycleMutation({ - scope: storePath, - identities: restoreLockIdentities, - prepare: async () => { - const current = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - const currentCheckpoint = current.entry - ? getSessionCompactionCheckpoint({ entry: current.entry, checkpointId }) - : undefined; - restoreTargetStillCurrent = - current.entry?.sessionId === entry.sessionId && - current.entry.lifecycleRevision === entry.lifecycleRevision && - currentCheckpoint !== undefined; - if (!restoreTargetStillCurrent) { - return; - } - restoreBlockedByModelLock = current.entry?.modelSelectionLocked === true; - if (restoreBlockedByModelLock) { - return; - } - restorePlacementError = resolveSessionWorkerPlacementMutationError({ - action: "restore", - context, - key, - sessionId: current.entry?.sessionId, - }); - if (restorePlacementError) { - return; - } - clearSessionQueues([ - key, - current.canonicalKey, - current.sessionStoreKey, - current.entry?.sessionId, - ]); - admittedWorkReleased = await interruptSessionWorkAdmissions({ - scope: storePath, - identities: lifecycleIdentities, - timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, - }); - }, - run: async () => { - if (!restoreTargetStillCurrent) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Session ${key} changed before checkpoint restore. Retry.`, - { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, - ), - ); - return; - } - if (restoreBlockedByModelLock) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), - ); - return; - } - if (restorePlacementError) { - respondSessionWorkerPlacementMutationError(restorePlacementError, respond); - return; - } - if (!admittedWorkReleased) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), - ); - return; - } - const current = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - if (!current.entry?.sessionId) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - if (current.entry.modelSelectionLocked === true) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), - ); - return; - } - const currentCheckpoint = getSessionCompactionCheckpoint({ - entry: current.entry, - checkpointId, - }); - if (!currentCheckpoint) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - const interruptResult = await interruptSessionRunIfActive({ - req, - context, - client, - isWebchatConnect, - requestedKey: key, - canonicalKey: current.canonicalKey, - agentId: requestedAgent.agentId, - sessionId: current.entry.sessionId, - }); - if (interruptResult.error) { - respond(false, undefined, interruptResult.error); - return; - } - - const restoredSession = await compactionCheckpointStore.restoreCheckpointSession({ - agentId: requestedAgent.agentId, - storePath, - sessionKey: current.canonicalKey, - sessionStoreKey: current.sessionStoreKey, - checkpointId, - }); - if ( - restoredSession.status === "missing-checkpoint" || - restoredSession.status === "missing-boundary" - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), - ); - return; - } - if (restoredSession.status === "missing-session") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - if (restoredSession.status === "model-selection-locked") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_CHECKPOINT_MESSAGE), - ); - return; - } - if (restoredSession.status === "failed") { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "failed to restore checkpoint transcript"), - ); - return; - } - - respond( - true, - { - ok: true, - key: restoredSession.key, - sessionId: restoredSession.entry.sessionId, - checkpoint: restoredSession.checkpoint, - entry: restoredSession.entry, - }, - undefined, - ); - emitSessionsChanged(context, { - sessionKey: current.canonicalKey, - ...(current.canonicalKey === "global" && requestedAgent.agentId - ? { agentId: requestedAgent.agentId } - : {}), - reason: "checkpoint-restore", - }); - }, - }); - }, - "sessions.dispatch": async ({ params, respond, context, client, isWebchatConnect }) => { - if (!assertValidParams(params, validateSessionsDispatchParams, "sessions.dispatch", respond)) { - return; - } - const key = requireSessionKey(params.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "dispatch", client, isWebchatConnect, respond })) { - return; - } - const dispatchService = context.workerPlacementDispatchService; - const placementReader = context.workerSessionPlacementService; - if (!dispatchService || !placementReader) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker dispatch is not configured"), - ); - return; - } - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - if (!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `cloud worker profile is not configured: ${params.profileId}`, - ), - ); - return; - } - const target = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - const entry = target.entry; - const sessionId = normalizeOptionalString(entry?.sessionId); - if (!entry || !sessionId) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), - ); - return; - } - if (entry.archivedAt !== undefined) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "cannot dispatch an archived session"), - ); - return; - } - const sessionRuntime = resolveWorkerPlacementSessionRuntime({ - cfg, - entry, - agentId: target.target.agentId, - sessionKey: target.canonicalKey, - }); - if (!isWorkerPlacementSessionRuntimeSupported(sessionRuntime)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`, - ), - ); - return; - } - const existingPlacement = placementReader.getMany([sessionId]).get(sessionId); - if ( - existingPlacement && - existingPlacement.state !== "local" && - existingPlacement.state !== "reclaimed" - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `session cannot dispatch from placement ${existingPlacement.state}`, - ), - ); - return; - } - const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey); - if ( - !target.entry?.worktree?.id || - !worktree || - worktree.id !== target.entry.worktree.id || - worktree.ownerId !== target.canonicalKey - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.dispatch requires a session-owned managed worktree", - ), - ); - return; - } - try { - const placement = await dispatchService.dispatch({ - sessionId, - sessionKey: target.canonicalKey, - agentId: target.target.agentId, - profileId: params.profileId, - }); - respond( - true, - { - ok: true, - key: target.canonicalKey, - sessionId, - placement: projectWorkerSessionPlacement(placement), - }, - undefined, - ); - } catch (error) { - respond( - false, - undefined, - errorShape( - isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - formatErrorMessage(error), - ), - ); - } - }, - "sessions.send": async ({ req, params, respond, context, client, isWebchatConnect }) => { - await handleSessionSend({ - method: "sessions.send", - req, - params, - respond, - context, - client, - isWebchatConnect, - interruptIfActive: false, - }); - }, - "sessions.steer": async ({ req, params, respond, context, client, isWebchatConnect }) => { - await handleSessionSend({ - method: "sessions.steer", - req, - params, - respond, - context, - client, - isWebchatConnect, - interruptIfActive: true, - }); - }, - "sessions.abort": async ({ req, params, respond, context, client, isWebchatConnect }) => { - if (!assertValidParams(params, validateSessionsAbortParams, "sessions.abort", respond)) { - return; - } - const p = params; - const cfg = context.getRuntimeConfig(); - const requestedRunId = readStringValue(p.runId); - const requestedKey = normalizeOptionalString(p.key); - const requestedParamAgentId = normalizeOptionalString(p.agentId); - const workerRunSessionId = requestedRunId - ? asWorkerInferenceControl(context.workerEnvironmentService)?.resolveInferenceSessionForRunId( - requestedRunId, - ) - : undefined; - const workerRunTarget = workerRunSessionId - ? resolveWorkerSessionTarget(cfg, workerRunSessionId) - : undefined; - const scopedRequestedKey = resolveScopedAbortKey({ - cfg, - key: requestedKey, - agentId: requestedParamAgentId, - }); - if (requestedKey && requestedParamAgentId && !scopedRequestedKey) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"), - ); - return; - } - const requestedKeyAgentId = scopedRequestedKey - ? resolveSessionKeyAgentId(scopedRequestedKey, cfg) - : undefined; - const activeRun = requestedRunId ? context.chatAbortControllers.get(requestedRunId) : undefined; - const activeRunSessionKey = activeRun?.sessionKey; - const activeRunAgentId = normalizeOptionalString(activeRun?.agentId); - const inferredRunAgentId = - requestedParamAgentId ?? - (requestedRunId && scopedRequestedKey?.toLowerCase() === "global" - ? activeRunAgentId - : undefined) ?? - requestedKeyAgentId ?? - workerRunTarget?.agentId ?? - (requestedRunId && !activeRunSessionKey ? resolveDefaultAgentId(cfg) : undefined); - const requestedRunAgentId = requestedRunId - ? inferredRunAgentId - ? normalizeAgentId(inferredRunAgentId) - : undefined - : undefined; - const scopedActiveRunSessionKey = activeRunSessionKey - ? requestedRunAgentId - ? sessionKeyBelongsToAgent(activeRunSessionKey, requestedRunAgentId, cfg) - ? activeRunSessionKey - : undefined - : activeRunSessionKey - : undefined; - const keyCandidate = - scopedRequestedKey ?? - scopedActiveRunSessionKey ?? - (requestedRunId - ? resolveSessionKeyForRun(requestedRunId, { - agentId: requestedRunAgentId ?? resolveDefaultAgentId(cfg), - }) - : undefined) ?? - workerRunTarget?.sessionKey; - if (!keyCandidate && requestedRunId) { - respond(true, { ok: true, abortedRunId: null, status: "no-active-run" }); - return; - } - const key = requireSessionKey(keyCandidate, respond); - if (!key) { - return; - } - const requestedGlobalAgent = resolveRequestedGlobalAgentId( - cfg, - key, - requestedParamAgentId ?? requestedRunAgentId, - ); - if (!requestedGlobalAgent.ok) { - respond(false, undefined, requestedGlobalAgent.error); - return; - } - const requestedGlobalAgentId = requestedGlobalAgent.agentId; - const { canonicalKey } = loadSessionEntry(key, { agentId: requestedGlobalAgentId }); - const requestedKeyAliases = - requestedKey && - requestedKey !== key && - (!requestedParamAgentId || sessionKeyBelongsToAgent(requestedKey, requestedParamAgentId, cfg)) - ? [requestedKey] - : undefined; - const resolvedAbortSessionKey = resolveAbortSessionKey({ - context, - requestedKey: key, - canonicalKey, - activeRunSessionKey: scopedActiveRunSessionKey, - aliasKeys: requestedKeyAliases, - }); - const abortSessionKey = - canonicalKey === "global" && requestedGlobalAgentId ? "global" : resolvedAbortSessionKey; - const abortAgentId = - abortSessionKey === "global" ? (requestedGlobalAgentId ?? activeRunAgentId) : undefined; - // Capture run kinds before the abort because abortChatRunById deletes entries - // from chatAbortControllers synchronously. We use this snapshot to choose the - // correct dedupe namespace: agent-kind runs use "agent:" (their runId equals - // their idempotency key), while chat-send runs use "chat:" so the abort - // snapshot does not collide with the agent RPC dedupe cache. - const preAbortRunKinds = new Map(); - if (requestedRunId) { - preAbortRunKinds.set(requestedRunId, activeRun?.kind); - } else { - for (const [rid, entry] of context.chatAbortControllers) { - preAbortRunKinds.set(rid, entry.kind); - } - } - let abortedRunId: string | null = null; - await expectDefined( - chatHandlers["chat.abort"], - "chat.abort handler", - )({ - req, - params: { - sessionKey: abortSessionKey, - runId: requestedRunId, - ...(abortAgentId ? { agentId: abortAgentId } : {}), - }, - respond: (ok, payload, error, meta) => { - if (!ok) { - respond(ok, payload, error, meta); - return; - } - const runIds = - payload && - typeof payload === "object" && - Array.isArray((payload as { runIds?: unknown[] }).runIds) - ? (payload as { runIds: unknown[] }).runIds.filter((value): value is string => - Boolean(normalizeOptionalString(value)), - ) - : []; - const firstAbortedRunId = runIds[0] ?? null; - abortedRunId = firstAbortedRunId; - const workerOnly = Boolean(workerRunSessionId && !activeRun); - if (firstAbortedRunId && !workerOnly) { - const endedAt = Date.now(); - const runKind = preAbortRunKinds.get(firstAbortedRunId); - const dedupePrefix = runKind === "agent" ? "agent" : "chat"; - setGatewayDedupeEntry({ - dedupe: context.dedupe, - key: `${dedupePrefix}:${firstAbortedRunId}`, - entry: { - ts: endedAt, - ok: true, - payload: { - status: "timeout", - runId: firstAbortedRunId, - ...(abortAgentId ? { agentId: abortAgentId } : {}), - stopReason: "rpc", - endedAt, - }, - }, - }); - } - respond( - true, - { - ok: true, - abortedRunId, - status: abortedRunId ? "aborted" : "no-active-run", - }, - undefined, - meta, - ); - }, - context, - client, - isWebchatConnect, - }); - if (abortedRunId) { - emitSessionsChanged(context, { - sessionKey: canonicalKey, - ...(canonicalKey === "global" && abortAgentId ? { agentId: abortAgentId } : {}), - reason: "abort", - }); - } - }, - "sessions.patch": async ({ params, respond, context, client, isWebchatConnect }) => { - if (!assertValidParams(params, validateSessionsPatchParams, "sessions.patch", respond)) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { - return; - } - - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { - agentId: requestedAgentId, - }); - const canonicalKey = target.canonicalKey ?? key; - const lifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; - const missingHarnessSessionError = resolveMissingAgentHarnessSessionError( - canonicalKey, - lifecycleEntry, - ); - if (missingHarnessSessionError) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, missingHarnessSessionError)); - return; - } - const initialPlacementPatchError = resolveSessionWorkerPlacementPatchError({ - agentId: target.agentId, - cfg, - context, - entry: lifecycleEntry, - key, - patch: p, - sessionKey: canonicalKey, - validateModelRuntime: false, - }); - if (initialPlacementPatchError) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, initialPlacementPatchError)); - return; - } - const lifecycleIdentities = [canonicalKey, key, lifecycleEntry?.sessionId]; - if (p.archived === true && isSessionLifecycleMutationActive(storePath, lifecycleIdentities)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive a session with an active run."), - ); - return; - } - let patchModelCatalog: Awaited> | undefined; - const loadPatchModelCatalog = async () => { - const catalog = await context.loadGatewayModelCatalog(); - patchModelCatalog = catalog; - return catalog; - }; - const applyPatch = async () => { - const currentLifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; - // A reset queued ahead of archive can rotate the row before this mutation starts. - // Never apply stale destructive intent to the replacement session identity. - const lifecycleEntryRemoved = - lifecycleEntry !== undefined && currentLifecycleEntry === undefined; - const archiveTargetChanged = - p.archived === true && - (lifecycleEntry === undefined - ? currentLifecycleEntry !== undefined - : currentLifecycleEntry !== undefined && - (currentLifecycleEntry.sessionId !== lifecycleEntry.sessionId || - currentLifecycleEntry.lifecycleRevision !== lifecycleEntry.lifecycleRevision)); - if (lifecycleEntryRemoved || archiveTargetChanged) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Session ${key} changed before patch. Retry.`), - ); - return null; - } - if (p.archived === true) { - if (canonicalKey === "global" || isAgentMainSessionKey(cfg, canonicalKey)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive an agent's main session."), - ); - return null; - } - const { entry } = loadSessionEntry(key, { agentId: requestedAgentId }); - const activeIdentities = [canonicalKey, key, entry?.sessionId]; - if ( - isSessionWorkAdmissionActive(storePath, activeIdentities) || - replyRunRegistry.isActive(canonicalKey) || - replyRunRegistry.isActive(key) || - hasVisibleActiveSessionRun({ - context, - requestedKey: key, - canonicalKey, - sessionId: entry?.sessionId, - defaultAgentId: resolveDefaultAgentId(cfg), - }) - ) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "Cannot archive a session with an active run."), - ); - return null; - } - } - return await applySessionPatchProjection({ - agentId: target.agentId, - storePath, - resolveTarget: ({ entries }) => { - const store = Object.fromEntries( - entries.map(({ sessionKey, entry }) => [sessionKey, entry]), - ); - const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ - cfg, - key, - store, - agentId: requestedAgentId, - }); - return { primaryKey, candidateKeys: migratedTarget.storeKeys }; - }, - project: async ({ primaryKey, existingEntry, entries }) => { - const projected = await projectSessionsPatchEntry({ - cfg, - entries, - existingEntry, - storeKey: primaryKey, - agentId: requestedAgentId, - patch: p, - loadGatewayModelCatalog: loadPatchModelCatalog, - }); - if (!projected.ok) { - return projected; - } - const placementPatchError = resolveSessionWorkerPlacementPatchError({ - agentId: target.agentId, - cfg, - context, - entry: projected.entry, - key, - patch: p, - sessionKey: canonicalKey, - validateModelRuntime: true, - }); - return placementPatchError - ? { - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, placementPatchError), - } - : projected; - }, - }); - }; - const applied = await runExclusiveSessionLifecycleMutation({ - scope: storePath, - identities: lifecycleIdentities, - run: applyPatch, - }); - if (!applied) { - return; - } - if (!applied.ok) { - respond(false, undefined, applied.error); - return; - } - - triggerSessionPatchHook({ - cfg, - sessionEntry: applied.entry, - sessionKey: target.canonicalKey ?? key, - patch: p, - }); - - // Cron mutations are operator.admin surface while archive is write-scoped; - // only cascade for internal callers (client == null) or admin operators so - // write-scoped archiving cannot flip admin-managed schedules. - const callerScopes = client?.connect ? (client.connect.scopes ?? []) : null; - const callerCanManageCron = callerScopes === null || callerScopes.includes(ADMIN_SCOPE); - if (p.archived === true && callerCanManageCron) { - // Archived sessions reject new work, so schedules bound to them would - // only accumulate failing runs; disable them with the archive. - try { - const disabledJobIds = await disableCronJobsBoundToSession({ - cron: context.cron, - cfg, - sessionKey: target.canonicalKey ?? key, - }); - if (disabledJobIds.length > 0) { - log.info( - `sessions.patch: disabled cron jobs bound to archived session ${target.canonicalKey ?? key}: ${disabledJobIds.join(", ")}`, - ); - } - } catch (error) { - // Best-effort by design: archive is the primary action and must not - // fail or roll back on cron-store errors. Any job left enabled fails - // closed at run start because archived sessions reject new work. - log.warn( - `sessions.patch: failed to disable cron jobs for archived session ${target.canonicalKey ?? key}: ${formatErrorMessage(error)}`, - ); - } - } - - // Absorb ad-hoc categories into the gateway group catalog so ordering - // covers every group an operator UI can observe. - if (typeof p.category === "string" && p.category.trim()) { - ensureSessionGroupRegistered(p.category); - } - - const parsed = parseAgentSessionKey(target.canonicalKey ?? key); - const agentId = normalizeAgentId( - target.canonicalKey === "global" - ? target.agentId - : (parsed?.agentId ?? resolveDefaultAgentId(cfg)), - ); - const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); - const resolvedDisplayModel = resolveSessionDisplayModelIdentityRef({ - cfg, - agentId, - provider: resolved.provider, - model: resolved.model, - }); - const thinkingProjection = resolveGatewaySessionThinkingProjection({ - cfg, - agentId, - provider: resolvedDisplayModel.provider ?? resolved.provider, - model: resolvedDisplayModel.model ?? resolved.model, - sessionKey: target.canonicalKey ?? key, - entry: applied.entry, - modelCatalog: patchModelCatalog, - }); - const resolvedThinkingMetadata = - patchModelCatalog === undefined - ? {} - : { - thinkingLevel: thinkingProjection.effectiveThinkingLevel, - thinkingLevels: thinkingProjection.thinkingLevels, - }; - const result: SessionsPatchResult = { - ok: true, - path: storePath, - key: target.canonicalKey, - entry: applied.entry, - resolved: { - modelProvider: resolvedDisplayModel.provider, - model: resolvedDisplayModel.model, - agentRuntime: thinkingProjection.agentRuntime, - ...resolvedThinkingMetadata, - }, - }; - respond(true, result, undefined); - emitSessionsChanged(context, { - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && requestedAgentId - ? { agentId: requestedAgentId } - : {}), - reason: "patch", - }); - }, - "sessions.pluginPatch": async ({ params, respond, context, client, isWebchatConnect }) => { - if ( - !assertValidParams(params, validateSessionsPluginPatchParams, "sessions.pluginPatch", respond) - ) { - return; - } - const key = requireSessionKey(params.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { - return; - } - const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; - if (!scopes.includes(ADMIN_SCOPE)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `sessions.pluginPatch requires gateway scope: ${ADMIN_SCOPE}`, - ), - ); - return; - } - const pluginId = normalizeOptionalString(params.pluginId); - const namespace = normalizeOptionalString(params.namespace); - if (!pluginId || !namespace) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "pluginId and namespace are required"), - ); - return; - } - if (params.unset === true && params.value !== undefined) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.pluginPatch cannot specify both unset and value", - ), - ); - return; - } - if (params.value !== undefined && !isPluginJsonValue(params.value)) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "sessions.pluginPatch value must be JSON-compatible", - ), - ); - return; - } - const patched = await patchPluginSessionExtension({ - cfg: context.getRuntimeConfig(), - sessionKey: key, - pluginId, - namespace, - value: params.value, - unset: params.unset === true, - }); - if (!patched.ok) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, patched.error)); - return; - } - respond(true, { ok: true, key: patched.key, value: patched.value }, undefined); - emitSessionsChanged(context, { - sessionKey: patched.key, - reason: "plugin-patch", - }); - }, - "sessions.reset": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateSessionsResetParams, "sessions.reset", respond)) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - - const reason = p.reason === "new" ? "new" : "reset"; - const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); - const result = await performGatewaySessionReset({ - key, - ...(p.agentId ? { agentId: p.agentId } : {}), - reason, - commandSource: "gateway:sessions.reset", - }); - if (!result.ok) { - respond(false, undefined, result.error); - return; - } - respond( - true, - { ok: true, key: result.key, entry: result.entry, resolved: result.resolved }, - undefined, - ); - emitSessionsChanged(context, { - sessionKey: result.key, - ...(result.key === "global" ? { agentId: result.agentId } : {}), - reason, - }); - }, - "sessions.delete": async ({ req, params, respond, client, isWebchatConnect, context }) => { - if (!assertValidParams(params, validateSessionsDeleteParams, "sessions.delete", respond)) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "delete", client, isWebchatConnect, respond })) { - return; - } - - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { - agentId: requestedAgentId, - }); - const mainKey = resolveMainSessionKey(cfg); - const isSelectedNonDefaultGlobal = - target.canonicalKey === "global" && - requestedAgentId !== undefined && - requestedAgentId !== resolveDefaultAgentId(cfg); - if (target.canonicalKey === mainKey && !isSelectedNonDefaultGlobal) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`), - ); - return; - } - - const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; - const { - cleanupSessionBeforeMutation, - emitGatewaySessionEndPluginHook, - emitSessionUnboundLifecycleEvent, - } = await loadSessionsRuntimeModule(); - - const initialDeleteEntry = loadSessionEntry(key, { - agentId: requestedAgentId, - }).entry; - const rejectModelSelectionLockedDelete = ( - entry: SessionEntry | undefined, - sessionKey: string, - ): boolean => { - if (!isModelSelectionLocked(entry)) { - return false; - } - const deletablePluginOwnedSession = - normalizeOptionalString(entry?.pluginOwnerId) !== undefined && - entry?.agentHarnessId === undefined && - !isAgentHarnessSessionKey(sessionKey); - if (deletablePluginOwnedSession) { - return false; - } - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "This session cannot be deleted while model selection is locked.", - ), - ); - return true; - }; - if (rejectModelSelectionLockedDelete(initialDeleteEntry, target.canonicalKey)) { - return; - } - // archivedOnly is the archive-then-delete contract: the dispatcher grants - // it to write-scope operators, so the target must actually be archived. - if (p.archivedOnly === true && initialDeleteEntry?.archivedAt === undefined) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Session ${key} is not archived. Archive it first, then delete it.`, - ), - ); - return; - } - const expectedSessionId = p.expectedSessionId?.trim(); - const expectedLifecycleRevision = p.expectedLifecycleRevision?.trim(); - const expectedSessionUpdatedAt = p.expectedSessionUpdatedAt; - const expectedLifecycleRevisionMatches = (entry: SessionEntry | undefined): boolean => - !expectedLifecycleRevision || entry?.lifecycleRevision === expectedLifecycleRevision; - const expectedSessionIdMatches = (entry: SessionEntry | undefined): boolean => { - if (!expectedSessionId || entry?.sessionId === expectedSessionId) { - return true; - } - return false; - }; - const respondSessionChanged = () => { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Session ${key} changed before deletion. Retry.`, { - details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON }, - }), - ); - }; - const rejectExpectedSessionMismatch = (entry: SessionEntry | undefined): boolean => { - const updatedAtMatches = - expectedSessionUpdatedAt === undefined || entry?.updatedAt === expectedSessionUpdatedAt; - if ( - expectedLifecycleRevisionMatches(entry) && - expectedSessionIdMatches(entry) && - updatedAtMatches - ) { - return false; - } - respondSessionChanged(); - return true; - }; - if (rejectExpectedSessionMismatch(initialDeleteEntry)) { - return; - } - const initialPlacementError = resolveSessionWorkerPlacementMutationError({ - action: "delete", - context, - key, - sessionId: normalizeOptionalString(initialDeleteEntry?.sessionId), - }); - if (initialPlacementError) { - respondSessionWorkerPlacementMutationError(initialPlacementError, respond); - return; - } - if ( - rejectPluginRuntimeDeleteMismatch({ - client, - key: target.canonicalKey ?? key, - entry: initialDeleteEntry, - respond, - }) - ) { - return; - } - let abortResult: - | { - ok: boolean; - error?: ReturnType; - } - | undefined; - const abortSessionKey = target.canonicalKey ?? key; - const chatAbort = chatHandlers["chat.abort"]; - if (!chatAbort) { - throw new Error("chat.abort handler is not registered"); - } - await chatAbort({ - req, - params: { - sessionKey: abortSessionKey, - ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - }, - respond: (ok, _payload, error) => { - abortResult = { ok, ...(error ? { error } : {}) }; - }, - context, - client, - isWebchatConnect, - }); - if (abortResult?.ok === false) { - respond(false, undefined, abortResult.error); - return; - } - const deleteLifecycleIdentities = [ - target.canonicalKey, - key, - initialDeleteEntry?.sessionId, - expectedSessionId, - ]; - let admittedWorkReleased = true; - let expectedSessionStillCurrent = true; - let deleteBlockedByModelLock = false; - let deleteBlockedByWorkerPlacement = false; - const deletion = await runExclusiveSessionLifecycleMutation({ - scope: storePath, - identities: deleteLifecycleIdentities, - prepare: async () => { - const preparedEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry; - deleteBlockedByModelLock = rejectModelSelectionLockedDelete( - preparedEntry, - target.canonicalKey, - ); - if (deleteBlockedByModelLock) { - return; - } - expectedSessionStillCurrent = !rejectExpectedSessionMismatch(preparedEntry); - if (!expectedSessionStillCurrent) { - return; - } - const placementError = resolveSessionWorkerPlacementMutationError({ - action: "delete", - context, - key, - sessionId: normalizeOptionalString(preparedEntry?.sessionId), - }); - if (placementError) { - deleteBlockedByWorkerPlacement = true; - respondSessionWorkerPlacementMutationError(placementError, respond); - return; - } - admittedWorkReleased = await interruptSessionWorkAdmissions({ - scope: storePath, - identities: deleteLifecycleIdentities, - timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, - }); - }, - run: async () => { - if ( - deleteBlockedByModelLock || - deleteBlockedByWorkerPlacement || - !expectedSessionStillCurrent - ) { - return undefined; - } - if (!admittedWorkReleased) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), - ); - return undefined; - } - const { entry, legacyKey, canonicalKey } = loadSessionEntry(key, { - agentId: requestedAgentId, - }); - if (rejectModelSelectionLockedDelete(entry, canonicalKey ?? target.canonicalKey)) { - return undefined; - } - if (rejectExpectedSessionMismatch(entry)) { - return undefined; - } - // Recheck under the lifecycle lock: an unarchive racing the pre-lock - // check must not let an archive-gated delete remove an active session. - if (p.archivedOnly === true && entry?.archivedAt === undefined) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Session ${key} is not archived. Archive it first, then delete it.`, - ), - ); - return undefined; - } - if ( - rejectPluginRuntimeDeleteMismatch({ - client, - key: canonicalKey ?? key, - entry, - respond, - }) - ) { - return undefined; - } - const mutationCleanupError = await cleanupSessionBeforeMutation({ - cfg, - key, - target, - entry, - legacyKey, - canonicalKey, - reason: "session-delete", - }); - if (mutationCleanupError) { - respond(false, undefined, mutationCleanupError); - return undefined; - } - const postCleanupTarget = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - ...(requestedAgentId ? { agentId: requestedAgentId } : {}), - }); - const postCleanupEntry = postCleanupTarget.entry; - if ( - !expectedLifecycleRevisionMatches(postCleanupEntry) || - !expectedSessionIdMatches(postCleanupEntry) - ) { - respondSessionChanged(); - return undefined; - } - const pluginOwnerId = normalizeOptionalString(postCleanupEntry?.pluginOwnerId); - const deletionParams = { - agentId: target.agentId, - archiveTranscript: deleteTranscript, - expectedEntry: postCleanupEntry, - expectedLifecycleRevision, - expectedSessionId, - expectedUpdatedAt: postCleanupEntry?.updatedAt, - storePath, - target: { - canonicalKey: target.canonicalKey, - storeKeys: target.storeKeys, - }, - }; - // Catalog and other plugin-owned sessions keep model selection locked, - // so deletion must use the exact-row owner-validated lifecycle seam. - const result = - postCleanupEntry && pluginOwnerId && isModelSelectionLocked(postCleanupEntry) - ? await rollbackPluginOwnedSessionEntryLifecycle({ - ...deletionParams, - expectedEntry: postCleanupEntry, - expectedPluginOwnerId: pluginOwnerId, - target: { - canonicalKey: postCleanupTarget.target.canonicalKey, - storeKeys: postCleanupTarget.target.storeKeys, - }, - }) - : await deleteSessionEntryLifecycle(deletionParams); - if (result.expectedEntryMismatch) { - respondSessionChanged(); - return undefined; - } - if (result.deleted) { - emitGatewaySessionEndPluginHook({ - cfg, - sessionKey: target.canonicalKey ?? key, - sessionId: result.deletedSessionId, - storePath, - sessionFile: result.deletedSessionFile, - agentId: target.agentId, - reason: "deleted", - archivedTranscripts: result.archivedTranscripts, - }); - await emitSessionUnboundLifecycleEvent({ - targetSessionKey: target.canonicalKey ?? key, - reason: "session-delete", - emitHooks: p.emitLifecycleHooks !== false, - }); - } - return result; - }, - }); - if (!deletion) { - return; - } - const deleted = deletion.deleted; - const archivedTranscripts = deletion.archivedTranscripts; - const archived = archivedTranscripts.map((entryLocal) => entryLocal.archivedPath); - - // Dirty or unpushed worktrees survive session deletion; tell the caller so - // operator UIs can point at the preserved checkout instead of orphaning it. - let worktreePreserved: { id: string; branch: string; path: string } | undefined; - if (deleted) { - // requestedAgentId wins: "global" canonical keys resolve to the default store - // agent, which would purge the wrong agent's rows for explicit-agent deletes. - handleSessionStateSessionDeleted( - target.canonicalKey ?? key, - requestedAgentId ?? resolveSessionStoreAgentId(cfg, target.canonicalKey ?? key), - ); - try { - const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey); - if (worktree && !(await managedWorktrees.removeIfLossless(worktree.id))) { - worktreePreserved = { id: worktree.id, branch: worktree.branch, path: worktree.path }; - } - } catch (error) { - log.warn( - `failed to clean up worktree for deleted session ${target.canonicalKey}: ${formatErrorMessage(error)}`, - ); - } - } - - respond( - true, - { - ok: true, - key: target.canonicalKey, - deleted, - archived, - ...(worktreePreserved ? { worktreePreserved } : {}), - }, - undefined, - ); - if (deleted) { - emitSessionsChanged(context, { - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && requestedAgentId - ? { agentId: requestedAgentId } - : {}), - reason: "delete", - }); - } - }, - "sessions.groups.list": async ({ params, respond }) => { - if ( - !assertValidParams(params, validateSessionsGroupsListParams, "sessions.groups.list", respond) - ) { - return; - } - respond(true, { groups: listSessionGroups() }, undefined); - }, - "sessions.groups.put": async ({ params, respond, context }) => { - if ( - !assertValidParams(params, validateSessionsGroupsPutParams, "sessions.groups.put", respond) - ) { - return; - } - respond(true, { ok: true, groups: putSessionGroups(params.names) }, undefined); - // Catalog-only changes still need to reach other open clients. - emitSessionsChanged(context, { reason: "groups" }); - }, - "sessions.groups.rename": async ({ params, respond, context }) => { - if ( - !assertValidParams( - params, - validateSessionsGroupsRenameParams, - "sessions.groups.rename", - respond, - ) - ) { - return; - } - try { - const result = await renameSessionGroup({ - cfg: context.getRuntimeConfig(), - name: params.name, - to: params.to, - }); - respond(true, { ok: true, ...result }, undefined); - emitSessionsChanged(context, { reason: "groups" }); - } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - } - }, - "sessions.groups.delete": async ({ params, respond, context }) => { - if ( - !assertValidParams( - params, - validateSessionsGroupsDeleteParams, - "sessions.groups.delete", - respond, - ) - ) { - return; - } - try { - const result = await deleteSessionGroup({ - cfg: context.getRuntimeConfig(), - name: params.name, - }); - respond(true, { ok: true, ...result }, undefined); - emitSessionsChanged(context, { reason: "groups" }); - } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - } - }, - "sessions.get": async ({ params, respond, context }) => { - const p = params as { - key?: unknown; - sessionKey?: unknown; - limit?: unknown; - agentId?: unknown; - }; - const key = requireSessionKey(p.key ?? p.sessionKey, respond); - if (!key) { - return; - } - const limit = - typeof p.limit === "number" && Number.isFinite(p.limit) - ? Math.max(1, Math.floor(p.limit)) - : 200; - - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId( - cfg, - key, - normalizeOptionalString(p.agentId), - ); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const { storePath, entry } = loadSessionEntriesForTarget({ - key, - cfg, - agentId: requestedAgent.agentId, - }); - if (!entry?.sessionId) { - respond(true, { messages: [] }, undefined); - return; - } - const { messages } = await readRecentSessionMessagesWithStatsAsync( - { - agentId: requestedAgent.agentId, - sessionEntry: entry, - sessionId: entry.sessionId, - sessionKey: key, - storePath, - }, - { - maxMessages: limit, - maxLines: limit * 20 + 20, - allowResetArchiveFallback: true, - }, - ); - respond(true, { messages }, undefined); - }, - "sessions.compact": async ({ req, params, respond, context, client, isWebchatConnect }) => { - if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) { - return; - } - const p = params; - const key = requireSessionKey(p.key, respond); - if (!key) { - return; - } - if (rejectWebchatSessionMutation({ action: "compact", client, isWebchatConnect, respond })) { - return; - } - - const maxLines = - typeof p.maxLines === "number" && Number.isFinite(p.maxLines) - ? Math.max(1, Math.floor(p.maxLines)) - : undefined; - - const cfg = context.getRuntimeConfig(); - const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, p.agentId); - if (!requestedAgent.ok) { - respond(false, undefined, requestedAgent.error); - return; - } - const requestedAgentId = requestedAgent.agentId; - const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, { - agentId: requestedAgentId, - }); - // Lock + read in a short critical section; transcript work happens outside. - // The projection resolver re-runs gateway key migration on the writer - // snapshot so alias promotion/pruning persists through the accessor. - let compactPrimaryKey = target.canonicalKey; - const compactRead = await applySessionPatchProjection({ - agentId: target.agentId, - storePath, - resolveTarget: ({ entries }) => { - const snapshot = Object.fromEntries( - entries.map(({ sessionKey, entry }) => [sessionKey, entry]), - ); - const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ - cfg, - key, - store: snapshot, - agentId: requestedAgentId, - }); - compactPrimaryKey = primaryKey; - return { primaryKey, candidateKeys: migratedTarget.storeKeys }; - }, - // Read-only projection: persist the resolved row unchanged so the alias - // migration above is saved even when compaction bails out below. - project: ({ existingEntry }) => - existingEntry ? { ok: true, entry: existingEntry } : { ok: false }, - }); - const compactTarget = { - entry: compactRead.ok ? compactRead.entry : undefined, - primaryKey: compactPrimaryKey, - }; - const entry = compactTarget.entry; - const sessionId = entry?.sessionId; - if (!sessionId) { - respond( - true, - { - ok: true, - key: target.canonicalKey, - compacted: false, - reason: "no sessionId", - }, - undefined, - ); - return; - } - - if (maxLines !== undefined) { - const trimPreflight = await preflightSessionTranscriptForManualCompact( - { - sessionId, - storePath, - sessionKey: compactTarget.primaryKey, - agentId: target.agentId, - }, - { maxLines, sessionFile: entry.sessionFile }, - ); - if (!trimPreflight.compacted) { - respond( - true, - { - ok: true, - key: target.canonicalKey, - compacted: false, - ...("kept" in trimPreflight - ? { kept: trimPreflight.kept } - : { reason: "no transcript" }), - }, - undefined, - ); - return; - } - } else { - const transcriptEvents = await loadTranscriptEvents({ - agentId: target.agentId, - sessionId, - sessionKey: compactTarget.primaryKey, - storePath, - }).catch(() => []); - if (transcriptEvents.length === 0) { - respond( - true, - { - ok: true, - key: target.canonicalKey, - compacted: false, - reason: "no transcript", - }, - undefined, - ); - return; - } - } - - const lifecycleRevision = entry.lifecycleRevision; - const lifecycleIdentities = [ - key, - target.canonicalKey, - compactTarget.primaryKey, - sessionId, - lifecycleRevision, - ]; - let sessionStillCurrent = true; - let admittedWorkReleased = true; - try { - await runExclusiveSessionLifecycleMutation({ - scope: storePath, - identities: lifecycleIdentities, - kind: "compaction", - prepare: async () => { - const latestEntry = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgentId, - }).entry; - sessionStillCurrent = Boolean( - latestEntry && - latestEntry.sessionId === sessionId && - latestEntry.lifecycleRevision === lifecycleRevision && - !resolveSessionWorkStartError(target.canonicalKey, latestEntry), - ); - if (!sessionStillCurrent) { - return; - } - // Drop work queued against the pre-compaction transcript before its - // active admission drains and no longer exposes queue cleanup. - clearSessionQueues([key, target.canonicalKey, compactTarget.primaryKey, sessionId]); - admittedWorkReleased = await interruptSessionWorkAdmissions({ - scope: storePath, - identities: lifecycleIdentities, - timeoutMs: SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, - }); - }, - run: async () => { - if (!sessionStillCurrent) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Session ${key} changed before compaction. Retry.`, - { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, - ), - ); - return; - } - if (!admittedWorkReleased) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, `Session ${key} is still active; try again.`), - ); - return; - } - - const latestEntry = loadAccessorSessionEntryForGatewayTarget({ - key, - cfg, - agentId: requestedAgentId, - }).entry; - if ( - !latestEntry || - latestEntry.sessionId !== sessionId || - latestEntry.lifecycleRevision !== lifecycleRevision || - resolveSessionWorkStartError(target.canonicalKey, latestEntry) - ) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Session ${key} changed before compaction. Retry.`, - { details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON } }, - ), - ); - return; - } - - const interruptResult = await interruptSessionRunIfActive({ - req, - context, - client, - isWebchatConnect, - requestedKey: key, - canonicalKey: target.canonicalKey, - agentId: requestedAgentId, - sessionId, - }); - if (interruptResult.error) { - respond(false, undefined, interruptResult.error); - return; - } - - const operationId = randomUUID(); - if (maxLines !== undefined) { - const trimResult = await trimSessionTranscriptForManualCompact( - { - sessionId, - storePath, - sessionKey: compactTarget.primaryKey, - agentId: target.agentId, - }, - { maxLines, sessionFile: latestEntry.sessionFile }, - ); - respond( - true, - { - ok: true, - key: target.canonicalKey, - compacted: trimResult.compacted, - ...(trimResult.compacted - ? { archived: trimResult.archived, kept: trimResult.kept } - : "kept" in trimResult - ? { kept: trimResult.kept } - : { reason: "no transcript" }), - }, - undefined, - ); - if (trimResult.compacted) { - recordSessionCompacted({ - sessionKey: target.canonicalKey, - operationId, - sessionId, - agentId: target.agentId ?? requestedAgentId, - }); - emitSessionsChanged(context, { - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), - reason: "compact", - compacted: true, - }); - } - return; - } - - const transcriptEvents = await loadTranscriptEvents({ - agentId: target.agentId, - sessionId, - sessionKey: compactTarget.primaryKey, - storePath, - }).catch(() => []); - if (transcriptEvents.length === 0) { - respond( - true, - { - ok: true, - key: target.canonicalKey, - compacted: false, - reason: "no transcript", - }, - undefined, - ); - return; - } - const compactionTranscriptTarget = await resolveSessionTranscriptRuntimeTarget({ - agentId: target.agentId, - sessionId, - sessionKey: compactTarget.primaryKey, - storePath, - }); - - const resolvedModel = resolveSessionModelRef(cfg, latestEntry, target.agentId); - const workspaceDir = - resolveIngressWorkspaceOverrideForSessionRun({ - spawnedBy: latestEntry.spawnedBy, - workspaceDir: latestEntry.spawnedWorkspaceDir, - cwd: latestEntry.spawnedCwd, - }) ?? resolveAgentWorkspaceDir(cfg, target.agentId); - emitSessionOperation(context, { - operationId, - operation: "compact", - phase: "start", - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), - }); - const emitCompactionEnd = (completed: boolean, reason?: string) => - emitSessionOperation(context, { - operationId, - operation: "compact", - phase: "end", - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), - completed, - reason, - }); - let result: Awaited>; - try { - result = await compactEmbeddedAgentSession({ - sessionId, - sessionKey: target.canonicalKey, - agentId: target.agentId, - sessionTarget: { - agentId: target.agentId, - sessionId, - sessionKey: target.canonicalKey, - storePath, - }, - allowGatewaySubagentBinding: true, - sessionFile: compactionTranscriptTarget.sessionFile, - workspaceDir, - cwd: normalizeOptionalString(latestEntry.spawnedCwd), - config: cfg, - provider: resolvedModel.provider, - model: resolvedModel.model, - authProfileId: latestEntry.authProfileOverride, - authProfileIdSource: - latestEntry.authProfileOverrideSource ?? - (latestEntry.authProfileOverride - ? typeof latestEntry.authProfileOverrideCompactionCount === "number" - ? "auto" - : "user" - : undefined), - agentHarnessId: - latestEntry.modelSelectionLocked === true - ? resolvePersistedSessionRuntimeId(latestEntry) - : latestEntry.agentHarnessId, - modelSelectionLocked: latestEntry.modelSelectionLocked === true, - thinkLevel: normalizeThinkLevel(latestEntry.thinkingLevel), - reasoningLevel: normalizeReasoningLevel(latestEntry.reasoningLevel), - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - trigger: "manual", - }); - } catch (err) { - emitCompactionEnd(false, formatErrorMessage(err)); - throw err; - } - if (result.ok && result.compacted) { - let persisted: boolean; - try { - // Guarded terminal persist: skip when session ownership rotated - // while compaction ran (sessionId/lifecycleRevision/work-start). - const persistProjection = await applySessionPatchProjection({ - agentId: target.agentId, - storePath, - resolveTarget: () => ({ primaryKey: compactTarget.primaryKey }), - project: ({ existingEntry }) => { - if ( - !existingEntry || - existingEntry.sessionId !== sessionId || - existingEntry.lifecycleRevision !== lifecycleRevision || - resolveSessionWorkStartError(target.canonicalKey, existingEntry) - ) { - return { ok: false }; - } - const entryToUpdate = existingEntry; - entryToUpdate.updatedAt = Date.now(); - entryToUpdate.compactionCount = - Math.max(0, entryToUpdate.compactionCount ?? 0) + 1; - if ( - result.result?.sessionId && - result.result.sessionId !== entryToUpdate.sessionId - ) { - entryToUpdate.sessionId = result.result.sessionId; - } - delete entryToUpdate.inputTokens; - delete entryToUpdate.outputTokens; - delete entryToUpdate.contextBudgetStatus; - if ( - typeof result.result?.tokensAfter === "number" && - Number.isFinite(result.result.tokensAfter) - ) { - entryToUpdate.totalTokens = result.result.tokensAfter; - entryToUpdate.totalTokensFresh = true; - } else { - delete entryToUpdate.totalTokens; - delete entryToUpdate.totalTokensFresh; - } - return { ok: true, entry: entryToUpdate }; - }, - }); - persisted = persistProjection.ok; - } catch (err) { - emitCompactionEnd(false, formatErrorMessage(err)); - throw err; - } - if (!persisted) { - const reason = `Session ${key} changed before compaction completed. Retry.`; - emitCompactionEnd(false, reason); - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, reason, { - details: { reason: SESSION_LIFECYCLE_CHANGED_ERROR_REASON }, - }), - ); - return; - } - recordSessionCompacted({ - sessionKey: target.canonicalKey, - operationId, - sessionId: result.result?.sessionId ?? sessionId, - agentId: target.agentId ?? requestedAgentId, - }); - } - - emitCompactionEnd(result.ok && result.compacted, result.reason); - respond( - true, - { - ok: result.ok, - key: target.canonicalKey, - compacted: result.compacted, - reason: result.reason, - result: result.result, - }, - undefined, - ); - if (result.ok) { - emitSessionsChanged(context, { - sessionKey: target.canonicalKey, - ...(target.canonicalKey === "global" && target.agentId - ? { agentId: target.agentId } - : {}), - reason: "compact", - compacted: result.compacted, - }); - } - }, - }); - } catch (err) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(err))); - } - }, + ...sessionReadHandlers, + ...sessionSubscriptionHandlers, + ...sessionCreateHandlers, + ...sessionCheckpointQueryHandlers, + ...sessionCheckpointHandlers, + ...sessionDispatchHandlers, + ...sessionMessagingHandlers, + ...sessionAbortHandlers, + ...sessionMutationHandlers, + ...sessionDeleteHandlers, + ...sessionGroupHandlers, + ...sessionCompactHandlers, }; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index ca32debe864b..fb8901178d0a 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -70,6 +70,20 @@ describe("session accessor boundary guard", () => { "src/gateway/server-methods/chat.ts", "src/gateway/sessions-resolve.ts", "src/gateway/server-methods/sessions-files.ts", + "src/gateway/server-methods/sessions-abort.ts", + "src/gateway/server-methods/sessions-compact.ts", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + "src/gateway/server-methods/sessions-compaction-queries.ts", + "src/gateway/server-methods/sessions-compaction-runner.ts", + "src/gateway/server-methods/sessions-create.ts", + "src/gateway/server-methods/sessions-delete.ts", + "src/gateway/server-methods/sessions-dispatch.ts", + "src/gateway/server-methods/sessions-groups.ts", + "src/gateway/server-methods/sessions-messaging.ts", + "src/gateway/server-methods/sessions-mutations.ts", + "src/gateway/server-methods/sessions-read.ts", + "src/gateway/server-methods/sessions-shared.ts", + "src/gateway/server-methods/sessions-subscriptions.ts", "src/gateway/server-methods/sessions.ts", "src/gateway/server-session-events.ts", "src/gateway/session-reset-service.ts", @@ -157,6 +171,20 @@ describe("session accessor boundary guard", () => { "src/config/sessions/goals.ts", "src/gateway/boot.ts", "src/gateway/server-methods/chat.ts", + "src/gateway/server-methods/sessions-abort.ts", + "src/gateway/server-methods/sessions-compact.ts", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + "src/gateway/server-methods/sessions-compaction-queries.ts", + "src/gateway/server-methods/sessions-compaction-runner.ts", + "src/gateway/server-methods/sessions-create.ts", + "src/gateway/server-methods/sessions-delete.ts", + "src/gateway/server-methods/sessions-dispatch.ts", + "src/gateway/server-methods/sessions-groups.ts", + "src/gateway/server-methods/sessions-messaging.ts", + "src/gateway/server-methods/sessions-mutations.ts", + "src/gateway/server-methods/sessions-read.ts", + "src/gateway/server-methods/sessions-shared.ts", + "src/gateway/server-methods/sessions-subscriptions.ts", "src/gateway/server-methods/sessions.ts", "src/gateway/server-node-events.ts", "src/gateway/session-compaction-checkpoints.ts", @@ -185,7 +213,7 @@ describe("session accessor boundary guard", () => { it("ratchets only compact manual trim gateway files", () => { expect(migratedSessionCompactManualTrimFiles).toEqual( - new Set(["src/gateway/server-methods/sessions.ts"]), + new Set(["src/gateway/server-methods/sessions-compact.ts"]), ); }); diff --git a/test/scripts/check-session-transcript-reader-boundary.test.ts b/test/scripts/check-session-transcript-reader-boundary.test.ts index ff78d8bc011f..4fcd2489da2c 100644 --- a/test/scripts/check-session-transcript-reader-boundary.test.ts +++ b/test/scripts/check-session-transcript-reader-boundary.test.ts @@ -29,6 +29,20 @@ describe("session transcript reader boundary guard", () => { "src/gateway/server-methods/chat.ts", "src/gateway/server-methods/sessions-files.test.ts", "src/gateway/server-methods/sessions-files.ts", + "src/gateway/server-methods/sessions-abort.ts", + "src/gateway/server-methods/sessions-compact.ts", + "src/gateway/server-methods/sessions-compaction-checkpoints.ts", + "src/gateway/server-methods/sessions-compaction-queries.ts", + "src/gateway/server-methods/sessions-compaction-runner.ts", + "src/gateway/server-methods/sessions-create.ts", + "src/gateway/server-methods/sessions-delete.ts", + "src/gateway/server-methods/sessions-dispatch.ts", + "src/gateway/server-methods/sessions-groups.ts", + "src/gateway/server-methods/sessions-messaging.ts", + "src/gateway/server-methods/sessions-mutations.ts", + "src/gateway/server-methods/sessions-read.ts", + "src/gateway/server-methods/sessions-shared.ts", + "src/gateway/server-methods/sessions-subscriptions.ts", "src/gateway/server-methods/sessions.ts", "src/gateway/server-session-events.ts", "src/gateway/session-history-state.test.ts",