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

@@ -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>`,
]
: []),
];
}