mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
clawdbot-d02.1.9.1.16: add session patch projection seam (#93739)
This commit is contained in:
@@ -68,6 +68,7 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
"src/gateway/server-methods/sessions.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]);
|
||||
|
||||
export const migratedBundledPluginSessionAccessorFiles = new Set([
|
||||
@@ -98,6 +99,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/auto-reply/reply/session-reset-model.ts",
|
||||
"src/auto-reply/reply/session-updates.ts",
|
||||
"src/auto-reply/reply/session-usage.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]);
|
||||
|
||||
export const migratedTranscriptWriterFiles = new Set([
|
||||
@@ -290,8 +292,13 @@ export async function main() {
|
||||
"src/cron",
|
||||
"src/gateway",
|
||||
"src/infra",
|
||||
"src/tui",
|
||||
]);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/tui",
|
||||
]);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, ["src/agents", "src/auto-reply"]);
|
||||
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/agents/command",
|
||||
"src/agents/embedded-agent-runner",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import {
|
||||
appendTranscriptMessage,
|
||||
appendTranscriptEvent,
|
||||
applySessionPatchProjection,
|
||||
cleanupSessionLifecycleArtifacts,
|
||||
createSessionEntryWithTranscript,
|
||||
listSessionEntries,
|
||||
@@ -323,6 +324,93 @@ describe("session accessor file-backed seam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies projected session patches after migrating legacy candidate keys", async () => {
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
sessionId: "canonical-session",
|
||||
updatedAt: 10,
|
||||
},
|
||||
"AGENT:MAIN:MAIN": {
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const projected = await applySessionPatchProjection({
|
||||
storePath,
|
||||
resolveTarget: () => ({
|
||||
primaryKey: "agent:main:main",
|
||||
candidateKeys: ["agent:main:main"],
|
||||
}),
|
||||
project: ({ entries, existingEntry, primaryKey }) => {
|
||||
expect(primaryKey).toBe("agent:main:main");
|
||||
expect(existingEntry?.sessionId).toBe("legacy-session");
|
||||
expect(entries.map((entry) => entry.sessionKey)).toEqual(["agent:main:main"]);
|
||||
return {
|
||||
ok: true as const,
|
||||
entry: {
|
||||
...existingEntry,
|
||||
label: "Projected",
|
||||
} as SessionEntry,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(projected).toMatchObject({
|
||||
ok: true,
|
||||
entry: {
|
||||
label: "Projected",
|
||||
sessionId: "legacy-session",
|
||||
},
|
||||
});
|
||||
expect(loadSessionStore(storePath)).toEqual({
|
||||
"agent:main:main": expect.objectContaining({
|
||||
label: "Projected",
|
||||
sessionId: "legacy-session",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("persists legacy key pruning when projected session patches fail validation", async () => {
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:main": {
|
||||
sessionId: "canonical-session",
|
||||
updatedAt: 10,
|
||||
},
|
||||
"AGENT:MAIN:MAIN": {
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: 20,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const projected = await applySessionPatchProjection({
|
||||
storePath,
|
||||
resolveTarget: () => ({
|
||||
primaryKey: "agent:main:main",
|
||||
candidateKeys: ["agent:main:main"],
|
||||
}),
|
||||
project: () => ({
|
||||
ok: false as const,
|
||||
error: "invalid patch",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(projected).toEqual({ ok: false, error: "invalid patch" });
|
||||
expect(loadSessionStore(storePath)).toEqual({
|
||||
"agent:main:main": expect.objectContaining({
|
||||
sessionId: "legacy-session",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans scoped lifecycle entries and unreferenced transcript artifacts", async () => {
|
||||
const nowMs = Date.now();
|
||||
const oldDate = new Date(nowMs - 600_000);
|
||||
|
||||
@@ -25,11 +25,17 @@ import {
|
||||
cleanupSessionLifecycleArtifacts as cleanupFileSessionLifecycleArtifacts,
|
||||
listSessionEntries as listFileSessionEntries,
|
||||
loadSessionStore,
|
||||
applySessionEntryPatchProjection as applyFileSessionEntryPatchProjection,
|
||||
patchSessionEntry as patchFileSessionEntry,
|
||||
readSessionUpdatedAt as readFileSessionUpdatedAt,
|
||||
resolveSessionStoreEntry,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry as updateFileSessionStoreEntry,
|
||||
type SessionEntryPatchProjectionContext,
|
||||
type SessionEntryPatchProjectionFailure,
|
||||
type SessionEntryPatchProjectionResult,
|
||||
type SessionEntryPatchProjectionSnapshot,
|
||||
type SessionEntryPatchProjectionTarget,
|
||||
type SessionLifecycleArtifactCleanupParams,
|
||||
type SessionLifecycleArtifactCleanupResult,
|
||||
} from "./store.js";
|
||||
@@ -267,6 +273,13 @@ type CreatedSessionTranscriptResult =
|
||||
| { ok: true; sessionFile: string }
|
||||
| { ok: false; error: string; phase: "transcript" };
|
||||
|
||||
export type SessionPatchProjectionContext = SessionEntryPatchProjectionContext;
|
||||
export type SessionPatchProjectionFailure = SessionEntryPatchProjectionFailure;
|
||||
export type SessionPatchProjectionResult<TFailure extends SessionPatchProjectionFailure> =
|
||||
SessionEntryPatchProjectionResult<TFailure>;
|
||||
export type SessionPatchProjectionSnapshot = SessionEntryPatchProjectionSnapshot;
|
||||
export type SessionPatchProjectionTarget = SessionEntryPatchProjectionTarget;
|
||||
|
||||
export type { SessionLifecycleArtifactCleanupParams, SessionLifecycleArtifactCleanupResult };
|
||||
|
||||
/** Returns the entry for a canonical or alias session key, if one exists. */
|
||||
@@ -484,6 +497,23 @@ export async function updateSessionEntry(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a session patch projection through the accessor boundary.
|
||||
* The resolver sees a read-only snapshot and names the persisted key set; the
|
||||
* projector returns one replacement entry without receiving the mutable store.
|
||||
*/
|
||||
export async function applySessionPatchProjection<
|
||||
TFailure extends SessionPatchProjectionFailure,
|
||||
>(params: {
|
||||
storePath: string;
|
||||
resolveTarget: (snapshot: SessionPatchProjectionSnapshot) => SessionPatchProjectionTarget;
|
||||
project: (
|
||||
context: SessionPatchProjectionContext,
|
||||
) => Promise<SessionPatchProjectionResult<TFailure>> | SessionPatchProjectionResult<TFailure>;
|
||||
}): Promise<SessionPatchProjectionResult<TFailure>> {
|
||||
return await applyFileSessionEntryPatchProjection(params);
|
||||
}
|
||||
|
||||
/** Removes entries and orphan transcript artifacts owned by a named session lifecycle. */
|
||||
export async function cleanupSessionLifecycleArtifacts(
|
||||
params: SessionLifecycleArtifactCleanupParams,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Session store facade coordinates reads, writes, maintenance, delivery metadata, and exports.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { writeTextAtomic } from "../../infra/json-files.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
@@ -86,6 +90,25 @@ export type {
|
||||
} from "./store-cache.js";
|
||||
export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js";
|
||||
|
||||
export type SessionEntryPatchProjectionSnapshot = {
|
||||
entries: ReadonlyArray<{ sessionKey: string; entry: SessionEntry }>;
|
||||
};
|
||||
|
||||
export type SessionEntryPatchProjectionTarget = {
|
||||
candidateKeys?: readonly string[];
|
||||
primaryKey: string;
|
||||
};
|
||||
|
||||
export type SessionEntryPatchProjectionContext = SessionEntryPatchProjectionSnapshot &
|
||||
SessionEntryPatchProjectionTarget & {
|
||||
existingEntry?: SessionEntry;
|
||||
};
|
||||
|
||||
export type SessionEntryPatchProjectionFailure = { ok: false };
|
||||
|
||||
export type SessionEntryPatchProjectionResult<TFailure extends SessionEntryPatchProjectionFailure> =
|
||||
{ ok: true; entry: SessionEntry } | TFailure;
|
||||
|
||||
const log = createSubsystemLogger("sessions/store");
|
||||
let sessionArchiveRuntimePromise: Promise<
|
||||
typeof import("../../gateway/session-archive.runtime.js")
|
||||
@@ -926,6 +949,129 @@ export async function updateSessionStore<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function cloneSessionEntryProjectionSnapshot(
|
||||
store: Record<string, SessionEntry>,
|
||||
): SessionEntryPatchProjectionSnapshot {
|
||||
return {
|
||||
entries: Object.entries(store).map(([sessionKey, entry]) => ({
|
||||
sessionKey,
|
||||
entry: cloneSessionEntry(entry),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFreshestProjectedEntry(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
candidateKeys: readonly string[];
|
||||
}): SessionEntry | undefined {
|
||||
let freshest: SessionEntry | undefined;
|
||||
const keys = new Set<string>();
|
||||
for (const candidateKey of params.candidateKeys) {
|
||||
const trimmed = normalizeOptionalString(candidateKey) ?? "";
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
keys.add(trimmed);
|
||||
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
|
||||
keys.add(match);
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
const entry = params.store[key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (!freshest || (entry.updatedAt ?? 0) > (freshest.updatedAt ?? 0)) {
|
||||
freshest = entry;
|
||||
}
|
||||
}
|
||||
return freshest;
|
||||
}
|
||||
|
||||
function findSessionStoreKeysIgnoreCase(
|
||||
store: Record<string, SessionEntry>,
|
||||
targetKey: string,
|
||||
): string[] {
|
||||
const lowered = normalizeLowercaseStringOrEmpty(targetKey);
|
||||
return Object.keys(store).filter((key) => normalizeLowercaseStringOrEmpty(key) === lowered);
|
||||
}
|
||||
|
||||
function migrateSessionEntryProjectionTarget(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
target: SessionEntryPatchProjectionTarget;
|
||||
}): void {
|
||||
const candidateKeys = params.target.candidateKeys ?? [params.target.primaryKey];
|
||||
const freshest = resolveFreshestProjectedEntry({
|
||||
store: params.store,
|
||||
candidateKeys,
|
||||
});
|
||||
const currentPrimary = params.store[params.target.primaryKey];
|
||||
if (
|
||||
freshest &&
|
||||
(!currentPrimary || (freshest.updatedAt ?? 0) > (currentPrimary.updatedAt ?? 0))
|
||||
) {
|
||||
params.store[params.target.primaryKey] = freshest;
|
||||
}
|
||||
|
||||
const keysToDelete = new Set<string>();
|
||||
for (const candidateKey of candidateKeys) {
|
||||
const trimmed = normalizeOptionalString(candidateKey) ?? "";
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed !== params.target.primaryKey) {
|
||||
keysToDelete.add(trimmed);
|
||||
}
|
||||
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
|
||||
if (match !== params.target.primaryKey) {
|
||||
keysToDelete.add(match);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of keysToDelete) {
|
||||
delete params.store[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a storage-neutral entry projection inside the session-store writer.
|
||||
* The projection receives a cloned snapshot and returns the replacement entry;
|
||||
* it cannot mutate the backing whole store.
|
||||
*/
|
||||
export async function applySessionEntryPatchProjection<
|
||||
TFailure extends SessionEntryPatchProjectionFailure,
|
||||
>(params: {
|
||||
storePath: string;
|
||||
resolveTarget: (
|
||||
snapshot: SessionEntryPatchProjectionSnapshot,
|
||||
) => SessionEntryPatchProjectionTarget;
|
||||
project: (
|
||||
context: SessionEntryPatchProjectionContext,
|
||||
) =>
|
||||
| Promise<SessionEntryPatchProjectionResult<TFailure>>
|
||||
| SessionEntryPatchProjectionResult<TFailure>;
|
||||
}): Promise<SessionEntryPatchProjectionResult<TFailure>> {
|
||||
return await runExclusiveSessionStoreWrite(params.storePath, async () => {
|
||||
const store = loadMutableSessionStoreForWriter(params.storePath);
|
||||
const target = params.resolveTarget(cloneSessionEntryProjectionSnapshot(store));
|
||||
migrateSessionEntryProjectionTarget({ store, target });
|
||||
const projected = await params.project({
|
||||
...target,
|
||||
entries: cloneSessionEntryProjectionSnapshot(store).entries,
|
||||
...(store[target.primaryKey]
|
||||
? { existingEntry: cloneSessionEntry(store[target.primaryKey]) }
|
||||
: {}),
|
||||
});
|
||||
if (projected.ok) {
|
||||
store[target.primaryKey] = cloneSessionEntry(projected.entry);
|
||||
}
|
||||
await saveSessionStoreUnlocked(params.storePath, store, {
|
||||
activeSessionKey: target.primaryKey,
|
||||
});
|
||||
return projected.ok ? { ...projected, entry: cloneSessionEntry(projected.entry) } : projected;
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveUnreferencedLifecycleTranscriptArtifacts(params: {
|
||||
storePath: string;
|
||||
transcriptContentMarker: string;
|
||||
|
||||
@@ -57,7 +57,10 @@ import {
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
import { createSessionEntryWithTranscript } from "../../config/sessions/session-accessor.js";
|
||||
import {
|
||||
applySessionPatchProjection,
|
||||
createSessionEntryWithTranscript,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
@@ -116,7 +119,7 @@ import {
|
||||
type SessionsPreviewEntry,
|
||||
type SessionsPreviewResult,
|
||||
} from "../session-utils.js";
|
||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||
import { applySessionsPatchToStore, projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
@@ -2066,21 +2069,30 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, {
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
const applied = await updateSessionStore(storePath, async (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key,
|
||||
store,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
return await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
agentId: requestedAgentId,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
});
|
||||
const applied = await applySessionPatchProjection({
|
||||
storePath,
|
||||
resolveTarget: ({ entries }) => {
|
||||
const store = Object.fromEntries(
|
||||
entries.map(({ sessionKey, entry }) => [sessionKey, entry]),
|
||||
);
|
||||
const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key,
|
||||
store,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
return { primaryKey, candidateKeys: migratedTarget.storeKeys };
|
||||
},
|
||||
project: async ({ primaryKey, existingEntry, entries }) =>
|
||||
await projectSessionsPatchEntry({
|
||||
cfg,
|
||||
entries,
|
||||
existingEntry,
|
||||
storeKey: primaryKey,
|
||||
agentId: requestedAgentId,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
}),
|
||||
});
|
||||
if (!applied.ok) {
|
||||
respond(false, undefined, applied.error);
|
||||
|
||||
@@ -130,16 +130,22 @@ function normalizeSubagentControlScope(raw: string): "children" | "none" | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Apply a validated gateway session patch to an in-memory session store entry. */
|
||||
export async function applySessionsPatchToStore(params: {
|
||||
type SessionPatchProjectionEntry = {
|
||||
entry: SessionEntry;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
/** Project a validated gateway session patch for one session entry. */
|
||||
export async function projectSessionsPatchEntry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
entries: readonly SessionPatchProjectionEntry[];
|
||||
existingEntry?: SessionEntry;
|
||||
storeKey: string;
|
||||
agentId?: string;
|
||||
patch: SessionsPatchParams;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
|
||||
const { cfg, store, storeKey, patch } = params;
|
||||
const { cfg, storeKey, patch } = params;
|
||||
const now = Date.now();
|
||||
const parsedAgent = parseAgentSessionKey(storeKey);
|
||||
const sessionAgentId = normalizeAgentId(
|
||||
@@ -162,7 +168,7 @@ export async function applySessionsPatchToStore(params: {
|
||||
return loadedModelCatalog;
|
||||
};
|
||||
|
||||
const existing = store[storeKey];
|
||||
const existing = params.existingEntry;
|
||||
// Existing entries without session ids are placeholder aliases; assigning an id makes them real.
|
||||
const next: SessionEntry = existing?.sessionId
|
||||
? {
|
||||
@@ -356,8 +362,8 @@ export async function applySessionsPatchToStore(params: {
|
||||
if (!parsed.ok) {
|
||||
return invalid(parsed.error);
|
||||
}
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key === storeKey) {
|
||||
for (const { sessionKey, entry } of params.entries) {
|
||||
if (sessionKey === storeKey) {
|
||||
continue;
|
||||
}
|
||||
if (entry?.label === parsed.label) {
|
||||
@@ -642,6 +648,29 @@ export async function applySessionsPatchToStore(params: {
|
||||
}
|
||||
}
|
||||
|
||||
store[storeKey] = next;
|
||||
return { ok: true, entry: next };
|
||||
}
|
||||
|
||||
/** Apply a validated gateway session patch to an in-memory session store entry. */
|
||||
export async function applySessionsPatchToStore(params: {
|
||||
cfg: OpenClawConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
storeKey: string;
|
||||
agentId?: string;
|
||||
patch: SessionsPatchParams;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
|
||||
const projected = await projectSessionsPatchEntry({
|
||||
cfg: params.cfg,
|
||||
entries: Object.entries(params.store).map(([sessionKey, entry]) => ({ sessionKey, entry })),
|
||||
existingEntry: params.store[params.storeKey],
|
||||
storeKey: params.storeKey,
|
||||
agentId: params.agentId,
|
||||
patch: params.patch,
|
||||
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
|
||||
});
|
||||
if (projected.ok) {
|
||||
params.store[params.storeKey] = projected.entry;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { withEnvAsync } from "../test-utils/env.js";
|
||||
const agentCommandFromIngressMock = vi.fn();
|
||||
const runBtwSideQuestionMock = vi.fn();
|
||||
const updateSessionStoreMock = vi.fn();
|
||||
const applySessionsPatchToStoreMock = vi.fn();
|
||||
const applySessionPatchProjectionMock = vi.fn();
|
||||
const projectSessionsPatchEntryMock = vi.fn();
|
||||
const createSessionGoalMock = vi.fn();
|
||||
const clearSessionGoalMock = vi.fn();
|
||||
const getSessionGoalMock = vi.fn();
|
||||
@@ -101,6 +102,10 @@ vi.mock("../config/sessions.js", () => ({
|
||||
updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions/session-accessor.js", () => ({
|
||||
applySessionPatchProjection: (...args: unknown[]) => applySessionPatchProjectionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveAgentDir: (_cfg: unknown, agentId: string) => `/tmp/openclaw-agent-${agentId}/agent`,
|
||||
resolveAgentWorkspaceDir: (_cfg: unknown, agentId: string) => `/tmp/openclaw-agent-${agentId}`,
|
||||
@@ -175,7 +180,10 @@ vi.mock("../gateway/session-utils.js", () => ({
|
||||
loadCombinedSessionStoreForGatewayMock(...args),
|
||||
loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) =>
|
||||
loadSessionEntryMock(sessionKey, opts),
|
||||
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key }),
|
||||
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({
|
||||
primaryKey: key,
|
||||
target: { storeKeys: [key] },
|
||||
}),
|
||||
resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({
|
||||
canonicalKey: key,
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
@@ -198,7 +206,7 @@ vi.mock("../gateway/session-transcript-readers.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/sessions-patch.js", () => ({
|
||||
applySessionsPatchToStore: (...args: unknown[]) => applySessionsPatchToStoreMock(...args),
|
||||
projectSessionsPatchEntry: (...args: unknown[]) => projectSessionsPatchEntryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-methods/agent-timestamp.js", () => ({
|
||||
@@ -267,8 +275,22 @@ describe("EmbeddedTuiBackend", () => {
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
store: {},
|
||||
});
|
||||
applySessionsPatchToStoreMock.mockReset();
|
||||
applySessionsPatchToStoreMock.mockResolvedValue({ ok: true, entry: {} });
|
||||
applySessionPatchProjectionMock.mockReset();
|
||||
applySessionPatchProjectionMock.mockImplementation(
|
||||
async (params: {
|
||||
project: (context: {
|
||||
entries: unknown[];
|
||||
existingEntry?: unknown;
|
||||
primaryKey: string;
|
||||
}) => Promise<unknown>;
|
||||
resolveTarget: (snapshot: { entries: unknown[] }) => { primaryKey: string };
|
||||
}) => {
|
||||
const target = params.resolveTarget({ entries: [] });
|
||||
return await params.project({ ...target, entries: [] });
|
||||
},
|
||||
);
|
||||
projectSessionsPatchEntryMock.mockReset();
|
||||
projectSessionsPatchEntryMock.mockResolvedValue({ ok: true, entry: {} });
|
||||
getRuntimeConfigMock.mockReset();
|
||||
getRuntimeConfigMock.mockReturnValue({});
|
||||
loadGatewayModelCatalogMock.mockReset();
|
||||
@@ -494,7 +516,7 @@ describe("EmbeddedTuiBackend", () => {
|
||||
provider: "tui-pty-mock",
|
||||
},
|
||||
]);
|
||||
applySessionsPatchToStoreMock.mockImplementation(
|
||||
projectSessionsPatchEntryMock.mockImplementation(
|
||||
async ({
|
||||
loadGatewayModelCatalog,
|
||||
}: {
|
||||
@@ -1345,7 +1367,7 @@ describe("EmbeddedTuiBackend", () => {
|
||||
fastMode: true,
|
||||
});
|
||||
|
||||
expect(applySessionsPatchToStoreMock).toHaveBeenCalledWith(
|
||||
expect(projectSessionsPatchEntryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storeKey: "global",
|
||||
agentId: "work",
|
||||
|
||||
+26
-17
@@ -25,8 +25,8 @@ import {
|
||||
formatSessionGoalStatus,
|
||||
getSessionGoal,
|
||||
updateSessionGoalStatus,
|
||||
updateSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { applySessionPatchProjection } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isChatStopCommandText } from "../gateway/chat-abort.js";
|
||||
import {
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionModelRef,
|
||||
} from "../gateway/session-utils.js";
|
||||
import { applySessionsPatchToStore } from "../gateway/sessions-patch.js";
|
||||
import { projectSessionsPatchEntry } from "../gateway/sessions-patch.js";
|
||||
import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js";
|
||||
import { setEmbeddedMode } from "../infra/embedded-mode.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
@@ -545,21 +545,30 @@ export class EmbeddedTuiBackend implements TuiBackend {
|
||||
key: opts.key,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
const applied = await updateSessionStore(target.storePath, async (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key: opts.key,
|
||||
store,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
return await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
agentId: opts.agentId,
|
||||
patch: opts,
|
||||
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
|
||||
});
|
||||
const applied = await applySessionPatchProjection({
|
||||
storePath: target.storePath,
|
||||
resolveTarget: ({ entries }) => {
|
||||
const store = Object.fromEntries(
|
||||
entries.map(({ sessionKey, entry }) => [sessionKey, entry]),
|
||||
);
|
||||
const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key: opts.key,
|
||||
store,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
return { primaryKey, candidateKeys: migratedTarget.storeKeys };
|
||||
},
|
||||
project: async ({ primaryKey, existingEntry, entries }) =>
|
||||
await projectSessionsPatchEntry({
|
||||
cfg,
|
||||
entries,
|
||||
existingEntry,
|
||||
storeKey: primaryKey,
|
||||
agentId: opts.agentId,
|
||||
patch: opts,
|
||||
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
|
||||
}),
|
||||
});
|
||||
if (!applied.ok) {
|
||||
throw new Error(applied.error.message);
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("session accessor boundary guard", () => {
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
"src/gateway/server-methods/sessions.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -77,6 +78,7 @@ describe("session accessor boundary guard", () => {
|
||||
"src/auto-reply/reply/session-reset-model.ts",
|
||||
"src/auto-reply/reply/session-updates.ts",
|
||||
"src/auto-reply/reply/session-usage.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user