Gateway: add compaction checkpoints

This commit is contained in:
scoootscooob
2026-04-06 14:47:50 -07:00
parent b44c10e91c
commit 19697bbc03
25 changed files with 1616 additions and 48 deletions

View File

@@ -15,6 +15,13 @@ import {
ensureContextEnginesInitialized,
resolveContextEngine,
} from "../../context-engine/index.js";
import {
captureCompactionCheckpointSnapshot,
cleanupCompactionCheckpointSnapshot,
persistSessionCompactionCheckpoint,
resolveSessionCompactionCheckpointReason,
type CapturedCompactionCheckpointSnapshot,
} from "../../gateway/session-compaction-checkpoints.js";
import { resolveHeartbeatSummaryForAgent } from "../../infra/heartbeat-summary.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
@@ -415,6 +422,8 @@ export async function compactEmbeddedPiSessionDirect(
let restoreSkillEnv: (() => void) | undefined;
let compactionSessionManager: unknown = null;
let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null;
let checkpointSnapshotRetained = false;
try {
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
workspaceDir: effectiveWorkspace,
@@ -730,6 +739,10 @@ export async function compactEmbeddedPiSessionDirect(
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
allowedToolNames,
});
checkpointSnapshot = captureCompactionCheckpointSnapshot({
sessionManager,
sessionFile: params.sessionFile,
});
compactionSessionManager = sessionManager;
trackSessionManagerAccess(params.sessionFile);
const settingsManager = createPreparedEmbeddedPiSettingsManager({
@@ -969,6 +982,33 @@ export async function compactEmbeddedPiSessionDirect(
});
const messageCountAfter = session.messages.length;
const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter);
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const postCompactionLeafId = sessionManager.getLeafId() ?? undefined;
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
reason: resolveSessionCompactionCheckpointReason({
trigger: params.trigger,
}),
snapshot: checkpointSnapshot,
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: observedTokenCount ?? result.tokensBefore,
tokensAfter,
postSessionFile: params.sessionFile,
postLeafId: postCompactionLeafId,
postEntryId: postCompactionLeafId,
createdAt: compactStartedAt,
});
checkpointSnapshotRetained = storedCheckpoint !== null;
} catch (err) {
log.warn("failed to persist compaction checkpoint", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
const postMetrics = diagEnabled
? summarizeCompactionMessages(session.messages)
: undefined;
@@ -1091,6 +1131,9 @@ export async function compactEmbeddedPiSessionDirect(
});
return fail(reason);
} finally {
if (!checkpointSnapshotRetained) {
await cleanupCompactionCheckpointSnapshot(checkpointSnapshot);
}
restoreSkillEnv?.();
}
}
@@ -1116,6 +1159,8 @@ export async function compactEmbeddedPiSession(
});
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null;
let checkpointSnapshotRetained = false;
try {
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
@@ -1150,6 +1195,12 @@ export async function compactEmbeddedPiSession(
// Fire before_compaction / after_compaction hooks here so plugin subscribers
// are notified regardless of which engine is active.
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
checkpointSnapshot = engineOwnsCompaction
? captureCompactionCheckpointSnapshot({
sessionManager: SessionManager.open(params.sessionFile),
sessionFile: params.sessionFile,
})
: null;
const hookRunner = engineOwnsCompaction
? asCompactionHookRunner(getGlobalHookRunner())
: null;
@@ -1222,6 +1273,33 @@ export async function compactEmbeddedPiSession(
runtimeContext,
});
if (result.ok && result.compacted) {
if (params.config && params.sessionKey && checkpointSnapshot) {
try {
const postCompactionSession = SessionManager.open(params.sessionFile);
const postLeafId = postCompactionSession.getLeafId() ?? undefined;
const storedCheckpoint = await persistSessionCompactionCheckpoint({
cfg: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
reason: resolveSessionCompactionCheckpointReason({
trigger: params.trigger,
}),
snapshot: checkpointSnapshot,
summary: result.result?.summary,
firstKeptEntryId: result.result?.firstKeptEntryId,
tokensBefore: result.result?.tokensBefore,
tokensAfter: result.result?.tokensAfter,
postSessionFile: params.sessionFile,
postLeafId,
postEntryId: postLeafId,
});
checkpointSnapshotRetained = storedCheckpoint !== null;
} catch (err) {
log.warn("failed to persist compaction checkpoint", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
await runContextEngineMaintenance({
contextEngine,
sessionId: params.sessionId,
@@ -1275,6 +1353,9 @@ export async function compactEmbeddedPiSession(
: undefined,
};
} finally {
if (!checkpointSnapshotRetained) {
await cleanupCompactionCheckpointSnapshot(checkpointSnapshot);
}
await contextEngine.dispose?.();
}
}),

View File

@@ -75,6 +75,33 @@ export type CliSessionBinding = {
mcpConfigHash?: string;
};
export type SessionCompactionCheckpointReason =
| "manual"
| "auto-threshold"
| "overflow-retry"
| "timeout-retry";
export type SessionCompactionTranscriptReference = {
sessionId: string;
sessionFile?: string;
leafId?: string;
entryId?: string;
};
export type SessionCompactionCheckpoint = {
checkpointId: string;
sessionKey: string;
sessionId: string;
createdAt: number;
reason: SessionCompactionCheckpointReason;
tokensBefore?: number;
tokensAfter?: number;
summary?: string;
firstKeptEntryId?: string;
preCompaction: SessionCompactionTranscriptReference;
postCompaction: SessionCompactionTranscriptReference;
};
export type SessionEntry = {
/**
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
@@ -182,6 +209,7 @@ export type SessionEntry = {
fallbackNoticeReason?: string;
contextTokens?: number;
compactionCount?: number;
compactionCheckpoints?: SessionCompactionCheckpoint[];
memoryFlushAt?: number;
memoryFlushCompactionCount?: number;
memoryFlushContextHash?: string;

View File

@@ -87,6 +87,8 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"sessions.get",
"sessions.preview",
"sessions.resolve",
"sessions.compaction.list",
"sessions.compaction.get",
"sessions.subscribe",
"sessions.unsubscribe",
"sessions.messages.subscribe",
@@ -129,6 +131,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"sessions.send",
"sessions.steer",
"sessions.abort",
"sessions.compaction.branch",
"push.test",
"node.pending.enqueue",
],
@@ -149,6 +152,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"sessions.reset",
"sessions.delete",
"sessions.compact",
"sessions.compaction.restore",
"connect",
"chat.inject",
"web.login.start",

View File

@@ -200,6 +200,14 @@ import {
SessionsAbortParamsSchema,
type SessionsCompactParams,
SessionsCompactParamsSchema,
type SessionsCompactionBranchParams,
SessionsCompactionBranchParamsSchema,
type SessionsCompactionGetParams,
SessionsCompactionGetParamsSchema,
type SessionsCompactionListParams,
SessionsCompactionListParamsSchema,
type SessionsCompactionRestoreParams,
SessionsCompactionRestoreParamsSchema,
type SessionsCreateParams,
SessionsCreateParamsSchema,
type SessionsDeleteParams,
@@ -376,6 +384,18 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
SessionsCompactParamsSchema,
);
export const validateSessionsCompactionListParams = ajv.compile<SessionsCompactionListParams>(
SessionsCompactionListParamsSchema,
);
export const validateSessionsCompactionGetParams = ajv.compile<SessionsCompactionGetParams>(
SessionsCompactionGetParamsSchema,
);
export const validateSessionsCompactionBranchParams = ajv.compile<SessionsCompactionBranchParams>(
SessionsCompactionBranchParamsSchema,
);
export const validateSessionsCompactionRestoreParams = ajv.compile<SessionsCompactionRestoreParams>(
SessionsCompactionRestoreParamsSchema,
);
export const validateSessionsUsageParams =
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
@@ -551,6 +571,10 @@ export {
SessionsListParamsSchema,
SessionsPreviewParamsSchema,
SessionsResolveParamsSchema,
SessionsCompactionListParamsSchema,
SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParamsSchema,
SessionsCreateParamsSchema,
SessionsSendParamsSchema,
SessionsAbortParamsSchema,

View File

@@ -155,6 +155,15 @@ import {
import {
SessionsAbortParamsSchema,
SessionsCompactParamsSchema,
SessionsCompactionBranchParamsSchema,
SessionsCompactionBranchResultSchema,
SessionsCompactionGetParamsSchema,
SessionsCompactionGetResultSchema,
SessionsCompactionListParamsSchema,
SessionsCompactionListResultSchema,
SessionsCompactionRestoreParamsSchema,
SessionsCompactionRestoreResultSchema,
SessionCompactionCheckpointSchema,
SessionsCreateParamsSchema,
SessionsDeleteParamsSchema,
SessionsListParamsSchema,
@@ -224,6 +233,15 @@ export const ProtocolSchemas = {
SessionsListParams: SessionsListParamsSchema,
SessionsPreviewParams: SessionsPreviewParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionCompactionCheckpoint: SessionCompactionCheckpointSchema,
SessionsCompactionListParams: SessionsCompactionListParamsSchema,
SessionsCompactionGetParams: SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParams: SessionsCompactionRestoreParamsSchema,
SessionsCompactionListResult: SessionsCompactionListResultSchema,
SessionsCompactionGetResult: SessionsCompactionGetResultSchema,
SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema,
SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema,
SessionsCreateParams: SessionsCreateParamsSchema,
SessionsSendParams: SessionsSendParamsSchema,
SessionsMessagesSubscribeParams: SessionsMessagesSubscribeParamsSchema,

View File

@@ -1,6 +1,40 @@
import { Type } from "@sinclair/typebox";
import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const SessionCompactionCheckpointReasonSchema = Type.Union([
Type.Literal("manual"),
Type.Literal("auto-threshold"),
Type.Literal("overflow-retry"),
Type.Literal("timeout-retry"),
]);
export const SessionCompactionTranscriptReferenceSchema = Type.Object(
{
sessionId: NonEmptyString,
sessionFile: Type.Optional(NonEmptyString),
leafId: Type.Optional(NonEmptyString),
entryId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const SessionCompactionCheckpointSchema = Type.Object(
{
checkpointId: NonEmptyString,
sessionKey: NonEmptyString,
sessionId: NonEmptyString,
createdAt: Type.Integer({ minimum: 0 }),
reason: SessionCompactionCheckpointReasonSchema,
tokensBefore: Type.Optional(Type.Integer({ minimum: 0 })),
tokensAfter: Type.Optional(Type.Integer({ minimum: 0 })),
summary: Type.Optional(Type.String()),
firstKeptEntryId: Type.Optional(NonEmptyString),
preCompaction: SessionCompactionTranscriptReferenceSchema,
postCompaction: SessionCompactionTranscriptReferenceSchema,
},
{ additionalProperties: false },
);
export const SessionsListParamsSchema = Type.Object(
{
limit: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -163,6 +197,90 @@ export const SessionsCompactParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const SessionsCompactionListParamsSchema = Type.Object(
{
key: NonEmptyString,
},
{ additionalProperties: false },
);
export const SessionsCompactionGetParamsSchema = Type.Object(
{
key: NonEmptyString,
checkpointId: NonEmptyString,
},
{ additionalProperties: false },
);
export const SessionsCompactionBranchParamsSchema = Type.Object(
{
key: NonEmptyString,
checkpointId: NonEmptyString,
},
{ additionalProperties: false },
);
export const SessionsCompactionRestoreParamsSchema = Type.Object(
{
key: NonEmptyString,
checkpointId: NonEmptyString,
},
{ additionalProperties: false },
);
export const SessionsCompactionListResultSchema = Type.Object(
{
ok: Type.Literal(true),
key: NonEmptyString,
checkpoints: Type.Array(SessionCompactionCheckpointSchema),
},
{ additionalProperties: false },
);
export const SessionsCompactionGetResultSchema = Type.Object(
{
ok: Type.Literal(true),
key: NonEmptyString,
checkpoint: SessionCompactionCheckpointSchema,
},
{ additionalProperties: false },
);
export const SessionsCompactionBranchResultSchema = Type.Object(
{
ok: Type.Literal(true),
sourceKey: NonEmptyString,
key: NonEmptyString,
sessionId: NonEmptyString,
checkpoint: SessionCompactionCheckpointSchema,
entry: Type.Object(
{
sessionId: NonEmptyString,
updatedAt: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: true },
),
},
{ additionalProperties: false },
);
export const SessionsCompactionRestoreResultSchema = Type.Object(
{
ok: Type.Literal(true),
key: NonEmptyString,
sessionId: NonEmptyString,
checkpoint: SessionCompactionCheckpointSchema,
entry: Type.Object(
{
sessionId: NonEmptyString,
updatedAt: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: true },
),
},
{ additionalProperties: false },
);
export const SessionsUsageParamsSchema = Type.Object(
{
/** Specific session key to analyze; if omitted returns all sessions. */

View File

@@ -41,6 +41,15 @@ export type PushTestResult = SchemaType<"PushTestResult">;
export type SessionsListParams = SchemaType<"SessionsListParams">;
export type SessionsPreviewParams = SchemaType<"SessionsPreviewParams">;
export type SessionsResolveParams = SchemaType<"SessionsResolveParams">;
export type SessionCompactionCheckpoint = SchemaType<"SessionCompactionCheckpoint">;
export type SessionsCompactionListParams = SchemaType<"SessionsCompactionListParams">;
export type SessionsCompactionGetParams = SchemaType<"SessionsCompactionGetParams">;
export type SessionsCompactionBranchParams = SchemaType<"SessionsCompactionBranchParams">;
export type SessionsCompactionRestoreParams = SchemaType<"SessionsCompactionRestoreParams">;
export type SessionsCompactionListResult = SchemaType<"SessionsCompactionListResult">;
export type SessionsCompactionGetResult = SchemaType<"SessionsCompactionGetResult">;
export type SessionsCompactionBranchResult = SchemaType<"SessionsCompactionBranchResult">;
export type SessionsCompactionRestoreResult = SchemaType<"SessionsCompactionRestoreResult">;
export type SessionsCreateParams = SchemaType<"SessionsCreateParams">;
export type SessionsSendParams = SchemaType<"SessionsSendParams">;
export type SessionsMessagesSubscribeParams = SchemaType<"SessionsMessagesSubscribeParams">;

View File

@@ -68,6 +68,10 @@ const BASE_METHODS = [
"sessions.messages.subscribe",
"sessions.messages.unsubscribe",
"sessions.preview",
"sessions.compaction.list",
"sessions.compaction.get",
"sessions.compaction.branch",
"sessions.compaction.restore",
"sessions.create",
"sessions.send",
"sessions.abort",

View File

@@ -177,6 +177,8 @@ function emitSessionsChanged(
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
}
: {}),
},

View File

@@ -1,13 +1,14 @@
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 { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded-runner/runs.js";
import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
import { loadConfig } from "../../config/config.js";
import {
@@ -36,6 +37,10 @@ import {
errorShape,
validateSessionsAbortParams,
validateSessionsCompactParams,
validateSessionsCompactionBranchParams,
validateSessionsCompactionGetParams,
validateSessionsCompactionListParams,
validateSessionsCompactionRestoreParams,
validateSessionsCreateParams,
validateSessionsDeleteParams,
validateSessionsListParams,
@@ -47,6 +52,10 @@ import {
validateSessionsResolveParams,
validateSessionsSendParams,
} from "../protocol/index.js";
import {
getSessionCompactionCheckpoint,
listSessionCompactionCheckpoints,
} from "../session-compaction-checkpoints.js";
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
archiveFileOnDisk,
@@ -185,6 +194,8 @@ function emitSessionsChanged(
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
}
: {}),
},
@@ -220,6 +231,47 @@ function buildDashboardSessionKey(agentId: string): string {
return `agent:${agentId}:dashboard:${randomUUID()}`;
}
function cloneCheckpointSessionEntry(params: {
currentEntry: SessionEntry;
nextSessionId: string;
nextSessionFile: string;
label?: string;
parentSessionKey?: string;
totalTokens?: number;
preserveCompactionCheckpoints?: boolean;
}): SessionEntry {
return {
...params.currentEntry,
sessionId: params.nextSessionId,
sessionFile: params.nextSessionFile,
updatedAt: Date.now(),
systemSent: false,
abortedLastRun: false,
startedAt: undefined,
endedAt: undefined,
runtimeMs: undefined,
status: undefined,
inputTokens: undefined,
outputTokens: undefined,
cacheRead: undefined,
cacheWrite: undefined,
estimatedCostUsd: undefined,
totalTokens:
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
? params.totalTokens
: undefined,
totalTokensFresh:
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
? true
: undefined,
label: params.label ?? params.currentEntry.label,
parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey,
compactionCheckpoints: params.preserveCompactionCheckpoints
? params.currentEntry.compactionCheckpoints
: undefined,
};
}
function ensureSessionTranscriptFile(params: {
sessionId: string;
storePath: string;
@@ -644,6 +696,74 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
respond(true, { ok: true, key: resolved.key }, undefined);
},
"sessions.compaction.list": ({ params, respond }) => {
if (
!assertValidParams(
params,
validateSessionsCompactionListParams,
"sessions.compaction.list",
respond,
)
) {
return;
}
const key = requireSessionKey((params as { key?: unknown }).key, respond);
if (!key) {
return;
}
const { entry, canonicalKey } = loadSessionEntry(key);
respond(
true,
{
ok: true,
key: canonicalKey,
checkpoints: listSessionCompactionCheckpoints(entry),
},
undefined,
);
},
"sessions.compaction.get": ({ params, respond }) => {
if (
!assertValidParams(
params,
validateSessionsCompactionGetParams,
"sessions.compaction.get",
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 { entry, canonicalKey } = loadSessionEntry(key);
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;
@@ -831,6 +951,228 @@ export const sessionsHandlers: 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 loaded = loadSessionEntry(key);
const { cfg, entry, canonicalKey } = loaded;
const target = resolveGatewaySessionStoreTarget({ cfg, key: canonicalKey });
if (!entry?.sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
);
return;
}
const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId });
if (!checkpoint?.preCompaction.sessionFile) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`),
);
return;
}
if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"),
);
return;
}
const snapshotSession = SessionManager.open(
checkpoint.preCompaction.sessionFile,
path.dirname(checkpoint.preCompaction.sessionFile),
);
const branchedSession = SessionManager.forkFrom(
checkpoint.preCompaction.sessionFile,
snapshotSession.getCwd(),
path.dirname(checkpoint.preCompaction.sessionFile),
);
const branchedSessionFile = branchedSession.getSessionFile();
if (!branchedSessionFile) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "failed to create checkpoint branch transcript"),
);
return;
}
const nextKey = buildDashboardSessionKey(target.agentId);
const label = entry.label?.trim() ? `${entry.label.trim()} (checkpoint)` : "Checkpoint branch";
const nextEntry = cloneCheckpointSessionEntry({
currentEntry: entry,
nextSessionId: branchedSession.getSessionId(),
nextSessionFile: branchedSessionFile,
label,
parentSessionKey: canonicalKey,
totalTokens: checkpoint.tokensBefore,
});
await updateSessionStore(target.storePath, (store) => {
store[nextKey] = nextEntry;
});
respond(
true,
{
ok: true,
sourceKey: canonicalKey,
key: nextKey,
sessionId: nextEntry.sessionId,
checkpoint,
entry: nextEntry,
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: canonicalKey,
reason: "checkpoint-branch",
});
emitSessionsChanged(context, {
sessionKey: nextKey,
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;
}
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 loaded = loadSessionEntry(key);
const { entry, canonicalKey, storePath } = loaded;
if (!entry?.sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
);
return;
}
const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId });
if (!checkpoint?.preCompaction.sessionFile) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`),
);
return;
}
if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"),
);
return;
}
const interruptResult = await interruptSessionRunIfActive({
req,
context,
client,
isWebchatConnect,
requestedKey: key,
canonicalKey,
sessionId: entry.sessionId,
});
if (interruptResult.error) {
respond(false, undefined, interruptResult.error);
return;
}
const snapshotSession = SessionManager.open(
checkpoint.preCompaction.sessionFile,
path.dirname(checkpoint.preCompaction.sessionFile),
);
const restoredSession = SessionManager.forkFrom(
checkpoint.preCompaction.sessionFile,
snapshotSession.getCwd(),
path.dirname(checkpoint.preCompaction.sessionFile),
);
const restoredSessionFile = restoredSession.getSessionFile();
if (!restoredSessionFile) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "failed to restore checkpoint transcript"),
);
return;
}
const nextEntry = cloneCheckpointSessionEntry({
currentEntry: entry,
nextSessionId: restoredSession.getSessionId(),
nextSessionFile: restoredSessionFile,
totalTokens: checkpoint.tokensBefore,
preserveCompactionCheckpoints: true,
});
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = nextEntry;
});
respond(
true,
{
ok: true,
key: canonicalKey,
sessionId: nextEntry.sessionId,
checkpoint,
entry: nextEntry,
},
undefined,
);
emitSessionsChanged(context, {
sessionKey: canonicalKey,
reason: "checkpoint-restore",
});
},
"sessions.send": async ({ req, params, respond, context, client, isWebchatConnect }) => {
await handleSessionSend({
method: "sessions.send",
@@ -1135,7 +1477,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
: undefined;
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
// Lock + read in a short critical section; transcript work happens outside.
@@ -1179,6 +1521,88 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
if (maxLines === undefined) {
const interruptResult = await interruptSessionRunIfActive({
req: undefined,
context,
client: null,
isWebchatConnect: () => false,
requestedKey: key,
canonicalKey: target.canonicalKey,
sessionId,
});
if (interruptResult.error) {
respond(false, undefined, interruptResult.error);
return;
}
const resolvedModel = resolveSessionModelRef(cfg, entry, target.agentId);
const workspaceDir =
entry?.spawnedWorkspaceDir?.trim() || resolveAgentWorkspaceDir(cfg, target.agentId);
const result = await compactEmbeddedPiSession({
sessionId,
sessionKey: target.canonicalKey,
allowGatewaySubagentBinding: true,
sessionFile: filePath,
workspaceDir,
config: cfg,
provider: resolvedModel.provider,
model: resolvedModel.model,
thinkLevel: entry?.thinkingLevel,
reasoningLevel: entry?.reasoningLevel,
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
trigger: "manual",
});
if (result.ok && result.compacted) {
await updateSessionStore(storePath, (store) => {
const entryKey = compactTarget.primaryKey;
const entryToUpdate = store[entryKey];
if (!entryToUpdate) {
return;
}
entryToUpdate.updatedAt = Date.now();
entryToUpdate.compactionCount = Math.max(0, entryToUpdate.compactionCount ?? 0) + 1;
delete entryToUpdate.inputTokens;
delete entryToUpdate.outputTokens;
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;
}
});
}
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,
reason: "compact",
compacted: result.compacted,
});
}
return;
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {

View File

@@ -1059,6 +1059,8 @@ export async function startGatewayServer(
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
}
: {};
const message = attachOpenClawTranscriptMeta(update.message, {
@@ -1160,6 +1162,8 @@ export async function startGatewayServer(
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
}
: {}),
},

View File

@@ -1,6 +1,8 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
@@ -221,6 +223,43 @@ async function writeSingleLineSession(dir: string, sessionId: string, content: s
);
}
function createCheckpointFixture(dir: string) {
const session = SessionManager.create(dir, dir);
session.appendMessage({ role: "user", content: "before compaction" });
session.appendMessage({
role: "assistant",
content: [{ type: "text", text: "working on it" }],
});
const preCompactionLeafId = session.getLeafId();
if (!preCompactionLeafId) {
throw new Error("expected persisted session leaf before compaction");
}
const sessionFile = session.getSessionFile();
if (!sessionFile) {
throw new Error("expected persisted session file");
}
const preCompactionSessionFile = path.join(
dir,
`${path.parse(sessionFile).name}.checkpoint-test.jsonl`,
);
fsSync.copyFileSync(sessionFile, preCompactionSessionFile);
const preCompactionSession = SessionManager.open(preCompactionSessionFile, dir);
session.appendCompaction("checkpoint summary", preCompactionLeafId, 123, { ok: true });
const postCompactionLeafId = session.getLeafId();
if (!postCompactionLeafId) {
throw new Error("expected post-compaction leaf");
}
return {
session,
sessionId: session.getSessionId(),
sessionFile,
preCompactionSession,
preCompactionSessionFile,
preCompactionLeafId,
postCompactionLeafId,
};
}
async function seedActiveMainSession() {
const { dir, storePath } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
@@ -1227,6 +1266,211 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.compaction.* lists checkpoints and branches or restores from pre-compaction snapshots", async () => {
const { dir, storePath } = await createSessionStoreDir();
const fixture = createCheckpointFixture(dir);
await writeSessionStore({
entries: {
main: {
sessionId: fixture.sessionId,
sessionFile: fixture.sessionFile,
updatedAt: Date.now(),
compactionCheckpoints: [
{
checkpointId: "checkpoint-1",
sessionKey: "agent:main:main",
sessionId: fixture.sessionId,
createdAt: Date.now(),
reason: "manual",
tokensBefore: 123,
tokensAfter: 45,
summary: "checkpoint summary",
firstKeptEntryId: fixture.preCompactionLeafId,
preCompaction: {
sessionId: fixture.preCompactionSession.getSessionId(),
sessionFile: fixture.preCompactionSessionFile,
leafId: fixture.preCompactionLeafId,
},
postCompaction: {
sessionId: fixture.sessionId,
sessionFile: fixture.sessionFile,
leafId: fixture.postCompactionLeafId,
entryId: fixture.postCompactionLeafId,
},
},
],
},
},
});
const { ws } = await openClient();
const listedSessions = await rpcReq<{
sessions: Array<{
key: string;
compactionCheckpointCount?: number;
latestCompactionCheckpoint?: {
checkpointId: string;
reason: string;
tokensBefore?: number;
tokensAfter?: number;
};
}>;
}>(ws, "sessions.list", {});
expect(listedSessions.ok).toBe(true);
const main = listedSessions.payload?.sessions.find(
(session) => session.key === "agent:main:main",
);
expect(main?.compactionCheckpointCount).toBe(1);
expect(main?.latestCompactionCheckpoint?.checkpointId).toBe("checkpoint-1");
expect(main?.latestCompactionCheckpoint?.reason).toBe("manual");
const listedCheckpoints = await rpcReq<{
ok: true;
key: string;
checkpoints: Array<{ checkpointId: string; summary?: string; tokensBefore?: number }>;
}>(ws, "sessions.compaction.list", { key: "main" });
expect(listedCheckpoints.ok).toBe(true);
expect(listedCheckpoints.payload?.key).toBe("agent:main:main");
expect(listedCheckpoints.payload?.checkpoints).toHaveLength(1);
expect(listedCheckpoints.payload?.checkpoints[0]).toMatchObject({
checkpointId: "checkpoint-1",
summary: "checkpoint summary",
tokensBefore: 123,
});
const checkpoint = await rpcReq<{
ok: true;
key: string;
checkpoint: { checkpointId: string; preCompaction: { sessionFile: string } };
}>(ws, "sessions.compaction.get", {
key: "main",
checkpointId: "checkpoint-1",
});
expect(checkpoint.ok).toBe(true);
expect(checkpoint.payload?.checkpoint.checkpointId).toBe("checkpoint-1");
expect(checkpoint.payload?.checkpoint.preCompaction.sessionFile).toBe(
fixture.preCompactionSessionFile,
);
const branched = await rpcReq<{
ok: true;
sourceKey: string;
key: string;
entry: { sessionId: string; sessionFile?: string; parentSessionKey?: string };
}>(ws, "sessions.compaction.branch", {
key: "main",
checkpointId: "checkpoint-1",
});
expect(branched.ok).toBe(true);
expect(branched.payload?.sourceKey).toBe("agent:main:main");
expect(branched.payload?.entry.parentSessionKey).toBe("agent:main:main");
const branchedSessionFile = branched.payload?.entry.sessionFile;
expect(branchedSessionFile).toBeTruthy();
const branchedSession = SessionManager.open(branchedSessionFile!, dir);
expect(branchedSession.getEntries()).toHaveLength(
fixture.preCompactionSession.getEntries().length,
);
const storeAfterBranch = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{
parentSessionKey?: string;
compactionCheckpoints?: unknown[];
sessionId?: string;
}
>;
const branchedEntry = storeAfterBranch[branched.payload!.key];
expect(branchedEntry?.parentSessionKey).toBe("agent:main:main");
expect(branchedEntry?.compactionCheckpoints).toBeUndefined();
const restored = await rpcReq<{
ok: true;
key: string;
sessionId: string;
entry: { sessionId: string; sessionFile?: string; compactionCheckpoints?: unknown[] };
}>(ws, "sessions.compaction.restore", {
key: "main",
checkpointId: "checkpoint-1",
});
expect(restored.ok).toBe(true);
expect(restored.payload?.key).toBe("agent:main:main");
expect(restored.payload?.sessionId).not.toBe(fixture.sessionId);
expect(restored.payload?.entry.compactionCheckpoints).toHaveLength(1);
const restoredSessionFile = restored.payload?.entry.sessionFile;
expect(restoredSessionFile).toBeTruthy();
const restoredSession = SessionManager.open(restoredSessionFile!, dir);
expect(restoredSession.getEntries()).toHaveLength(
fixture.preCompactionSession.getEntries().length,
);
const storeAfterRestore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ compactionCheckpoints?: unknown[]; sessionId?: string }
>;
expect(storeAfterRestore["agent:main:main"]?.sessionId).toBe(restored.payload?.sessionId);
expect(storeAfterRestore["agent:main:main"]?.compactionCheckpoints).toHaveLength(1);
ws.close();
});
test("sessions.compact without maxLines runs embedded manual compaction for checkpoint-capable flows", async () => {
const { dir, storePath } = await createSessionStoreDir();
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${JSON.stringify({ role: "user", content: "hello" })}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
thinkingLevel: "medium",
reasoningLevel: "stream",
},
},
});
const { ws } = await openClient();
const compacted = await rpcReq<{
ok: true;
key: string;
compacted: boolean;
result?: { tokensAfter?: number };
}>(ws, "sessions.compact", {
key: "main",
});
expect(compacted.ok).toBe(true);
expect(compacted.payload?.key).toBe("agent:main:main");
expect(compacted.payload?.compacted).toBe(true);
expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledTimes(1);
expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess-main",
sessionKey: "agent:main:main",
sessionFile: expect.stringMatching(/sess-main\.jsonl$/),
config: expect.any(Object),
provider: expect.any(String),
model: expect.any(String),
thinkLevel: "medium",
reasoningLevel: "stream",
trigger: "manual",
}),
);
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ compactionCount?: number; totalTokens?: number; totalTokensFresh?: boolean }
>;
expect(store["agent:main:main"]?.compactionCount).toBe(1);
expect(store["agent:main:main"]?.totalTokens).toBe(80);
expect(store["agent:main:main"]?.totalTokensFresh).toBe(true);
ws.close();
});
test("sessions.preview returns transcript previews", async () => {
const { dir } = await createSessionStoreDir();
const sessionId = "sess-preview";

View File

@@ -0,0 +1,207 @@
import { randomUUID } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js";
import type {
SessionCompactionCheckpoint,
SessionCompactionCheckpointReason,
SessionEntry,
} from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGatewaySessionStoreTarget } from "./session-utils.js";
const log = createSubsystemLogger("gateway/session-compaction-checkpoints");
const MAX_COMPACTION_CHECKPOINTS_PER_SESSION = 25;
export type CapturedCompactionCheckpointSnapshot = {
sessionId: string;
sessionFile: string;
leafId: string;
};
function trimSessionCheckpoints(
checkpoints: SessionCompactionCheckpoint[] | undefined,
): SessionCompactionCheckpoint[] | undefined {
if (!Array.isArray(checkpoints) || checkpoints.length === 0) {
return undefined;
}
return checkpoints.slice(-MAX_COMPACTION_CHECKPOINTS_PER_SESSION);
}
function sessionStoreCheckpoints(
entry: Pick<SessionEntry, "compactionCheckpoints"> | undefined,
): SessionCompactionCheckpoint[] {
return Array.isArray(entry?.compactionCheckpoints) ? [...entry.compactionCheckpoints] : [];
}
export function resolveSessionCompactionCheckpointReason(params: {
trigger?: "budget" | "overflow" | "manual";
timedOut?: boolean;
}): SessionCompactionCheckpointReason {
if (params.trigger === "manual") {
return "manual";
}
if (params.timedOut) {
return "timeout-retry";
}
if (params.trigger === "overflow") {
return "overflow-retry";
}
return "auto-threshold";
}
export function captureCompactionCheckpointSnapshot(params: {
sessionManager: Pick<SessionManager, "getLeafId">;
sessionFile: string;
}): CapturedCompactionCheckpointSnapshot | null {
const getLeafId =
params.sessionManager && typeof params.sessionManager.getLeafId === "function"
? params.sessionManager.getLeafId.bind(params.sessionManager)
: null;
const sessionFile = params.sessionFile.trim();
if (!getLeafId || !sessionFile) {
return null;
}
const leafId = getLeafId();
if (!leafId) {
return null;
}
const parsedSessionFile = path.parse(sessionFile);
const snapshotFile = path.join(
parsedSessionFile.dir,
`${parsedSessionFile.name}.checkpoint.${randomUUID()}${parsedSessionFile.ext || ".jsonl"}`,
);
try {
fsSync.copyFileSync(sessionFile, snapshotFile);
} catch {
return null;
}
let snapshotSession: SessionManager;
try {
snapshotSession = SessionManager.open(snapshotFile, path.dirname(snapshotFile));
} catch {
try {
fsSync.unlinkSync(snapshotFile);
} catch {
// Best-effort cleanup if the copied transcript cannot be reopened.
}
return null;
}
const getSessionId =
snapshotSession && typeof snapshotSession.getSessionId === "function"
? snapshotSession.getSessionId.bind(snapshotSession)
: null;
if (!getSessionId) {
return null;
}
return {
sessionId: getSessionId(),
sessionFile,
leafId,
};
}
export async function cleanupCompactionCheckpointSnapshot(
snapshot: CapturedCompactionCheckpointSnapshot | null | undefined,
): Promise<void> {
if (!snapshot?.sessionFile) {
return;
}
try {
await fs.unlink(snapshot.sessionFile);
} catch {
// Best-effort cleanup; retained snapshots are harmless and easier to debug.
}
}
export async function persistSessionCompactionCheckpoint(params: {
cfg: OpenClawConfig;
sessionKey: string;
sessionId: string;
reason: SessionCompactionCheckpointReason;
snapshot: CapturedCompactionCheckpointSnapshot;
summary?: string;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
postSessionFile?: string;
postLeafId?: string;
postEntryId?: string;
createdAt?: number;
}): Promise<SessionCompactionCheckpoint | null> {
const target = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.sessionKey,
});
const createdAt = params.createdAt ?? Date.now();
const checkpoint: SessionCompactionCheckpoint = {
checkpointId: randomUUID(),
sessionKey: target.canonicalKey,
sessionId: params.sessionId,
createdAt,
reason: params.reason,
...(typeof params.tokensBefore === "number" ? { tokensBefore: params.tokensBefore } : {}),
...(typeof params.tokensAfter === "number" ? { tokensAfter: params.tokensAfter } : {}),
...(params.summary?.trim() ? { summary: params.summary.trim() } : {}),
...(params.firstKeptEntryId?.trim()
? { firstKeptEntryId: params.firstKeptEntryId.trim() }
: {}),
preCompaction: {
sessionId: params.snapshot.sessionId,
sessionFile: params.snapshot.sessionFile,
leafId: params.snapshot.leafId,
},
postCompaction: {
sessionId: params.sessionId,
...(params.postSessionFile?.trim() ? { sessionFile: params.postSessionFile.trim() } : {}),
...(params.postLeafId?.trim() ? { leafId: params.postLeafId.trim() } : {}),
...(params.postEntryId?.trim() ? { entryId: params.postEntryId.trim() } : {}),
},
};
let stored = false;
await updateSessionStore(target.storePath, (store) => {
const existing = store[target.canonicalKey];
if (!existing?.sessionId) {
return;
}
const checkpoints = sessionStoreCheckpoints(existing);
checkpoints.push(checkpoint);
store[target.canonicalKey] = {
...existing,
updatedAt: Math.max(existing.updatedAt ?? 0, createdAt),
compactionCheckpoints: trimSessionCheckpoints(checkpoints),
};
stored = true;
});
if (!stored) {
log.warn("skipping compaction checkpoint persist: session not found", {
sessionKey: params.sessionKey,
});
return null;
}
return checkpoint;
}
export function listSessionCompactionCheckpoints(
entry: Pick<SessionEntry, "compactionCheckpoints"> | undefined,
): SessionCompactionCheckpoint[] {
return sessionStoreCheckpoints(entry).toSorted((a, b) => b.createdAt - a.createdAt);
}
export function getSessionCompactionCheckpoint(params: {
entry: Pick<SessionEntry, "compactionCheckpoints"> | undefined;
checkpointId: string;
}): SessionCompactionCheckpoint | undefined {
const checkpointId = params.checkpointId.trim();
if (!checkpointId) {
return undefined;
}
return listSessionCompactionCheckpoints(params.entry).find(
(checkpoint) => checkpoint.checkpointId === checkpointId,
);
}

