mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
Gateway: add compaction checkpoints
This commit is contained in:
@@ -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?.();
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -177,6 +177,8 @@ function emitSessionsChanged(
|
||||
startedAt: sessionRow.startedAt,
|
||||
endedAt: sessionRow.endedAt,
|
||||
runtimeMs: sessionRow.runtimeMs,
|
||||
compactionCheckpointCount: sessionRow.compactionCheckpointCount,
|
||||
latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
207
src/gateway/session-compaction-checkpoints.ts
Normal file
207
src/gateway/session-compaction-checkpoints.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}` };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user