openclaw/src/gateway/server-methods/sessions.ts

1147 lines
34 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded-runner/runs.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
import { loadConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js";
import {
ErrorCodes,
errorShape,
validateSessionsAbortParams,
validateSessionsCompactParams,
validateSessionsCreateParams,
validateSessionsDeleteParams,
validateSessionsListParams,
validateSessionsMessagesSubscribeParams,
validateSessionsMessagesUnsubscribeParams,
validateSessionsPatchParams,
validateSessionsPreviewParams,
validateSessionsResetParams,
validateSessionsResolveParams,
validateSessionsSendParams,
} from "../protocol/index.js";
import {
archiveSessionTranscriptsForSession,
cleanupSessionBeforeMutation,
emitSessionUnboundLifecycleEvent,
performGatewaySessionReset,
} from "../session-reset-service.js";
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
archiveFileOnDisk,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadGatewaySessionRow,
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
readSessionPreviewItemsFromTranscript,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
type SessionsPreviewEntry,
type SessionsPreviewResult,
readSessionMessages,
} from "../session-utils.js";
import { applySessionsPatchToStore } from "../sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
import { chatHandlers } from "./chat.js";
import type {
GatewayClient,
GatewayRequestContext,
GatewayRequestHandlerOptions,
GatewayRequestHandlers,
RespondFn,
} from "./types.js";
import { assertValidParams } from "./validation.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 = raw.trim();
if (!normalized) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required"));
return null;
}
return normalized;
}
function resolveGatewaySessionTargetFromKey(key: string) {
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
return { cfg, target, storePath: target.storePath };
}
function resolveOptionalInitialSessionMessage(params: {
task?: unknown;
message?: unknown;
}): string | undefined {
if (typeof params.task === "string" && params.task.trim()) {
return params.task;
}
if (typeof params.message === "string" && params.message.trim()) {
return params.message;
}
return undefined;
}
function shouldAttachPendingMessageSeq(params: { payload: unknown; cached?: boolean }): boolean {
if (params.cached) {
return false;
}
const status =
params.payload && typeof params.payload === "object"
? (params.payload as { status?: unknown }).status
: undefined;
return status === "started";
}
function emitSessionsChanged(
context: Pick<GatewayRequestContext, "broadcastToConnIds" | "getSessionEventSubscriberConnIds">,
payload: { sessionKey?: string; reason: string; compacted?: boolean },
) {
const connIds = context.getSessionEventSubscriberConnIds();
if (connIds.size === 0) {
return;
}
const sessionRow = payload.sessionKey ? loadGatewaySessionRow(payload.sessionKey) : null;
context.broadcastToConnIds(
"sessions.changed",
{
...payload,
ts: Date.now(),
...(sessionRow
? {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
kind: sessionRow.kind,
channel: sessionRow.channel,
label: sessionRow.label,
displayName: sessionRow.displayName,
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
thinkingLevel: sessionRow.thinkingLevel,
systemSent: sessionRow.systemSent,
abortedLastRun: sessionRow.abortedLastRun,
lastChannel: sessionRow.lastChannel,
lastTo: sessionRow.lastTo,
lastAccountId: sessionRow.lastAccountId,
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
}
: {}),
},
connIds,
{ dropIfSlow: true },
);
}
function rejectWebchatSessionMutation(params: {
action: "patch" | "delete";
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 buildDashboardSessionKey(agentId: string): string {
return `agent:${agentId}:dashboard:${randomUUID()}`;
}
function ensureSessionTranscriptFile(params: {
sessionId: string;
storePath: string;
sessionFile?: string;
agentId: string;
}): { ok: true; transcriptPath: string } | { ok: false; error: string } {
try {
const transcriptPath = resolveSessionFilePath(
params.sessionId,
params.sessionFile ? { sessionFile: params.sessionFile } : undefined,
resolveSessionFilePathOptions({
storePath: params.storePath,
agentId: params.agentId,
}),
);
if (!fs.existsSync(transcriptPath)) {
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
fs.writeFileSync(transcriptPath, `${JSON.stringify(header)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
}
return { ok: true, transcriptPath };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
function resolveAbortSessionKey(params: {
context: Pick<GatewayRequestContext, "chatAbortControllers">;
requestedKey: string;
canonicalKey: string;
runId?: string;
}): string {
const activeRunKey =
typeof params.runId === "string"
? params.context.chatAbortControllers.get(params.runId)?.sessionKey
: undefined;
if (activeRunKey) {
return activeRunKey;
}
for (const active of params.context.chatAbortControllers.values()) {
if (active.sessionKey === params.canonicalKey) {
return params.canonicalKey;
}
if (active.sessionKey === params.requestedKey) {
return params.requestedKey;
}
}
return params.requestedKey;
}
function hasTrackedActiveSessionRun(params: {
context: Pick<GatewayRequestContext, "chatAbortControllers">;
requestedKey: string;
canonicalKey: string;
}): boolean {
for (const active of params.context.chatAbortControllers.values()) {
if (active.sessionKey === params.canonicalKey || active.sessionKey === params.requestedKey) {
return true;
}
}
return false;
}
async function interruptSessionRunIfActive(params: {
req: GatewayRequestHandlerOptions["req"];
context: GatewayRequestContext;
client: GatewayClient | null;
isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"];
requestedKey: string;
canonicalKey: string;
sessionId?: string;
}): Promise<{ interrupted: boolean; error?: ReturnType<typeof errorShape> }> {
const hasTrackedRun = hasTrackedActiveSessionRun({
context: params.context,
requestedKey: params.requestedKey,
canonicalKey: params.canonicalKey,
});
const hasEmbeddedRun =
typeof params.sessionId === "string" && params.sessionId
? isEmbeddedPiRunActive(params.sessionId)
: false;
if (!hasTrackedRun && !hasEmbeddedRun) {
return { interrupted: false };
}
if (hasTrackedRun) {
let abortOk = true;
let abortError: ReturnType<typeof errorShape> | undefined;
const abortSessionKey = resolveAbortSessionKey({
context: params.context,
requestedKey: params.requestedKey,
canonicalKey: params.canonicalKey,
});
await chatHandlers["chat.abort"]({
req: params.req,
params: {
sessionKey: abortSessionKey,
},
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) {
abortEmbeddedPiRun(params.sessionId);
}
clearSessionQueues([params.requestedKey, params.canonicalKey, params.sessionId]);
if (hasEmbeddedRun && params.sessionId) {
const ended = await waitForEmbeddedPiRunEnd(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<string, unknown>;
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 { entry, canonicalKey, storePath } = loadSessionEntry(key);
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,
sessionId: entry.sessionId,
});
if (interruptResult.error) {
params.respond(false, undefined, interruptResult.error);
return;
}
interruptedActiveRun = interruptResult.interrupted;
}
const messageSeq = readSessionMessages(entry.sessionId, storePath, entry.sessionFile).length + 1;
let sendAcked = false;
let sendPayload: unknown;
let sendCached = false;
let startedRunId: string | undefined;
const rawIdempotencyKey = (p as { idempotencyKey?: string }).idempotencyKey;
const idempotencyKey =
typeof rawIdempotencyKey === "string" && rawIdempotencyKey.trim()
? rawIdempotencyKey.trim()
: randomUUID();
await chatHandlers["chat.send"]({
req: params.req,
params: {
sessionKey: canonicalKey,
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: (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,
);
},
context: params.context,
client: params.client,
isWebchatConnect: params.isWebchatConnect,
});
if (sendAcked) {
if (shouldAttachPendingMessageSeq({ payload: sendPayload, cached: sendCached })) {
reactivateCompletedSubagentSession({
sessionKey: canonicalKey,
runId: startedRunId,
});
}
emitSessionsChanged(params.context, {
sessionKey: canonicalKey,
reason: interruptedActiveRun ? "steer" : "send",
});
}
}
export const sessionsHandlers: GatewayRequestHandlers = {
"sessions.list": ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) {
return;
}
const p = params;
const cfg = loadConfig();
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const result = listSessionsFromStore({
cfg,
storePath,
store,
opts: p,
});
respond(true, result, undefined);
},
"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 key = requireSessionKey((params as { key?: unknown }).key, respond);
if (!key) {
return;
}
const { canonicalKey } = loadSessionEntry(key);
if (connId) {
context.subscribeSessionMessageEvents(connId, canonicalKey);
respond(true, { subscribed: true, key: canonicalKey }, 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 key = requireSessionKey((params as { key?: unknown }).key, respond);
if (!key) {
return;
}
const { canonicalKey } = loadSessionEntry(key);
if (connId) {
context.unsubscribeSessionMessageEvents(connId, canonicalKey);
}
respond(true, { subscribed: false, key: canonicalKey }, undefined);
},
"sessions.preview": ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsPreviewParams, "sessions.preview", respond)) {
return;
}
const p = params;
const keysRaw = Array.isArray(p.keys) ? p.keys : [];
const keys = keysRaw
.map((key) => String(key ?? "").trim())
.filter(Boolean)
.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 = loadConfig();
const storeCache = new Map<string, Record<string, SessionEntry>>();
const previews: SessionsPreviewEntry[] = [];
for (const key of keys) {
try {
const storeTarget = resolveGatewaySessionStoreTarget({ cfg, key, scanLegacyKeys: false });
const store =
storeCache.get(storeTarget.storePath) ?? loadSessionStore(storeTarget.storePath);
storeCache.set(storeTarget.storePath, store);
const target = resolveGatewaySessionStoreTarget({
cfg,
key,
store,
});
const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean);
if (!entry?.sessionId) {
previews.push({ key, status: "missing", items: [] });
continue;
}
const items = readSessionPreviewItemsFromTranscript(
entry.sessionId,
target.storePath,
entry.sessionFile,
target.agentId,
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.resolve": async ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) {
return;
}
const p = params;
const cfg = loadConfig();
const resolved = await resolveSessionKeyFromResolveParams({ cfg, p });
if (!resolved.ok) {
respond(false, undefined, resolved.error);
return;
}
respond(true, { ok: true, key: resolved.key }, undefined);
},
"sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => {
if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) {
return;
}
const p = params;
const cfg = loadConfig();
const requestedKey = typeof p.key === "string" && p.key.trim() ? p.key.trim() : undefined;
const agentId = normalizeAgentId(
typeof p.agentId === "string" && p.agentId.trim() ? p.agentId : resolveDefaultAgentId(cfg),
);
if (requestedKey) {
const requestedAgentId = parseAgentSessionKey(requestedKey)?.agentId;
if (
requestedAgentId &&
requestedAgentId !== agentId &&
typeof p.agentId === "string" &&
p.agentId.trim()
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`sessions.create key agent (${requestedAgentId}) does not match agentId (${agentId})`,
),
);
return;
}
}
const parentSessionKey =
typeof p.parentSessionKey === "string" && p.parentSessionKey.trim()
? p.parentSessionKey.trim()
: undefined;
let canonicalParentSessionKey: string | undefined;
if (parentSessionKey) {
const parent = loadSessionEntry(parentSessionKey);
if (!parent.entry?.sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown parent session: ${parentSessionKey}`),
);
return;
}
canonicalParentSessionKey = parent.canonicalKey;
}
const key = requestedKey ?? buildDashboardSessionKey(agentId);
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const targetAgentId = resolveAgentIdFromSessionKey(target.canonicalKey);
const created = await updateSessionStore(target.storePath, async (store) => {
const patched = await applySessionsPatchToStore({
cfg,
store,
storeKey: target.canonicalKey,
patch: {
key: target.canonicalKey,
label: typeof p.label === "string" ? p.label.trim() : undefined,
model: typeof p.model === "string" ? p.model.trim() : undefined,
},
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
if (!patched.ok || !canonicalParentSessionKey) {
return patched;
}
const nextEntry: SessionEntry = {
...patched.entry,
parentSessionKey: canonicalParentSessionKey,
};
store[target.canonicalKey] = nextEntry;
return {
...patched,
entry: nextEntry,
};
});
if (!created.ok) {
respond(false, undefined, created.error);
return;
}
const ensured = ensureSessionTranscriptFile({
sessionId: created.entry.sessionId,
storePath: target.storePath,
sessionFile: created.entry.sessionFile,
agentId: targetAgentId,
});
if (!ensured.ok) {
await updateSessionStore(target.storePath, (store) => {
delete store[target.canonicalKey];
});
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `failed to create session transcript: ${ensured.error}`),
);
return;
}
const initialMessage = resolveOptionalInitialSessionMessage(p);
let runPayload: Record<string, unknown> | undefined;
let runError: unknown;
let runMeta: Record<string, unknown> | undefined;
const messageSeq = initialMessage
? readSessionMessages(created.entry.sessionId, target.storePath, created.entry.sessionFile)
.length + 1
: undefined;
if (initialMessage) {
await chatHandlers["chat.send"]({
req,
params: {
sessionKey: target.canonicalKey,
message: initialMessage,
idempotencyKey: randomUUID(),
},
respond: (ok, payload, error, meta) => {
if (ok && payload && typeof payload === "object") {
runPayload = payload as Record<string, unknown>;
} else {
runError = error;
}
runMeta = meta;
},
context,
client,
isWebchatConnect,
});
}
const runStarted =
runPayload !== undefined &&
shouldAttachPendingMessageSeq({
payload: runPayload,
cached: runMeta?.cached === true,
});
respond(
true,
{
ok: true,
key: target.canonicalKey,
sessionId: created.entry.sessionId,
entry: created.entry,
runStarted,
...(runPayload ? runPayload : {}),
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
...(runError ? { runError } : {}),
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
reason: "create",
});
if (runStarted) {
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
reason: "send",
});
}
},
"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 key = requireSessionKey(p.key, respond);
if (!key) {
return;
}
const { canonicalKey } = loadSessionEntry(key);
const abortSessionKey = resolveAbortSessionKey({
context,
requestedKey: key,
canonicalKey,
runId: typeof p.runId === "string" ? p.runId : undefined,
});
let abortedRunId: string | null = null;
await chatHandlers["chat.abort"]({
req,
params: {
sessionKey: abortSessionKey,
runId: typeof p.runId === "string" ? p.runId : undefined,
},
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 => typeof value === "string" && value.trim().length > 0,
)
: [];
abortedRunId = runIds[0] ?? null;
respond(
true,
{
ok: true,
abortedRunId,
status: abortedRunId ? "aborted" : "no-active-run",
},
undefined,
meta,
);
},
context,
client,
isWebchatConnect,
});
if (abortedRunId) {
emitSessionsChanged(context, {
sessionKey: canonicalKey,
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, target, storePath } = resolveGatewaySessionTargetFromKey(key);
const applied = await updateSessionStore(storePath, async (store) => {
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store });
return await applySessionsPatchToStore({
cfg,
store,
storeKey: primaryKey,
patch: p,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
});
if (!applied.ok) {
respond(false, undefined, applied.error);
return;
}
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);
const result: SessionsPatchResult = {
ok: true,
path: storePath,
key: target.canonicalKey,
entry: applied.entry,
resolved: {
modelProvider: resolved.provider,
model: resolved.model,
},
};
respond(true, result, undefined);
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
reason: "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 result = await performGatewaySessionReset({
key,
reason,
commandSource: "gateway:sessions.reset",
});
if (!result.ok) {
respond(false, undefined, result.error);
return;
}
respond(true, { ok: true, key: result.key, entry: result.entry }, undefined);
emitSessionsChanged(context, {
sessionKey: result.key,
reason,
});
},
"sessions.delete": async ({ 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, target, storePath } = resolveGatewaySessionTargetFromKey(key);
const mainKey = resolveMainSessionKey(cfg);
if (target.canonicalKey === mainKey) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`),
);
return;
}
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const { entry, legacyKey, canonicalKey } = loadSessionEntry(key);
const mutationCleanupError = await cleanupSessionBeforeMutation({
cfg,
key,
target,
entry,
legacyKey,
canonicalKey,
reason: "session-delete",
});
if (mutationCleanupError) {
respond(false, undefined, mutationCleanupError);
return;
}
const sessionId = entry?.sessionId;
const deleted = await updateSessionStore(storePath, (store) => {
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store });
const hadEntry = Boolean(store[primaryKey]);
if (hadEntry) {
delete store[primaryKey];
}
return hadEntry;
});
const archived =
deleted && deleteTranscript
? archiveSessionTranscriptsForSession({
sessionId,
storePath,
sessionFile: entry?.sessionFile,
agentId: target.agentId,
reason: "deleted",
})
: [];
if (deleted) {
const emitLifecycleHooks = p.emitLifecycleHooks !== false;
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? key,
reason: "session-delete",
emitHooks: emitLifecycleHooks,
});
}
respond(true, { ok: true, key: target.canonicalKey, deleted, archived }, undefined);
if (deleted) {
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
reason: "delete",
});
}
},
"sessions.get": ({ params, respond }) => {
const p = params;
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 { target, storePath } = resolveGatewaySessionTargetFromKey(key);
const store = loadSessionStore(storePath);
const entry = target.storeKeys.map((k) => store[k]).find(Boolean);
if (!entry?.sessionId) {
respond(true, { messages: [] }, undefined);
return;
}
const allMessages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile);
const messages = limit < allMessages.length ? allMessages.slice(-limit) : allMessages;
respond(true, { messages }, undefined);
},
"sessions.compact": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) {
return;
}
const p = params;
const key = requireSessionKey(p.key, respond);
if (!key) {
return;
}
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
// Lock + read in a short critical section; transcript work happens outside.
const compactTarget = await updateSessionStore(storePath, (store) => {
const { entry, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store });
return { entry, primaryKey };
});
const entry = compactTarget.entry;
const sessionId = entry?.sessionId;
if (!sessionId) {
respond(
true,
{
ok: true,
key: target.canonicalKey,
compacted: false,
reason: "no sessionId",
},
undefined,
);
return;
}
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
entry?.sessionFile,
target.agentId,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
respond(
true,
{
ok: true,
key: target.canonicalKey,
compacted: false,
reason: "no transcript",
},
undefined,
);
return;
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {
respond(
true,
{
ok: true,
key: target.canonicalKey,
compacted: false,
kept: lines.length,
},
undefined,
);
return;
}
const archived = archiveFileOnDisk(filePath, "bak");
const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
await updateSessionStore(storePath, (store) => {
const entryKey = compactTarget.primaryKey;
const entryToUpdate = store[entryKey];
if (!entryToUpdate) {
return;
}
delete entryToUpdate.inputTokens;
delete entryToUpdate.outputTokens;
delete entryToUpdate.totalTokens;
delete entryToUpdate.totalTokensFresh;
entryToUpdate.updatedAt = Date.now();
});
respond(
true,
{
ok: true,
key: target.canonicalKey,
compacted: true,
archived,
kept: keptLines.length,
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: target.canonicalKey,
reason: "compact",
compacted: true,
});
},
};