View File

@@ -472,6 +472,8 @@ export async function performGatewaySessionReset(params: {
model: resolvedModel.model,
modelProvider: resolvedModel.provider,
contextTokens: resetEntry?.contextTokens,
compactionCount: currentEntry?.compactionCount,
compactionCheckpoints: currentEntry?.compactionCheckpoints,
sendPolicy: currentEntry?.sendPolicy,
queueMode: currentEntry?.queueMode,
queueDebounceMs: currentEntry?.queueDebounceMs,

View File

@@ -213,6 +213,18 @@ function resolveNonNegativeNumber(value: number | null | undefined): number | un
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function resolveLatestCompactionCheckpoint(
entry?: Pick<SessionEntry, "compactionCheckpoints"> | null,
): NonNullable<SessionEntry["compactionCheckpoints"]>[number] | undefined {
const checkpoints = entry?.compactionCheckpoints;
if (!Array.isArray(checkpoints) || checkpoints.length === 0) {
return undefined;
}
return checkpoints.reduce((latest, checkpoint) =>
!latest || checkpoint.createdAt > latest.createdAt ? checkpoint : latest,
);
}
function resolveEstimatedSessionCostUsd(params: {
cfg: OpenClawConfig;
provider?: string;
@@ -1268,6 +1280,7 @@ export function buildGatewaySessionRow(params: {
? true
: transcriptUsage?.totalTokensFresh === true;
const childSessions = resolveChildSessionKeys(key, store);
const latestCompactionCheckpoint = resolveLatestCompactionCheckpoint(entry);
const estimatedCostUsd =
resolveEstimatedSessionCostUsd({
cfg,
@@ -1354,6 +1367,8 @@ export function buildGatewaySessionRow(params: {
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId,
compactionCheckpointCount: entry?.compactionCheckpoints?.length,
latestCompactionCheckpoint,
};
}

View File

@@ -1,5 +1,6 @@
import type { ChatType } from "../channels/chat-type.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionCompactionCheckpoint } from "../config/sessions.js";
import type {
GatewayAgentRow as SharedGatewayAgentRow,
SessionsListResultBase,
@@ -64,6 +65,8 @@ export type GatewaySessionRow = {
lastTo?: string;
lastAccountId?: string;
lastThreadId?: SessionEntry["lastThreadId"];
compactionCheckpointCount?: number;
latestCompactionCheckpoint?: SessionCompactionCheckpoint;
};
export type GatewayAgentRow = SharedGatewayAgentRow;

View File

@@ -54,6 +54,8 @@ const gatewayTestHoisted = getGatewayTestHoistedState();
function createEmbeddedRunMockExports() {
return {
compactEmbeddedPiSession: (...args: unknown[]) =>
embeddedRunMock.compactEmbeddedPiSession(...args),
isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId),
abortEmbeddedPiRun: (sessionId: string) => {
embeddedRunMock.abortCalls.push(sessionId);

View File

@@ -332,6 +332,17 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
embeddedRunMock.compactEmbeddedPiSession.mockReset();
embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({
ok: true,
compacted: true,
result: {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
tokensAfter: 80,
},
});
for (const sessionKey of resolveGatewayTestMainSessionKeys()) {
drainSystemEvents(sessionKey);
}
@@ -411,6 +422,17 @@ async function resetGatewayTestRuntimeOnly() {
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
embeddedRunMock.compactEmbeddedPiSession.mockReset();
embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({
ok: true,
compacted: true,
result: {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 120,
tokensAfter: 80,
},
});
clearSessionStoreCacheForTest();
await persistTestSessionConfig();
for (const sessionKey of resolveGatewayTestMainSessionKeys()) {

View File

@@ -79,7 +79,14 @@ import {
import { loadLogs } from "./controllers/logs.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts";
import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts";
import {
branchSessionFromCheckpoint,
deleteSessionsAndRefresh,
loadSessions,
patchSession,
restoreSessionFromCheckpoint,
toggleSessionCompactionCheckpoints,
} from "./controllers/sessions.ts";
import {
closeClawHubDetail,
installFromClawHub,
@@ -840,6 +847,11 @@ export function renderApp(state: AppViewState) {
page: state.sessionsPage,
pageSize: state.sessionsPageSize,
selectedKeys: state.sessionsSelectedKeys,
expandedCheckpointKey: state.sessionsExpandedCheckpointKey,
checkpointItemsByKey: state.sessionsCheckpointItemsByKey,
checkpointLoadingKey: state.sessionsCheckpointLoadingKey,
checkpointBusyKey: state.sessionsCheckpointBusyKey,
checkpointErrorByKey: state.sessionsCheckpointErrorByKey,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
@@ -905,6 +917,21 @@ export function renderApp(state: AppViewState) {
switchChatSession(state, sessionKey);
state.setTab("chat" as import("./navigation.ts").Tab);
},
onToggleCheckpointDetails: (sessionKey) =>
toggleSessionCompactionCheckpoints(state, sessionKey),
onBranchFromCheckpoint: async (sessionKey, checkpointId) => {
const nextKey = await branchSessionFromCheckpoint(
state,
sessionKey,
checkpointId,
);
if (nextKey) {
switchChatSession(state, nextKey);
state.setTab("chat" as import("./navigation.ts").Tab);
}
},
onRestoreCheckpoint: (sessionKey, checkpointId) =>
restoreSessionFromCheckpoint(state, sessionKey, checkpointId),
}),
)
: nothing}

View File

@@ -209,6 +209,11 @@ export type AppViewState = {
sessionsPage: number;
sessionsPageSize: number;
sessionsSelectedKeys: Set<string>;
sessionsExpandedCheckpointKey: string | null;
sessionsCheckpointItemsByKey: Record<string, import("./types.ts").SessionCompactionCheckpoint[]>;
sessionsCheckpointLoadingKey: string | null;
sessionsCheckpointBusyKey: string | null;
sessionsCheckpointErrorByKey: Record<string, string>;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;

View File

@@ -89,6 +89,7 @@ import type {
ModelCatalogEntry,
PresenceEntry,
ChannelsStatusSnapshot,
SessionCompactionCheckpoint,
SessionsListResult,
SkillStatusReport,
StatusSummary,
@@ -312,6 +313,11 @@ export class OpenClawApp extends LitElement {
@state() sessionsPage = 0;
@state() sessionsPageSize = 25;
@state() sessionsSelectedKeys: Set<string> = new Set();
@state() sessionsExpandedCheckpointKey: string | null = null;
@state() sessionsCheckpointItemsByKey: Record<string, SessionCompactionCheckpoint[]> = {};
@state() sessionsCheckpointLoadingKey: string | null = null;
@state() sessionsCheckpointBusyKey: string | null = null;
@state() sessionsCheckpointErrorByKey: Record<string, string> = {};
@state() usageLoading = false;
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;

View File

@@ -146,8 +146,24 @@ async function executeCompact(
sessionKey: string,
): Promise<SlashCommandResult> {
try {
await client.request("sessions.compact", { key: sessionKey });
return { content: "Context compacted successfully.", action: "refresh" };
const result = await client.request<{
compacted?: boolean;
reason?: string;
result?: { tokensBefore?: number; tokensAfter?: number };
}>("sessions.compact", { key: sessionKey });
if (result?.compacted) {
const before = result.result?.tokensBefore;
const after = result.result?.tokensAfter;
const tokenSummary =
typeof before === "number" && typeof after === "number"
? ` (${before.toLocaleString()} -> ${after.toLocaleString()} tokens)`
: "";
return { content: `Context compacted successfully${tokenSummary}.`, action: "refresh" };
}
if (typeof result?.reason === "string" && result.reason.trim()) {
return { content: `Compaction skipped: ${result.reason}`, action: "refresh" };
}
return { content: "Compaction skipped.", action: "refresh" };
} catch (err) {
return { content: `Compaction failed: ${String(err)}` };
}

View File

@@ -1,6 +1,12 @@
import { toNumber } from "../format.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { SessionsListResult } from "../types.ts";
import type {
SessionCompactionCheckpoint,
SessionsCompactionBranchResult,
SessionsCompactionListResult,
SessionsCompactionRestoreResult,
SessionsListResult,
} from "../types.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
@@ -16,6 +22,11 @@ export type SessionsState = {
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsExpandedCheckpointKey: string | null;
sessionsCheckpointItemsByKey: Record<string, SessionCompactionCheckpoint[]>;
sessionsCheckpointLoadingKey: string | null;
sessionsCheckpointBusyKey: string | null;
sessionsCheckpointErrorByKey: Record<string, string>;
};
export async function subscribeSessions(state: SessionsState) {
@@ -156,3 +167,106 @@ export async function deleteSessionsAndRefresh(
}
return deleted;
}
export async function toggleSessionCompactionCheckpoints(state: SessionsState, key: string) {
const trimmedKey = key.trim();
if (!trimmedKey) {
return;
}
if (state.sessionsExpandedCheckpointKey === trimmedKey) {
state.sessionsExpandedCheckpointKey = null;
return;
}
state.sessionsExpandedCheckpointKey = trimmedKey;
if (state.sessionsCheckpointItemsByKey[trimmedKey]) {
return;
}
state.sessionsCheckpointLoadingKey = trimmedKey;
state.sessionsCheckpointErrorByKey = {
...state.sessionsCheckpointErrorByKey,
[trimmedKey]: "",
};
try {
const result = await state.client?.request<SessionsCompactionListResult>(
"sessions.compaction.list",
{ key: trimmedKey },
);
if (result) {
state.sessionsCheckpointItemsByKey = {
...state.sessionsCheckpointItemsByKey,
[trimmedKey]: result.checkpoints ?? [],
};
}
} catch (err) {
state.sessionsCheckpointErrorByKey = {
...state.sessionsCheckpointErrorByKey,
[trimmedKey]: String(err),
};
} finally {
if (state.sessionsCheckpointLoadingKey === trimmedKey) {
state.sessionsCheckpointLoadingKey = null;
}
}
}
export async function branchSessionFromCheckpoint(
state: SessionsState,
key: string,
checkpointId: string,
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const confirmed = window.confirm(
"Create a new child session from this pre-compaction checkpoint?",
);
if (!confirmed) {
return null;
}
state.sessionsCheckpointBusyKey = checkpointId;
try {
const result = await state.client.request<SessionsCompactionBranchResult>(
"sessions.compaction.branch",
{ key, checkpointId },
);
await loadSessions(state);
return result?.key ?? null;
} catch (err) {
state.sessionsError = String(err);
return null;
} finally {
if (state.sessionsCheckpointBusyKey === checkpointId) {
state.sessionsCheckpointBusyKey = null;
}
}
}
export async function restoreSessionFromCheckpoint(
state: SessionsState,
key: string,
checkpointId: string,
) {
if (!state.client || !state.connected) {
return;
}
const confirmed = window.confirm(
"Restore this session to the selected pre-compaction checkpoint?\n\nThis replaces the current active transcript for the session key.",
);
if (!confirmed) {
return;
}
state.sessionsCheckpointBusyKey = checkpointId;
try {
await state.client.request<SessionsCompactionRestoreResult>("sessions.compaction.restore", {
key,
checkpointId,
});
await loadSessions(state);
} catch (err) {
state.sessionsError = String(err);
} finally {
if (state.sessionsCheckpointBusyKey === checkpointId) {
state.sessionsCheckpointBusyKey = null;
}
}
}

View File

@@ -369,6 +369,33 @@ export type AgentsFilesSetResult = {
export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout";
export type SessionCompactionCheckpointReason =
| "manual"
| "auto-threshold"
| "overflow-retry"
| "timeout-retry";
export type SessionCompactionTranscriptReference = {
sessionId: string;
sessionFile?: string;
leafId?: string;
entryId?: string;
};
export type SessionCompactionCheckpoint = {
checkpointId: string;
sessionKey: string;
sessionId: string;
createdAt: number;
reason: SessionCompactionCheckpointReason;
tokensBefore?: number;
tokensAfter?: number;
summary?: string;
firstKeptEntryId?: string;
preCompaction: SessionCompactionTranscriptReference;
postCompaction: SessionCompactionTranscriptReference;
};
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
@@ -400,10 +427,47 @@ export type GatewaySessionRow = {
model?: string;
modelProvider?: string;
contextTokens?: number;
compactionCheckpointCount?: number;
latestCompactionCheckpoint?: SessionCompactionCheckpoint;
};
export type SessionsListResult = SessionsListResultBase<GatewaySessionsDefaults, GatewaySessionRow>;
export type SessionsCompactionListResult = {
ok: true;
key: string;
checkpoints: SessionCompactionCheckpoint[];
};
export type SessionsCompactionGetResult = {
ok: true;
key: string;
checkpoint: SessionCompactionCheckpoint;
};
export type SessionsCompactionBranchResult = {
ok: true;
sourceKey: string;
key: string;
sessionId: string;
checkpoint: SessionCompactionCheckpoint;
entry: {
sessionId: string;
updatedAt: number;
} & Record<string, unknown>;
};
export type SessionsCompactionRestoreResult = {
ok: true;
key: string;
sessionId: string;
checkpoint: SessionCompactionCheckpoint;
entry: {
sessionId: string;
updatedAt: number;
} & Record<string, unknown>;
};
export type SessionsPatchResult = SessionsPatchResultBase<{
sessionId: string;
updatedAt?: number;

View File

@@ -4,7 +4,11 @@ import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
import type {
GatewaySessionRow,
SessionCompactionCheckpoint,
SessionsListResult,
} from "../types.ts";
export type SessionsProps = {
loading: boolean;
@@ -21,6 +25,11 @@ export type SessionsProps = {
page: number;
pageSize: number;
selectedKeys: Set<string>;
expandedCheckpointKey: string | null;
checkpointItemsByKey: Record<string, SessionCompactionCheckpoint[]>;
checkpointLoadingKey: string | null;
checkpointBusyKey: string | null;
checkpointErrorByKey: Record<string, string>;
onFiltersChange: (next: {
activeMinutes: string;
limit: string;
@@ -48,6 +57,9 @@ export type SessionsProps = {
onDeselectAll: () => void;
onDeleteSelected: () => void;
onNavigateToChat?: (sessionKey: string) => void;
onToggleCheckpointDetails: (sessionKey: string) => void;
onBranchFromCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise<void>;
onRestoreCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise<void>;
};
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const;
@@ -182,6 +194,36 @@ function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
return rows.slice(start, start + pageSize);
}
function formatCheckpointReason(reason: SessionCompactionCheckpoint["reason"]): string {
switch (reason) {
case "manual":
return "manual";
case "auto-threshold":
return "auto-threshold";
case "overflow-retry":
return "overflow retry";
case "timeout-retry":
return "timeout retry";
default:
return reason;
}
}
function formatCheckpointDelta(checkpoint: SessionCompactionCheckpoint): string {
if (
typeof checkpoint.tokensBefore === "number" &&
typeof checkpoint.tokensAfter === "number" &&
Number.isFinite(checkpoint.tokensBefore) &&
Number.isFinite(checkpoint.tokensAfter)
) {
return `${checkpoint.tokensBefore.toLocaleString()}${checkpoint.tokensAfter.toLocaleString()} tokens`;
}
if (typeof checkpoint.tokensBefore === "number" && Number.isFinite(checkpoint.tokensBefore)) {
return `${checkpoint.tokensBefore.toLocaleString()} tokens before`;
}
return "token delta unavailable";
}
export function renderSessions(props: SessionsProps) {
const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
@@ -349,6 +391,7 @@ export function renderSessions(props: SessionsProps) {
<th>Label</th>
${sortHeader("kind", "Kind")} ${sortHeader("updated", "Updated")}
${sortHeader("tokens", "Tokens")}
<th>Compaction</th>
<th>Thinking</th>
<th>Fast</th>
<th>Verbose</th>
@@ -360,24 +403,14 @@ export function renderSessions(props: SessionsProps) {
? html`
<tr>
<td
colspan="10"
colspan="11"
style="text-align: center; padding: 48px 16px; color: var(--muted)"
>
No sessions found.
</td>
</tr>
`
: paginated.map((row) =>
renderRow(
row,
props.basePath,
props.onPatch,
props.selectedKeys.has(row.key),
props.onToggleSelect,
props.loading,
props.onNavigateToChat,
),
)}
: paginated.flatMap((row) => renderRows(row, props))}
</tbody>
</table>
</div>
@@ -416,15 +449,7 @@ export function renderSessions(props: SessionsProps) {
`;
}
function renderRow(
row: GatewaySessionRow,
basePath: string,
onPatch: SessionsProps["onPatch"],
selected: boolean,
onToggleSelect: SessionsProps["onToggleSelect"],
disabled: boolean,
onNavigateToChat?: (sessionKey: string) => void,
) {
function renderRows(row: GatewaySessionRow, props: SessionsProps) {
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na");
const rawThinking = row.thinkingLevel ?? "";
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
@@ -436,6 +461,11 @@ function renderRow(
const verboseLevels = withCurrentLabeledOption(VERBOSE_LEVELS, verbose);
const reasoning = row.reasoningLevel ?? "";
const reasoningLevels = withCurrentOption(REASONING_LEVELS, reasoning);
const latestCheckpoint = row.latestCompactionCheckpoint;
const checkpointCount = row.compactionCheckpointCount ?? 0;
const isExpanded = props.expandedCheckpointKey === row.key;
const checkpointItems = props.checkpointItemsByKey[row.key] ?? [];
const checkpointError = props.checkpointErrorByKey[row.key];
const displayName =
typeof row.displayName === "string" && row.displayName.trim().length > 0
? row.displayName.trim()
@@ -447,7 +477,7 @@ function renderRow(
);
const canLink = row.kind !== "global";
const chatUrl = canLink
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}`
: null;
const badgeClass =
row.kind === "direct"
@@ -458,13 +488,13 @@ function renderRow(
? "data-table-badge--global"
: "data-table-badge--unknown";
return html`
<tr>
return [
html`<tr>
<td class="data-table-checkbox-col">
<input
type="checkbox"
.checked=${selected}
@change=${() => onToggleSelect(row.key)}
.checked=${props.selectedKeys.has(row.key)}
@change=${() => props.onToggleSelect(row.key)}
aria-label="Select session"
/>
</td>
@@ -485,9 +515,9 @@ function renderRow(
) {
return;
}
if (onNavigateToChat) {
if (props.onNavigateToChat) {
e.preventDefault();
onNavigateToChat(row.key);
props.onNavigateToChat(row.key);
}
}}
>${row.key}</a
@@ -501,12 +531,12 @@ function renderRow(
<td>
<input
.value=${row.label ?? ""}
?disabled=${disabled}
?disabled=${props.loading}
placeholder="(optional)"
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
props.onPatch(row.key, { label: value || null });
}}
/>
</td>
@@ -515,13 +545,37 @@ function renderRow(
</td>
<td>${updated}</td>
<td>${formatSessionTokens(row)}</td>
<td>
<div style="display: grid; gap: 6px;">
<span class="muted" style="font-size: 12px;">
${checkpointCount > 0
? `${checkpointCount} checkpoint${checkpointCount === 1 ? "" : "s"}`
: "none"}
</span>
${latestCheckpoint
? html`
<span style="font-size: 12px;">
${formatCheckpointReason(latestCheckpoint.reason)} ·
${formatRelativeTimestamp(latestCheckpoint.createdAt)}
</span>
`
: nothing}
<button
class="btn btn--sm"
?disabled=${props.checkpointLoadingKey === row.key}
@click=${() => props.onToggleCheckpointDetails(row.key)}
>
${isExpanded ? "Hide checkpoints" : "Show checkpoints"}
</button>
</div>
</td>
<td>
<select
?disabled=${disabled}
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, {
props.onPatch(row.key, {
thinkingLevel: resolveThinkLevelPatchValue(value, isBinaryThinking),
});
}}
@@ -536,11 +590,11 @@ function renderRow(
</td>
<td>
<select
?disabled=${disabled}
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { fastMode: value === "" ? null : value === "on" });
props.onPatch(row.key, { fastMode: value === "" ? null : value === "on" });
}}
>
${fastLevels.map(
@@ -553,11 +607,11 @@ function renderRow(
</td>
<td>
<select
?disabled=${disabled}
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null });
props.onPatch(row.key, { verboseLevel: value || null });
}}
>
${verboseLevels.map(
@@ -570,11 +624,11 @@ function renderRow(
</td>
<td>
<select
?disabled=${disabled}
?disabled=${props.loading}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null });
props.onPatch(row.key, { reasoningLevel: value || null });
}}
>
${reasoningLevels.map(
@@ -585,6 +639,77 @@ function renderRow(
)}
</select>
</td>
</tr>
`;
</tr>`,
...(isExpanded
? [
html`<tr>
<td colspan="11" style="padding: 0;">
<div
style="padding: 14px 16px; border-top: 1px solid var(--border); background: var(--surface-2, rgba(127, 127, 127, 0.05));"
>
${props.checkpointLoadingKey === row.key
? html`<div class="muted">Loading checkpoints…</div>`
: checkpointError
? html`<div class="callout danger">${checkpointError}</div>`
: checkpointItems.length === 0
? html`<div class="muted">
No compaction checkpoints recorded for this session.
</div>`
: html`
<div style="display: grid; gap: 10px;">
${checkpointItems.map(
(checkpoint) => html`
<div
style="border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; display: grid; gap: 8px;"
>
<div
style="display: flex; gap: 8px; justify-content: space-between; align-items: center; flex-wrap: wrap;"
>
<strong>
${formatCheckpointReason(checkpoint.reason)} ·
${formatRelativeTimestamp(checkpoint.createdAt)}
</strong>
<span class="muted" style="font-size: 12px;">
${formatCheckpointDelta(checkpoint)}
</span>
</div>
${checkpoint.summary
? html`<div style="white-space: pre-wrap;">
${checkpoint.summary}
</div>`
: html`<div class="muted">No summary captured.</div>`}
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey ===
checkpoint.checkpointId}
@click=${() =>
props.onBranchFromCheckpoint(
row.key,
checkpoint.checkpointId,
)}
>
Branch from checkpoint
</button>
<button
class="btn btn--sm"
?disabled=${props.checkpointBusyKey ===
checkpoint.checkpointId}
@click=${() =>
props.onRestoreCheckpoint(row.key, checkpoint.checkpointId)}
>
Restore
</button>
</div>
</div>
`,
)}
</div>
`}
</div>
</td>
</tr>`,
]
: []),
];
}